Kubernetes для самых маленьких. Часть 6: Что такое Pod?

Все части серии «Kubernetes для самых маленьких»

В прошлой части мы собрали образ ghcr.io/example/hello:1.0, положили его в частный реестр и запустили одним коротким манифестом kind: Pod, ни о чём не задумываясь. Теперь разберём, что это за объект. Pod — минимальная единица, которую Kubernetes планирует и запускает: не контейнер, а группа контейнеров с общей сетью и общими томами. Всё, что будет дальше — Deployment, StatefulSet, Job, — в конечном счёте создаёт поды, поэтому то, как под устроен, как стартует, как проверяется и как останавливается, нужно понимать досконально.

Как API-сервер хранит объекты и как kubelet узнаёт о своих подах через watch, было в частях 2 и 3. Здесь — то, что происходит на узле k8s-node-2, когда kubelet получил под.


Зачем под, а не контейнер

Контейнер — это процесс в своих namespaces Linux (изолированные представления сети, файловой системы, процессов, имени хоста) и своей cgroup (ограничения ресурсов). Приложению часто нужен напарник, которому нужно то же, что и ему: прокси, принимающий трафик и передающий его приложению по localhost; агент, отправляющий логи из общего каталога; процесс, обновляющий сертификаты в файле, который приложение читает. Таких напарников нужно запускать на том же узле, вместе, с общей сетью и общими файлами — и планировать, перезапускать и удалять как одно целое.

Под и есть такое целое:

  • общая сеть: у всех контейнеров пода один сетевой namespace — один IP-адрес, один lo, общие порты. Контейнеры одного пода ходят друг к другу по 127.0.0.1 и не могут занять один и тот же порт;
  • общий IPC: общая разделяемая память и очереди сообщений System V и POSIX;
  • общее имя хоста (UTS namespace): hostname в любом контейнере пода возвращает имя пода;
  • общие тома: том, объявленный в поде, можно смонтировать в любой его контейнер;
  • общая судьба: под целиком назначается на один узел, целиком удаляется и не переезжает — на другом узле будет уже другой под с другим IP.

Своё у каждого контейнера — файловая система (mount namespace, корень из своего образа) и, по умолчанию, пространство процессов (PID namespace): в контейнере hello процесс /hello — это PID 1, и процессов nginx соседнего контейнера он не видит. Поле shareProcessNamespace: true в спецификации пода делает PID namespace общим — это нужно, например, чтобы отладочный контейнер видел процессы приложения или чтобы один контейнер мог послать сигнал другому.

Самый частый случай — под из одного контейнера. Под из нескольких — когда контейнеры действительно неразделимы. Веб-приложение и его база данных в один под не кладутся: их нужно масштабировать, обновлять и перезапускать независимо.


pause-контейнер

Если сетевой namespace общий, кто-то должен его держать. Контейнеры пода перезапускаются по отдельности: упал hello — kubelet создаст его заново, и если бы namespace принадлежал hello, вместе с ним исчез бы IP-адрес пода. Поэтому первым в поде создаётся служебный контейнер pause (в терминах CRI — sandbox, песочница пода). Его образ registry.k8s.io/pause (kubeadm 1.36 ожидает версию 3.10.2) — около 300 КБ: программа на C, которая вызывает pause() и спит до сигнала. Её единственная задача — существовать: namespaces пода создаются вместе с ней, а остальные контейнеры подключаются к её namespaces.

Вторая роль pause проявляется при shareProcessNamespace: true: тогда pause — PID 1 общего пространства процессов и подбирает «осиротевшие» процессы-зомби, как это делает init в обычной системе.

 Под hello (sandbox)
 ┌──────────────────────────────────────────────────────────────┐
 │ общие namespaces: net (eth0 172.20.172.5, lo), ipc, uts      │
 │                                                              │
 │ ┌──────────────┐ ┌─────────────────┐ ┌─────────────────────┐ │
 │ │ pause        │ │ proxy (nginx)   │ │ hello               │ │
 │ │ держит       │ │ слушает :8081   │ │ слушает :8080       │ │
 │ │ namespaces   │ │ свои mnt и PID  │ │ свои mnt и PID      │ │
 │ └──────────────┘ └────────┬────────┘ └──────────▲──────────┘ │
 │                           └──── 127.0.0.1:8080 ─┘            │
 │                                                              │
 │ том emptyDir nginx-conf: смонтирован в init и в proxy        │
 └──────────────────────────────────────────────────────────────┘

Образ pause containerd 2.x берёт из своей конфигурации (pinned_images.sandbox в секции io.containerd.cri.v1.images) и защищает от сборки мусора. kubeadm скачивает свою версию, но о расхождении с containerd не предупреждает, а запускается та, что указана у containerd, — поэтому в части 4 мы прописали в config.toml ту же 3.10.2, что ожидает kubeadm.


Как под стартует

 kubectl apply ──► kube-apiserver ──► etcd: /registry/pods/default/hello   (nodeName пуст)
                         │
 kube-scheduler: watch ──┤ выбирает узел ──► binding: nodeName = k8s-node-2
                         │
 kubelet k8s-node-2: watch подов со своим nodeName
   1. cgroup пода   /sys/fs/cgroup/kubepods.slice/…/kubepods-besteffort-pod<uid>.slice
   2. тома          /var/lib/kubelet/pods/<uid>/volumes/…
   3. CRI RunPodSandbox ──► containerd: pause, новые netns, ipc, uts
                              └──► CNI ADD (Calico): veth, адрес 172.20.172.5
   4. образы        CRI PullImage для каждого контейнера (imagePullPolicy)
   5. init-контейнеры, по одному, каждый до успешного завершения
   6. sidecar-контейнеры (init с restartPolicy: Always) — по порядку,
      следующий ждёт, пока предыдущий запустится
   7. основные контейнеры — все сразу
   8. проверки (probes) ──► статус пода в API: условия, Ready

