Skip to content

Instantly share code, notes, and snippets.

@lexfrei
Created July 20, 2026 14:04
Show Gist options
  • Select an option

  • Save lexfrei/ea6835987d81a7f962d2dfd88d96044a to your computer and use it in GitHub Desktop.

Select an option

Save lexfrei/ea6835987d81a7f962d2dfd88d96044a to your computer and use it in GitHub Desktop.

Пишем container runtime на bash: curl, tar и немного самоуважения

Есть старая шутка, что докер — это:

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-бридж, только без бриджа — на один контейнер он не нужен.

Чего не хватает до Docker™

Строго говоря: 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment