Commit a424ba1 ("Linux: Make copyFile sparse aware") introduced sparse file support to continuity's fs.CopyFile on Linux. The change is valuable — it preserves sparse file holes during copy, avoiding unnecessary disk allocation. However, it also added a per-file fsync (tgt.Sync()) that causes a severe performance regression when copying directories with many files — the dominant workload for container image extraction and volume copy-up.
Before a424ba1, copyFile on Linux was a simple io.Copy:
func openAndCopyFile(target, source string) error {
src, err := os.Open(source)
defer src.Close()
tgt, err := os.Create(target)
defer tgt.Close()
_, err = io.Copy(tgt, src)
return err
}After the change, the new implementation uses SEEK_DATA/SEEK_HOLE to skip holes, copy_file_range for zero-copy data transfer, and calls tgt.Sync() before returning:
func copyFile(target, source string) error {
// ... open, truncate, SEEK_DATA/SEEK_HOLE loop with copy_file_range ...
if err := tgt.Sync(); err != nil {
return fmt.Errorf("failed to sync target %s: %w", target, err)
}
return nil
}fsync forces the kernel to flush all dirty pages for the file to disk and wait for the I/O to complete. When you call it once per file across thousands of files, you're issuing thousands of individual disk flush operations. Each one waits for the storage device to confirm the write — this serializes what would otherwise be batched writeback.
The old io.Copy path had no fsync. Data stayed in the kernel's page cache after write() returned, and the kernel's writeback daemon flushed it to disk in batches on its own schedule. This is perfectly acceptable for container workloads — if the machine crashes mid-operation, you discard the partial result and redo it.
Test setup: 50,000 regular files, each 10 KiB, filled with random data. Copied on the same ext4 filesystem. Page cache dropped between runs (echo 3 > /proc/sys/vm/drop_caches).
| Copy method | Sync mode | Time | Relative |
|---|---|---|---|
Naive (io.Copy, no fsync) |
none | 4.35s | 1.0x |
Sparse-aware (SEEK_DATA + copy_file_range) |
none | 4.84s | 1.1x |
| Sparse-aware | single sync at end of dir | 4.88s | 1.1x |
| Sparse-aware | per-file fsync | 1m 16s | 17.4x |
-
The fsync is the bottleneck, not the sparse logic. Without fsync, the sparse-aware copy is only ~10% slower than naive
io.Copy(due to the extraSEEK_DATA+SEEK_HOLEsyscalls per file). -
Per-file fsync is catastrophic for many-files workloads. 50K fsyncs take over a minute vs 4.4 seconds without.
-
A single
sync()at the end of the directory copy achieves the same durability as per-file fsync (all data reaches disk before the operation returns) but amortizes the cost across all files.
continuity/fs.CopyDir (which calls fs.CopyFile → copyFile for each regular file) is used in several critical paths:
When a Dockerfile declares VOLUME /path, and the image has existing content at that path, the container runtime must copy that content into the volume mount at container start. In containerd's CRI plugin, this is the volumeCopyUp path (pkg/cri/server/container_create_linux.go) which calls fs.CopyDir. If the image has thousands of files in the volume path (e.g., a /var/lib with application data, a /usr/share with assets), the per-file fsync adds significant startup latency.
This was already reported as a performance issue in containerd/containerd#8639 — "Image-defined volumes with lots of files cause high I/O load at container start."
nerdctl cp— copying files into/out of containers- Snapshot operations that replicate directory trees
- Any tool using
continuity/fsfor filesystem operations
The old code was never "safe" from a crash perspective — it just didn't need to be. For these workloads:
- Volume copy-up: If the machine crashes during copy-up, the container never started successfully. On restart, the runtime redoes the copy-up from the immutable image layer.
- Image extraction: If extraction is interrupted, the partial snapshot is discarded and re-extracted.
- File copy operations: The caller controls whether durability matters.
None of these require per-file durability guarantees. The data source (image layer) is always available for replay.
Remove the per-file tgt.Sync() from copyFile. If some callers genuinely need durability, options include:
- A single
sync()at the end ofCopyDir(same durability, amortized cost — adds <1% overhead in benchmarks). - A
CopyDirOptto opt in to per-file fsync for callers that need it.
The sparse-aware copy logic (SEEK_DATA/SEEK_HOLE + copy_file_range) should remain — it's the right approach for sparse files and only adds ~10% overhead for non-sparse files.
git clone https://github.com/pengubco/ad-hoc-continuity-0.5.0-fsync-regression
cd ad-hoc-continuity-0.5.0-fsync-regression
# Create test data (50K files × 10KiB)
go run ./create-many/ -n 50000 -size 10240 ./_bench/source
# Run benchmark (requires root for drop_caches)
go build -o copy-bench-bin ./copy-bench/
sudo ./copy-bench-bin -sync per-file ./_bench/source # 1m16s — current behavior
sudo ./copy-bench-bin -sync none ./_bench/source # 4.8s — without fsync
sudo ./copy-bench-bin -sync dir ./_bench/source # 4.9s — single sync at end