Level 6 capstone solution¶
Level 6 · Capstone solution · Task: Level 6 capstone
Try the capstone yourself before reading this. The value is in the struggle with pivot_root errors and missing /proc mounts, not in the finished script.
This page has two parts: a complete, tested solution for option A (a minimal container), with a step-by-step explanation and verification output for every acceptance criterion, and a milestone guide for option B (Linux From Scratch).
Option A: the minicontainer.sh solution¶
Design¶
The script runs in two stages. The first runs on the host and sets up the cgroup and namespaces; the second runs inside the new namespaces and builds the container's filesystem. The script calls itself to get from one stage to the next.
sequenceDiagram
participant U as alex (host shell)
participant S1 as minicontainer.sh<br/>stage 1 (host)
participant SR as systemd --user
participant UN as unshare
participant S2 as minicontainer.sh __init<br/>stage 2 (inside)
participant C as /bin/sh (PID 1)
U->>S1: ./minicontainer.sh alpine
S1->>S1: parse options, check rootfs
S1->>SR: systemd-run --user --scope<br/>MemoryMax, TasksMax, CPUQuota
SR->>UN: run in new scope (cgroup)
UN->>UN: new user, mnt, uts, ipc, pid, net, cgroup namespaces
UN->>S2: fork, exec "$0 __init NAME ROOTFS CMD"
S2->>S2: hostname, mounts, /dev, pivot_root
S2->>C: exec env -i CMD (becomes PID 1)
C-->>U: interactive shell in the container
In rootless mode (a normal user, which is the default on Mint), a user namespace maps you to root inside and systemd applies the cgroup limits through a transient scope. In root mode (⚠️ VM only), the script creates its own cgroup under /sys/fs/cgroup, writes the limit files directly, and can optionally connect the container to the host with a veth pair.
The script¶
Save this as minicontainer.sh and make it executable (chmod +x minicontainer.sh). It's also in the repository as scripts/minicontainer.sh.
#!/usr/bin/env bash
# minicontainer.sh - a minimal container built from unshare, pivot_root and cgroups v2.
#
# Part of the Linux Handbook, Level 6 capstone (option A).
#
# Usage:
# minicontainer.sh [-m MEM] [-p PIDS] [-c CPU] [-H NAME] [-n] ROOTFS [COMMAND [ARG...]]
#
# -m MEM memory limit, cgroup syntax (default: 64M)
# -p PIDS maximum number of tasks (default: 32)
# -c CPU CPU quota in percent of one CPU (default: 50)
# -H NAME hostname inside the container (default: minibox)
# -n private network with a veth pair to the host (root only, VM only)
# ROOTFS directory holding a root filesystem (for example an Alpine minirootfs)
# COMMAND program to run as PID 1 inside the container (default: /bin/sh)
#
# Two modes:
# * As a normal user (rootless): a user namespace maps you to root inside,
# and a transient systemd user scope applies the cgroup limits.
# * As root (VM only): the script writes the cgroup v2 files itself under
# /sys/fs/cgroup and can wire up a veth pair with -n.
set -euo pipefail
SELF=$(readlink -f "$0")
die() { echo "minicontainer: $*" >&2; exit 1; }
# ---------------------------------------------------------------------------
# Stage 2: runs INSIDE the new namespaces, as (namespaced) root.
# Arguments: __init NAME ROOTFS COMMAND [ARG...]
# ---------------------------------------------------------------------------
container_init() {
local name=$1 root=$2
shift 2
# 1. Our own hostname (UTS namespace).
hostname "$name"
# 2. Stop mount events leaking back to the host (mount namespace).
mount --make-rprivate /
# 3. pivot_root needs the new root to be a mount point: bind it onto itself.
mount --bind "$root" "$root"
# 4. Kernel filesystems. proc shows only our PID namespace.
mount -t proc -o nosuid,nodev,noexec proc "$root/proc"
mount -t sysfs -o ro,nosuid,nodev,noexec sysfs "$root/sys"
mount -t cgroup2 -o nosuid,nodev,noexec cgroup2 "$root/sys/fs/cgroup"
mount -t tmpfs -o nosuid,nodev,mode=1777,size=16m tmpfs "$root/tmp"
# 5. A tiny /dev: a tmpfs with a few host device files bind-mounted in.
# (Creating device nodes with mknod is not allowed in a user namespace.)
mount -t tmpfs -o nosuid,noexec,mode=755,size=64k tmpfs "$root/dev"
local dev
for dev in null zero full random urandom tty; do
touch "$root/dev/$dev"
mount --bind "/dev/$dev" "$root/dev/$dev"
done
ln -s /proc/self/fd "$root/dev/fd"
ln -s /proc/self/fd/0 "$root/dev/stdin"
ln -s /proc/self/fd/1 "$root/dev/stdout"
ln -s /proc/self/fd/2 "$root/dev/stderr"
# 6. Swap the root filesystem, then detach the old one.
cd "$root"
mkdir -p .oldroot
pivot_root . .oldroot
cd /
# From here on, commands come from the container's rootfs.
umount -l /.oldroot
rmdir /.oldroot
# 7. Replace this shell with the container's command. It becomes PID 1.
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
exec env -i PATH="$PATH" HOME=/root TERM="${TERM:-xterm}" HOSTNAME="$name" "$@"
}
if [[ ${1:-} == __init ]]; then
shift
container_init "$@"
fi
# ---------------------------------------------------------------------------
# Stage 1: runs on the host.
# ---------------------------------------------------------------------------
MEM=64M
PIDS=32
CPU=50
NAME=minibox
NET=no
usage() { sed -n '6,16p' "$SELF" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
while getopts ':m:p:c:H:nh' opt; do
case $opt in
m) MEM=$OPTARG ;;
p) PIDS=$OPTARG ;;
c) CPU=$OPTARG ;;
H) NAME=$OPTARG ;;
n) NET=yes ;;
h) usage 0 ;;
:) die "option -$OPTARG needs a value" ;;
*) die "unknown option -$OPTARG (try -h)" ;;
esac
done
shift $((OPTIND - 1))
[[ $# -ge 1 ]] || usage 1
ROOTFS=$(readlink -f "$1")
shift
[[ $# -ge 1 ]] || set -- /bin/sh
[[ -x $ROOTFS/bin/sh ]] || die "$ROOTFS does not look like a root filesystem (no /bin/sh)"
for d in proc sys tmp dev; do
[[ -d $ROOTFS/$d ]] || die "$ROOTFS/$d is missing (mkdir it first)"
done
[[ $PIDS =~ ^[0-9]+$ ]] || die "-p needs a number"
[[ $CPU =~ ^[0-9]+$ ]] || die "-c needs a number (percent of one CPU)"
# Namespaces every container gets. --fork makes the command a child of
# unshare, so it lands in the new PID namespace as PID 1. --kill-child
# makes sure the container dies if unshare is killed.
NS_FLAGS=(--mount --uts --ipc --pid --cgroup --fork --kill-child)
if [[ $EUID -ne 0 ]]; then
# ---------------- Rootless mode ----------------
[[ $NET == no ]] || die "-n needs root (use your VM: sudo $0 -n ...)"
if [[ $(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null || echo 0) == 1 ]]; then
echo "minicontainer: warning: kernel.apparmor_restrict_unprivileged_userns=1;" \
"user namespaces may be blocked (see the Containers chapter)" >&2
fi
echo "minicontainer: rootless, limits mem=$MEM pids=$PIDS cpu=${CPU}%" >&2
exec systemd-run --user --scope --quiet --collect \
-p MemoryMax="$MEM" -p MemorySwapMax=0 \
-p TasksMax="$PIDS" -p CPUQuota="${CPU}%" \
unshare --user --map-root-user --net "${NS_FLAGS[@]}" \
"$SELF" __init "$NAME" "$ROOTFS" "$@"
fi
# ---------------- Root mode (VM only) ----------------
CG=/sys/fs/cgroup/minicontainer-$$
NETNS=mc$$
VETH=vh$$
# shellcheck disable=SC2329 # invoked via trap below
cleanup() {
if [[ $NET == yes ]]; then
ip netns delete "$NETNS" 2>/dev/null || true
ip link delete "$VETH" 2>/dev/null || true
fi
# A cgroup can only be removed once it has no processes left.
if [[ -d $CG ]]; then
rmdir "$CG" 2>/dev/null || true
fi
}
trap cleanup EXIT
# Make sure the root cgroup hands the controllers we need to its children.
for c in memory pids cpu; do
grep -qw "$c" /sys/fs/cgroup/cgroup.subtree_control ||
echo "+$c" > /sys/fs/cgroup/cgroup.subtree_control
done
mkdir "$CG"
echo "$MEM" > "$CG/memory.max"
echo 0 > "$CG/memory.swap.max"
echo "$PIDS" > "$CG/pids.max"
echo "$((CPU * 1000)) 100000" > "$CG/cpu.max"
echo "minicontainer: cgroup $CG mem=$MEM pids=$PIDS cpu=${CPU}%" >&2
WRAP=()
if [[ $NET == yes ]]; then
ip netns add "$NETNS"
ip link add "$VETH" type veth peer name eth0 netns "$NETNS"
ip addr add 10.200.0.1/24 dev "$VETH"
ip link set "$VETH" up
ip -n "$NETNS" link set lo up
ip -n "$NETNS" addr add 10.200.0.2/24 dev eth0
ip -n "$NETNS" link set eth0 up
ip -n "$NETNS" route add default via 10.200.0.1
WRAP=(nsenter --net="/run/netns/$NETNS")
echo "minicontainer: network host 10.200.0.1 <-> container 10.200.0.2" >&2
else
NS_FLAGS+=(--net)
fi
rc=0
(
# Move only this subshell into the cgroup, so the parent script can
# still remove the cgroup afterwards.
echo "$BASHPID" > "$CG/cgroup.procs"
exec "${WRAP[@]}" unshare "${NS_FLAGS[@]}" "$SELF" __init "$NAME" "$ROOTFS" "$@"
) || rc=$?
exit "$rc"
Step-by-step explanation¶
Stage 1: on the host.
- Option parsing with
getopts(from Arguments and getopts) sets the memory limit, PID limit, CPU quota, hostname, and network flag. The first remaining argument is the rootfs; anything after it is the command (default/bin/sh). - Sanity checks make sure the rootfs has
/bin/shand the four mount-point directories, so failures happen early with a clear message instead of halfway through the mounts. NS_FLAGSlists the namespaces every container gets: mount, UTS, IPC, PID, and cgroup, plus--fork(so the command becomes PID 1) and--kill-child(so the container dies ifunshareis killed).- Rootless mode
execssystemd-run --user --scope, which creates a transient scope (a cgroup) under your user manager withMemoryMax,MemorySwapMax=0,TasksMax, andCPUQuota. Inside it,unshare --user --map-root-user --netcreates the user namespace first (so the rest can be created without real root), then the others.--collectmakes systemd remove the scope even if the container exits with an error. The script warns if Ubuntu's AppArmor restriction on user namespaces is on. - Root mode (⚠️ VM only) makes sure the root cgroup delegates
memory,pids, andcputo its children, creates/sys/fs/cgroup/minicontainer-PID, and writesmemory.max,memory.swap.max,pids.max, andcpu.max(CPU × 1000microseconds per 100,000). With-n, it creates a named network namespace and a veth pair (10.200.0.1on the host,10.200.0.2aseth0inside) and usesnsenter --net=to start the container in it. Then a subshell moves only itself into the cgroup (echo $BASHPID > cgroup.procs) andexecsunshare. The parent script stays outside the cgroup, so itsEXITtrap canrmdirthe cgroup and delete the network namespace afterwards.
Stage 2: inside the namespaces (__init). At this point the process is PID 1 of a new PID namespace and "root" in its own user namespace.
hostname "$name"sets the hostname in the new UTS namespace.mount --make-rprivate /stops mount events propagating back to the host.mount --bind "$root" "$root"makes the rootfs a mount point, whichpivot_rootrequires.- Kernel filesystems: a fresh
proc(it shows only the new PID namespace), a read-onlysysfs,cgroup2on/sys/fs/cgroup(thanks to the cgroup namespace it shows the container's own cgroup as the root), and a size-limitedtmpfson/tmp. All are mounted withnosuid/nodev/noexecwhere sensible. /devis a 64 KB tmpfs with six harmless device files bind-mounted from the host, plus thefd,stdin,stdout, andstderrsymlinks programs expect. A user namespace can't create device nodes withmknod, and the container gets no access to disks or/dev/mem.pivot_root . .oldrootmakes the rootfs/. From here on, external commands come from the container.umount -l /.oldrootandrmdir /.oldrootremove the host's filesystem from the container's mount table entirely.exec env -i ... "$@"replaces the script with the requested command, with a clean environment. Because ofexec, the command is PID 1.
Verification¶
All of this runs rootless on Mint 22 as user alex, in ~/lab/capstone, with the Alpine 3.20.3 minirootfs in ./alpine.
Script quality:
shellcheck: clean
Usage:
minicontainer.sh [-m MEM] [-p PIDS] [-c CPU] [-H NAME] [-n] ROOTFS [COMMAND [ARG...]]
-m MEM memory limit, cgroup syntax (default: 64M)
-p PIDS maximum number of tasks (default: 32)
-c CPU CPU quota in percent of one CPU (default: 50)
-H NAME hostname inside the container (default: minibox)
-n private network with a veth pair to the host (root only, VM only)
ROOTFS directory holding a root filesystem (for example an Alpine minirootfs)
COMMAND program to run as PID 1 inside the container (default: /bin/sh)
Hostname:
minicontainer: rootless, limits mem=64M pids=32 cpu=50%
minibox
minicontainer: rootless, limits mem=64M pids=32 cpu=50%
lab1
mint
The status line goes to stderr, so it doesn't mix with the command's output if you pipe it. The host's hostname is unchanged.
PID 1, interactively:
/ # echo $$
1
/ # ps
PID USER TIME COMMAND
1 root 0:00 /bin/sh
26 root 0:00 ps
/ # id
uid=0(root) gid=0(root) groups=65534(nobody),65534(nobody),...,0(root)
/ # exit
The shell is PID 1 and ps sees only the container. ps is PID 26 because the mounts, touches, and rmdir in stage 2 already used PIDs 2–25 inside the new namespace. id says root, but supplementary groups that aren't mapped into the user namespace show up as nobody: this "root" has no power over host files.
Root filesystem and mounts:
./minicontainer.sh alpine sh -c 'cat /etc/alpine-release; ls -A /home; ls /.oldroot; cat /proc/mounts | cut -d" " -f1-3'
minicontainer: rootless, limits mem=64M pids=32 cpu=50%
3.20.3
ls: /.oldroot: No such file or directory
/dev/sda2 / ext4
proc /proc proc
sysfs /sys sysfs
cgroup2 /sys/fs/cgroup cgroup2
tmpfs /tmp tmpfs
tmpfs /dev tmpfs
udev /dev/null devtmpfs
udev /dev/zero devtmpfs
udev /dev/full devtmpfs
udev /dev/random devtmpfs
udev /dev/urandom devtmpfs
udev /dev/tty devtmpfs
- The Alpine release file proves
/is the rootfs. ls -A /homeprints nothing: Alpine's empty/home, not yours.- The old root is gone.
- The mount table has only the container's mounts. The first line names the host device that holds
~/lab/capstone/alpine(yours may be/dev/nvme0n1p2or similar), because the rootfs is a bind mount of a directory on it. No host paths appear.
cgroup limits visible inside:
./minicontainer.sh alpine sh -c 'cat /proc/self/cgroup; cd /sys/fs/cgroup; cat memory.max memory.swap.max pids.max cpu.max'
0::/ shows the cgroup namespace at work: the container thinks it's at the root of the tree. The four files match -m 64M, no swap, -p 32, and -c 50.
Memory limit enforced:
./minicontainer.sh -m 32M alpine sh -c 'dd if=/dev/zero of=/dev/null bs=100M count=1; echo "dd exit status: $?"; grep -E "^(max|oom_kill) " /sys/fs/cgroup/memory.events'
journalctl -k -n 1 --no-pager
minicontainer: rootless, limits mem=32M pids=32 cpu=50%
Killed
dd exit status: 137
max 38
oom_kill 1
Oct 02 15:21:44 mint kernel: Memory cgroup out of memory: Killed process 25311 (dd) total-vm:104804kB, anon-rss:32128kB, file-rss:928kB, shmem-rss:0kB, UID:1000 pgtables:120kB oom_score_adj:0
dd tried to allocate a 100 MB buffer in a 32 MB cgroup and was killed by the OOM killer (137 = 128 + SIGKILL). max 38 counts the times usage hit the limit before the kill. The shell (PID 1) survived because it was small; only the biggest process was chosen. The host's kernel log has the matching Memory cgroup out of memory line, with UID 1000: it's still alex's process from the host's point of view.
PID limit enforced:
./minicontainer.sh -p 8 alpine sh -c 'sh -c "for i in 1 2 3 4 5 6 7 8 9 10 11 12; do sleep 2 & done; wait"; echo "pids.peak=$(cat /sys/fs/cgroup/pids.peak)"; cat /sys/fs/cgroup/pids.events'
minicontainer: rootless, limits mem=64M pids=8 cpu=50%
sh: can't fork: Resource temporarily unavailable
pids.peak=8
max 1
The inner shell hit the 8-task ceiling and BusyBox sh gave up with can't fork (EAGAIN). The loop runs in a child sh -c so that the outer shell survives to print the counters. A fork bomb in this container would stop at 8 tasks.
CPU limit (stretch):
./minicontainer.sh -c 25 alpine sh -c 'timeout 4 sh -c "while :; do :; done"; grep -E "usage_usec|nr_periods|nr_throttled|throttled_usec" /sys/fs/cgroup/cpu.stat'
minicontainer: rootless, limits mem=64M pids=32 cpu=25%
Terminated
usage_usec 1096009
nr_periods 44
nr_throttled 44
throttled_usec 3250760
A busy loop ran for 4 seconds of wall-clock time but got only about 1.1 seconds of CPU (usage_usec): 25%, as configured. It was throttled in every one of the 44 scheduling periods (nr_throttled), for a total of 3.25 seconds.
Isolated network:
minicontainer: rootless, limits mem=64M pids=32 cpu=50%
1: lo: <LOOPBACK> mtu 65536 qdisc noop qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
Namespaces seen from the host. Start a long-running container in one terminal:
In another:
P=$(pgrep -n -x sleep)
lsns -p "$P" -o NS,TYPE,PID,COMMAND
readlink /proc/self/ns/pid /proc/"$P"/ns/pid
cat /proc/"$P"/cgroup
NS TYPE PID COMMAND
4026531834 time 1325 /usr/lib/systemd/systemd --user
4026533163 user 25480 /usr/bin/unshare --user --map-root-user --net --mount -
4026533195 mnt 25480 /usr/bin/unshare --user --map-root-user --net --mount -
4026533197 uts 25480 /usr/bin/unshare --user --map-root-user --net --mount -
4026533198 ipc 25480 /usr/bin/unshare --user --map-root-user --net --mount -
4026533199 pid 25486 └─sleep 300
4026533200 cgroup 25480 /usr/bin/unshare --user --map-root-user --net --mount -
4026533201 net 25480 /usr/bin/unshare --user --map-root-user --net --mount -
pid:[4026531836]
pid:[4026533199]
0::/user.slice/user-1000.slice/user@1000.service/app.slice/run-r3f0a5c2e9d7b4e61a0c2f1d8e5b7a903.scope
Seven of the eight namespace types are new (only time is shared with the host, which we didn't unshare). The unshare process (25480) is in all of them except the PID namespace, because unsharing a PID namespace only affects children. That's exactly why --fork exists. The host sees the container's real cgroup path, while inside it's 0::/.
Clean exit. After the container exits:
No mounts leaked (they lived only in the container's mount namespace, which died with its last process), and --collect removed the transient scope.
Root mode and networking (stretch, ⚠️ VM only)¶
⚠️ VM only
Run root mode only in your throwaway VM. It writes directly to the root of the cgroup tree, creates network interfaces and namespaces, and the container's root is real root (there's no user namespace in this mode).
Copy the script and the Alpine rootfs to the VM. Extract the rootfs as root there (sudo tar -xzf ... -C alpine), so the files are owned by root as Alpine expects. Then, in one VM terminal:
minicontainer: cgroup /sys/fs/cgroup/minicontainer-4120 mem=128M pids=32 cpu=50%
minicontainer: network host 10.200.0.1 <-> container 10.200.0.2
/ # ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
2: eth0@if7: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue qlen 1000
link/ether d6:f3:2b:41:9a:10 brd ff:ff:ff:ff:ff:ff
inet 10.200.0.2/24 scope global eth0
valid_lft forever preferred_lft forever
/ # ping -c 1 10.200.0.1
PING 10.200.0.1 (10.200.0.1): 56 data bytes
64 bytes from 10.200.0.1: seq=0 ttl=64 time=0.071 ms
In a second VM terminal, while the container runs:
ls -d /sys/fs/cgroup/minicontainer-*
cat /sys/fs/cgroup/minicontainer-*/memory.max
ip -brief addr show | grep vh
ping -c 1 10.200.0.2
/sys/fs/cgroup/minicontainer-4120
134217728
vh4120@if2 UP 10.200.0.1/24 fe80::8c2e:61ff:fe0a:44b2/64
64 bytes from 10.200.0.2: icmp_seq=1 ttl=64 time=0.052 ms
eth0@if7 and vh4120@if2 are the two ends of the same veth pair: each @ifN names the interface index of its peer. After you exit the container, the EXIT trap deletes the network namespace (which destroys both veth ends) and removes the cgroup directory:
To give the container internet access, you'd also need net.ipv4.ip_forward=1, a NAT masquerade rule for 10.200.0.0/24, and an /etc/resolv.conf inside the rootfs: see the veth section of Containers from scratch.
What this container still lacks¶
Compare it with what runc does, to see what "production-grade" adds:
- Capability dropping. In root mode the container's root has every capability. runc keeps only 14 by default (
capsh --drop=...orsetpriv --bounding-setcould do this). - seccomp and AppArmor. No system call filter and no MAC profile.
- A proper init. Our PID 1 is whatever command you run. If it doesn't reap zombies or handle
SIGTERM, the container misbehaves. Tools liketiniexist for exactly this. - Image layers. We run directly on an extracted rootfs, so changes are permanent. An overlayfs mount with a throwaway upper directory would give each run a fresh copy.
- User-namespace ID ranges. We map one UID. Real rootless runtimes map 65,536 IDs using
newuidmapand/etc/subuid, so packages that create users work inside. - Rootless networking. Podman uses
pastaorslirp4netnsto give rootless containers internet access without root.
Each of these is a good next project.
Option B: Linux From Scratch milestone guide¶
There's no single "answer" for LFS: the book is the walkthrough, and you should follow it exactly. This guide gives you a check for each milestone, the mistakes that most often cause trouble there, and what you should understand before moving on. Chapter numbers refer to current LFS (12.x) books.
Milestone 1: host ready (chapter 2)¶
Check:
The first command should print nothing except possibly a line about aliases; every tool should be OK. $LFS is /mnt/lfs and is a mounted ext4 filesystem on its own partition.
Common trouble: /bin/sh must point to bash, not dash, on Ubuntu hosts (the book's check catches this; fix it with sudo ln -sf bash /bin/sh in the VM). Missing texinfo, bison, or gawk packages on the host.
Understand: why LFS needs its own partition, and what $LFS is used for in every later command.
Milestone 2: sources downloaded (chapter 3)¶
Check:
Prints nothing when every package downloaded correctly.
Common trouble: a mirror serving an HTML error page instead of a tarball. md5sum catches it.
Milestone 3: build environment (chapter 4)¶
Check (as the lfs user):
whoami prints lfs, LFS_TGT is like x86_64-lfs-linux-gnu, and $LFS/tools/bin comes first in PATH.
Common trouble: building as root by accident. Every chapter 5–6 command must run as lfs; a root build can overwrite host files.
Understand: why the book wipes the environment (env -i in .bash_profile) so host settings can't leak into the build.
Milestone 4: cross toolchain (chapter 5)¶
Check: the book's sanity test after Glibc:
Common trouble: skipping a package's mkdir -v build; cd build step; running a later step in a stale directory; forgetting --with-sysroot=$LFS.
Understand: what a cross-compiler is (a compiler that builds programs for a different target, here the x86_64-lfs-linux-gnu triplet) and why it's the trick that isolates the new system from the host's libraries.
Milestone 5: temporary tools (chapter 6)¶
Check: the cross-compiled tools exist in the new tree:
file reports a dynamically linked x86-64 executable with interpreter /lib64/ld-linux-x86-64.so.2.
Understand: these tools are built with the cross toolchain but for the new system; they're enough to work inside a chroot.
Milestone 6: into chroot (chapter 7)¶
Check (inside chroot):
You see the new system's root, the minimal passwd file you created, and the temporary GCC.
Common trouble: after a reboot, forgetting to mount the virtual kernel filesystems (/dev, /dev/pts, /proc, /sys, /run) before re-entering chroot. Many builds then fail in odd ways. Write a small script that mounts them and enters chroot, and use it every time.
Understand: how chroot here relates to Containers from scratch: it's the same "different /" idea, without namespaces. Back up $LFS now (the book shows how): it's the most valuable restore point.
Milestone 7: base system (chapter 8)¶
Check: the critical test suites pass. Save their logs:
For Glibc, GCC, and Binutils, compare failures against the book's list of known failures. After GCC, run the book's sanity checks again (compiling a dummy program and checking which crt*.o files and include paths it uses).
Common trouble: running out of disk space during the GCC test suite; skipping a package because "it built fine last time"; not stripping debug symbols and running out of space.
Understand: what Glibc, GCC, Binutils, Coreutils, Util-linux, and systemd (or SysVinit) each provide. These are the packages behind every command in this handbook.
Milestone 8: system configuration (chapter 9)¶
Check: the files you wrote:
cat /etc/hostname /etc/hosts /etc/fstab
ls /etc/systemd/network/ 2>/dev/null || cat /etc/sysconfig/ifconfig.*
Common trouble: the wrong network interface name (check what the VM calls it with ip link on the host, and remember the LFS kernel may name it differently); /etc/fstab pointing at the wrong partition.
Milestone 9: kernel and bootloader (chapter 10)¶
Check:
ls -l /boot
grep -E 'CONFIG_EXT4_FS=|CONFIG_DEVTMPFS=|CONFIG_VIRTIO_BLK=' /usr/src/linux-*/.config
grep -A6 menuentry /boot/grub/grub.cfg
/boot contains your vmlinuz-...-lfs-..., System.map, and config. Drivers for your root disk and filesystem must be =y (built in), because there's no initramfs. On a QEMU/KVM VM that usually means CONFIG_VIRTIO_BLK=y and CONFIG_VIRTIO_PCI=y.
Common trouble: the root disk driver built as a module (=m); a root= in grub.cfg that names the wrong partition; installing GRUB to the host's disk instead of the LFS disk (snapshot first!).
Understand: everything you learned in Kernel basics and The boot process is now concrete: you chose the command line, the built-in drivers, and the bootloader entry yourself.
Milestone 10: first boot (chapter 11)¶
Check (on the booted LFS system):
cat /etc/lfs-release
uname -a
cat /proc/cmdline
gcc --version | head -1
ldd --version | head -1
ip addr
Common trouble: Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(0,0). The (0,0) means the kernel found no usable root disk at all: almost always a missing built-in disk driver. unknown-block(8,2) with a real number means the driver works but the filesystem type isn't built in, or root= is wrong.
When it boots, you've built a Linux system from nothing but source code. From here, Beyond Linux From Scratch adds SSH, a package manager, Python, a desktop, and more.