Под создаётся в API с пустым spec.nodeName. Планировщик (часть 13) выбирает узел и записывает его через подресурс binding. kubelet на этом узле видит под в своём watch и начинает работу. Сначала — cgroup пода: kubelet с драйвером systemd создаёт slice-юнит внутри kubepods.slice, а в каком именно подкаталоге (besteffort, burstable или в корне для Guaranteed) — зависит от класса QoS пода, о котором часть 10. Потом готовит тома. Потом просит runtime через CRI создать песочницу: containerd запускает pause с новыми namespaces и вызывает CNI-плагин, который для Calico создаёт пару veth — один конец eth0 внутри пода, второй cali… на узле — и выдаёт адрес из пула 172.20.0.0/16; подробно — в части 14. Дальше по очереди контейнеры.

Большинство полей пода после создания менять нельзя: не добавить контейнер, не поменять команду или переменные окружения. Разрешено немногое — образ контейнера, activeDeadlineSeconds, новые tolerations и, отдельным подресурсом, ресурсы. Изменить под — значит удалить его и создать новый. Этим занимаются контроллеры вроде Deployment, о них — следующая часть.


Init-контейнеры

Init-контейнеры (spec.initContainers) выполняются до основных, строго по одному и в порядке объявления; каждый должен завершиться с кодом 0, иначе kubelet перезапускает его согласно restartPolicy пода, и до основных контейнеров дело не доходит. Типичные задачи: подготовить конфигурацию или каталог на томе, дождаться зависимости (базы данных, DNS-имени), выполнить миграцию схемы, скачать данные. Их удобство — в отдельном образе: инструменты, нужные только для подготовки (shell, curl, клиент базы), не попадают в образ приложения.

Пока init-контейнеры работают, статус пода показывает Init:0/2, Init:1/2; упавший init-контейнер — Init:Error или Init:CrashLoopBackOff.

Sidecar-контейнеры

Долгое время «sidecar» был просто соглашением: второй контейнер в containers. Проблем у такого соглашения три. Порядок запуска не гарантирован: приложение может стартовать раньше прокси, через который ходит в сеть. Порядок остановки тоже: прокси может умереть раньше приложения, и последние запросы уйдут в никуда. А в поде Job (часть 9), где основной контейнер завершается, бесконечный sidecar не даёт поду завершиться никогда.

Нативные sidecar-контейнеры решают все три проблемы. Это init-контейнер с restartPolicy: Always. Функция стабильна (GA) с Kubernetes 1.33, в нашей 1.36 работает без флагов. Поведение:

  • sidecar запускается в порядке init-контейнеров, но kubelet не ждёт его завершения: дождавшись старта (а если у sidecar есть startupProbe — её успеха), он переходит к следующему init-контейнеру и затем к основным;
  • sidecar работает всё время жизни пода и перезапускается при падении, независимо от restartPolicy пода;
  • его readinessProbe участвует в готовности пода;
  • при остановке пода kubelet посылает сигнал остановки sidecar-контейнерам только после того, как завершились все основные, и останавливает их в обратном порядке объявления;
  • в поде Job завершение основных контейнеров завершает под, sidecar-ы не мешают.

Примеры sidecar-ов: прокси, принимающий трафик, — так работают сервисные сети (service mesh) вроде Istio и Linkerd, которые ставят в каждый под прокси и через него шифруют и учитывают трафик между сервисами; агент доставки логов из файла на общем томе; процесс, обновляющий сертификат или токен.


Под с hello и sidecar-ом

Соберём под из трёх частей: init-контейнер готовит конфигурацию nginx на общем томе emptyDir (временный каталог на диске узла, живущий столько же, сколько под; подробно — в части 21), sidecar nginx принимает запросы на порту 8081 и проксирует их приложению на 127.0.0.1:8080, основной контейнер — hello.

Сначала удалим проверочный под из прошлой части: под с тем же именем создать нельзя, а изменить его в нашу сторону — тем более.

$ kubectl delete pod hello
pod "hello" deleted from default namespace

hello-pod.yaml:

apiVersion: v1
kind: Pod
metadata:
  name: hello
  labels:
    app.kubernetes.io/name: hello
  annotations:
    kubectl.kubernetes.io/default-container: hello
