docker exec и inspect: вход в namespace'ы контейнера и чтение истинного состояния от движка
docker exec входит в namespace'ы работающего контейнера и даёт шелл, но запускается как сосед PID 1 — не как PID 1 — поэтому живой шелл может прятать мёртвое приложение. inspect читает правду движка: State.ExitCode, где 137 — это OOM-kill, а 143 — SIGTERM.
Пейджер сработал в 03:12: API отдавал 502, но контейнер показывал Up 3 hours, а readiness-проба была зелёной. Дежурный сделал очевидное — docker exec -it api sh, затем curl localhost:8080/health. Connection refused. Значит, приложение упало. Но ps в этом шелле показывал работающий Java-процесс, PID 1, съедающий 100% одного ядра. Приложение не упало; оно зависло в GC-спирали смерти — достаточно живое, чтобы держать PID 1 открытым и контейнер никогда не выходил, достаточно мёртвое, чтобы ничего не отдавать. Шелл солгал умолчанием: exec высадил их в совершенно новый процесс, который был соседом приложения, разделял его namespace’ы, но не его судьбу. Настоящий сигнал был в одной команде — docker inspect показал бы мерцающий флаг OOMKilled и растущий счётчик рестартов — но шелл ощущался как «я внутри приложения», и это ощущение стоило двадцати минут.
exec входит в namespace’ы — он не становится приложением
Контейнер — это не коробка; это обычный процесс хоста, обёрнутый в namespace’ы (механизм изоляции ядра Linux: PID, mount, net, IPC, UTS) и cgroup’ы (контрольные группы, ограничивающие ресурсы). docker exec просит движок породить новый процесс внутри тех же namespace’ов: тот же сетевой стек, тот же вид файловой системы, то же PID-пространство — но это свежий процесс, потомок PID 1 контейнера, а не сам PID 1. Ровно это делает nsenter --target <PID-на-хосте> --all (утилита для входа в namespace’ы произвольного процесса), когда вы вручную обходите Docker: он делает setns() в каждый namespace целевого процесса. Это один и тот же механизм; docker exec — версия через движок, с корректными cgroup’ами, а nsenter --target <pid> --all — сырой вызов ядра, к которому тянешься, когда движок завис или у контейнера нет точки входа, которую демон уважит.
# Через движок: войти в namespace'ы api, попасть потомком его PID 1
docker exec -it api sh
# Сырое ядро: найти PID на хосте, setns во ВСЕ его namespace'ы
PID=$(docker inspect --format '{{.State.Pid}}' api)
sudo nsenter --target "$PID" --all # --mount --uts --ipc --net --pidПоследствие, которое кусается: ваш шелл — не PID 1. Если приложение зависло, exec всё равно успешен — вы отдельный процесс. Если внутренние потоки приложения застряли, ваш ps их видит, а шелл продолжает отвечать. Здоровое приглашение доказывает, что жив namespace, а не что жива нагрузка. Честные проверки изнутри: ps -ef, чтобы подтвердить, что PID 1 — именно тот процесс, что вы ждёте, и он не зомби/defunct, и обращение к собственному порту приложения (curl localhost:PORT), а не вера в то, что «exec сработал, значит приложение работает».
Вы запускаете docker exec -it api sh и получаете рабочий шелл, но curl localhost:8080 внутри отвергается. Что на самом деле доказал рабочий шелл?
inspect — это истина движка, а код выхода — приговор
Когда вопрос про нагрузку, перестаньте доверять виду изнутри и читайте то, что записал движок. docker inspect возвращает структурированное состояние от демона, и .State.ExitCode — самый сжатый отчёт об инциденте, который вы получите:
docker inspect --format '{{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} restarts={{.RestartCount}}' api
# exited exit=137 oom=true restarts=14Коды выхода следуют формуле 128 + сигнал: 137 = 128 + 9 (SIGKILL) — почти всегда OOM-killer, когда OOMKilled:true, или docker kill; 143 = 128 + 15 (SIGTERM) — чистый запрос на остановку, который приложение уважило (или сработал таймер grace у docker stop). Читать 137 как «приложение упало с багом» — классическая ошибка диагноза: ничто в коде приложения не бросило исключение — контроллер памяти cgroup в ядре убил его за превышение лимита, и фикс — это лимит памяти или поиск утечки, а не стектрейс. Растущий RestartCount с oom=true — это краш-луп по памяти, а не баг логики. inspect также даёт .State.Pid (PID на хосте, мост к nsenter), разрешённые маунты, действующее окружение и лог health-проверки — факты, которые переживут исчезновение контейнера.
docker inspect показывает State.ExitCode 137 с OOMKilled true и RestartCount 14. Как правильно это прочесть?
▸Почему это работает
Почему доверять inspect, а не шеллу? Потому что шелл — это живой вид движущейся системы, а inspect — записанный вердикт движка. Шелл может показать только текущее мгновение — и если приложение уже умерло, а restart-политика его оживила, шелл показывает свежий здоровый процесс, тогда как RestartCount и последний ExitCode у inspect сохраняют тот краш, который вы реально ищете. Истина — это слой, который помнит; вид изнутри забывает в момент рестарта.
- 01Почему docker exec может дать абсолютно здоровый шелл, пока приложение внутри мертво, и при чём тут nsenter?
- 02Как читать State.ExitCode у docker inspect и почему 137 регулярно диагностируют неверно?
Контейнер — это обычный процесс хоста, огороженный namespace’ами и cgroup’ами, и docker exec порождает новый процесс внутри тех же namespace’ов — эквивалент nsenter —target <PID-на-хосте> —all через движок, где nsenter вручную делает setns() во всё это. Подвох, который каждый сеньор однажды узнаёт на своей шкуре: этот новый процесс — сосед PID 1 контейнера, а не сам PID 1, поэтому exec успешен и ваш шелл отзывчив, даже когда приложение в дедлоке или крутится. Зелёное приглашение доказывает живой namespace, никогда — нагрузку; честные проверки изнутри — это ps -ef для подтверждения, что PID 1 — тот процесс, что вы ждёте, и обращение к реальному порту приложения. Когда вопрос меняется с «что оно делает» на «почему умерло», перестаньте доверять живому виду и читайте docker inspect — записанный вердикт движка. State.ExitCode говорит на языке 128 + сигнал: 137 — это внешний SIGKILL (с OOMKilled:true — killer памяти ядра, превышение лимита, краш-луп — не баг приложения и не задача про стектрейс), а 143 — штатный SIGTERM, который приложение уважило. docker stop идёт SIGTERM, затем SIGKILL, поэтому 143 против 137 говорит вам, было выключение чистым или принудительным. Растущий RestartCount с OOMKilled:true — это краш-луп по памяти, и поскольку живой шелл забывает в момент, когда restart-политика оживляет контейнер, inspect — единственный вид, помнящий тот краш, который вы ищете. К сырому nsenter через .State.Pid тянитесь лишь когда сам демон завис. Теперь, когда видите зелёный шелл рядом с отвергнутым портом — первым делом тянитесь к inspect, а не к промпту; и ExitCode 137 читаете как проблему лимита памяти, а не как баг в коде.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.