MAATRIX / Блог / Что происходит при docker run: по слоям, от команды до процесса

Что происходит при docker run: по слоям, от команды до процесса

MAATRIX

Вы набираете docker run nginx, через секунду-другую контейнер уже отвечает на порту — и это ощущается как магия, пока не начинает что-то ломаться. Контейнер не стартует, зависает на создании, ест непонятно сколько времени на первом запуске — и вот тут без понимания, что происходит внутри, диагностика превращается в гадание. Разберём весь путь по шагам: кто кого дергает, что создаётся в ядре и как это всё посмотреть руками, не веря на слово документации.

Docker CLI — это просто клиент, не более

Первое, что стоит понять: команда docker, которую вы набираете в терминале, ничего сама не делает. Это тонкий клиент, который сериализует ваш запрос в HTTP-вызов к REST API демона dockerd — по умолчанию через unix-сокет /var/run/docker.sock. Убедиться в этом легко:

curl --unix-socket /var/run/docker.sock http://localhost/containers/json

Это тот же самый запрос, что делает docker ps, просто без обвязки CLI. Отсюда важное следствие: если у вас есть доступ к docker.sock, вы фактически имеете root на хосте — демон работает от root и выполнит любую команду, которую вы ему пришлёте. Именно поэтому монтирование /var/run/docker.sock внутрь контейнера (частый паттерн для CI-раннеров и разных "docker-in-docker" решений) — это осознанный отказ от изоляции, а не мелочь.

Когда вы пишете docker run nginx, CLI собирает JSON с параметрами (образ, команда, порты, volumes, переменные окружения) и отправляет POST на /containers/create, затем /containers/{id}/start. Дальше вся работа переходит к демону.

dockerd принимает эстафету и передаёт её containerd

dockerd — не единый монолит, как многие думают. Начиная с Docker 1.11 (это было ещё в 2016 году), демон сам почти ничего не запускает — он делегирует работу containerd, отдельному процессу, который живёт своей жизнью и умеет работать без Docker вообще (Kubernetes через CRI использует именно containerd напрямую, без dockerd).

Разделение обязанностей выглядит так:

КомпонентЧто делает
dockerdAPI, сборка образов, сети Docker-стиля, volumes, CLI-совместимость
containerdУправление жизненным циклом контейнеров, образами, снапшотами через gRPC-API
containerd-shimРодительский процесс для контейнера, переживает перезапуск containerd
runcНизкоуровневый рантайм, который реально создаёт namespaces и запускает процесс

dockerd обращается к containerd через gRPC (сокет обычно /run/containerd/containerd.sock). Можно убедиться, что containerd работает независимо от dockerd:

ctr --namespace moby containers list

Флаг --namespace moby нужен, потому что Docker создаёт свои контейнеры в отдельном containerd-namespace — это не то же самое, что namespace ядра Linux, а логическое разделение внутри containerd (аналогично k8s использует namespace k8s.io).

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

containerd-shim и runc: кто на самом деле создаёт контейнер

Вот здесь начинается самое интересное. containerd не запускает контейнер сам и не остаётся его родителем — вместо этого он один раз вызывает runc create/runc start, а сам процесс контейнера "усыновляется" процессом containerd-shim.

Зачем нужен shim, если есть runc? Причина архитектурная: runc — это утилита, которая выполнила свою работу (создала контейнер) и завершилась. Если бы контейнер оставался прямым потомком containerd, перезапуск демона containerd (например, при обновлении) убил бы все запущенные контейнеры. Shim решает эту проблему: он становится PID 1 для контейнера с точки зрения хоста, держит открытыми файловые дескрипторы stdin/stdout/stderr и продолжает жить, даже если containerd или dockerd перезапустились или упали.

Проверить это можно прямо сейчас на любом сервере с запущенными контейнерами:

ps aux | grep containerd-shim

