← назад к разделу

Снаружи все инциденты в Kubernetes выглядят одинаково — «сервис не работает». Но причины разные: упал сам процесс, кончилась память, не скачался образ, не прошла проверка здоровья, сломалась маршрутизация. Эта статья — набор команд и пошаговый разбор типичных ситуаций: по симптому находим причину и понимаем, что делать.

Проще всего держать в голове карту: какой симптом в какую команду ведёт и какую улику там искать.

снаружи одинаково: «сервис не работает» команда что печатает причина CrashLoopBackOff в logs -p — стектрейс CrashLoopBackOff в logs -p — нет ошибки Running 1/1 а соседи ловят 503 Pending нода не назначена kubectl logs order-service-xxx -pстектрейс или «port already in use»describe: Exit Code: 1процесс упал сам — ответ в логахlogs без -p — только свежий старт, счёт с нуля kubectl describe pod order-service-xxxLast State: Terminated, Reason: OOMKilledExit Code: 137 = 128 + SIGKILLубит снаружи по memory limit, следа в логах нетOutOfMemoryError в логах — это не OOMKilled kubectl get endpoints order-serviceсписок то сокращается, то восстанавливаетсяdescribe: Readiness probe failedreadiness мигает — под выпал из endpointsзависимость в readiness мигает — мигает сервис kubectl get events --sort-by=.lastTimestampInsufficient memory / Insufficient cpuget pods: RESTARTS 0 — не стартовал ни разупланирование идёт по requests, не по фактукластер бывает «полон» при низком потреблении CrashLoopBackOff в двух верхних строках — один STATUS, разные причины

Снаружи все четыре — «сервис не работает». Различает их не STATUS, а место, где улика уже записана: logs -p, Last State, endpoints, events.

Обязательно

Базовый набор команд

Прежде чем разбирать конкретные симптомы — десяток команд, которые нужны постоянно. Все примеры с неймспейсом payments, подставьте свой.

kubectl -n payments get pods                                  # общее состояние: STATUS, RESTARTS, AGE
kubectl -n payments describe pod order-service-xxx           # подробности пода: события, причины
kubectl -n payments logs order-service-xxx                   # логи текущего контейнера
kubectl -n payments logs order-service-xxx -p                # логи ПРЕДЫДУЩЕГО запуска (до рестарта!)
kubectl -n payments logs deploy/order-service --tail=200 -f  # логи ОДНОГО пода деплоймента
kubectl -n payments logs -l app=order-service --prefix --max-log-requests=5   # логи всех реплик сразу
kubectl -n payments get events --sort-by=.lastTimestamp      # лента событий неймспейса
kubectl -n payments exec -it order-service-xxx -- sh         # шелл внутри контейнера
kubectl -n payments port-forward svc/order-service 8080:80   # сервис к себе на localhost
kubectl -n payments top pods                                  # фактический CPU и память
kubectl -n payments get endpoints order-service              # кто реально стоит за Service
kubectl -n payments rollout status deploy/order-service      # как идёт выкатка

Две команды недооценены больше остальных. logs -p — единственный способ увидеть, что происходило до рестарта: текущие логи начинаются с нуля после перезапуска. get events --sort-by=.lastTimestamp — Kubernetes прямым текстом пишет причины: не хватило памяти на ноде, не смог скачать образ, проверка здоровья провалилась.

И четыре оговорки, на которых спотыкаются в первый раз:

  • logs deploy/order-service даёт логи одного пода, а не всех: kubectl сам выбирает реплику и честно пишет об этом строкой вида Found 3 pods, using pod/order-service-xxx. Если ошибка плавающая и ловится не на каждой реплике, берите вариант с -l app=...: он подключается к нескольким подам сразу, а --prefix подписывает, из какого пода какая строка. Число одновременных подключений ограничено (--max-log-requests, по умолчанию 5).
  • Сортировка событий по .lastTimestamp иногда выглядит сломанной: у событий нового API (events.k8s.io) это поле бывает пустым, и такие записи уезжают в начало списка. Запасной вариант — --sort-by=.metadata.creationTimestamp либо отдельная команда kubectl -n payments events --for pod/order-service-xxx, которая сразу показывает события одного объекта по порядку.
  • top pods работает не везде: цифры отдаёт не сам Kubernetes, а компонент metrics-server, и если в кластере его нет, команда ответит error: Metrics API not available — это не поломка пода, а отсутствие компонента.
  • exec -it ... -- sh требует, чтобы в образе был шелл. В slim- и distroless-образах и в scratch, куда обычно кладут бинарник Go, его нет — команда упадёт с executable file not found. Для таких случаев есть kubectl -n payments debug -it order-service-xxx --image=busybox --target=app: он подсаживает в работающий под временный контейнер с нужными утилитами, не трогая основной.

