$RodHat_
Console Tips

The new mount API: fsopen(2), fsmount(2), and doing it right

Published by

The new mount API: fsopen(2), fsmount(2), and doing it right
Photo: AI-generated — no human photographer / RodHat AI Cover

The mount(2) syscall has been in the kernel since before most of you started touching computers. It takes a source, a target, a filesystem type, some flags, and a void pointer to filesystem-specific options crammed into a single string. Everything gets committed in one atomic call or you retry from scratch. There is no way to inspect what you built before it lands. There is no way to hand an almost-ready mount to a subprocess without involving a shared pathname. There is no POSIX; it was always a mess.

Linux 5.2, shipped in 2019, added a replacement. Six new syscalls, all fd-based, building a mount the way you build any other kernel object: open a context, configure it incrementally, commit when ready. The old mount(2) still works. The new API is better in every way that matters for serious work. Almost nobody uses it yet because the man pages were late and the Go and Python wrappers came even later.

The six syscalls

fsopen(2)     — open a filesystem type context; returns an fd
fsconfig(2)   — set options on an fsopen or fspick context
fsmount(2)    — create a detached mount from a configured context; returns an fd
fspick(2)     — open a context for an already-mounted filesystem (for reconfiguration)
open_tree(2)  — attach an fd to a mount point, optionally cloning the tree
move_mount(2) — attach or move a detached mount to the namespace

None of these have glibc wrapper functions at time of writing; call them via syscall(2) with numbers from <sys/syscall.h>. The numbers are stable on x86_64: open_tree is 428, move_mount is 429, fsopen is 430, fsconfig is 431, fsmount is 432, fspick is 433.

Mounting ext4: old way vs new way

Old:

mount("/dev/sdb1", "/mnt/data", "ext4", MS_RDONLY | MS_NODEV, "errors=remount-ro");

One shot. Either it works or you get an errno and start over.

New:

#define _GNU_SOURCE
#include <sys/syscall.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <linux/mount.h>

int main(void)
{
    /* Step 1: open a context for ext4 */
    int fs = syscall(SYS_fsopen, "ext4", 0);
    if (fs < 0) { perror("fsopen"); return 1; }

    /* Step 2: configure it */
    if (syscall(SYS_fsconfig, fs, FSCONFIG_SET_STRING, "source", "/dev/sdb1", 0) < 0) {
        perror("fsconfig source"); return 1;
    }
    if (syscall(SYS_fsconfig, fs, FSCONFIG_SET_FLAG, "ro", NULL, 0) < 0) {
        perror("fsconfig ro"); return 1;
    }
    if (syscall(SYS_fsconfig, fs, FSCONFIG_SET_STRING, "errors", "remount-ro", 0) < 0) {
        perror("fsconfig errors"); return 1;
    }

    /* Step 3: commit the configuration (equivalent to superblock creation) */
    if (syscall(SYS_fsconfig, fs, FSCONFIG_CMD_CREATE, NULL, NULL, 0) < 0) {
        perror("fsconfig create"); return 1;
    }

    /* Step 4: create a detached mount */
    int mnt = syscall(SYS_fsmount, fs, 0, 0);
    if (mnt < 0) { perror("fsmount"); return 1; }

    close(fs); /* context fd no longer needed */

    /* Step 5: attach the mount to the namespace */
    if (syscall(SYS_move_mount, mnt, "", AT_FDCWD, "/mnt/data", MOVE_MOUNT_F_EMPTY_PATH) < 0) {
        perror("move_mount"); return 1;
    }

    close(mnt);
    return 0;
}

More lines, yes. But you get error granularity per-option instead of one undifferentiated EINVAL from the combined call. You can validate the configuration before it touches any path. And the detached mount fd at step 4 is a real resource you can hand to another process over a Unix socket before deciding where, or whether, to attach it.

fsconfig command constants

FSCONFIG_SET_FLAG sets a boolean option with no value (ro, nodev, noexec, nosuid).

FSCONFIG_SET_STRING sets a string option: source, errors, journal_path, anything the filesystem driver accepts.

FSCONFIG_SET_BINARY sets a binary blob option. Most filesystems do not use this.

