Skip to content

从软件包到RootFS镜像

定义完软件包后,无需做更多修改,直接 nix run .#rootfs-${target} 即可构建 RootFS。

生成 Docker 镜像

我们利用 pkgs.dockerTools.buildImage 的展平和拷贝属性,将 user/apps 下的软件包,以及 user/sysconfig 中的配置文件复制到单层 overlayfs 中(Docker原理),buildImage 的终产物是一个可以被 docker import 的一个 tar.gz。见 rootfs-tar.nix

nix
{
  lib,
  pkgs,
  nixpkgs,
  system,
  target,
  fenix,
  testOpt,
}:

# 产物是一个可以生成 rootfs.tar 的脚本
let
  apps = import ./apps {
    inherit
      lib
      pkgs
      nixpkgs
      system
      target
      fenix
      testOpt
      ;
  };

  sys-config =
    pkgs.runCommand "sysconfig"
      {
        src = ./sysconfig;
      }
      ''
        mkdir -p $out $out/root
        cp -r $src/* $out/
      '';

  # 使用 buildImage 创建 Docker 镜像(单层)
  # 直接返回 dockerImage,解压逻辑在 default.nix 中处理
  dockerImage = pkgs.dockerTools.buildImage {
    name = "busybox-rootfs";
    copyToRoot = [
      sys-config
    ]
    ++ apps;
    keepContentsDirlinks = false;
  };

in
dockerImage

解压 Docker tar 并生成文件系统镜像

原理:解压 docker 镜像,拿出第一层中的 layer.tar,然后使用 guestfish 导入到虚拟镜像文件中。见 user/default.nix

shell
{
  lib,
  pkgs,
  nixpkgs,
  system,
  target,
  fenix,
  buildDir,
  testOpt,
  rootfsType ? "vfat",
  diskPath,
  partitionType ? "mbr",
}:

let
  image = import ./rootfs-tar.nix {
    inherit
      lib
      pkgs
      nixpkgs
      system
      target
      fenix
      testOpt
      ;
  };

  # parted 使用 msdos 而不是 mbr
  partedLabel = if partitionType == "mbr" then "msdos" else partitionType;

  # 根据文件系统类型生成 mkfs 命令
  mkfsCommand =
    if rootfsType == "vfat" then
      ''sudo mkfs.vfat "''${LOOP_DEV}p1"''
    else if rootfsType == "ext4" then
      ''sudo mkfs.ext4 -F "''${LOOP_DEV}p1"''
    else
      ''sudo mkfs -t ${rootfsType} "''${LOOP_DEV}p1"'';

  # vfat 特殊处理脚本片段
  vfatProcessing = ''
    echo "  Processing rootfs for vfat (excluding /nix/store, dereferencing symlinks)..."

    EXTRACT_DIR=$(mktemp -d)
    FILTERED_TAR="${buildDir}/rootfs-filtered.tar"

    # 解压原始 tar,排除 /nix/store
    echo "    Extracting and not filtering..."
    chmod +w -R "$TEMP_DIR" "$EXTRACT_DIR"
    fakeroot tar --owner=0 --group=0 --numeric-owner --exclude='proc' --exclude='dev' \
        --exclude='sys' -xf "$OUTPUT_TAR" -C "$EXTRACT_DIR"

    # 重新打包,解引用符号链接和硬链接
    echo "    Re-packing with dereferenced links..."
    fakeroot tar --owner=0 --group=0 --numeric-owner --dereference --hard-dereference -cf "$FILTERED_TAR" -C "$EXTRACT_DIR" .

    FILTERED_SIZE=$(du -h "$FILTERED_TAR" | cut -f1)
    echo "  ✓ Re-packed rootfs.tar created ($FILTERED_SIZE)"

    FINAL_TAR="$FILTERED_TAR"
  '';

  # 非 vfat 不需要特殊处理
  nonVfatProcessing = ''
    FINAL_TAR="$OUTPUT_TAR"
  '';

  # 根据 rootfsType 选择处理逻辑
  rootfsProcessing = if rootfsType == "vfat" then vfatProcessing else nonVfatProcessing;

  # guestfish 写盘逻辑
  guestfishWrite = ''
    echo "  Using guestfish (unprivileged mode)..."
    export LIBGUESTFS_CACHEDIR=/tmp
    export LIBGUESTFS_BACKEND=direct

    # 使用 guestfish 创建分区并注入 tar
    echo "  Initializing disk and copying rootfs..."
    guestfish -a "$TEMP_IMG" <<EOF
      run
      part-init /dev/sda ${partitionType}
      part-add /dev/sda primary 2048 -2048
      mkfs ${rootfsType} /dev/sda1
      mount /dev/sda1 /
      tar-in $FINAL_TAR /
      chmod 0755 /
      umount /
      sync
      shutdown
    EOF
  '';

  # loop 设备写盘逻辑
  loopWrite = ''
    echo "  Using loop device (privileged mode, faster)..."

    # 使用 parted 创建分区表和分区
    echo "    Creating partition table..."
    parted -s "$TEMP_IMG" mklabel ${partedLabel}
    parted -s "$TEMP_IMG" mkpart primary ${rootfsType} 1MiB 100%

    # 设置 loop 设备
    echo "    Setting up loop device..."
    LOOP_DEV=$(sudo losetup --find --show --partscan "$TEMP_IMG")
    echo "    Loop device: $LOOP_DEV"

    # 确保清理 loop 设备
    # shellcheck disable=SC2317,SC2329
    cleanup_loop() {
      echo "    Cleaning up loop device..."
      sudo umount "''${LOOP_DEV}p1" 2>/dev/null || true
      sudo losetup -d "$LOOP_DEV" 2>/dev/null || true
    }
    trap 'cleanup_loop; chmod +w -R "$TEMP_DIR" 2>/dev/null && rm -rf "$TEMP_DIR"; [ -n "$EXTRACT_DIR" ] && chmod +w -R "$EXTRACT_DIR" 2>/dev/null && rm -rf "$EXTRACT_DIR"' EXIT

    # 等待分区设备出现
    echo "    Waiting for partition device..."
    for _ in $(seq 1 10); do
      if [ -b "''${LOOP_DEV}p1" ]; then
        break
      fi
      sleep 0.1
    done

    if [ ! -b "''${LOOP_DEV}p1" ]; then
      echo "Error: Partition device ''${LOOP_DEV}p1 not found"
      exit 1
    fi

    # 格式化分区
    echo "    Formatting partition as ${rootfsType}..."
    ${mkfsCommand}

    # 挂载分区
    MOUNT_DIR=$(mktemp -d)
    echo "    Mounting partition to $MOUNT_DIR..."
    sudo mount "''${LOOP_DEV}p1" "$MOUNT_DIR"

    # 更新 trap 以包含 MOUNT_DIR
    # shellcheck disable=SC2317,SC2329
    cleanup_loop() {
      echo "    Cleaning up..."
      sudo umount "$MOUNT_DIR" 2>/dev/null || true
      sudo losetup -d "$LOOP_DEV" 2>/dev/null || true
      rm -rf "$MOUNT_DIR" 2>/dev/null || true
    }
    trap 'cleanup_loop; chmod +w -R "$TEMP_DIR" 2>/dev/null && rm -rf "$TEMP_DIR"; [ -n "$EXTRACT_DIR" ] && chmod +w -R "$EXTRACT_DIR" 2>/dev/null && rm -rf "$EXTRACT_DIR"' EXIT

    # 解压 tar 到分区
    echo "    Extracting rootfs to partition..."
    sudo tar -xf "$FINAL_TAR" -C "$MOUNT_DIR"
    sudo chmod 0755 "$MOUNT_DIR"

    # 同步并卸载
    echo "    Syncing and unmounting..."
    sync
    sudo umount "$MOUNT_DIR"
    sudo losetup -d "$LOOP_DEV"
    rm -rf "$MOUNT_DIR"

    # 重置 trap
    trap 'chmod +w -R "$TEMP_DIR" 2>/dev/null && rm -rf "$TEMP_DIR"; [ -n "$EXTRACT_DIR" ] && chmod +w -R "$EXTRACT_DIR" 2>/dev/null && rm -rf "$EXTRACT_DIR"' EXIT
  '';

  # 构建脚本 - 在bin/目录下构建
  buildScript = pkgs.writeShellApplication {
    name = "dragonos-rootfs";
    runtimeInputs = [
      pkgs.coreutils
      pkgs.gnutar
      pkgs.libguestfs-with-appliance
      pkgs.findutils
      pkgs.parted
      pkgs.dosfstools
      pkgs.e2fsprogs
      pkgs.util-linux
    ];
    text = ''
      set -euo pipefail

      # Ensure build directory exists
      mkdir -p "${buildDir}"

      OUTPUT_TAR="${buildDir}/rootfs.tar"

      echo "==> Generating rootfs"

      # 创建临时目录
      TEMP_DIR=$(mktemp -d)
      EXTRACT_DIR=""  # 初始化为空,vfat 处理时会赋值
      trap 'chmod +w -R "$TEMP_DIR" 2>/dev/null && rm -rf "$TEMP_DIR"; [ -n "$EXTRACT_DIR" ] && chmod +w -R "$EXTRACT_DIR" 2>/dev/null && rm -rf "$EXTRACT_DIR"' EXIT

      # 提取 layer.tar (rootfs)
      echo "  Extracting rootfs layer..."
      cd "$TEMP_DIR"
      tar -xzf ${image}

      # 找到 layer.tar 并复制到 bin/
      LAYER_TAR=$(find . -name "layer.tar" | head -1)
      if [ -z "$LAYER_TAR" ]; then
        echo "Error: layer.tar not found in docker image"
        exit 1
      fi

      cp "$LAYER_TAR" "$OLDPWD/$OUTPUT_TAR"
      cd "$OLDPWD"
      chmod +w "$OUTPUT_TAR"

      TAR_SIZE=$(du -h "$OUTPUT_TAR" | cut -f1)
      echo "  ✓ rootfs.tar created ($TAR_SIZE)"

      # 根据文件系统类型处理 rootfs(Nix 编译时决定)
      ${rootfsProcessing}

      echo "==> Building disk image at ${diskPath}"

      # 创建磁盘镜像并初始化文件系统
      echo "  Creating disk image..."
      TEMP_IMG="${diskPath}.tmp"

      # 计算所需磁盘大小:tar包大小 + 1G 缓冲空间
      TAR_SIZE_KB=$(du -k "$FINAL_TAR" | cut -f1)
      DISK_SIZE_KB=$(( TAR_SIZE_KB + 1024 * 1024 ))
      truncate -s "''${DISK_SIZE_KB}K" "$TEMP_IMG"

      # 检查是否使用非特权构建模式(guestfish)
      if [ "''${DRAGONOS_UNPRIVILEGED_BUILD:-0}" = "1" ]; then
        ${guestfishWrite}
      else
        ${loopWrite}
      fi

      mv -f "$TEMP_IMG" "${diskPath}"

      IMG_SIZE=$(du -h "${diskPath}" | cut -f1)
      echo "  ✓ disk image created ($IMG_SIZE)"

      echo "==> Build complete!"
      echo "    Rootfs tar: $OUTPUT_TAR"
      echo "    Disk image: ${diskPath}"
    '';
  };

in
buildScript

nix script 的背后

生成 Docker 镜像时,Docker Image 是作为二进制产物缓存在 /nix/store 中的,因此多次构建可能会快速占用硬盘。

hint: 要想释放磁盘空间,执行 nix store gc

但第二步:解压 Docker tar 到文件系统磁盘镜像文件,是以生成一个 shell script 并运行来实现的,生成的 disk-image-${target}.img 只会存在一个在 bin 目录下;同时从 .tar.gz 中解压出来的 rootfs.tar 也会存在于 bin 目录下,你可以自己解压看看里面都存了些啥。

TODO: 后续脚本允许分两步进行,中间允许注入自定义文件(除了直接在 sysconfig 中注入外)

如果你想看看这个脚本长什么样,你可以在项目根目录执行 nix build .#rootfs-${target} -o bin/result 。 nix 会把 script 对应的 derivation 链接到 result 目录下,只需 cat bin/result/bin/build-rootfs-image 就能看到全部命令。