CrashLoopBackOff: под перезапускается снова и снова

Под стартует, падает, Kubernetes ждёт (пауза нарастает с каждым разом) и перезапускает снова. Это не отдельная проблема — это симптом: приложение завершается. Причина внутри.

Про паузу стоит знать конкретные числа, иначе поведение выглядит зависанием. Первая задержка — 10 секунд, дальше каждая удваивается: 20, 40, 80, 160, 320 — и упирается в потолок 5 минут. Счётчик сбрасывается, только если контейнер проработал без падений 10 минут. Отсюда два практических вывода. Под с одиннадцатью перезапусками лежит по пять минут между попытками — это не поломка кластера, а нормальная работа ограничителя; и если вы поправили конфигурацию, ждать следующей попытки не нужно, под проще удалить (kubectl delete pod), новый запустится сразу.

Порядок разбора:

  1. logs -p — что сказало приложение перед тем, как упасть? Стектрейс? Сообщение «port already in use»? Ошибка чтения конфига?
  2. describe pod — смотрим exit code. Код 1 — процесс упал сам, причина в логах. Код 137 — процесс убит снаружи, см. OOMKilled ниже.
  3. Если логов нет совсем — приложение не успело добраться до логгера: проверьте entrypoint образа и настройки логирования.

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

kubectl logs my-pod -c app            # логи своего контейнера
kubectl logs my-pod -c app -p         # логи предыдущего запуска
kubectl logs my-pod --all-containers  # всё вперемешку, полезно на общий взгляд
kubectl exec -it my-pod -c app -- sh  # зайти именно в своё приложение
kubectl logs my-pod -c istio-proxy    # а вот это читают, когда виноват прокси

Имена контейнеров видно в kubectl get pod my-pod -o jsonpath='{.spec.containers[*].name}' или в описании пода.

Отдельный частый случай — убийство liveness probe. Приложение запускается 90 секунд, а liveness probe начинает проверки через 30 — под убивается снова и снова, так и не успевая стартовать. Лечится startup probe с честным failureThreshold. Подробнее про probes — в статье Spring Boot в Kubernetes и в её версиях для Go, Node и Python.

OOMKilled: контейнер убит из-за памяти

Под перезапустился, а в логах приложения пусто: ни стектрейса, ни прощальных сообщений. В выводе describe pod стоит Last State: Terminated, Reason: OOMKilled — контейнер превысил memory limit, и ядро операционной системы убило его мгновенно.

Exit code при этом — 137 (128 + сигнал SIGKILL).

Для приложений на виртуальной машине — Java и Kotlin работают на JVM, рядом стоит .NET — причина почти всегда одна из двух. Первая: рантайм не знает об ограничении контейнера и выделяет heap пропорционально всей памяти ноды, а не лимиту. Вторая: heap как раз укладывается, а лимит съедает всё остальное — metaspace, стеки потоков, кэш кода, direct buffers; про эту часть памяти забывают чаще всего, потому что её не видно в привычных графиках heap. Читать cgroup-лимиты JVM научилась давно: в Java 10 это работает из коробки, а в восьмёрку возможность завезли обратным портом начиная с обновления 8u191 (на более старых сборках восьмёрки её нет вовсе). Убедитесь, что версия подходящая и нужные флаги выставлены. У Go без GOMEMLIMIT сборщик знает только GOGC и даёт куче удвоиться относительно живых данных, у Node старое поколение V8 ограничено --max-old-space-size и по умолчанию не связано с лимитом контейнера. Подробнее — в статьях про рантайм в контейнере: JVM, Go, Node, Python.

