Multi-Storage File System (MSFS)¶
The Multi-Storage File System (MSFS) provides POSIX filesystem access to object storage backends through FUSE (Filesystem in Userspace). This enables applications that require traditional filesystem operations to work seamlessly with cloud object storage without code modifications.
Overview¶
While the Python Multi-Storage Client is designed for easy adoption of object storage by Python applications, some applications prefer or require POSIX filesystem access. MSFS bridges this gap by:
Providing a POSIX-compliant filesystem interface to object storage
Supporting S3-compatible object storage (AWS S3, AIS, etc.)
Enabling applications written in any language to access object storage
Sharing the same configuration format as the Python MSC for consistency
Note
S3 Read and Write Support
MSFS supports file creation, modification, truncation, deletion, and explicit
fsync/fdatasync durability for writable S3 backends. The default
flush_on_close: false mode favors close latency and completes the remote
commit asynchronously; applications requiring an acknowledgement must call
fsync/fdatasync or configure flush_on_close: true.
If an asynchronous commit fails after close has already been
acknowledged, the failure is recorded against the file and the next
fsync/fdatasync on it returns EIO once. The data is not lost —
it stays dirty and a later flush retries it — so a successful retry clears
the record and reports no error.
Key Features¶
FUSE-based: Mounts object storage as a standard filesystem
S3 backend support: AWS S3 and S3-compatible object stores
High-performance caching: Configurable cache for improved read performance
Write-to-read cache promotion: Optional reuse of committed write bytes for immediate readback without another object-store download
Dynamic configuration: Add or remove backends without unmounting via SIGHUP
Standard Unix tools: Use with
mount,umount, and/etc/fstabObservability: Integrated telemetry with OpenTelemetry metrics
When to Use MSFS¶
MSFS exists for one situation: an application that must see a filesystem, over data that lives in object storage. If you can call an object-storage API directly, do that instead — the Python Multi-Storage Client avoids a FUSE hop entirely and will be faster.
MSFS is a good fit when:
The application cannot be changed to speak S3 — a training framework that takes a directory path, a third-party binary, a shell pipeline, or code in a language with no usable SDK.
The access pattern is read-heavy over a working set that fits the node-local cache. Repeat reads are served from cache and never reach the backend.
Datasets are large but the working set is not. Manifest-based bootstrap makes a 100M-object namespace browsable in under two minutes without listing it on every mount.
Writes are whole-file — created once, written, closed. Deferred
PutObjectmakes this efficient, and small files commit in a single request.
Look elsewhere when:
Many nodes need the same cache. Each MSFS process owns its own; there is no shared or coordinated tier. Placing
cache_dir_pathon a shared filesystem does not change this. Front the bucket with a caching tier such as AIStore instead, which MSFS can read as a backend.You need POSIX semantics object stores cannot provide — byte-range in-place updates, hard links, atomic rename across directories, or
O_APPENDfrom several writers. A write replaces the whole object.Per-user authorization is required. Access is per mount: anyone who can read the mount point can read everything the backend credentials reach.
The workload is many small concurrent writes. Small-object writes are bounded by per-object commit latency rather than bandwidth; see Measured Scale and Performance.
You need a warm cache immediately after mount. There is no pre-warm API and the cache is discarded on unmount.
Deployment Model¶
MSFS is not a centralized storage or caching service. It is a FUSE process that runs on each compute node, started either directly through mount -t msfs or by the Kubernetes CSI node plugin on behalf of a pod. Nothing is provisioned outside the compute node, so MSFS can be deployed without dedicated storage appliances. The consequence is that every MSFS process owns an independent cache: two mounts on one node, or the same mount on two nodes, share nothing.
Access is granted per mount rather than per user. Anyone who can read the mount point can read everything the backend credentials can reach, so deployments that need tenant isolation must separate tenants by mount.
Capability |
Status |
|---|---|
POSIX read access to S3, AIStore, and GCS backends |
Supported |
POSIX write access (create, modify, truncate, delete) |
S3 backends only |
Write-to-read cache promotion after a successful commit |
Supported (opt-in) |
Multiple buckets or bucket+prefixes as sibling subdirectories of one mount |
Supported |
Adding and removing backends without unmounting (SIGHUP) |
Supported |
Manifest-based bootstrap for large namespaces |
Supported |
Node-local read cache with read-ahead, capacity bound, and LRU eviction |
Supported |
Cache backed by RAM, a shared mapped file, or per-inode files |
Supported |
OpenTelemetry metrics and a Prometheus endpoint |
Supported |
Kubernetes deployment via the CSI node plugin |
Supported |
Cache shared or coordinated across mounts or nodes |
Not implemented |
Cache surviving unmount |
Not implemented |
Explicit data pre-warm API |
Not implemented |
Per-user (UID/GID) authorization within a mount |
Not implemented |
Modifying an existing backend in place via SIGHUP |
Not implemented |
Installation¶
Download from GitHub Actions Artifacts¶
The easiest way to install MSFS is to download pre-built packages from our GitHub Actions artifacts.
Download the artifact archive:
Navigate to the GitHub Actions Default Branch workflow
Select a workflow run for the desired commit
Download the
multi-storage-file-systemartifact
Extract the archive:
unzip multi-storage-file-system.zip
The archive contains:
RPM packages:
msfs-<version>-1.x86_64.rpmandmsfs-<version>-1.aarch64.rpmDEB packages:
msfs_<version>_amd64.debandmsfs_<version>_arm64.deb
After installation, MSFS provides:
/usr/bin/msfs- The FUSE daemon binary/usr/bin/mount.msfs- Mount helper for standardmountcommand
Build from Source¶
Alternatively, you can build MSFS from source:
cd multi-storage-file-system
make
sudo make install
Configuration¶
MSFS uses the standard MSC configuration format, providing seamless integration with existing MSC configurations.
MSFS searches for configuration files in the same locations as the Python MSC:
Path specified by
MSC_CONFIGenvironment variable${XDG_CONFIG_HOME}/msc/config.yamlor${XDG_CONFIG_HOME}/msc/config.json${HOME}/.msc_config.yamlor${HOME}/.msc_config.json${HOME}/.config/msc/config.yamlor${HOME}/.config/msc/config.json${XDG_CONFIG_DIRS:-/etc/xdg}/msc/config.yamlor${XDG_CONFIG_DIRS:-/etc/xdg}/msc/config.json/etc/msc_config.yamlor/etc/msc_config.json
See Configuration Reference for the complete MSC configuration schema.
Note
Advanced Configuration Mode
For advanced users requiring fine-grained control over FUSE behavior, caching parameters, and other low-level settings, MSFS provides an extended configuration mode (msfs_version: 1). This advanced mode is intended for specialized use cases and performance tuning. For details, see the MSFS README.
Environment Variables¶
Configuration files support environment variable expansion using $VAR or ${VAR} syntax:
profiles:
my-profile:
storage_provider:
type: s3
options:
base_path: ${BUCKET_NAME}
access_key_id: ${AWS_ACCESS_KEY_ID}
secret_access_key: ${AWS_SECRET_ACCESS_KEY}
MSFS-Specific Environment Variables:
MSC_CONFIG- Path to configuration fileMSFS_MOUNTPOINT- Mount point (overrides config file setting)MSFS_BINARY- Path to msfs binary (default:/usr/bin/msfs)MSFS_LOG_DIR- Log directory (default:/var/log/msfs)
Usage¶
Basic Usage¶
Manual mount/unmount using the MSFS binary directly:
# Start MSFS daemon with config file
export MSC_CONFIG=/path/to/config.yaml
/usr/bin/msfs
# In another terminal, verify mount
mount | grep msfs
df -h /mnt
# Access files
ls -l /mnt/backend-name/
cat /mnt/backend-name/path/to/file.txt
# Stop daemon (unmount)
umount /mnt
Mount Helpers¶
After installation, MSFS can be mounted using standard Unix mount and umount commands:
Mounting¶
# Mount with config file and mountpoint
sudo mount -t msfs /path/to/config.yaml /mnt/storage
# Mount multiple instances with different configs
sudo mount -t msfs /path/to/config1.yaml /mnt/storage1
sudo mount -t msfs /path/to/config2.json /mnt/storage2
How It Works:
When you run mount -t msfs <config> <mountpoint>, the mount command automatically calls /usr/bin/mount.msfs, which:
Exports
MSC_CONFIGenvironment variable from the config file argumentExports
MSFS_MOUNTPOINTenvironment variable from the mountpoint argumentCreates log directory if needed (
/var/log/msfs/)Launches the
msfsdaemon in the background usingsetsidStores the process ID in
/var/log/msfs/msfs_*.pid
Note
The mount command behaves differently based on arguments:
mount(no args) → Lists all mounted filesystemsmount -t msfs(type only) → Lists all MSFS filesystems (does NOT call mount.msfs)mount -t msfs <config> <mountpoint>→ Calls mount.msfs to perform the mount
Unmounting¶
To unmount the filesystem, use the standard umount command:
# Unmount MSFS filesystem
umount <mount_point>
# Example
umount /mnt/storage1
Automatic Mounting with /etc/fstab¶
MSFS filesystems can be automatically mounted at boot time using /etc/fstab:
# MSFS filesystem with S3 backend
/etc/msfs/s3-config.yaml /mnt/s3-data msfs defaults,_netdev 0 0
# MSFS filesystem with local config
/home/user/msfs.json /mnt/storage msfs defaults,noauto 0 0
Field Explanation:
Device - Path to MSFS configuration file (YAML or JSON)
Mount Point - Directory where the filesystem will be mounted
Type - Filesystem type (
msfs)Options - Mount options (comma-separated):
defaults- Standard mount options_netdev- Wait for network before mounting (recommended for remote storage)noauto- Don’t mount automatically at boot (mount manually)user- Allow non-root users to mount (requiresallow_otherin config)
Dump - Backup frequency (usually
0)Pass - fsck pass number (usually
0)
After editing /etc/fstab, test the configuration:
# Mount all filesystems in fstab
sudo mount -a
# Verify mount
df -h /mnt/s3-data
Dynamic Configuration Reload¶
MSFS supports dynamic configuration changes without unmounting:
# Edit configuration file
vim /path/to/config.yaml
# Send SIGHUP to reload configuration
sudo kill -SIGHUP $(pidof msfs)
Configuration changes are processed as follows:
Existing backends - Cannot be modified (unmount and remount required)
New backends - Automatically mounted and appear as new subdirectories
Removed backends - Automatically unmounted and subdirectories disappear
Alternatively, enable automatic periodic configuration reloading:
msfs_version: 1
auto_sighup_interval: 300 # Check config every 5 minutes
backends:
# ...
Manifest-Based Bootstrap¶
For large-scale datasets (millions of objects), MSFS can pre-generate a manifest of directory listings at mount time. This enables immediate POSIX access without per-file S3 calls.
Enable manifest generation by adding manifest_path to a backend:
backends:
- dir_name: s3
readonly: true
manifest_path: "/home/user/.msfs_manifest"
backend_type: S3
S3:
# ...
On first mount, MSFS generates per-directory TSV manifests via parallel BFS listing of S3, then ingests entries into sharded B+Trees backed by PebbleDB for persistent, memory-efficient lookups.
Writable Manifest-Backed Mounts¶
A manifest-backed mount can also be writable. Manifest ingest runs at mount whenever manifest_path is set, regardless of readonly, so files created, overwritten, or deleted through a previous session are reconstructed.
Manifest generation at mount is restricted to readonly: true backends, because generation lists the entire backend namespace and that listing is only consistent while nothing can mutate it. Generate the manifest out of band before mounting a backend writable:
msfs generate-manifest -backend s3 -output /home/user/.msfs_manifest
A writable backend whose manifest_path contains no manifest logs skipping generation and mounts without manifest metadata.
The generated manifest is an immutable snapshot and is never rewritten on the write path. Instead, committed mutations are recorded as upsert and tombstone records in an append-only delta log under <manifest_path>/_msfs_delta/, which lookup, readdir, and ingest overlay on top of the base manifest. Records are appended only after the object-store commit succeeds, so the delta log never advertises an object the backend does not hold.
Manifest Configuration Options¶
backends:
- dir_name: s3
manifest_path: "/home/user/.msfs_manifest"
manifest_gen_workers: 200 # Number of parallel BFS listing workers (default: 200)
flat_dir_confirmation_pages: 5 # Pages to confirm a flat dir before parallel listing (default: 5)
Flat Directory Acceleration
For buckets with flat layouts (millions of files in a single prefix with no subdirectories), MSFS automatically detects large flat directories and parallelizes listing using prefix-based or range-based splitting. User-provided hints can further optimize this:
backends:
- dir_name: s3
manifest_path: "/home/user/.msfs_manifest"
flat_dir_hints:
- path: "training-data/"
key_prefix_chars: "0123456789"
split_depth: 2
Field |
Type |
Description |
|---|---|---|
|
string |
Directory path relative to the backend prefix (must end with |
|
string |
Characters that appear at the start of basenames (default: |
|
int |
Number of leading characters for sub-prefix generation (default: 1; depth 2 with 10 chars = 100 sub-workers) |
Without hints, MSFS detects flat directories automatically and selects the best parallelization strategy (prefix discovery or lexicographic range splitting).
Performance¶
MSFS includes a sophisticated caching layer to optimize read performance and optionally reuse exact bytes retained from successful writes.
Cache Configuration¶
The cache uses a line-based architecture where each cache line represents a fixed-size chunk of data:
cache_line_size: 1048576 # 1 MiB per cache line
cache_lines: 4096 # 4096 cache lines = 4 GiB total cache
write_cache_promotion: false # Opt in to write-to-read cache reuse
Cache Tuning Guidelines:
Larger cache line size - Better for sequential access patterns, fewer cache lines needed
Smaller cache line size - Better for random access patterns, more granular caching
More cache lines - Allows caching more files or larger portions of files
Less cache lines - Reduces memory usage
Write-to-Read Cache Promotion¶
When write_cache_promotion: true, MSFS admits locally retained bytes into
the read cache only after the object-store commit succeeds. Admission starts at
offset zero and stops when cache capacity is exhausted; MSFS never downloads
data merely to populate the cache. Deferred single-PUT writes and retained
multipart buffers can populate complete objects. Existing-object overlays
populate only cache lines fully covered by local write ranges.
Promotion is disabled by default. Cache population runs asynchronously through a bounded worker pool, while an immediate read waits for any matching Inbound cache line to become Clean. Local cache-population failure does not change the success of the already-completed object-store write.
Read Performance¶
Read performance is optimized through:
Read-ahead caching - Cache lines are prefetched for sequential reads
Cache hit reuse - Frequently accessed data remains cached
Parallel prefetching - Multiple cache lines loaded concurrently
Best practices:
Size
cache_linesto accommodate your working setUse larger
cache_line_sizefor large filesUse smaller
cache_line_sizefor many small files
Cache Capacity and Lifetime¶
cache_line_size (default 10 MiB) is the fetch and residency granularity and cache_lines (default 128) is how many lines are provisioned, so the default capacity is about 1.25 GiB. The benchmarks in Measured Scale and Performance used cache_lines: 10000, or roughly 100 GiB. Capacity is a hard bound and eviction is LRU, so a dataset larger than the cache holds only its active working set. For very large datasets, size the cache to the working set rather than to the dataset.
cache_storage selects where lines live: ram (anonymous mmap), mapped-file (one shared memory-mapped file, the default), or per-inode-file (per-inode contiguous files served with pread). All three are node-local.
Each mount creates its own private cache directory under cache_dir_path and removes that directory on unmount. The cache does not survive the mount, and a remount starts cold. Files left behind by a crash are not discovered or reused.
Note
Pointing cache_dir_path at a shared filesystem such as Lustre places each mount’s private directory on shared storage, but does not produce a shared cache. The catalog that makes cached bytes findable — the object-to-line index, cache-line state and ETags, in-flight fetch tracking, LRU order, and capacity accounting — lives in the memory of a single MSFS process. Two MSFS processes over the same shared path still use different directories, fetch the same object twice, cannot see each other’s entries, and cannot coordinate fills, invalidation, or eviction. A shared filesystem guarantees consistency for shared files; it does not supply object-cache semantics such as key/version lookup, single-flight fetches, or ETag invalidation.
Pre-warming¶
There is no pre-warm API. Reading files warms the cache of the MSFS process that served those reads, so one job can warm a mount that a later job reuses — but only if that MSFS process is still running and the working set still fits in cache. A pre-warm job that unmounts when it finishes, or a later job that creates its own mount, starts cold.
Manifest generation is a separate mechanism and warms only namespace metadata. It makes directory traversal and attribute lookups fast without per-object backend calls; it does not fetch file contents.
Measured Scale and Performance¶
These are single-node measurements of the read path, write path and namespace bootstrap against same-region storage. They record what has been measured, not a supported configuration limit. See Qualified Scale Boundary.
Namespace Scale: 100M Objects¶
Measured on an EC2 c5a.12xlarge (48 vCPU, 96 GiB) in us-west-2 against an S3 bucket in the same region, over a dataset of 100,237,498 objects across 101,339 directories.
Phase |
Elapsed |
Throughput |
Peak RSS |
|---|---|---|---|
Manifest generation (parallel BFS listing, 200 workers) |
1m 45s |
954,680 obj/s |
|
Manifest ingest (per-directory TSV into sharded B+Tree/PebbleDB) |
16m 41s |
100,129 obj/s |
~7.4 GiB |
Total bootstrap |
~18m 26s |
The mount is browsable when generation finishes, at about 105 seconds, not when ingest finishes. During ingest, metadata is served from the manifest while the optimized index is built in the background, so traversal and enumeration — enough to compute dataset splits and begin streaming — work well before the 18m 26s mark. Generation held at about 1m 43s and ingest at about 16m 50s across repeated runs.
Note
Set process_memory_limit generously for an ingest of this size. The 4 GiB default sits below the working set of a 100M-object ingest, which drives continuous garbage collection and collapses throughput.
These figures assume a hierarchical layout. Both phases degrade sharply when one directory holds the entire namespace, because generation finds a single key prefix to split across roughly 20 range workers instead of 200 directory workers, and every directory entry lands in one B+Tree shard.
Layout |
Generation |
Ingest |
Peak RSS |
|---|---|---|---|
101,339 directories |
1m 43s (977K obj/s) |
16m 29s (101K obj/s) |
~6.5 GiB |
1 directory |
30m 35s (~55K obj/s) |
44m 19s (37.8K obj/s) |
~21 GiB |
The penalty is super-linear in directory width: 10M objects in a single directory generate in 1m 41s and ingest in 2m 5s, so the same flat shape is far cheaper an order of magnitude smaller.
Read Throughput: 24-Cell Matrix¶
Measured on an EC2 c5n.18xlarge (72 vCPU, 184 GiB) in us-west-2 against same-region S3 with a ~100 GiB cache, over a ~88 GiB dataset of 8,192 x 1 MiB plus 80 x 1 GiB files. Six workload families — 4 KiB and 64 KiB request sizes, small-file and large-file, sequential and random — at 1, 2, 4, and 8 application threads, each with a cold and a cache-resident pass, driven by elbencho -r --direct and compared against s3fs-fuse 1.93 with a local disk cache.
The result was 18 wins, 3 ties, and 3 losses across the 24 cells.
Workload family |
MSFS vs s3fs |
|---|---|
Small files, 4 KiB sequential |
20x - 52x |
Small files, 64 KiB sequential |
6.5x - 27x |
Large files, 4 KiB sequential |
0.69x - 1.04x |
Large files, 64 KiB sequential |
0.45x - 1.05x |
Large files, 4 KiB random |
3.9x - 6.9x |
Large files, 64 KiB random |
5.4x - 14x |
Cold reads scale with thread count because each reader issues concurrent ranged GETs: large-file 64 KiB sequential moves 92 MiB/s at one thread and 192 MiB/s at eight, while s3fs cold stays flat near 125 MiB/s on the same families. Cache-resident reads reach 4,536 MiB/s on that family at eight threads. At eight threads MSFS wins or ties every family.
Two caveats keep the losses honest. The large-file 64 KiB losses at one and two threads are largely a page-cache artifact: on a 184 GiB host, s3fs serves its warm reads from the Linux page cache over its own cache files, and after dropping caches it falls to about 148 MiB/s on the same data. The large-file 4 KiB results reflect a real per-operation FUSE ceiling — at one or two threads only one or two FUSE operations are in flight, so neither additional readers nor cache geometry help. --direct forces strict 4 KiB operations with no kernel read-ahead, a deliberately pessimistic operating point; workloads that do not use O_DIRECT benefit from page-cache assistance and reach roughly 2.6 GiB/s on the same data.
Write Throughput¶
Measured on an EC2 c5n.18xlarge in us-west-2 against same-region S3, driven by elbencho -w --direct and compared against s3fs-fuse 1.93. Both filesystems ran the identical workload on the same host, bucket and day, so the columns are directly comparable. MSFS ran with cache_line_size: 1048576, cache_lines: 1024, multipart_upload_threshold_bytes: 67108864, write_commit_workers: 32 and flush_on_close: true.
Figures are aggregate MiB/s once every thread has finished.
The sequential large-file rows write 80 GiB per cell as 80 separate 1 GiB files, partitioned across threads — 80 files on one thread, 20 files each on four, 10 each on eight — so the total work is identical at every thread count and no two threads write the same object. This matches the scale of the read matrix above and of the June 2026 write matrix. MSFS cells ran roughly 2 to 15 minutes; the same cells on s3fs ran roughly 13 to 43 minutes, because it is slower on identical work. The remaining rows are smaller and state their size and layout in the row itself.
Workload layout is not a detail here. The shared-object rows below write one 1 GiB object from every thread, and at eight threads that writes 502 MiB/s against 290 for one object per thread — the same block size and thread count, 1.7x apart. Reproducing “80 GiB” with a different file count or sharing pattern will produce materially different numbers.
Workload |
MSFS |
s3fs |
Ratio |
|---|---|---|---|
1,024 x 1 MiB, 4 KiB blocks, 1 thread |
16 |
5 |
3.2x |
1,024 x 1 MiB, 4 KiB blocks, 4 threads |
18 |
14 |
1.3x |
1,024 x 1 MiB, 4 KiB blocks, 8 threads |
17 |
14 |
1.2x |
1,024 x 1 MiB, 64 KiB blocks, 1 thread |
18 |
7 |
2.6x |
1,024 x 1 MiB, 64 KiB blocks, 4 threads |
19 |
17 |
1.1x |
1,024 x 1 MiB, 64 KiB blocks, 8 threads |
19 |
17 |
1.1x |
80 GiB, 4 KiB sequential, 1 thread |
141 |
32 |
4.4x |
80 GiB, 4 KiB sequential, 2 threads |
239 |
57 |
4.2x |
80 GiB, 4 KiB sequential, 4 threads |
279 |
83 |
3.4x |
80 GiB, 4 KiB sequential, 8 threads |
92 |
101 |
0.91x |
80 GiB, 64 KiB sequential, 1 thread |
358 |
62 |
5.8x |
80 GiB, 64 KiB sequential, 2 threads |
573 |
78 |
7.3x |
80 GiB, 64 KiB sequential, 4 threads |
706 |
96 |
7.4x |
80 GiB, 64 KiB sequential, 8 threads |
434 |
106 |
4.1x |
1 GiB per thread, 64 KiB random, 1 thread |
338 |
73 |
4.6x |
1 GiB per thread, 64 KiB random, 4 threads |
299 |
78 |
3.8x |
1 GiB per thread, 64 KiB random, 8 threads |
290 |
90 |
3.2x |
Shared 1 GiB object, 64 KiB random, 1 thread |
382 |
107 |
3.6x |
Shared 1 GiB object, 64 KiB random, 4 threads |
574 |
108 |
5.3x |
Shared 1 GiB object, 64 KiB random, 8 threads |
502 |
127 |
4.0x |
Random 4 KiB writes are the widest gap and are described rather than tabulated, because s3fs cannot be run there at the same scale. Rewriting a 1 GiB object with random 4 KiB blocks, s3fs sustains about 100 IOPS — under 1 MiB/s, roughly 43 minutes per run — because each write rewrites the whole object. MSFS sustains 33,000-36,000 IOPS, about 135 MiB/s, finishing in about 7 seconds, and holds that rate flat from 1 to 8 threads against one shared object. Writing a separate 1 GiB object per thread with random 4 KiB blocks, MSFS reaches 138 MiB/s at one thread and 68-73 MiB/s at four and eight; the equivalent s3fs run would take several hours, so no ratio is given.
Small-file throughput is bounded by per-object commit latency rather than bandwidth, which is why the 1 MiB rows sit an order of magnitude below the 1 GiB rows for both filesystems. Deferred PutObject is what makes those rows competitive at all: committing a small object in one request instead of a three-request multipart upload moved this family from about 3 MiB/s to 16-19 MiB/s. Raising write_commit_workers increases how many small objects commit in parallel.
Large-file writes peak at four threads and fall back at eight. The one cell MSFS loses is the extreme of that pattern: 4 KiB sequential at eight threads drops from 279 MiB/s to 92 while s3fs continues climbing to 101. Smallest block size and highest thread count is the worst case for a write path that serializes per-file bookkeeping, and it is the operating point to avoid until it is addressed. Every other cell is a 3.4x to 7.4x win, and 64 KiB blocks scale cleanly to four threads.
Note
Write cells are only comparable when the workload shape matches exactly. The two random 64 KiB groups above make the point: at eight threads, one object per thread gives 290 MiB/s while one shared object gives 502 MiB/s. Same block size, same thread count, different path. Never compare a number from one group against the other.
Reproducing These Numbers¶
Defaults are not benchmark settings. The values below are what the results above were measured with; a run that differs on any of them is not comparable.
Setting |
Value |
Why |
|---|---|---|
|
|
Shared |
|
|
On a 72-vCPU host. The default of |
|
|
10 MiB fetch and residency granularity. |
|
sized to the working set |
|
|
|
Read-ahead depth. Higher values can help backends with a different latency knee. |
|
|
The 4 GiB default causes sustained garbage collection on large ingests. |
|
unset on a 72-vCPU host |
Left unset so Go uses every vCPU. On a 256-vCPU host, pinning it to 72 matched the 72-vCPU result; leaving it at 32 throttled the read and GC path. |
Two settings live outside the configuration file and must be re-applied after every mount, because the FUSE connection id changes each time:
ulimit -n 131072
sudo sh -c 'for d in /sys/fs/fuse/connections/*/; do
echo 144 > "${d}max_background"
echo 108 > "${d}congestion_threshold"
done'
The kernel defaults, max_background=12 and congestion_threshold=9, throttle in-flight background and read-ahead requests and cap read concurrency well below what the thread count suggests. Leaving them at their defaults is the most common reason a run appears to stop scaling after a few threads.
Qualified Scale Boundary¶
What the measurements above establish, and what they do not:
Dimension |
Measured |
Not established |
|---|---|---|
Clients |
1 MSFS process per test |
Many concurrent clients against one backend, including request fan-out and cache-hit behavior under aggregate load |
Application threads |
1 - 8 |
Higher concurrency per mount |
Objects |
~100M in one namespace |
Substantially larger namespaces |
Working set |
~88 GiB, cache-resident |
Datasets far larger than cache, where only a small fraction is resident |
Latency |
Mean throughput per cell |
Tail-latency targets |
Failure handling |
Clean runs |
Backend failure and recovery under load |
Access pattern |
Read path and namespace bootstrap |
Write throughput and write durability at scale |
The companion run in which the dataset deliberately exceeded cache capacity, so that reads had to keep returning to the backend, stopped after 18 of 24 cases and was never completed. Sustained-eviction behavior is therefore not characterized.
Authentication¶
Direct mounts take credentials from the configuration file, or from the standard AWS configuration and credentials files through use_config_env and use_credentials_env. Environment variable references such as ${AWS_ACCESS_KEY_ID} keep literal secrets out of the configuration file.
Under Kubernetes, the CSI node plugin additionally supports a static Secret referenced by nodePublishSecretRef, a driver-level workload identity (IRSA on EKS), and a per-workload role assumed from volumeAttributes.roleArn.
Note
Credential rotation and multi-tenant isolation have not been qualified end-to-end. Because authorization is per mount rather than per user, a mount exposes everything its credentials can reach to every reader of the mount point.
Observability¶
MSFS supports OpenTelemetry metrics for monitoring performance and operations. Metrics configuration uses the same schema as the Python MSC for consistency.
Configuration¶
Enable metrics collection by adding observability configuration:
1opentelemetry:
2 metrics:
3 attributes:
4 - type: static
5 options:
6 attributes:
7 service.name: msc-posix
8 deployment.environment: production
9 - type: host
10 - type: process
11
12 reader:
13 type: periodic
14 options:
15 collect_interval_millis: 1000
16 export_interval_millis: 60000
17
18 exporter:
19 type: otlp
20 options:
21 endpoint: "http://otel-collector:4318"
22 insecure: true
23
24backends:
25 # ...
See Telemetry for complete observability configuration options.
Metrics Exported¶
MSFS exports the following metrics:
Cache Metrics:
msfs.cache.hits- Number of cache hitsmsfs.cache.misses- Number of cache missesmsfs.cache.evictions- Number of cache evictions
I/O Metrics:
msfs.io.bytes_read- Total bytes readmsfs.io.read_operations- Number of read operations
Backend Metrics:
msfs.backend.operations- Operations per backend (with labels)msfs.backend.errors- Errors per backend (with labels)
Logs¶
MSFS logs are written to stdout by default. When using mount helpers, logs are redirected to /var/log/msfs/msfs_<pid>.log.
Configure log verbosity per backend:
backends:
- dir_name: debug-backend
trace_level: 3 # 0=none, 1=errors, 2=successes, 3+=details
# ...
Development¶
Docker Development Environment¶
A Docker-based development environment is provided for testing:
# Pull MinIO image
docker pull minio/minio:latest
# Build development container
docker-compose build
# Start containers (MinIO + dev)
docker-compose up -d dev
# Enter development container
docker-compose exec dev bash
Inside the container:
# Setup development environment with MinIO backend
./dev_setup.sh minio
# Build MSFS
make
# Run MSFS in background
./msfs &
# Test filesystem
mount | grep fuse
df -h /mnt
ls -lR /mnt
# Reload configuration
kill -SIGHUP $(pidof ./msfs)
# Stop daemon
kill -SIGTERM $(pidof ./msfs)
# Exit container
exit
# Stop containers
docker-compose down
Testing¶
Test scripts are provided in the multi-storage-client/tests/test_mscp/ directory:
cd multi-storage-client/tests/test_mscp
# Test mount/unmount
./test_mount.sh
# Test cleanup
./test_cleanup.sh
# Test observability
./test_observability.sh
Deployment¶
Building for Production¶
Build optimized binaries for production deployment:
cd multi-storage-file-system
# Build for current platform
make
# Build and extract binaries for multiple platforms
make publish
This creates platform-specific binaries:
msfs-linux-amd64- Linux x86_64msfs-linux-arm64- Linux ARM64
Docker Deployment¶
Deploy MSFS using Docker containers:
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y fuse
COPY msfs-linux-amd64 /usr/bin/msfs
COPY mount.msfs /usr/bin/mount.msfs
RUN chmod +x /usr/bin/msfs /usr/bin/mount.msfs
CMD ["/usr/bin/msfs"]
# Build container
docker build -t msfs:latest .
# Run with config from environment
docker run -d \
--device /dev/fuse \
--cap-add SYS_ADMIN \
--security-opt apparmor:unconfined \
-e MSC_CONFIG=/config/msfs.yaml \
-v /path/to/config:/config \
-v /mnt/storage:/mnt/storage:shared \
msfs:latest
Troubleshooting¶
Common Issues¶
FUSE device not found
Error: /dev/fuse: open: no such file or directory
Solution: Load the FUSE kernel module:
sudo modprobe fuse
Permission denied when mounting
Error: fusermount: mount failed: Operation not permitted
Solution: Ensure your user is in the fuse group or run with sudo:
sudo usermod -aG fuse $USER
# Log out and back in for group changes to take effect
Backend not appearing after SIGHUP
Solution: Check logs in /var/log/msfs/ for configuration errors. Ensure new backend configurations are valid.
Cache thrashing with many small files
Solution: Decrease cache_line_size for better cache utilization:
cache_line_size: 262144 # 256 KiB instead of 1 MiB
cache_lines: 16384 # Increase count to maintain total cache size
Debug Mode¶
Enable verbose logging to diagnose issues:
backends:
- dir_name: debug-backend
trace_level: 3 # Maximum verbosity
# ...
Check daemon logs:
# If using mount helper
tail -f /var/log/msfs/msfs_*.log
# If running manually
./msfs # Logs go to stdout
Limitations¶
Current limitations of MSFS:
Writes are S3-only: Writable mounts are supported for S3 backends; GCS and AIStore backends remain read-only
Backend modifications: Existing backends cannot be modified via SIGHUP; only additions and removals are supported
Node-local cache: Each MSFS process owns an independent cache. It is not shared or coordinated across mounts or nodes, even when
cache_dir_pathpoints at a shared filesystemCache lifetime: The cache is discarded on unmount; a remount starts cold
No pre-warm API: Data can only be warmed by reading it through a mount that stays running
Mount-level authorization: Access is granted per mount, not per UID/GID
Qualified scale: Measurements cover a single client at 1-8 threads; see Qualified Scale Boundary
Use-Case Suggestions¶
Fronting a Remote Bucket with a Fast Tier (AIStore)¶
MSFS caching is node-local and bounded, so the cost of a first touch is paid per node and again after any remount. Where that cost dominates — many nodes reading the same dataset once, or working sets too large to stay cache-resident — a shared cache tier in front of the remote bucket can absorb it. MSFS does not implement such a tier, but it can read one as a backend.
We measured this with AIStore. The 24-cell read matrix was re-run with MSFS pointed at an AIStore cluster (3 proxies, 3 targets) fronting the same S3 bucket, with the 88 GiB dataset prefetched into the cluster, from a 256 vCPU / 1.5 TiB client:
First-touch reads were 2.2x to 37x faster than MSFS reading S3 directly, peaking near 1.7 GiB/s. The gain was largest on small files (23x - 37x at 64 KiB, 8.7x - 19x at 4 KiB) and smallest on large files (2.2x - 6.9x), because a first touch lands on in-datacenter targets instead of crossing the network to the remote bucket.
Cache-resident reads were unchanged, which is the expected result:
backend_read_file_successes_totalstayed flat across the cache-resident pass on all 24 cells, so those reads never reached any backend. Cache-resident throughput is a property of the MSFS cache and the host, not of what sits behind it. A fast tier improves the first touch, not the cached steady state.The 100M-object namespace on the same cluster generated in 1m 27.7s and ingested in about 3m 19s, but only with listing delegated to the underlying store through
manifest_gen_backend. Listing the fronted bucket through AIStore timed out at this scale, while listing the store directly did not. Object reads still went through AIStore.
The practical reading: a fast tier is worth evaluating when first-touch cost dominates, and manifest_gen_backend should point at whichever backend lists the namespace fastest, which is not necessarily the one serving reads. This was one client at 1-8 threads and does not establish behavior for many concurrent clients; see Qualified Scale Boundary.
See Also¶
Quickstart - Getting started with MSC configuration
Configuration Reference - Complete configuration schema
Telemetry - Observability and metrics configuration
Concepts - Core MSC concepts