From 96dd2df514a336221bfe02422b4bc75a350b7e9f Mon Sep 17 00:00:00 2001 From: Michael Vogt Date: Sun, 1 Feb 2026 14:03:22 +0100 Subject: [PATCH 1/2] nspawn: extract snapshot creation into new shared helper This commit extracts the creation of the snapshot for nspawn into a shared helper. Its not a lot of code but its subtle so having a single place seems beneficial. While extracting the snapshot code got tweaked/simplified based on the feedback from Yu (many thanks!). With that we can simplify the snapshot delete and error handling as well. The shared helper is then going to be used by vmspawn when it also gets the --ephemeral option. --- src/nspawn/nspawn.c | 68 ++++++++------------------------------ src/shared/meson.build | 1 + src/shared/snapshot-util.c | 65 ++++++++++++++++++++++++++++++++++++ src/shared/snapshot-util.h | 21 ++++++++++++ 4 files changed, 100 insertions(+), 55 deletions(-) create mode 100644 src/shared/snapshot-util.c create mode 100644 src/shared/snapshot-util.h diff --git a/src/nspawn/nspawn.c b/src/nspawn/nspawn.c index 6cfc0e7395..a010245da4 100644 --- a/src/nspawn/nspawn.c +++ b/src/nspawn/nspawn.c @@ -107,6 +107,7 @@ #include "shift-uid.h" #include "signal-util.h" #include "siphash24.h" +#include "snapshot-util.h" #include "socket-util.h" #include "stat-util.h" #include "stdio-util.h" @@ -5915,7 +5916,7 @@ static int do_cleanup(void) { } static int run(int argc, char *argv[]) { - bool remove_directory = false, remove_image = false, veth_created = false; + bool remove_image = false, veth_created = false; _cleanup_close_ int master = -EBADF, userns_fd = -EBADF, mount_fd = -EBADF; _cleanup_fdset_free_ FDSet *fds = NULL; int r, ret = EXIT_SUCCESS; @@ -5923,6 +5924,7 @@ static int run(int argc, char *argv[]) { struct ExposeArgs expose_args = {}; _cleanup_(release_lock_file) LockFile tree_global_lock = LOCK_FILE_INIT, tree_local_lock = LOCK_FILE_INIT; _cleanup_(rmdir_and_freep) char *rootdir = NULL; + _cleanup_(rm_rf_subvolume_and_freep) char *snapshot_dir = NULL; _cleanup_(loop_device_unrefp) LoopDevice *loop = NULL; _cleanup_(dissected_image_unrefp) DissectedImage *dissected_image = NULL; _cleanup_(sd_netlink_unrefp) sd_netlink *nfnl = NULL; @@ -6064,63 +6066,27 @@ static int run(int argc, char *argv[]) { } if (arg_ephemeral) { - _cleanup_free_ char *np = NULL; - r = chase_and_update(&arg_directory, 0); if (r < 0) goto finish; - /* If the specified path is a mount point we generate the new snapshot immediately - * inside it under a random name. However if the specified is not a mount point we - * create the new snapshot in the parent directory, just next to it. */ - r = path_is_mount_point(arg_directory); - if (r < 0) { - log_error_errno(r, "Failed to determine whether directory %s is mount point: %m", arg_directory); - goto finish; - } - if (r > 0) - r = tempfn_random_child(arg_directory, "machine.", &np); - else - r = tempfn_random(arg_directory, "machine.", &np); - if (r < 0) { - log_error_errno(r, "Failed to generate name for directory snapshot: %m"); - goto finish; - } - - /* We take an exclusive lock on this image, since it's our private, ephemeral copy - * only owned by us and no one else. */ - r = image_path_lock( + r = create_ephemeral_snapshot( + arg_directory, arg_privileged ? RUNTIME_SCOPE_SYSTEM : RUNTIME_SCOPE_USER, - np, - LOCK_EX|LOCK_NB, - arg_privileged ? &tree_global_lock : NULL, - &tree_local_lock); + arg_read_only, + &tree_global_lock, + &tree_local_lock, + &snapshot_dir); if (r < 0) { - log_error_errno(r, "Failed to lock %s: %m", np); + log_error_errno(r, "Failed to create ephemeral snapshot: %m"); goto finish; } - { - BLOCK_SIGNALS(SIGINT); - r = btrfs_subvol_snapshot_at(AT_FDCWD, arg_directory, AT_FDCWD, np, - (arg_read_only ? BTRFS_SNAPSHOT_READ_ONLY : 0) | - BTRFS_SNAPSHOT_FALLBACK_COPY | - BTRFS_SNAPSHOT_FALLBACK_DIRECTORY | - BTRFS_SNAPSHOT_RECURSIVE | - BTRFS_SNAPSHOT_QUOTA | - BTRFS_SNAPSHOT_SIGINT); - } - if (r == -EINTR) { - log_error_errno(r, "Interrupted while copying file system tree to %s, removed again.", np); + arg_directory = strdup(snapshot_dir); + if (!arg_directory) { + log_oom(); goto finish; } - if (r < 0) { - log_error_errno(r, "Failed to create snapshot %s from %s: %m", np, arg_directory); - goto finish; - } - - free_and_replace(arg_directory, np); - remove_directory = true; } else { r = chase_and_update(&arg_directory, arg_template ? CHASE_NONEXISTENT : 0); if (r < 0) @@ -6471,14 +6437,6 @@ finish: pager_close(); - if (remove_directory && arg_directory) { - int k; - - k = rm_rf(arg_directory, REMOVE_ROOT|REMOVE_PHYSICAL|REMOVE_SUBVOLUME); - if (k < 0) - log_warning_errno(k, "Cannot remove '%s', ignoring: %m", arg_directory); - } - if (remove_image && arg_image) { if (unlink(arg_image) < 0) log_warning_errno(errno, "Can't remove image file '%s', ignoring: %m", arg_image); diff --git a/src/shared/meson.build b/src/shared/meson.build index 9fa976925a..87802469de 100644 --- a/src/shared/meson.build +++ b/src/shared/meson.build @@ -177,6 +177,7 @@ shared_sources = files( 'sleep-config.c', 'smack-util.c', 'smbios11.c', + 'snapshot-util.c', 'socket-label.c', 'socket-netlink.c', 'specifier.c', diff --git a/src/shared/snapshot-util.c b/src/shared/snapshot-util.c new file mode 100644 index 0000000000..a05cbcc8ea --- /dev/null +++ b/src/shared/snapshot-util.c @@ -0,0 +1,65 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include + +#include "alloc-util.h" +#include "btrfs-util.h" +#include "discover-image.h" +#include "log.h" +#include "mountpoint-util.h" +#include "snapshot-util.h" +#include "signal-util.h" +#include "tmpfile-util.h" + +int create_ephemeral_snapshot( + const char *directory, + RuntimeScope scope, + bool read_only, + LockFile *tree_global_lock, + LockFile *tree_local_lock, + char **ret_new_path) { + + _cleanup_free_ char *np = NULL; + int r; + + /* If the specified path is a mount point we generate the new snapshot immediately + * inside it under a random name. However if the specified is not a mount point we + * create the new snapshot in the parent directory, just next to it. */ + r = path_is_mount_point(directory); + if (r < 0) + return log_debug_errno(r, "Failed to determine whether directory %s is mount point: %m", directory); + if (r > 0) + r = tempfn_random_child(directory, "snapshot.", &np); + else + r = tempfn_random(directory, "snapshot.", &np); + if (r < 0) + return log_debug_errno(r, "Failed to generate name for directory snapshot: %m"); + + /* We take an exclusive lock on this image, since it's our private, ephemeral copy + * only owned by us and no one else. */ + r = image_path_lock( + scope, + np, + LOCK_EX|LOCK_NB, + scope == RUNTIME_SCOPE_SYSTEM ? tree_global_lock : NULL, + tree_local_lock); + if (r < 0) + return log_debug_errno(r, "Failed to lock %s: %m", np); + + { + BLOCK_SIGNALS(SIGINT); + r = btrfs_subvol_snapshot_at(AT_FDCWD, directory, AT_FDCWD, np, + (read_only ? BTRFS_SNAPSHOT_READ_ONLY : 0) | + BTRFS_SNAPSHOT_FALLBACK_COPY | + BTRFS_SNAPSHOT_FALLBACK_DIRECTORY | + BTRFS_SNAPSHOT_RECURSIVE | + BTRFS_SNAPSHOT_QUOTA | + BTRFS_SNAPSHOT_SIGINT); + } + if (r < 0) + return log_debug_errno(r, "Failed to create snapshot %s from %s: %m", np, directory); + + *ret_new_path = TAKE_PTR(np); + + return 0; +} diff --git a/src/shared/snapshot-util.h b/src/shared/snapshot-util.h new file mode 100644 index 0000000000..18a593f5c8 --- /dev/null +++ b/src/shared/snapshot-util.h @@ -0,0 +1,21 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "runtime-scope.h" + +/* create_ephemeral_snapshot - create a snapshot of the given directory. + * + * It will use a btrfs snapshot when available with fallback to traditional dir copy. It will set the global + * and local lock files based on the passed runtime scope. On success the new directory path is returned via + * `ret_new_path`. + * + * The caller is responsible for the cleanup of the directory, using `_cleanup_(rm_rf_subvolume_and_freep)` + * is recommended. + */ +int create_ephemeral_snapshot( + const char *directory, + RuntimeScope scope, + bool read_only, + LockFile *tree_global_lock, + LockFile *tree_local_lock, + char **ret_new_path); From 6e67fc2938ad690a065a984c5813c6b5c0e48806 Mon Sep 17 00:00:00 2001 From: Michael Vogt Date: Sun, 1 Feb 2026 14:07:40 +0100 Subject: [PATCH 2/2] vmspawn: add --ephemeral option similar to nspawn This patch adds a `--ephemeral` option to vmspawn that will turn on snapshot mode on the qemu disk passed via `--image` or the directory passed via `--directory`. For disk images it uses the native mechanism that qemu provides. For directories it (re)uses the snapshot mechanism that nspawn is using, i.e. it will create a btrfs snapshot if possible and if not falls back to a traditional directory copy. --- man/systemd-vmspawn.xml | 11 ++++++++++ src/nspawn/nspawn.c | 4 ++-- src/vmspawn/vmspawn-settings.h | 1 + src/vmspawn/vmspawn.c | 38 ++++++++++++++++++++++++++++++++-- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/man/systemd-vmspawn.xml b/man/systemd-vmspawn.xml index eb0d4bff76..dc56c59058 100644 --- a/man/systemd-vmspawn.xml +++ b/man/systemd-vmspawn.xml @@ -125,6 +125,17 @@ + + + + + If specified, the VM is run with a temporary snapshot of its file system that is removed + immediately when the VM terminates. Only works with currently. + + Note that will not work with . + + + diff --git a/src/nspawn/nspawn.c b/src/nspawn/nspawn.c index a010245da4..d2adc64931 100644 --- a/src/nspawn/nspawn.c +++ b/src/nspawn/nspawn.c @@ -6082,8 +6082,8 @@ static int run(int argc, char *argv[]) { goto finish; } - arg_directory = strdup(snapshot_dir); - if (!arg_directory) { + r = free_and_strdup(&arg_directory, snapshot_dir); + if (r < 0) { log_oom(); goto finish; } diff --git a/src/vmspawn/vmspawn-settings.h b/src/vmspawn/vmspawn-settings.h index 1cfe4ffd72..ee937c993a 100644 --- a/src/vmspawn/vmspawn-settings.h +++ b/src/vmspawn/vmspawn-settings.h @@ -35,6 +35,7 @@ typedef enum SettingsMask { SETTING_START_MODE = UINT64_C(1) << 0, SETTING_MACHINE_ID = UINT64_C(1) << 6, SETTING_BIND_MOUNTS = UINT64_C(1) << 11, + SETTING_EPHEMERAL = UINT64_C(1) << 24, SETTING_DIRECTORY = UINT64_C(1) << 26, SETTING_CREDENTIALS = UINT64_C(1) << 30, _SETTING_FORCE_ENUM_WIDTH = UINT64_MAX diff --git a/src/vmspawn/vmspawn.c b/src/vmspawn/vmspawn.c index 06f6961b1a..7f58f62b04 100644 --- a/src/vmspawn/vmspawn.c +++ b/src/vmspawn/vmspawn.c @@ -62,6 +62,7 @@ #include "random-util.h" #include "rm-rf.h" #include "signal-util.h" +#include "snapshot-util.h" #include "socket-util.h" #include "stat-util.h" #include "stdio-util.h" @@ -145,6 +146,7 @@ static char **arg_bind_user = NULL; static char *arg_bind_user_shell = NULL; static bool arg_bind_user_shell_copy = false; static char **arg_bind_user_groups = NULL; +static bool arg_ephemeral = false; static RuntimeScope arg_runtime_scope = _RUNTIME_SCOPE_INVALID; STATIC_DESTRUCTOR_REGISTER(arg_directory, freep); @@ -190,6 +192,7 @@ static int help(void) { " --system Interact with system manager\n" "\n%3$sImage:%4$s\n" " -D --directory=PATH Root directory for the VM\n" + " -x --ephemeral Run VM with snapshot of the disk or directory\n" " -i --image=FILE|DEVICE Root file system disk image or device for the VM\n" " --image-format=FORMAT Specify disk image format (raw, qcow2; default: raw)\n" "\n%3$sHost Configuration:%4$s\n" @@ -327,6 +330,7 @@ static int parse_argv(int argc, char *argv[]) { { "no-pager", no_argument, NULL, ARG_NO_PAGER }, { "image", required_argument, NULL, 'i' }, { "image-format", required_argument, NULL, ARG_IMAGE_FORMAT }, + { "ephemeral", no_argument, NULL, 'x' }, { "directory", required_argument, NULL, 'D' }, { "machine", required_argument, NULL, 'M' }, { "slice", required_argument, NULL, 'S' }, @@ -382,7 +386,7 @@ static int parse_argv(int argc, char *argv[]) { assert(argv); optind = 0; - while ((c = getopt_long(argc, argv, "+hD:i:M:nqs:G:S:", options, NULL)) >= 0) + while ((c = getopt_long(argc, argv, "+hD:i:xM:nqs:G:S:", options, NULL)) >= 0) switch (c) { case 'h': return help(); @@ -429,6 +433,10 @@ static int parse_argv(int argc, char *argv[]) { } break; + case 'x': + arg_ephemeral = true; + break; + case ARG_NO_PAGER: arg_pager_flags |= PAGER_DISABLE; break; @@ -794,6 +802,9 @@ static int parse_argv(int argc, char *argv[]) { if (!strv_isempty(arg_bind_user_groups) && strv_isempty(arg_bind_user)) return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Cannot use --bind-user-group= without --bind-user="); + if (arg_ephemeral && arg_extra_drives.n_drives > 0) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Cannot use --ephemeral with --extra-drive="); + if (argc > optind) { arg_kernel_cmdline_extra = strv_copy(argv + optind); if (!arg_kernel_cmdline_extra) @@ -1843,6 +1854,8 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { _cleanup_(ovmf_config_freep) OvmfConfig *ovmf_config = NULL; _cleanup_free_ char *qemu_binary = NULL, *mem = NULL, *kernel = NULL; _cleanup_(rm_rf_physical_and_freep) char *ssh_private_key_path = NULL, *ssh_public_key_path = NULL; + _cleanup_(rm_rf_subvolume_and_freep) char *snapshot_directory = NULL; + _cleanup_(release_lock_file) LockFile tree_global_lock = LOCK_FILE_INIT, tree_local_lock = LOCK_FILE_INIT; _cleanup_close_ int notify_sock_fd = -EBADF; _cleanup_strv_free_ char **cmdline = NULL; _cleanup_free_ int *pass_fds = NULL; @@ -2319,7 +2332,8 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { if (!escaped_image) return log_oom(); - if (strv_extendf(&cmdline, "if=none,id=vmspawn,file=%s,format=%s,discard=%s", escaped_image, image_format_to_string(arg_image_format), on_off(arg_discard_disk)) < 0) + if (strv_extendf(&cmdline, "if=none,id=vmspawn,file=%s,format=%s,discard=%s,snapshot=%s", + escaped_image, image_format_to_string(arg_image_format), on_off(arg_discard_disk), on_off(arg_ephemeral)) < 0) return log_oom(); _cleanup_free_ char *image_fn = NULL; @@ -2366,6 +2380,21 @@ static int run_virtual_machine(int kvm_device_fd, int vhost_device_fd) { if (!GREEDY_REALLOC(children, n_children + 1)) return log_oom(); + if (arg_ephemeral) { + r = create_ephemeral_snapshot(arg_directory, + arg_runtime_scope, + /* read-only */ false, + &tree_global_lock, + &tree_local_lock, + &snapshot_directory); + if (r < 0) + return r; + + arg_directory = strdup(snapshot_directory); + if (!arg_directory) + return log_oom(); + } + r = start_virtiofsd( unit, arg_directory, @@ -3063,6 +3092,11 @@ static int determine_names(void) { if (r < 0) return log_error_errno(r, "Failed to extract file name from '%s': %m", arg_directory); } + /* Add a random suffix when this is an ephemeral machine, so that we can run many + * instances at once without manually having to specify -M each time. */ + if (arg_ephemeral) + if (strextendf(&arg_machine, "-%016" PRIx64, random_u64()) < 0) + return log_oom(); hostname_cleanup(arg_machine); if (!hostname_is_valid(arg_machine, 0))