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не указан: в прошлой части мы привязали секрет реестра к ServiceAccountdefault, и 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 и корректное завершение всех контейнеров пода, отсчитывается от начала удаления. preStopsleep 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 с уровнем подробности по умолчанию об остановке контейнеров строк нет: порядок видно по логам самих контейнеров.
Под умеет стартовать в правильном порядке, сообщать о готовности и аккуратно останавливаться. Но живёт он ровно до первой проблемы: удалили — исчез, умер узел — исчез вместе с ним, и никто не создаст новый. Для этого есть контроллеры.
Источники
- Kubernetes: поды, жизненный цикл пода (фазы, условия, перезапуски и CrashLoopBackOff, завершение), init-контейнеры, sidecar-контейнеры, общее пространство процессов.
- Проверки: liveness, readiness и startup и их настройка; хуки жизненного цикла контейнера (
postStart,preStop, действиеsleep). - Логи в Kubernetes (файлы на узле и ротация), port-forward, crictl.
- Конфигурация kubelet (
crashLoopBackOff,containerLogMaxSize,containerLogMaxFiles) и feature gates — с какой версии что стабильно. - Исходник pause-контейнера и конфигурация CRI в containerd (
pinned_images).
Дальше — Deployment и ReplicaSet: как контроллеры поддерживают нужное число подов, что такое pod-template-hash, как идёт rolling update с maxSurge и maxUnavailable, чем readiness probe управляет выкаткой и как откатиться на предыдущую версию.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.