Отладка процессов через границы контейнеров в Kubernetes

Отладка процессов через границы контейнеров в 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) Я знаю, что подсветка синтаксиса в этой статье не работает, и занимаюсь исправлением.

© 2026 meganuke