Главная/Основы Containerization/Урок

2.6. Container runtime и OCI

Цели

После этого материала вы сможете:

  • описать цепочку компонентов от docker run до запущенного процесса;
  • объяснить роль каждого: CLI, dockerd, containerd, shim, runc;
  • объяснить, зачем нужен shim и что даёт его существование;
  • объяснить, что стандартизируют OCI Image Specification и OCI Runtime Specification;
  • найти OCI bundle работающего container и прочитать его config.json;
  • объяснить, что означало «Kubernetes отказался от Docker» и почему образы это не затронуло.

Предварительные знания

Ключевые термины

ТерминОбъяснение
container runtimeПрограмма, создающая и запускающая containers
high-level runtimeУправляет образами, сетью, хранением, жизненным циклом. Пример: containerd
low-level runtimeНепосредственно создаёт container из готового bundle. Пример: runc
OCIOpen Container Initiative — организация, разрабатывающая открытые стандарты для containers
OCI bundleКаталог с корневой файловой системой и файлом config.json — вход для low-level runtime
shimПроцесс-посредник между containerd и container, переживающий перезапуск родителя
CRIContainer Runtime Interface — интерфейс, через который Kubernetes общается с runtime

Теория

Зачем нужна цепочка из четырёх компонентов

Первый вопрос, который возникает при виде схемы Docker: почему нельзя проще? Ответ — историей и разделением ответственности.

text
  docker (CLI)
      │  HTTP через /var/run/docker.sock
      ▼
  dockerd                      образы, сети, volumes, API, сборка
      │  gRPC через /run/containerd/containerd.sock
      ▼
  containerd                   жизненный цикл containers, распространение образов
      │  порождает по одному на container
      ▼
  containerd-shim-runc-v2      удерживает stdio и exit code, переживает перезапуск
      │  вызывает однократно
      ▼
  runc                         создаёт namespaces/cgroups, выполняет execve и завершается
      │
      ▼
  процесс приложения

Ответственность распределена так:

КомпонентОтвечает за
CLIРазбор команд, формирование HTTP-запросов, форматирование вывода
dockerdСборка образов, управление сетями и volumes, Docker API, оркестрация всего остального
containerdЗагрузка и хранение образов, жизненный цикл containers, снапшоттеры
shimБыть родителем процесса container, удерживать его потоки ввода-вывода и код возврата
runcОдин вызов: создать namespaces, cgroup, применить capabilities и seccomp, выполнить execve()

Разделение не декоративное. Оно даёт заменяемость: containerd работает и без Docker (его использует Kubernetes напрямую), runc заменяется на другие реализации (crun, gVisor, Kata Containers) без изменения остальной цепочки.

Зачем нужен shim

Самый неочевидный компонент. Его существование решает три задачи.

1. Containers переживают перезапуск daemon. runc завершается сразу после execve() — он не остаётся родителем. Если бы родителем был containerd, то его перезапуск сделал бы все процессы containers сиротами. Shim — отдельный процесс на каждый container, и он не зависит от жизни containerd.

Именно это делает возможным live-restore из урока 1.3.

2. Удержание stdio. Потоки ввода-вывода container должны существовать, даже когда никто к ним не подключён. Shim держит их открытыми, буферизует и отдаёт по запросу.

3. Сбор exit code. Когда процесс container завершается, кто-то должен вызвать wait() и получить код возврата. Это обязанность родителя — то есть shim. Он сообщает код containerd, и тот сохраняет его в состоянии container.

Проверить структуру можно на реальном дереве процессов — см. раздел «Команды и примеры».

Что стандартизирует OCI

Open Container Initiative создана в 2015 году при участии Docker. Она разрабатывает три спецификации.

OCI Image Specification описывает формат образа: manifest, config, слои, index для multi-platform образов. Благодаря ей образ, собранный Docker, запускается в Podman, containerd, CRI-O и Kubernetes без конвертации.

OCI Runtime Specification описывает формат OCI bundle и жизненный цикл container. Bundle — это каталог из двух вещей:

text
bundle/
├── config.json    полное описание: namespaces, cgroups, capabilities, mounts, процесс
└── rootfs/        корневая файловая система