У Go своя пара граблей того же рода. GOMAXPROCS до версии 1.25 равнялся числу ядер ноды, а не квоте контейнера: сервис с лимитом в одно ядро на 64-ядерной ноде заводил 64 потока планировщика и ловил троттлинг, лечили go.uber.org/automaxprocs или переменной GOMAXPROCS; с Go 1.25 рантайм читает квоту cgroup сам. Память сборщик мусора ограничивает только через GOMEMLIMIT: без него он растит кучу до удвоения живых данных и упирается в лимит контейнера, поэтому GOMEMLIMIT ставят в 80–90 % лимита пода, а GOGC не трогают. У Node потолок кучи задаёт --max-old-space-size, и он тоже ничего не знает про cgroup.

Важно не путать: OutOfMemoryError в логах — это не OOMKilled. Это heap закончился внутри живого процесса. Лечится по-другому: смотрим, что держит память, и либо увеличиваем heap, либо ищем утечку.

ImagePullBackOff: образ не скачивается

Нода не может загрузить образ из реестра. Три причины закрывают почти всё:

  • Опечатка в теге, или тег не был отправлен — CI собрал образ, но push упал.
  • Нет прав к реестру: протух imagePullSecret или он вовсе не указан.
  • Реестр недоступен с ноды.

describe pod покажет точную ошибку реестра в разделе events. Это единственный инцидент из всех, где код приложения гарантированно ни при чём.

Ещё пять состояний, которые встречаются не реже

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

Init:CrashLoopBackOff — падает не приложение, а init-контейнер: тот, что выполняется до основного (обычно миграция базы или подготовка каталога). Отличие в приставке Init:, и оно сбивает: kubectl logs my-pod покажет пусто или ошибку, потому что основной контейнер ещё не запускался. Смотреть надо логи именно init-контейнера: kubectl logs my-pod -c flyway-migrate. Дальше причина обычная — недоступная база, неверный пароль, миграция с ошибкой.

CreateContainerConfigError — kubelet не смог собрать конфигурацию контейнера, то есть не нашёл то, на что тот ссылается: нет ключа в ConfigMap, нет секрета целиком, опечатка в имени ключа. Признак: под не запускался ни разу, событий про образ нет, а в описании стоит текст вида couldn't find key SPRING_DATASOURCE_PASSWORD in Secret payments/orders-db. Это ошибка манифеста, не приложения.

ErrImagePull против ImagePullBackOff — два шага одной истории. Первое означает «попытка скачать только что провалилась», второе — «провалилась несколько раз, теперь ждём перед следующей попыткой» (с той же нарастающей паузой). Смотреть в обоих случаях надо события с точной ошибкой реестра: not found — опечатка в теге или образ не опубликован, unauthorized/denied — права и секрет реестра, таймаут — доступность реестра с узла.

CreateContainerError — контейнер создать не удалось по причине уровня узла: например, точка монтирования уже занята, том не примонтировался, задана команда, которой нет в образе. Ищут в событиях и в журнале kubelet на узле.

Под застрял в Terminating — удаление запрошено, а под не исчезает. Две обычные причины. Первая: приложение не завершается, и идёт отсчёт отведённого времени (terminationGracePeriodSeconds, по умолчанию 30 секунд) — через него под всё равно убьют. Вторая, если висит дольше: у объекта есть финализаторы — записи в metadata.finalizers, означающие «сначала пусть такой-то контроллер завершит уборку». Если контроллер мёртв или ошибается, под будет висеть вечно.

kubectl get pod my-pod -o jsonpath='{.metadata.deletionTimestamp} {.metadata.finalizers}'
kubectl delete pod my-pod --grace-period=0 --force   # последнее средство, не первое

Про принудительное удаление стоит знать, что оно не решает проблему, а прячет: объект убирается из базы кластера, а контейнер на узле может остаться работать. Для StatefulSet с диском это прямой путь к двум экземплярам, пишущим в одно хранилище. Поэтому сначала выясняют, кто держит финализатор, и только потом, осознанно, форсируют.

Pending: под висит и никуда не идёт

