Отладка distroless: ни шелла, ни exec — вносим свои инструменты в namespace'ы чужого контейнера
У distroless-образа нет шелла, поэтому docker exec sh падает — войти не во что. Его отлаживают снаружи: эфемерный debug-контейнер (docker debug / kubectl debug), разделяющий namespace'ы цели и приносящий свои инструменты, или доступ к ФС мёртвого контейнера через /proc/PID/root.
Сервис выехал на gcr.io/distroless/static — 2 МБ, без шелла, без пакетного менеджера, чистый скан безопасности, все довольны. Пока он не начал отдавать обрезанные ответы под нагрузкой, и дежурный потянулся за рефлексом: docker exec -it svc sh. OCI runtime exec failed: exec: "sh": executable file not found in $PATH. Нет sh. Нет bash. Нет ls, cat, curl — образ был просто бинарником. Инстинкт, работавший десятилетие — войти внутрь и осмотреться — упёрся в стену, которой образ был намеренно построен. Хуже: когда бинарник через десять минут упал в segfault, контейнер исчез, а с ним и любой шанс осмотреть оставленную им файловую систему. Первой реакцией команды было пересобрать на debug-базе и передеплоить — что уничтожило бы то самое состояние, которое они пытались захватить. Настоящий ход был противоположностью входа внутрь: инструменты приносишь к контейнеру снаружи, входя в его namespace’ы из отдельного образа с шеллом, не модифицируя то, что отлаживаешь.
Почему exec падает: шелла там никогда не было, чтобы его найти
docker exec sh не вызывает шелл; он исполняет бинарник, который должен уже существовать в файловой системе контейнера. Distroless-образы (и на базе scratch, и большинство хорошо укреплённых) содержат только бинарник приложения и его рантайм-библиотеки — никакого /bin/sh, никакого busybox, никакого coreutils. Это отсутствие и есть фича безопасности: атакующий, добившийся RCE, не находит шелла для разворота, ни curl для эксфильтрации — почти нулевая поверхность атаки. Так что exec: "sh": executable file not found — не баг, а образ, работающий как задумано, и инструмент, к которому тянешься, должен уважать, что работающий контейнер остаётся немодифицированным.
Правильный примитив приносит инструменты, а не ищет их. docker debug (Docker Desktop / Pro) подключает эфемерный набор инструментов к работающему контейнеру, давая шелл-с-утилитами в namespace’ах цели без изменения её образа или ФС. В Kubernetes та же идея — kubectl debug -it <pod> --image=busybox --target=<container>: он поднимает эфемерный контейнер в поде, разделяющий PID- и сетевой namespace’ы цели, так что ваши busybox ps/curl/nc видят distroless-процесс и его сокеты напрямую. --target — самая важная часть: он входит в процессный namespace конкретного контейнера, так что ps показывает PID 1 приложения, а не только ваш debug-шелл. Ничто в образе приложения не меняется; вы подключаетесь, смотрите, отключаетесь, а эфемерный контейнер выбрасывается.
# Kubernetes: подключить busybox, разделяющий namespace'ы distroless-контейнера
kubectl debug -it payments-xyz --image=busybox:1.36 \
--target=payments # войти в PID + net namespace ЭТОГО контейнера
# Внутри: теперь есть инструменты, которых нет у distroless-образа
ps -ef # видеть PID 1 приложения, общий PID namespace
wget -qO- localhost:8080/health # бить в порт приложения, общий net namespacedocker exec -it svc sh на distroless-контейнере возвращает 'exec: sh: executable file not found in $PATH'. Как правильно это понять и что делать дальше?
Когда контейнер уже мёртв: скопируй статический busybox или читай /proc/PID/root
Эфемерным debug-контейнерам нужна работающая цель, чтобы разделить namespace’ы. Если distroless-бинарник уже упал, подключаться не к чему — процесс и его namespace’ы исчезли. Два сеньорских хода закрывают пробелы:
- Для всё ещё работающего контейнера без доступного debug-инструментария: скопируйте внутрь самодостаточный статический бинарник и запустите, например
docker cp busybox <ctr>:/busybox && docker exec <ctr> /busybox sh. Поскольку busybox слинкован статически, ему не нужны библиотеки из distroless-образа — он несёт всё с собой. Это затрагивает записываемый слой контейнера, поэтому это намеренная модификация на крайний случай, а не чистый путь эфемерного контейнера. - Чтобы достать ФС контейнера с хоста: корень каждого работающего контейнера смонтирован на хосте по
/proc/<PID-на-хосте>/root. СPID=$(docker inspect --format '{{.State.Pid}}' svc)командаsudo ls /proc/$PID/root/appдаёт читать distroless-ФС — конфиг, бинарник, записанные файлы — инструментами хоста, вообще без шелла в контейнере. Так осматривают файлы бесшелльного образа, не входя в него.
Вместе эти два подхода закрывают всё, что открывает работающий бесшелльный контейнер: эфемерный контейнер видит процесс и сокеты, /proc/PID/root — файловую систему. Без эфемерного подхода остаётся жёсткий выбор: пересобирать образ (уничтожая состояние) или лететь вслепую.
Для самого краша отладка смещается влево и наружу: захватите core dump (задайте --ulimit core=... и хостовый core_pattern или пусть рантайм пишет dump в смонтированный том), чтобы segfault оставил артефакт для офлайн-анализа, и опирайтесь на запись движка — docker inspect State и docker events — раз мёртвый контейнер уже не войти никаким способом.
Distroless-контейнер уже упал и исчез. Почему эфемерный debug-контейнер (kubectl debug --target) здесь не поможет и что поможет?
▸Почему это работает
Почему «приноси инструменты снаружи» — верная ментальная модель, а не «войди в шелл»? Потому что вся ценность укреплённого образа в том, что внутри нет инструментов, чтобы их найти — ни вам, ни атакующему. Масштабируемый примитив отладки — это разделение: инструменты живут в одноразовом образе, namespace’ы разделены, чтобы эти инструменты видели реальный процесс и сокеты, а образ приложения остаётся байт-в-байт неизменным. Вы не меняете безопасность на отлаживаемость; вы держите и то, и другое, держа их в разных контейнерах.
- 01Почему docker exec sh падает на distroless-образе и как правильно получить инструменты отладки против работающего?
- 02Как осмотреть ФС бесшелльного контейнера с хоста и что меняется после краша?
distroless-образ несёт только бинарник приложения и его рантайм-библиотеки — никакого /bin/sh, busybox, coreutils — поэтому docker exec sh падает с ‘executable file not found’, потому что шелла для исполнения реально нет, и это отсутствие и есть суть: атакующий с RCE не находит ничего для разворота. Верная модель отладки — приносить инструменты снаружи, а не искать внутри, и никогда не пересобирать работающий образ на debug-базе, что уничтожило бы то самое состояние, которое вы расследуете. Против работающего контейнера эфемерный debug-контейнер — docker debug или kubectl debug —image=busybox —target=<container> — подключает одноразовый набор инструментов, разделяющий PID- и сетевой namespace’ы цели, так что его инструменты видят PID 1 distroless-процесса и сокеты напрямую, пока образ приложения остаётся байт-в-байт неизменным. Чтобы прочитать ФС вообще не входя, корень каждого работающего контейнера смонтирован на хосте по /proc/<PID-на-хосте>/root, с PID из State.Pid у docker inspect. Как намеренную крайнюю меру можно docker cp статически слинкованный busybox в работающий контейнер и сделать exec, ведь статическому бинарнику от образа ничего не нужно — но это затрагивает записываемый слой. Всё выше требует живого процесса: namespace’ы и /proc/<PID>/root существуют лишь пока процесс держит их открытыми. В момент краха distroless-контейнера его namespace’ы сносятся, и ничто не может войти, поэтому post-mortem живёт целиком в артефактах, захваченных до смерти — core dump (дамп памяти процесса для офлайн-анализа) из —ulimit core плюс хостовый core_pattern или dump на смонтированном томе — и в собственной записи движка в docker inspect State и docker events. Вы держите и безопасность, и отлаживаемость сразу, держа их в разных контейнерах. Теперь, когда встретите ‘executable file not found’ на укреплённом образе, потянетесь к kubectl debug —target, а не будете перебирать имена шеллов или пересобирать — и заранее настроите захват core dump до следующего segfault, а не после.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.