spec:
  terminationGracePeriodSeconds: 30
  initContainers:
  - name: render-config
    image: busybox:1.37
    command:
    - sh
    - -c
    - |
      cat > /etc/nginx/conf.d/default.conf <<'EOF'
      server {
        listen 8081;
        location = /nginx-health { return 200 "ok\n"; }
        location / {
          proxy_pass http://127.0.0.1:8080;
          proxy_set_header Host $host;
          proxy_set_header X-Forwarded-For $remote_addr;
        }
      }
      EOF
    volumeMounts:
    - name: nginx-conf
      mountPath: /etc/nginx/conf.d
  - name: proxy
    image: nginx:1.29
    restartPolicy: Always          # это и делает init-контейнер sidecar-ом
    ports:
    - name: proxy
      containerPort: 8081
    volumeMounts:
    - name: nginx-conf
      mountPath: /etc/nginx/conf.d
      readOnly: true
    startupProbe:
      httpGet: {path: /nginx-health, port: proxy}
      periodSeconds: 1
      failureThreshold: 10
    readinessProbe:
      httpGet: {path: /nginx-health, port: proxy}
      periodSeconds: 10
  containers:
  - name: hello
    image: ghcr.io/example/hello:1.0
    ports:
    - name: http
      containerPort: 8080
    startupProbe:
      httpGet: {path: /healthz, port: http}
      periodSeconds: 2
      failureThreshold: 15         # до 30 секунд на старт
    livenessProbe:
      httpGet: {path: /healthz, port: http}
      periodSeconds: 10
      timeoutSeconds: 2
      failureThreshold: 3
    readinessProbe:
      httpGet: {path: /readyz, port: http}
      periodSeconds: 5
      timeoutSeconds: 2
      failureThreshold: 2
    lifecycle:
      preStop:
        sleep:
          seconds: 5
  volumes:
  - name: nginx-conf
    emptyDir: {}

Пояснения к тому, что не очевидно:

  • аннотация kubectl.kubernetes.io/default-container говорит kubectl logs и kubectl exec, какой контейнер брать, если -c не указан; без неё в многоконтейнерном поде kubectl берёт первый из containers и предупреждает: Defaulted container "hello" out of: hello, render-config (init), proxy (init);
  • imagePullSecrets не указан: в прошлой части мы привязали секрет реестра к ServiceAccount default, и admission-плагин подставит его при создании;
  • том nginx-conf смонтирован поверх /etc/nginx/conf.d образа nginx: стандартный default.conf со слушателем на 80-м порту спрятан, работает только наш;
  • $host и $remote_addr — переменные nginx, а не shell: <<'EOF' в кавычках запрещает shell подставлять переменные внутри.
$ kubectl apply -f hello-pod.yaml && kubectl get pod hello -w
pod/hello created
NAME    READY   STATUS    RESTARTS   AGE
hello   0/2     Pending   0          0s
hello   0/2     Init:0/2   0          1s
hello   0/2     Init:0/2   0          2s
hello   0/2     Init:0/2   0          3s
hello   0/2     Init:0/2   0          4s
hello   0/2     Init:1/2   0          5s
hello   0/2     Init:1/2   0          5s
hello   0/2     PodInitializing   0          6s
hello   0/2     PodInitializing   0          6s
hello   1/2     PodInitializing   0          7s
hello   1/2     Running           0          7s
hello   1/2     Running           0          9s
hello   2/2     Running           0          9s

kubectl get -w печатает строку на каждое изменение объекта и не выравнивает колонки заново, поэтому они «съезжают», а соседние строки бывают одинаковыми: менялись поля, которых в таблице нет. Init:1/2 — render-config отработал, kubelet ждёт startupProbe sidecar-а; 1/2 PodInitializing — sidecar готов, основной контейнер запускается. READY 2/2 — готовы и sidecar, и основной контейнер: в счёт входят оба. Init-контейнер render-config отработал и завершился, в счёт он не входит.

$ kubectl port-forward pod/hello 8081:8081 &
Forwarding from 127.0.0.1:8081 -> 8081
Forwarding from [::1]:8081 -> 8081
$ curl -s http://127.0.0.1:8081/
Handling connection for 8081
hello from hello
$ kubectl logs hello --tail=2
2026/10/25 23:14:44 hello 1.0 listening on [::]:8080
2026/10/25 23:15:13 127.0.0.1:43052 GET /

Запрос прошёл ноутбук → API-сервер → kubelet → порт 8081 пода (nginx) → 127.0.0.1:8080 (hello). В логе hello адрес клиента — 127.0.0.1: nginx пришёл к нему через общий lo. Ответ — hello from hello: hostname контейнера равен имени пода.


Проверки: startup, liveness, readiness

Проверки (probes) выполняет kubelet на узле пода. Механизмов четыре: httpGet (код ответа 200–399 — успех), tcpSocket (удалось открыть TCP-соединение), grpc (стандартный протокол проверки здоровья gRPC — фреймворка удалённых вызовов поверх HTTP/2) и exec (команда внутри контейнера завершилась с кодом 0). HTTP- и TCP-проверки kubelet делает сам, из сетевого namespace узла на IP пода — в логе nginx такие запросы приходят с адреса узла и с User-Agent: kube-probe/1.36; exec запускает процесс внутри контейнера через runtime — это дороже и требует, чтобы в образе была нужная команда (в distroless её нет).

Проверок три, и они отвечают на разные вопросы:

Проверка Вопрос Что делает kubelet при провале
startupProbe приложение уже запустилось? пока не прошла — не запускает liveness и readiness; провал failureThreshold раз подряд — убивает контейнер, дальше по restartPolicy
livenessProbe процесс жив и не завис? убивает и перезапускает контейнер
readinessProbe готов принимать запросы прямо сейчас? помечает контейнер и под неготовыми; адрес пода убирается из EndpointSlice сервисов; контейнер не трогает

EndpointSlice — объект API со списком адресов подов, стоящих за сервисом; его ведёт контроллер в controller-manager, а kube-proxy по нему пишет правила балансировки. Подробно — в части 15. Для этой части достаточно: под с Ready=False не получает трафика через Service.

