Есть старая шутка, что докер — это:
curl && untar && chroot && ./path/to/binШутка хорошая, но нечестная: в ней нет неймспейсов, cgroups и pivot_root. Давайте доведём её до состояния, в котором она перестаёт быть шуткой и начинает запускать Alpine. Всё, что нам нужно, уже лежит в util-linux и iproute2 — докер, как обычно, просто удобная обёртка вокруг пяти системных вызовов.
Формат: один скрипт. На вход — ссылка на tgz с rootfs, на выход — работающий «контейнер».
#!/usr/bin/env bash
# bashocker — container runtime, которого мы заслужили
set -euo pipefail
[[ $EUID -eq 0 ]] || { echo "нужен root (мы же серьёзный runtime)" >&2; exit 1; }
[[ $# -ge 1 ]] || { echo "usage: $0 <url-to-rootfs.tgz> [cmd...]" >&2; exit 1; }
URL=$1; shift
CMD=("$@")
[[ ${#CMD[@]} -gt 0 ]] || CMD=(/bin/sh)
ID="c$(head -c3 /dev/urandom | od -An -tx1 | tr -d ' \n')"
ROOT="/var/lib/bashocker/$ID/rootfs"
mkdir -p "$ROOT"
echo "==> pull $URL"
curl -fsSL "$URL" | tar -xz -C "$ROOT"
echo "==> cgroup $ID"
grep -q memory /sys/fs/cgroup/cgroup.subtree_control \
|| echo "+cpu +memory +pids" > /sys/fs/cgroup/cgroup.subtree_control
CG="/sys/fs/cgroup/$ID"
mkdir "$CG"
echo 256M > "$CG/memory.max"
echo "50000 100000" > "$CG/cpu.max" # пол-ядра
echo 64 > "$CG/pids.max" # fork-бомбы идут лесом
trap 'rmdir "$CG" 2>/dev/null || true' EXIT
echo "==> run ${CMD[*]}"
echo $$ > "$CG/cgroup.procs" # членство в cgroup наследуется
exec unshare --mount --uts --ipc --net --pid --fork --kill-child \
bash -c '
set -euo pipefail
ROOT=$1; ID=$2; shift 2
hostname "$ID"
mount --make-rprivate /
mount --bind "$ROOT" "$ROOT" # pivot_root хочет mount point
cd "$ROOT"
mkdir -p .oldroot
pivot_root . .oldroot
cd /
mount -t proc proc /proc
mount -t tmpfs tmpfs /tmp 2>/dev/null || true
umount -l /.oldroot
rmdir /.oldroot
exec "$@"
' _ "$ROOT" "$ID" "${CMD[@]}"Проверяем:
./bashocker.sh https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-minirootfs-3.20.3-x86_64.tar.gz
/ # hostname
c1f3a9
/ # ps aux
PID USER COMMAND
1 root /bin/sh
2 root ps aux
/ # ls /
bin dev etc home lib ...PID 1, свой hostname, свой rootfs, чужих процессов не видно. Поздравляю, вы containerd.
curl | tar — это docker pull. Разница в том, что настоящий pull ходит в registry за JSON-манифестом, скачивает те же самые tar'ы послойно и накладывает их через overlayfs. У нас слой один, поэтому и overlayfs не нужен. Честность против оптимизации кеша — выбор очевиден.
cgroup v2 — это флаги --memory и --cpus. Три echo в sysfs: лимит памяти, cpu.max в формате «квота период» (50000/100000 = полъядра), pids.max против fork-бомб. Хитрость одна: echo $$ > cgroup.procs мы делаем до exec unshare — членство в cgroup наследуется, так что контейнер рождается уже внутри лимитов, а не вбегает в них постфактум.
unshare — это, собственно, контейнер. Пять неймспейсов одним флагом каждый:
--mount свои маунты --uts свой hostname
--ipc свой SysV IPC --net своя сетка (пока пустая)
--pid свои PID'ы
--fork обязателен для pid-неймспейса: новый PID 1 должен быть потомком unshare, сам себя в новый неймспейс процесс поместить не может. --kill-child — чтобы контейнер умирал вместе с runtime, а не оставался сиротой (докеровский shim передаёт привет).
pivot_root вместо chroot — единственное место, где шутка из заголовка нам врёт. Из chroot тривиально выйти, если у процесса есть CAP_SYS_ADMIN, — это не изоляция, а вежливая просьба. pivot_root физически подменяет корень в mount-неймспейсе, после чего старый корень отмонтируется (umount -l) и исчезает. Нюансы: новый корень обязан быть mount point (отсюда mount --bind самого на себя), а --make-rprivate нужен, чтобы наши umount'ы не уехали через shared propagation на хост и не отмонтировали чего-нибудь лишнего у хозяина машины.
mount -t proc — без этого ps внутри показывал бы процессы хоста (procfs хоста!) и вся pid-изоляция была бы декорацией.
После --net внутри только опущенный loopback. Даём контейнеру интерфейс снаружи (PID — это пид контейнерного init на хосте):
PID=$(pgrep -f "unshare.*$ID" | head -1) # или сохранить при запуске
ip link add "veth-$ID" type veth peer name eth0 netns "$PID"
ip addr add 10.42.0.1/24 dev "veth-$ID"
ip link set "veth-$ID" up
nsenter -t "$PID" -n ip addr add 10.42.0.2/24 dev eth0
nsenter -t "$PID" -n ip link set eth0 up
nsenter -t "$PID" -n ip link set lo up
nsenter -t "$PID" -n ip route add default via 10.42.0.1
echo 1 > /proc/sys/net/ipv4/ip_forward
iptables -t nat -A POSTROUTING -s 10.42.0.0/24 -j MASQUERADEВсё. veth-пара, адрес, дефолтный маршрут, NAT. Это и есть docker0-бридж, только без бриджа — на один контейнер он не нужен.
Строго говоря: overlayfs для слоёв, image-манифестов из registry, user namespaces (запуск без рута), seccomp/capabilities (сейчас контейнер имеет полный capability set рута — не запускайте туда чужой код), volumes и — главное — демона на 200 МБ с gRPC-сокетом, чтобы простой unshare требовал клиент-серверную архитектуру: dockerd → containerd → runc → shim.
Первые четыре пункта — хорошее домашнее задание. Последний — нет.
Контейнер — это не технология, это man 7 namespaces плюс три echo в sysfs. Весь «рантайм» уместился в полсотни строк bash, из которых половина — обработка ошибок и комментарии. Докер прекрасен не изоляцией — её делает ядро, — а тем, что упаковал это в UX, который не стыдно показать людям.
Но в следующий раз, когда dockerd съест гигабайт RSS, вы знаете, что ему есть альтернатива. Она называется bash.