Этот урок - про устройство docker архитектуры под капотом. Разберём, что такое dockerd, containerd, runc, зачем нужны shim-процессы, что стоит за аббревиатурой OCI и почему Kubernetes выкинул docker daemon и теперь ходит в containerd напрямую. Это не абстрактная теория - это карта, по которой ты будешь искать, где именно сломалось.
Слои: от docker CLI до системного вызова
Главное, что нужно усвоить: docker - это не одна программа, а стек. Запрос проходит сверху вниз через несколько слоёв, каждый из которых решает свою задачу и общается только с соседом.
- docker (CLI) - тонкий клиент. Сам ничего не запускает. Он берёт твою команду, превращает её в HTTP-запрос к REST API и отправляет демону. По умолчанию - через unix-сокет /var/run/docker.sock. Клиент и демон - разные процессы, и они даже могут жить на разных машинах (переменная DOCKER_HOST).
- dockerd (демон) - тот самый docker daemon, фоновый сервис, который слушает API. Он отвечает за высокоуровневые вещи: сборку образов, сети, volume-тома, аутентификацию в реестрах, REST API. Но сами контейнеры он своими руками не запускает - делегирует ниже.
- containerd - менеджер жизненного цикла контейнеров и образов. Тянет образы из реестра, распаковывает слои в снапшоты на диске, управляет состоянием контейнеров (создан, запущен, остановлен), отдаёт метрики. Это отдельный демон со своим сокетом /run/containerd/containerd.sock и своим CLI - ctr.
- runc - низкоуровневый runtime. Эталонная реализация OCI Runtime Spec. Маленькая утилита командной строки, которая берёт спецификацию (config.json + распакованный rootfs) и через примитивы ядра Linux - namespaces, cgroups, capabilities, seccomp - создаёт изолированный процесс. После запуска контейнера runc завершается. Это важная деталь, к ней вернёмся.
- ядро Linux - финальный исполнитель. Никакого docker внутри ядра нет. Контейнер - это обычный процесс, которому ядро через namespaces показало урезанную картину мира (свою файловую систему, сеть, список процессов), а через cgroups ограничило ресурсы.