Вы увидите по одному процессу containerd-shim-runc-v2 на каждый запущенный контейнер, и его родителем (можно проверить через ps -o pid,ppid,cmd -C containerd-shim-runc-v2) окажется не containerd напрямую, а systemd/init — shim переусыновляется в root-дерево процессов через двойной fork, классический unix-приём демонизации.

Сам runc — это реализация спецификации OCI (Open Container Initiative). Его задача разовая: собрать namespaces, cgroups, файловую систему по конфигу config.json (который генерирует containerd на основе OCI runtime spec) и exec'нуть целевой процесс. После этого runc завершается, а созданный процесс остаётся жить под присмотром shim.

Namespaces: изоляция, которая создаёт иллюзию отдельной машины

То, что внутри контейнера ps aux показывает только процессы контейнера, а не всей хост-машины — заслуга Linux namespaces. Это механизм ядра, который существует независимо от Docker (namespaces появились в ядре Linux ещё в середине 2000-х, Docker их не изобретал, а грамотно скомбинировал). При старте контейнера runc создаёт для процесса набор из нескольких namespaces через системный вызов clone() с соответствующими флагами:

  • PID namespace — процесс внутри контейнера видит себя как PID 1, не видит процессы хоста и других контейнеров.
  • NET namespace — своя сетевая стойка: интерфейсы, таблица маршрутизации, iptables-правила, порты.
  • MNT namespace — своя точка зрения на смонтированные файловые системы, отдельная от хоста.
  • UTS namespace — свой hostname, не зависящий от хоста.
  • IPC namespace — изолированные механизмы межпроцессного взаимодействия (semaphores, message queues).

Есть ещё user namespace (переопределение UID/GID, часто выключен по умолчанию в Docker) и cgroup namespace, но пять перечисленных — базовый набор для обычного контейнера.

Посмотреть на namespaces процесса можно через /proc:

# найти PID процесса контейнера на хосте
docker inspect --format '{{.State.Pid}}' <container_id>

# посмотреть его namespaces
ls -la /proc/<PID>/ns/

Вы увидите symlink'и вида pid:[4026532345] — число в скобках это inode, идентифицирующий конкретный namespace. Если у двух процессов совпадает это число — они делят один и тот же namespace. Именно так можно проверить, например, что docker exec заходит в тот же PID namespace, что и основной процесс контейнера.

cgroups: не изоляция, а лимиты

Namespaces дают контейнеру иллюзию отдельной системы, но не ограничивают потребление ресурсов — процесс внутри контейнера без ограничений мог бы съесть всю память или все ядра хоста. За это отвечает второй столп контейнеризации — control groups (cgroups), тоже родом из ядра Linux, а не из Docker.

Когда вы указываете docker run --memory=512m --cpus=1 nginx, эти значения превращаются в записи в файловой системе cgroup. На системах с cgroups v2 (сейчас это стандарт для Ubuntu 22.04+, Debian 12, AlmaLinux 9) это выглядит примерно так:

cat /sys/fs/cgroup/system.slice/docker-<container_id>.scope/memory.max
cat /sys/fs/cgroup/system.slice/docker-<container_id>.scope/cpu.max

Если контейнер падает с кодом 137 — это почти всегда OOM killer, сработавший из-за превышения memory.max. Проверить это можно через dmesg сразу после падения:

dmesg | grep -i "killed process"

Если контейнер вообще не задаёте лимитов — он по умолчанию имеет доступ ко всем ресурсам хоста в рамках того, что не занято другими процессами. Это частая причина, почему один "тяжёлый" контейнер на VPS может положить соседние сервисы — cgroups без явных limits не спасают.

Файловая система образа: слои и overlay2

Отдельный механизм — как контейнер видит свою файловую систему. Образ Docker состоит из слоёв (layers), каждый из которых — это результат одной инструкции Dockerfile (RUN, COPY, ADD создают новый слой; ENV, CMD — только метаданные). Слои иммутабельны и переиспользуются между образами: если у вас пять образов на базе одного ubuntu:24.04, базовый слой физически хранится один раз.