Параметры у всех трёх одинаковые:

Поле По умолчанию Смысл
initialDelaySeconds 0 пауза после старта контейнера до первой проверки
periodSeconds 10 интервал между проверками
timeoutSeconds 1 сколько ждать ответа
failureThreshold 3 сколько провалов подряд считать отказом
successThreshold 1 сколько успехов подряд считать восстановлением (для liveness и startup — только 1)

Время, которое контейнер получает на старт, — failureThreshold × periodSeconds у startupProbe: у hello это 15 × 2 = 30 секунд, и проверка начинается сразу, так что быстрый старт не ждёт лишнего. Это лучше, чем initialDelaySeconds у liveness: задержка одинакова при любом старте, и её всегда выбирают с запасом на худший случай, а startupProbe отпускает приложение, как только оно готово.

Типичные ошибки

  • liveness проверяет зависимости. /healthz, который ходит в базу данных: база замедлилась — liveness проваливается у всех реплик сразу, kubelet их перезапускает, перезапуск не лечит базу, а нагрузка на неё от переподключений растёт. Liveness должна отвечать только на вопрос «жив ли этот процесс» — у hello она возвращает ok, если HTTP-сервер вообще отвечает. Зависимости — дело readiness, и то осторожно: если зависимость общая для всех реплик, readiness уберёт из балансировки все, и сервис будет недоступен целиком, вместо того чтобы отдавать ошибки части запросов.
  • Одна и та же проверка для liveness и readiness. Тогда перегруженный под, который правильно было бы временно вывести из балансировки, ещё и перезапускается.
  • Слишком жёсткие таймауты. timeoutSeconds: 1 по умолчанию — мало для приложения со сборщиком мусора, у которого бывают паузы. Под нагрузкой проверки начинают проваливаться, перезапуск добавляет нагрузку остальным, и они тоже начинают проваливаться.
  • Нет startupProbe у медленно стартующего приложения. Liveness начинает проверять, пока приложение ещё поднимается, и убивает его раньше, чем оно успело стартовать. Бесконечный цикл перезапусков на ровном месте.
  • Нет readinessProbe. Под попадает в балансировку в момент старта контейнера, ещё до того, как приложение открыло порт, и первые запросы получают отказ в соединении.
  • exec-проверка с тяжёлой командой (psql -c 'select 1' каждые 5 секунд в сотне подов) — заметная нагрузка и на узел, и на базу.

restartPolicy, фазы и состояния

spec.restartPolicy пода определяет, что kubelet делает с завершившимися основными контейнерами:

Значение Поведение Где применяется
Always (по умолчанию) перезапускать при любом завершении, и с кодом 0 тоже сервисы, Deployment; единственное допустимое там значение
OnFailure перезапускать только при ненулевом коде Job
Never не перезапускать Job, разовые поды

Перезапуск — это новый контейнер в том же поде, на том же узле, с тем же IP; счётчик RESTARTS растёт. С 1.35 в бете (включено по умолчанию) поле restartPolicyRules у контейнера позволяет тоньше: например, перезапускать только при определённых кодах выхода, а остальные считать окончательными.

Фаза пода (status.phase) — грубое итоговое состояние:

Фаза Значение
Pending под принят, но ещё не все контейнеры созданы: ждёт планирования, скачивания образа, init-контейнеров
Running под на узле, все контейнеры созданы, хотя бы один работает или перезапускается
Succeeded все контейнеры завершились успешно и перезапускаться не будут
Failed все контейнеры завершились, хотя бы один — с ошибкой
Unknown состояние неизвестно, обычно потеряна связь с узлом

Фаза Running не значит «работает нормально»: под в CrashLoopBackOff тоже Running. Подробности — в состояниях контейнеров (status.containerStatuses[].state): Waiting с причиной (ContainerCreating, ErrImagePull, ImagePullBackOff, CrashLoopBackOff, CreateContainerConfigError), Running с временем старта, Terminated с кодом выхода и причиной (Completed, Error, OOMKilled). Поле lastState хранит, чем закончилась предыдущая попытка, — первое, куда смотреть при перезапусках. Колонка STATUS в kubectl get pods — не фаза, а сводка, которую kubectl собирает из состояний контейнеров.

И условия (status.conditions): PodScheduled — под назначен на узел; PodReadyToStartContainers — песочница создана, сеть настроена (в 1.36 бета, включено по умолчанию); Initialized — init-контейнеры отработали; ContainersReady — все контейнеры готовы; Ready — под готов принимать трафик. Ready может зависеть и от внешних readiness gates — дополнительных условий, которые выставляет сторонний контроллер (например, балансировщик облака, когда зарегистрировал под у себя).

CrashLoopBackOff

Если контейнер падает раз за разом, kubelet не перезапускает его мгновенно, а ждёт, удваивая паузу: 10 с, 20 с, 40 с, 80 с, 160 с и дальше по 300 с (пять минут) — это потолок. Пока kubelet ждёт, состояние контейнера — Waiting с причиной CrashLoopBackOff. Если контейнер после перезапуска проработал без падений 10 минут, счётчик задержки сбрасывается.

CrashLoopBackOff — не ошибка сама по себе, а сообщение «контейнер падает, я жду перед следующей попыткой». Причину нужно искать в логах предыдущего запуска и в lastState. Потолок в 300 секунд с 1.35 настраивается на узле полем crashLoopBackOff.maxContainerRestartPeriod в конфигурации kubelet (бета, от 1 до 300 секунд) — например, для узлов, где важен быстрый подъём после кратких сбоев зависимостей.