Low-level runtime получает такой каталог и создаёт по нему container. Именно эта спецификация делает runc заменяемым.

OCI Distribution Specification описывает протокол обмена образами с registry — то, как работают docker push и docker pull. Разбирается в разделе 14.

Практический смысл всех трёх: инструменты сборки, хранения и запуска развязаны. Можно собрать образ в CI через BuildKit, положить в GitLab Registry, а запустить в Kubernetes на containerd — ни один шаг не знает о существовании остальных.

История «Kubernetes отказался от Docker»

Эту новость 2020 года часто понимают неверно.

Kubernetes общается с container runtime через интерфейс CRI. Docker появился раньше CRI и его не реализует. Чтобы Kubernetes мог работать с Docker, существовал компонент-переходник dockershim внутри самого Kubernetes.

Поддержка dockershim требовала усилий ради одного частного случая, притом что Docker внутри всё равно вызывал containerd, который CRI реализует напрямую. В версии 1.24 dockershim удалили: Kubernetes стал обращаться к containerd напрямую, минуя dockerd.

Что это означало на практике:

УтверждениеВерно?
Образы Docker перестали работать в KubernetesНет. Образы соответствуют OCI Image Specification, формат не менялся
Нужно пересобирать образыНет
Docker больше нельзя использовать для разработкиНет. Сборка и локальный запуск не затронуты
Из цепочки на узлах Kubernetes убрали один слойДа. Это единственное фактическое изменение

Вывод, полезный за пределами этой истории: Docker — это инструмент разработчика поверх стандартизированного стека. Стандарт (OCI) обеспечивает переносимость независимо от того, какой инструмент запускает container.


Внутренний механизм

Полная последовательность docker run

  1. CLI разбирает команду и формирует POST /containers/create.
  2. Запрос идёт в /var/run/docker.sock.
  3. dockerd проверяет наличие образа; при необходимости обращается к registry.
  4. dockerd создаёт запись о container и готовит конфигурацию.
  5. CLI отправляет POST /containers/<id>/start.
  6. dockerd через gRPC просит containerd создать задачу.
  7. containerd через снапшоттер готовит корневую файловую систему: слои образа плюс writable layer.
  8. containerd формирует OCI bundle: config.json и смонтированный rootfs.
  9. containerd порождает containerd-shim-runc-v2.
  10. Shim вызывает runc create, передавая путь к bundle.
  11. runc создаёт namespaces, cgroup, применяет capabilities, seccomp, AppArmor.
  12. Shim вызывает runc start; выполняется execve() — процесс становится приложением.
  13. runc завершается. Родителем процесса остаётся shim.
  14. Приложение работает. Shim удерживает stdio и ждёт завершения.
  15. При завершении shim собирает exit code и сообщает containerd, тот — dockerd.

Шаг 13 — ключевой для понимания: runc не остаётся в памяти. Он делает свою работу и уходит. В ps вы его не увидите.

Что внутри config.json

Это полное описание container в декларативном виде. Основные разделы:

РазделСодержимое
processКоманда, аргументы, переменные окружения, рабочий каталог, UID/GID, capabilities
rootПуть к rootfs и признак read-only
mountsВсе монтирования: /proc, /sys, /dev, volumes, bind mounts
linux.namespacesСписок создаваемых namespaces
linux.resourcesЛимиты cgroup
linux.seccompПрофиль seccomp: разрешённые системные вызовы
linux.maskedPathsПути, скрытые от container (например, /proc/kcore)
linux.readonlyPathsПути, доступные только для чтения

Всё, что изучалось в уроках 2.3–2.5, сходится в этом файле: namespaces, cgroups, capabilities, seccomp — в одном декларативном описании.


Команды и примеры

Дерево процессов

bash
docker run -d --name rt-demo alpine sleep 3600
CPID="$(docker inspect rt-demo --format '{{.State.Pid}}')"
pstree -sp "$CPID"
text
systemd(1)───containerd-shim(188082)───sleep(188104)

Обратите внимание: родитель — shim, не dockerd и не containerd. Их в цепочке нет.

Посмотрим полную командную строку shim:

bash
PPID="$(awk '{print $4}' /proc/"$CPID"/stat)"
tr '\0' ' ' < /proc/"$PPID"/cmdline; echo
text
/usr/bin/containerd-shim-runc-v2 -namespace moby -id c8f4a1b2d3e5... -address /run/containerd/containerd.sock 