Под создан, но Kubernetes не назначил его ни на одну ноду. describe pod → events → там будет причина словами.

Самые частые:

  • Insufficient memory / Insufficient cpu — ни на одной ноде нет столько свободных requests. Планирование идёт по requests, а не по фактическому потреблению. Кластер может быть «полон» при низком реальном использовании.
  • Несовпадение nodeSelector, affinity или taints — под ищет ноду с определёнными метками, но таких нет.

Что делать разработчику: проверить, не завышены ли requests у сервиса. Если нет — разговор с командой, которая управляет кластером, про ёмкость.

Чтобы этот разговор был предметным, полезно уметь смотреть ёмкость самому. Её показывает описание узла:

kubectl describe node node-3

Два места в выводе. Allocatable — сколько ресурсов узла доступно подам: это не вся память машины, из неё уже вычтено то, что зарезервировано под kubelet, среду выполнения и запас на вытеснение (обычно от гигабайта и выше). Именно с этим числом сравнивает планировщик, и именно поэтому «на узле 16 ГБ, а под на 15 ГБ не встаёт». Allocated resources внизу — таблица с суммой requests и limits всех подов узла в процентах от Allocatable. Сумма запросов около 100 процентов означает, что новый под сюда не встанет, сколько бы свободной памяти ни показывал top на самой машине.

Быстрый взгляд на весь кластер:

kubectl describe nodes | grep -A 5 "Allocated resources"
kubectl top nodes     # фактическое потребление, если установлен metrics-server

Сравнение этих двух картин и объясняет главную особенность: кластер бывает «полон» по запросам при реальной загрузке в тридцать процентов. Лечится это не добавлением узлов, а приведением запросов к правде — по измеренному потреблению.

Мигающая readiness: соседи получают 503

Симптом: сервис «работает», но потребители периодически ловят 503 или таймауты. Под то входит в ротацию, то выпадает.

Причина — readiness probe нестабильна. Когда под не проходит проверку, Kubernetes убирает его из endpoints: трафик на него не идёт. Когда проходит — возвращает обратно.

Типичные сценарии:

  • В readiness включена нестабильная внешняя зависимость. Мигает она — мигает весь сервис.
  • GC-паузы или перегрузка заставляют probe не укладываться в timeout.
  • Слишком агрессивные periodSeconds и failureThreshold.
  • Слишком маленький timeoutSeconds — и это самая частая из всех причин.

Про числа стоит говорить конкретно, потому что значения по умолчанию для сервиса под нагрузкой не годятся, а для JVM особенно. timeoutSeconds по умолчанию равен 1 секунде: если проверка готовности отвечает дольше секунды (а под нагрузкой, во время паузы сборщика мусора или при обращении к базе она легко отвечает 1,2 секунды), проба считается провалившейся. Три таких промаха при failureThreshold: 3 — и под вылетел из ротации, хотя он полностью работоспособен. Через десять секунд он вернётся, потом вылетит снова: это и есть мигание.

Рабочие значения для сервиса, который стартует полминуты и держит соединения с базой, выглядят так (для Spring Boot ручки Actuator, для Go, Node и Python свои /healthz и /readyz):

        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080
          periodSeconds: 10        # проверять раз в 10 секунд, а не каждую секунду
          timeoutSeconds: 3        # дать пробе три секунды, а не одну
          failureThreshold: 3      # вылетаем после трёх промахов подряд
          successThreshold: 1
        livenessProbe:
          httpGet:
            path: /actuator/health/liveness
            port: 8080
          periodSeconds: 20
          timeoutSeconds: 5        # у живости запас ещё больше: её промах = перезапуск
          failureThreshold: 6

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

Проверка: kubectl get endpoints несколько раз подряд — список то сокращается, то восстанавливается. В describe pod — Readiness probe failed в событиях.

Решение: вынести из readiness зависимости, без которых сервис может отвечать в деградированном режиме, и скорректировать пороги.

«Запрос не доходит»: проверяем цепочку

diagram

Снаружи 502 или 504, а поды зелёные. Проверяем цепочку по пути запроса сверху вниз, каждое звено отдельно:

