Cooking and Baking Linux Distributions in Nix
nix (2.4+) due
to an implementation change. Take a
look at this
shell.nix
that uses a relaxed sandbox along with the __noChroot option.
The Linux distribution space
is vast and fragmented. It’s the wild wild west, and that’s all right in my
book. Having one distribution installed, to a lesser degree means having
Arch Linux to try out an idea,
while mixing in packages from NixOS to
compare or hack around. The process of
The terms “cook” and “bake” is a bit of jargon. In this context and for my purposes, “cooking” refers to the cherry picking of various binaries and files from multiple Linux distributions into a final system, and “baking” refers to the process of injecting a Linux kernel into a “cooked” distribution image for boot ability. The recipe for a “cake” — a cook and bake, can be composed in any preferred programming language or process.
Countless programs offer a way to cook or bake systems in some form.
LinuxKit,
Darch,
BitBake, and
BuildRoot are just a few.
Manual chroots (change root),
Toolbox, and
Distrobox also allow for fully
mutable distribution environments. Let’s use
Nix,
nix-shell,
and PRoot (user space chroot) to produce an
experimental environment that lets us cook and bake various distributions.
Nix Shell
The nix-shell command sets up an interactive shell environment according to
the specified nix code. A shell.nix file below creates an
nix-shell will have to do.
PS1 (Prompt String #1).
nix
let
name = "nix-shell";
pkgs = import <nixpkgs> { };
in pkgs.mkShell {
inherit name;
buildInputs = [ ];
shellHook = ''
export PS1='\h (${name}) \W \$ '
'';
}This shell.nix sets a name that describes the environment, buildInputs
that adds a list of programs to the new shell path, and a shellHook that runs
arbitrary commands after nix-shell invocation.
Executing nix-shell without a file path acts on the default.nix or
shell.nix in the current directory.
shell
$ nix-shellAdding --pure as an argument to nix-shell clears the majority of the
environment variables before entering the nix shell environment.
shell
$ nix-shell --pureThe --command argument can be used to further cleanup the environment
variables by specifying a shell of choice and the sourcing procedure.
shell
$ nix-shell --pure --command 'bash --login --norc --noprofile'Cooking
Now that the nix-shell summary is out of the way — let’s set up a basic
shell.nix that implements the cooking functionality. Using the previous
minimal shell.nix setup, the interactive environment is named, the pkgs
attribute locked to a specific version of nixpkgs, and a file system for
Alpine Linux
3.14. My stack rarely
uses docker, so that was a minor inconvenience.
3.12
root
file systems. You could also download an archive of the distribution directly.
pkgs.dockerTools.pullImage.
nix
let
name = "nix-shell.cake";
pkgs = import (builtins.fetchTarball {
url = "https://releases.nixos.org/nixos/21.05/nixos-21.05.650.eaba7870ffc/nixexprs.tar.xz";
sha256 = "08fpds1bkv9106c6s5w3p5r4v3dc24bhk9asm9vqbxxypjglqg9l";
}) { };
alpine-3-12-amd64 = pkgs.dockerTools.pullImage rec {
imageName = "alpine";
imageDigest = "sha256:2a8831c57b2e2cb2cda0f3a7c260d3b6c51ad04daea0b3bfc5b55f489ebafd71";
sha256 = "1px8xhk0a3b129cc98d3wm4s0g1z2mahnrxd648gkdbfsdj9dlxp";
finalImageName = imageName;
finalImageTag = "3.12";
};
in pkgs.mkShell {
inherit name;
buildInputs = [ ];
shellHook = ''
export PS1='\h (${name}) \W \$ '
'';
}The cook function creates a derivation that extracts a rootfs, passes it to
proot for bootstrapping, and cherry picks contents into the file system. The
nix sandbox defaults to a strict policy that prohibits network access so the
derivation’s hash mode is made recursive to allow network access inside the
proot. If sandboxing is nix.conf.
__noChroot option to true inside the derivation without the need
for a recursive hash. Most attributes defined in a derivation are exposed as
bash
nixpkgs source code —bash is everywhere. See,
The dark secret of nixpkgs.
nix
{
cook = { name, src, contents ? [ ], path ? [ ], script ? "", prepare ? "", cleanup ? "", sha256 ? pkgs.lib.fakeSha256 }: pkgs.stdenvNoCC.mkDerivation {
inherit name src contents;
phases = [ "unpackPhase" "installPhase" ];
buildInputs = [ pkgs.proot pkgs.rsync pkgs.tree pkgs.kmod ];
bootstrap = pkgs.writeScript "bootstrap-${name}" ''
${script}
rm "$0"
'';
installPhase = ''
set -euo pipefail
mkdir --parents rootfs $out/rootfs
tar --extract --file=layer.tar -C rootfs
${prepare}
cp $bootstrap rootfs/bootstrap
proot --cwd=/ --root-id --rootfs=rootfs /usr/bin/env - /bin/sh -euc '. /etc/profile && /bootstrap'
printf 'PATH=${pkgs.lib.strings.makeBinPath path}:$PATH' >> rootfs/etc/profile
[ -n "$contents" ] && {
printf "\n"
for paths in $contents; do
printf "Cooking... Adding %s \n" "$paths"
rsync --copy-dirlinks --relative --archive --chown=0:0 "$paths/" "rootfs" || exit 1
done
printf "\n"
} || printf '\n%s\n' 'No contents to cook.';
${cleanup}
printf '\n%s\n\n' "$(du --all --max-depth 1 --human-readable rootfs | sort --human-numeric-sort)"
cp -rT rootfs $out/rootfs
'';
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash = sha256;
};
}This soft abstraction allows us to minimally cook a distribution. Binaries are
added in from the nix store or from other distributions. The NixOS packages
glibc and awk are thrown into the mix just for fun.
GNU’s awk is added to the system path and serial
console ttyS0 activated for low level virtual machine access.
nix
{
alpine = cook {
name = "alpine";
src = alpine-3-12-amd64;
contents = [ pkgs.glibc pkgs.gawk ];
path = [ pkgs.gawk ];
script = ''
apk update
apk upgrade
apk add openrc
sed -i 's/#ttyS0/ttyS0/' /etc/inittab
printf 'migh7Lib\nmigh7Lib\n' | adduser alpine
'';
};
}The cooking sha256 is unknown until the shell completes the derivation because
running apk update breaks the immutability requirement. Once the system is
cooked to my liking, the generated hash is added to save the derivation as a
nix
store.
Nothing else matters.
This cooked alpine file system is good enough for useful work. Inside
pkgs.mkShell serve up the system by running proot from the shellHook. Pass
through selected host directories to the guest using the --bind argument with
proot. These host directories will /nix and /home may be convenient pass through
directories on the guest system.
nix
pkgs.mkShell {
inherit name;
buildInputs = [ pkgs.proot pkgs.qemu ];
shellHook = ''
export PS1='\h (${name}) \W \$ '
proot --cwd=/ --rootfs=${alpine}/rootfs --bind=/proc --bind=/dev /usr/bin/env - /bin/sh -c '. /etc/profile && sh'
exit
'';
}Take a look at
my cake.nix shell that creates this environment. You can
URL in a nix-shell.
nix-shell argument --expr on a raw format URL. Never
ever run code from a URL in your shell without first downloading and
auditing the code.
shell
nix-shell -E 'import (builtins.fetchurl "https://raw.githubusercontent.com/tdro/dotfiles/b9a9051a06d7bba2911c2ed0da0f2e73b4b5de81/.config/nixpkgs/shells/cake.nix")'Baking
The bake function begins by cooking the initrd
(initial ramdisk) image. The
initial RAM
(Random Access Memory)
disk contains the first root file system the kernel sees after
initialization. The simplest initrd file system (initramfs) my mind can come
up with is worked out in a cooking script. Kernel modules are prepared and
cooked before baking.
nix
{
bake = { name, image, size ? "1G", debug ? false, kernel ? pkgs.linux, options ? [ ], modules ? [ ], uuid ? "99999999-9999-9999-9999-999999999999", sha256 ? pkgs.lib.fakeSha256 }: let
initrd = cook {
inherit sha256;
name = "initrd-${name}";
src = alpine-3-12-amd64;
script = ''
rm -rf home opt media root run srv tmp var
printf '#!/bin/sh -eu
mount -t devtmpfs none /dev
mount -t proc none /proc
mount -t sysfs none /sys
sh /lib/modules/initrd/init
${pkgs.lib.optionalString (debug) "sh +m"}
mount -r "$(findfs UUID=${uuid})" /mnt
mount -o move /dev /mnt/dev
umount /proc /sys
exec switch_root /mnt /sbin/init
' > init
chmod +x init
find . ! -name bootstrap ! -name initramfs.cpio | cpio -H newc -ov > initramfs.cpio
gzip -9 initramfs.cpio
'';
prepare = ''
modules='${pkgs.lib.strings.concatMapStringsSep " " (module: module) modules}'
initrd_directory=rootfs/lib/modules/initrd
[ -n "$modules" ] && {
mkdir --parents "$initrd_directory"
printf "\n"
for module in $modules; do
module_file=$(find ${kernel} -name "$module.ko*" -type f)
module_basename=$(basename "$module_file")
printf "Cooking initrd... Adding module %s \n" "$module"
cp "$module_file" "$initrd_directory" || exit 1
printf 'insmod /lib/modules/initrd/%s\n' "$module_basename" >> "$initrd_directory/init"
done
} || printf '\n%s\n' 'No modules to cook.'
'';
}; in pkgs.writeScript name ''
# Baking Script
'';
}The script for the bake function creates a disk image, formats its partitions,
installs the syslinux bootloader,
and injects the kernel. Some commands are partially parameterized for more
control.
nix
{
pkgs.writeScript name ''
set -euo pipefail
PATH=${pkgs.lib.strings.makeBinPath [
pkgs.coreutils
pkgs.e2fsprogs
pkgs.gawk
pkgs.rsync
pkgs.syslinux
pkgs.tree
pkgs.utillinux
]
}
IMAGE=${name}.img
LOOP=/dev/loop0
ROOTFS=rootfs
rm "$IMAGE" || true
fallocate --length ${size} $IMAGE && chmod o+rw "$IMAGE"
printf 'o\nn\np\n1\n2048\n\na\nw\n' | fdisk "$IMAGE"
dd bs=440 count=1 conv=notrunc if=${pkgs.syslinux}/share/syslinux/mbr.bin of="$IMAGE"
mkdir --parents "$ROOTFS"
umount --verbose "$ROOTFS" || true
losetup --detach "$LOOP" || true
losetup --offset "$((2048 * 512))" "$LOOP" "$IMAGE"
mkfs.ext4 -U ${uuid} "$LOOP"
mount --verbose "$LOOP" "$ROOTFS"
rsync --archive --chown=0:0 "${image}/rootfs/" "$ROOTFS";
mkdir --parents "$ROOTFS/boot"
cp ${kernel}/bzImage "$ROOTFS/boot/vmlinux"
cp ${initrd}/rootfs/initramfs.cpio.gz "$ROOTFS/boot/initrd"
printf '
DEFAULT linux
LABEL linux
LINUX /boot/vmlinux
INITRD /boot/initrd
APPEND ${pkgs.lib.strings.concatMapStringsSep " " (option: option) options}
' > "$ROOTFS/boot/syslinux.cfg"
extlinux --heads 64 --sectors 32 --install $ROOTFS/boot
printf '\n%s\n\n' "$(du --max-depth 1 --human-readable $ROOTFS | sort --human-numeric-sort)"
umount --verbose "$ROOTFS"
rm -r "$ROOTFS"
losetup --detach "$LOOP"
'';
}The desired kernel version is injected, and kernel options for connecting
the virtual console tty1 and serial console ttyS0 are added. The modules are
baked in dependency order using a
minimal
modprobe with the
--show-depends argument on a live machine. These modules are just
virtio
based block device and a root switch
to an ext4 file system.
nix
{
alpine-machine = bake {
name = "alpine-machine";
image = alpine;
kernel = pkgs.linuxPackages_5_10.kernel;
options = [ "console=tty1" "console=ttyS0" ];
size = "128M";
modules = [
"virtio"
"virtio_ring"
"virtio_blk"
"virtio_pci"
"jbd2"
"mbcache"
"crc16"
"crc32c_generic"
"ext4"
];
};
}Baking delegates to the shellHook because keeping the implementation inside
the nix derivation sandbox requires specialized wizardry. It’s easier to use
privileged programs such as doas or sudo within the shell environment to
bake the image. Once baked, initialize the image inside a virtual machine using
QEMU (Quick Emulator) in -vga std,
-curses or -nographic serial mode. You can also -enable-kvm for better
performance and set memory size with -m 1024.
nix
pkgs.mkShell {
inherit name;
buildInputs = [ pkgs.proot pkgs.qemu ];
shellHook = ''
export PS1='\h (${name}) \W \$ '
doas ${alpine-machine}
sudo ${alpine-machine}
qemu-system-x86_64 -nographic -drive if=virtio,file=./${alpine-machine.name}.img,format=raw
exit
'';
}Conclusion
The nix-shell paradigm is extremely powerful. The functional compositions give
way to replacing a lot of tools with minimal effort through abstraction. A lot
more could be done with more advance usage of this type of nix-shell but it’s
good enough to bind a few distributions into my user environment and cook up
small virtual machines quickly.
My main systems run somewhat lean NixOS setups, and while spinning out nix
code is easier for me now, the language seems more like a glue, and getting too
much of it everywhere may slow you down. Escape pods like virtual machines and
easily bound user space chroots are nice to have.