voidlearn
 

3.1Модель и базовые объекты

Сопоставишь Deployment, ReplicaSet и Pod с их владельцами-контроллерами и разберёшь, как kubelet запускает Pod. Научишься отличать конфигурацию приложения от секрета и формулировать её как желаемое состояние.

Зачем k8s и модель желаемого состояния

До кластеров деплой был императивным: «зайди на четыре сервера, останови сервис, положи бинарь, запусти, проверь». Каждый шаг мог упасть посередине, и состояние системы становилось неизвестным. Kubernetes переворачивает подход: ты описываешь желаемое состояние («хочу 6 реплик образа app:1.4.2 с такими лимитами»), а кластер сам приводит к нему реальность и дальше удерживает её там вечно.

Технически всё держится на одном паттерне, цикле согласования. API-сервер с базой в etcd хранит объекты. Контроллер подписывается на изменения нужного типа объектов (watch), в бесконечном цикле сравнивает spec (чего хотят) с status (что есть) и делает шаг в сторону сближения. Никто не отдаёт команды напрямую: Deployment-контроллер создаёт ReplicaSet, ReplicaSet-контроллер создаёт Pod-объекты, scheduler проставляет им nodeName, kubelet на этой ноде видит «мой под» и просит container runtime запустить контейнеры. Каждый занят своим маленьким инвариантом.

Формулировка, которую хочет услышать интервьюер

«Kubernetes не столько запускает контейнеры, сколько поддерживает состояние. Отсюда все свойства: самовосстановление (упал под - контроллер заметил расхождение и создал новый), декларативность (манифест в git = источник правды, а GitOps просто автоматизирует apply), идемпотентность (kubectl apply дважды не ломает ничего), и eventual consistency. Последнее важнее всего на практике: половина проблем с трафиком при деплое идёт от гонок между контроллерами, которые сходятся с разной скоростью.»

Базовые объекты: от Pod до Ingress

ОбъектЧто этоЧто важно помнить
Podминимальная единица планирования: один или несколько контейнеров с общим сетевым namespace, IP и томамиэфемерен, IP меняется при пересоздании; напрямую поды не создают
ReplicaSetдержит заданное число одинаковых подовруками не трогают - им управляет Deployment; на каждую версию свой RS
Deploymentуправляет ReplicaSet-ами и выкаткой между нимихранит историю ревизий → rollout undo
StatefulSetто же для stateful: стабильные имена app-0…app-N, свой PVC на реплику, упорядоченный запускдля БД, Kafka, всего, где реплики не взаимозаменяемы
DaemonSetпо одному поду на каждую нодуагенты логов, метрик, CNI
Job / CronJobразовая и периодическая задачамиграции БД - обычно Job; следи за backoffLimit и конкурентностью
Serviceстабильный виртуальный адрес и балансировка на поды по label-селекторуэто не процесс, а правила в ядре каждой ноды
Ingressправила L7-маршрутизации (host/path → Service) для контроллерасам по себе ничего не делает: нужен Ingress Controller; API заморожен, новое развивают в Gateway API

Три типа Service

  • ClusterIP (по умолчанию) даёт виртуальный IP, доступный только внутри кластера. DNS-имя app.default.svc.cluster.local. Основной рабочий вариант: сервис-к-сервису ходят именно так.
  • NodePort добавляет к этому один и тот же порт из диапазона 30000–32767, открытый на каждой ноде кластера. Прийти можно на любую ноду, дальше DNAT уведёт на под, возможно на другой ноде. В проде сам по себе почти не нужен: это строительный блок для внешнего балансировщика.
  • LoadBalancer добавляет к NodePort запрос к облаку на внешний балансировщик, который раскидывает трафик по нодам. Неприятный нюанс тут один: каждый такой Service оборачивается отдельным платным LB в облаке, поэтому наружу обычно торчит один LoadBalancer перед Ingress Controller, а все приложения живут за ним на ClusterIP.
  • Ещё есть headless (clusterIP: None): DNS отдаёт не один VIP, а список IP всех готовых подов. Нужен, когда клиент балансирует сам: gRPC-клиент, драйвер БД, StatefulSet с адресацией конкретной реплики.
Путь одного HTTP-запроса: снаружи внутрь Клиент api.site.ru Cloud LB Service типа LoadBalancer Ingress Controller (под с nginx) Service 10.96.0.14:80 процесса нет kube-proxy: iptables / nftables DNAT на под 10.1.5.7:8080 контейнер твой Go-бинарь Правила DNAT берутся не из воздуха - их пишет kube-proxy по EndpointSlice: EndpointSlice сервиса app 10.1.5.7:8080 ready 10.1.2.3:8080 ready 10.1.9.1:8080 notReady EndpointSlice-контроллер следит за подами с нужными labels и их readiness readiness=false - адрес помечают notReady, и только потом трафик перестаёт идти Ключевое: Service - это не прокси и не процесс, а набор правил в ядре каждой ноды. Балансировка происходит на исходящей ноде и на первый пакет соединения: в iptables и nftables выбор случайный. Дальше соединение живёт на одном поде.
Ingress → Service → EndpointSlice → Pod. Данные идут по верхней цепочке, но управляет ей нижняя: пока EndpointSlice не обновился, трафик продолжает лететь в под, который уже уходит.
Два уточнения, которые отличают «читал доку» от «работал»
  • Ingress-контроллеры чаще всего минуют ClusterIP. ingress-nginx по умолчанию читает EndpointSlice сам и балансирует прямо на IP подов, потому что iptables-балансировка случайная и не умеет ни sticky-сессий по cookie, ни ретраев, ни L7-политик. Через Service он ходит, только если включить service-upstream. Сам проект в марте 2026 закрыт: обновлений, в том числе по безопасности, больше не будет, и Kubernetes советует переходить на Gateway API или другой контроллер.
  • gRPC и keep-alive ломают балансировку Service. DNAT работает на уровне соединения, а gRPC держит одно долгоживущее HTTP/2-соединение и льёт в него все запросы. Итог: один под получает весь трафик клиента. Лечится headless-сервисом плюс client-side round-robin в gRPC-клиенте, либо service mesh, либо периодическим MAX_CONNECTION_AGE на сервере.

ConfigMap и Secret: env против volume

ConfigMap и Secret устроены одинаково: наборы пар ключ-значение в etcd. Главных отличий три: значения Secret в API передаются в base64 (в etcd лежат сырые байты), к нему обычно применяют отдельные RBAC-правила, а смонтированный томом он лежит у kubelet в tmpfs, а не на диске. Base64 не шифрует, а кодирует; kubectl get secret -o yaml плюс base64 -d отдают пароль в открытом виде любому, у кого есть права на чтение секретов в неймспейсе.

