Когда новичок слышит "kubernetes pod", в голове рисуется один контейнер с приложением. Это упрощение, которое потом больно бьёт. Под (Pod) - это атомарная единица планирования в Kubernetes, группа из одного или нескольких контейнеров, которые всегда едут на одной ноде, разделяют сеть и могут делить тома. Их нельзя растащить по разным машинам, нельзя отскейлить по отдельности - под живёт и умирает целиком.
Боль, которую мы сегодня лечим, такая: твоё приложение запускается, но падает, потому что база ещё не подняла порт. Логи валятся в stdout, а никто их не собирает. При деплое часть запросов отваливается с 502, потому что под убили грубо. Под жрёт память сверх лимита, и kubelet выселяет именно твой критичный сервис, а не какую-то фоновую джобу. Всё это - вопросы устройства пода: порядок старта контейнеров, init и sidecar, хуки жизненного цикла, корректная остановка и QoS-классы. Разберёмся, как это работает под капотом, а не на уровне "скопировал манифест из интернета".

Анатомия пода и pause-контейнер
Внутри одного kubernetes под все контейнеры делят несколько Linux namespaces: сетевой (один IP на весь под, общий localhost), IPC, и опционально PID. При этом файловые системы у контейнеров раздельные - root-каталог у каждого свой, общими становятся только смонтированные тома.
Кто держит эти namespaces, пока контейнеры перезапускаются? Скрытый pause-контейнер (в CRI его называют sandbox). Это крошечный процесс, который не делает ничего - просто спит и держит сетевой и IPC namespace открытыми. Когда твой основной контейнер крашится и рестартует, IP пода не меняется именно потому, что pause его держит. Увидеть его можно прямо на ноде:
Код: Выделить всё
crictl pods
POD ID NAME STATE ATTEMPT
3f9a1c2b... web-7d4f-abcde Ready 0
crictl ps --pod 3f9a1c2b...
CONTAINER IMAGE STATE NAME
a1b2c3... nginx:1.27 Running web
Init-контейнеры: подготовка строго по порядку
Init container - это контейнер, который выполняется до основных и обязан завершиться успешно (exit code 0). Их может быть несколько, и они идут строго последовательно: второй не стартует, пока первый не отработал. Если init упал - kubelet перезапускает его согласно restartPolicy пода, и под висит в фазе Pending, не пуская основное приложение. Это ровно то поведение, которое нужно для "ждать зависимость" и "подготовить данные".
Классический сценарий - дождаться, пока поднимется база, и накатить миграции:
Код: Выделить всё
apiVersion: v1
kind: Pod
metadata:
name: app-with-init
spec:
initContainers:
- name: wait-for-db
image: busybox:1.36
command: ['sh', '-c', 'until nc -z postgres 5432; do echo waiting db; sleep 2; done']
- name: run-migrations
image: myapp/migrator:1.4
command: ['./migrate', 'up']
containers:
- name: app
image: myapp/api:2.1
ports:
- containerPort: 8080
Sidecar: нативные restartable init с 1.29
Sidecar kubernetes - это контейнер-спутник: сборщик логов, прокси сервисной сетки (Envoy в Istio), агент метрик. Раньше его просто добавляли вторым в containers, и это порождало две классические проблемы. Первая - порядок старта не гарантирован: приложение могло начать слать трафик до того, как Envoy готов. Вторая - на Job-подах sidecar не завершался сам, и джоба висела вечно в Running, потому что прокси не понимает, что основная работа окончена.
С 1.28 (alpha), 1.29 (beta, включено по умолчанию) и стабильно с 1.33 появились нативные sidecar - это init-контейнер с restartPolicy: Always. Магия в том, что такой контейнер:
- стартует в порядке initContainers, до основных, но не блокирует их - kubelet идёт дальше, как только sidecar запущен (и прошёл startup-проба, если задана);
- продолжает работать всё время жизни пода, как обычный контейнер;
- рестартует независимо, не валя при этом основное приложение;
- на Job-подах завершается после основных контейнеров - джоба корректно переходит в Succeeded.
Код: Выделить всё
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels: {app: web}
template:
metadata:
labels: {app: web}
spec:
initContainers:
- name: log-shipper
image: fluent/fluent-bit:3.1
restartPolicy: Always
volumeMounts:
- name: varlog
mountPath: /var/log/app
containers:
- name: app
image: myapp/api:2.1
volumeMounts:
- name: varlog
mountPath: /var/log/app
volumes:
- name: varlog
emptyDir: {}
Общие тома и namespace между контейнерами
В примере выше app пишет логи в /var/log/app, а log-shipper их оттуда читает - оба монтируют один emptyDir-том. emptyDir создаётся при старте пода и живёт ровно столько, сколько под; при удалении пода данные исчезают. Это идеальный канал обмена между контейнерами одного пода.
Сеть тоже общая: внутри пода контейнеры обращаются друг к другу через localhost. Если app слушает на 127.0.0.1:8080, sidecar-прокси достучится туда без всякого Service. А вот PID namespace по умолчанию не общий - чтобы один контейнер видел процессы другого, нужно явно выставить shareProcessNamespace: true в spec. Это бывает нужно для отладочных sidecar, которые шлют сигналы основному процессу.
Lifecycle hooks: postStart и preStop
Хуки жизненного цикла - это команды, которые kubelet выполняет в ключевые моменты. Их два.
postStart запускается сразу после создания контейнера, параллельно с ENTRYPOINT - это не "после запуска приложения", а одновременно с ним. Гарантии порядка между ними нет. Если postStart висит долго, контейнер не перейдёт в Running, а если хук вернёт ошибку - kubelet убьёт и перезапустит контейнер. Поэтому в postStart не суют тяжёлую инициализацию (для этого есть init-контейнеры).
preStop - наш главный инструмент graceful shutdown. Он выполняется синхронно перед отправкой SIGTERM: kubelet ждёт завершения preStop, и только потом шлёт сигнал. Классический трюк - дать балансировщику и kube-proxy время убрать под из EndpointSlices, прежде чем приложение начнёт отключаться:
Код: Выделить всё
spec:
containers:
- name: app
image: myapp/api:2.1
lifecycle:
preStop:
exec:
command: ['sh', '-c', 'sleep 10']
terminationGracePeriodSeconds: 45
Pod lifecycle: остановка по шагам и terminationGracePeriod
Когда ты делаешь kubectl delete pod, разворачивается строгая последовательность (это и есть pod lifecycle на этапе остановки):
- API-сервер помечает под на удаление, ставит deletionTimestamp, под уходит в фазу Terminating;
- параллельно эндпоинты пода выпиливаются из EndpointSlices - новый трафик перестаёт направляться;
- kubelet запускает preStop-хук (если есть) и ждёт его;
- kubelet шлёт SIGTERM основному процессу контейнера;
- идёт отсчёт terminationGracePeriodSeconds (по умолчанию 30 секунд);
- если процесс не завершился за грейс-период - прилетает SIGKILL, и контейнер убивается жёстко.
Фазы пода и состояния контейнеров
Не путай фазу пода и состояние контейнера - это разные уровни. Фаза пода (поле status.phase) - грубое резюме:
- Pending - под принят, но контейнеры ещё не запущены: тянутся образы, крутятся init-контейнеры, либо нет места на нодах;
- Running - под привязан к ноде, все контейнеры созданы, хотя бы один работает;
- Succeeded - все контейнеры завершились с кодом 0 и не перезапускаются (типично для Job);
- Failed - все контейнеры завершились, хотя бы один с ненулевым кодом;
- Unknown - kubelet не отвечает, состояние пода неизвестно.
Код: Выделить всё
kubectl describe pod web-7d4f-abcde
...
Containers:
app:
State: Waiting
Reason: CrashLoopBackOff
Last State: Terminated
Reason: Error
Exit Code: 1
Started: Mon, 15 Jun 2026 10:02:11
Finished: Mon, 15 Jun 2026 10:02:13
Restart Count: 6
QoS-классы и выселение
Когда на ноде кончается память, kubelet начинает выселять (evict) поды. Кого первым - решает QoS-класс, который Kubernetes вычисляет автоматически из requests и limits. Класса три:
- Guaranteed - у каждого контейнера requests равны limits по cpu и памяти. Самый защищённый класс, выселяется в последнюю очередь;
- Burstable - заданы requests, но они меньше limits (или limits не заданы вовсе). Большинство реальных подов тут;
- BestEffort - не заданы ни requests, ни limits. Первый кандидат на вылет под давлением памяти.
Код: Выделить всё
spec:
containers:
- name: app
image: myapp/api:2.1
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "500m"
memory: "512Mi"
Код: Выделить всё
kubectl get pod app -o jsonpath='{.status.qosClass}'
Guaranteed
Static pods: поды без API-сервера
Есть особый вид подов, которыми управляет сам kubelet, а не планировщик. Static pod описывается YAML-файлом, лежащим в каталоге на ноде (обычно /etc/kubernetes/manifests), и kubelet поднимает его напрямую, читая файл. Так живут компоненты управляющего слоя в кластерах, развёрнутых kubeadm: kube-apiserver, etcd, controller-manager. Положил манифест в каталог - под поднялся, убрал файл - под удалился. На API-сервере при этом появляется зеркальный (mirror) под только для наблюдения, но управлять им через kubectl нельзя - удалишь, kubelet тут же воссоздаст из файла. В managed-кластерах (Yandex Managed Service for Kubernetes, VK Cloud) управляющий слой скрыт от тебя, а вот в k3s или Deckhouse на своих нодах static pods встречаются регулярно.
Типичные грабли и антипаттерны
- Тяжёлая инициализация в postStart вместо init-контейнера - хук не дружит с долгими операциями и валит контейнер по таймауту.
- Sidecar вторым контейнером в containers на Job - джоба зависает в Running навечно. В 2026 - нативный sidecar (restartPolicy: Always в initContainers).
- preStop дольше terminationGracePeriodSeconds - под прибивают посреди хука, graceful не случается.
- Приложение игнорирует SIGTERM (частый случай с shell-обёртками типа sh -c) - сигнал не доходит до реального процесса, всегда прилетает SIGKILL. Используй exec-форму ENTRYPOINT или tini.
- Все поды BestEffort, потому что лень прописать requests - под нагрузкой выносит именно их, причём в случайном порядке.
- Логи только в файл внутри контейнера без общего тома - sidecar-сборщик их не видит. Пиши в stdout или в shared emptyDir.
- Создай под с двумя init-контейнерами (первый ждёт сервис через nc -z, второй пишет файл в emptyDir) и основным контейнером, читающим этот файл. Запусти kubectl get pod -w и понаблюдай переход Pending -> Running.
- Добавь нативный sidecar (busybox с restartPolicy: Always в initContainers), который раз в секунду пишет в общий том. Проверь kubectl logs pod -c sidecar.
- Повесь preStop с sleep 10 и terminationGracePeriodSeconds: 30. Сделай kubectl delete pod и засеки время до исчезновения - должно быть около 10+ секунд, не мгновенно.
- Сделай три копии пода: с requests==limits, с requests<limits, и без ресурсов вовсе. Проверь kubectl get pod -o jsonpath на qosClass и убедись, что классы Guaranteed/Burstable/BestEffort.
- Чем нативный sidecar (init с restartPolicy: Always) отличается от обычного второго контейнера в containers, и почему он критичен для Job?
- В каком порядке выполняются preStop, SIGTERM и SIGKILL при удалении пода, и как с этим связан terminationGracePeriodSeconds?
- Как Kubernetes вычисляет QoS-класс пода и в каком порядке выселяет поды при нехватке памяти на ноде?
- Зачем нужен pause-контейнер и почему IP пода не меняется при рестарте основного контейнера?
Под - это не контейнер, а оркестр: pause держит сеть, init-контейнеры готовят почву строго по очереди, нативные sidecar сопровождают приложение всю его жизнь, lifecycle hooks и terminationGracePeriod дают корректный старт и graceful shutdown, а QoS-классы решают, кто выживет под давлением. Понимаешь эту механику - и pod lifecycle перестаёт быть чёрным ящиком: ImagePullBackOff, зависшие джобы, 502 при деплое и внезапные выселения превращаются из мистики в понятные следствия конкретных решений в манифесте.