NAS hardware and performance reference¶
Purpose: the single place that records what the TrueNAS box is and what it actually does — hardware inventory, pool layout, measured throughput on disk and across the network, and the client configuration that produced those numbers.
Status: current state. Every figure on this page was measured on the running
system with fio, not estimated or taken from a vendor sheet.
Scope: this page describes the storage server and the network paths into it. It does not cover Kubernetes-side storage policy — see Storage architecture for that, and kopiur backup architecture for backups.
1. The one-paragraph summary¶
The 10 gigabit network is not a bottleneck and cannot become one at current pool speeds: a single TCP stream reaches 1119 MB/s (9.39 Gbit/s) at standard MTU 1500, and nothing storage-related has ever come close to that. Writes land between 141 and 243 MB/s on every path measured — local, NFS, or SMB — because the pools, not the wire, set that ceiling. Reads are entirely a question of what caches them: 2.5–2.9 GB/s when ARC serves them from RAM, 408–645 MB/s across the network with readahead or parallel streams, and as low as 224 MB/s when prefetch is defeated.
READ 0 1000 2000 3000 MB/s
| ¦ | | |
ai-pool ARC (RAM) 2891 ██████████████████████████████████████████████
BigTank ARC (RAM) 2490 ████████████████████████████████████████
NFS 4 streams 645 ██████████
SMB 1 stream+readahead 634 ██████████
NFS 1 stream+readahead 463 ███████
BigTank cold disk, prefetch 408 ██████
SMB 4 streams 408 ██████
BigTank disk, no prefetch 224 ███
| ¦
| ¦ 10G line rate, 1119 — nothing reaches it
WRITE 0 100 200 250 MB/s
| | | ¦
ai-pool local, 3x SSD 243 ███████████████████████████████████
NFS buffered, 10G 218 ███████████████████████████████
Backup10T local, 1 disk 203 █████████████████████████████
SMB parallel, 10G 174 █████████████████████████
BigTank local, 4 disks 168 ████████████████████████
SMB buffered, 10G 141 ████████████████████
| | | ¦
| | | ¦ calomel 4x raid10, 226
Note the scale change between the two blocks: 0–3000 for reads, 0–250 for writes. Plotted on one axis the entire write story would be invisible.
2. Hardware¶
| Component | Value |
|---|---|
| OS | TrueNAS SCALE 26.0 Community |
| ZFS | OpenZFS 2.4.3-1 |
| CPU | Intel Xeon E5-2680 v4 — 14 cores / 28 threads @ 2.40 GHz |
| RAM | 377 GB |
| ARC | 275 GB in use, c_max 404 GB, hit rate 99.96% |
| NIC | 10 GbE, MTU 1500 |
The CPU matters when reading general NAS advice: commentary about consumer NAS units bottlenecking on weak ARM/N100/Celeron processors does not apply here. This box has never been CPU-bound in any measurement on this page.
Disks¶
| Device | Model | Size | Type | Pool |
|---|---|---|---|---|
sdi |
HGST HUH721010AL4200 | 10 TB | 7200 rpm SAS | BigTank — mirror-0 |
sdj |
HGST HUH721010AL4200 | 10 TB | 7200 rpm SAS | BigTank — mirror-0 |
sdk |
HGST HUH721010AL4200 | 10 TB | 7200 rpm SAS | BigTank — mirror-1 |
sdl |
HGST HUH721010AL4200 | 10 TB | 7200 rpm SAS | BigTank — mirror-1 |
sda |
Seagate ST10000NM0096 | 10 TB | 7200 rpm | Backup10T |
sdd |
P3-512 | 512 GB | SATA SSD | ai-pool |
sde |
HP SSD S700 500GB | 500 GB | SATA SSD | ai-pool |
sdf |
Samsung 860 EVO 1TB | 1 TB | SATA SSD | ai-pool |
sdg |
T-FORCE 512GB | 512 GB | SATA SSD | boot-pool mirror |
sdh |
MK000480GWCEV | 480 GB | SATA SSD | boot-pool mirror |
sdb, sdc |
T-FORCE 512GB | 512 GB | SATA SSD | unused, no partitions |
3. Pools¶
| Pool | Topology | Raw | Used | Frag | Role |
|---|---|---|---|---|---|
| BigTank | 2 × 2-way mirror, striped (RAID10) | 18.2 TB | 53% | 25% | Primary data. Backs all NFS and SMB shares. |
| Backup10T | single disk | 9.08 TB | 68% | 1% | Holding/staging disk. Replication target for BigTank/General,backup,photos. Single-disk by design. |
| ai-pool | 3 × single-disk stripe | 1.82 TB | 50% | 2% | LLM model weights. No redundancy, deliberately — contents are re-downloadable. |
| boot-pool | 2-way mirror | 222 GB | 50% | 26% | Boot. |
Two properties of BigTank explain its write behaviour and should not be forgotten when reading section 4:
- 25% fragmentation at 53% capacity.
- A removed vdev.
zpool statusreportsRemoval of vdev 3 copied 325Gwith18.8M memory used for removed device mappings. Every block lookup on this pool passes through that indirection table for the life of the pool.
4. Measured throughput¶
Local, straight to the pools¶
Compression was set to off and the payload was incompressible, so these are
true disk figures rather than compression artefacts.
| Pool | Write, 1 stream | Write, 4 streams | Read from ARC | Read from disk |
|---|---|---|---|---|
| BigTank (4 disks) | 168 MB/s | 164 MB/s | 2490 MB/s | 408 MB/s |
| Backup10T (1 disk) | 203 MB/s | 158 MB/s | 2466 MB/s | — |
| ai-pool (3 SSDs) | 243 MB/s | 179 MB/s | 2891 MB/s | — |
BigTank's four disks write slower than Backup10T's one disk. A striped mirror should write at roughly twice a single drive; it writes at 0.8×. Adding parallel streams does not help — 4 streams were slower than 1 on every pool.
Per-disk instrumentation during a write shows both mirrors loaded evenly, so striping itself is working, and the disks burst to roughly 490 MB/s aggregate raw. But sustained per-disk throughput sits at 120–130 MB/s against roughly 250 MB/s these drives do sequentially. That gap is consistent with the 25% fragmentation and the removed-vdev indirection turning a sequential write into a scattered one. This is the single clearest improvement target on the box.
Across the network¶
Client is Proxmox pve at 192.168.10.14 over 10 GbE, reading and writing
BigTank.
| Path | Read | Write |
|---|---|---|
| Raw TCP, 1 stream (no storage involved) | 1119 MB/s | — |
| NFS v4.2, 1 stream + readahead | 463 MB/s | 218 MB/s |
| NFS v4.2, 4 parallel streams | 645 MB/s | 230 MB/s |
| SMB 3.1.1, 1 stream + readahead | 634 MB/s | 141 MB/s |
| SMB 3.1.1, 4 parallel streams | 408 MB/s | 174 MB/s |
| 2.5 GbE workstation, raw TCP | 256 MB/s | — |
Read it against the two ceilings. NFS write (218) lands within 30% of BigTank's local write (168) — writes are pool-bound end to end, and no network change will move them. Reads never exceed 645 MB/s against a 1119 MB/s wire, so the network has headroom nothing is using.
The NFS-vs-SMB write comparison in that table is not valid and must not be
quoted as a protocol result. The NFS figures were taken against
BigTank/k8s, which has sync=disabled; the SMB figures against
BigTank/virtual-machines, which has sync=standard. That measures the sync
setting, not the protocol. Treat 218 vs 141 as "sync off vs sync on".
The read difference is real but is a mount-option effect, not a protocol
one: the SMB mount negotiated rsize=4194304 (4 MB) against NFS's
rsize=1048576 (1 MB), so SMB makes a quarter as many round trips. NFS at
rsize=4M would be expected to close most of the gap.
The ad-hoc NFS mount used for these tests had no nconnect. The cluster's real
truenas-nfs storageClass does mount with nconnect=16, so the
single-stream NFS numbers above understate the production path.
5. Against calomel's reference table¶
calomel.org's ZFS RAID speed and capacity table
is the usual yardstick for "is my pool normal?". It benchmarked 24 × WD Black
4 TB 7200rpm SAS on an LSI 9207-8i, FreeBSD 10.2, with bonnie++ on a 16 GB
file and compression disabled.
Their 4 × raid10 — two 2-drive mirrors striped — is exactly BigTank's topology, which makes it the one row worth comparing against.
| Metric | calomel, WD Black 4 TB | BigTank, HGST He10 10 TB | Delta |
|---|---|---|---|
| Sequential write | 226 MB/s | 168 MB/s | −26% |
| Sequential read | 644 MB/s | 408 MB/s | −37% |
| Metric | calomel 1 × single | Backup10T, 1 disk | Delta |
|---|---|---|---|
| Sequential write | 108 MB/s | 203 MB/s | +88% |
The comparison is not symmetric, and that is the point. calomel ran
bonnie++ -b — synchronous writes with the drive cache disabled — on freshly
created, unfragmented pools. That is a harsher write test than the buffered
--end_fsync=1 used here. Backup10T beating their single-drive number by 88%
is about what an easier test plus a newer, denser platter should produce.
BigTank landing 26% below their figure despite the easier test, on drives a generation newer and 2.5× larger, is the anomaly. It is the same finding as section 4 arrived at from a different direction, and the causes are the same: 25% fragmentation and the removed-vdev indirection layer.
For reference, the rest of calomel's spinning-disk table, useful when sizing a future pool:
| Configuration | Write | Read |
|---|---|---|
| 1 × single | 108 | 204 |
| 2 × mirror | 106 | 488 |
| 4 × raid10 | 226 | 644 |
| 4 × raidz1 | 225 | 619 |
| 4 × raidz2 | 204 | 183 |
| 6 × raid10 | 389 | 655 |
| 6 × raidz2 | 429 | 488 |
Their numbers are not directly portable to this box — different drives, OS, ZFS
version, and test tool — so treat them as shape rather than target. The useful
signal is relative: adding spindles buys write throughput roughly linearly, and
raidz2 at four drives collapses on reads.
6. How to read these numbers correctly¶
Four traps produced wrong answers during measurement. They will produce wrong answers again on any re-run.
Cache-defeating is mandatory on a 377 GB machine. With a 275 GB ARC, no
practical file size defeats the cache. Use fio --direct=1 (OpenZFS 2.3+ has
real O_DIRECT and every dataset here is direct=standard), or read a cold
region of a file far larger than ARC.
O_DIRECT disables prefetch, so it understates sequential reads. BigTank
reads 224 MB/s under O_DIRECT and 408 MB/s buffered with prefetch — an
83% difference on identical hardware. O_DIRECT answers "is ARC involved?"; it
does not answer "how fast is this pool?" Comparisons against published
reference tables such as
calomel.org's ZFS RAID speed table
must use the buffered figure, because that is what dd-based tests measure.
Client page cache can invent throughput above line rate. Four buffered
streams reading one file reported 1596 MB/s over NFS and 2486 MB/s over SMB.
Both are impossible on a 10 GbE link, because jobs 2–4 were served from the
client's RAM. Always sanity-check a network result against measured raw TCP;
anything above it is a measurement artefact. Parallel tests must use
--direct=1 and a separate file per job.
Compression turns benchmarks into fiction. fio must write incompressible
data, or set compression=off on the test dataset. Note --refill_buffers
regenerates random data per I/O and becomes its own CPU bottleneck; a single
random buffer is already incompressible and is enough.
One more artefact worth knowing: zfs_vdev_direct_write_verify=1 makes ZFS read
back and verify every O_DIRECT write, which showed up as an absurd 82 MB/s.
O_DIRECT writes are not a meaningful measurement here — ARC is a read cache, so
measure writes buffered with --end_fsync=1.
7. Tuning observations¶
Not yet applied. Recorded here so the reasoning is not lost.
zfs_dirty_data_maxis 4 GB on a 377 GB machine. ZFS caps this default at 4 GB regardless of RAM. Writes are visibly bursty — disks idle, then flush at ~490 MB/s. Raising it lets ZFS absorb more before throttling. Test before adopting; a larger dirty buffer also lengthens txg flush pauses.- NFS has no
nconnect. Every mount is one TCP connection, which is why 1 stream reaches 463 MB/s while 4 reach 645.nconnect=4would let a single mount use multiple connections. - Jumbo frames would gain nothing. Single-stream TCP already reaches 94% of theoretical 10 GbE at MTU 1500. This is the most commonly suggested tuning knob and the least useful one here — and a partially applied MTU change across a path breaks connectivity in ways that are tedious to diagnose.
- BigTank fragmentation and vdev indirection are the real write limit. Both are properties of the pool's history; neither is fixable by tuning. Only rewriting the data into a freshly created pool clears them.
8. Client configuration¶
Mount options in effect when the section 4 numbers were taken.
SMB — Proxmox storage truenas-smb, share virtual-machines on
/mnt/BigTank/virtual-machines:
NFS — export /mnt/BigTank/k8s:
NFS exports differ in root mapping, which decides whether a client's root can
write. /mnt/BigTank/proxmox squashes root; /mnt/BigTank/k8s and
/mnt/ai-pool/vllm set maproot_user: root. A "permission denied" as root on
an NFS mount is usually this, not a filesystem permission.
9. Identity, sharing, and permissions¶
Purpose: who can reach which data, and why. This is the model to reason from when adding a device or debugging "why can't this user read that file".
Status: current state, implemented. Replaces an earlier arrangement in which every account sat in its own private group and cross-account access was structurally impossible.
The model in one sentence¶
Two groups define two tiers, and the only thing separating the tiers is which groups an account belongs to.
| Account | UID | Groups | Reaches |
|---|---|---|---|
vanillax |
3000 | nas-public + nas-private |
everything |
k8-smb-user |
3001 | nas-public + nas-private |
everything |
proxmox |
3002 | nas-public + nas-private |
everything |
media-server |
3003 | none | public tier, read-only |
truenas_admin |
950 | not an SMB user | UI / SSH / API only |
media-server (TVs and appliances) is deliberately in no nas-* group. It
reads the public tier through the directory "other" bits and is blocked from the
private tier because those directories have no "other" bits at all.
Why the appliance account is in no group rather than in nas-public: POSIX
mode bits cannot distinguish two members of the same group. If the appliance
were in nas-public, then either the group has write — and a TV can delete your
media — or it does not, and your own account loses write too. Leaving the
appliance out of every group yields group = writers, other = readers, which is
exactly the intent.
Tiers¶
| Tier | Group | Mode | Datasets |
|---|---|---|---|
| Public | nas-public (3100) |
2775 |
k8s/jellyfin-media, k8s/tubearchivist, k8s/kiwix, k8s/versatiles, and the BigTank/k8s parent |
| Private | nas-private (3101) |
2770 |
General, backup, photos, organized_backups, virtual-machines, proxmox, users, k8s/{frigate,ollama,rustfs,vanillax,volsync-kopia-nfs,llama-cpp-archive}, ai-pool/{comfyui,llama-cpp,vllm} |
2770 is what does the hiding: no "other" permission means an account outside
the group cannot even traverse into the directory. 2775 grants the group write
and everyone else read. The leading 2 is setgid, so files created inside
inherit the directory's group instead of the creator's private group — that is
what stops the arrangement drifting apart as data is written.
Verified behaviour¶
Tested by running as each account rather than by reading modes:
media-server |
vanillax |
k8-smb-user |
|
|---|---|---|---|
| Public tier | read | write | write |
| Private tier | no access | write | write |
Rules¶
- Adding a device is one decision: which groups does it join? An appliance joins none. A trusted machine joins both. There is no per-share configuration.
BigTank/k8sis a mixed container — it holds public and private children. Never apply a recursivechmod/chgrpat that level; it will re-open the private children. Always scope to the individual leaf dataset.- The parent of a public dataset must stay traversable.
BigTank/k8sisnas-publicfor exactly this reason: if it were private, appliances could not traverse into the public datasets beneath it. aclinheritispassthroughon every pool. Do not set it back todiscard— that stops new files inheriting and the tiers decay silently.
Setting permissions through the API¶
filesystem.setperm rejects four-digit modes ("Please supply a value between
000 and 777"), so setgid cannot be applied through it. Two passes are required:
# 1. group + base mode (recursive, strips stale ACLs)
midclt call -j filesystem.setperm \
'{"path": "<path>", "uid": <uid>, "gid": 3101, "mode": "770",
"options": {"stripacl": true, "recursive": true}}'
# 2. setgid on directories
sudo find <path> -type d -exec chmod g+s {} +
stripacl: true discards existing NFSv4 ACLs. That is intended here — it
establishes a predictable mode-based baseline — but be aware it removes hidden
grants. Before this work, BigTank/General showed mode 770 vanillax:vanillax
while a different account had write through an invisible NFSv4 ACL. On an
nfsv4 dataset the mode you see is not necessarily the rule being enforced.
Known remaining inconsistencies¶
- Two ACL models coexist.
acltype=nfsv4on BigTank and children;acltype=posixon Backup10T, ai-pool, andBigTank/backup.acltypecannot be changed casually on a populated dataset, so this is left as-is. Backup10Tis intentionallyreadonly=on. It is the second copy of personal data. ZFS replication (zfs recv) still writes to a read-only dataset, but humans and processes cannot — which is the correct protection for a replica. Do not turn it off.- Two unrelated locations are called "photos": the SMB share
photosserves/mnt/BigTank/organized_backups/photos, while the NFS export/mnt/BigTank/photos/Allserves the separateBigTank/photosdataset. jellyfin-mediais empty. The public tier is real, but there is no film library on this NAS yet.
10. Reproducing these measurements¶
Requires SSH to the NAS and a client. midclt and read-only zpool commands
work as truenas_admin without sudo; zfs set does not, so change dataset
properties through the API instead.
Create a scratch dataset, own it, and disable compression:
# on the NAS
midclt call pool.dataset.create '{"name": "<pool>/_bench", "type": "FILESYSTEM"}'
midclt call -j filesystem.setperm \
'{"path": "/mnt/<pool>/_bench", "uid": 950, "gid": 950, "mode": "755", "options": {"stripacl": true}}'
midclt call pool.dataset.update "<pool>/_bench" '{"compression": "OFF"}'
midclt call -job fails to parse on this build — use -j.
Sequential write, and read as ARC would serve it:
fio --name=w --filename=/mnt/<pool>/_bench/f --rw=write --bs=1M --size=24G \
--direct=0 --ioengine=psync --end_fsync=1 --group_reporting
fio --name=r --filename=/mnt/<pool>/_bench/f --rw=read --bs=1M --size=24G \
--direct=0 --ioengine=psync --invalidate=0 --group_reporting
True disk read, using a cold region of a file much larger than ARC. --readonly
is required when pointing fio at production data:
fio --name=cold --filename=<large-file> --rw=read --bs=1M --size=32G \
--offset=120G --direct=0 --ioengine=psync --readonly --group_reporting
Confirm it really came from disk — watch zpool iostat <pool> 5 for read
bandwidth during the run, and check that ARC misses climbed:
Remove the scratch dataset afterwards:
Raw network ceiling, with no storage in the path, needs only python3 on both
ends. Listen on the NAS, then send from the client, and compare the result to
every network figure in section 4:
# receiver: python3 - (bind 0.0.0.0:5201, recv until EOF, report bytes/elapsed)
# sender: python3 - (connect, sendall a 4 MiB buffer N times, report bytes/elapsed)
11. Source of truth¶
- Pool topology and health:
zpool statuson the NAS — authoritative over any table here. - Share definitions:
midclt call sharing.smb.query/sharing.nfs.query. - Kubernetes-side storage policy: Storage architecture.
- A pool that disappears from the Storage dashboard: see
TrueNAS special-vdev stall runbook.
Note that an OFFLINE pool with a null topology blanks the entire Storage
dashboard —
zpool listis the reliable check, and exporting the dead pool withdestroy: falserestores the UI without touching data.