Архитектура Docker: демон, клиент, containerd, OCI

Разбираем, из каких частей на самом деле состоит «Docker» и кто за что отвечает.

Docker — это не один процесс, а связка из клиента (CLI), демона dockerd и нижележащих рантаймов containerd и runc, общающихся по стандартам OCI.

Зачем это знать на практике

Пока всё работает, можно жить с моделью «Docker — это магия, которая запускает контейнеры». Но как только сервер тормозит, контейнер не стартует или нужно подключить Kubernetes, эта магия рассыпается. Понимание архитектуры отвечает на вопросы вроде «почему контейнеры пережили перезапуск dockerd?», «что такое containerd, про который пишут в логах k8s?» и «почему один docker build грузит CPU на машине, где я ничего не запускал». Это знание превращает отладку из гадания в чтение конкретного слоя.

Клиент и демон: две разные программы

Когда вы набираете docker run nginx, исполняемый файл docker — это всего лишь клиент. Он не умеет запускать контейнеры. Он переводит вашу команду в HTTP-запрос к REST API демона dockerd и печатает ответ. По умолчанию связь идёт через Unix-сокет.

# посмотреть, к чему обращается клиент
ls -l /var/run/docker.sock

# тот же запрос «вручную», без клиента docker
curl --unix-socket /var/run/docker.sock http://localhost/version

Демон dockerd — это долгоживущий фоновый процесс (служба systemd), который принимает запросы, управляет образами, сетями, томами и делегирует запуск контейнеров вниз. Важное следствие разделения: клиент и демон могут быть на разных машинах. Если выставить переменную окружения, клиент будет управлять удалённым демоном.

# направить локальный клиент на удалённый демон
export DOCKER_HOST=ssh://user@build-server
docker ps   # покажет контейнеры на build-server, а не локально

containerd и runc: кто реально запускает контейнер

Сам dockerd тоже не запускает контейнеры напрямую. Он передаёт работу демону containerd — это отдельный высокоуровневый рантайм, который отвечает за загрузку образов, управление их хранением, сеть на уровне контейнера и жизненный цикл. containerd настолько самостоятелен, что его используют и без Docker — например, в Kubernetes.

Когда нужно фактически создать процесс контейнера, containerd вызывает runc — низкоуровневый OCI-runtime. Именно runc делает системные вызовы Linux: создаёт namespaces, настраивает cgroups, монтирует корневую файловую систему и запускает первый процесс. Сделав своё дело, runc завершается — он не остаётся «рядом» с контейнером.

Цепочка делегирования выглядит так:

docker (CLI)
   │  HTTP по /var/run/docker.sock
   ▼
dockerd  (демон Docker)
   │  gRPC
   ▼
containerd  (управление образами и жизненным циклом)
   │  на каждый контейнер
   ▼
containerd-shim  →  runc  (создаёт namespaces/cgroups и стартует процесс)
   ▼
ваш процесс (PID 1 внутри контейнера)

Между containerd и процессом стоит ещё containerd-shim — маленький процесс-«нянька». Именно он, а не runc, остаётся родителем контейнера. Благодаря shim контейнеры переживают перезапуск dockerd: демон можно обновить, а работающие контейнеры не упадут, потому что их держит shim.

Что такое OCI

OCI (Open Container Initiative) — это набор открытых спецификаций, чтобы контейнеры были совместимы между разными инструментами. Нас интересуют две из них:

  • OCI Image Spec — как устроен образ: это манифест плюс набор слоёв-архивов плюс конфигурация (какую команду запускать, переменные окружения и т.п.). Благодаря этому образ, собранный Docker, запустится в Podman или containerd.
  • OCI Runtime Spec — как из распакованного образа запустить контейнер: формат config.json, описывающего namespaces, точки монтирования, лимиты. Эту спецификацию реализует runc.

Различайте образ (статичный артефакт на диске, как .zip с метаданными) и runtime (процесс, который из этого артефакта делает живой контейнер). Образ — «рецепт», runtime — «готовка».

Как это работает под капотом

Контейнер — это не виртуальная машина. У ВМ есть собственное ядро и эмулируемое «железо» через гипервизор; ВМ грузится секунды и съедает сотни мегабайт только на ОС. Контейнер же — это обычный процесс хоста, которому ядро Linux ограничило видимость (namespaces) и ресурсы (cgroups). Своего ядра у контейнера нет — он использует ядро хоста.

# процесс контейнера виден на хосте как обычный процесс
docker run -d --name web nginx
ps -ef | grep nginx   # PID nginx есть прямо в списке процессов хоста

Поэтому контейнеры стартуют за миллисекунды и их плотность на сервере несравнимо выше, чем у ВМ. Плата за это — общее ядро: контейнеры менее изолированы, чем виртуальные машины, и запустить ядро другой ОС (например, настоящий Windows-контейнер на Linux-хосте) нельзя.

На том же разделении ролей строится и связка с другими инструментами. Поскольку containerd и runc — самостоятельные компоненты со стандартизированными интерфейсами, Kubernetes давно научился работать с containerd напрямую, минуя dockerd. Для вас как инженера это значит, что навык «читать слои» переносим: образы, собранные Docker, и понимание OCI пригодятся в любой современной контейнерной платформе, а не только в Docker.

Частые ошибки

  • Думать, что «docker» — это процесс контейнера. Команда docker завершается сразу после отправки запроса; контейнер держат shim и ваш процесс, а не клиент.
  • Считать контейнер виртуалкой. Из этого рождаются неверные ожидания: «там же своя ОS, поставлю туда другое ядро/модуль» — нет, ядро общее с хостом.
  • Путать containerd и runc. containerd — долгоживущий менеджер, runc — одноразовый инструмент запуска, который сразу выходит.
  • Бояться обновлять dockerd при работающих контейнерах. Из-за shim рестарт демона обычно безопасен для уже запущенных контейнеров.

Итоги

  • Клиент docker только отправляет команды; всю работу делает демон dockerd.
  • dockerdcontainerdcontainerd-shimrunc — цепочка делегирования запуска.
  • OCI Image Spec описывает формат образа, OCI Runtime Spec — как его запустить; благодаря им экосистема совместима.
  • Контейнер — это процесс хоста с урезанной видимостью и ресурсами, а не виртуальная машина: ядро у него общее с хостом.
Проверьте себя
1. Какой компонент непосредственно создаёт namespaces и cgroups и запускает первый процесс контейнера?
AКлиент docker
BДемон dockerd
Crunc
DREST API
2. Почему уже запущенные контейнеры обычно переживают перезапуск демона dockerd?
AПотому что они работают как виртуальные машины с собственным ядром
BПотому что их родителем остаётся containerd-shim, а не dockerd
CПотому что dockerd никогда не перезапускается
DПотому что runc держит их в памяти
3. Что описывает OCI Image Spec?
AФормат образа: манифест, слои и конфигурацию
BКак ядро Linux изолирует процессы
CПротокол общения клиента docker с демоном
DПравила работы cgroups