Внутреннее устройство контейнеров¶
Docker, Podman, containerd, Kubernetes — все они называют свои абстракции одним словом «контейнер», но в Linux ядре никакой сущности «container» нет. Контейнер — это композиция трёх независимых kernel-механизмов: namespaces (что процесс видит), cgroups (сколько ресурсов потребляет) и OverlayFS (откуда берётся корневая файловая система). Сверху накручены пользовательские контракты: формат образа, реестр для распространения, runtime для запуска, daemon для управления, scheduler для оркестрации. Когда осознаёшь, что Docker — не магия, а оркестрация уже существовавших примитивов, исчезает мистика и появляются ответы на «почему оно ведёт себя именно так».
Из чего собран контейнер¶
┌─────────────────────────────────────────────────────────────────────┐
│ один Linux kernel │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ контейнер │ │
│ │ │ │
│ │ ┌───────────────┐ ┌───────────────┐ ┌──────────────────┐ │ │
│ │ │ namespaces │ │ cgroups │ │ OverlayFS │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ mnt, pid, │ │ cpu, memory, │ │ lowerdir = ro │ │ │
│ │ │ net, uts, │ │ io, pids, │ │ upperdir = rw │ │ │
│ │ │ ipc, user, │ │ PSI, freezer │ │ workdir │ │ │
│ │ │ cgroup, time │ │ │ │ merged = rootfs │ │ │
│ │ └───────────────┘ └───────────────┘ └──────────────────┘ │ │
│ │ │ │ │ │ │
│ │ └──────────────────┼────────────────────┘ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ процесс entrypoint │ │ │
│ │ │ (для него — отдельная │ │ │
│ │ │ "машина" со своим init) │ │ │
│ │ └─────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ + seccomp filter, capabilities mask, AppArmor/SELinux profile │
└─────────────────────────────────────────────────────────────────────┘
Заменить любой из трёх блоков можно отдельно. systemd-nspawn берёт namespaces + cgroups, но монтирует обычную
директорию как rootfs (без overlay). firejail использует namespaces + seccomp, но не использует cgroups и overlay.
LXC исторически работал поверх bind-mount rootfs, а не overlay. Docker — это конкретная связка, навязанная
популярностью. Понимая, что собирать можно как угодно, перестаёшь думать в категориях «Docker даёт» — начинаешь
думать в категориях «эти три kernel-фичи дают».
OCI: стандартизация¶
До 2015 года Docker был и форматом образа, и runtime'ом, и daemon'ом, и реестром — всё в одном проприетарном комплекте. Rkt от CoreOS пытался сделать альтернативу, но без стандарта это вело к фрагментации экосистемы. В июне 2015 Docker, CoreOS, Red Hat, Google и ещё ~20 компаний учредили Open Container Initiative (OCI) под крылом Linux Foundation. OCI определяет три спецификации:
| Спецификация | Что определяет | Реализации |
|---|---|---|
| image-spec | формат tarball-слоёв, manifest.json, config.json, digest | Docker, Podman, BuildKit, Buildah |
| runtime-spec | формат runtime bundle (rootfs + config.json), API runtime'а | runc, crun, youki, gVisor, Kata |
| distribution-spec | HTTP API реестра для push/pull/discovery | Docker Hub, Harbor, GHCR, Quay |
Дополнительная artifacts-spec (с 2023) позволяет хранить в реестре не только образы, но и подписи (cosign), SBOM, Helm chart'ы, WASM-модули. Это превратило OCI-реестр в универсальный content-addressable store.
образ bundle процесс
┌───────────┐ unpack ┌───────────────────────────┐ runc create ┌─────────────────┐
│ layers/ │ ───────▶ │ rootfs/ (распакованный) │ ────────────▶ │ PID, ns, cgrp, │
│ manifest │ │ config.json (runtime) │ │ cap, seccomp │
│ config │ └───────────────────────────┘ └─────────────────┘
│ (image- │ ▲ ▲
│ spec) │ │ │
└───────────┘ │ │
▲ │ │
│ │ │
registry containerd / kernel
(distribution-spec) dockerd прокидывает (namespaces +
OCI image → OCI bundle cgroups + ovl)
Три спецификации формально независимы. На практике все три реализуются одним и тем же набором инструментов: containerd понимает image-spec, генерирует bundle, дёргает runc для runtime-spec, а скачивает всё с реестра по distribution-spec.
OCI image format¶
Образ — это не «диск с операционной системой», как можно подумать по аналогии с VM. Это набор tar-архивов плюс два JSON-файла, описывающих как они складываются и что с ними делать.
my-image.tar (unpacked):
┌──────────────────────────────────────────────────────────────┐
│ oci-layout ← маркер формата │
│ index.json ← список manifest'ов │
│ (multi-arch: amd64, arm64...) │
│ blobs/sha256/ │
│ ├── 0a1b2c... (config.json) ← runtime-config: cmd, env, ws │
│ ├── 4d5e6f... (manifest) ← список слоёв + конфиг │
│ ├── 7890ab... (layer 0) ← tarball: базовая ОС │
│ ├── cdef01... (layer 1) ← tarball: установка пакетов │
│ ├── 234567... (layer 2) ← tarball: COPY app │
│ └── 89abcd... (layer 3) ← tarball: COPY config │
└──────────────────────────────────────────────────────────────┘
Каждый слой — обычный tar (опционально gzip), содержащий diff относительно предыдущего: новые файлы плюс
файлы-маркеры удалений (.wh.foo означает «удалить foo из нижележащих слоёв»). Идентификатор слоя — SHA-256 от
его содержимого. Это content-addressable: одинаковый слой в двух разных образах хранится один раз.
manifest.json¶
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"size": 1457,
"digest": "sha256:0a1b2c..."
},
"layers": [
{ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"size": 78462913, "digest": "sha256:7890ab..." },
{ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"size": 245013, "digest": "sha256:cdef01..." }
]
}
Manifest — это рецепт: какие слои в каком порядке распаковать и какой config к ним приложить. Сам не содержит данных, только указатели на blob'ы по digest.
config.json (image config, не путать с runtime config)¶
{
"architecture": "amd64", "os": "linux",
"config": {
"Env": ["PATH=/usr/local/bin:/usr/bin"],
"Cmd": ["/app/server"],
"WorkingDir": "/app",
"ExposedPorts": { "8080/tcp": {} },
"User": "1000:1000"
},
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:111...", ← tar-digest БЕЗ gzip
"sha256:222..."
]
},
"history": [ { "created": "...", "created_by": "RUN apt-get install" } ]
}
Поле rootfs.diff_ids — это digest'ы распакованных tar'ов (а layers[].digest в manifest — digest'ы сжатых
blob'ов). Двойной digest нужен потому, что при подписи и доверии важно содержимое после распаковки, а при передаче
по сети — байтовое содержимое архива.
distribution-spec: pull¶
docker pull alpine:latest под капотом — серия HTTP-запросов к реестру:
1. GET /v2/ ← auth challenge, узнаём scheme
2. GET /v2/library/alpine/manifests/latest
← по тегу → manifest digest
3. GET /v2/library/alpine/manifests/sha256:<digest>
← manifest.json (список слоёв)
4. GET /v2/library/alpine/blobs/sha256:<config-digest>
← image config
5. GET /v2/library/alpine/blobs/sha256:<layer-1-digest>
GET /v2/library/alpine/blobs/sha256:<layer-2-digest>
... ← каждый слой отдельно,
параллельно, с возобновлением
Каждый шаг возвращает либо JSON, либо blob с заголовком Docker-Content-Digest. Клиент проверяет digest перед
сохранением: если содержимое подделано — оно не совпадёт с requested URL, и pull отклонится.
Слоистая файловая система: OverlayFS¶
Контейнерный образ из 8 слоёв и весом 1 GB запускается за миллисекунды, потому что слои не копируются — они монтируются стопкой через OverlayFS (или, исторически, AUFS, btrfs, devicemapper, ZFS).
OverlayFS — это union mount: несколько read-only нижних директорий (lowerdir) объединяются с одной
read-write верхней (upperdir) в логическую файловую систему merged. Запись идёт только в upperdir; для
изменения файла из lowerdir он сначала копируется наверх (copy-up), и дальше работа идёт с копией.
┌─────────────────────────────────────────────────────────────────────┐
│ OverlayFS в контейнере │
│ │
│ merged/ │
│ (то, что видит контейнер: /, /etc, /usr, /app, ...) │
│ ▲ │
│ │ union view │
│ │ │
│ ┌─────┴──────┐ │
│ │ │ │
│ upperdir lowerdir (стопка, нижний первым) │
│ (rw) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ writes │ │ layer 3 │ │ layer 2 │ │ layer 1 │ ← base │
│ │ от │ │ COPY │ │ COPY app │ │ apt inst │ │
│ │ контей- │ │ config │ │ │ │ │ │
│ │ нера │ │ (image) │ │ (image) │ │ (image) │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ + workdir (служебный, для атомарности rename внутри overlay) │
└─────────────────────────────────────────────────────────────────────┘
Copy-up¶
До изменения файла /etc/nginx.conf:
merged: /etc/nginx.conf ──▶ читается из layer 1 (lower)
upper: —
Контейнер делает: echo "new" >> /etc/nginx.conf
kernel: open(/etc/nginx.conf, O_RDWR)
1. lookup в upper: нет
2. lookup в lower: layer 1, файл найден
3. copy-up: cp lower/etc/nginx.conf upper/etc/nginx.conf
4. open в upper
После:
merged: /etc/nginx.conf ──▶ читается из upper
upper: /etc/nginx.conf (модифицированный)
Copy-up — это разовая операция: после неё файл живёт в upperdir и больше не трогает lower. Для маленьких файлов цена незаметна, для крупных (видео-файл 4 GB, который контейнер только дописывает) — заметна. Решения: если приложение пишет в большие файлы — монтировать volume в обход overlay; если только читает — overlay не делает копию вовсе.
Whiteout¶
Удаление файла из lower-слоя не может «стереть» сам lower (он read-only). Вместо этого в upperdir создаётся
специальный файл-маркер. На overlayfs это character device 0:0 с тем же именем; в tar OCI-слое — файл с
префиксом .wh. (например, .wh.config.ini означает «config.ini удалён»). При union view kernel пропускает
файл с whiteout-маркером, как будто его нет.
# Посмотреть стопку слоёв конкретного контейнера
docker inspect --format '{{.GraphDriver.Data}}' my-container
# Driver: overlay2
# LowerDir: /var/lib/docker/overlay2/.../diff:/var/lib/docker/overlay2/.../diff:...
# UpperDir: /var/lib/docker/overlay2/<id>/diff
# WorkDir: /var/lib/docker/overlay2/<id>/work
# MergedDir: /var/lib/docker/overlay2/<id>/merged
Что значит «каждый RUN — новый слой»¶
FROM ubuntu:22.04 # layer 0 (база)
RUN apt-get update # layer 1: + индексы apt
RUN apt-get install -y curl # layer 2: + бинари + библиотеки
COPY app/ /app # layer 3: + ваш код
CMD ["/app/start.sh"] # config-only, не layer
Каждая RUN/COPY/ADD — новый tar-diff поверх предыдущего. Это даёт кэширование при пересборке (Docker
сравнивает hash инструкции — если не менялась, переиспользует слой) и расшаривание между образами
(ubuntu:22.04 хранится один раз для всех образов на его основе).
Антипаттерн: RUN apt-get install x && rm -rf /var/cache/apt/archives/* в одной инструкции — потому что
если разделить на два RUN, кэш сохранится в первом слое, и второй слой добавит whiteout — суммарно занятого
места столько же, как до удаления.
Слой runtime: runc, crun, youki¶
Когда containerd говорит «запусти контейнер из этого bundle» — он зовёт low-level container runtime. Его
задача: прочитать config.json, разложить пути, создать namespaces, применить cgroups, дропнуть capabilities,
загрузить seccomp-фильтр, сделать pivot_root и exec'нуть entrypoint. После запуска runtime обычно завершается —
контейнер живёт сам по себе.
| Runtime | Язык | Размер | Особенности |
|---|---|---|---|
| runc | Go | ~10 MB | reference impl OCI, поддерживается opencontainers |
| crun | C | ~500 KB | в 50× быстрее старта, меньше памяти, дефолт в Podman |
| youki | Rust | ~6 MB | memory-safe, экспериментальный, активная разработка |
| gVisor | Go | ~30 MB | userspace kernel (sentry), перехватывает все syscall'ы |
| Kata | Go + VM | (+VM) | каждый контейнер = микро-VM, KVM-изоляция |
runc исторически появился из выделения runtime-кода из dockerd в 2015 году — это и был первый OCI-compliant runtime. crun написан с целью «то же самое, но без Go-runtime'а»: на крупных кластерах разница в скорости старта и расходе памяти на тысячи контейнеров заметна. youki — попытка переписать runc на Rust с акцентом на memory safety и удобство расширения.
gVisor и Kata стоят особняком: они называют себя «runtimes», но изоляция у них другая. gVisor запускает гостевые процессы под собственным userspace-«ядром» (Sentry), все syscall'ы гостя ловятся через seccomp+ptrace и обрабатываются в userspace — это защищает host-kernel от 0-day. Kata оборачивает каждый контейнер в полноценную KVM-VM с минимальным гостевым ядром — изоляция как у VM, API как у контейнера.
Слой management: containerd, dockerd¶
Low-level runtime запускает один контейнер и забывает про него. Чтобы скачать образ, распаковать слои, отслеживать
жизнь сотни контейнеров, обрабатывать docker exec, прокидывать порты — нужен container manager.
┌──────────────────────────────────────────────────────────────────────┐
│ layered architecture (Linux/Docker) │
│ │
│ ┌──────────────────┐ │
│ │ docker CLI │ client (gRPC/HTTP к dockerd) │
│ └────────┬─────────┘ │
│ │ /var/run/docker.sock │
│ ┌────────▼─────────┐ │
│ │ dockerd │ high-level: images, volumes, networks, │
│ │ │ build (BuildKit), swarm, plugins │
│ └────────┬─────────┘ │
│ │ gRPC │
│ ┌────────▼─────────┐ │
│ │ containerd │ image store, snapshotter (overlay), │
│ │ │ task lifecycle, CRI для Kubernetes │
│ └────────┬─────────┘ │
│ │ shim API │
│ ┌────────▼─────────┐ │
│ │ containerd-shim │ ОДИН на каждый контейнер; родитель init │
│ │ (один на │ процесса контейнера; держит stdio, reap'ит │
│ │ контейнер) │ зомби; переживает рестарт containerd │
│ └────────┬─────────┘ │
│ │ exec │
│ ┌────────▼─────────┐ │
│ │ runc / crun │ OCI runtime: clone+ns+cgrp+seccomp+exec, │
│ │ │ сразу после exec — exit (контейнер живёт) │
│ └────────┬─────────┘ │
│ │ │
│ ┌────────▼─────────┐ │
│ │ контейнерный │ ваш nginx / postgres / app │
│ │ процесс (PID 1 │ │
│ │ в своём ns) │ │
│ └──────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
Зачем shim¶
Containerd-shim существует, чтобы разделить жизненный цикл контейнера и containerd. Если бы containerd был прямым родителем процесса контейнера, рестарт containerd убил бы все контейнеры (или превратил их в orphan'ов, которых reparent на PID 1). Вместо этого:
- containerd просит runc создать контейнер.
- runc форкается, в ребёнке делает setup ns/cgroup и exec entrypoint, выходит.
- Перед exec'ом запускается shim как промежуточный родитель: shim делает
setsidи становится отдельной process group. - Когда containerd убивают и рестартуют — shim'ы продолжают работать, держат stdio контейнеров, и containerd при старте пере-подключается к ним.
до shim (упрощённо): с shim:
systemd systemd
└── containerd ├── containerd (можно рестартовать)
└── nginx (контейнер) └── shim-abc (PID 1 для nginx)
└── nginx (контейнер)
рестарт containerd: рестарт containerd:
nginx становится orphan'ом nginx ничего не замечает,
или умирает shim переживает рестарт
В Kubernetes из всей этой цепочки dockerd обычно убран: kubelet общается с containerd напрямую через CRI (Container Runtime Interface). dockerd-shim переименован в containerd-shim-runc-v2, но суть та же.
Что делает docker run пошагово¶
$ docker run -d --name web -p 8080:80 nginx:alpine
┌────────────────────────────────────────────────────────────────────────┐
│ lifecycle docker run │
└────────────────────────────────────────────────────────────────────────┘
Шаг 1. dockerd принимает HTTP-запрос от docker CLI.
Проверяет, есть ли образ nginx:alpine в локальном content store.
Шаг 2. Если нет — pull:
containerd → реестру (distribution-spec):
manifest → config → blobs (слои параллельно)
сохраняет в /var/lib/containerd/io.containerd.content.v1.content/
Шаг 3. Snapshotter (overlay) распаковывает слои:
для каждого слоя — отдельная директория в
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/<id>/fs/
lowerdir = layer_0/fs:layer_1/fs:layer_2/fs
Шаг 4. Готовится writable snapshot:
mkdir snapshots/<container-id>/{fs,work}
upperdir = <container-id>/fs
workdir = <container-id>/work
Шаг 5. Mount overlay в bundle:
mkdir /run/containerd/io.containerd.runtime.v2.task/<ns>/<id>/rootfs
mount -t overlay overlay \
-o lowerdir=L1:L2:L3,upperdir=U,workdir=W \
.../rootfs
Шаг 6. Готовится config.json (runtime-spec):
linux.namespaces = [pid, net, ipc, uts, mount, cgroup]
linux.resources = (cpu, memory limits)
process.args = ["nginx", "-g", "daemon off;"]
+ mounts /proc, /sys, /dev/pts
+ seccomp profile (default Docker policy)
+ capabilities (default whitelist: CAP_NET_BIND_SERVICE, ...)
Шаг 7. containerd зовёт shim, shim зовёт runc create:
в runc:
a) clone(CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET |
CLONE_NEWUTS | CLONE_NEWIPC [| CLONE_NEWUSER])
b) (если user ns) write /proc/self/{uid,gid}_map
c) sethostname()
d) mount /proc, /sys, /dev, /tmp в новом mount ns
e) pivot_root(rootfs)
f) apply rlimits, set no_new_privs, drop capabilities
g) install seccomp filter (после drop'а, иначе фильтр не загрузится)
h) execve(entrypoint)
Шаг 8. Параллельно cgroup-manager помещает PID контейнера в
/sys/fs/cgroup/system.slice/docker-<id>.scope/
с лимитами cpu.max, memory.max и т.д.
Шаг 9. Networking:
dockerd создаёт veth pair (vethXXX ↔ eth0-в-контейнере),
host-конец подключает к bridge docker0,
внутри контейнера: ip addr add 172.17.0.2/16 dev eth0
в iptables: -t nat -A PREROUTING -p tcp --dport 8080
-j DNAT --to-destination 172.17.0.2:80
Шаг 10. dockerd возвращает container ID клиенту.
Контейнер работает. Shim — его родитель, держит stdio.
Время от docker run до старта entrypoint — обычно 50–200 мс на тёплом образе. Из них на runc приходится ~10 мс, остальное — overhead containerd, snapshotter, networking.
Networking: bridge mode¶
По умолчанию Docker создаёт виртуальный bridge docker0 и подключает каждый контейнер к нему через свою
veth pair. Это даёт изолированную подсеть (например, 172.17.0.0/16), внутренний DNS между контейнерами и NAT
для исходящего трафика.
┌───────────────────────────────────────────────────────────────────────┐
│ host (root netns) │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ bridge docker0 (172.17.0.1/16) │ │
│ │ ┌─────────┬─────────┬─────────┐ │ │
│ │ │ vethA │ vethB │ vethC │ │ │
│ │ └────╥────┴────╥────┴────╥────┘ │ │
│ └───────╫─────────╫─────────╫──────────┘ │
│ ║ ║ ║ │
│ ┌──────────╫─────────╫─────────╫──────┐ │
│ │ iptables ║ ║ ║ │ (FORWARD chain │
│ │ -A PREROUTING -p tcp --dport 8080 │ проверяет, что │
│ │ -j DNAT --to 172.17.0.2:80 │ bridge → eth0 │
│ │ -A POSTROUTING -s 172.17.0.0/16 │ разрешён) │
│ │ -j MASQUERADE │ │
│ └──────────╫─────────╫─────────╫──────┘ │
│ ║ ║ ║ │
│ ┌────────╨──┐ ┌────╨───┐ ┌───╨───┐ eth0 ──▶ интернет │
│ │ web (.0.2)│ │db(.0.3)│ │app(.4)│ │
│ │ eth0 = veth (peer-конец, в контейнерном netns) │
│ └───────────┘ └────────┘ └───────┘ │
└───────────────────────────────────────────────────────────────────────┘
Что делает Docker под капотом¶
# Создание bridge при старте dockerd (один раз)
ip link add docker0 type bridge
ip addr add 172.17.0.1/16 dev docker0
ip link set docker0 up
iptables -t nat -A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
# Для каждого контейнера:
ip link add vethA type veth peer name vethB
ip link set vethA master docker0
ip link set vethA up
ip link set vethB netns <container-pid>
nsenter -t <container-pid> -n ip link set vethB name eth0
nsenter -t <container-pid> -n ip addr add 172.17.0.2/16 dev eth0
nsenter -t <container-pid> -n ip link set eth0 up
nsenter -t <container-pid> -n ip route add default via 172.17.0.1
# Для опубликованного порта (-p 8080:80):
iptables -t nat -A PREROUTING -p tcp --dport 8080 \
-j DNAT --to-destination 172.17.0.2:80
iptables -t nat -A OUTPUT -p tcp --dport 8080 \
-d 127.0.0.1 -j DNAT --to-destination 172.17.0.2:80
iptables -A FORWARD -d 172.17.0.2 -p tcp --dport 80 -j ACCEPT
Параллельно запускается docker-proxy — userspace TCP-proxy, который слушает 0.0.0.0:8080 и форвардит на
172.17.0.2:80. Зачем — если iptables и так делает DNAT? Затем, что DNAT не работает для loopback (localhost:8080
не транслируется через PREROUTING без специального правила в OUTPUT). docker-proxy покрывает этот случай. Многие
production-инсталляции отключают его (--userland-proxy=false) ради экономии памяти — тогда нужно настраивать
iptables OUTPUT-правила вручную.
Другие сетевые режимы¶
| Режим | Что делает |
|---|---|
bridge |
дефолт; контейнер в своём netns, подключён к docker0 через veth |
host |
контейнер в host'овском netns; видит все интерфейсы хоста, нет NAT, нет veth |
none |
свой netns без интерфейсов (даже lo down); пишите сеть руками или не пишите |
container:<id> |
подключиться к netns другого контейнера (sidecar-паттерн в k8s) |
macvlan |
контейнер получает собственный MAC-адрес на физическом интерфейсе |
ipvlan |
то же, но общий MAC, разные IP |
| custom | плагины CNI: Calico, Cilium, Flannel, Weave |
В Kubernetes стандартный bridge не используется — там через CNI plugin настраивается overlay-сеть между нодами (VXLAN, WireGuard, eBPF tunnel), которая даёт каждому Pod уникальный IP во всём кластере.
Rootless контейнеры¶
Классический Docker требует daemon, работающий от root: создание veth, mount overlay, namespaces, cgroup-v1 manipulations — всё требует CAP_SYS_ADMIN. Rootless контейнеры (Podman, rootless Docker, Buildah) обходятся без root через комбинацию user namespace + uidmap + slirp4netns.
┌──────────────────────────────────────────────────────────────────────┐
│ rootless container │
│ │
│ user endor (UID 1000) на host │
│ │ │
│ │ podman run alpine sh │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ user namespace (создан без root через unshare(CLONE_NEWUSER)) │ │
│ │ │ │
│ │ uid_map: 0 1000 1 ← root в ns = endor │ │
│ │ 1 100000 65536 ← из /etc/subuid │ │
│ │ │ │
│ │ внутри ns: │ │
│ │ UID 0 = root │ │
│ │ может делать unshare(CLONE_NEWNET, CLONE_NEWNS, ...) │ │
│ │ может mount tmpfs, overlay, pivot_root │ │
│ │ но НЕ может реально открыть raw socket на host, │ │
│ │ привязаться к порту <1024, │ │
│ │ создать device-node, ... │ │
│ │ │ │
│ │ network: slirp4netns или pasta │ │
│ │ userspace TCP/IP-стек, читает пакеты из tap-устройства │ │
│ │ внутри ns, отправляет наружу как обычные сокеты от endor │ │
│ │ │ │
│ │ storage: ~/.local/share/containers/storage │ │
│ │ overlayfs работает БЕЗ root, но требует kernel >= 5.11 │ │
│ │ или fuse-overlayfs как fallback │ │
│ └────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
Subordinate UIDs¶
/etc/subuid и /etc/subgid распределяют каждому пользователю диапазон UID, которые он может маппировать
внутри своего user ns:
Это означает: пользователю endor (UID 1000) разрешено в его user namespace маппировать «снаружние» UID
100000–165535 на «внутренние» 1–65536. Запись 0 1000 1 в uid_map маппирует root внутри ns на самого endor
снаружи. В сумме это даёт ему 65537 различных UID для процессов и файлов контейнера.
Сетевые ограничения¶
Без CAP_NET_ADMIN на host'е rootless не может создать veth/bridge. Решение — userspace TCP/IP stack:
- slirp4netns — программа на стороне host'а, которая работает как «модем» между tap-интерфейсом внутри netns контейнера и обычными сокетами от имени UID 1000 на host'е. Просто, но overhead 30–50% на throughput.
- pasta (passt) — современный быстрый аналог; использует zero-copy через io_uring, throughput близок к bridge.
Привязка к портам <1024 (privileged ports) внутри rootless контейнера видна как «root делает bind», но снаружи
этот процесс — обычный UID 1000, которому ядро не даст открыть, например, порт 80 на 0.0.0.0. Решения: sysctl
net.ipv4.ip_unprivileged_port_start=80 (понизить порог), либо использовать -p 8080:80 (внутренний 80 → внешний
8080, и 8080 уже unprivileged).
Когда rootless не подходит¶
- CRI-O / kubelet на ноде Kubernetes — нужны root-привилегии для управления физическими интерфейсами и большими mount-операциями.
- PID limits: pids cgroup делегируется в user.slice пользователя, но общий лимит на хосте всё ещё
pid_max. - Storage drivers: btrfs/devicemapper rootless не работают, только overlay (с native >= 5.11) или fuse-overlay.
- systemd внутри контейнера работает плохо без полного user@1000.service setup'а.
Подводные камни¶
OverlayFS и inotify через слои. Если файл лежит в lower (read-only), inotify-watcher на нём не получает события о copy-up как «modify» — он привязан к inode, а после copy-up это другой inode. Многие watch-приложения (file-syncing, hot-reload в Node.js) ломаются на overlay непредсказуемо.
OverlayFS и fsync. fsync в overlay-файле, ещё не прошедшем copy-up, сначала делает copy-up, потом fsync.
Это превращает быстрый «прочитать файл и закрыть» в «скопировать 100 MB и тогда fsync». Базы данных в качестве
storage внутри overlay — антипаттерн; volume в обход overlay — правильный путь.
iptables и FORWARD chain. Docker по умолчанию ставит net.ipv4.ip_forward=1 и добавляет правила в FORWARD.
Если на host'е работает firewall, который сбрасывает FORWARD-цепь (например, ufw), он сломает Docker-сеть.
Решение: либо не использовать ufw/firewalld с Docker, либо настраивать их совместимо.
Image bloat от плохого Dockerfile. Каждый RUN — новый слой. RUN wget X && rm X — два слоя: первый с
файлом, второй с whiteout. Файл всё равно занимает место в первом слое. Правильно — одной командой:
RUN wget X && process X && rm X (всё в одном слое).
docker exec ≠ ssh. docker exec запускает новый процесс в namespaces работающего контейнера через setns,
но наследует не все state'ы. Например, environment variables берутся не из контейнера, а из текущего shell'а
(если -e явно не передано). nsenter под капотом и нюансы те же.
Layer caching и зависимости. COPY package.json . && RUN npm install сохраняет npm-кэш между билдами, но
только если package.json не менялся (hash инструкции зависит от содержимого). COPY . . && RUN npm install
ломает кэш на любом изменении кода — npm install будет каждый раз заново.
OCI image manifest list (multi-arch). На docker pull alpine:latest сначала приходит index со списком
manifest'ов для разных архитектур; клиент сам выбирает свой. Если в кастомном реестре сломалась индексация
архитектур, можно получить ARM-образ на x86 host'е с непонятной ошибкой exec format error.
Инструменты¶
# Низкоуровневые
runc list # запущенные OCI-контейнеры
runc spec # сгенерировать config.json в bundle/
ctr -n moby c ls # containerd CLI (namespace moby = dockerd)
ctr -n moby snapshots ls # snapshot'ы (overlay-стопки)
nerdctl run nginx # CLI к containerd, как docker
# Инспекция образа
skopeo inspect docker://alpine:latest # manifest без скачивания
skopeo copy docker://alpine:latest oci:./out # скачать в OCI-формат
dive my-image:tag # просмотр слоёв и diff'ов
# Сборка без daemon
buildah bud -t my-image .
img build -t my-image .
buildkit (как daemon или daemonless)
# Низкоуровневая отладка контейнера
nsenter -t $(docker inspect -f '{{.State.Pid}}' web) -m -u -p -n /bin/sh
crictl ps # CRI клиент для k8s nodes
# Просмотр сетевой настройки
docker network ls
docker network inspect bridge
ip -d link show docker0
iptables -t nat -L -n -v
Связанные темы¶
- Linux namespaces — фундамент: изоляция mount/pid/net/uts/ipc/user/cgroup/time
- cgroups: углублённо — ограничение ресурсов контейнера; v2 hierarchy, memory/io/pids, PSI
- seccomp — фильтр syscall'ов; накладывается в самом конце runc setup перед execve
- eBPF — современный networking в Kubernetes (Cilium), наблюдение контейнеров
- systemd — systemd-nspawn как минимальный container runtime, scope для docker
Источники¶
- OCI Image Specification
- OCI Runtime Specification
- OCI Distribution Specification
- runc source —
libcontainer/,init.go - containerd architecture
- Liz Rice, «Containers from Scratch» — запись на YouTube
- overlayfs — kernel.org
- Docker networking deep dive и
man iptables-extensions(DNAT, MASQUERADE) - Rootless containers — rootlesscontaine.rs