kubectl -n payments get ingress api                               # 1. Ingress есть, host/path верные?
kubectl -n payments get endpoints order-service                  # 2. За Service есть поды?
kubectl -n payments port-forward svc/order-service 8080:80       # 3. Service отвечает?
kubectl -n payments port-forward pod/order-service-xxx 8080:8080 # 4. Сам под отвечает?

Где цепочка рвётся — там и уровень проблемы:

  • Шаг 4 не работает → проблема в приложении.
  • Шаги 2–3 → selector или readiness.
  • Шаг 1 → Ingress, DNS или TLS.

Пустые endpoints при живых подах — почти всегда readiness или опечатка в selector (метки пода не совпадают с selector сервиса — после переименования манифестов такое бывает). Также стоит проверить NetworkPolicy: «запрос не доходит» между неймспейсами может быть политикой, а не поломкой.

Под перезапустился «сам»

RESTARTS > 0 без деплоя — у рестарта всегда есть причина, и она записана. describe pod → Last State. Варианты:

  • OOMKilled — см. выше.
  • Liveness probe failed — проверьте, не проверяет ли она внешнюю базу данных или другой сервис.
  • Нода ушла в drain или на обновление — это штатная ситуация. Поды обязаны переживать переезд: для этого нужны PodDisruptionBudget и минимум две реплики.
  • Evicted — нода исчерпала дисковое пространство или другой ресурс и вытеснила под.

Сколько живут события и где логи умершего пода

Два ограничения, о которые упирается разбор вчерашнего инцидента.

События живут около часа. По умолчанию кластер хранит события примерно 60 минут (--event-ttl у api-server), после чего они исчезают из kubectl get events навсегда. Утренний разбор ночного сбоя поэтому начинается не с событий: их уже нет. Что с этим делают — собирают события так же, как логи: экспортёр событий в систему сбора логов или отдельный компонент, который пишет их в хранилище. Если этого нет, единственный способ сохранить события во время инцидента — сделать это сразу:

kubectl get events -A --sort-by=.lastTimestamp > events-$(date +%F-%H%M).txt
kubectl describe pod my-pod > pod-describe.txt

Логи удалённого пода недоступны. kubectl logs читает файл на узле, который принадлежит контейнеру; -p показывает предыдущий запуск того же пода, и только пока сам под существует. Как только под пересоздан (а при выкате или падении узла это происходит сразу), логов нет ни с флагом, ни без.

Отсюда следует то, что должно быть настроено до инцидента, а не после: логи уходят в систему сбора (сборщик на каждом узле забирает поток вывода контейнеров и отправляет в хранилище), и искать вы будете там, по имени пода и времени. Это единственный надёжный способ; всё остальное — попытки успеть.

Пока сборщика нет, есть два приёма на крайний случай. Читать поток в реальном времени с отслеживанием пересозданий — kubectl logs -f deployment/orders --all-containers --max-log-requests 10 (он переподключается к новым подам). И смотреть файлы логов контейнеров прямо на узле (/var/log/pods/<namespace>_<pod>_<uid>/), если есть доступ к машине: они живут до уборки, которую делает kubelet по своим правилам ротации.

И ещё одно место, куда стоит смотреть при разборе: kubectl get pod my-pod -o yaml показывает status целиком, включая lastState.terminated с кодом выхода, причиной и временем — эти данные остаются в объекте, даже когда события уже исчезли.

Что должно быть до инцидента

Всё описанное разбирается за минуты, когда у сервиса есть базовая гигиена:

  • Структурированные логи в stdout — платформа их собирает. Файлы внутри контейнера умирают вместе с ним.
  • Метрики в формате Prometheus — де-факто стандарт в Kubernetes.
  • Алерты на рестарты и недоступность endpoints.
  • Дашборд с памятью и CPU по подам в сравнении с limits.

Без этого отладка превращается в угадывание. Подробнее — в Observability Style Guide.

Дополнительно: при первом чтении можно пропустить

Глубже: права пода: ServiceAccount, RBAC и почему default плохая идеярасширенное

