open atlas
↑ К треку
Docker: контейнеры как система DOCK · 02 · 04

Жизненный цикл контейнера: от docker run до пожатого выхода

Жизнь контейнера — это dockerd → containerd → shim → runc. runc настраивает namespaces/cgroups, делает exec бинаря и выходит — shim остаётся родителем PID. Стоп — это SIGTERM, 10 с grace, затем SIGKILL; сигналы доходят до PID 1, лишь если он их пробрасывает.

DOCK Senior ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

У деплоев был налог, который никто не мог объяснить: каждый раскат добавлял около десяти секунд на под чистого мёртвого времени. Приложение логировало «shutting down gracefully», оркестратор логировал SIGTERM, но контейнер всегда висел весь grace-период перед смертью. Уликой был entrypoint: ENTRYPOINT sh -c "node server.js". Shell был PID 1. Когда оркестратор слал SIGTERM, тот шёл в PID 1 — в shell, — у которого нет обработчика SIGTERM и который, как PID 1, не получает дефолтного действия от ядра, так что просто игнорировал сигнал. node его не видел, продолжал обслуживать и умирал лишь когда grace-таймер истекал и рантайм эскалировал до SIGKILL — ровно десятью секундами позже, каждый раз. Фикс — exec-форма, ENTRYPOINT ["node", "server.js"], чтобы node был PID 1 и получал SIGTERM напрямую. Десятисекундный налог был стоп-последовательностью цикла, столкнувшейся с shell, который глотал сигнал.

Цепочка, что запускает контейнер

docker run не порождает твой процесс напрямую. Есть продуманная цепочка, у каждого слоя одна работа:

  • dockerd (демон Docker) — API и управление образами; он не родительствует контейнерам.
  • containerd — супервизор контейнеров: тянет образы, управляет циклом, говорит с рантаймом.
  • containerd-shim (shim — тонкая прокладка-посредник) — по одному на контейнер; он — стойкий родитель процесса контейнера. Shim — то, что даёт containerd (и даже dockerd) рестартовать без убийства работающих контейнеров, держит открытым stdio и сообщает код выхода.
  • runc — OCI-рантайм (OCI — Open Container Initiative, открытый стандарт формата контейнера и его запуска). Он делает реальную работу ядра: clone()/unshare() namespaces, пишет лимиты cgroup, ставит capabilities и seccomp-фильтр, pivot_root в образ, затем execve() твоего бинаря, чтобы он заменил сам runc. После runc выходит — он не работающий супервизор, а лишь инструмент настройки.

Вместе эти четыре слоя разделяют ответственность настолько чётко, что каждый может упасть или обновиться независимо: dockerd можно рестартовать, не трогая работающие контейнеры; containerd можно пропатчить, пока shim держит процесс; runc можно заменить другим OCI-рантаймом, не меняя цепочку супервизоров. Без shim любой рестарт containerd осиротил бы все работающие контейнеры.

Последнее удивляет людей: после старта runc исчез. Твой процесс теперь PID 1 в своём namespace, родитель (с точки зрения хоста) — shim. Поэтому контейнеры дёшево гонять тысячами: нет толстого по-контейнерного демона, лишь крошечный shim и твой процесс прямо на хостовом ядре.

dockerd ──API──▶ containerd ──▶ containerd-shim-runc-v2  (остаётся родителем)

                                      └─▶ runc create/start
                                            ├─ unshare namespaces
                                            ├─ применить лимиты cgroup
                                            ├─ задать caps + seccomp
                                            ├─ pivot_root → образ
                                            └─ execve(entrypoint)  ← становится PID 1
                                      runc выходит; shim пожинает код выхода

Стоп: SIGTERM, grace, SIGKILL

Остановка контейнера — не убийство, а переговоры с дедлайном. docker stop (и завершение пода в Kubernetes) шлёт SIGTERM в PID 1, ждёт grace-период (дефолт Docker 10 с; terminationGracePeriodSeconds Kubernetes дефолт 30 с), и если процесс ещё жив, шлёт SIGKILL, который процесс не может поймать или проигнорировать. Контракт таков: PID 1 должен обработать SIGTERM, перестать брать новую работу, слить запросы в полёте и выйти до таймера. Загвоздка — та, что из Hook: сигналы доставляются в PID 1, и только в PID 1 — если PID 1 это shell или обёртка, не пробрасывающая сигналы детям, реальный сервер никогда не услышит SIGTERM, и grace-период — чистая латентность перед SIGKILL. Это операционная причина, почему exec-форма (ENTRYPOINT ["app"]) и init-шимы (--init, tini) важны: они ставят сигнало-осведомлённый процесс на PID 1.