Что такое OCI и зачем три спецификации
Раньше Docker был и форматом образа, и средой запуска, и протоколом - всё проприетарное и слитое в один проект. Это пугало индустрию: никто не хотел, чтобы целый мир контейнеров зависел от одной компании. Так в 2015 году появилась OCI - Open Container Initiative, которая вынесла ключевые вещи в открытые стандарты. Связка oci docker - это и есть фундамент, на котором стоит совместимость.
OCI - это три отдельные спецификации:
- Image Spec - как устроен образ. Манифест, конфиг, слои (tar-архивы со своими SHA256-дайджестами). Именно поэтому образ, собранный Docker, спокойно запускается через Podman или containerd - формат общий.
- Runtime Spec - как из распакованного образа запустить контейнер. Описывает config.json: какой процесс стартовать, какие namespaces создать, какие монтирования сделать, какие лимиты cgroups выставить. runc реализует именно этот стандарт. Поэтому его можно заменить на crun (написан на C, быстрее стартует), gVisor (runsc, песочница с перехватом сисколлов) или Kata (микро-VM) - все они понимают один и тот же config.json.
- Distribution Spec - как образы хранятся и передаются по сети. Протокол registry: docker pull и docker push общаются с Docker Hub, Yandex Container Registry или VK Cloud Container Registry по одному и тому же стандарту. Для RU-реалий это удобно - переключаешь реестр на зеркало, и весь инструментарий работает без переделки.
Shim-процессы: почему контейнер не умирает вместе с runc
Теперь та самая деталь, которую все упускают. runc запускает контейнер и сразу выходит. Возникает вопрос: если runc ушёл, кто остаётся родителем контейнерного процесса? Кто соберёт его код возврата, кто удержит открытыми его stdout/stderr, кто заметит, что он упал?
Ответ - shim (полное имя процесса - containerd-shim-runc-v2). Это процесс-прокладка, который containerd ставит между собой и контейнером для каждого запущенного контейнера. Его задачи:
- Стать родителем процесса контейнера, чтобы не плодить зомби-процессы и корректно собирать exit code.
- Держать каналы ввода-вывода (stdout/stderr), чтобы логи продолжали течь даже если демон занят.
- Отвязать жизнь контейнера от жизни демона. Это ключевое: shim не дочерний для dockerd. Поэтому ты можешь сделать systemctl restart docker или обновить containerd - и работающие контейнеры не упадут. Демон вернётся и через shim снова подцепится к ним.
Проверим руками. Запустим контейнер и посмотрим дерево процессов:
Код: Выделить всё
$ docker run -d --name web nginx
$ ps -ef --forest | grep -E 'dockerd|containerd|nginx'
root 1180 1 /usr/bin/dockerd -H fd://
root 1205 1 /usr/bin/containerd
root 3410 1205 \_ /usr/bin/containerd-shim-runc-v2 -namespace moby -id a1b2c3...
root 3431 3410 \_ nginx: master process nginx -g daemon off;
- dockerd (PID 1180) и containerd (PID 1205) оба имеют родителя 1 - это systemd. Они независимые демоны, а не родитель-потомок.
- shim (PID 3410) - дочерний для containerd (PPID 1205), namespace moby (так Docker называет своё пространство в containerd).
- nginx (PID 3431) - дочерний для shim (PPID 3410), а не для dockerd. Вот оно, отвязывание. Убей dockerd - nginx останется жить под shim.
Самый громкий сюжет последних лет, который до сих пор пугает новичков заголовками вроде Kubernetes drops Docker. Разберём без паники.
Kubernetes общается с runtime через стандартный интерфейс CRI - Container Runtime Interface. Это gRPC-контракт: kubelet говорит запусти под, останови контейнер, дай логи - и ждёт ответа по стандарту. containerd CRI реализует нативно. А dockerd - нет: Docker появился раньше CRI и никогда его не поддерживал.
Чтобы как-то скрестить kubelet с Docker, в Kubernetes держали костыль - dockershim, переходник CRI -> Docker API. Но это был лишний слой, который надо было сопровождать, тестировать и чинить. А самое нелепое - под капотом dockerd всё равно вызывал containerd. То есть путь был издевательский: kubelet -> dockershim -> dockerd -> containerd -> shim -> runc. Два лишних звена, которые ничего не добавляли для Kubernetes.
Логичный итог: dockershim объявили устаревшим в Kubernetes 1.20 и полностью удалили в 1.24. Теперь путь короткий и прямой: kubelet -> containerd (через CRI) -> shim -> runc. dockerd выброшен из цепочки как лишний посредник.
Что это значит на практике, и где паника лишняя:
- Твои образы продолжают работать. Они в формате OCI Image Spec, containerd понимает их напрямую. Ничего пересобирать не надо.
- Это не значит, что Docker мёртв. На рабочей машине docker build, docker compose, локальная отладка - всё на месте и удобно.
- Изменилось одно: в проде кластера за запуск контейнеров отвечает не docker daemon, а containerd сам по себе. Если делаешь docker ps на ноде Kubernetes - ты ничего не увидишь, потому что Docker там просто нет. Контейнеры смотрят через crictl ps или ctr.
Раз containerd умеет всё сам, возникает идея: а зачем вообще dockerd на сервере, где не нужны сборка и compose? Так появился nerdctl - CLI поверх containerd с интерфейсом, идентичным docker. Команды один в один:
Код: Выделить всё
# то же, что docker, но напрямую через containerd, без dockerd
$ nerdctl run -d -p 8080:80 --name web nginx
$ nerdctl ps
$ nerdctl build -t myapp:1.0 . # сборка через встроенный BuildKit
$ nerdctl compose up -d # да, compose тоже есть
docker info: где всё это лежит на диске
Теория без практики бесполезна. Команда docker info показывает, какие именно runtime собраны у тебя в стеке и где их данные:
Код: Выделить всё
$ docker info
Server Version: 27.x.x
Storage Driver: overlay2
Backing Filesystem: extfs
Cgroup Driver: systemd
Cgroup Version: 2
Runtimes: io.containerd.runc.v2 runc
Default Runtime: runc
containerd version: ...
runc version: ...
Docker Root Dir: /var/lib/docker
- Storage Driver: overlay2 - механизм слоёв образа. overlay2 - современный дефолт, объединяет read-only слои образа и writable-слой контейнера через overlayfs ядра.
- Cgroup Version: 2 - на современных системах это cgroup v2 (единая иерархия). Если видишь v1 - система старая, часть лимитов работает иначе.
- Runtimes / Default Runtime: runc - какой OCI runtime используется по умолчанию. Сюда можно добавить crun или runsc и переключать через docker run --runtime.
- Docker Root Dir: /var/lib/docker - корень всех данных Docker.
Код: Выделить всё
$ sudo ls /var/lib/docker
buildkit/ containers/ image/ network/ overlay2/ volumes/
- overlay2/ - слои образов и writable-слои контейнеров (обычно самое тяжёлое).
- containers/ - метаданные и логи каждого контейнера (тут лежат те самые json-логи).
- image/ - метаданные образов, дерево слоёв, дайджесты.
- volumes/ - named volumes с данными.
Зачем инженеру знать это под капотом
Не ради эрудиции. Это прямые навыки эксплуатации:
- Диагностика. Контейнер висит в Created, docker run молчит? Проблема не в образе, а на стыке dockerd-containerd-runc. Зная слои, ты лезешь в journalctl -u containerd, а не гадаешь.
- Устойчивость. Понимаешь, что перезапуск демона не убьёт контейнеры (благодаря shim), и не боишься обновлять Docker на проде с живой нагрузкой.
- Переносимость. Знаешь, что образ - это OCI, и он переедет в Kubernetes/Podman/nerdctl без переделки.
- Безопасность и тюнинг. Можешь осознанно сменить runc на gVisor для недоверенного кода или на crun ради скорости старта.
- Паника вокруг Kubernetes drops Docker. Это про runtime на ноде, не про твои образы. Образы в OCI-формате работают везде. Не надо ничего пересобирать под containerd.
- Ожидать docker ps на ноде Kubernetes. Там Docker нет. Используй crictl ps или ctr -n k8s.io containers list.
- Думать, что рестарт dockerd убивает контейнеры. При включённом live-restore - нет, shim их удержит. Но если демон уронили kill -9 без живого containerd, можно осиротить ресурсы.
- Лечить зависший контейнер рестартом всего демона. Сначала смотри логи containerd. Часто проблема локальна - застрявший shim или upstream-сбой реестра, а ты ребутом роняешь всё хозяйство.
- Путать ctr, crictl и nerdctl. ctr - сырой отладочный CLI containerd (не для продакшен-скриптов), crictl - инструмент CRI для нод Kubernetes, nerdctl - docker-совместимый пользовательский CLI. Это три разные утилиты для трёх задач.
- Запусти docker run -d --name lab nginx.
- Выполни ps -ef --forest | grep -E 'dockerd|containerd|nginx' и найди в дереве shim. Убедись, что родитель nginx - это containerd-shim-runc-v2, а не dockerd.
- Посмотри docker info и выпиши: Storage Driver, Cgroup Version, Default Runtime, Docker Root Dir.
- Загляни в sudo ls /var/lib/docker/overlay2 и sudo ls /var/lib/docker/containers - найди папку своего контейнера и его лог-файл.
- Сделай sudo systemctl restart docker и сразу docker ps. Контейнер lab всё ещё Up? Поздравляю - ты только что увидел работу shim вживую.
- Бонус: если есть containerd-tools, выполни sudo ctr -n moby containers list и найди свой контейнер на уровне containerd, минуя dockerd.
- Перечисли четыре слоя docker архитектуры от CLI до ядра и назови, за что отвечает каждый.
- runc после запуска контейнера выходит. Кто тогда остаётся родителем процесса и почему это позволяет перезапускать docker daemon без падения контейнеров?
- Какие три спецификации входят в OCI и за что отвечает каждая?
- Почему Kubernetes удалил dockershim и как теперь выглядит путь от kubelet до контейнера?
docker - это стек, а не монолит: docker CLI -> dockerd -> containerd -> runc -> ядро. dockerd ведёт высокоуровневую работу, containerd управляет контейнерами и образами, runc запускает их по OCI Runtime Spec и выходит, а shim удерживает контейнер живым отдельно от демона. OCI с тремя спецификациями сделала всё это сменным и переносимым - поэтому Kubernetes спокойно выкинул лишний docker daemon и ходит в containerd напрямую через CRI. Понимая эту карту, ты чинишь контейнеры осознанно, а не наугад.