Под работает от имени учётной записи в кластере, ServiceAccount, и если её не назначить, получает default своего namespace. У default прав почти нет, но токен от неё Kubernetes всё равно монтирует в каждый под, в /var/run/secrets/kubernetes.io/serviceaccount/token, и любой, кто выполнил код в контейнере, получает этот токен и может пробовать API кластера. Первое правило: automountServiceAccountToken: false у пода, которому API кластера не нужен, а это почти все обычные сервисы.

Когда доступ нужен (оператор, задача, которая читает ConfigMap соседей, контроллер), заводят отдельный ServiceAccount на Deployment и выдают ему ровно нужное через RBAC: Role перечисляет глаголы и ресурсы в namespace (get, list на configmaps), RoleBinding привязывает роль к учётной записи; ClusterRole и ClusterRoleBinding то же на весь кластер, и их выдают редко. Проверяют без пода: kubectl auth can-i list secrets --as=system:serviceaccount:shop/order-service. Право get secrets в namespace означает чтение всех секретов namespace, и это обычная дыра: сервис, которому нужен один секрет, получает все.

Доступ во внешние системы через ту же учётную запись устроен иначе: токен ServiceAccount можно выпустить для конкретной аудитории (проецируемый том с audience), и его принимают Vault для выдачи секретов и облака для выдачи ролей (у AWS это IRSA, у Google Workload Identity, у российских облаков свои варианты). Так под получает доступ к бакету или базе без пароля в Secret, и это правильный путь для секретов в проде; Secret в манифесте остаётся для стендов.

Права людей это те же Role и RoleBinding, привязанные к группам из провайдера входа; разработчикам дают view и logs на своих namespace, edit на стендах и ничего в проде, где изменения идут через GitOps. cluster-admin у конвейера это тот же docker.sock из раздела Docker: любой, кто попал в конвейер, попал в кластер.

Глубже: квота namespace: ResourceQuota, LimitRange и Pending из-за квотырасширенное

Выкат не идёт, а в Pending ничего нет: подов новой версии просто не появляется. В событиях ReplicaSet (kubectl describe rs) строка exceeded quota. Это не узел без места, это квота namespace, и в разборе Pending выше её нет, потому что до Pending под не доходит.

ResourceQuota ограничивает сумму по namespace: сколько всего можно запросить процессора и памяти (requests.cpu, limits.memory), сколько подов, сервисов, заявок на диск. Платформенная команда выдаёт квоту на проект, и с ростом числа сервисов она заканчивается тихо. kubectl describe quota -n shop показывает использованное и предел. Rolling update с maxSurge требует запаса на лишние поды на время выката: при квоте впритык выкат не пройдёт, хотя стабильное состояние в квоту укладывается.

LimitRange задаёт умолчания и границы на контейнер: если в манифесте нет requests, их подставит LimitRange, и «мы не ставили лимитов» в таком namespace неверно, лимит есть, и он общий для всех, кто его не задал; максимум на контейнер отвергнет под с limits.memory: 8Gi. kubectl describe limitrange показывает, что именно подставляется. Из этого растёт классический сюрприз: сервис, который на стенде без LimitRange работал без лимита, в проде получает подставленные 512 МБ и уходит в OOMKilled.

Что делать, когда упёрлись. Сначала посмотреть, кто ест квоту: kubectl top pods против requests покажет сервисы, которые просят вдвое больше, чем используют, и их requests уменьшают. Потом убрать забытое: старые Job без ttlSecondsAfterFinished, стенды, которые никто не выключил. И только потом просить расширения, с цифрами. Квота это тот же бюджет, что и в разделе про ресурсы рантайма, только на весь проект.

Глубже: увидеть до звонка: метрики подов, kube-state-metrics и два сигнала тревогирасширенное

«Что должно быть до инцидента» выше называет наблюдаемость, а вот из чего она складывается для подов, чтобы перезапуск был виден на панели раньше, чем в чате.

