From: Daan De Meyer Date: Fri, 5 May 2023 06:57:10 +0000 (+0200) Subject: Introduce --use-subvolumes X-Git-Tag: v15~179^2~2 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=7db8a93f970ddbb532d6d0b6123e25d5924fbcef;p=thirdparty%2Fmkosi.git Introduce --use-subvolumes Let's optionally use subvolumes to speed up copies when incremental builds or base trees are used. --- diff --git a/mkosi.md b/mkosi.md index a8e8c2b1a..dc53154b3 100644 --- a/mkosi.md +++ b/mkosi.md @@ -433,7 +433,7 @@ a boolean argument: either "1", "yes", or "true" to enable, or "0", `Bootable=`, `--bootable=` -: Takes a boolean or `auto`. Enables or disable generating of a bootable +: Takes a boolean or `auto`. Enables or disables generation of a bootable image. If enabled, mkosi will install systemd-boot, run kernel-install, generate unified kernel images for installed kernels and add an ESP partition when the disk image output is used. If systemd-boot is not @@ -444,6 +444,16 @@ a boolean argument: either "1", "yes", or "true" to enable, or "0", executed, no unified kernel images will be generated and no ESP partition will be added to the image if the disk output format is used. +`UseSubvolumes=`, `--use-subvolumes=` + +: Takes a boolean or `auto`. Enables or disables use of btrfs subvolumes for + directory tree outputs. If enabled, mkosi will create the root directory as + a btrfs subvolume and use btrfs subvolume snapshots where possible to copy + base or cached trees which is much faster than doing a recursive copy. If + explicitly enabled and `btrfs` is not installed or subvolumes cannot be + created, an error is raised. If `auto`, missing `btrfs` or failures to + create subvolumes are ignored. + `KernelCommandLine=`, `--kernel-command-line=` : Use the specified kernel command line when building images. By default diff --git a/mkosi/__init__.py b/mkosi/__init__.py index c9e0c2f67..100979262 100644 --- a/mkosi/__init__.py +++ b/mkosi/__init__.py @@ -22,6 +22,7 @@ from pathlib import Path from textwrap import dedent from typing import Callable, ContextManager, Optional, TextIO, TypeVar, Union, cast +from mkosi.btrfs import btrfs_maybe_snapshot_subvolume from mkosi.config import ( ConfigFeature, GenericVersion, @@ -349,16 +350,22 @@ def configure_autologin(state: MkosiState) -> None: @contextlib.contextmanager def mount_cache_overlay(state: MkosiState) -> Iterator[None]: - if not state.config.incremental: + if not state.config.incremental or not any(state.root.iterdir()): yield return - with mount_overlay([state.root], state.cache_overlay, state.workdir, state.root, read_only=False): + d = state.workspace / "cache-overlay" + d.mkdir(mode=0o755, exist_ok=True) + + with mount_overlay([state.root], d, state.workdir, state.root, read_only=False): yield def mount_build_overlay(state: MkosiState, read_only: bool = False) -> ContextManager[Path]: - return mount_overlay([state.root], state.build_overlay, state.workdir, state.root, read_only) + d = state.workspace / "build-overlay" + if not d.is_symlink(): + d.mkdir(mode=0o755, exist_ok=True) + return mount_overlay([state.root], state.workspace.joinpath("build-overlay"), state.workdir, state.root, read_only) def run_prepare_script(state: MkosiState, build: bool) -> None: @@ -491,11 +498,16 @@ def install_base_trees(state: MkosiState) -> None: if not state.config.base_trees or state.config.overlay: return + # We only do this step once, even if we're doing cached images. If we're doing a cached image on top of + # base trees, the cached image is an overlay on top of the base trees, so the unpacked base trees are + # guaranteed to still be pristine when we run the second time to generate the final image. + if any(state.root.iterdir()): + return + with complete_step("Copying in base trees…"): for path in state.config.base_trees: - if path.is_dir(): - copy_path(path, state.root) + btrfs_maybe_snapshot_subvolume(state.config, path, state.root) elif path.suffix == ".tar": shutil.unpack_archive(path, state.root) elif path.suffix == ".raw": @@ -889,11 +901,17 @@ def save_cache(state: MkosiState) -> None: with complete_step("Installing cache copies"): unlink_try_hard(final) - shutil.move(state.cache_overlay, final) + + # We only use the cache-overlay directory for caching if we have a base tree, otherwise we just + # cache the root directory. + if state.workspace.joinpath("cache-overlay").exists(): + shutil.move(state.workspace / "cache-overlay", final) + else: + shutil.move(state.root, final) if state.config.build_script: unlink_try_hard(build) - shutil.move(state.build_overlay, build) + shutil.move(state.workspace / "build-overlay", build) def dir_size(path: Union[Path, os.DirEntry[str]]) -> int: @@ -1328,10 +1346,9 @@ def reuse_cache_tree(state: MkosiState) -> bool: return False with complete_step("Copying cached trees"): - copy_path(final, state.root) + btrfs_maybe_snapshot_subvolume(state.config, final, state.root) if state.config.build_script: - state.build_overlay.rmdir() - state.build_overlay.symlink_to(build) + state.workspace.joinpath("build-overlay").symlink_to(build) return True diff --git a/mkosi/btrfs.py b/mkosi/btrfs.py new file mode 100644 index 000000000..b34b44752 --- /dev/null +++ b/mkosi/btrfs.py @@ -0,0 +1,41 @@ +import shutil +from pathlib import Path + +from mkosi.config import ConfigFeature, MkosiConfig +from mkosi.install import copy_path +from mkosi.log import die +from mkosi.run import run + + +def btrfs_maybe_make_subvolume(config: MkosiConfig, path: Path, mode: int) -> None: + if config.use_subvolumes == ConfigFeature.enabled and not shutil.which("btrfs"): + die("Subvolumes requested but the btrfs command was not found") + + if config.use_subvolumes != ConfigFeature.disabled: + result = run(["btrfs", "subvolume", "create", path], check=config.use_subvolumes == ConfigFeature.enabled).returncode + else: + result = 1 + + if result == 0: + path.chmod(mode) + else: + path.mkdir(mode) + + +def btrfs_maybe_snapshot_subvolume(config: MkosiConfig, src: Path, dst: Path) -> None: + subvolume = (config.use_subvolumes == ConfigFeature.enabled or + config.use_subvolumes == ConfigFeature.auto and shutil.which("btrfs") is not None) + + if config.use_subvolumes == ConfigFeature.enabled and not shutil.which("btrfs"): + die("Subvolumes requested but the btrfs command was not found") + + # Subvolumes always have inode 256 so we can use that to check if a directory is a subvolume. + if not subvolume or src.stat().st_ino != 256 or (dst.exists() and any(dst.iterdir())): + return copy_path(src, dst) + + # btrfs can't snapshot to an existing directory so make sure the destination does not exist. + if dst.exists(): + dst.rmdir() + + run(["btrfs", "subvolume", "snapshot", src, dst], + check=config.use_subvolumes == ConfigFeature.enabled) diff --git a/mkosi/config.py b/mkosi/config.py index 49e231873..9bc982dbc 100644 --- a/mkosi/config.py +++ b/mkosi/config.py @@ -605,6 +605,7 @@ class MkosiConfig: kernel_command_line_extra: list[str] acl: bool bootable: ConfigFeature + use_subvolumes: ConfigFeature # QEMU-specific options qemu_gui: bool @@ -864,6 +865,11 @@ class MkosiConfigParser: section="Output", parse=config_parse_boolean, ), + MkosiConfigSetting( + dest="use_subvolumes", + section="Output", + parse=config_parse_feature, + ), MkosiConfigSetting( dest="packages", section="Content", @@ -1441,6 +1447,13 @@ class MkosiConfigParser: nargs="?", action=action, ) + group.add_argument( + "--use-subvolumes", + metavar="FEATURE", + help="Use btrfs subvolumes for faster directory operations where possible", + nargs="?", + action=action, + ) group = parser.add_argument_group("Content options") group.add_argument( diff --git a/mkosi/state.py b/mkosi/state.py index 8d6df09d4..fa7c2b855 100644 --- a/mkosi/state.py +++ b/mkosi/state.py @@ -4,6 +4,7 @@ import dataclasses import importlib from pathlib import Path +from mkosi.btrfs import btrfs_maybe_make_subvolume from mkosi.config import MkosiConfig from mkosi.distributions import DistributionInstaller from mkosi.log import die @@ -38,24 +39,14 @@ class MkosiState: die("No installer for this distribution.") self.installer = instance - self.root.mkdir(exist_ok=True, mode=0o755) - self.build_overlay.mkdir(exist_ok=True, mode=0o755) - self.cache_overlay.mkdir(exist_ok=True, mode=0o755) - self.workdir.mkdir(exist_ok=True) - self.staging.mkdir(exist_ok=True) + btrfs_maybe_make_subvolume(self.config, self.root, mode=0o755) + self.workdir.mkdir() + self.staging.mkdir() @property def root(self) -> Path: return self.workspace / "root" - @property - def cache_overlay(self) -> Path: - return self.workspace / "cache-overlay" - - @property - def build_overlay(self) -> Path: - return self.workspace / "build-overlay" - @property def workdir(self) -> Path: return self.workspace / "workdir"