image

Предисловие


С версии Kubernetes 1.16 была добавлена возможность запуска эфемерных контейнеров (Ephemeral Containers). Эта функция позволяет запускать временные контейнеры в рамках существующих Pod'ов, чтобы помочь в диагностике и отладке проблем, а также для выполнения различных задач в рамках существующего окружения.

Эфемерные контейнеры не заменяют существующие контейнеры в Pod'ах, а запускаются рядом с ними в рамках того же сетевого пространства и с теми же точками монтирования. Это означает, что эфемерные контейнеры могут легко получить доступ к ресурсам Pod'а, таким как файловая система или сетевые интерфейсы.

Нужны ли вообще эфемерные контейнеры


Как дебажить приложения в Kubernetes?

Запекать в боевой образ контейнера полный набор пользовательских утилит и инструментов отладки приведет к разросшемуся образу с добавленными векторами атак. Копировать отладочный набор в уже запущенные контейнеры слишком неудобно и не всегда возможно (как минимум, в контейнере должен быть доступен tar). Но даже если мы получили нужные инструменты в контейнере, kubectl exec вряд ли поможет, если контейнер застрял в цикле падений (CrashLoopBackOff).

Какие еще у нас есть варианты для отладки при отсутствии возможности менять спецификации подов?

Можно, конечно, дебажить напрямую с Kubernetes ноды, но SSH доступ не всегда в наличии. Возможно, остается не так уж и много вариантов…

Если только мы немного не ослабим требование к иммутабельности спецификации пода!

А что если можно было бы добавлять новые, немного ограниченные в своих возможностях, контейнеры к уже запущенному поду без его перезагрузки? Поды это всего лишь группы полувзаимосвязанных контейнеров, и изоляция между ними в рамках одного пода слабая. И таким образом, новый контейнер можно было бы использовать, чтобы проникать в другие контейнеры и снимать с них данные, в том числе отладочные, независимо от их состояния и содержимого.

И так родилась идея Эфемерного Контейнера — «контейнера специального назначения, который запускается на некоторое время в существующем поде, чтобы выполнить пользовательские действия, например, с целью поиска и устранения причин сбоев».

Хм, а разве допускать изменение пода налету не противоречит принципу декларативности Kubernetes? ????
Что вас остановит от злоупотребления эфемерными контейнерами с целью взлома запущенных боевых приложений? Помимо здравого смысла, следующие ограничения:

— У них нет никаких гарантий на выделяемые ресурсы и на запуск в принципе.
— Они могут использовать только те ресурсы, которые уже предоставлены поду.
— Их нельзя перезагрузить, и для них нельзя открыть слушающий порт.
— Для них не настроить никаких liveness/readiness проб.

Чисто технически, их действительно можно использовать для одноразовых задач, но они никак не подходят на долгосрок.

С теорией разобрались, перейдем к практической части.

Великий и могучий kubectl debug


Kubernetes добавил поддержку эфемерных контейнеров, включив в Pod spec новый атрибут (какой бы вы думали?) «ephemeralContainers». Он содержит список объектов типа Container и является одним из немногих атрибутов, которые можно изменить для уже созданного пода.

Чуть больше технических тонкостей API эфемерных контейнеров ????
Список ephemeralContainers можно добавить через PATCH на путь /pods/NAME/ephemeralcontainers. Это возможно сделать только *после* создания пода.

Попытка создать под с непустым списком эфемерных контейнеров приведет к ошибке:

The Pod is invalid: spec.ephemeralContainers: Forbidden: cannot be set on create.


Любопытно, что эту операцию невозможно выполнить с помощью kubectl edit, получим ошибку:

Pod is invalid: spec: Forbidden: pod updates may not change fields other than `spec.containers[*].image`, `spec.initContainers[*].image`, `spec.activeDeadlineSeconds`, `spec.tolerations` (only additions to existing tolerations) or `spec.terminationGracePeriodSeconds` (allow it to be set to 1 if it was previously negative)
A container spec added to the ephemeralContainers list cannot be modified and remains in the list even after the corresponding container terminates.


Добавление элементов в список ephemeralContainers запустит новые контейнеры в существующем поде. Объект EphemeralContainer содержит значительный набор параметров. В частности, ровно как и с обычными контейнерами, с эфемерными можно использовать интерактивный режим и терминал, используя команду kubectl attach -it, чтобы получить желаемый доступ к shell'у.

Большинству рядовых пользователей не понадобится использовать напрямую API эфемерных контейнеров благодаря команде kubectl debug, которая скрывает абстракцию эфемерных контейнеров под набором отладочных команд более высокого уровня. Попробуем это в действии!

Поднимаем песочницу ????️
Потребуется Kubernetes версии 1.23 и выше.

Для песочницы предпочитаю использовать временные уничтожаемые виртуальные машины. Ниже привожу Vagrantfile для быстрого поднятия машины с Debian и Docker на борту:
Vagrant.configure("2") do |config|
  config.vm.box = "debian/bullseye64"

  config.vm.provider "virtualbox" do |vb|
    vb.cpus = 4
    vb.memory = "4096"
  end

  config.vm.provision "shell", inline: <<-SHELL
    apt-get update
    apt-get install -y curl git cmake vim
  SHELL

  config.vm.provision "docker"
end


Положите файл в папку и из нее выполните команды vagrant up && vagrant ssh.

Как только машина запустилась, можно воспользоваться arkade для поднятия кластера:

$ curl -sLS https://get.arkade.dev | sudo sh
$ arkade get kind kubectl
$ kind create cluster


Первая и неудачная попытка использования kubectl debug


В качестве подопытного, возьмем Deployment с distroless образами Python и Node.JS:
$ kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
  name: slim
spec:
  selector:
    matchLabels:
      app: slim
  template:
    metadata:
      labels:
        app: slim
    spec:
      containers:
      - name: app
        image: gcr.io/distroless/python3-debian11
        command:
        - python
        - -m
        - http.server
        - '8080'
      - name: sidecar
        image: gcr.io/distroless/nodejs-debian11
        command:
        - /nodejs/bin/node
        - -e
        - 'setTimeout(() => console.log("done"), 999999)'
EOF

# Сохраним имя пода на будущее:
$ POD_NAME=$(kubectl get pods -l app=slim -o jsonpath='{.items[0].metadata.name}')

Когда подобные контейнеры начинают работать некорректно, от kubectl exec пользы нет, ибо distroless образы не содержат в себе никаких инструментов для навигации и выполнения команд:

# app container - no `bash`
$ kubectl exec -it -c app ${POD_NAME} -- bash
error: exec: "bash": executable file not found in $PATH: unknown

# ...and very limited `sh` (actually, it's aliased `dash`)
$ kubectl exec -it -c app ${POD_NAME} -- sh
$# ls
sh: 1: ls: not found

# sidecar container - no shell at all
$ kubectl exec -it -c sidecar ${POD_NAME} -- bash
error: exec: "bash": executable file not found in $PATH: unknown

$ kubectl exec -it -c sidecar ${POD_NAME} -- sh
error: exec: "sh": executable file not found in $PATH: unknown

Попробуем призвать на помощь эфемерный контейнер:

$ kubectl debug -it --attach=false -c debugger --image=busybox ${POD_NAME}


Мы добавим в наш под новый эфемерный контейнер под названием debugger на образе busybox:latest, не забыв про ключи interactive (-i) и PTY-controller (-t) для возможности подключения к его shell'у.

Посмотрим на изменения спецификации пода:

$ kubectl get pod ${POD_NAME} \
  -o jsonpath='{.spec.ephemeralContainers}' \
  | python3 -m json.tool
[
    {
        "image": "busybox",
        "imagePullPolicy": "Always",
        "name": "debugger",
        "resources": {},
        "stdin": true,
        "terminationMessagePath": "/dev/termination-log",
        "terminationMessagePolicy": "File",
        "tty": true
    }
]


и статус:

$ kubectl get pod ${POD_NAME} \
  -o jsonpath='{.status.ephemeralContainerStatuses}' \
  | python3 -m json.tool
[
    {
        "containerID": "containerd://049d76...",
        "image": "docker.io/library/busybox:latest",
        "imageID": "docker.io/library/busybox@sha256:ebadf8...",
        "lastState": {},
        "name": "debugger",
        "ready": false,
        "restartCount": 0,
        "state": {
????          "running": {
                "startedAt": "2022-05-29T13:41:04Z"
            }
        }
    }
]