В аргументах видны: пространство имён containerd (moby — историческое имя проекта Docker), ID container и адрес сокета containerd.

Убедимся, что runc не работает:

bash
pgrep -a runc || echo "runc не запущен — он завершился после создания container"
text
runc не запущен — он завершился после создания container

Все компоненты:

bash
pgrep -a 'dockerd|containerd' | grep -v shim
text
1842 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
1653 /usr/bin/containerd

Два постоянных процесса плюс по одному shim на container.

Проверка независимости от daemon

bash
docker info --format 'LiveRestore: {{.LiveRestoreEnabled}}'

Если включён live-restore:

bash
sudo systemctl restart docker
sleep 3
docker ps --filter name=rt-demo --format '{{.Names}} {{.Status}}'
ps -o pid,ppid,comm -p "$CPID"
text
rt-demo Up 2 minutes
    PID    PPID COMMAND
 188104  188082 sleep

Container продолжил работу, PID не изменился. Механизм: его родителем является shim, а shim перезапуск dockerd не затрагивает.

Обращение к containerd напрямую

Утилита ctr — низкоуровневый клиент containerd, поставляется вместе с ним:

bash
sudo ctr --namespace moby containers list | head -3
text
CONTAINER                                                           IMAGE    RUNTIME
c8f4a1b2d3e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1    -        io.containerd.runc.v2
bash
sudo ctr --namespace moby tasks list | head -3
text
TASK                                                                PID       STATUS
c8f4a1b2d3e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1    188104    RUNNING

Виден тот же PID, что мы получили через docker inspect. Это подтверждает: containerd управляет тем же процессом, о котором знает Docker.

Пространство имён moby изолирует объекты Docker от других пользователей containerd — например, от Kubernetes, который использует пространство k8s.io.

Просмотр OCI bundle

bash
CID="$(docker inspect rt-demo --format '{{.Id}}')"
sudo ctr --namespace moby containers info "$CID" | head -40

Полная спецификация в формате config.json доступна так:

bash
sudo ctr --namespace moby containers info "$CID" \
    | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['Spec'], indent=2))" \
    | head -60

Извлечём конкретные разделы. Namespaces:

bash
sudo ctr --namespace moby containers info "$CID" \
    | python3 -c "
import json, sys
spec = json.load(sys.stdin)['Spec']
for ns in spec['linux']['namespaces']:
    print(ns['type'])
"
text
mount
network
uts
pid
ipc
cgroup

Ровно шесть namespaces, которые мы наблюдали в уроке 2.3 — теперь видно, откуда они берутся: из декларативной спецификации.

Capabilities:

bash
sudo ctr --namespace moby containers info "$CID" \
    | python3 -c "
import json, sys
spec = json.load(sys.stdin)['Spec']
caps = spec['process']['capabilities']['bounding']
print(f'Всего: {len(caps)}')
for c in caps: print(' ', c)
"
text
Всего: 14
  CAP_CHOWN
  CAP_DAC_OVERRIDE
  CAP_FSETID
  CAP_FOWNER
  CAP_MKNOD
  CAP_NET_RAW
  CAP_SETGID
  CAP_SETUID
  CAP_SETFCAP
  CAP_SETPCAP
  CAP_NET_BIND_SERVICE
  CAP_SYS_CHROOT
  CAP_KILL
  CAP_AUDIT_WRITE

Те самые 14 capabilities из урока 2.5, теперь — в виде исходной конфигурации, а не наблюдаемого следствия.

Скрытые пути:

bash
sudo ctr --namespace moby containers info "$CID" \
    | python3 -c "
import json, sys
spec = json.load(sys.stdin)['Spec']
for p in spec['linux']['maskedPaths'][:8]: print(p)
"
text
/proc/asound
/proc/acpi
/proc/interrupts
/proc/kcore
/proc/keys
/proc/latency_stats
/proc/timer_list
/proc/scsi

Эти пути скрыты, чтобы container не получил сведения об оборудовании и памяти host. /proc/kcore особенно важен: это образ физической памяти системы.

Уборка

bash
docker rm -f rt-demo

