Ты дошел до продвинутой части курса, и теперь у тебя в руках опасный инструмент. Kubernetes с радостью задеплоит то, что в три часа ночи положит прод, выжрет всю память узла и не отдаст ни одного полезного лога. Большинство инцидентов в кластерах - это не хитрые атаки и не баги самого Kubernetes. Это голый Pod без owner-а, который удалили вместе с узлом. Это контейнер без limits, который сожрал RAM соседей. Это образ с тегом latest, который ночью молча подтянул сломанную версию. Все это - известные грабли, на которые наступают годами.
Этот урок - свод правил, к которым пришла индустрия. Не догма, а дистиллят боли. Мы разберем kubernetes best practices не как заклинания (всегда ставь requests), а с механикой: ЧТО конкретно ломается, если правило нарушить, и КАК планировщик, kubelet и API-сервер ведут себя в этих краевых случаях. Параллельно соберем зеркальный список - kubernetes антипаттерны, то есть конструкции, которые работают на демо и взрываются в продакшене. В конце - чеклист ревью манифеста, который можно повесить рядом с монитором.
Держи в голове простую рамку. Любая kubernetes лучшая практика отвечает на один из трех вопросов: переживет ли это рестарт узла (надежность), не уронит ли это соседей (изоляция ресурсов), сможет ли это кто-то воспроизвести и откатить (управляемость). Если правило не закрывает ни один из них - это карго-культ, и его можно игнорировать.