Попробуем к нему подключиться:

$ kubectl attach -it -c debugger ${POD_NAME}
# Trying to access the `app` container (a python webserver).
$# wget -O - localhost:8080
Connecting to localhost:8080 (127.0.0.1:8080)
writing to stdout
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
...
</html>


Вроде бы сработало, но есть проблемка:

$# ps auxf
PID   USER     TIME  COMMAND
    1 root      0:00 sh
   14 root      0:00 ps auxf

Команда ps внутри контейнера debugger видит только его собственные процессы… То есть, команда kubectl debug предоставила мне доступ к сетевому неймспейсу и, возможно, ipc, и вероятно также добавила контейнер к той же родительской cgroup, как и другие контейнеры пода, но на этом всё. Маловато для полноценного отладочного сценария ????



Во время поиска проблем с подом, мне бы хотелось видеть полный список процессов всех его контейнеров, а также иметь доступ к их файловой системе. Так может ли эфемерный контейнер быть чуть более «проникающим»?

Используем kubectl debug с общим пространством имен pid


Интересно, что официальная документация сразу подсвечивает эту проблемуи дает решение: включение общего пространства имен pid для всех контейнеров пода. Это можно сделать, выставив свойство shareProcessNamespace в шаблоне спецификации пода в true:

$ kubectl patch deployment slim --patch '
spec:
  template:
    spec:
      shareProcessNamespace: true'


Посмотрим внутрь эфемерного контейнера после этого действия:
$ kubectl debug -it -c debugger --image=busybox \
  $(kubectl get pods -l app=slim -o jsonpath='{.items[0].metadata.name}')
$# # ps auxf
PID   USER     TIME  COMMAND
    1 65535     0:00 /pause
    7 root      0:00 python -m http.server 8080
   19 root      0:00 /nodejs/bin/node -e setTimeout(() => console.log("done"), 999999)
   37 root      0:00 sh
   49 root      0:00 ps auxf


Уже гораздо лучше! Мы подходим ближе к процессу отладки, когда нам доступны все запущенные процессы в одном месте.



Но вот с файловой системой все ещё не всё гладко, мы продолжаем видеть файлы только эфемерного контейнера, а не какого-либо другого. И это логично — контейнеры в поде обычно имеют общие пространства net, ipc и uts, могут разделять pid, но никогда не станут объединять пространства mnt. Файловые системы контейнеров всегда остаются независимыми друг от друга. В противном случае, мы бы получили хаос при работе с файлами.

Но все-таки для дебага мне бы хотелось получить такой доступ…

Что ж, есть один приемчик:

# Внутри эфемерного контейнера:
$# ls /proc/$(pgrep python)/root/usr/bin
c_rehash   getconf    iconv      locale     openssl    python     python3.9  zdump
catchsegv  getent     ldd        localedef  pldd       python3    tzselect

$# ls /proc/$(pgrep node)/root/nodejs
CHANGELOG.md  LICENSE       README.md     bin           include       share


Получается, что зная PID главного процесса из контейнера, который мы хотим исследовать, можно попасть в его файловую систему через путь /proc//root дебажного контейнера!

Настоящая магия! ????✨

Но за магию нужно платить свою цену… Смена значения shareProcessNamespace стоило нам ролл-аута пода!

$ kubectl get replicasets -l app=slim
NAME              DESIRED   CURRENT   READY   AGE
slim-6d8c6578bc   1         1         1       119s
slim-79487d6484   0         0         0       7m57s


Как и другие атрибуты спецификации пода, изменение shareProcessNamespace ведет к его пересозданию. А его смена на уровне шаблона Deployment вызывает полноценный ролл-аут, что может быть слишком дорогой ценой для того, что бы мы посчитали успешной отладочной сессией. Но даже если вам покажется это приемлемым, на мой взгляд, перезагружать поды для отладки сводит на нет весь смысл существования эфемерных контейнеров — сама их суть в том, чтобы подключать их к поду безболезненно, не вызывая прерываний в работе.

Поэтому, если только все ваши приложения не работают с включенным shareProcessNamespace по умолчанию (что вообще-то не очень хорошая идея, так как это серьезно понижает уровень изоляции между контейнерами), нам потребуется решение получше.

Используем kubectl debug только для одного контейнера


Если общее пространство pid для контейнеров не было активировано изначально, то у одного контейнера может быть только один неймспейс pid, и, как мы выяснили, эфемерный контейнер не сможет иметь доступ ко всем процессам всех контейнеров.

Однако, технически, у нас остается возможность запустить контейнер с общим пространством pid с отдельно взятым конкретным контейнером!

Про эту возможность можно детальнее почитать здесь: Про контейнеры и поды

Спецификация Kubernetes CRI явным образом об этом упоминает (этот документ в целом очень занятное чтиво, рекомендую):

// NamespaceOption provides options for Linux namespaces.
type NamespaceOption struct {
  // Network namespace for this container/sandbox.
  // Note: There is currently no way to set CONTAINER scoped network in the Kubernetes API.
  // Namespaces currently set by the kubelet: POD, NODE
  Network NamespaceMode `protobuf:"varint,1,opt,name=network,proto3,enum=runtime.v1.NamespaceMode" json:"network,omitempty"`
  // PID namespace for this container/sandbox.
  // Note: The CRI default is POD, but the v1.PodSpec default is CONTAINER.
  // The kubelet's runtime manager will set this to CONTAINER explicitly for v1 pods.
  // Namespaces currently set by the kubelet: POD, CONTAINER, NODE, TARGET
  Pid NamespaceMode `protobuf:"varint,2,opt,name=pid,proto3,enum=runtime.v1.NamespaceMode" json:"pid,omitempty"`
  // IPC namespace for this container/sandbox.
  // Note: There is currently no way to set CONTAINER scoped IPC in the Kubernetes API.
  // Namespaces currently set by the kubelet: POD, NODE
  Ipc NamespaceMode `protobuf:"varint,3,opt,name=ipc,proto3,enum=runtime.v1.NamespaceMode" json:"ipc,omitempty"`
  // Target Container ID for NamespaceMode of TARGET. This container must have been
  // previously created in the same pod. It is not possible to specify different targets
  // for each namespace.
  TargetId             string   `protobuf:"bytes,4,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"`
}


И действительно, более пристально взглянув на аргументы команды kubectl debug, мы увидим флаг --target, который изначально я упустил:

# Обновим переменную POD_NAME:
$ POD_NAME=$(kubectl get pods -l app=slim -o jsonpath='{.items[0].metadata.name}')

$ kubectl debug -it -c debugger --target=app --image=busybox ${POD_NAME}


Подключаемся напрямую к контейнеру «app». Если процессы после этого не видны, это может быть ограничением вашего container runtime.

$# ps auxf
PID   USER     TIME  COMMAND
    1 root      0:00 python -m http.server 8080
   13 root      0:00 sh
   25 root      0:00 ps auxf


Связка containerd + runc эту возможность поддерживает ????



Используем kubectl debug с копированием пода


Использовать kubectl debug с опцией --target для отладки проблемного пода однозначно станет моим любимым способом, но есть ещё один режим, про который стоит рассказать.

В определенных случаях, лучшей идеей будет запустить при отладке копию нашего пода. Для этой цели тоже есть подходящий флаг --copy-to <new-name>. Новый под не будет принадлежать исходному Deployment (или StatefulSet/DaemonSet/Job), а также Service не будет учитывать его при направлении сетевых пакетов. Это будет «тихая» копия нашего пода для проведения расследования.

А так как это копия, мы можем на ней спокойно выставлять атрибут shareProcessNamespace, не влияя на боевой под. Это можно сделать при помощи ещё одного флага --share-processes:

$ kubectl debug -it -c debugger --image=busybox \
  --copy-to test-pod \
  --share-processes \
  ${POD_NAME}

# All processes are here!
$# ps auxf
PID   USER     TIME  COMMAND
    1 65535     0:00 /pause
    7 root      0:00 python -m http.server 8080
   19 root      0:00 /nodejs/bin/node -e setTimeout(() => console.log("done"), 999999)
   37 root      0:00 sh
   49 root      0:00 ps auxf