Подключить и то и другое можно переменными окружения или файлами из тома:

apiVersion: apps/v1
kind: Deployment
metadata: { name: app }
spec:
  selector: { matchLabels: { app: app } }
  template:
    metadata: { labels: { app: app } }
    spec:
      containers:
        - name: app
          image: registry.local/app:1.4.2
          env:
            # 1) отдельный ключ из ConfigMap
            - name: LOG_LEVEL
              valueFrom:
                configMapKeyRef: { name: app-config, key: log_level }
            # 2) секрет как переменная окружения
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef: { name: app-secrets, key: db_password }
            # 3) поля самого пода через Downward API
            - name: POD_NAME
              valueFrom: { fieldRef: { fieldPath: metadata.name } }
            - name: POD_NAMESPACE
              valueFrom: { fieldRef: { fieldPath: metadata.namespace } }
          envFrom:
            - configMapRef: { name: app-config }   # все ключи разом
          volumeMounts:
            - name: config
              mountPath: /etc/app        # файлы, обновляются на лету
              readOnly: true
      volumes:
        - name: config
          configMap: { name: app-config }
env / envFromvolume (файлы)
Когда читаетсяодин раз, при execveв любой момент, как обычный файл
Обновление ConfigMapсам не подхватится - нужен перезапуск контейнера или новый подфайл обновится сам, но с задержкой до ~1–2 минут (kubelet sync period + задержка его кэша)
Утечкивидно в crictl inspect на ноде, в /proc/PID/environ, попадает в дочерние процессы и часто в креш-дампывиден только тому, кто читает файл; можно ограничить права
Форматтолько плоские строкилюбой файл: YAML, PEM-сертификат, keytab
Размерлимит на суммарное окружениедо 1 МиБ на объект (проверка API-сервера)
Итоггодится для неизменяемых настроек и для 12-factor-стиляобязателен для секретов, сертификатов и всего, что хочется менять без рестарта
«Мы поменяли ConfigMap, а сервис работает по-старому»

Классика. Если конфиг прокинут через env, правка ConfigMap вообще ничего не меняет: процесс прочитал окружение при старте, а перезапускать его кластеру незачем, spec Deployment-а ведь не трогали. Есть два честных решения: (1) считать хеш ConfigMap и класть его в аннотацию pod-шаблона (checksum/config: {{ ... | sha256sum }}, так принято в Helm-чартах), тогда изменение конфига меняет шаблон и запускает нормальный rolling update; (2) монтировать конфиг файлом и перечитывать его в приложении по fsnotify или по SIGHUP. Второй путь красив, но требует, чтобы весь код умел безопасно переключаться на новые значения на лету. Это не бесплатно.

Почему Secret «по умолчанию не защищён» и что с этим делают
  • В etcd секреты лежат как есть, пока не включён EncryptionConfiguration (encryption at rest) на API-сервере. Бэкап etcd забирает с собой все пароли.
  • Права на get secrets в неймспейсе = права на все пароли этого неймспейса. Право создавать поды даёт то же самое: под смонтирует любой секрет своего неймспейса. RBAC надо резать по-настоящему, а не «разработчикам admin на dev».
  • Секрет, смонтированный томом, обновит файл сам, и это плюс к ротации.
  • Промышленные варианты: External Secrets Operator или CSI-драйвер, тянущие значения из Vault / облачного менеджера секретов; Sealed Secrets или SOPS, чтобы зашифрованный секрет можно было хранить в git; в облаках привязывают идентичность пода к IAM-роли (Workload Identity, IRSA), чтобы длинных статических ключей не существовало вовсе.

Вопросы

8
Суть: Kubernetes нужен не для того, чтобы «запускать контейнеры» - это умеет и systemd. Он нужен, чтобы непрерывно приводить реальность к описанному состоянию: держать N живых реплик, знать их адреса, заменять их по одной при выкатке и делить между ними ресурсы ноды. Ежедневный набор объектов узкий: Deployment (который делает ReplicaSet, который делает Pod), Service, Ingress, ConfigMap/Secret. Остальное - по мере надобности.

Что реально даёт кластер

  • Самовосстановление. Процесс упал, kubelet перезапустил контейнер. Нода умерла, контроллер увидел, что подов меньше, чем в spec.replicas, и создал новые на других нодах. Никто не звонит дежурному.
  • Service discovery без внешнего реестра. Адрес зависимости живёт в DNS: billing.prod.svc.cluster.local вместо списка IP в конфиге, который протухает при каждом перезапуске.
  • Управляемая выкатка. Rolling update с контролем скорости и автоматической остановкой, если новые поды не становятся Ready. Откат делается одной командой, потому что старый ReplicaSet никуда не делся.
  • Bin-packing и изоляция ресурсов. Планировщик раскладывает поды по нодам, глядя на requests, а cgroups не дают одному сервису съесть ноду.
  • Горизонтальное масштабирование по метрике делает HPA, а ноды добавляет Cluster Autoscaler.
  • Единый декларативный интерфейс. Вся конфигурация инфраструктуры живёт в git как YAML и применяется идемпотентно. На этом и стоит GitOps.

Объекты снизу вверх

  • Pod планируется как единое целое: один или несколько контейнеров, которые делят network namespace (общий localhost и один IP) и могут делить тома. Под смертен и почти неизменяем: на месте меняются только образ контейнера и (с 1.35) ресурсы, остальное не «обновить», можно только удалить и создать новый под, уже с другим IP. Всё, что ты пишешь про «под перезапустился», почти всегда означает «перезапустился контейнер внутри того же пода» (restartCount растёт, IP не меняется).
  • ReplicaSet держит один инвариант: «подов с такими labels должно быть ровно N». Руками его почти никогда не создают.
  • Deployment управляет ReplicaSet-ами и задаёт стратегию обновления: на каждую версию pod-шаблона создаётся свой ReplicaSet, а деплой перекачивает реплики из старого в новый. Старые RS остаются (revisionHistoryLimit), поэтому rollout undo просто возвращает старый шаблон, а реплики перекачиваются обратно тем же rolling update.
  • Service даёт стабильное имя и виртуальный IP меняющемуся множеству подов. Живёт над labels, а не над подами напрямую.
  • Ingress описывает L7-маршрутизацию снаружи внутрь: хост, путь, TLS. Сам по себе это просто объект-описание; работает он, только если в кластере есть ingress-контроллер (Traefik, HAProxy, NGINX Ingress Controller от F5), который читает эти объекты и перенастраивает себя. API Ingress заморожен: Kubernetes рекомендует Gateway API.
  • ConfigMap / Secret держат конфигурацию и чувствительные данные отдельно от образа.
  • Рядом: StatefulSet (стабильные имена и своё хранилище на реплику, нужен для БД, Kafka, всего, где реплики не взаимозаменяемы), DaemonSet (по одному поду на каждую ноду, так живут агенты логов и метрик), Job/CronJob (разовые и периодические задачи), PVC (заявка на диск).

Типы Service - что за чем стоит

ТипЧто делаетКогда используют
ClusterIPвиртуальный IP, доступный только внутри кластера; правила DNAT в ядре нодпо умолчанию - межсервисное общение
NodePortто же плюс открытый порт 30000–32767 на каждой нодеотладка, dev-кластер, вход для внешнего балансировщика
LoadBalancerNodePort плюс заказ облачного L4-балансировщика с внешним IPпубличный вход; в облаке каждый такой Service - это отдельный платный LB
ExternalNameпросто CNAME в DNS кластераобёртка над внешним адресом, чтобы код не знал, внутри сервис или снаружи
headless (clusterIP: None)никакого VIP; DNS отдаёт A-записи всех готовых подовStatefulSet, gRPC с клиентской балансировкой

Именно поэтому наружу обычно торчит один LoadBalancer, за ним ingress-контроллер, а дальше уже десятки ClusterIP-сервисов: платить за LB на каждый микросервис никто не хочет, да и TLS с маршрутизацией по хостам всё равно нужны в одном месте.

Чем добить

«Все объекты связаны через labels, а не через ссылки. Service не знает про Deployment, он знает про селектор. Отсюда и хорошее (canary делается вторым Deployment с теми же labels), и плохое: опечатка в селекторе даёт Service без эндпоинтов, который молча отвечает connection refused, и в манифестах ничего не подсвечено красным. При “сервис не отвечает” я первым делом смотрю kubectl get endpointslices -l kubernetes.io/service-name=app: если список пуст, проблема не в сети, а в labels или в readiness.»

Честный ответ на «а когда k8s не нужен»

Когда сервисов два, нагрузка ровная, а команда одна. Кластер сам становится отдельной системой со своей эксплуатацией: обновления control plane, CNI, RBAC, ingress, мониторинг самого кластера. Для одного бинаря systemd + nginx честнее и дешевле. k8s начинает окупаться, когда сервисов десятки, команд несколько и нужен единый способ выкатывать и ограничивать их всех.

Суть: технически ConfigMap и Secret - одно и то же (пары ключ-значение в etcd), отличаются base64 в API, отдельными RBAC-правилами и tmpfs на ноде; Secret не зашифрован сам по себе. Выбор способа монтирования важнее выбора объекта: env читается один раз при старте и в работающем процессе не обновится, volume обновляется на лету, но с задержкой, и не светится в /proc/PID/environ.

Разница между объектами

Secret отличается от ConfigMap тремя вещами, и ни одна из них не даёт шифрования: в API значения передаются в base64 (это кодирование, а не защита), к секретам обычно применяют отдельные RBAC-правила, и kubelet держит их в tmpfs, а не на диске ноды. В etcd секрет лежит открытым текстом, пока на API-сервере не включён EncryptionConfiguration. Практический вывод: кто унёс бэкап etcd, унёс все пароли, а право get secrets в неймспейсе равно знанию всех паролей этого неймспейса.

env против volume - разница принципиальная

  • env/envFrom. Значения подставляются в момент execve и после этого заморожены навсегда. Правка ConfigMap проходит мимо: spec Deployment-а не поменялся, значит поды пересоздавать незачем. Плюс значения видны в crictl inspect на ноде, в /proc/PID/environ, наследуется всеми дочерними процессами и регулярно попадает в креш-дампы и в трассировки паник.
  • Volume. kubelet проецирует ключи как файлы через симлинки и обновляет их при изменении объекта, но не мгновенно, а с задержкой до пары минут (период синхронизации kubelet плюс задержка его кэша). Обновление атомарное: подменяется симлинк на новый каталог, так что частично записанного файла приложение не увидит. Секрет файлом можно ротировать без рестарта, и это главный аргумент.

Есть ещё immutable: true: помеченный так ConfigMap или Secret нельзя изменить (только удалить и создать заново), зато kubelet закрывает на него watch, и на больших кластерах это заметно снимает нагрузку с API-сервера.

Как правильно перезапускать поды на смену конфига

# Helm-идиома: хеш конфига в аннотации pod-шаблона.
# Меняется ConfigMap -> меняется аннотация -> меняется шаблон -> честный rolling update.
spec:
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
# Без Helm то же самое руками:
kubectl rollout restart deployment/app      # проставит kubectl.kubernetes.io/restartedAt

# Посмотреть, что реально лежит в секрете:
kubectl get secret app-secrets -o jsonpath='{.data.db_password}' | base64 -d
На чём ловят
  • «Secret и так безопасен». Нет. Безопасность даёт encryption at rest плюс жёсткий RBAC плюс отсутствие длинных статических ключей вообще (External Secrets Operator, Vault, IRSA/Workload Identity).
  • Секреты в git. Даже в приватном репозитории plaintext лежать не должен. Ответ уровня мидла: SOPS или Sealed Secrets, чтобы в git лежал шифротекст, а расшифровывали уже в кластере.
  • «Смонтировали файлом, значит обновится сразу». Не сразу, и только если приложение перечитывает файл (fsnotify, SIGHUP, таймер). Иначе разницы с env нет никакой, а иллюзия есть.
  • subPath ломает обновление. Файл, смонтированный через subPath, в работающем контейнере не обновляется: kubelet подменяет каталог целиком, а тут привязан конкретный inode. Новое содержимое контейнер увидит только после перезапуска.
Суть: три пробы отвечают на три разных вопроса. Startup - «процесс ещё поднимается, не трогайте его». Readiness - «можно ли слать трафик прямо сейчас» (провал = адрес пода в EndpointSlice помечают неготовым, процесс жив). Liveness - «процесс безнадёжно завис» (провал = kubelet убивает контейнер). Правило: liveness должна проверять только сам процесс, readiness - готовность обслуживать. Смешение этих двух ролей - главный способ уронить сервис пробами.
Таймлайн одного контейнера startup readiness liveness опрашивается, двум другим - стоп не запускается каждые periodSeconds: «слать ли трафик?» не запускается каждые periodSeconds: «процесс жив?» t = 0 старт startup: успех пробы включаются readiness: fail адрес помечен notReady в EndpointSlice, процесс НЕ трогают, вернётся - вернут liveness: fail × failureThreshold SIGTERM, затем SIGKILL контейнеру, restartCount += 1 Пока идёт startup, ни readiness, ни liveness не выполняются - именно поэтому медленный старт больше не убивает контейнер.
Три пробы - три исхода. Провал readiness обратим и стоит трафика; провал liveness необратим и стоит перезапуска. Startup нужен ровно для того, чтобы долгий старт не выглядел как зависание.