Практическое упражнение

Задание. Проследите путь от команды до процесса и оформите отчёт.

Для запущенного container определите:

  1. PID процесса приложения на host.
  2. PID и полную командную строку его родителя (shim).
  3. PID dockerd и containerd.
  4. Присутствует ли runc в списке процессов и почему.
  5. Список namespaces из OCI-спецификации.
  6. Число capabilities в bounding set из спецификации.
  7. Совпадает ли PID, известный containerd, с PID, известным Docker.

Подсказки

Подсказка 1

PPID процесса лежит в четвёртом поле /proc/<pid>/stat:

bash
awk '{print $4}' /proc/<pid>/stat
Подсказка 2

ctr требует указания пространства имён. Для Docker это moby:

bash
sudo ctr --namespace moby tasks list

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
#!/usr/bin/env bash
# runtime-trace.sh — цепочка компонентов от Docker до процесса.
set -euo pipefail

NAME="rt-trace-$$"
trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT

docker run -d --name "$NAME" alpine sleep 300 > /dev/null
CID="$(docker inspect "$NAME" --format '{{.Id}}')"
CPID="$(docker inspect "$NAME" --format '{{.State.Pid}}')"

echo "=== 1. Процесс приложения ==="
printf '  PID:     %s\n' "$CPID"
printf '  Команда: %s\n' "$(tr '\0' ' ' < "/proc/$CPID/cmdline")"

echo
echo "=== 2. Родитель (shim) ==="
SPID="$(awk '{print $4}' "/proc/$CPID/stat")"
printf '  PID:     %s\n' "$SPID"
printf '  Команда: %s\n' "$(sudo tr '\0' ' ' < "/proc/$SPID/cmdline")"

echo
echo "=== 3. Постоянные процессы ==="
printf '  dockerd:    PID %s\n' "$(pgrep -x dockerd | head -1)"
printf '  containerd: PID %s\n' "$(pgrep -x containerd | head -1)"

echo
echo "=== 4. runc ==="
if pgrep -x runc > /dev/null; then
    echo "  запущен (необычно — обычно завершается сразу)"
else
    echo "  не запущен: выполнил execve() и завершился, оставив процесс shim"
fi

echo
echo "=== 5. Namespaces из OCI-спецификации ==="
sudo ctr --namespace moby containers info "$CID" 2>/dev/null \
  | python3 -c "
import json,sys
spec=json.load(sys.stdin)['Spec']
for ns in spec['linux']['namespaces']: print('  ', ns['type'])
" || echo "  (ctr недоступен)"

echo
echo "=== 6. Capabilities из OCI-спецификации ==="
sudo ctr --namespace moby containers info "$CID" 2>/dev/null \
  | python3 -c "
import json,sys
spec=json.load(sys.stdin)['Spec']
print('  bounding set:', len(spec['process']['capabilities']['bounding']), 'шт.')
" || echo "  (ctr недоступен)"

echo
echo "=== 7. Сверка PID ==="
CTR_PID="$(sudo ctr --namespace moby tasks list 2>/dev/null | awk -v id="$CID" '$1==id {print $2}')"
printf '  Docker:     %s\n' "$CPID"
printf '  containerd: %s\n' "${CTR_PID:-недоступно}"
if [ "${CTR_PID:-}" = "$CPID" ]; then
    echo "  [+] совпадают — это один процесс"
fi

echo
echo "Цепочка: docker CLI → dockerd → containerd → shim → (runc) → sleep"

Ожидаемый вывод:

text
=== 1. Процесс приложения ===
  PID:     188104
  Команда: sleep 300 

=== 2. Родитель (shim) ===
  PID:     188082
  Команда: /usr/bin/containerd-shim-runc-v2 -namespace moby -id c8f4a1... -address /run/containerd/containerd.sock 

=== 3. Постоянные процессы ===
  dockerd:    PID 1842
  containerd: PID 1653

=== 4. runc ===
  не запущен: выполнил execve() и завершился, оставив процесс shim

=== 5. Namespaces из OCI-спецификации ===
   mount
   network
   uts
   pid
   ipc
   cgroup

=== 6. Capabilities из OCI-спецификации ===
  bounding set: 14 шт.

=== 7. Сверка PID ===
  Docker:     188104
  containerd: 188082
  [+] совпадают — это один процесс

Цепочка: docker CLI → dockerd → containerd → shim → (runc) → sleep

Проверка результата

bash
docker run -d --name check alpine sleep 60
CPID=$(docker inspect check --format '{{.State.Pid}}')
pstree -sp "$CPID"
docker rm -f check

В выводе должен присутствовать containerd-shim как родитель процесса. Если вы это видите и можете объяснить, почему там нет dockerd и runc, — материал усвоен.

Типичные ошибки

ОшибкаПричинаИсправление
Ожидание увидеть runc в psКажется, что раз он запускает container, то и работает вместе с нимrunc завершается после execve(); родителем остаётся shim
Мнение, что dockerd — родитель процессов containersЛогично по названиюРодитель — shim; это и делает возможным live-restore
«Kubernetes отказался от Docker, образы надо пересобирать»Смешаны формат образа и runtimeФормат стандартизирован OCI; изменилась только цепочка на узлах
Использование ctr без указания namespaceПо умолчанию используется default, а Docker работает в mobyВсегда указывать --namespace moby
Управление containers Docker через ctrDocker хранит собственное состояние; изменения мимо него рассогласуют егоctr — только для чтения и диагностики
Мнение, что containerd — часть DockerИсторически так и было; сейчас это независимый проектcontainerd используется и без Docker: Kubernetes обращается к нему напрямую

Контрольные вопросы

На понимание:

  1. Почему в цепочке четыре компонента, а не один?
  2. Зачем нужен shim и что сломается без него?
  3. Почему runc не виден в списке процессов?
  4. Что стандартизирует OCI Runtime Specification и что это даёт на практике?
  5. Почему отказ Kubernetes от dockershim не потребовал пересборки образов?

На применение:

  1. Как найти родителя процесса container и убедиться, что это shim?
  2. Как посмотреть OCI-спецификацию работающего container?
  3. Как проверить, что containerd знает о том же процессе, что и Docker?

На диагностику:

  1. После systemctl restart docker все containers остановились. Какая настройка это объясняет?
  2. Нужно узнать, какой профиль seccomp применён к работающему container. Где это посмотреть?

Краткое резюме

  1. Цепочка: CLI → dockerdcontainerd → shim → runc → процесс приложения.
  2. Ответственность разделена: сборка и API у dockerd, жизненный цикл у containerd, создание container у runc.
  3. Shim — отдельный процесс на каждый container; он удерживает stdio, собирает exit code и переживает перезапуск daemon.
  4. runc завершается сразу после execve() и не остаётся в памяти.
  5. OCI стандартизирует формат образа, формат bundle и протокол обмена с registry.
  6. OCI bundle — это config.json плюс rootfs.
  7. В config.json сходятся namespaces, cgroups, capabilities, seccomp и маскированные пути.
  8. Стандартизация развязывает инструменты сборки, хранения и запуска.
  9. Kubernetes обращается к containerd напрямую через CRI; dockershim удалён в версии 1.24.
  10. Docker — инструмент разработчика поверх стандартизированного стека; переносимость обеспечивает OCI, а не Docker.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker Engine overviewhttps://docs.docker.com/engine/Архитектура daemon–client, роль dockerd
OCI Runtime Specificationhttps://github.com/opencontainers/runtime-specФормат OCI bundle, структура config.json, жизненный цикл container
OCI Image Specificationhttps://github.com/opencontainers/image-specФормат образа: manifest, config, слои, index
OCI Distribution Specificationhttps://github.com/opencontainers/distribution-specПротокол обмена образами с registry
containerd documentationhttps://containerd.io/docs/Роль containerd, пространства имён, снапшоттеры
runchttps://github.com/opencontainers/runcРеализация OCI Runtime Specification
Don't Panic: Kubernetes and Dockerhttps://kubernetes.io/blog/2020/12/02/dont-panic-kubernetes-and-docker/Удаление dockershim, сохранение совместимости образов
Kubernetes: Container Runtimeshttps://kubernetes.io/docs/setup/production-environment/container-runtimes/CRI и поддерживаемые runtime

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Терминология Docker
Главное оглавление

Markdown на GitHub ↗