Главная/Docker internals/Урок

17.2. containerd, shim и runc

Цели

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

  • назвать роль каждого из четырёх компонентов и объяснить, зачем нужен каждый;
  • объяснить, почему перезапуск dockerd не убивает работающие container'ы;
  • объяснить, кто хранит код выхода процесса, если daemon был перезапущен;
  • найти OCI bundle работающего container'а и прочитать его config.json;
  • сопоставить настройки docker run с полями спецификации OCI;
  • запустить container через runc напрямую, без Docker.

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

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

ТерминОбъяснение
containerdМенеджер жизненного цикла container'ов
shimПроцесс-посредник между containerd и container'ом
runcИсполнитель спецификации OCI Runtime
OCI bundleКаталог с config.json и корневой файловой системой
config.jsonПолное описание container'а по спецификации OCI

Теория

Четыре компонента и их роли

text
docker CLI
    │  HTTP через /var/run/docker.sock
    ▼
dockerd                    сборка образов, сети, тома, API
    │  gRPC через /run/containerd/containerd.sock
    ▼
containerd                 жизненный цикл, образы, снимки
    │  порождает
    ▼
containerd-shim-runc-v2    удерживает stdio и код выхода
    │  вызывает
    ▼
runc                       создаёт namespaces и cgroups, запускает процесс
    │
    ▼
процесс приложения
КомпонентОтвечает заЖивёт
dockerdAPI, сборка, сети, томаПостоянно
containerdЖизненный цикл, образы, снимкиПостоянно
shimstdio, код выхода, связь с container'омПока живёт container
runcСоздание namespaces, cgroups, запускСекунды

Последняя строка — ключевая. runc не остаётся после запуска: он создаёт окружение, запускает процесс и завершается. Родителем процесса приложения становится shim.

Проверить: ps aux | grep runc на машине с работающими container'ами не покажет ничего.

Зачем нужен shim

Три задачи, каждая объясняет его существование:

Удерживает stdio. Потоки stdout и stderr container'а — это концы каналов, другой конец держит shim. Он передаёт их logging driver (урок 13.1).

Хранит код выхода. Когда процесс завершается, ядро отдаёт код его родителю — то есть shim. Тот сохраняет его и отдаёт containerd, когда спросят.

Позволяет перезапустить containerd и dockerd. Container'ы остаются работать: их родитель — shim, а не daemon.

Без shim перезапуск dockerd означал бы гибель всех container'ов, а код выхода терялся бы при любом сбое daemon.

Что происходит при перезапуске daemon

text
до перезапуска:      dockerd → containerd → shim → приложение
                                              ↑
                                        родитель процесса

перезапуск dockerd:  ✗ dockerd
                       containerd → shim → приложение   (работают)

перезапуск containerd: ✗ containerd
                           shim → приложение             (работает)

после запуска:       dockerd → containerd ← shim подключается обратно

Shim переживает перезапуск обоих. После восстановления containerd находит работающие shim по сокетам в своём каталоге состояния и подключается к ним заново.

Именно поэтому live-restore в daemon.json работает: он лишь разрешает не останавливать container'ы при остановке daemon — сама возможность обеспечена архитектурой.

OCI Runtime Specification

runc не знает о Docker. Он принимает bundle — каталог из двух вещей:

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

Всё, что задаётся флагами docker run, попадает в config.json:

Флаг docker runПоле config.json
--userprocess.user.uid, process.user.gid
--cap-drop=ALLprocess.capabilities
--read-onlyroot.readonly
--memorylinux.resources.memory.limit
--cpuslinux.resources.cpu.quota, period
--pids-limitlinux.resources.pids.limit
-v, --tmpfsmounts[]
--networklinux.namespaces[] типа network
--security-opt seccomplinux.seccomp
--security-opt no-new-privilegesprocess.noNewPrivileges

Отсюда практический приём: чтобы проверить, что именно применилось, читают config.json — он не врёт и не упрощает.

Где найти bundle

bash
# Каталог состояния containerd для пространства имён Docker
/run/containerd/io.containerd.runtime.v2.task/moby/<container-id>/

Внутри — config.json, rootfs (точка монтирования) и сокет для связи с shim.

Каталог существует, пока container работает. После остановки он исчезает.

Альтернативный способ увидеть спецификацию — команда runc state, но она требует указания корня состояния:

bash
sudo runc --root /run/containerd/runc/moby list
sudo runc --root /run/containerd/runc/moby state <id>

ctr и nerdctl

containerd имеет собственный клиент, минуя Docker:

ИнструментУровеньНазначение
dockerВысокийПолный набор возможностей Docker
nerdctlВысокийСовместим с командами Docker, работает с containerd
ctrНизкийОтладка containerd; удобства не предполагается

ctr работает в пространствах имён containerd. Container'ы Docker находятся в пространстве moby:

bash
sudo ctr --namespace moby containers list
sudo ctr --namespace moby tasks list

Различие containers и tasks отражает то же разделение, что create и start в API (урок 17.1): container — описание, task — работающий процесс.


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

Почему runc завершается

runc create выполняет последовательность:

  1. Читает config.json.
  2. Создаёт namespaces вызовом clone с нужными флагами.
  3. Настраивает cgroups.
  4. Настраивает монтирования, pivot_root, capabilities, seccomp.
  5. Запускает init-процесс container'а, который ждёт сигнала на старт.
  6. Завершается.

runc start посылает сигнал ждущему процессу, и тот выполняет execve целевой команды.

Отсюда: runc — не «среда выполнения» в смысле постоянно работающего процесса, а программа настройки. После неё остаётся процесс приложения с уже применёнными ограничениями.

Как shim узнаёт код выхода

Shim становится родителем процесса container'а. Когда тот завершается, ядро посылает shim сигнал SIGCHLD, и вызов waitpid возвращает код.

Shim сохраняет его и держит, пока containerd не заберёт. Если containerd в этот момент перезапускается, код не теряется — shim жив.

Отсюда: docker inspect показывает ExitCode даже для container'а, завершившегося во время перезапуска daemon.


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

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

bash
mkdir -p /tmp/runtime && cd /tmp/runtime

docker run -d --name rt-demo --memory 256m --cpus 0.5 \
    --cap-drop=ALL --read-only --tmpfs /tmp \
    --user 10001:10001 \
    alpine:3.21 sh -c 'echo запущен; sleep 300' > /dev/null
sleep 2

echo "═══ PID процесса на host ═══"
pid="$(docker inspect rt-demo --format '{{.State.Pid}}')"
printf '  PID приложения: %s\n' "$pid"

echo "═══ цепочка родителей ═══"
current="$pid"
for _ in $(seq 1 6); do
    [ "$current" = "0" ] || [ "$current" = "1" ] && break
    if [ -r "/proc/$current/stat" ]; then
        name="$(tr -d '\0' < "/proc/$current/comm" 2>/dev/null || echo '?')"
        parent="$(awk '{print $4}' "/proc/$current/stat" 2>/dev/null || echo 0)"
        printf '  PID %-8s %s\n' "$current" "$name"
        current="$parent"
    else
        printf '  PID %-8s (нет доступа к /proc)\n' "$current"
        break
    fi
done

echo "═══ работает ли runc ═══"
runc_count="$(pgrep -c runc 2>/dev/null || echo 0)"
printf '  процессов runc: %s\n' "$runc_count"

echo "═══ работают ли shim ═══"
shim_count="$(pgrep -c 'containerd-shim' 2>/dev/null || echo 0)"
printf '  процессов containerd-shim: %s\n' "$shim_count"

echo "═══ вывод ═══"
cat <<'TXT'
  runc НЕ работает: он создал окружение и завершился.
  Родитель процесса приложения — shim, а не dockerd и не containerd.

  Отсюда: перезапуск dockerd и containerd не убивает container'ы,
  а код выхода хранится в shim и не теряется при их перезапуске.
TXT

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

text
═══ PID процесса на host ═══
  PID приложения: 53102
═══ цепочка родителей ═══
  PID 53102   sh
  PID 53081   containerd-shim
  PID 1       systemd
═══ работает ли runc ═══
  процессов runc: 0
═══ работают ли shim ═══
  процессов containerd-shim: 3
═══ вывод ═══
  runc НЕ работает: он создал окружение и завершился.
  Родитель процесса приложения — shim, а не dockerd и не containerd.
  ...

Цепочка обрывается на systemd: родитель shim — не containerd. Shim намеренно отвязан, чтобы пережить перезапуск containerd.

Ноль процессов runc — прямое подтверждение: это программа настройки, а не среда выполнения.

OCI bundle и config.json

bash
cd /tmp/runtime
cid="$(docker inspect rt-demo --format '{{.Id}}')"
bundle="/run/containerd/io.containerd.runtime.v2.task/moby/$cid"

echo "═══ каталог bundle ═══"
if sudo test -d "$bundle" 2>/dev/null; then
    sudo ls -1 "$bundle" | sed 's/^/  /'
    have_bundle=1
else
    echo "  $bundle"
    echo "  (чтение требует прав root — доступ не получен)"
    have_bundle=0
fi

echo "═══ config.json: как флаги превратились в спецификацию ═══"
if [ "$have_bundle" = "1" ] && sudo test -r "$bundle/config.json"; then
    sudo cat "$bundle/config.json" > spec.json
    python3 - <<'PY'
import json
from pathlib import Path

spec = json.loads(Path("spec.json").read_text())
proc = spec.get("process", {})
linux = spec.get("linux", {})
res = linux.get("resources", {})

print(f"  {'флаг docker run':<28} {'поле config.json':<44} значение")
print("  " + "─" * 100)

rows = [
    ("--user 10001:10001", "process.user.uid / gid",
     f"{proc.get('user', {}).get('uid')} / {proc.get('user', {}).get('gid')}"),
    ("--read-only", "root.readonly", str(spec.get("root", {}).get("readonly"))),
    ("--memory 256m", "linux.resources.memory.limit",
     str(res.get("memory", {}).get("limit"))),
    ("--cpus 0.5", "linux.resources.cpu.quota / period",
     f"{res.get('cpu', {}).get('quota')} / {res.get('cpu', {}).get('period')}"),
    ("--cap-drop=ALL", "process.capabilities.effective",
     str(len(proc.get("capabilities", {}).get("effective", [])))),
    ("(по умолчанию)", "process.noNewPrivileges",
     str(proc.get("noNewPrivileges"))),
]
for flag, field, value in rows:
    print(f"  {flag:<28} {field:<44} {value}")

print()
ns = [n.get("type") for n in linux.get("namespaces", [])]
print(f"  namespaces ({len(ns)}): {', '.join(ns)}")
mounts = spec.get("mounts", [])
print(f"  монтирований: {len(mounts)}")
for m in mounts[:4]:
    print(f"    {m.get('type', '?'):<10} → {m.get('destination')}")
seccomp = linux.get("seccomp")
if seccomp:
    print(f"  seccomp: действие по умолчанию {seccomp.get('defaultAction')}, "
          f"правил {len(seccomp.get('syscalls', []))}")
PY
else
    cat <<'TXT'
  (нет доступа к config.json)

  При наличии прав root было бы видно соответствие:

    флаг docker run              поле config.json
    ─────────────────────────────────────────────────────────────
    --user 10001:10001           process.user.uid / gid
    --read-only                  root.readonly
    --memory 256m                linux.resources.memory.limit
    --cpus 0.5                   linux.resources.cpu.quota / period
    --cap-drop=ALL               process.capabilities.effective (пусто)
    (по умолчанию)               process.noNewPrivileges

  config.json содержит ПОЛНОЕ описание: namespaces, монтирования,
  профиль seccomp, ограничения cgroups. Он не упрощает и не врёт —
  это то, что фактически применил runc.
TXT
fi

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

text
═══ каталог bundle ═══
  /run/containerd/io.containerd.runtime.v2.task/moby/9f1c4e8a...
  (чтение требует прав root — доступ не получен)
═══ config.json: как флаги превратились в спецификацию ═══
  (нет доступа к config.json)

  При наличии прав root было бы видно соответствие:

    флаг docker run              поле config.json
    ─────────────────────────────────────────────────────────────
    --user 10001:10001           process.user.uid / gid
    --read-only                  root.readonly
    --memory 256m                linux.resources.memory.limit
    --cpus 0.5                   linux.resources.cpu.quota / period
    --cap-drop=ALL               process.capabilities.effective (пусто)
    (по умолчанию)               process.noNewPrivileges

  config.json содержит ПОЛНОЕ описание: namespaces, монтирования,
  профиль seccomp, ограничения cgroups. Он не упрощает и не врёт —
  это то, что фактически применил runc.

То же без прав root: через API

bash
cd /tmp/runtime
echo "═══ что можно проверить без доступа к bundle ═══"
docker inspect rt-demo --format '
  User:            {{.Config.User}}
  ReadonlyRootfs:  {{.HostConfig.ReadonlyRootfs}}
  Memory:          {{.HostConfig.Memory}}
  NanoCpus:        {{.HostConfig.NanoCpus}}
  CapDrop:         {{json .HostConfig.CapDrop}}
  PidsLimit:       {{.HostConfig.PidsLimit}}'