Вот что мы увидим после выполнения этой команды:

$ kubectl get all
NAME                        READY   STATUS    RESTARTS   AGE
pod/slim-79487d6484-h4rss   2/2     Running   0          63s
pod/test-pod                3/3     Running   0          20s

NAME                 TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
service/kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP   11d

NAME                   READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/slim   1/1     1            1           64s

NAME                              DESIRED   CURRENT   READY   AGE
replicaset.apps/slim-79487d6484   1         1         1       64s


В качестве бонуса: эфемерные контейнеры без kubectl debug


Мне потребовалось некоторое время, чтобы понять и разобраться как запустить эфемерный контейнер без kubectl, поэтому поделюсь здесь своим опытом.

???? Зачем мне может понадобиться создание эфемерных контейнеров не через kubectl debug?
Потому что нам могут потребоваться чуть более расширенные возможности. К примеру, с kubectl debug невозможность создать эфемерный контейнер с привязанным томом (volume mount), а на уровне API это выполнимо.


Через kubectl debug -v 8 я выяснил, что на уровне API, эфемерные контейнеры добавляются к поду через PATCH на их подресурс /ephemeralcontainers. В статье 2019 года я нашел совет использовать команду:

kubectl replace --raw /api/v1/namespaces/<ns>/pods/<name>/ephemeralcontainers

но это больше не работает с Kubernetes версии 1.23 и выше.

В целом, я не нашел ни одной kubectl команды, кроме debug, которая бы позволила создать эфемерный контейнер. Но вот пример этой операции с чистым Kubernetes API:

$ kubectl proxy

$ POD_NAME=$(kubectl get pods -l app=slim -o jsonpath='{.items[0].metadata.name}')

$ curl localhost:8001/api/v1/namespaces/default/pods/${POD_NAME}/ephemeralcontainers \
  -XPATCH \
  -H 'Content-Type: application/strategic-merge-patch+json' \
  -d '
{
    "spec":
    {
        "ephemeralContainers":
        [
            {
                "name": "debugger",
                "command": ["sh"],
                "image": "busybox",
                "targetContainerName": "app",
                "stdin": true,
                "tty": true,
                "volumeMounts": [{
                    "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount",
                    "name": "kube-api-access-qnhvv",
                    "readOnly": true
                }]
            }
        ]
    }
}'


Заключение


Использовать тяжелые образы контейнеров для запуска приложений в Kubernetes это не эффективно (все мы встречали сборки контейнеров в CI/CD, которые длятся из-за этого слишком долго) и не безопасно (чем больше мы добавляем в образ, тем выше шансы получить неприятную уязвимость). Поэтому подключать отладочные инструменты в реальном времени — это крайне необходимый функционал, и эфемерные контейнеры в Kubernetes наконец дали нам эту возможность.

Мне определенно очень понравилось использовать команду kubectl debug после ее переработки и обновления (раньше ее возможности были гораздо более ограниченны). Но здесь определенно требуется некоторые дополнительные знания низкого уровня как работать с контейнерами и Kubernetes наиболее эффективно. Иначе можно наткнуться на неожиданное поведение контейнеров, невозможностью добраться до процессов и файлов, а также получить непреднамеренные перезагрузки живых подов.

Надеюсь, данная статья помогла разобраться как пользоваться debug-техниками и поможет вам в будущем. Успехов!

Использованные ресурсы


Kubernetes Documentation — Ephemeral Containers.
Kubernetes Documentation — Debugging with an ephemeral debug container.
Google Open Source Blog — Introducing Ephemeral Containers
Ephemeral Containers — the future of Kubernetes workload debugging — an early take (2019)
Using Kubernetes Ephemeral Containers for Troubleshooting

Рекомендовано к прочтению


Containers vs. Pods — Taking a Deeper Look
How To Call Kubernetes API using Simple HTTP Client
Linux PTY — How docker attach and docker exec Commands Work Inside

Комментарии (2)


  1. vassabi
    00.00.0000 00:00
    -1

    curl localhost:8001/api/v1/namespaces/default/pods/

    ... и эти люди потом ещё катят бочку на CLI !


  1. Keirichs
    00.00.0000 00:00
    +2

    Отличная статья, спасибо