FSCONFIG_SET_PATH and FSCONFIG_SET_PATH_EMPTY take a dirfd + path; for options that want a pathname. Useful for filesystems that take a key file or a journal path that you want to express with O_PATH security rather than a raw string.

FSCONFIG_SET_FD passes an open fd as an option value. tmpfs uses this for fd= options. Overlay uses it for lowerdir= as of recent kernels.

FSCONFIG_CMD_CREATE commits the superblock. Call it once after all options are set.

FSCONFIG_CMD_RECONFIGURE triggers a remount on a context opened with fspick.

open_tree and bind mounts

open_tree is the fd-based replacement for bind mounts. Old way:

mount("/src", "/dst", NULL, MS_BIND | MS_REC, NULL);

New way:

int tree = syscall(SYS_open_tree, AT_FDCWD, "/src",
                   OPEN_TREE_CLONE | OPEN_TREE_CLOEXEC | AT_RECURSIVE);
if (tree < 0) { perror("open_tree"); return 1; }

/* tree is now a detached copy of /src's mount tree */
if (syscall(SYS_move_mount, tree, "", AT_FDCWD, "/dst",
            MOVE_MOUNT_F_EMPTY_PATH) < 0) {
    perror("move_mount"); return 1;
}
close(tree);

OPEN_TREE_CLONE makes a detached copy of the mount tree rooted at the path. Without it, you get an fd referring to the live mount, which you can then move without clone overhead. With AT_RECURSIVE, the clone captures the entire subtree, same semantics as MS_REC on a bind.

The fd you get back is a mount fd like the one from fsmount. You can move_mount it to zero, one, or several targets before closing it.

Propagation control via fsmount flags

fsmount’s third argument controls mount propagation:

int mnt = syscall(SYS_fsmount, fs,
                  FSMOUNT_CLOEXEC,
                  MS_SHARED);       /* or MS_PRIVATE, MS_SLAVE, MS_UNBINDABLE */

This is cleaner than chasing the mount with a second mount(MS_SHARED) call like you had to do with the old API. You set propagation at creation time, before the mount is visible to anyone.

Why this matters for container setup

Container runtimes that care about correctness unshare the mount namespace with CLONE_NEWNS, then build the container’s rootfs using open_tree/fsmount/move_mount inside the private namespace. The rootfs never appears in the host namespace as an intermediate state. The detached mount fds can be passed to a privileged helper process via SCM_RIGHTS without any shared pathname — the helper attaches them wherever the policy says without knowing the container’s layout.

The old MS_MOVE trick, where you mounted everything under a temp path then pivoted root and moved mounts one by one, relied on shared pathnames and had a window where intermediate bind mounts were visible in the parent namespace. The new API eliminates that window.

pivot_root itself still exists and still works. But the setup before you call it can now be clean.

Privilege

fsopen on most filesystem types requires CAP_SYS_ADMIN because you are creating a superblock. open_tree with OPEN_TREE_CLONE on a mount you own requires CAP_SYS_ADMIN if the source mount has propagation; on private mounts inside a user namespace it is unprivileged. User-namespace-unprivileged bind mounts of your own mounts, inside a CLONE_NEWNS + CLONE_NEWUSER combined unshare, work on 5.9+.

move_mount requires the same privilege as placing a mount at the target: you need CAP_SYS_ADMIN in the namespace that owns the target, or you must be operating entirely inside a user namespace where you have that capability.

Kernel version check

These syscalls require Linux 5.2 at minimum. OPEN_TREE_CLONE behaving correctly with propagated subtrees was fixed across 5.2-5.4, so for production use treat 5.4 LTS as the floor. Check with:

uname -r

If you are running FreeBSD and are reading this out of curiosity, nmount(2) has had a similar structured-options interface since 5.0 (2003). The BSDs got there first, as is traditional.


The SCM_RIGHTS post covers passing those detached mount fds to a helper process over a socket. The openat2(2) post covers the path resolution flags that complement where you point these mounts. If you are building a full sandbox, landlock(2) is the unprivileged layer that restricts what paths a process can access within whatever mount namespace you built.