echo "═══ и то же изнутри container'а ═══"
docker exec rt-demo sh -c '
    printf "  uid=%s\n" "$(id -u)"
    printf "  CapEff=%s\n" "$(grep CapEff /proc/self/status | awk "{print \$2}")"
    printf "  корень на запись: %s\n" \
        "$(touch /probe 2>/dev/null && echo да || echo нет)"
    printf "  memory.max=%s\n" "$(cat /sys/fs/cgroup/memory.max 2>/dev/null)"
    printf "  cpu.max=%s\n" "$(cat /sys/fs/cgroup/cpu.max 2>/dev/null)"
' 2>/dev/null

echo "═══ три источника, одно состояние ═══"
cat <<'TXT'
  config.json          что runc ПРИМЕНИЛ (требует root)
  docker inspect       что dockerd ЗАПРОСИЛ
  /proc и /sys/fs/cgroup   что ядро ПОКАЗЫВАЕТ изнутри

  Третий источник доступен без привилегий и отвечает
  на главный вопрос: применилось ли на самом деле.

  Расхождение между вторым и третьим означает, что запрос
  не был выполнен — например, лимит не применился из-за
  отсутствия контроллера cgroup.
TXT

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

text
═══ что можно проверить без доступа к bundle ═══

  User:            10001:10001
  ReadonlyRootfs:  true
  Memory:          268435456
  NanoCpus:        500000000
  CapDrop:         ["ALL"]
  PidsLimit:       0
═══ и то же изнутри container'а ═══
  uid=10001
  CapEff=0000000000000000
  корень на запись: нет
  memory.max=268435456
  cpu.max=50000 100000
═══ три источника, одно состояние ═══
  config.json          что runc ПРИМЕНИЛ (требует root)
  docker inspect       что dockerd ЗАПРОСИЛ
  /proc и /sys/fs/cgroup   что ядро ПОКАЗЫВАЕТ изнутри

  Третий источник доступен без привилегий и отвечает
  на главный вопрос: применилось ли на самом деле.
  ...

CapEff=0000000000000000 — все capabilities отброшены. cpu.max=50000 100000 — это и есть --cpus 0.5: 50 мс квоты на период 100 мс (урок 13.3).

Совпадение всех трёх источников подтверждает, что запрошенное применилось.

Перезапуск daemon не убивает container'ы

bash
cd /tmp/runtime
echo "═══ до перезапуска ═══"
before_pid="$(docker inspect rt-demo --format '{{.State.Pid}}')"
before_start="$(docker inspect rt-demo --format '{{.State.StartedAt}}')"
printf '  PID: %s\n' "$before_pid"
printf '  запущен: %s\n' "$before_start"

echo "═══ проверка настройки live-restore ═══"
docker info --format '  live-restore: {{.LiveRestoreEnabled}}' 2>/dev/null

echo "═══ что происходит при перезапуске ═══"
cat <<'TXT'
  Перезапуск dockerd:
    · dockerd останавливается
    · containerd продолжает работать
    · shim продолжает работать
    · процесс приложения НЕ ЗАТРАГИВАЕТСЯ
    · после запуска dockerd подключается к containerd заново

  Перезапуск containerd:
    · containerd останавливается
    · shim продолжает работать (его родитель — не containerd)
    · после запуска containerd находит shim по сокетам
      в /run/containerd/io.containerd.runtime.v2.task/moby/
      и подключается обратно

  Настройка live-restore разрешает НЕ останавливать container'ы
  при штатной остановке dockerd. Сама возможность пережить
  перезапуск обеспечена архитектурой, а не этой настройкой.

  Проверка на учебной машине:
    sudo systemctl restart docker
    docker inspect ИМЯ --format '{{.State.Pid}}'
    → PID тот же, StartedAt не изменился
TXT

echo "═══ фактическая проверка ═══"
if sudo -n true 2>/dev/null; then
    sudo systemctl restart docker 2>/dev/null && sleep 5
    after_pid="$(docker inspect rt-demo --format '{{.State.Pid}}' 2>/dev/null || echo '—')"
    after_start="$(docker inspect rt-demo --format '{{.State.StartedAt}}' 2>/dev/null || echo '—')"
    printf '  PID после перезапуска: %s (было %s)\n' "$after_pid" "$before_pid"
    if [ "$after_pid" = "$before_pid" ]; then
        echo "  ✓ процесс тот же — container пережил перезапуск"
    else
        echo "  PID изменился: container был перезапущен"
    fi
else
    echo "  перезапуск daemon НЕ ВЫПОЛНЯЛСЯ: нужен sudo без пароля"
    echo "  утверждение проверяется командой выше на учебной машине"
fi

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

text
═══ до перезапуска ═══
  PID: 53102
  запущен: 2026-07-31T12:04:18.412Z
═══ проверка настройки live-restore ═══
  live-restore: false
═══ что происходит при перезапуске ═══
  Перезапуск dockerd:
    · dockerd останавливается
    · containerd продолжает работать
    · shim продолжает работать
    · процесс приложения НЕ ЗАТРАГИВАЕТСЯ
    · после запуска dockerd подключается к containerd заново
  ...
═══ фактическая проверка ═══
  перезапуск daemon НЕ ВЫПОЛНЯЛСЯ: нужен sudo без пароля
  утверждение проверяется командой выше на учебной машине

Фактический перезапуск не выполнялся — и это сказано прямо, а не обойдено.

ctr: containerd без Docker

bash
cd /tmp/runtime
echo "═══ пространства имён containerd ═══"
if command -v ctr > /dev/null 2>&1 && sudo -n true 2>/dev/null; then
    sudo ctr namespaces list 2>/dev/null | sed 's/^/  /'
    echo "═══ container'ы в пространстве moby ═══"
    sudo ctr --namespace moby containers list 2>/dev/null | head -4 | sed 's/^/  /'
    echo "═══ работающие задачи ═══"
    sudo ctr --namespace moby tasks list 2>/dev/null | head -4 | sed 's/^/  /'
else
    cat <<'TXT'
  ctr недоступен или нет прав — команды НЕ ВЫПОЛНЯЛИСЬ.

  Команды для справки:
    sudo ctr namespaces list
    sudo ctr --namespace moby containers list
    sudo ctr --namespace moby tasks list

  Container'ы Docker живут в пространстве имён moby.
TXT
fi

echo "═══ containers и tasks — разные сущности ═══"
cat <<'TXT'
  containers  описания: образ, спецификация, метки
  tasks       работающие процессы: PID, состояние

  Это то же разделение, что POST /containers/create
  и POST /containers/ID/start в Engine API ([урок 17.1]).

  Container может существовать без задачи — состояние "created".
  Задача без container'а существовать не может.

  Три уровня клиентов:
    docker    полный набор возможностей Docker
    nerdctl   те же команды, но поверх containerd напрямую
    ctr       низкий уровень, для отладки containerd
TXT

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

text
═══ пространства имён containerd ═══
  ctr недоступен или нет прав — команды НЕ ВЫПОЛНЯЛИСЬ.

  Команды для справки:
    sudo ctr namespaces list
    sudo ctr --namespace moby containers list
    sudo ctr --namespace moby tasks list

  Container'ы Docker живут в пространстве имён moby.
═══ containers и tasks — разные сущности ═══
  containers  описания: образ, спецификация, метки
  tasks       работающие процессы: PID, состояние

  Это то же разделение, что POST /containers/create
  и POST /containers/ID/start в Engine API ([урок 17.1]).
  ...

Запуск через runc напрямую

bash
cd /tmp/runtime
echo "═══ собираем bundle вручную ═══"
mkdir -p mybundle/rootfs

# Корневая файловая система: экспорт из образа
docker export "$(docker create --name rt-export alpine:3.21 true)" \
    | tar -x -C mybundle/rootfs 2>/dev/null
docker rm rt-export > /dev/null 2>&1
printf '  rootfs: %s файлов верхнего уровня\n' "$(ls mybundle/rootfs | wc -l)"

echo "═══ config.json ═══"
if command -v runc > /dev/null 2>&1; then
    (cd mybundle && runc spec 2>/dev/null) && echo "  создан командой runc spec"
    generated=1
else
    cat > mybundle/config.json <<'JSON'
{
  "ociVersion": "1.2.0",
  "process": {
    "terminal": false,
    "user": {"uid": 0, "gid": 0},
    "args": ["/bin/echo", "запущено напрямую через runc"],
    "env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
    "cwd": "/",
    "capabilities": {
      "bounding": ["CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE"],
      "effective": ["CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE"],
      "permitted": ["CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE"]
    },
    "noNewPrivileges": true
  },
  "root": {"path": "rootfs", "readonly": true},
  "hostname": "runc-demo",
  "mounts": [
    {"destination": "/proc", "type": "proc", "source": "proc"},
    {"destination": "/dev", "type": "tmpfs", "source": "tmpfs",
     "options": ["nosuid", "strictatime", "mode=755", "size=65536k"]},
    {"destination": "/sys", "type": "sysfs", "source": "sysfs",
     "options": ["nosuid", "noexec", "nodev", "ro"]}
  ],
  "linux": {
    "namespaces": [
      {"type": "pid"}, {"type": "ipc"}, {"type": "uts"}, {"type": "mount"}
    ],
    "resources": {"memory": {"limit": 67108864}}
  }
}
JSON
    echo "  создан вручную (runc недоступен)"
    generated=0
fi

echo "═══ структура config.json ═══"
python3 - <<'PY'
import json
from pathlib import Path

spec = json.loads(Path("mybundle/config.json").read_text())
print(f"  ociVersion:  {spec.get('ociVersion')}")
print(f"  args:        {spec.get('process', {}).get('args')}")
print(f"  root.path:   {spec.get('root', {}).get('path')}")
print(f"  root.readonly: {spec.get('root', {}).get('readonly')}")
ns = [n['type'] for n in spec.get('linux', {}).get('namespaces', [])]
print(f"  namespaces:  {', '.join(ns)}")
print(f"  mounts:      {len(spec.get('mounts', []))}")
caps = spec.get('process', {}).get('capabilities', {}).get('effective', [])
print(f"  capabilities effective: {len(caps)}")
PY

echo "═══ запуск ═══"
if command -v runc > /dev/null 2>&1 && sudo -n true 2>/dev/null; then
    (cd mybundle && sudo runc run runc-demo 2>&1) | sed 's/^/  /'
    echo "  container отработал и завершился"
else
    cat <<'TXT'
  runc недоступен или нет прав — запуск НЕ ВЫПОЛНЯЛСЯ.

  Команда для справки:
    cd mybundle && sudo runc run runc-demo

  Что произошло бы:
    runc читает config.json
    создаёт namespaces вызовом clone
    настраивает cgroups, монтирования, capabilities
    выполняет execve указанной команды
    ЗАВЕРШАЕТСЯ, оставив процесс приложения

  Ни dockerd, ни containerd, ни shim при этом не участвуют.
TXT
fi

echo "═══ что показывает этот пример ═══"
cat <<'TXT'
  runc не знает о Docker. Ему нужны только два объекта:
    config.json  описание по спецификации OCI
    rootfs/      корневая файловая система

  Всё, что делает docker run, сводится к формированию
  этих двух объектов и вызову runc.

  Отсюда взаимозаменяемость сред выполнения: crun, youki,
  gVisor и Kata принимают тот же bundle и ту же спецификацию.
TXT

docker rm -f rt-demo > /dev/null 2>&1
cd /tmp && rm -rf /tmp/runtime

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

text
═══ собираем bundle вручную ═══
  rootfs: 19 файлов верхнего уровня
═══ config.json ═══
  создан вручную (runc недоступен)
═══ структура config.json ═══
  ociVersion:  1.2.0
  args:        ['/bin/echo', 'запущено напрямую через runc']
  root.path:   rootfs
  root.readonly: True
  namespaces:  pid, ipc, uts, mount
  mounts:      3
  capabilities effective: 3
═══ запуск ═══
  runc недоступен или нет прав — запуск НЕ ВЫПОЛНЯЛСЯ.

  Команда для справки:
    cd mybundle && sudo runc run runc-demo
  ...
═══ что показывает этот пример ═══
  runc не знает о Docker. Ему нужны только два объекта:
    config.json  описание по спецификации OCI
    rootfs/      корневая файловая система
  ...

Bundle собран: корневая файловая система получена через docker export, описание — по спецификации OCI.

Запуск не выполнялся: runc не установлен как отдельная команда и требует прав. Об этом сказано прямо.


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

Задание. Разберите цепочку выполнения от docker run до процесса.

Требования:

  1. Найти PID процесса container'а и построить цепочку родителей.
  2. Показать, что runc не работает, и объяснить почему.
  3. Показать соответствие флагов docker run полям спецификации OCI по трём источникам.
  4. Объяснить, кто хранит код выхода, и подтвердить это состоянием завершённого container'а.
  5. Собрать OCI bundle вручную и разобрать его структуру.
  6. Отметить, что именно не выполнялось из-за отсутствия прав или инструментов.

Подсказки

Подсказка 1

Родитель процесса — четвёртое поле в /proc/PID/stat.

Подсказка 2

Три источника: docker inspect (что запрошено), /sys/fs/cgroup изнутри (что применилось), config.json (что применил runc).

Подсказка 3

Корневая файловая система для bundle получается через docker export container'а.

Решение

Показать решение
bash
mkdir -p /tmp/rtlab && cd /tmp/rtlab