Драйвер, который склеивает эти слои в единую файловую систему для процесса — по умолчанию overlay2 на современных дистрибутивах. Идея union filesystem простая: несколько read-only слоёв образа накладываются друг на друга, а сверху добавляется один тонкий read-write слой, уникальный для конкретного запущенного контейнера. Когда процесс внутри контейнера меняет файл из нижнего слоя — срабатывает copy-on-write: файл копируется в верхний read-write слой, и уже там правится. Оригинал в read-only слое не трогается — поэтому один и тот же образ можно безопасно использовать для десятков контейнеров одновременно.

Посмотреть слои конкретного образа:

docker history nginx:latest
docker inspect nginx:latest --format '{{json .RootFS.Layers}}' | jq

А смонтированную для конкретного контейнера overlay-файловую систему — через mount:

mount | grep overlay

Именно из-за этого механизма первый docker run с новым образом заметно медленнее последующих — Docker должен скачать (pull) все слои образа, которых ещё нет локально, распаковать их и подготовить overlay-точку монтирования. Дальше, при повторных запусках того же образа, слои уже лежат в /var/lib/docker/overlay2/, и старт — это по сути только создание нового тонкого read-write слоя плюс namespaces/cgroups, что на порядок быстрее скачивания. Сколько именно займёт первый pull — зависит от размера образа и скорости канала до registry, тут нет универсального числа, но разница между "первый раз" и "уже есть локально" обычно ощущается визуально, без секундомера. Про то, как эти слои со временем раздувают диск и что с этим делать, у нас есть отдельный разбор — как уменьшить размер Docker-образа.

Сеть: veth-пара и мост docker0

Последний кусок пазла — как контейнер получает доступ в сеть. По умолчанию Docker создаёт для контейнера собственный network namespace (о нём уже шла речь выше) и подключает его к хосту через виртуальную пару интерфейсов — veth pair. Это два "конца провода": один конец остаётся в network namespace хоста и подключается к мосту docker0, второй попадает внутрь namespace контейнера и виден там как eth0.

# на хосте: список интерфейсов, подключённых к docker0
brctl show docker0
# или без brctl (утилита устарела):
ip link show master docker0

Мост docker0 — это программный Linux bridge, который работает как обычный L2-свитч между всеми контейнерами, подключёнными к сети bridge (сеть по умолчанию для docker run без явного --network). Каждому контейнеру мост выдаёт IP из приватного диапазона (обычно 172.17.0.0/16, если не переопределено). Выход во внешний мир идёт через NAT — правила iptables в цепочке POSTROUTING подменяют исходный адрес контейнера на адрес хоста (masquerade).

Проброс портов (-p 8080:80) — это тоже iptables, конкретно правило DNAT в цепочке DOCKER, которое перенаправляет входящий трафик с порта хоста на IP контейнера внутри моста. Посмотреть эти правила:

iptables -t nat -L DOCKER -n

Если контейнер не отвечает на проброшенный порт, а внутри всё работает — почти всегда проблема именно тут: либо правило не создалось (демон Docker не успел/не смог его прописать), либо конфликтующее правило firewall (ufw, firewalld) перехватывает трафик раньше цепочки DOCKER. Про типы сетей Docker и когда какой выбирать — отдельная статья: Docker network: bridge, host, overlay.

Диагностика: "контейнер не стартует"