Жизненный цикл завершения

Удаление пода — не мгновенное событие, а процесс, и большая часть потерянных при выкатках запросов теряется именно здесь.

 t=0   kubectl delete pod hello
       API: deletionTimestamp, deletionGracePeriodSeconds=30; под — Terminating
         │
         ├──► контроллер EndpointSlice: адрес пода → ready=false, terminating=true
         │      └──► kube-proxy на всех узлах убирает под из балансировки
         │           (параллельно, за доли секунды или секунды)
         │
         └──► kubelet k8s-node-2:
                1. preStop основного контейнера hello (sleep 5 с)
                2. SIGTERM процессу PID 1 контейнера hello
                   hello: ready=false, srv.Shutdown(), дождаться запросов
                3. hello завершился ──► сигнал остановки sidecar proxy (nginx)
                4. всё завершилось ──► удалить песочницу, освободить IP
 t=30  если кто-то ещё жив: SIGKILL (preStop получает разовые +2 с)
       kubelet сообщает API: под удалён окончательно

Ключевая тонкость — две ветви идут параллельно. kubelet начинает останавливать контейнер, а kube-proxy на других узлах ещё какое-то время направляет на под новые соединения: изменения EndpointSlice расходятся по кластеру не мгновенно. Если приложение по SIGTERM сразу закрывает порт, эти соединения получат отказ.

Лечится это паузой перед SIGTERM: preStop-хук, который просто ждёт несколько секунд, пока под исчезнет из балансировки. У хуков три вида: exec (команда в контейнере), httpGet (запрос к контейнеру) и sleep (пауза силами kubelet, стабильна с 1.34). Раньше писали exec: {command: ["sleep", "5"]}, но в distroless-образе hello нет команды sleep, и такой хук просто упал бы; действие sleep выполняет сам kubelet и не требует ничего от образа.

Правила, которые из этого следуют:

  • приложение обрабатывает SIGTERM: завершает начатые запросы и выходит. Особенно важно, когда процесс приложения — PID 1 в контейнере: ядро не применяет к PID 1 действие сигнала по умолчанию, и процесс без обработчика SIGTERM его просто не заметит — будет убит через 30 секунд SIGKILL-ом;
  • terminationGracePeriodSeconds (по умолчанию 30) — общий бюджет на preStop и корректное завершение всех контейнеров пода, отсчитывается от начала удаления. preStop sleep 5 и 20 секунд на srv.Shutdown в hello укладываются в 30 с запасом. Sidecar-ы получают то, что осталось от бюджета после основных контейнеров; если бюджет кончился раньше, kubelet останавливает все оставшиеся контейнеры разом с коротким сроком. Если приложению нужно больше — увеличить, а не надеяться;
  • сигнал остановки можно сменить инструкцией STOPSIGNAL в образе; официальный образ nginx так и делает — STOPSIGNAL SIGQUIT, по которому nginx дорабатывает текущие запросы, тогда как SIGTERM для него означает быструю остановку. Поле lifecycle.stopSignal в спецификации контейнера пока в альфе;
  • kubectl delete pod --grace-period=0 --force удаляет объект из API, не дожидаясь узла. Контейнеры на узле при этом могут ещё работать. Для подов StatefulSet (часть 8) это прямой путь к двум экземплярам с одним именем — пользоваться только когда узел точно мёртв.

Хук postStart — пара к preStop: выполняется сразу после создания контейнера, параллельно с его стартом и без гарантии, что сработает раньше точки входа. Пригождается редко.


kubectl logs, exec, port-forward

Все три команды работают через API-сервер: kubectl обращается к подресурсам пода (pods/log, pods/exec, pods/portforward), API-сервер проксирует запрос kubelet-у нужного узла на порт 10250, а kubelet — потоковому серверу CRI в containerd. Прямой сетевой доступ к узлу или поду ноутбуку не нужен, а права на эти подресурсы раздаются через RBAC отдельно от права читать поды (часть 29). Потоки идут поверх WebSocket; в свежих версиях это поведение по умолчанию, а раньше использовался SPDY — экспериментальный предшественник HTTP/2, давно заброшенный браузерами.

kubectl logs читает файлы, куда containerd пишет stdout и stderr контейнеров: /var/log/pods/<неймспейс>_<под>_<uid>/<контейнер>/<номер перезапуска>.log. Полезные ключи: -c — контейнер, -f — следить, --previous — лог предыдущего, упавшего экземпляра (незаменимо при CrashLoopBackOff), --since=10m, --timestamps, --all-containers. kubelet хранит лог текущего и одного предыдущего экземпляра контейнера и сам ротирует файлы (containerLogMaxSize, по умолчанию 10 МиБ, и containerLogMaxFiles, 5). Это не система хранения логов: удалён под — удалены и логи; для хранения нужен сборщик (часть 33).

kubectl exec запускает новый процесс в namespaces и cgroup работающего контейнера: kubectl exec hello -c proxy -- nginx -T. В контейнер hello так не зайти — в distroless нет shell:

$ kubectl exec -it hello -- sh
error: Internal error occurred: Internal error occurred: error executing command in container: failed to exec in container: failed to start exec "d1ce3a58…": OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH

Для отладки таких контейнеров есть kubectl debug с временным контейнером, который подключается к namespaces пода и приносит свои инструменты, — часть 31.