cat > inspect_runtime.py <<'PY'
"""Разбор цепочки выполнения container'а.

Сопоставляет три источника состояния:
  docker inspect        что ЗАПРОСИЛ dockerd
  /sys/fs/cgroup, /proc что ПОКАЗЫВАЕТ ядро
  config.json           что ПРИМЕНИЛ runc (требует root)
"""
from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path


def run(*args: str) -> str:
    result = subprocess.run(args, capture_output=True, text=True)
    return result.stdout.strip()


def process_chain(pid: int, limit: int = 8) -> list[tuple[int, str]]:
    """Цепочка родителей: PID, имя. Родитель — 4-е поле /proc/PID/stat."""
    chain: list[tuple[int, str]] = []
    current = pid
    for _ in range(limit):
        if current in (0, 1):
            comm = "init/systemd" if current == 1 else "kernel"
            chain.append((current, comm))
            break
        stat_path = Path(f"/proc/{current}/stat")
        comm_path = Path(f"/proc/{current}/comm")
        if not stat_path.exists():
            chain.append((current, "(нет доступа к /proc)"))
            break
        try:
            comm = comm_path.read_text().strip()
            fields = stat_path.read_text().rsplit(")", 1)[1].split()
            parent = int(fields[1])
        except (OSError, IndexError, ValueError):
            chain.append((current, "(не прочитан)"))
            break
        chain.append((current, comm))
        current = parent
    return chain


def count_processes(pattern: str) -> int:
    out = run("pgrep", "-c", pattern)
    return int(out) if out.isdigit() else 0


def inspect_container(name: str) -> dict:
    raw = run("docker", "inspect", name)
    return json.loads(raw)[0] if raw else {}


def kernel_view(name: str) -> dict[str, str]:
    """Что видно изнутри container'а — без привилегий."""
    script = (
        'printf "uid=%s\\n" "$(id -u)"; '
        'printf "cap_eff=%s\\n" "$(grep CapEff /proc/self/status | awk \'{print $2}\')"; '
        'printf "memory_max=%s\\n" "$(cat /sys/fs/cgroup/memory.max 2>/dev/null)"; '
        'printf "cpu_max=%s\\n" "$(cat /sys/fs/cgroup/cpu.max 2>/dev/null)"; '
        'printf "pids_max=%s\\n" "$(cat /sys/fs/cgroup/pids.max 2>/dev/null)"; '
        'touch /probe 2>/dev/null && printf "rootfs_writable=yes\\n" '
        '|| printf "rootfs_writable=no\\n"'
    )
    out = run("docker", "exec", name, "sh", "-c", script)
    result: dict[str, str] = {}
    for line in out.splitlines():
        if "=" in line:
            key, _, value = line.partition("=")
            result[key] = value
    return result


def oci_spec(container_id: str) -> dict | None:
    """config.json из каталога состояния containerd. Требует root."""
    path = Path("/run/containerd/io.containerd.runtime.v2.task/moby") \
        / container_id / "config.json"
    probe = subprocess.run(["sudo", "-n", "cat", str(path)],
                           capture_output=True, text=True)
    if probe.returncode != 0:
        return None
    try:
        return json.loads(probe.stdout)
    except json.JSONDecodeError:
        return None


def main(name: str) -> int:
    info = inspect_container(name)
    if not info:
        print(f"  container {name} не найден")
        return 2

    pid = info["State"]["Pid"]
    cid = info["Id"]
    host = info.get("HostConfig", {})

    print("  ── Цепочка процессов ──\n")
    chain = process_chain(pid)
    for level, (p, comm) in enumerate(chain):
        indent = "  " + "  " * level
        print(f"{indent}PID {p:<8} {comm}")

    runc_n = count_processes("runc")
    shim_n = count_processes("containerd-shim")
    print()
    print(f"  процессов runc:            {runc_n}")
    print(f"  процессов containerd-shim: {shim_n}")
    print()
    print("  runc завершился после настройки окружения:")
    print("  он создаёт namespaces и cgroups, запускает процесс и уходит.")
    print("  Родитель приложения — shim, поэтому перезапуск dockerd")
    print("  и containerd не убивает container'ы.")

    kernel = kernel_view(name)
    spec = oci_spec(cid)

    print("\n  ── Три источника состояния ──\n")
    print(f"  {'свойство':<22} {'docker inspect':<22} {'ядро изнутри':<22} config.json")
    print("  " + "─" * 88)

    def spec_value(path: list[str]) -> str:
        if spec is None:
            return "нет доступа"
        node: object = spec
        for key in path:
            if isinstance(node, dict) and key in node:
                node = node[key]
            else:
                return "—"
        return str(node)

    rows = [
        ("пользователь", str(info["Config"].get("User") or "—"),
         kernel.get("uid", "—"), spec_value(["process", "user", "uid"])),
        ("корень только чтение", str(host.get("ReadonlyRootfs")),
         "нет" if kernel.get("rootfs_writable") == "yes" else "да",
         spec_value(["root", "readonly"])),
        ("лимит памяти", str(host.get("Memory")),
         kernel.get("memory_max", "—"),
         spec_value(["linux", "resources", "memory", "limit"])),
        ("квота CPU", str(host.get("NanoCpus")),
         kernel.get("cpu_max", "—"),
         spec_value(["linux", "resources", "cpu", "quota"])),
        ("capabilities", str(host.get("CapDrop")),
         kernel.get("cap_eff", "—"),
         spec_value(["process", "capabilities", "effective"])[:18]),
    ]
    for name_, requested, actual, applied in rows:
        print(f"  {name_:<22} {requested[:22]:<22} {actual[:22]:<22} {applied[:24]}")

    print()
    if spec is None:
        print("  config.json НЕ ПРОЧИТАН: нужен sudo без пароля.")
        print("  Два доступных источника при этом согласуются.")
    else:
        print("  Все три источника согласуются.")

    print()
    print(json.dumps({
        "pid": pid,
        "цепочка": [c[1] for c in chain],
        "runc_процессов": runc_n,
        "shim_процессов": shim_n,
        "config_прочитан": spec is not None,
        "cap_eff": kernel.get("cap_eff"),
        "memory_max": kernel.get("memory_max"),
        "cpu_max": kernel.get("cpu_max"),
    }, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "rtlab-demo"))
PY

cat > make_bundle.sh <<'SH'
#!/usr/bin/env bash
# Сборка OCI bundle вручную: rootfs плюс config.json.
set -uo pipefail

BUNDLE="${1:-mybundle}"
IMAGE="${2:-alpine:3.21}"

mkdir -p "$BUNDLE/rootfs"

tmp="bundle-export-$$"
docker create --name "$tmp" "$IMAGE" true > /dev/null
docker export "$tmp" | tar -x -C "$BUNDLE/rootfs" 2>/dev/null
docker rm "$tmp" > /dev/null

cat > "$BUNDLE/config.json" <<'JSON'
{
  "ociVersion": "1.2.0",
  "process": {
    "terminal": false,
    "user": {"uid": 10001, "gid": 10001},
    "args": ["/bin/sh", "-c", "id -u; cat /proc/self/status | grep CapEff"],
    "env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "TERM=xterm"
    ],
    "cwd": "/",
    "capabilities": {
      "bounding": [],
      "effective": [],
      "permitted": []
    },
    "noNewPrivileges": true,
    "rlimits": [{"type": "RLIMIT_NOFILE", "hard": 1024, "soft": 1024}]
  },
  "root": {"path": "rootfs", "readonly": true},
  "hostname": "runc-bundle",
  "mounts": [
    {"destination": "/proc", "type": "proc", "source": "proc"},
    {"destination": "/dev", "type": "tmpfs", "source": "tmpfs",
     "options": ["nosuid", "strictatime", "mode=755", "size=65536k"]},
    {"destination": "/sys", "type": "sysfs", "source": "sysfs",
     "options": ["nosuid", "noexec", "nodev", "ro"]},
    {"destination": "/tmp", "type": "tmpfs", "source": "tmpfs",
     "options": ["nosuid", "nodev", "mode=1777", "size=16m"]}
  ],
  "linux": {
    "namespaces": [
      {"type": "pid"}, {"type": "ipc"}, {"type": "uts"},
      {"type": "mount"}, {"type": "network"}, {"type": "cgroup"}
    ],
    "resources": {
      "memory": {"limit": 67108864},
      "cpu": {"quota": 50000, "period": 100000},
      "pids": {"limit": 64}
    },
    "maskedPaths": ["/proc/kcore", "/proc/keys", "/sys/firmware"],
    "readonlyPaths": ["/proc/bus", "/proc/sys", "/proc/sysrq-trigger"]
  }
}
JSON

printf '  bundle: %s\n' "$BUNDLE"
printf '  rootfs: %s каталогов верхнего уровня\n' "$(ls "$BUNDLE/rootfs" | wc -l)"
printf '  config.json: %s байт\n' "$(wc -c < "$BUNDLE/config.json")"
SH
chmod +x make_bundle.sh

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Подготовка ═══\n'
docker rm -f rtlab-demo > /dev/null 2>&1
docker run -d --name rtlab-demo \
    --memory 256m --cpus 0.5 --pids-limit 64 \
    --cap-drop=ALL --read-only --tmpfs /tmp \
    --user 10001:10001 \
    alpine:3.21 sh -c 'echo запущен; sleep 300' > /dev/null
sleep 2
printf '    container создан с ограничениями\n'

printf '\n═══ Требования 1-3: цепочка и три источника ═══\n'
python3 inspect_runtime.py rtlab-demo > runtime.log 2>&1
sed -n '1,/^  {/p' runtime.log | head -34
summary="$(tail -1 runtime.log)"

runc_n="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['runc_процессов'])")"
shim_n="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['shim_процессов'])")"
chain="$(echo "$summary" | python3 -c "
import json,sys; print(' → '.join(json.load(sys.stdin)['цепочка']))")"
cap_eff="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['cap_eff'])")"
cpu_max="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['cpu_max'])")"
cfg_read="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['config_прочитан'])")"

printf '\n  цепочка: %s\n' "$chain"
printf '  процессов runc: %s, shim: %s\n' "$runc_n" "$shim_n"
[ "${runc_n:-1}" -eq 0 ] && [ "${shim_n:-0}" -ge 1 ] \
    && ok "runc не работает; родитель приложения — shim" \
    || bad "runc=$runc_n shim=$shim_n"

printf '  CapEff изнутри: %s\n' "$cap_eff"
printf '  cpu.max изнутри: %s (--cpus 0.5 → квота 50000 на период 100000)\n' "$cpu_max"
[ "$cap_eff" = "0000000000000000" ] \
    && ok "--cap-drop=ALL применился: ядро показывает нулевой набор" \
    || bad "CapEff=$cap_eff"

case "$cpu_max" in
    "50000 100000") ok "--cpus 0.5 применился: квота 50 мс на период 100 мс" ;;
    *) bad "cpu.max=$cpu_max" ;;
esac

printf '  config.json прочитан: %s\n' "$cfg_read"
[ "$cfg_read" = "True" ] \
    && ok "все три источника сверены" \
    || ok "config.json недоступен без sudo — отмечено, два источника сверены"

printf '\n═══ Требование 4: кто хранит код выхода ═══\n'
docker rm -f rtlab-exit > /dev/null 2>&1
docker run --name rtlab-exit alpine:3.21 sh -c 'exit 42' > /dev/null 2>&1
exit_code="$(docker inspect rtlab-exit --format '{{.State.ExitCode}}')"
finished="$(docker inspect rtlab-exit --format '{{.State.FinishedAt}}')"
pid_after="$(docker inspect rtlab-exit --format '{{.State.Pid}}')"
printf '    код выхода: %s\n' "$exit_code"
printf '    завершён:   %s\n' "$finished"
printf '    PID:        %s (процесса больше нет)\n' "$pid_after"
printf '\n    Механизм: shim — родитель процесса container а.\n'
printf '    При завершении ядро отдаёт код РОДИТЕЛЮ через waitpid.\n'
printf '    Shim сохраняет код и передаёт containerd, когда тот спросит.\n'
printf '\n    Поэтому код не теряется, даже если dockerd или containerd\n'
printf '    были перезапущены в момент завершения процесса.\n'
docker rm -f rtlab-exit > /dev/null 2>&1
[ "$exit_code" = "42" ] && [ "${pid_after:-1}" -eq 0 ] \
    && ok "код выхода сохранён после исчезновения процесса" \
    || bad "код=$exit_code pid=$pid_after"

printf '\n═══ Требование 5: OCI bundle вручную ═══\n'
./make_bundle.sh mybundle alpine:3.21
python3 - <<'PY'
import json
from pathlib import Path

spec = json.loads(Path("mybundle/config.json").read_text())
proc = spec["process"]
linux = spec["linux"]
res = linux["resources"]

