Skip to content

storage: fsync staging directory before atomic rename#922

Draft
asahay19 wants to merge 1 commit into
podman-container-tools:mainfrom
asahay19:asahay-storage-fsync
Draft

storage: fsync staging directory before atomic rename#922
asahay19 wants to merge 1 commit into
podman-container-tools:mainfrom
asahay19:asahay-storage-fsync

Conversation

@asahay19

@asahay19 asahay19 commented Jun 17, 2026

Copy link
Copy Markdown

PR Summary

Add fsync of overlay staging directory contents before the atomic rename that
commits a new layer during image pull/unpack.

Replaces auto-closed containers/storage#2397
after the storage library moved into this monorepo.

Problem

This gap was reported against real customer deployments. Edge and
disconnected SNO customers (vehicle-mounted, tactical, and air-gapped
deployments with unstable power) hit readlink: invalid argument errors and
CRI-O crashes after hard resets, and requested a fix, since these nodes cannot
simply re-pull images from a registry that isn't reachable.
Root cause: when CRI-O (via go.podman.io/storage) pulls an image, layer data
is written into a staging directory and then committed with os.Rename().
Nothing forces that data to physical disk before the rename makes it visible
at its final path.
If power is lost in that window, the layer isn't just missing — it's present
but corrupted
: binaries can end up truncated, and overlay l/ symlinks can be
left invalid, producing exactly the readlink: invalid argument error reported
above. On restart, CRI-O detects the unclean shutdown and internal_repair
can't tell which layer is actually bad, so it falls back to a broad,
destructive repair — on a disconnected node, that means unrecoverable image
loss.

Solution

Guarantee durability before the rename ever exposes the new layer, so that
window can't exist regardless of sync=filesystem configuration:

  1. fdatasync() every file under the staging directory
  2. fsync() every directory under the staging tree, bottom-up
  3. Only then perform the atomic os.Rename()
    This mirrors the pattern already used by atomicFileWriter
    (storage/pkg/ioutils/fswriters_linux.go) for single files, extended to a
    whole staging tree. It's scoped to just the layer being committed — much
    cheaper than Syncfs()'s whole-filesystem flush

Changes

File Change
storage/pkg/ioutils/sync_directory_linux.go New SyncDirectoryContents() helper (Linux)
storage/pkg/ioutils/sync_directory_linux_test.go Unit test
storage/drivers/overlay/overlay.go Call sync before rename in ApplyDiffFromStagingDirectory()

Testing

 asahay@asahay-ubuntu:~/container-libs/storage$ go test ./pkg/ioutils/ -run TestSyncDirectoryContents -v
=== RUN   TestSyncDirectoryContents
--- PASS: TestSyncDirectoryContents (0.00s)
PASS
ok  	go.podman.io/storage/pkg/ioutils	0

@github-actions github-actions Bot added the storage Related to "storage" package label Jun 17, 2026

@mtrmac mtrmac left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/podman-container-tools/container-libs/blob/main/CONTRIBUTING.md#sign-your-prs please, we can’t really even look at contributions with unclear copyright status.


We now have sync=filesystem via #622 , isn’t that sufficient? What is missing?

@asahay19

asahay19 commented Jul 8, 2026

Copy link
Copy Markdown
Author

https://github.com/podman-container-tools/container-libs/blob/main/CONTRIBUTING.md#sign-your-prs please, we can’t really even look at contributions with unclear copyright status.

We now have sync=filesystem via #622 , isn’t that sufficient? What is missing?

Thank you @mtrmac for your feedback. sync=filesystem helps, but doesn't fully close this gap: it's opt-in (SyncModeNone is the default), and even when enabled, its Syncfs() call happens after ApplyDiffFromStagingDirectory's os.Rename() (as part of saveFor(layer, true)), so there's still a window where the layer is visible at its final path before it's actually durable. This PR closes that window by syncing before the rename, and works unconditionally regardless of sync=filesystem config. I've updated the PR description with the full details (Problem/Solution sections). Happy to adjust the approach if there's a simpler way to get the same guarantee.

@mtrmac

mtrmac commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

We now have sync=filesystem via #622 , isn’t that sufficient? What is missing?

sync=filesystem helps, but doesn't fully close this gap: it's opt-in (SyncModeNone is the default)

*shrug* so opt in.

, and even when enabled, its Syncfs() call happens after ApplyDiffFromStagingDirectory's os.Rename() (as part of saveFor(layer, true)), so there's still a window where the layer is visible at its final path before it's actually durable.

That shouldn’t matter; the store is locked in the meantime, so there should be no concurrent accesses . And in case of power loss or the like, what governs is the layer database in layers.json, and until the Syncfs succeeds, layers.json still records the layer with incompleteFlag, to be removed on the next write. Nothing should observe the unsynced layer contents.

Signed-off-by: Aditi Sahay <asahay@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@asahay19 asahay19 force-pushed the asahay-storage-fsync branch from 986fac7 to 1e62330 Compare July 9, 2026 09:01
Comment on lines +32 to +33
// inode metadata, which is already covered once the parent
// directory is fsync'd below. Treating them like regular

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What documents that fsync on a directory also syncs all nodes of its children?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

storage Related to "storage" package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants