Отладка процессов через границы контейнеров в Kubernetes
Отладка программного обеспечения в контейнерной среде — задача непростая. Как правило, программы собраны в минимальном окружении без каких-либо вспомогательных инструментов, запускаются с минимальными привилегиями и в целом недоступны напрямую из сеансов с более высокими правами.
К счастью, в Kubernetes ситуация значительно улучшилась: были доработаны эфемерные контейнеры (ephemeral containers), а в kubectl debug появились параметры --profile и --target. Теперь существует относительно простой способ создать привилегированный процессный контекст, разделяющий пространство имён PID (pid namespace) с конкретным целевым контейнером. Старые обходные пути — запуск отдельных подов, «побег» из контейнера и переход через пространство имён PID узла — в большинстве случаев больше не нужны.
В этой статье рассматривается, почему для таких задач необходимы эфемерные контейнеры и как их использовать в типичной среде Kubernetes.
TL;DR: Если вас интересует только готовая команда для gdb, перейдите к разделу «Рабочая команда gdb» ниже.
Разместить отладчик в целевом контейнере
Самый очевидный способ отладить процесс в контейнере — поместить отладчик или его агент в тот же контейнер, что и отлаживаемый процесс. Однако на практике это почти никогда не получается, поскольку целевой контейнер:
-
как правило, крайне минималистичен или не имеет полноценной ОС — никаких вспомогательных команд и библиотек;
-
зачастую работает с файловой системой только для чтения без доступных для записи мест;
-
намеренно оставляется как можно меньшего размера;
-
обычно запускается с минимальными привилегиями и набором возможностей (capabilities).
…то есть там нет установленного отладчика, нет возможности его установить, а прав на запуск и подключение не хватит даже в том случае, если нужный исполняемый файл каким-то образом там оказался.
Иногда можно встроить в контейнер минималистичный удалённый агент отладчика — например, gdb-server. Возможно также подключиться к процессу через встроенные механизмы среды выполнения языка программирования, но в боевых конфигурациях они, как правило, отключены из соображений безопасности.
Собрать и развернуть отладочную версию контейнера
Подобно тому как при разработке собирают отдельный отладочный вариант программы, нередко создают и отладочные сборки контейнеров. Такие образы обычно содержат сборку целевой программы с полными отладочными символами, а также гораздо больше утилит и инструментов.
Если проблема легко воспроизводится и допустимо отправить отладочный контейнер заказчику с запуском на повышенных привилегиях, такой подход вполне приемлем.
Но во многих случаях он совершенно не подходит: проблема возникает нерегулярно и требует отладки непосредственно в работающей системе без её остановки; образ может содержать проприетарный исходный код или отладочные данные, пригодные для обратной разработки; отладочный образ может оказаться слишком большим; требования к безопасности и соответствию нормативам у заказчика могут запрещать доставку нестандартных образов и т. д.
Внедрить эфемерный контейнер для отладки
Если отладка «на месте» невозможна, логичным решением выглядит использование эфемерного контейнера Kubernetes для внедрения привилегированного контейнера-помощника (sidecar) в под с доступом к пространству имён процессов целевого контейнера.
Эфемерные контейнеры можно добавить к Pod-у уже после его создания, причём они могут обладать большими привилегиями, чем базовый Pod, — это делает их полезным инструментом для отладки. Поскольку Pod-ы в остальном неизменяемы (immutable), эфемерные контейнеры становятся важным обходным решением, когда необходима инспекция работающей системы.
Параметр --target команды kubectl debug задаёт targetContainerName в спецификации ephemeralContainer (на данный момент это недостаточно хорошо задокументировано). Благодаря этому новый эфемерный контейнер видит процессы из целевого контейнера без необходимости «вырываться» на уровень узла.
Например, чтобы внедрить эфемерный контейнер с Ubuntu 24.04 в pod/target-pod так, чтобы он видел PID-ы из target-container:
kubectl debug -it \
--image "ubuntu:24.04" --profile sysadmin \
-n default pod/target-pod --target target-container \
-- /bin/bash
Обход контекста безопасности пода с помощью эфемерных контейнеров
Однако если у целевого Pod-а задан .spec.securityContext, процессы эфемерного контейнера могут оказаться не такими привилегированными, как ожидается. Если спецификация Pod-а содержит:
spec:
securityContext:
fsGroup: 65534
runAsGroup: 65534
runAsNonRoot: true
runAsUser: 65534
…то эфемерный контейнер унаследует эти настройки и запустится как непривилегированный процесс без нужных флагов возможностей (capabilities).
В команде kubectl debug отсутствуют аргументы для переопределения контекста безопасности на уровне контейнера — она предполагает, что privileged: true будет достаточно. Однако шаблон отладочного контейнера вида:
# debug-container-template.yaml
imagePullPolicy: Always
securityContext:
privileged: true
runAsNonRoot: false
runAsUser: 0
runAsGroup: 0
allowPrivilegeEscalation: true
…решает проблему при передаче в kubectl debug с дополнительным аргументом --custom debug-template.yaml.
Например:
kubectl debug -it \
--image "ubuntu:24.04" --profile sysadmin \
-n default pod/target-pod --target target-container \
--custom debug-template.yaml \
-- /bin/bash
В результате вы получите привилегированный сеанс:
# getpcaps $$
1037418: =ep
Подключение отладчика изнутри контейнера
Дальнейшие инструкции рассчитаны на gdb, но аналогичные принципы применимы и к другим распространённым отладчикам, таким как lldb и dlv.
В качестве примера я буду работать с контейнером postgres, управляемым CloudNativePG (CNPG), используя внедрённый привилегированный эфемерный контейнер. Он запускается с --target postgres, чтобы мой отладочный контейнер разделял пространство имён PID с контейнером postgres. Для удобства я использую образ, в котором заранее установлены gdb и другие распространённые инструменты.
Целевой PID в данном случае определяется так: открывается новый сеанс psql, в котором выполняется SELECT pg_backend_pid();, затем SELECT pg_sleep(3600);, чтобы процесс что-то делал, — и сеанс оставляется запущенным.
Простое подключение gdb только по PID скорее всего даст бесполезный результат, потому что процесс находится в другом пространстве имён монтирования (mount namespace) и gdb не сможет найти исполняемый файл. Например:
# gdb -q -p 893620
Attaching to process 893620
No executable file now.
warning: Could not load vsyscall page because no executable was specified
0x00007f8117f4783a in ?? ()
(gdb) bt
#0 0x00007f8117f4783a in ?? ()
#1 0x000000000096cd6b in ?? ()
#2 0x0000000030ad7b38 in ?? ()
#3 0x00007ffced121a60 in ?? ()
...
Необходимо явно указать исполняемый файл, хотя он всегда доступен по пути /proc/$pid/exe. Просто добавьте путь к исполняемому файлу /proc/$pid/exe в конец команды:
# gdb -q -p 893620 /proc/893620/exe
Reading symbols from /proc/893620/exe...
Reading symbols from .gnu_debugdata for /proc/893620/exe...
(No debugging symbols found in .gnu_debugdata for /proc/893620/exe)
Attaching to program: /proc/893620/exe, process 893620
warning: Could not load shared library symbols for 53 libraries, e.g. /lib64/libzstd.so.1.
Use the "info sharedlibrary" command to see the complete listing.
Do you need "set solib-search-path" or "set sysroot"?
warning: Unable to find dynamic linker breakpoint function.
GDB will be unable to debug shared library initializers
and track explicitly loaded dynamic code.
0x00007f8117f4783a in ?? ()
(gdb) bt
#0 0x00007f8117f4783a in ?? ()
#1 0x000000000096cd6b in WaitEventSetWait ()
#2 0x0000000000a4708c in pg_sleep ()
#3 0x0000000000b18170 in FunctionCallInvokeCheckSPL ()
#4 0x000000000077e1bf in ExecInterpExpr.lto_priv.0 ()
#5 0x00000000007b7890 in ExecResult ()
#6 0x00000000007828eb in ExecProcNodeRowNum ()
#7 0x000000000078150a in standard_ExecutorRun ()
#8 0x00007f8112bb3b6d in ?? ()
#9 0x0000000030cc0758 in ?? ()
#10 0x00007ffced121d60 in ?? ()
#11 0x0000000000000000 in ?? ()
Уже лучше, однако остаются две проблемы:
-
Бинарный файл собран без отладочных символов, поэтому возможности отладки ограничены.
-
gdbне может разрешить адреса в библиотеках, поскольку ищет их не там — из-за различия пространств имён монтирования.
Нужно сообщить gdb, что пути к библиотекам следует разрешать относительно исполняемого файла целевого процесса. Добавьте в команду параметр -iex 'set sysroot /proc/893620/root'. Вывод станет значительно подробнее, но всё равно появится предупреждение:
warning: File "/proc/893620/root/lib64/libthread_db.so.1" auto-loading has been declined by your 'auto-load safe-path' set to "$debugdir:$datadir/auto-load:/target/lib64:/target/usr/lib64".
Решим и это, указав путь с подстановочным символом:
-iex 'add-auto-load-safe-path /proc/893620/root/lib64/*'
В результате получается практически полностью работающая команда gdb.
Рабочая команда gdb
Для подключения к PID 893620 в том же пространстве имён PID, но в другом пространстве имён монтирования:
target_pid=893620;
gdb -q -p "${target_pid}" -iex "set sysroot /proc/${target_pid}/root" \
-iex "add-auto-load-safe-path /proc/${target_pid}/root/lib64/*" \
-iex 'set print symbol-loading off' \
"/proc/${target_pid}/exe"
Вывод команды:
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/proc/893620/root/lib64/libthread_db.so.1".
0x00007f8117f4783a in epoll_wait () from /proc/893620/root/lib64/libc.so.6
(gdb) bt
#0 0x00007f8117f4783a in epoll_wait () from /proc/893620/root/lib64/libc.so.6
#1 0x000000000096cd6b in WaitEventSetWait ()
#2 0x0000000000a4708c in pg_sleep ()
#3 0x0000000000b18170 in FunctionCallInvokeCheckSPL ()
#4 0x000000000077e1bf in ExecInterpExpr.lto_priv.0 ()
#5 0x00000000007b7890 in ExecResult ()
#6 0x00000000007828eb in ExecProcNodeRowNum ()
#7 0x000000000078150a in standard_ExecutorRun ()
#8 0x00007f8112bb3b6d in pgsm_ExecutorRun () from /proc/893620/root/usr/edb/as17/lib/edb_stat_monitor.so
#9 0x00007f8112ba60a9 in ews_ExecutorRun () from /proc/893620/root/usr/edb/as17/lib/edb_wait_states.so
#10 0x00007f8112b96170 in pgss_ExecutorRun () from /proc/893620/root/usr/edb/as17/lib/pg_stat_statements.so
#11 0x00007f810d56ec49 in pgqs_ExecutorRun () from /proc/893620/root/usr/edb/as17/lib/query_advisor.so
#12 0x000000000099cdb1 in PortalRunSelect ()
#13 0x000000000099ec06 in PortalRun ()
#14 0x0000000000993983 in exec_simple_query ()
#15 0x0000000000997540 in PostgresMain ()
#16 0x0000000000998525 in BackendMain ()
#17 0x00000000008eb590 in postmaster_child_launch.part ()
#18 0x00000000008f2609 in ServerLoop ()
#19 0x00000000008f33ee in PostmasterMain ()
#20 0x0000000000547cea in main ()
Простые операции — получение трассировки стека (без аргументов), точки останова на входе в функцию, инспекция потоков и т. д. — теперь будут работать.
Ограниченные возможности из-за отсутствия отладочной информации
Возможности отладчика по-прежнему ограничены отсутствием отладочной информации (debuginfo). Файловая система целевого контейнера доступна только для чтения, а база данных пакетного менеджера может быть удалена.
Сообщения вида (они появляются, если не передавать gdb параметр -iex 'set print symbol-loading off'):
(No debugging symbols found in .gnu_debugdata for /proc/893620/exe)
…свидетельствуют об ограниченных возможностях отладки.
Точки останова на уровне функций, трассировка стека на уровне функций, инспекция потоков и просмотр сырой памяти работают, но этим возможности и ограничиваются.
Например, нельзя пошагово выполнять функцию, устанавливать точки останова по номеру строки или просматривать переменные на стеке. Если остановить pg_sleep(), выполнить в gdb:
(gdb) break exec_simple_query
Breakpoint 1 at 0x993600
(gdb) c
Continuing.
и снова запустить pg_sleep, точка останова сработает, но сделать с ней почти ничего не выйдет:
Breakpoint 1, 0x0000000000993600 in exec_simple_query ()
(gdb) info locals
No symbol table info available.
(gdb) step
Single stepping until exit from function exec_simple_query,
which has no line number information.
...
Чтобы это исправить, необходимо загрузить отладочную информацию для целевого процесса и всех используемых им библиотек в отладочный контейнер и указать gdb, где её искать.
Если в вашей организации работает сервер debuginfod и gdb собран с поддержкой debuginfod, всё достаточно просто: настройте gdb для подключения к вашему серверу debuginfod (а при необходимости — и к серверу debuginfod для дистрибутива, на котором основан ваш контейнер) и дайте ему автоматически загрузить нужные символы.
Иметь свой сервер debuginfod — хорошая практика.
Если в вашей организации debuginfod отсутствует, установка отладочных символов может потребовать значительно больше усилий — при условии, что внешние символы вообще доступны. Это выходит за рамки данной статьи, но я хотел бы разобрать эту тему в следующей.
Что делать, если эфемерные контейнеры недоступны?
Если использование эфемерных контейнеров заблокировано — например, строгой политикой безопасности подов или средой выполнения контейнеров, не поддерживающей targetContainerName, — существуют более сложные обходные пути.
В одной из следующих статей я расскажу, как добиться аналогичного результата с помощью отдельного Pod-а, запущенного командой kubectl debug на том же Node-е, что и целевой контейнер.
(Стоит также отметить, что функция --target / targetContainerName молча игнорируется, если у Pod-а установлен hostPID: true. В этом случае отладка через разные пространства имён невозможна, поскольку PID контейнера уже существует непосредственно в пространстве имён PID хоста.)
Более глубокое погружение
В ряде случаев требуется отлаживать взаимодействие процессов из разных пространств имён PID и монтирования. Это сложнее из-за таких проблем, как несовпадение файловой системы /proc и различие PID-ов, которые видят отладчик и цель отладки. gdb может выдать предупреждение:
warning: Target and debugger are in different PID namespaces; thread lists and other data are likely unreliable. Connect to gdbserver inside the container.
Я планирую написать об этом отдельную статью с описанием обходных путей. Пока же, если вы попали сюда именно из-за этой ошибки, возможно, вам будет полезен этот запрос на улучшение gdb — там есть подробное объяснение и шаги для обхода проблемы, которые я туда добавил.
Примечания
(1) Технически chroot не создавал нового пространства имён монтирования — он лишь пытался ограничить процесс видимостью только поддерева единого глобального пространства имён монтирования. С весьма относительным успехом.
(a) Я знаю, что подсветка синтаксиса в этой статье не работает, и занимаюсь исправлением.