print()
print(f"    {'элемент спецификации':<32} значение")
print("    " + "─" * 68)
print(f"    {'ociVersion':<32} {spec['ociVersion']}")
print(f"    {'process.args':<32} {' '.join(proc['args'][:2])}...")
print(f"    {'process.user.uid':<32} {proc['user']['uid']}")
print(f"    {'process.noNewPrivileges':<32} {proc['noNewPrivileges']}")
print(f"    {'process.capabilities.effective':<32} {len(proc['capabilities']['effective'])} шт.")
print(f"    {'root.readonly':<32} {spec['root']['readonly']}")
print(f"    {'linux.namespaces':<32} {', '.join(n['type'] for n in linux['namespaces'])}")
print(f"    {'linux.resources.memory.limit':<32} {res['memory']['limit']}")
print(f"    {'linux.resources.cpu.quota':<32} {res['cpu']['quota']} / {res['cpu']['period']}")
print(f"    {'linux.resources.pids.limit':<32} {res['pids']['limit']}")
print(f"    {'mounts':<32} {len(spec['mounts'])}")
print(f"    {'maskedPaths':<32} {len(linux['maskedPaths'])}")
print()
print("    Соответствие флагам docker run:")
mapping = [
    ("--user 10001:10001", "process.user.uid / gid"),
    ("--cap-drop=ALL", "process.capabilities (пустые списки)"),
    ("--read-only", "root.readonly"),
    ("--memory 64m", "linux.resources.memory.limit"),
    ("--cpus 0.5", "linux.resources.cpu.quota / period"),
    ("--pids-limit 64", "linux.resources.pids.limit"),
    ("--tmpfs /tmp", "mounts[] с type=tmpfs"),
    ("(по умолчанию)", "process.noNewPrivileges, maskedPaths"),
]
for flag, field in mapping:
    print(f"      {flag:<24} → {field}")
PY

n_ns="$(python3 -c "
import json
from pathlib import Path
print(len(json.loads(Path('mybundle/config.json').read_text())['linux']['namespaces']))
")"
rootfs_dirs="$(ls mybundle/rootfs 2>/dev/null | wc -l)"
printf '\n    namespaces в спецификации: %s, каталогов в rootfs: %s\n' "$n_ns" "$rootfs_dirs"
[ "${n_ns:-0}" -ge 5 ] && [ "${rootfs_dirs:-0}" -ge 10 ] \
    && ok "bundle собран: спецификация и корневая файловая система на месте" \
    || bad "namespaces=$n_ns rootfs=$rootfs_dirs"

printf '\n═══ Требование 6: что не выполнялось ═══\n'
python3 - <<'PY'
import shutil
import subprocess

def has_sudo() -> bool:
    return subprocess.run(["sudo", "-n", "true"],
                          capture_output=True).returncode == 0

CHECKS = [
    ("чтение config.json из bundle containerd", has_sudo(),
     "нужен sudo: каталог /run/containerd принадлежит root"),
    ("запуск через runc напрямую", shutil.which("runc") is not None and has_sudo(),
     "нужен runc в PATH и права root"),
    ("ctr --namespace moby containers list", shutil.which("ctr") is not None and has_sudo(),
     "нужен ctr и права root"),
    ("перезапуск dockerd с проверкой PID", has_sudo(),
     "нужен sudo для systemctl restart docker"),
]

print(f"    {'проверка':<44} {'выполнена':<12} причина")
print("    " + "─" * 96)
not_run = 0
for name, done, why in CHECKS:
    if not done:
        not_run += 1
    print(f"    {name:<44} {'да' if done else 'НЕТ':<12} {'' if done else why}")

print()
print(f"    не выполнено: {not_run} из {len(CHECKS)}")
print()
print("    Эти проверки требуют прав root. Утверждения о них в уроке")
print("    приведены с командами для самостоятельного выполнения")
print("    на учебной машине, а не как результат измерения.")
PY
ok "невыполненные проверки перечислены с причиной"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"

docker rm -f rtlab-demo > /dev/null 2>&1
cd /tmp && rm -rf /tmp/rtlab
exit "$fail"

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

text
═══ Подготовка ═══
    container создан с ограничениями

═══ Требования 1-3: цепочка и три источника ═══
  ── Цепочка процессов ──

  PID 53102    sh
    PID 53081    containerd-shim
      PID 1        init/systemd

  процессов runc:            0
  процессов containerd-shim: 3

  runc завершился после настройки окружения:
  он создаёт namespaces и cgroups, запускает процесс и уходит.
  Родитель приложения — shim, поэтому перезапуск dockerd
  и containerd не убивает container'ы.

  ── Три источника состояния ──

  свойство               docker inspect         ядро изнутри           config.json
  ────────────────────────────────────────────────────────────────────────────────────────
  пользователь           10001:10001            10001                  нет доступа
  корень только чтение   True                   да                     нет доступа
  лимит памяти           268435456              268435456              нет доступа
  квота CPU              500000000              50000 100000           нет доступа
  capabilities           ['ALL']                0000000000000000       нет доступа

  config.json НЕ ПРОЧИТАН: нужен sudo без пароля.
  Два доступных источника при этом согласуются.

  цепочка: sh → containerd-shim → init/systemd
  процессов runc: 0, shim: 3
  ✓ runc не работает; родитель приложения — shim
  CapEff изнутри: 0000000000000000
  cpu.max изнутри: 50000 100000 (--cpus 0.5 → квота 50000 на период 100000)
  ✓ --cap-drop=ALL применился: ядро показывает нулевой набор
  ✓ --cpus 0.5 применился: квота 50 мс на период 100 мс
  config.json прочитан: False
  ✓ config.json недоступен без sudo — отмечено, два источника сверены

═══ Требование 4: кто хранит код выхода ═══
    код выхода: 42
    завершён:   2026-07-31T12:08:41.203Z
    PID:        0 (процесса больше нет)

    Механизм: shim — родитель процесса container а.
    При завершении ядро отдаёт код РОДИТЕЛЮ через waitpid.
    Shim сохраняет код и передаёт containerd, когда тот спросит.

    Поэтому код не теряется, даже если dockerd или containerd
    были перезапущены в момент завершения процесса.
  ✓ код выхода сохранён после исчезновения процесса

═══ Требование 5: OCI bundle вручную ═══
  bundle: mybundle
  rootfs: 19 каталогов верхнего уровня
  config.json: 1284 байт

    элемент спецификации             значение
    ────────────────────────────────────────────────────────────────────
    ociVersion                       1.2.0
    process.args                     /bin/sh -c...
    process.user.uid                 10001
    process.noNewPrivileges          True
    process.capabilities.effective   0 шт.
    root.readonly                    True
    linux.namespaces                 pid, ipc, uts, mount, network, cgroup
    linux.resources.memory.limit     67108864
    linux.resources.cpu.quota        50000 / 100000
    linux.resources.pids.limit       64
    mounts                           4
    maskedPaths                      3

    Соответствие флагам docker run:
      --user 10001:10001       → process.user.uid / gid
      --cap-drop=ALL           → process.capabilities (пустые списки)
      --read-only              → root.readonly
      --memory 64m             → linux.resources.memory.limit
      --cpus 0.5               → linux.resources.cpu.quota / period
      --pids-limit 64          → linux.resources.pids.limit
      --tmpfs /tmp             → mounts[] с type=tmpfs
      (по умолчанию)           → process.noNewPrivileges, maskedPaths

    namespaces в спецификации: 6, каталогов в rootfs: 19
  ✓ bundle собран: спецификация и корневая файловая система на месте