Три источника метрик. metrics-server отдаёт текущее потребление и стоит за kubectl top; для панелей и тревог он не хранит историю. kubelet и cAdvisor отдают в Prometheus потребление контейнеров: память рабочего набора (container_memory_working_set_bytes, именно её сравнивают с лимитом), процессор и его троттлинг (container_cpu_cfs_throttled_seconds_total, растёт при limits.cpu впритык). kube-state-metrics отдаёт состояние объектов, а не потребление: число перезапусков контейнера (kube_pod_container_status_restarts_total), причину последнего завершения (kube_pod_container_status_last_terminated_reason с OOMKilled), сколько реплик Deployment доступно против желаемого, сколько адресов в Endpoints, сколько подов в Pending и сколько времени. Метрики самого приложения (Actuator через Micrometer) собирают отдельно, по аннотациям или ServiceMonitor.

Два сигнала тревоги, которые закрывают большинство инцидентов этой статьи. Перезапуски: рост restarts_total за пятнадцать минут больше нуля у сервиса в проде, с причиной из last_terminated_reason прямо в тексте тревоги; так OOMKilled и CrashLoopBackOff приходят до звонка. Пустые endpoints: число доступных адресов у Service равно нулю или число доступных реплик Deployment меньше желаемого дольше пяти минут; так ловят мигающую readiness и выкат, который не поднялся. К ним добавляют троттлинг процессора выше десятой доли и поды в Pending дольше пяти минут.

События кластера (kubectl get events --sort-by=.lastTimestamp) хранятся час и в Prometheus не попадают, поэтому их либо собирают отдельным экспортёром, либо смотрят сразу, пока не протухли. И панель: на одном экране реплики желаемые и доступные, перезапуски, память против лимита, троттлинг, задержка и ошибки из метрик приложения, и по ней за минуту видно, что произошло: под перезапустился по памяти, реплики просели, ошибки выросли, и в каком порядке.

Коротко

  • logs -p показывает логи до рестарта — это первое, что смотрят при CrashLoopBackOff. В поде с прокси или сборщиком логов команды требуют -c <контейнер>, а logs --all-containers даёт общий взгляд.
  • get events --sort-by=.lastTimestamp — Kubernetes прямым текстом пишет, что пошло не так. События живут около часа, а логи пересозданного пода недоступны даже с -p: обе вещи должны уходить в систему сбора до инцидента, иначе разбирать вчерашний сбой нечем.
  • CrashLoopBackOff — приложение падает; exit code 1 → смотрим логи, exit code 137 → OOMKilled. Пауза перед перезапуском: 10 секунд с удвоением до 5 минут, счётчик сбрасывается после 10 минут без падений; поправили конфигурацию — проще удалить под, чем ждать.
  • OOMKilled: рантайм не видит cgroup-лимит или лимит мал; в логах следа нет. ImagePullBackOff: тег не существует, нет прав к реестру или реестр недоступен.
  • Pending: requests не помещаются на ноде; планирование идёт по requests, не по факту. Ёмкость смотрят в describe node: Allocatable меньше памяти машины, а сумма requests около 100 процентов означает Pending при низкой реальной загрузке.
  • Мигающая readiness → под выпадает из endpoints → 503 у потребителей. timeoutSeconds по умолчанию равен секунде — под нагрузкой и на паузе сборщика мусора это и есть причина мигающей готовности; берут не меньше p99 самой пробы, а у живости пороги свободнее, чем у готовности.
  • 502/504 при живых подах: проверяем цепочку Ingress → Service → endpoints → под.
  • Под работает от ServiceAccount; default с примонтированным токеном отключают через automountServiceAccountToken: false, доступ к API дают отдельной учётной записи через Role и RoleBinding, во внешние системы по проецируемому токену с аудиторией. Квота namespace останавливает выкат до Pending: exceeded quota в событиях ReplicaSet, describe quota, LimitRange подставляет лимиты тем, кто их не задал; сначала уменьшают завышенные requests и чистят забытое.
  • Наблюдаемость подов: kubelet даёт потребление и троттлинг, kube-state-metrics состояние объектов; две тревоги закрывают большинство инцидентов: рост перезапусков с причиной и пустые endpoints или реплики меньше желаемых.
  • Кроме трёх известных состояний есть Init:CrashLoopBackOff (логи init-контейнера), CreateContainerConfigError (нет ключа в ConfigMap или секрета), ErrImagePull против ImagePullBackOff и Terminating из-за финализаторов — принудительное удаление прячет проблему, а не решает.

Что почитать дальше