Надежность: requests, limits и пробы - физика, а не формальность
Главное kubernetes правило, которое нарушают чаще всего: каждый контейнер обязан объявить, сколько ресурсов ему нужно. Без requests планировщик считает, что Pod-у нужно ноль CPU и ноль памяти, и пихает его на любой узел, где формально есть место. По факту места нет - и узел уходит в memory pressure. Дальше включается kubelet и начинает выселять Pod-ы по QoS-классам.
QoS - это не абстракция, а буквальный приговор при нехватке памяти. Pod без requests/limits получает класс BestEffort и выселяется первым. Pod, где requests меньше limits, - это Burstable, выселяется во вторую очередь по превышению request-а. И только Pod, где requests равны limits по обоим ресурсам, получает Guaranteed и трогается последним. Вот валидный кусок, который переводит контейнер в Burstable с жесткой защитой по памяти:
Код: Выделить всё
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
memory: "256Mi"
Пробы - вторая половина надежности. Их три, и путать их - классическая ошибка. livenessProbe решает, жив ли контейнер; провал - рестарт. Если повесить туда тяжелую проверку зависимостей, при лагах БД kubelet начнет крутить рестарты живого приложения и устроит каскад. readinessProbe решает, готов ли Pod принимать трафик; провал - Pod просто убирают из EndpointSlice сервиса, но не убивают. startupProbe прикрывает медленный старт: пока она не прошла, liveness и readiness молчат, и тяжелое JVM-приложение не перезапускают в бесконечном цикле до прогрева. Разделяй их осознанно.
И последнее по надежности - graceful shutdown. Когда Pod удаляют, происходит две вещи параллельно: его убирают из эндпойнтов и шлют процессу SIGTERM. Гонка в том, что kube-proxy и DNS обновляются не мгновенно, и трафик еще секунду-две может литься в умирающий Pod. Лечится preStop-хуком с короткой паузой плюс корректной обработкой SIGTERM в коде (дослать ответы, закрыть соединения), и обязательно terminationGracePeriodSeconds с запасом.
Управляемость: правильные объекты, метки и образы
Никогда не создавай голый Pod в проде. Голый Pod не имеет контроллера-владельца: умер узел - Pod исчез навсегда, никто его не пересоздаст. Всегда оборачивай в контроллер. Stateless-сервис - Deployment. Что-то со стабильной идентичностью и личным диском (БД, очередь, etcd) - StatefulSet, он дает предсказуемые имена pod-0, pod-1 и приклеенные PVC. Узловой агент на каждой ноде (CNI, лог-шиппер) - DaemonSet. Разовая задача - Job, по расписанию - CronJob. Голый Pod допустим только для одноразового дебага через kubectl run --rm -it.
Метки - это не косметика, а способ, которым Kubernetes склеивает объекты. Service находит Pod-ы по selector-у меток, а не по именам. Промахнулся в одной букве лейбла - сервис указывает в пустоту, эндпойнтов ноль, и снаружи это выглядит как полностью рабочий Service без единого бэкенда. Используй стандартный набор app.kubernetes.io: name, instance, version, component, part-of, managed-by. Это не каприз - на эти ключи опираются Helm, дашборды, операторы и автоматика. Минимальный осмысленный набор:
Код: Выделить всё
metadata:
labels:
app.kubernetes.io/name: payments-api
app.kubernetes.io/instance: payments-api-prod
app.kubernetes.io/version: "1.8.3"
app.kubernetes.io/component: backend
app.kubernetes.io/part-of: billing
app.kubernetes.io/managed-by: argocd
Код: Выделить всё
image: registry.example.ru/payments-api@sha256:9b2c...
imagePullPolicy: IfNotPresent
Код: Выделить всё
kubectl get pod payments-api-7d9f-abc -o jsonpath='{.status.containerStatuses[0].imageID}'
# registry.example.ru/payments-api@sha256:9b2c...e1
Изоляция и безопасность: default-deny, не от root, секреты снаружи
По умолчанию в Kubernetes сеть плоская: любой Pod достучится до любого другого Pod-а во всем кластере. Для прода это неприемлемо. Базовая практика безопасности - default-deny: в каждом namespace создаешь NetworkPolicy, которая запрещает весь входящий (а лучше и исходящий) трафик, а дальше точечно открываешь только нужное. Это zero-trust на уровне сети.
Код: Выделить всё
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: billing
spec:
podSelector: {}
policyTypes:
- Ingress
Дальше - не запускай от root. Pod Security Standards - современная замена устаревших PodSecurityPolicy. Уровень restricted требует non-root, запрещает привилегии, эскалацию и опасные capabilities. Включается лейблом на namespace, и API-сервер сам начинает резать нарушителей:
Код: Выделить всё
apiVersion: v1
kind: Namespace
metadata:
name: billing
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/warn: restricted
Код: Выделить всё
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
Высокая доступность: PodDisruptionBudget и anti-affinity
Допустим, у тебя три реплики - кажется, что HA есть. Приходит админ, делает kubectl drain ноды на обслуживание, и если все три реплики стояли на ней, сервис лег целиком. От этого защищают два механизма в паре.
PodDisruptionBudget (apiVersion policy/v1) ограничивает добровольные выселения - drain, апгрейд узлов, работу autoscaler-а. Он говорит планировщику выселений: не уводи разом больше, чем можно. PDB защищает только от добровольных нарушений; от падения узла он не спасет - это уже работа репликации и anti-affinity.
Код: Выделить всё
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: payments-api-pdb
namespace: billing
spec:
minAvailable: 2
selector:
matchLabels:
app.kubernetes.io/name: payments-api
Второй механизм - podAntiAffinity. Он размазывает реплики по разным узлам и зонам, чтобы падение одного узла не унесло все сразу. Современный и более гибкий способ - topologySpreadConstraints:
Код: Выделить всё
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: payments-api
GitOps вместо kubectl-в-прод
Самая коварная категория - ручные изменения мимо git. kubectl edit на проде кажется быстрым, но создает дрейф: реальное состояние кластера расходится с тем, что в репозитории, и никто этого не видит. Через месяц никто не помнит, почему у Pod-а вдруг другой лимит, откатить не к чему, воспроизвести в стейджинге невозможно.
Практика 2026 - GitOps: git является единственным источником правды, а Argo CD или Flux непрерывно приводят кластер к описанному в репозитории состоянию. Любое изменение - это коммит и pull request с ревью. Откат - это git revert. Argo CD к тому же подсвечивает drift: если кто-то руками поменял ресурс, ты увидишь OutOfSync и сможешь засинкать обратно. Доступ на запись в прод у людей при этом стоит закрыть - пусть пишет только контроллер.
Манифесты не копипасть между окружениями - используй Kustomize: базовый набор плюс оверлеи для dev/stage/prod, которые патчат только различия (реплики, лимиты, домены). Helm - для упаковки и параметризации переиспользуемых чартов. А enforcement правил из этого урока автоматизируй политиками: Kyverno или нативная ValidatingAdmissionPolicy на CEL (стабильна, работает прямо в API-сервере без внешнего вебхука) могут блокировать на входе Pod без лимитов, образ с latest или запуск от root.
Сводный список антипаттернов
- cluster-admin всем подряд вместо узких ролей RBAC на namespace - один скомпрометированный токен открывает весь кластер.
- Один namespace на все - нет границ для квот, политик, RBAC и сетевой изоляции; все сервисы в общей куче.
- privileged: true и hostNetwork без крайней нужды - контейнер фактически равен руту на узле.
- Pod-ы без requests/limits - BestEffort, выселяются первыми, роняют соседей при нехватке памяти.
- БД, развернутая голым StatefulSet без оператора - бэкапы, failover и апгрейды придется делать руками, и в инцидент это всплывет.
- Состояние в emptyDir или в локальной ФС контейнера - данные исчезают при рестарте; состояние - в PVC или внешнем хранилище.
- Ручные правки kubectl edit в обход git - дрейф, который невозможно воспроизвести и откатить.
- latest вместо пина по версии или digest - неконтролируемая подмена кода под нагрузкой.
Возьми заведомо плохой Pod и пройди по чеклисту руками.
- Создай голый Pod с образом nginx:latest без ресурсов и проб: kubectl run badpod --image=nginx:latest. Посмотри kubectl describe pod badpod - найди QoS Class: BestEffort и imagePullPolicy: Always.
- Перепиши его в Deployment с тремя репликами, метками app.kubernetes.io, пином образа по тегу версии, requests/limits и тремя пробами.
- Добавь securityContext под restricted и навесь на namespace лейбл pod-security.kubernetes.io/enforce=restricted. Применяй, читай предупреждения, чини, пока не пройдет.
- Добавь PodDisruptionBudget с minAvailable: 2 и topologySpreadConstraints по hostname. Выполни kubectl drain на узле, где сидит реплика, и убедись, что выселяется не больше разрешенного.
- Сравни kubectl get pod ... -o jsonpath='{.status.qosClass}' до и после - было BestEffort, стало Burstable или Guaranteed.
- Это контроллер (Deployment/StatefulSet/DaemonSet/Job), а не голый Pod.
- Есть requests и memory limit; CPU-limit - осознанно.
- Три пробы по назначению: startup, readiness, liveness.
- Образ пинован по версии или digest, imagePullPolicy адекватен.
- Метки app.kubernetes.io проставлены и совпадают с selector-ом сервиса.
- securityContext: non-root, drop ALL caps, no privilege escalation; namespace под PSS restricted.
- Секреты через External Secrets, не в git и не в открытом манифесте.
- NetworkPolicy default-deny в namespace плюс точечные разрешения.
- Для HA: PDB с запасом и spread/anti-affinity по узлам и зонам.
- graceful shutdown: SIGTERM обрабатывается, есть terminationGracePeriodSeconds.
- Манифест едет через git и Argo CD/Flux, а не через kubectl edit.
Все эти k8s practices сводятся к трем вопросам: переживет ли рестарт, не уронит ли соседей, можно ли воспроизвести и откатить. Голый Pod, latest, отсутствие лимитов, root и ручные правки - вот пятерка, которая дает большинство ночных инцидентов. Прогоняй каждый манифест через чеклист, а лучше - повесь enforcement политикой, чтобы плохое просто не попадало в кластер. Хорошая практика, которую не проверяет машина, рано или поздно будет нарушена человеком в три часа ночи.