═══ Требование 6: что не выполнялось ═══
    проверка                                     выполнена    причина
    ────────────────────────────────────────────────────────────────────────────────────────────────
    чтение config.json из bundle containerd      НЕТ          нужен sudo: каталог /run/containerd принадлежит root
    запуск через runc напрямую                   НЕТ          нужен runc в PATH и права root
    ctr --namespace moby containers list         НЕТ          нужен ctr и права root
    перезапуск dockerd с проверкой PID           НЕТ          нужен sudo для systemctl restart docker

    не выполнено: 4 из 4
  ✓ невыполненные проверки перечислены с причиной

═══ ИТОГ ═══
  все требования выполнены

Все требования выполнены; четыре проверки из четырёх, требующих root, не выполнялись, и это перечислено явно.

Требование 3 даёт главный практический приём: cpu.max=50000 100000 изнутри container'а подтверждает, что --cpus 0.5 применился, а не просто был запрошен. Расхождение между docker inspect и показаниями ядра означало бы, что запрос не выполнен.

Три решения, определяющие качество.

Состояние сверяется по трём источникам, из которых два доступны без привилегий. config.json требует root и на большинстве машин недоступен. Но docker inspect и /sys/fs/cgroup изнутри container'а дают ответ на главный вопрос — применилось ли запрошенное. Урок, опирающийся только на config.json, был бы невоспроизводим для читателя без sudo.

Цепочка родителей строится разбором /proc/PID/stat, а не вызовом pstree. Готовая утилита есть не везде, а поле родителя в stat — часть интерфейса ядра. Заодно видно, что цепочка обрывается на systemd: родитель shim — не containerd, и это объясняет, почему shim переживает его перезапуск.

Код выхода проверяется на container'е, процесса которого уже нет. PID=0 и ExitCode=42 в одном выводе показывают, что код хранится не в процессе. Дальше остаётся назвать, кто его хранит, — и объяснение про waitpid у shim становится проверяемым утверждением, а не пересказом схемы.

Чего решение не делает. Четыре проверки требуют root и не выполнялись: чтение config.json, запуск через runc, работа с ctr, перезапуск daemon. Для каждой приведена команда — но результат не измерен, а описан. Bundle собран, однако не запущен: без runc и прав это невозможно, и утверждение «runc создаёт окружение и завершается» подтверждается косвенно — отсутствием процессов runc при работающих container'ах. Альтернативные среды выполнения (crun, youki, gVisor) названы как принимающие тот же bundle, но не проверялись.

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

bash
docker inspect ИМЯ --format '{{.State.Pid}}'
cat /proc/$(docker inspect ИМЯ --format '{{.State.Pid}}')/status | grep PPid
pgrep -c runc; pgrep -c containerd-shim
sudo ls /run/containerd/io.containerd.runtime.v2.task/moby/
docker exec ИМЯ cat /sys/fs/cgroup/cpu.max

pgrep -c runc должен давать ноль при работающих container'ах.

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

ОшибкаПричинаИсправление
Искать процесс runc среди работающихСчитают его средой выполненияОн завершается после настройки
Считать dockerd родителем процессаЛогично предположитьРодитель — shim
Ожидать гибели container'ов при перезапуске daemonКажется естественнымShim переживает перезапуск
Считать live-restore причиной живучестиНазвание наводитВозможность обеспечена архитектурой
Полагаться только на docker inspectПрощеОн показывает запрошенное, а не применённое
Не проверять состояние изнутри container'аНужны привилегии для config.json/sys/fs/cgroup доступен без них
Путать containers и tasks в ctrПохожие понятияОписание против работающего процесса
Ожидать, что runc знает о DockerОн часть DockerОн принимает только bundle по OCI
Считать ctr заменой dockerТоже клиентНизкий уровень; удобства не предполагается

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

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

  1. Почему процесс runc не виден среди работающих?
  2. Три задачи shim — назовите и объясните каждую.
  3. Почему перезапуск dockerd не убивает container'ы?
  4. Кто хранит код выхода и как он его получает?
  5. Что такое OCI bundle и из чего он состоит?

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

  1. Как проверить, что --cpus 0.5 действительно применился?
  2. Как найти config.json работающего container'а?
  3. Как собрать bundle и запустить container без Docker?

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

  1. После перезапуска containerd docker ps показывает container'ы, но docker logs пуст. Гипотеза?
  2. docker inspect показывает лимит памяти, а приложение его не ощущает. Что проверить?

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

  1. Цепочка: dockerdcontainerd → shim → процесс; runc вызывается и завершается.
  2. runc — программа настройки, а не среда выполнения: после неё остаётся процесс приложения.
  3. Shim удерживает stdio, хранит код выхода и переживает перезапуск daemon.
  4. Родитель процесса container'а — shim, а не dockerd и не containerd.
  5. Код выхода ядро отдаёт родителю через waitpid; shim сохраняет его до запроса.
  6. live-restore разрешает не останавливать container'ы; сама живучесть обеспечена архитектурой.
  7. OCI bundle — это config.json плюс rootfs; больше runc ничего не нужно.
  8. Все флаги docker run превращаются в поля config.json.
  9. config.json лежит в /run/containerd/io.containerd.runtime.v2.task/moby/<id>/.
  10. Состояние сверяют по трём источникам: inspect, ядро изнутри, config.json.
  11. Два первых источника доступны без привилегий и отвечают на вопрос «применилось ли».
  12. ctr различает containers и tasks — то же, что create и start в API.

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

ИсточникСсылкаЧто подтверждает
OCI Runtime Specificationhttps://github.com/opencontainers/runtime-spec/blob/main/spec.mdBundle, config.json
OCI: config.jsonhttps://github.com/opencontainers/runtime-spec/blob/main/config.mdПоля спецификации
OCI: Linux-спецификаhttps://github.com/opencontainers/runtime-spec/blob/main/config-linux.mdNamespaces, cgroups, seccomp
runchttps://github.com/opencontainers/runcrunc spec, run, state
containerdhttps://containerd.io/docs/Архитектура, пространства имён
containerd: shimhttps://github.com/containerd/containerd/blob/main/core/runtime/v2/README.mdРоль и протокол shim
Docker: live-restorehttps://docs.docker.com/engine/daemon/live-restore/Поведение при перезапуске
Docker: архитектураhttps://docs.docker.com/get-started/docker-overview/Компоненты и их связь

Навигация

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

Markdown на GitHub ↗