Что проверять в каждой

startupreadinessliveness
Вопрос«поднялся?»«готов принимать трафик?»«не завис ли навсегда?»
Провалрестарт контейнера (после исчерпания порога)адрес помечают неготовым в EndpointSliceрестарт контейнера
Проверятьтот же эндпоинт, что readiness, но с большим бюджетом временипрогрет ли кэш, есть ли соединение с БД и брокером, не идёт ли graceful shutdownтолько «HTTP-хендлер отвечает» - плоский 200 OK
Внешние зависимостиможноможно и нужно - но осторожнокатегорически нельзя

Как пробы роняют сервис - четыре сценария

  1. БД в liveness. База моргнула на 30 секунд. Liveness падает у всех подов сразу, kubelet перезапускает их все, поды стартуют, снова не видят базу, снова умирают, и весь сервис уходит в CrashLoopBackOff. Проблема была на полминуты, а недоступность растянулась на десять минут. Правильно проверять БД в readiness. Тогда поды живы, соединения переустановятся сами, и как только база вернётся, трафик пойдёт без единого рестарта.
  2. Слишком короткий liveness при медленном старте. Приложение поднимается 40 секунд (миграции, прогрев кэша), initialDelaySeconds: 10, порог 3, период 5. Контейнер убивают на 20–25-й секунде, и он никогда не стартует. Раньше это лечили большим initialDelaySeconds, но тогда зависший под тоже не перезапускался бы полминуты. Правильно ставить startupProbe: бюджет failureThreshold × periodSeconds задаётся щедро (скажем, 30 × 5 = 150 с), а после успеха liveness работает с жёсткими нормальными таймингами.
  3. Тяжёлая проба. Readiness, которая на каждый вызов делает SELECT с диска, под нагрузкой начинает отваливаться по таймауту. Поды по очереди выпадают из балансировки, нагрузка перераспределяется на оставшихся, те тоже перестают успевать. Каскад. Проба должна быть дешёвой: читать заранее посчитанный флаг, а не ходить в базу синхронно.
  4. Общий пул горутин/потоков. Если хендлер пробы обслуживается тем же сервером, что забит запросами, проба падает не потому, что сервис мёртв, а потому что он занят. Отсюда практика: отдельный порт и отдельный http.Server для /healthz и /readyz, не проходящий через общий middleware, rate limit и очередь.
startupProbe:                 # бюджет на старт: 30 × 5 = 150 секунд
  httpGet: { path: /readyz, port: 8081 }
  periodSeconds: 5
  failureThreshold: 30
readinessProbe:               # быстро выводим из ротации и быстро возвращаем
  httpGet: { path: /readyz, port: 8081 }
  periodSeconds: 5
  timeoutSeconds: 2
  failureThreshold: 2
  successThreshold: 1
livenessProbe:                # долго терпим и только потом убиваем
  httpGet: { path: /healthz, port: 8081 }
  periodSeconds: 10
  timeoutSeconds: 3
  failureThreshold: 6
Детали, которые выдают практику
  • Пробы выполняет kubelet ноды, а не API-сервер и не Service. Поэтому проба ходит на IP пода напрямую и не зависит от Ingress и Service. NetworkPolicy её тоже не режет: по спецификации трафик с собственной ноды пода разрешён всегда.
  • Liveness не чинит дедлок в одной горутине, если HTTP-сервер продолжает отвечать. Хочешь ловить зависания, проверяй в /healthz «сердцебиение» фоновых воркеров: таймстемп последнего успешного цикла, обновляемый атомарно. Условие «последний тик был больше 5 минут назад» даёт законный повод упасть.
  • readiness при graceful shutdown: 503 не обязателен, но помогает. По SIGTERM можно первым делом перевести /readyz в 503 и выждать несколько секунд, прежде чем закрывать сервер. Из EndpointSlice удаляемый под выводят и без пробы (условие ready сбрасывается сразу), но kube-proxy и ingress-контроллер узнают об этом с задержкой, а внешний балансировщик, который сам опрашивает поды, - только по пробе.
  • exec-пробы дороги. Каждый вызов запускает в контейнере отдельный процесс. На сотнях подов с периодом 1 секунда это заметная нагрузка на ноду; HTTP или gRPC-проба (grpc в spec с 1.24) дешевле.
  • Порядок SIGTERM/SIGKILL при провале liveness: контейнеру шлётся SIGTERM, даётся terminationGracePeriodSeconds (можно переопределить прямо в пробе полем terminationGracePeriodSeconds), потом SIGKILL.
Суть: requests - это то, что видит планировщик (сколько зарезервировать на ноде), limits - то, что видит ядро (cgroup-ограничение). CPU - ресурс сжимаемый: превышение лимита даёт троттлинг. Память - несжимаемый: превышение даёт SIGKILL от cgroup-OOM-killer. Соотношение requests и limits определяет QoS-класс, а по нему можно прикинуть, кого kubelet выселит с ноды первым.

Механика

  • requests используются прежде всего при планировании: scheduler ищет ноду, где сумма requests всех подов плюс наш под влезает в allocatable. После размещения requests продолжают работать для CPU как вес: requests.cpu: 500m превращается в cpu.weight в cgroup v2, то есть в долю при конкуренции. Память requests не ограничивают вообще, это чисто учётная величина (по ней же считаются OOM-скоринг и порядок вытеснения).
  • limits.cpu задаёт жёсткую квоту CFS: cpu.max, то есть «не больше X микросекунд процессорного времени за период 100 мс». Исчерпал квоту, и тебя останавливают до конца периода. Это троттлинг: процесс не падает, он просто стоит, и это видно как рост latency при низкой средней утилизации CPU.
  • limits.memory превращается в memory.max. Превысил, и ядро вызывает OOM-killer внутри cgroup. На cgroup v2 kubelet (с 1.28) по умолчанию включает групповой OOM-kill, так что гибнут все процессы контейнера разом, а не один самый прожорливый. Никакого предупреждения, никакого шанса «освободить память»: SIGKILL, exit code 137.

QoS-классы: кого выселят первым

КлассУсловиеЧто это значит
Guaranteedдля каждого контейнера limits == requests по CPU и памятивыселяют последним; при статической CPU-политике kubelet и целом числе CPU получает эксклюзивные ядра
Burstablerequests заданы, но не равны limits (или заданы не для всех ресурсов)обычный случай; при нехватке памяти на ноде kubelet смотрит на приоритет и на то, насколько под превысил свои requests
BestEffortни requests, ни limits не заданывыселяют первым; на проде так жить нельзя