Зная всю цепочку, диагностику можно вести последовательно, а не наугад. Порядок, который экономит время:

  1. Проверить, дошла ли команда до dockerd. docker version должен показать и Client, и Server секции. Если Server не отвечает — проблема не в контейнере, а в самом демоне (systemctl status docker, journalctl -u docker -n 100).
  1. Проверить логи самого контейнера. docker logs <container> — если контейнер успел стартовать и упал, здесь будет stdout/stderr упавшего процесса. Часто это самое информативное место: неправильный конфиг приложения, отсутствующий файл, ошибка миграции БД.
  1. Проверить код выхода. docker inspect <container> --format '{{.State.ExitCode}}'. Код 137 — почти всегда OOM killer (см. раздел про cgroups выше). Код 1 — обычно ошибка самого приложения. Код 126/127 — команда запуска не найдена или не исполняема (частая ошибка при неверном ENTRYPOINT).
  1. Проверить события containerd/runc напрямую, если логов dockerd недостаточно:
journalctl -u containerd -n 100 --no-pager
  1. Проверить, не упирается ли проблема в файловую систему образа. docker run --rm -it <image> sh — если даже интерактивная оболочка не стартует, дело может быть в повреждённом слое образа или в нехватке места на диске под /var/lib/docker:
df -h /var/lib/docker
docker system df
  1. Если контейнер "висит" при создании, а не при запуске команды — проверьте, не заблокирован ли сетевой namespace или cgroup-иерархия каким-то сторонним инструментом (нестандартные security-модули, SELinux/AppArmor в enforcing-режиме, который блокирует конкретный syscall). dmesg | tail в момент зависания часто показывает отказ audit/AppArmor раньше, чем это всплывёт в логах Docker.

Если вы разворачиваете Docker с нуля и хотите избежать части этих проблем ещё на этапе установки, у нас есть пошаговые разборы под конкретные дистрибутивы: установка Docker на Ubuntu 24.04, на Debian 12 и на AlmaLinux 9 — там же разобраны типичные грабли конкретно на этапе установки, которые потом маскируются под "контейнер не стартует".

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему docker run в первый раз работает дольше, чем во второй?

Потому что при первом запуске Docker скачивает все слои образа из registry и распаковывает их на диск — это сетевая и дисковая операция. При повторном запуске того же образа слои уже лежат локально в /var/lib/docker/overlay2/, и старт сводится к созданию namespaces, cgroups и тонкого read-write слоя — операциям в рамках ядра, без сети.

Чем отличается containerd-shim от runc?

runc — это утилита, которая один раз создаёт namespaces/cgroups и запускает процесс контейнера, после чего сама завершается. containerd-shim — долгоживущий процесс, который "усыновляет" запущенный процесс контейнера, держит его файловые дескрипторы и остаётся его родителем в дереве процессов хоста, даже если containerd или dockerd перезапустятся.

Можно ли работать с контейнерами вообще без Docker?

Да — containerd и runc можно использовать напрямую через ctr или crictl, без dockerd и без Docker CLI. Именно так работает Kubernetes через интерфейс CRI: kubelet общается с containerd напрямую, Docker в современных кластерах чаще всего не участвует.

Что означает код выхода 137 у контейнера?

Это сигнал SIGKILL (128 + номер сигнала 9), почти всегда посланный OOM killer'ом ядра, потому что процесс внутри контейнера превысил лимит --memory. Проверяется через dmesg | grep -i "killed process" сразу после падения — запись в логе живёт недолго.

Почему ps aux на хосте показывает процессы из контейнеров?

Потому что PID namespace изолирует нумерацию и видимость процессов только изнутри контейнера — снаружи, на хосте, все процессы контейнеров остаются обычными процессами в общем дереве, просто с другим PID внутри своего namespace. Поэтому ps aux | grep containerd-shim и работает — это взгляд с "внешней" стороны.

Нужно ли вручную чистить слои образов и остановленные контейнеры?

Со временем /var/lib/docker/overlay2/ действительно накапливает неиспользуемые слои и остановленные контейнеры съедают место на диске. docker system df покажет, сколько занято, а docker system prune (осторожно, с флагом -a он удалит и неиспользуемые образы) — освободит место. Регулярная чистка особенно актуальна на VPS с ограниченным диском.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →