ZFS tuning
Pool and dataset configuration for PutFS workloads: small-file random reads, writes and prefix listings. The feature mapping lists what ZFS brings to PutFS beyond speed.
Pool topology
Special vdev (the biggest win)
A special vdev stores ZFS metadata and small files (under special_small_blocks) on a dedicated device, typically NVMe.
# Mirrored data vdevs and a mirrored NVMe special vdev
zpool create tank \
mirror /dev/sda /dev/sdb \
mirror /dev/sdc /dev/sdd \
special mirror /dev/nvme0n1 /dev/nvme1n1
Warning
Always mirror the special vdev. If a single one fails, the pool is lost.
Metadata (dnodes, indirect blocks, directory entries) always lives on the special vdev, which alone speeds up listings and stat calls. Small file data follows only with:
With that, every small-file read and every directory lookup is an NVMe read; the spinning disks only handle large files.
SLOG
A SLOG (separate intent log) absorbs synchronous write latency. A small, fast NVMe device (16–32 GB) is enough; it holds in-flight transactions only.
L2ARC
L2ARC extends the ARC (RAM cache) onto SSD for a working set that exceeds RAM but fits on SSD. If the dataset fits in ARC, it adds nothing.
Recommended topology
NVMe 1+2 (mirror) → special vdev (metadata + small files)
NVMe 3 → SLOG (write intent log)
SSD 1 → L2ARC (read cache overflow)
HDD 1+2 (mirror) → data vdevs (large files)
HDD 3+4 (mirror) → data vdevs (large files)
Synchronous vs asynchronous writes
PutFS issues no fsync, so the dataset's sync property decides when a PUT is on stable storage:
| Value | Behavior with PutFS | A just-acknowledged PUT is... |
|---|---|---|
sync=standard (default) |
PUTs are asynchronous: batched into the open transaction group and flushed at the next commit (zfs_txg_timeout, default 5s). |
atomically visible, not yet on stable storage; a power loss within ~5s drops it whole, never torn. |
sync=always |
Every write goes to the ZIL (SLOG) before it returns. | on the SLOG before the 201: the setting that makes an acknowledged PUT power-loss-safe. |
sync=disabled |
All writes asynchronous, every fsync ignored. Identical to standard for PutFS. |
in the same ~5s window; no faster than standard for PutFS. |
sync=standard returns a PUT as soon as the body is in RAM and the rename is done, sub-millisecond, with no SLOG wait. sync=always costs ~0.1ms per write with an NVMe SLOG, 5–15ms per write on HDDs without one, every chunk of a large upload included; use it for primary data that must survive a crash. sync=disabled only removes durability for other tools that do fsync on the dataset (zfs receive); there is no reason to set it for PutFS.
# Default: asynchronous for PutFS
zfs set sync=standard tank/putfs
# Durable on ack, for primary data
zfs set sync=always tank/putfs/acme-corp/documents
Durability is opt-in for PutFS
Only sync=always makes an acknowledged PUT power-loss-safe; standard and disabled both leave a ~5s window. Children inherit the parent's sync unless overridden.
Dataset properties
One dataset per bucket
recordsize
recordsize is the maximum block size, not the minimum: a 2KB file in a 128K dataset uses one 4K block. Lower it only for datasets of many files between 4K and 128K, where it cuts per-block metadata:
Compression
Always on; with zstd the ARC and L2ARC effectively grow, since cached data stays compressed. Turn it off only for already-compressed content (JPEG, video, ZIP):
Extended attributes
Pack xattrs into the dnode to save an I/O:
atime
Every GET would otherwise write metadata:
Quotas
PutFS returns 507 Insufficient Storage when the filesystem is full.
ARC tuning
Size
ZFS uses up to 50% of RAM for the ARC by default. On a dedicated server, give it more:
Monitoring
arcstat 1 # live hit rate, size, evictions
arc_summary # detailed ARC breakdown
zpool iostat -v tank 5 # per-vdev I/O
zpool iostat -l tank 5 # latency histograms
- ARC hit rate: above 90% is good, above 98% means most reads come from RAM.
- Demand metadata hits: directory lookups and stat calls; a drop here degrades listings.
- L2ARC hit rate: if low, the working set exceeds SSD + RAM.
For Prometheus, zfs_exporter exposes pool, dataset and ARC metrics, and nginx-prometheus-exporter scrapes nginx's stub_status. Alert on pool capacity above 85% (PutFS answers 507 when full), an ARC demand hit rate below 0.9, and replication lag.
Complete example
A PutFS server with 64 GB RAM, 2x NVMe, 1x SSD, 4x HDD:
# Create pool
zpool create tank \
mirror /dev/sda /dev/sdb \
mirror /dev/sdc /dev/sdd \
special mirror /dev/nvme0n1 /dev/nvme1n1 \
cache /dev/sde
# Root dataset
zfs create tank/putfs
zfs set compression=zstd tank/putfs
zfs set atime=off tank/putfs
zfs set xattr=sa tank/putfs
zfs set dnodesize=auto tank/putfs
zfs set special_small_blocks=128K tank/putfs
# ARC – give it 48 GB of the 64 GB
echo 'options zfs zfs_arc_max=51539607552' > /etc/modprobe.d/zfs.conf
# Per-bucket datasets
zfs create tank/putfs/acme-corp
zfs create -o recordsize=4K tank/putfs/acme-corp/thumbnails
zfs create -o quota=500G tank/putfs/acme-corp/documents
Small files (< 128K) live on NVMe, hot files in RAM, warm files on SSD, large files on mirrored HDDs; everything is compressed in cache and no read writes an atime.
Further reading
- OpenZFS documentation
- OpenZFS
zfsprops: dataset properties - OpenZFS
zpoolprops: pool properties - Klara Systems: Performance tuning ARC, L2ARC & SLOG
- Brendan Gregg: ZFS L2ARC
- 45Drives: ZFS Caching
- Jim Salter: ZFS 101