С QoS связаны две разные вещи, которые часто путают. Eviction устраивает kubelet, когда на ноде кончается память: выселяет поды по превышению requests и приоритету. При жёстком пороге (по умолчанию настроены только такие) под убивают почти без grace period, при мягком дают урезанный. OOMKill устраивает ядро, когда контейнер упёрся в собственный limits.memory: мгновенно и без разговоров. Первое видно как Evicted в статусе пода, второе как OOMKilled в lastState контейнера.

Диагностика OOMKilled

kubectl describe pod app-7d9f8b6c5d-xk2wq | sed -n '/Last State/,/Restart Count/p'
# так выглядит блок в kubectl 1.37 (время условное):
#   Last State:     Terminated
#     Reason:       OOMKilled
#     Exit Code:    137          # 128 + 9 (SIGKILL)
#     Started:      Thu, 17 Sep 2026 10:41:02 +0300
#     Finished:     Thu, 17 Sep 2026 10:44:19 +0300
#   Ready:          True
#   Restart Count:  4

kubectl get pod app-7d9f8b6c5d-xk2wq -o jsonpath='{.status.containerStatuses[0].lastState}'
kubectl logs app-7d9f8b6c5d-xk2wq --previous  # логи убитого контейнера, а не нового
kubectl top pod -l app=billing --containers   # текущее потребление (нужен metrics-server)

# На ноде это подтвердит ядро:
dmesg -T | grep -i -E 'oom|killed process'

Дальше вопрос сводится к «это утечка или просто мало памяти». Различить их помогает форма графика RSS: ровная пила с растущим основанием выдаёт утечку; ступенька под нагрузкой говорит, что лимит просто мал; одиночный всплеск значит, что какой-то запрос тянет в память гигантский ответ или файл целиком. Для Go дальше идут pprof (/debug/pprof/heap, сравнение двух снимков через go tool pprof -base) и метрики рантайма: go_memstats_heap_inuse_bytes, go_memstats_heap_idle_bytes, go_goroutines. Если число горутин растёт, почти наверняка течёт и память.

Специфика Go, которую ждут в этом вопросе

По CPU это уже история - но её надо знать. До Go 1.25 рантайм не видел cgroup-лимиты: GOMAXPROCS брался из числа ядер ноды, и на 64-ядерной машине с лимитом 500m рантайм создавал 64 P, параллелил GC на 64 потока и мгновенно выбирал квоту CFS. Отсюда жестокий троттлинг и рост p99 на ровном месте. Лечилось это automaxprocs или явным GOMAXPROCS из Downward API. Начиная с Go 1.25 рантайм читает cpu.max своей cgroup сам (если в go.mod указана версия 1.25 или новее), дробный лимит округляет вверх, но не ниже 2, и пересматривает значение на лету, если лимит поменяли. Внешняя библиотека и переменная из Downward API больше не нужны и даже мешают: библиотека при заданном лимите вызывает runtime.GOMAXPROCS(n), переменная задаёт значение вручную, а ручная установка выключает автоматику рантайма (вернуть её можно вызовом runtime.SetDefaultGOMAXPROCS()). А вот по памяти автоматики нет до сих пор, и это остаётся на тебе: GC ориентируется на GOGC (рост кучи вдвое), а про limits.memory не знает, поэтому куча спокойно перерастёт лимит, и вместо сборки мусора ты получишь OOMKill. Лечится GOMEMLIMIT, выставленным примерно в 80–90 % от лимита пода: при приближении к нему GC начинает работать чаще и не даёт куче вырасти. Предел этот мягкий, от настоящей утечки он не спасёт, зато спасёт от «просто пиковой нагрузки».

Практические правила
  • limits.memory ставить всегда, иначе один под с утечкой выселит с ноды соседей. requests.memory держать близко к реальному рабочему потреблению, а лимит с запасом на пик.
  • С limits.cpu спорно. Многие команды сознательно его не ставят (оставляют только requests), потому что CFS-троттлинг бьёт по хвостовым перцентилям даже при средней утилизации 30 %: квота выбирается всплеском в начале периода, а остаток 100-миллисекундного окна процесс просто стоит. Как компромисс, лимит ставят не впритык и всегда смотрят метрику container_cpu_cfs_throttled_seconds_total.
  • Разрыв между requests и limits означает овербукинг ноды. Он полезен (плотность выше), но при синхронном пике все поды разом захотят свой burst, и начнётся eviction. Чем важнее сервис, тем ближе requests к limits.
  • Exit code 137 не всегда OOM. Это просто «убит SIGKILL»: так же выглядит превышение terminationGracePeriodSeconds. Отличать по полю Reason, а не по коду.
Суть: HPA - контроллер, который раз в 15 секунд считает желаемые реплики = ceil(текущие × текущая метрика / целевая метрика) и правит spec.replicas. Хорошая метрика для масштабирования - та, что линейно растёт с нагрузкой и линейно падает от добавления реплики. CPU этому условию удовлетворяет только у сервисов, которые действительно считают; у типичного I/O-bound Go-сервиса лучше работают RPS и длина очереди.

Как считает

Формула такая: desired = ceil(current × (metric / target)). Если 4 пода при целевых 60 % CPU показывают 90 %, HPA хочет ceil(4 × 90/60) = 6. Есть зона нечувствительности (по умолчанию 10 %), чтобы не дёргаться на шуме, и есть behavior, политики скорости: вверх обычно разрешают быстро, вниз медленно (stabilizationWindowSeconds: 300 на scale down по умолчанию), потому что за лишнюю реплику платишь деньгами, а за преждевременное снятие отказом.

Источники метрик: metrics-server (CPU/память подов, тип Resource), custom.metrics и external.metrics через адаптер (обычно prometheus-adapter или KEDA) - оттуда берутся RPS, длина очереди Kafka, глубина RabbitMQ, что угодно.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: api }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: api }
  minReplicas: 4
  maxReplicas: 40
  metrics:
    - type: Resource                       # база: не даём упереться в CPU
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 60 }
    - type: Pods                           # основное: запросов в секунду на под
      pods:
        metric: { name: http_requests_per_second }
        target: { type: AverageValue, averageValue: "120" }
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies: [{ type: Percent, value: 100, periodSeconds: 30 }]   # можно удваиваться
    scaleDown:
      stabilizationWindowSeconds: 300
      policies: [{ type: Percent, value: 10, periodSeconds: 60 }]    # снимаем по чуть-чуть

Когда метрик несколько, HPA считает желаемое число по каждой и берёт максимум - то есть метрики работают как «или», а не как «и». Это удобно: CPU остаётся страховкой, когда бизнес-метрика ведёт себя нетипично.

Почему CPU часто врёт

  • I/O-bound сервис не грузит CPU. Go-сервис, который ходит в базу и в три соседних API, при полном насыщении по latency показывает 25 % CPU: горутины стоят в ожидании сети. HPA по CPU не сработает. Сервис уже деградировал, а автоскейлер спокоен.
  • Utilization считается от requests, а не от лимита и не от ядра ноды. Занизили requests, и «утилизация» показывает 300 %, HPA раздувает деплой на пустом месте. Завысили - HPA сработает поздно или не сработает вовсе. Метрика зависит от цифры, которую человек написал в манифесте.
  • Троттлинг маскирует нагрузку. Под упёрся в квоту CFS: latency растёт, а cpu utilization держится ровно на лимите и выше не идёт. Сигнал есть в container_cpu_cfs_throttled_seconds_total, но HPA его не видит.
  • Старт нового пода стоит CPU. Прогрев, JIT-подобные эффекты, установка пула соединений - новый под первые секунды жрёт больше, чем работающий. Пока под не стал Ready, HPA его CPU не учитывает (первые 5 минут после старта), но прогрев после readiness попадает в среднее и подталкивает HPA к следующему шагу. Отсюда «эффект качелей», если behavior не настроен.
  • Средняя по подам скрывает перекос. Один под с «горячим» ключом нагружен на 100 %, три остальных на 10 %, в среднем выходит 32,5 %, HPA доволен, пользователи нет.
Что выбирать вместо/вместе с CPU
  • RPS на под остаётся самой честной метрикой для stateless HTTP/gRPC. Заранее меряется нагрузочным тестом: «один под держит 120 rps при p99 < 200 мс», это и есть target.
  • Количество активных запросов / насыщенность пула сводится к закону Литтла: конкурентность = RPS × latency. Отлично ловит «зависимость затормозила», чего CPU не увидит.
  • Лаг консьюмера для воркеров: kafka_consumergroup_lag, длина очереди в RabbitMQ/Redis. Здесь HPA по CPU почти всегда бессмысленен, а KEDA умеет ещё и scale to zero.
  • Память для HPA почти не годится: она у сервисов с пулами и кэшами не падает от добавления реплики. Масштабируешь по памяти, значит на деле нужен вертикальный скейл или починка утечки.
На чём ловят

Первое: «HPA спасёт от нагрузки» - не спасёт, если узкое место снаружи пода. Десять новых реплик, каждая со своим пулом на 20 соединений, просто добьют базу, у которой max_connections 200. Второе: HPA бесполезен, если в кластере нет свободных нод - нужен ещё и Cluster Autoscaler, а он поднимает ноду не мгновенно, а за минуты. Третье: HPA и replicas в git-манифесте дерутся друг с другом, так что при GitOps поле replicas из Deployment надо убрать (или добавить в игнор), иначе каждый sync будет откатывать масштабирование. И четвёртое: масштабирование не заменяет лимитов на входе - рядом всегда должен стоять rate limit, иначе автоскейлер будет честно масштабировать тебя под DDoS.

Суть: Deployment заменяет поды партиями, ограничивая «сколько можно создать сверх нормы» (maxSurge) и «сколько можно недосчитаться» (maxUnavailable). Запросы теряются не из-за этого, а из-за гонки: kubelet шлёт SIGTERM поду одновременно с тем, как контроллер EndpointSlice помечает его адрес неготовым, а kube-proxy и балансировщики узнают об этом с задержкой. Эти два процесса независимы, и второй почти всегда медленнее. Лечится связкой preStop-задержка + graceful shutdown в коде.

Параметры и что они означают на практике

spec:
  replicas: 10
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 25%          # можно временно иметь до 13 подов
      maxUnavailable: 0      # но ни одного меньше 10 доступных
  minReadySeconds: 10        # под считается доступным, только продержавшись Ready 10 с
  progressDeadlineSeconds: 600
  template:
    spec:
      terminationGracePeriodSeconds: 45
      containers:
        - name: app
          lifecycle:
            preStop:
              sleep: { seconds: 10 }   # дать время эндпоинтам разойтись
  • maxUnavailable: 0 + maxSurge: 25% - безопасный выбор для сервисов, которые нельзя просаживать (по умолчанию оба параметра 25 %, процент maxSurge округляется вверх, maxUnavailable - вниз): сначала поднимаем новые, и только когда они Ready, гасим старые. Взамен нужен запас ресурсов на ноде.
  • maxSurge: 0 + maxUnavailable: 1 берут, когда ресурсов в обрез или под держит эксклюзивный ресурс (лицензия, GPU, единственный PVC ReadWriteOnce). Правда, во время выкатки ёмкость меньше номинала.
  • minReadySeconds недооценивают зря. Без него под, который стал Ready и через секунду упал, считается успешным, и выкатка едет дальше, убивая старые. С ним поломанный релиз останавливается сам.
  • progressDeadlineSeconds задаёт, через сколько выкатка признаётся провалившейся (Progressing=False). Она не откатывается автоматически, просто останавливается; откатывать будешь ты сам или шаг CI.

Гонка, из-за которой теряются запросы

Удаление пода - это всего лишь deletionTimestamp и grace period в etcd. Дальше на него независимо реагируют две цепочки:

  1. Быстрая: kubelet видит удаление → вызывает preStop → шлёт SIGTERM процессу → ждёт terminationGracePeriodSeconds → SIGKILL.
  2. Медленная: EndpointSlice-контроллер помечает адрес в EndpointSlice как не готовый (terminating) → это событие разъезжается по всем нодам → kube-proxy на каждой ноде переписывает правила iptables/nftables → и параллельно ingress-контроллер обновляет свой апстрим, а облачный LB правит свой target group (это может занимать десятки секунд).

Вторая цепочка почти всегда финиширует позже первой. Значит, есть окно, в котором процесс уже начал закрываться, а трафик на него всё ещё направляют: клиент получает connection refused или обрыв соединения. Поэтому preStop: sleep 5..15 стал стандартной практикой: в этот момент процесс ещё полностью работоспособен и обслуживает запросы, а система успевает узнать, что под уходит.

Правильная последовательность выключения

  1. Под помечен на удаление: адрес метят notReady по самому deletionTimestamp, readiness для этого не нужна. Отдавать 503 в коде по SIGTERM всё равно стоит - это видят балансировщики, которые сами проверяют под своим health check или при дренаже смотрят на условие serving завершающегося адреса, - но случится это уже после preStop.
  2. preStop: sleep на время, заведомо большее, чем задержка распространения эндпоинтов в твоей инсталляции (5–15 секунд, для облачных LB бывает больше). Трафик за это время перетекает на другие поды.
  3. SIGTERM → в коде srv.Shutdown(ctx): сервер перестаёт принимать новые соединения, но дожидается текущих запросов.
  4. Закрываются фоновые воркеры и консьюмеры (дообработать сообщение и закоммитить оффсет), затем пул БД, затем flush метрик и трейсов.
  5. Процесс выходит сам, не дожидаясь SIGKILL.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()