kubectl port-forward пробрасывает локальный порт в под: containerd на узле входит в сетевой namespace пода и соединяется с localhost:<порт>. Поэтому port-forward достаёт и до порта, который слушается только на localhost пода, и не проходит ни через Service, ни через сетевые политики. Инструмент для отладки, не для доступа пользователей.


Смотрим руками

Статус и события пода (вывод сокращён: без идентификаторов контейнеров, команды init-контейнера, монтирований и томов):

$ kubectl describe pod hello
Name:             hello
Namespace:        default
Priority:         0
Service Account:  default
Node:             k8s-node-2/192.168.10.21
Labels:           app.kubernetes.io/name=hello
Status:           Running
IP:               172.20.172.5
Init Containers:
  render-config:
    Image:          busybox:1.37
    State:          Terminated
      Reason:       Completed
      Exit Code:    0
    Ready:          True
    Restart Count:  0
  proxy:
    Image:          nginx:1.29
    Port:           8081/TCP (proxy)
    State:          Running
    Ready:          True
    Restart Count:  0
    Readiness:      http-get http://:proxy/nginx-health delay=0s timeout=1s period=10s #success=1 #failure=3
    Startup:        http-get http://:proxy/nginx-health delay=0s timeout=1s period=1s #success=1 #failure=10
Containers:
  hello:
    Image:          ghcr.io/example/hello:1.0
    Image ID:       ghcr.io/example/hello@sha256:a5066cd40ef37b9b889ddfbe9c1860b3afe231b96b975000e0edca3fdd6f4a4a
    Port:           8080/TCP (http)
    State:          Running
    Ready:          True
    Restart Count:  0
    Liveness:       http-get http://:http/healthz delay=0s timeout=2s period=10s #success=1 #failure=3
    Readiness:      http-get http://:http/readyz delay=0s timeout=2s period=5s #success=1 #failure=2
    Startup:        http-get http://:http/healthz delay=0s timeout=1s period=2s #success=1 #failure=15
Conditions:
  Type                        Status
  PodReadyToStartContainers   True
  Initialized                 True
  Ready                       True
  ContainersReady             True
  PodScheduled                True
QoS Class:                   BestEffort
Events:
  Type    Reason     Age   From               Message
  ----    ------     ----  ----               -------
  Normal  Scheduled  52s   default-scheduler  Successfully assigned default/hello to k8s-node-2
  Normal  Pulled     50s   kubelet            spec.initContainers{render-config}: Container image "busybox:1.37" already present on machine and can be accessed by the pod
  Normal  Created    49s   kubelet            spec.initContainers{render-config}: Container created
  Normal  Started    49s   kubelet            spec.initContainers{render-config}: Container started
  Normal  Pulled     48s   kubelet            spec.initContainers{proxy}: Container image "nginx:1.29" already present on machine and can be accessed by the pod
  Normal  Created    47s   kubelet            spec.initContainers{proxy}: Container created
  Normal  Started    47s   kubelet            spec.initContainers{proxy}: Container started
  Normal  Pulled     46s   kubelet            spec.containers{hello}: Container image "ghcr.io/example/hello:1.0" already present on machine and can be accessed by the pod
  Normal  Created    45s   kubelet            spec.containers{hello}: Container created
  Normal  Started    45s   kubelet            spec.containers{hello}: Container started

Порядок событий — ровно тот, что на схеме запуска: init, затем sidecar, затем основной; к каждому событию kubelet приписывает, к какому контейнеру оно относится (spec.initContainers{proxy}). Слова «and can be accessed by the pod» в событии Pulled — проверка из прошлой части: kubelet убедился, что у пода есть право на уже скачанный образ. QoS Class: BestEffort — у контейнеров не заданы ресурсы; как это исправить и чем это грозит — в части 10.

Песочница и контейнеры на узле. crictl разговаривает с containerd по тому же CRI, что kubelet:

user@k8s-node-2:~$ sudo crictl pods --name hello
POD ID              CREATED             STATE               NAME                NAMESPACE           ATTEMPT             RUNTIME
51ed2323f4fa5       58 seconds ago      Ready               hello               default             0                   (default)

user@k8s-node-2:~$ sudo crictl ps -a --pod 51ed2323f4fa5
CONTAINER           IMAGE               CREATED             STATE               NAME                ATTEMPT             POD ID              POD                 NAMESPACE
79b420d295565       f768da2949737       53 seconds ago      Running             hello               0                   51ed2323f4fa5       hello               default
8fe828832e979       5dfe511714e1f       55 seconds ago      Running             proxy               0                   51ed2323f4fa5       hello               default
17203c058fb4c       30ecbe1509090       57 seconds ago      Exited              render-config       0                   51ed2323f4fa5       hello               default

Самого pause-контейнера crictl ps не показывает: для CRI это песочница, а не контейнер. Её видно в crictl inspectp — вместе с путём к сетевому namespace, который все контейнеры пода разделяют:

user@k8s-node-2:~$ sudo crictl inspectp 51ed2323f4fa5 | jq -r '.info.runtimeSpec.linux.namespaces[] | "\(.type) \(.path // "-")"'
pid -
ipc -
uts -
mount -
network /var/run/netns/cni-6436b1e4-179c-4a6a-df3b-f86c74a11268

