Locking composefs images with flock
Table of Contents
For the last couple of weeks, I’ve been working on garbage collection
for composefs repositories. A composefs repository is a
content-addressed object store, and it needs a garbage collector to
reclaim space from images that are no longer referenced. The hard part
is deciding what “referenced” actually means when an image has no ref
in the repository but is mounted somewhere, possibly from another
process, possibly in a different mount namespace, possibly by the
running system itself. On
composefs-rs#346
was suggested to use flock() on the EROFS backing file. Mounters
take a shared lock, the GC probes with an exclusive one, and if the
exclusive lock fails, the image is still in use.
The problem#
Named refs under images/refs/ point at EROFS images. Everything
reachable from a ref, meaning the EROFS image itself plus every
object referenced by its trusted.overlay.redirect xattrs, is live.
That works fine as long as the only consumers are named refs.
It stops working the moment a caller mounts an image that has no
ref, or deletes a ref while the image is still mounted somewhere.
The mount itself holds no reference that the GC can see. The EROFS
superblock keeps the backing file open in the kernel, but from
userspace the file is just sitting in objects/, looking like any
other candidate for pruning.
The obvious first attempt is a per-image lockfile: whoever mounts an
image writes a marker under refs/mounted/<digest>, the GC checks
for markers, the marker is removed on unmount. It doesn’t really
work. Cleaning up on abnormal exit needs its own mechanism, and the
bootc root mount has no long-lived userspace process to keep a marker
alive to begin with: EROFS is mounted in the initramfs, and then
systemd pivots into it. Writing state at mount time also races with
a concurrent GC, so we would need locking on top of the marker file
anyway.
Flatpak solved a very similar problem for deploy directories using
fcntl locks on a .ref file inside each deploy dir. Every app
that bind-mounts the directory takes a shared lock, and the GC only
removes a directory if it can take an exclusive one. The kernel
tracks lifetime for us. The same idea maps cleanly onto composefs
if we lock the EROFS image file directly.
The flock protocol#
The rule is symmetric. Mounters take flock(fd, LOCK_SH) on the
EROFS image before mounting. Multiple mounters can hold the shared
lock at the same time. The GC takes flock(fd, LOCK_EX | LOCK_NB)
on each object it is about to delete, and if the non-blocking
exclusive lock fails, at least one mounter is still holding a shared
lock and the object is live, so the GC skips it.
On the mount side, the code takes the shared lock right before
handing the fd to fsconfig:
|
|
On the GC side, before deleting an object we try to grab the exclusive lock on it:
|
|
flock is attached to the open file description, so once the kernel
holds a reference to the EROFS image for the lifetime of the
superblock, it keeps the lock alive for us. The lock lifetime tracks
the mount lifetime, not the lifetime of any particular process.
That is exactly what we want.
The kernel piece#
There is one issue left. EROFS has historically been mounted by path, so the fd the userspace mounter locked on is not the fd the kernel ends up holding on to. Once the mounting process exits, the lock goes away with it, and the mount is no longer protected by anything.
Passing the backing file to EROFS as an fd instead of a path was suggested on the issue as the cleanest way to make the mount lifetime and the userspace lock lifetime line up. I picked up the idea and turned it into a small EROFS patch:
[PATCH 1/2] erofs: accept source file descriptor via fsconfig https://www.mail-archive.com/[email protected]/msg16338.html
The patch adds fsparam_fd("source") so that userspace can pass an
already-open fd via fsconfig(FSCONFIG_SET_FD, "source", NULL, fd).
The kernel stores that fd directly in sbi->dif0.file and skips
filp_open(). It boils down to a handful of lines in
erofs_fc_parse_param:
|
|
With this in place, the flock(LOCK_SH) taken in userspace is
attached to the same open file description that the kernel is
holding for the mount. The lock survives even after the mounting
process exits, and it is released when the mount goes away.
The userspace code tries the fd path first and falls back to the path-based source on older kernels:
|
|
The fallback works, but on old kernels the lock lifetime is tied to whoever holds the userspace fd, which is exactly what I am trying to avoid.
Cross-namespace correctness#
flock is namespace-agnostic, so a shared lock held from one mount
namespace blocks an exclusive lock attempted from another. The GC
does not need to enumerate mount namespaces or use
statmount(2)/listmount(2) to decide whether an object is safe to
delete: it tries the exclusive lock and lets the kernel answer.
I also have code that enumerates mounts in composefs-rs to reuse an
existing EROFS mount as the overlay lower layer, so that mounting
the same image twice does not create two identical EROFS
superblocks. That path relies on a couple of extra kernel patches
(OVL_IOC_OPEN_LAYER on the overlay side, and EROFS_IOC_GET_SOURCE_FD
on the erofs side, to walk from the overlay mount back to the
backing image file and compare its fs-verity digest), but those
patches are currently stalled on some design discussions upstream,
so let’s ignore them. For now the only piece I actually need in the
kernel is the ability to mount an EROFS image by fd, and everything in
this post works on top of that alone. Locking and mount reuse are
independent problems anyway: locking protects objects from being
deleted while they are in use, reuse allows a better sharing of
resources since the same superblock is shared among different mounts.
What this does not solve#
flock is advisory: a stray rm -rf objects/ still deletes a
mounted image out from under the kernel.
Nothing here binds the lock to a specific mounter identity either. Any process with read access to the EROFS file can take a shared lock and pin an object. For the system-wide composefs repository that is fine, only privileged components mount from it. A shared repository serving unprivileged callers would need to combine this with the file permission model still under discussion in the upstream thread.
Conclusion#
flock on the EROFS backing file is the smallest change I could
come up with that gives the GC a correct answer to “is this object
in use?” without persisting state anywhere. Shared on the mount
side, non-blocking exclusive on the GC side, and the kernel does the
accounting for us through the open file description attached to the
mount. No daemon, no fd stash, no directory of markers under
/run.