go func() { _ = srv.ListenAndServe() }()

<-ctx.Done()                 // пришёл SIGTERM
ready.Store(false)           // 1) /readyz -> 503 для балансировщиков с health check
stop()                       // вернуть дефолтную обработку сигналов: второй Ctrl+C убьёт сразу

sh, cancel := context.WithTimeout(context.Background(), 30*time.Second) // < terminationGracePeriod
defer cancel()
if err := srv.Shutdown(sh); err != nil {   // 2) дожидаемся активных запросов
    _ = srv.Close()                        //    не дождались, рвём принудительно
}
workers.Wait()               // 3) фоновые задачи и консьюмеры
db.Close()                   // 4) пул соединений
tp.Shutdown(sh)              // 5) flush трейсов и метрик

Тайминги надо свести: terminationGracePeriodSeconds должен быть больше, чем preStop + максимальное время обработки запроса. Grace period отсчитывается с момента удаления и включает в себя время preStop - если поставить preStop 30 с при grace 30 с, SIGTERM придёт лишь за 2 секунды до SIGKILL, и дообработать запросы процесс не успеет.

Что ещё нужно, чтобы деплой был по-настоящему бесшовным
  • PodDisruptionBudget. Rolling update контролирует Deployment, но есть ещё добровольные выселения - drain ноды при обновлении кластера, работа Cluster Autoscaler. PDB (minAvailable: 80%) - то, что не даст выселить все твои поды разом.
  • Keep-alive со стороны клиента. Даже идеальный shutdown не поможет, если клиент держит долгоживущее соединение и не переустанавливает его. Go-сервер шлёт Connection: close при Shutdown - это работает для HTTP/1.1; для gRPC нужен GOAWAY, который GracefulStop отправляет сам.
  • Ретраи на клиенте. Часть запросов в окне переключения потерять почти неизбежно; идемпотентный retry с бэкоффом на стороне вызывающего превращает «пятисотку у пользователя» в «лишние 20 мс».
  • Anti-affinity. Если все реплики уехали на одну ноду, «10 реплик» не значит «переживём падение ноды». topologySpreadConstraints размажет их по зонам и нодам.
  • Проверка на практике. Единственный честный способ убедиться, что деплой бесшовный, - гонять vegeta/hey с постоянным rps во время выкатки и смотреть на счётчик не-200. Пока этого не сделали, ты не знаешь, теряешь запросы или нет.
Суть: kubectl apply ничего не запускает - он только записывает объект в etcd. Дальше цепочка независимых контроллеров, каждый из которых делает один маленький шаг: Deployment → ReplicaSet → Pod → scheduler проставляет ноду → kubelet на этой ноде тянет образ и просит runtime запустить контейнер → пробы → endpoints → трафик. Хороший ответ - назвать участников и точки, где всё обычно ломается.

Шаг за шагом

  1. Сборка и публикация образа. CI собирает multi-stage Dockerfile, тегирует образ неизменяемым тегом (git SHA, а не latest) и пушит в registry. Заодно стоит подписать образ и приложить SBOM.
  2. Обновление манифеста. Меняется тег образа в Helm-values / kustomize-overlay. При GitOps это коммит в репозиторий конфигурации, который подхватывает Argo CD или Flux; при push-модели это helm upgrade прямо из CI.
  3. API-сервер. Принимает объект: аутентификация → авторизация (RBAC) → admission (mutating - сюда влезают sidecar-инжекторы service mesh и defaulting) → валидация схемы объекта → admission (validating - сюда OPA/Kyverno, «без лимитов не пущу») → запись в etcd. На этом kubectl apply возвращает управление. Дальше всё асинхронно.
  4. Deployment-контроллер видит новый template, считает его хеш и создаёт новый ReplicaSet, начиная перекачивать реплики из старого по правилам maxSurge/maxUnavailable.
  5. ReplicaSet-контроллер создаёт Pod-объекты, пока без ноды (nodeName пуст, статус Pending).
  6. Scheduler для каждого такого пода прогоняет две фазы: filter (какие ноды вообще подходят: хватает ли allocatable под requests, проходят ли nodeSelector, affinity, taints/tolerations, есть ли нужный том) и score (какая из подходящих лучше: балансировка, spread по зонам, уже скачанный образ). Победителю проставляется nodeName - это тоже просто запись в etcd.
  7. kubelet нужной ноды видит «мой под»: монтирует тома и секреты, через CRI просит runtime создать sandbox (pause-контейнер, который держит network namespace), и runtime зовёт CNI за IP. Дальше для каждого контейнера kubelet тянет образ согласно imagePullPolicy (при необходимости - с imagePullSecrets) и запускает его: сначала init-контейнеры по очереди, затем основные (containerd → runc → namespaces + cgroups + процесс).
  8. Пробы. startup держит остальные выключенными, потом readiness решает, пора ли пускать трафик.
  9. EndpointSlice-контроллер помечает адрес пода в срезе готовым, kube-proxy на каждой ноде правит iptables/nftables, ingress-контроллер обновляет апстримы - и запросы поехали в новый под.
  10. Старые поды гасятся по описанной выше процедуре (preStop → SIGTERM → grace → SIGKILL), Deployment досчитывает реплики и переходит в Progressing: NewReplicaSetAvailable.
# Как это выглядит в командах
docker build -t registry.local/app:$(git rev-parse --short HEAD) .
docker push registry.local/app:9f3c2a1

kubectl set image deploy/app app=registry.local/app:9f3c2a1   # или helm upgrade
kubectl rollout status deploy/app --timeout=5m                # ждём и падаем по таймауту
kubectl rollout undo   deploy/app                             # если что-то не так
Где это обычно ломается - и что смотреть
СтатусПричинаКуда смотреть
Pendingscheduler не нашёл ноду: не хватает ресурсов под requests, taint без toleration, PVC не привязалсяkubectl describe pod - секция Events, сообщение FailedScheduling
ImagePullBackOffнет такого тега, нет доступа к registry, забыт imagePullSecretsEvents; проверить тег руками через crane/docker pull
CrashLoopBackOffпроцесс падает на старте (конфиг, миграции, паника) или liveness убивает раньше времениkubectl logs --previous, затем describe на Last State
Running, но 0/1 Readyreadiness не проходит: не тот порт, не тот путь, зависимость недоступнаEvents пробы, kubectl exec + curl localhost:8081/readyz
Ready, но трафика нетселектор Service не совпал с labels подаkubectl describe svc - в поле Endpoints пусто, значит опечатка в labels
Выкатка «зависла»новые поды не становятся Ready, а maxUnavailable: 0 не даёт гасить старыеkubectl rollout status, kubectl get rs - видно оба поколения
Чем добить

«Хорошая новость в том, что архитектура вся асинхронная и восстанавливаемая: любой контроллер можно перезапустить, он просто заново сравнит spec и status. Плохая в том, что kubectl apply успешен ровно в том смысле, что YAML принят; он ничего не говорит о том, поедет ли под. Поэтому в CI после apply обязателен kubectl rollout status --timeout, а по его ненулевому коду автоматический rollout undo. Без этого “зелёный” пайплайн и лежащий прод отлично сосуществуют.»

Суть: рабочий набор - пять команд: get (что вообще происходит), describe (Events - 80 % ответов), logs (обязательно с --previous для упавшего контейнера), exec (заглянуть изнутри) и port-forward (подключиться к поду напрямую, минуя балансировку). Плюс rollout для выкатки и отката.
# 1. Что происходит
kubectl get pods -l app=billing -o wide          # + нода и IP пода
kubectl get pods --sort-by='.status.containerStatuses[0].restartCount'
kubectl get events --sort-by=.lastTimestamp -A | tail -40   # события всего кластера
kubectl get deploy,rs,endpointslices -l app=billing   # оба поколения RS и адреса за Service

# 2. Почему под в таком состоянии: секция Events внизу
kubectl describe pod billing-6f8c9d7b54-2xk9q

# 3. Логи
kubectl logs billing-6f8c9d7b54-2xk9q -c app --tail=200 -f
kubectl logs billing-6f8c9d7b54-2xk9q --previous   # логи упавшего контейнера
kubectl logs -l app=billing --max-log-requests=20 --since=10m --tail=-1 --prefix   # со всех подов сразу

# 4. Внутрь
kubectl exec -it billing-6f8c9d7b54-2xk9q -- sh
kubectl debug -it billing-6f8c9d7b54-2xk9q --image=nicolaka/netshoot --target=app  # для distroless

# 5. Напрямую к поду, мимо Service и Ingress
kubectl port-forward pod/billing-6f8c9d7b54-2xk9q 8081:8081
curl localhost:8081/readyz
curl localhost:8081/debug/pprof/heap -o heap.out

# 6. Выкатка
kubectl rollout status deploy/billing --timeout=5m
kubectl rollout history deploy/billing
kubectl rollout undo deploy/billing --to-revision=7
kubectl rollout restart deploy/billing            # перечитать смонтированный конфиг

# 7. Ресурсы
kubectl top pod -l app=billing --containers
kubectl describe node ip-10-0-3-14 | sed -n '/Allocated resources/,$p'
Приёмы, которые отличают ежедневного пользователя
  • -o jsonpath и -o custom-columns вместо грепа по -o yaml. Например, «какие образы сейчас в проде»: kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[*].image}{"\n"}{end}'.
  • kubectl debug запускает в поде эфемерный контейнер: сеть у него общая с подом, а с флагом --target видны и процессы основного контейнера. Главный способ отладить distroless/scratch-образ, где нет ни sh, ни curl. Ещё через kubectl debug node/<node> -it --image=ubuntu можно попасть на ноду.
  • --previous доводи до рефлекса. Когда упавший контейнер уже перезапущен и снова работает, обычные logs покажут лог нового экземпляра, а причина смерти лежит в предыдущем.
  • kubectl get --watch вместо цикла с sleep, и kubectl wait --for=condition=Ready pod -l app=billing --timeout=120s в скриптах.
  • Осторожно с kubectl edit и scale на проде. Императивная правка расходится с git; при GitOps с self-heal её откатит ближайший sync, при обычном CI это сделает следующий деплой. Инцидентные правки надо фиксировать в репозитории.
  • Контекст и неймспейс. Половина инцидентов «команда ничего не сделала» сводится к работе не в том кластере. kubectl config current-context, kubens/kubectx и приглашение шелла с именем кластера (kube-ps1) стоят пяти минут настройки.
  • kubectl api-resources и kubectl explain deploy.spec.strategy дают встроенную документацию и помогают не гуглить поля манифеста.

Что стоит запомнить

  • Контроллер сравнивает spec и status и повторяет действия, пока состояние не сойдётся.
  • Deployment создаёт ReplicaSet, а тот поддерживает нужное число Pod.
  • ConfigMap и Secret передают настройки; выбор env или volume влияет на обновление и доступ приложения.

Частые ошибки

  • Успешное принятие объекта API не подтверждает готовность приложения.
  • Secret не становится зашифрованным только из-за типа объекта Secret.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Проследи владельцев объектов

Deployment описывает 3 реплики. Какой контроллер создаёт ReplicaSet и кто создаёт Pod? Если вручную удалить один Pod, кто восстановит количество?

apiVersion: apps/v1
kind: Deployment
spec:
  replicas: 3
  selector:
    matchLabels: {app: api}
  template:
    metadata: {labels: {app: api}}
    spec:
      containers: [{name: api, image: api:v1}]
Ответ и разбор

Deployment-контроллер создаёт ReplicaSet. ReplicaSet-контроллер поддерживает три Pod по selector и template. Если Pod удалить вручную, ReplicaSet заметит разницу и создаст замену. Это не означает, что удаление безопасно для обслуживаемого запроса.

Код-ревью

2. Убери пароль из манифеста

В шаблон Deployment пароль базы передают обычной строкой. Что исправить и какую границу защиты Secret сам по себе не даёт?

env:
  - name: DB_PASSWORD
    value: db-password
Ответ и разбор

Значение не должно лежать в шаблоне и попадать в историю репозитория. Ссылайся на ключ Secret, ограничь RBAC на его чтение и настрой хранение секретов кластера по требованиям безопасности. Secret обычно кодирует значение в base64, но это не шифрование и не защита от субъекта с правом чтения.

Применение

3. Выбери способ обновления настройки

Сервис читает конфигурационный файл только при старте. Настройка в ConfigMap меняется во время работы. Как доставить изменение и что потребуется от приложения?

Ответ и разбор

Смонтируй ConfigMap обычным volume без subPath: kubelet обновит проекцию файла с задержкой. Приложению нужна логика перечитывания файла или управляемый restart; mount через subPath не обновится до перезапуска Pod. Значение в переменной окружения тоже не меняется у работающего процесса, поэтому для него нужен новый Pod.

Применение

4. Разбери Pending после создания

Deployment создан, но Pod часами Pending. Назови по порядку, что проверишь в событиях и spec до изменения числа реплик.

Ответ и разбор

Сначала прочитай Events Pod: они могут указать нехватку CPU/памяти, неподходящий node selector, taint или PVC. Затем сравни requests контейнеров с allocatable подходящих узлов и проверь ограничения scheduling. Увеличение реплик без устранения причины добавит ещё Pending Pod.