exec против attach: новый процесс или PID 1
Это первое, что путают, и путаница дорого стоит. Контейнер - это в первую очередь его главный процесс, PID 1 внутри своего pid-namespace. Когда ты запускаешь docker run nginx, nginx и есть PID 1. Жив PID 1 - жив контейнер; умер - контейнер остановлен.
docker exec запускает новый, отдельный процесс внутри уже существующих namespace контейнера. Ты как будто открываешь второе окно в ту же квартиру: то же сетевое окружение, та же файловая система, те же лимиты, но процесс другой, со своим PID (например, PID 7 внутри контейнера). Это рабочая лошадь отладки.
Код: Выделить всё
docker exec -it api sh
- -u 0 или -u root - войти от конкретного UID. Контейнер бежит под непривилегированным юзером, а тебе нужно посмотреть файлы root. exec умеет сменить пользователя для нового процесса.
- -w /tmp - задать рабочую директорию.
- -e KEY=val - добавить переменную окружения только для этого процесса отладки.
- --privileged - дать новому процессу повышенные права (например, для strace, которому нужен CAP_SYS_PTRACE).
Код: Выделить всё
docker exec api cat /etc/resolv.conf
docker exec api ps -ef
docker exec api env
Код: Выделить всё
docker attach api
Грабля номер один: нажал Ctrl+C в attach - и ты послал SIGINT в PID 1. Приложение умрёт, контейнер остановится. Это не "выход из сессии", это сигнал главному процессу. Безопасно отцепиться без убийства - последовательность Ctrl+P, затем Ctrl+Q (detach keys). Запомни её, иначе будешь ронять прод.
Грабля номер два: если у PID 1 нет интерактивного ввода (тот же nginx), attach покажет просто поток логов и зависнет - делать там нечего. Для 95% отладки тебе нужен exec, а не attach. Простое правило: attach - поговорить с главным процессом, exec - запустить рядом свой инструмент.

Когда в образе нет shell: nsenter с хоста
Distroless, scratch, минимальный образ на основе Chainguard - внутри нет /bin/sh, нет coreutils, ничего. Это сделано нарочно: меньше поверхность атаки, меньше CVE, меньше вес. Но docker exec бессилен - ему нечего там запустить. И вот тут вспоминаем, что контейнер - это не магия, а процесс на хосте, обёрнутый в namespace и cgroup. Раз процесс есть на хосте, мы можем войти в его namespace инструментами хоста, а не контейнера.
Этим занимается nsenter (enter namespace) из пакета util-linux. Сначала узнаём PID главного процесса контейнера на хосте:
Код: Выделить всё
PID=$(docker inspect --format '{{.State.Pid}}' api)
echo $PID
# 34567
Код: Выделить всё
sudo nsenter -t 34567 -m -u -i -n -p
- -t 34567 - target, целевой PID, чей namespace берём.
- -m (mount) - войти в mount-namespace: видишь файловую систему контейнера.
- -n (net) - сетевой namespace: видишь его интерфейсы, можешь сделать ss или tcpdump по сети контейнера.
- -p (pid) - pid-namespace: видишь процессы контейнера с их внутренней нумерацией.
- -u (uts), -i (ipc) - hostname и IPC.
Код: Выделить всё
sudo nsenter -t 34567 -n ss -tlnp
sudo nsenter -t 34567 -n -p strace -p 1
Просмотр файлов контейнера прямо с хоста - ещё один трюк без всякого exec. Ядро даёт корень файловой системы любого процесса через /proc:
Код: Выделить всё
sudo ls -la /proc/34567/root/
sudo cat /proc/34567/root/app/config.yaml
docker debug и эфемерные debug-контейнеры
Подход "тащи инструменты хоста через nsenter" мощный, но требует доступа к хосту и root. Появились более цивилизованные способы.
docker debug (в Docker Desktop, начиная с 4.27) - команда, которая side-загружает в целевой контейнер полноценное окружение с набором инструментов, не меняя сам образ. Ты получаешь shell и привычные утилиты даже в distroless:
Код: Выделить всё
docker debug api
Эфемерный debug-контейнер - универсальный приём, работающий на любом движке. Идея: поднять отдельный контейнер с жирным toolset (busybox, nicolaka/netshoot) и подсунуть ему namespace целевого контейнера. Тогда инструменты бегут в окружении жертвы, хотя сами лежат в другом образе.
Код: Выделить всё
docker run -it --rm \
--pid container:api \
--network container:api \
--cap-add SYS_PTRACE \
nicolaka/netshoot
- --pid container:api - разделить pid-namespace с контейнером api. Сделаешь ps внутри netshoot - увидишь процессы api, включая его PID 1.
- --network container:api - тот же сетевой namespace: tcpdump, dig, curl localhost попадают точно в сеть api. netshoot для того и собран - в нём есть всё для сетевой диагностики.
- --cap-add SYS_PTRACE - даёт право трассировать чужие процессы (strace, gdb).
Когда контейнер уже упал: exit code, логи, cp, diff
Половина отладки - это не живой контейнер, а труп. Контейнер вышел, и надо понять почему. Первым делом - код выхода и причина:
Код: Выделить всё
docker inspect api --format '{{.State.ExitCode}} {{.State.OOMKilled}} {{.State.Error}}'
# 137 true
- 0 - штатный выход, всё ок.
- 137 = 128 + 9 - процесс убит сигналом SIGKILL (9). Чаще всего это OOM-killer (смотри поле OOMKilled: true) или docker stop, не дождавшийся graceful-остановки. 137 без OOM - значит превысил время на остановку и его добили.
- 143 = 128 + 15 - SIGTERM, корректная остановка по сигналу.
- 139 = 128 + 11 - SIGSEGV, segfault, баг в коде/библиотеке.
- 125 - ошибка самого docker run (например, неверный флаг). 126 - команда найдена, но не исполняемая. 127 - команда не найдена (типичная опечатка в ENTRYPOINT).
Код: Выделить всё
docker logs --tail 100 --timestamps api
docker logs --since 5m api
Достать файлы из мёртвого или живого контейнера - docker cp, ему не нужен ни shell, ни запущенный контейнер:
Код: Выделить всё
docker cp api:/app/logs/error.log ./error.log
docker cp api:/etc/nginx/nginx.conf ./nginx.conf
docker diff показывает, что контейнер наизменял в файловой системе относительно образа:
Код: Выделить всё
docker diff api
# C /app
# A /app/cache/session.lock
# C /tmp
# A /tmp/core.1234
# D /etc/ld.so.cache
Связка с диагностикой Linux и типичные грабли
Контейнер - это процесс, поэтому работают все привычные инструменты, если зайти в нужный namespace. Сетевую проблему гонишь через netshoot (--network container:): ss -tlnp - кто слушает, ip a - какие адреса, tcpdump -i any - реальный трафик, dig - резолвинг. Зависший процесс - strace -p 1 (на каком syscall встал), cat /proc/1/status (состояние, память, треды). Память и лимиты - в cgroup; через /proc/PID/root и хостовые /sys/fs/cgroup видно реальные limit и usage, которые и привели к 137.
Грабли, на которых теряют часы:
- Ctrl+C в attach роняет контейнер. Всегда выходи через Ctrl+P, Ctrl+Q. Лучше вообще не attach, а exec.
- exec модифицирует контейнер, но это не сохраняется в образе. Поставил пакет через exec - после рестарта контейнера он пропадёт. exec лечит здесь и сейчас, фикс правь в Dockerfile.
- Удалил контейнер docker rm - потерял логи и diff. Сначала сними logs/inspect/diff/cp, потом удаляй. С --rm контейнер исчезает мгновенно при выходе - для отладки запускай без --rm.
- strace без SYS_PTRACE. Получишь "Operation not permitted". Добавь --cap-add SYS_PTRACE (или --privileged в exec).
- nsenter без sudo / не на том хосте. В кластере процесс живёт на конкретной ноде - PID есть только там. На Docker Desktop процессы внутри VM, с ноутбука их PID не виден.
- Перепутал PID хоста и PID контейнера. docker inspect .State.Pid - это хостовый PID для nsenter и /proc. Внутри контейнера тот же процесс - PID 1.
Мини-лаба: отладить контейнер без shell
Повтори руками, это 10 минут и даёт мышечную память.
- Подними distroless-приложение (или просто docker run -d --name api --memory 64m nginx). Проверь docker exec -it api sh - убедись, что для distroless shell нет.
- Узнай хостовый PID: docker inspect --format '{{.State.Pid}}' api. Войди через sudo nsenter -t PID -n ss -tlnp и посмотри слушающие порты.
- Прочитай конфиг контейнера прямо с хоста: sudo ls /proc/PID/root/ и sudo cat /proc/PID/root/etc/hostname.
- Подними рядом netshoot с общими namespace: docker run -it --rm --pid container:api --network container:api nicolaka/netshoot, внутри сделай ps -ef (увидишь PID 1 от api) и curl -s localhost.
- Достань файл наружу: docker cp api:/etc/nginx/nginx.conf . Затем docker diff api - посмотри изменённые пути.
- Убей приложение (docker kill api), потом docker inspect api --format '{{.State.ExitCode}} {{.State.OOMKilled}}' и docker logs --tail 20 api. Сопоставь код выхода с сигналом.
- Чем docker exec принципиально отличается от docker attach по тому, какой процесс ты трогаешь, и почему Ctrl+C в attach опасен?
- В образе нет ни sh, ни ls. Опиши два разных способа всё равно заглянуть внутрь работающего контейнера и прочитать его файлы.
- Контейнер вышел с кодом 137 и OOMKilled: false. Что это значит и где искать причину?
- Зачем при запуске эфемерного debug-контейнера нужны флаги --pid container: и --network container:, и что без них сломается?
Отладка контейнера - это работа с процессом и его namespace, а не с чёрным ящиком. docker exec запускает новый процесс рядом и решает большинство задач; docker attach цепляется к самому PID 1 и нужен редко (и осторожно). Когда в образе нет shell, ты входишь в namespace инструментами хоста через nsenter docker, читаешь файлы через /proc/PID/root, поднимаешь эфемерный debug-контейнер с общими --pid/--network или используешь docker debug в Desktop. А если контейнер уже мёртв - exit code, OOMKilled, docker logs, docker cp и docker diff расскажут историю падения без всякого входа внутрь.