Викторина

После того как runc запустил контейнер, какой процесс — стойкий родитель PID 1 контейнера на хосте, и какую роль runc играет потом?

Почему это работает

Зачем вообще shim — почему не дать containerd быть родителем? Развязка. Будь containerd прямым родителем, рестарт или апгрейд containerd осиротил бы или убил каждый работающий контейнер, и нельзя было бы пропатчить супервизор без простоя. Shim — крошечный, долгоживущий процесс, владеющий stdio и статусом выхода ровно одного контейнера, так что containerd (и dockerd) может упасть, рестартовать или обновиться, пока твои контейнеры работают, а их коды выхода всё ещё захватываются при завершении. Это тот же инстинкт разделения ответственности, что и сама цепочка цикла: каждый слой может отказать или быть заменён без снятия остальных.

Пауза, рестарт и состояния

Если ты видишь контейнер в docker ps -a спустя долгое время после остановки и удивляешься, почему он ещё там, — ответ в том, как рантайм отслеживает состояния.

Между created и exited контейнер проходит состояния, которые рантайм отслеживает: created (namespaces и cgroups настроены, entrypoint ещё не работает), running, paused, stopped/exited. Пауза — свой механизм: freezer cgroup (cgroup.freeze) приостанавливает каждый процесс в cgroup без отправки сигнала, так что процесс не может среагировать и никакой обработчик SIGSTOP не запускается; он заморожен посреди инструкции и оттаивает ровно там, где был. Политики рестарта (--restart=on-failure, unless-stopped, always) — это containerd, следящий за кодом выхода и перезапускающий ту же цепочку настройки. Ключевая модель: остановленный контейнер всё ещё существует как запись (его writable-слой, его конфиг), пока не удалён — docker ps -a его показывает — поэтому логи и код выхода завершённого контейнера остаются инспектируемыми, и поэтому рестарт пере-exec-ает entrypoint, а не возобновляет замороженный процесс.

Викторина

Entrypoint контейнера — shell-обёртка (sh -c 'без exec'), не пробрасывающая сигналы. При docker stop что происходит в grace-период и почему?

Вспомните перед уходом
  1. 01
    Пройди стартовую цепочку от docker run до работающего PID 1, называя работу каждого слоя и что runc делает и чем становится.
  2. 02
    Опиши стоп-последовательность и объясни, почему shell-entrypoint может заставить контейнер умирать весь grace-период.
Итог

Жизнь контейнера — цепочка сотрудничающих процессов, а не монолит. docker run достигает dockerd, API-демона, что управляет образами, но намеренно не родительствует контейнерам; dockerd зовёт containerd, супервизор, тянущий образы и ведущий цикл; containerd стартует containerd-shim на каждый контейнер, и этот shim — стойкий родитель, держащий stdio и код выхода контейнера, чтобы containerd и dockerd могли рестартовать или обновляться без убийства работающих контейнеров. Shim вызывает runc, OCI-рантайм, выполняющий реальную работу ядра: unshare namespaces, запись лимитов cgroup, установка capabilities и seccomp-фильтра, pivot_root в образ и, наконец, execve() entrypoint, чтобы твой бинарь заменил runc и стал PID 1. После runc выходит — это инструмент настройки, поэтому гонять тысячи контейнеров дёшево: крошечный shim и твой процесс прямо на общем ядре, без по-контейнерного демона. Остановка — переговоры с дедлайном: SIGTERM идёт в PID 1, рантайм ждёт grace-период (10 с в Docker, 30 с в Kubernetes), затем шлёт неперехватываемый SIGKILL. Поскольку сигналы доходят лишь до PID 1, shell или обёртка, не пробрасывающая их, глотает SIGTERM и сжигает весь grace-период мёртвой латентностью перед убийством — лечится exec-формой или init-шимом, сидящим сигнало-осведомлённо на PID 1. Пауза — это freezer cgroup, приостанавливающий cgroup без всякого сигнала; политики рестарта перезапускают ту же цепочку настройки; а остановленный контейнер сохраняется как инспектируемая запись, с логами и кодом выхода, пока не удалён. Теперь, когда раскат добавляет лишние секунды на каждый под или контейнер игнорирует docker stop, первым делом смотри на entrypoint: это твоё приложение на PID 1 или shell-обёртка?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.