user@k8s-node-2:~$ for c in $(sudo crictl ps --pod 51ed2323f4fa5 -q); do
>   pid=$(sudo crictl inspect $c | jq .info.pid)
>   echo "${c:0:13} net=$(sudo readlink /proc/$pid/ns/net) mnt=$(sudo readlink /proc/$pid/ns/mnt) pid=$(sudo readlink /proc/$pid/ns/pid)"
> done
79b420d295565 net=net:[4026532287] mnt=mnt:[4026532358] pid=pid:[4026532359]
8fe828832e979 net=net:[4026532287] mnt=mnt:[4026532355] pid=pid:[4026532356]

Сетевой namespace у hello и proxy один и тот же, 4026532287; файловая система и пространство процессов — свои. Заглянем в эту общую сеть с узла, nsenter — войти в namespace процесса:

user@k8s-node-2:~$ pid=$(sudo crictl inspect 79b420d295565 | jq .info.pid)
user@k8s-node-2:~$ sudo nsenter -t $pid -n ss -ltn
State  Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0      511          0.0.0.0:8081      0.0.0.0:*
LISTEN 0      4096               *:8080            *:*
user@k8s-node-2:~$ sudo nsenter -t $pid -n ip -br addr
lo               UNKNOWN        127.0.0.1/8 ::1/128
eth0@if10        UP             172.20.172.5/32 fe80::18c2:82ff:fed2:9b0f/64

Оба порта — nginx и hello — в одном сетевом namespace. Адрес пода /32 и интерфейс eth0, парный к if10 на узле, — работа Calico, разбор в части 14.

Каталог пода у kubelet (tree ставится пакетом tree):

$ kubectl get pod hello -o jsonpath='{.metadata.uid}{"\n"}'
84f07d9e-e9e9-4e13-912f-c062a13a46e0

user@k8s-node-2:~$ uid=84f07d9e-e9e9-4e13-912f-c062a13a46e0
user@k8s-node-2:~$ sudo tree -L 3 /var/lib/kubelet/pods/$uid
/var/lib/kubelet/pods/84f07d9e-e9e9-4e13-912f-c062a13a46e0
├── containers
│   ├── hello
│   │   └── 86f8f74a
│   ├── proxy
│   │   └── 1526bf9a
│   └── render-config
│       └── 6765c03e
├── etc-hosts
├── plugins
│   └── kubernetes.io~empty-dir
│       ├── nginx-conf
│       └── wrapped_kube-api-access-4rv9d
└── volumes
    ├── kubernetes.io~empty-dir
    │   └── nginx-conf
    └── kubernetes.io~projected
        └── kube-api-access-4rv9d

14 directories, 4 files
  • volumes/kubernetes.io~empty-dir/nginx-conf/ — тот самый emptyDir: обычный каталог на диске узла, смонтированный в контейнеры bind-монтированием. Внутри — default.conf, записанный init-контейнером;
  • volumes/kubernetes.io~projected/kube-api-access-… — том с токеном ServiceAccount, сертификатом CA кластера и именем неймспейса, который Kubernetes по умолчанию монтирует в каждый под в /var/run/secrets/kubernetes.io/serviceaccount (часть 29); plugins/…/wrapped_kube-api-access-… — служебная запись kubelet о emptyDir, поверх которого этот том собран;
  • etc-hosts — файл /etc/hosts пода, его пишет kubelet: имя пода с его IP;
  • containers/<имя>/<id> — файлы terminationMessagePath: что контейнер написал в /dev/termination-log перед смертью, kubelet покажет в lastState.terminated.message.
user@k8s-node-2:~$ sudo cat /var/lib/kubelet/pods/$uid/etc-hosts
# Kubernetes-managed hosts file.
127.0.0.1	localhost
::1	localhost ip6-localhost ip6-loopback
fe00::0	ip6-localnet
fe00::0	ip6-mcastprefix
fe00::1	ip6-allnodes
fe00::2	ip6-allrouters
172.20.172.5	hello

cgroup пода — slice с UID пода, в нём по scope на каждый контейнер и на песочницу (идентификаторы контейнеров сокращены):

user@k8s-node-2:~$ systemd-cgls --no-pager -u kubepods-besteffort-pod84f07d9e_e9e9_4e13_912f_c062a13a46e0.slice
Unit kubepods-besteffort-pod84f07d9e_e9e9_4e13_912f_c062a13a46e0.slice (/kubepods.slice/kubepods-besteffort.slice/kubepods-besteffort-pod84f07d9e_e9e9_4e13_912f_c062a13a46e0.slice):
├─cri-containerd-79b420d29556….scope
│ └─204908 /hello
├─cri-containerd-8fe828832e97….scope
│ ├─204864 nginx: master process nginx -g daemon off;
│ ├─204892 nginx: worker process
│ └─204893 nginx: worker process
└─cri-containerd-51ed2323f4fa….scope
  └─204782 /pause

Логи на диске:

user@k8s-node-2:~$ sudo ls /var/log/pods/default_hello_$uid/
hello  proxy  render-config
user@k8s-node-2:~$ sudo tail -1 /var/log/pods/default_hello_$uid/hello/0.log
2026-10-26T02:15:13.083108848+03:00 stderr F 2026/10/25 23:15:13 127.0.0.1:43052 GET /

Формат строки — время, поток (stdout или stderr), признак полной (F) или разрезанной (P) строки и сам текст. Это формат CRI, его понимают все сборщики логов. Время containerd пишет в часовом поясе узла (здесь +03:00), а сам hello — в UTC: в образе distroless часовой пояс не задан, и Go считает его UTC.

CrashLoopBackOff своими глазами. Под, которому достался неправильный порт:

apiVersion: v1
kind: Pod
metadata:
  name: crash
spec:
  containers:
  - name: hello
    image: ghcr.io/example/hello:1.0
    env:
    - name: PORT
      value: "abc"
$ kubectl apply -f crash.yaml && kubectl get pod crash -w
pod/crash created
NAME    READY   STATUS    RESTARTS   AGE
crash   0/1     Pending   0          0s
crash   0/1     ContainerCreating   0          0s
crash   0/1     ContainerCreating   0          1s
crash   0/1     ContainerCreating   0          2s
crash   1/1     Running             0          3s
crash   0/1     Error               0          4s
crash   1/1     Running             1 (2s ago)   5s
crash   0/1     Error               1 (3s ago)   6s
crash   0/1     CrashLoopBackOff    1 (13s ago)   18s
crash   1/1     Running             2 (15s ago)   20s
crash   0/1     Error               2 (15s ago)   20s
crash   0/1     CrashLoopBackOff    2 (22s ago)   41s
crash   1/1     Running             3 (24s ago)   43s
crash   0/1     Error               3 (25s ago)   44s

Каждая попытка на мгновение даёт 1/1 Running: контейнер стартует и сразу падает. Первый перезапуск (5-я секунда) идёт почти сразу, следующие — на 20-й и 43-й: паузы 10 и 20 секунд плюс несколько секунд, пока kubelet снова дойдёт до пода. CrashLoopBackOff появляется в колонке не сразу после падения, а когда kubelet обновит статус пода. Текущую паузу kubelet пишет в state.waiting.message: если понаблюдать дольше, после пятого перезапуска там будет back-off 2m40s restarting failed container=hello pod=crash_default(…) — те самые 160 секунд. Причина:

$ kubectl logs crash --previous
2026/10/25 23:19:43 listen: listen tcp: lookup tcp/abc: unknown port
$ kubectl get pod crash -o jsonpath='{.status.containerStatuses[0].lastState}' | jq
{
  "terminated": {
    "containerID": "containerd://46c00258…",
    "exitCode": 1,
    "finishedAt": "2026-10-25T23:19:43Z",
    "reason": "Error",
    "startedAt": "2026-10-25T23:19:43Z"
  }
}
$ kubectl delete pod crash
pod "crash" deleted from default namespace

Завершение по шагам. Удалим под hello, следя за логами обоих контейнеров с отметками времени; в соседнем терминале через port-forward идут запросы curl, по два в секунду:

$ for i in $(seq 1 40); do curl -s -m 1 -o /dev/null -w '%{http_code} ' http://127.0.0.1:8081/; sleep 0.5; done
$ kubectl logs hello -f --timestamps > hello.log &
$ kubectl logs hello -c proxy -f --timestamps > proxy.log &
$ date +%T.%N; kubectl delete pod hello; date +%T.%N
02:23:04.604314715
pod "hello" deleted from default namespace
02:23:12.618510482
$ tail -4 hello.log
2026-10-26T02:23:08.732183328+03:00 2026/10/25 23:23:08 127.0.0.1:35846 GET /
2026-10-26T02:23:09.241063991+03:00 2026/10/25 23:23:09 127.0.0.1:35862 GET /
2026-10-26T02:23:09.733872142+03:00 2026/10/25 23:23:09 signal received, shutting down
2026-10-26T02:23:09.735109251+03:00 2026/10/25 23:23:09 bye
$ grep -E 'SIGQUIT|1#1: exit' proxy.log
2026-10-26T02:23:10.192158970+03:00 2026/10/25 23:23:10 [notice] 1#1: signal 3 (SIGQUIT) received, shutting down
2026-10-26T02:23:10.201916590+03:00 2026/10/25 23:23:10 [notice] 1#1: exit

А цикл curl в соседнем терминале напечатал:

200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 502 000 000 000 000 000 000 000

delete отправлен в 02:23:04,6, SIGTERM пришёл в 02:23:09,7 — через пять секунд preStop. Все запросы в эти пять секунд обслужены: под уже Terminating, но приложение работает. Само завершение заняло около миллисекунды, потому что открытых запросов не было. Только после выхода hello kubelet послал сигнал остановки sidecar-у: nginx получил SIGQUIT (тот самый STOPSIGNAL из образа) через полсекунды. В эти полсекунды один запрос получил 502 — nginx ещё жив, а приложения за ним уже нет; дальше 000: nginx остановился, и port-forward потерял соединение. Через Service такой запрос сюда бы не попал — к этому моменту адрес пода уже убран из EndpointSlice, — а port-forward идёт в под напрямую. kubectl delete по умолчанию ждёт окончательного удаления объекта и вернулся через восемь секунд. В журнале kubelet с уровнем подробности по умолчанию об остановке контейнеров строк нет: порядок видно по логам самих контейнеров.


Под умеет стартовать в правильном порядке, сообщать о готовности и аккуратно останавливаться. Но живёт он ровно до первой проблемы: удалили — исчез, умер узел — исчез вместе с ним, и никто не создаст новый. Для этого есть контроллеры.

Источники


Дальше — Deployment и ReplicaSet: как контроллеры поддерживают нужное число подов, что такое pod-template-hash, как идёт rolling update с maxSurge и maxUnavailable, чем readiness probe управляет выкаткой и как откатиться на предыдущую версию.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.