Kubernetes для самых маленьких. Часть 31: Как искать неисправности в кластере?

Все части серии «Kubernetes для самых маленьких»

За тридцать частей кластер оброс компонентами: поды и контроллеры из части 6 и части 7, планировщик из части 13, сеть из Calico, kube-proxy и CoreDNS (части 14, 15, 16), тома на NFS и SMB, политики безопасности из прошлой части. У каждого из них есть свой способ сломаться, и каждый сообщает об этом в своём месте. Эта часть открывает блок о наблюдаемости и посвящена ручной диагностике: в каком порядке смотреть, какими командами, где лежат события, логи и состояние контейнера, как попасть в под без shell и на узел без SSH, и что означают статусы, которые чаще всего видны в kubectl get pods. Метрики, сбор логов и трассы — в следующих двух частях; здесь всё делается одним kubectl и, в крайнем случае, crictl на узле.

Неймспейс demo остаётся таким, каким его оставила прошлая часть: под уровнем restricted Pod Security Admission, hello работает от UID 10001 без capabilities. Это повлияет на отладку, и мы увидим как.


Сверху вниз

Kubernetes — это цепочка контроллеров, где каждый следующий реагирует на объект, созданный предыдущим (часть 3). Когда что-то не работает, сломалось одно звено, а симптом виден в конце цепочки: «сайт не открывается». Поэтому искать причину надо в обратном порядке — от объекта, с которым работает человек, к узлу, где живёт процесс:

 уровень        что смотреть                                 чем
 ───────────    ─────────────────────────────────────────    ─────────────────────────────────────
 объект         Deployment, Service, Ingress, PVC:           kubectl get / describe
                status, conditions, сколько готово
    │
    ▼
 события        что контроллеры, планировщик и kubelet       kubectl events --for, describe
                сказали об объекте за последний час
    │
    ▼
 под            фаза, conditions, узел, IP,                  kubectl get pod -o wide / -o yaml
                состояние каждого контейнера
    │
    ▼
 контейнер      логи текущего и прошлого запуска,            kubectl logs, exec, debug
                код выхода, что внутри
    │
    ▼
 узел           kubelet, containerd, CNI, диск, память,      kubectl describe node, debug node,
                сертификаты                                  crictl, journalctl

На каждом уровне вопрос один: дошло ли дело до следующего звена. Deployment создал ReplicaSet? ReplicaSet создал поды? Поды назначены на узел? kubelet запустил контейнер? Контейнер прошёл readiness? Первое звено, ответившее «нет», и есть место поломки, а его события обычно называют причину прямым текстом. Перескакивать к логам приложения, пока не ясно, запущено ли оно вообще, — частая потеря времени.


События

Event — обычный объект API в группе events.k8s.io/v1 (и в старом core v1, который kubectl describe до сих пор читает): короткое сообщение, привязанное к другому объекту. Пишут их компоненты, которые что-то делают с объектами: планировщик (Scheduled, FailedScheduling), kubelet (Pulling, Pulled, Created, Started, Killing, BackOff, Unhealthy, FailedMount), контроллеры (ScalingReplicaSet, SuccessfulCreate), драйверы CSI через свои sidecar-контейнеры (ProvisioningSucceeded), cert-manager и любые операторы.

$ kubectl get events -n demo --field-selector reason=BackOff -o yaml
apiVersion: v1
items:
- apiVersion: v1
  count: 8
  eventTime: null
  firstTimestamp: "2026-11-02T13:57:25Z"
  involvedObject:
    apiVersion: v1
    fieldPath: spec.containers{hello}
    kind: Pod
    name: hello-798bd968d9-vkz9z
    namespace: demo
    resourceVersion: "1699629"
    uid: cab28704-1a6a-40d9-bd16-172760839f9d
  kind: Event
  lastTimestamp: "2026-11-02T13:58:06Z"
  message: Back-off restarting failed container hello in pod hello-798bd968d9-vkz9z_demo(cab28704-1a6a-40d9-bd16-172760839f9d)
  metadata:
    creationTimestamp: "2026-11-02T13:57:25Z"
    name: hello-798bd968d9-vkz9z.18e43f05b2daee83
    namespace: demo
    resourceVersion: "1699788"
    uid: e12dcf66-8527-4226-9af6-5775de85c8f8
  reason: BackOff
  reportingComponent: kubelet
  reportingInstance: k8s-node-2
  source:
    component: kubelet
    host: k8s-node-2
  type: Warning
kind: List
metadata:
  resourceVersion: ""

Что важно в устройстве событий:

  • Тип только два: Normal и Warning. Фильтр --types=Warning отсекает шум из «образ скачан, контейнер запущен».
  • Агрегация. Повторяющееся событие не создаётся заново, а увеличивает count (в новом API — series.count) и сдвигает lastTimestamp. «8 раз за 41 секунду» — это одно событие.
  • Срок жизни. События хранятся в etcd как отдельные объекты и удаляются через --event-ttl kube-apiserver, по умолчанию 1 час. Через час после аварии в кластере не останется ни одного события о ней. Поэтому их собирают в систему логов — как, разберём в части 33 — а при ручной диагностике смотрят сразу.
  • События не гарантированы. Компоненты пишут их «по возможности» и с ограничением частоты; отсутствие события не доказывает, что ничего не происходило.

Смотреть удобнее всего командой kubectl events: в отличие от kubectl get events, она по умолчанию сортирует по времени и умеет фильтровать по объекту:

$ kubectl events -n demo --for pod/hello-798bd968d9-vkz9z
LAST SEEN          TYPE      REASON      OBJECT                       MESSAGE
55s                Normal    Scheduled   Pod/hello-798bd968d9-vkz9z   Successfully assigned demo/hello-798bd968d9-vkz9z to k8s-node-2
9s (x4 over 51s)   Normal    Pulled      Pod/hello-798bd968d9-vkz9z   Container image "ghcr.io/example/hello:1.1" already present on machine and can be accessed by the pod
8s (x4 over 49s)   Normal    Created     Pod/hello-798bd968d9-vkz9z   Container created
8s (x4 over 49s)   Normal    Started     Pod/hello-798bd968d9-vkz9z   Container started
8s (x6 over 49s)   Warning   Unhealthy   Pod/hello-798bd968d9-vkz9z   Readiness probe failed: Get "http://172.20.172.6:8080/readyz": dial tcp 172.20.172.6:8080: connect: connection refused
4s (x8 over 45s)   Warning   BackOff     Pod/hello-798bd968d9-vkz9z   Back-off restarting failed container hello in pod hello-798bd968d9-vkz9z_demo(cab28704-1a6a-40d9-bd16-172760839f9d)

$ kubectl events -A --types=Warning --watch

--for принимает kind/name, -A смотрит по всем неймспейсам, --watch держит поток. События узла лежат в неймспейсе default и привязаны к объекту Node: kubectl events --for node/k8s-node-2.


Объект и под: describe, get, conditions

kubectl describe — сводка, собранная клиентом из нескольких запросов: сам объект в человекочитаемом виде плюс связанные события. Для пода это узел, IP, контейнеры с их состоянием, тома, tolerations и события в конце. Для Deployment — стратегия, реплики и ReplicaSet'ы; для узла — conditions, ресурсы, выделенные подам, и taints.

kubectl get с форматами отвечает на вопросы точнее:

$ kubectl get pods -n demo -o wide
NAME                     READY   STATUS             RESTARTS     AGE   IP              NODE         NOMINATED NODE   READINESS GATES
hello-798bd968d9-vkz9z   0/1     CrashLoopBackOff   3 (8s ago)   55s   172.20.172.6    k8s-node-2   <none>           <none>
hello-865b67cc76-682fc   1/1     Running            0            67s   172.20.211.15   k8s-node-1   <none>           <none>
hello-865b67cc76-6vcck   1/1     Running            0            67s   172.20.172.8    k8s-node-2   <none>           <none>
hello-865b67cc76-ntcj4   1/1     Running            0            67s   172.20.211.16   k8s-node-1   <none>           <none>

-o wide добавляет узел и IP: если все проблемные поды на одном узле, дело в узле, а не в приложении. Колонка STATUS — не поле API, а строка, которую kubectl собирает из фазы пода и причины ожидания или завершения контейнеров. Настоящее состояние — в -o yaml; ветку status вырезает sed (строки lastProbeTime и observedGeneration ниже убраны):

$ kubectl get pod hello-798bd968d9-vkz9z -n demo -o yaml | sed -n '/^status:/,$p'
status:
  conditions:
  - lastTransitionTime: "2026-11-02T13:57:19Z"
    status: "True"
    type: PodReadyToStartContainers
  - lastTransitionTime: "2026-11-02T13:57:15Z"
    status: "True"
    type: Initialized
  - lastTransitionTime: "2026-11-02T13:57:15Z"
    message: 'containers with unready status: [hello]'
    reason: ContainersNotReady
    status: "False"
    type: Ready
  - lastTransitionTime: "2026-11-02T13:57:15Z"
    message: 'containers with unready status: [hello]'
    reason: ContainersNotReady
    status: "False"
    type: ContainersReady
  - lastTransitionTime: "2026-11-02T13:57:15Z"
    status: "True"
    type: PodScheduled
  containerStatuses:
  - containerID: containerd://13884b1b4b6703e8cf8001a66cf0831dda91aafeb4fad1c0d5dc1133271b1ae4
    image: ghcr.io/example/hello:1.1
    imageID: ghcr.io/example/hello@sha256:2d8774f801594f2b1b115963e54ec54b1b49f95c2dece1d6b0fd1e59ab447c7b
    lastState:
      terminated:
        containerID: containerd://13884b1b4b6703e8cf8001a66cf0831dda91aafeb4fad1c0d5dc1133271b1ae4
        exitCode: 1
        finishedAt: "2026-11-02T13:58:02Z"
        reason: Error
        startedAt: "2026-11-02T13:58:02Z"
    name: hello
    ready: false
    resources: {}
    restartCount: 3
    started: false
    state:
      waiting:
        message: back-off 40s restarting failed container=hello pod=hello-798bd968d9-vkz9z_demo(cab28704-1a6a-40d9-bd16-172760839f9d)
        reason: CrashLoopBackOff
    user:
      linux:
        gid: 10001
        supplementalGroups:
        - 10001
        uid: 10001
    volumeMounts:
    - mountPath: /tmp
      name: tmp
  hostIP: 192.168.10.21
  hostIPs:
  - ip: 192.168.10.21
  phase: Running
  podIP: 172.20.172.6
  podIPs:
  - ip: 172.20.172.6
  qosClass: BestEffort
  resources: {}
  startTime: "2026-11-02T13:57:15Z"

Conditions пода — пять флагов в порядке, в котором они становятся True:

Condition Что значит Если False — смотреть
PodScheduled планировщик выбрал узел событие FailedScheduling
PodReadyToStartContainers kubelet создал песочницу пода: сетевой namespace, адрес от CNI, pause-контейнер события FailedCreatePodSandBox, CNI на узле
Initialized все init-контейнеры завершились успешно логи init-контейнера, -c <имя>
ContainersReady все контейнеры прошли readiness события Unhealthy, логи
Ready под готов принимать трафик и попадает в эндпоинты сервиса то же плюс readiness gates

У каждого контейнера три возможных state: waiting с reason (ContainerCreating, ErrImagePull, CrashLoopBackOff, CreateContainerConfigError), running со временем старта и terminated с exitCode, reason и signal. lastState хранит предыдущий запуск — именно там видно, почему контейнер упал в прошлый раз, пока текущий ждёт перезапуска. Поле user.linux показывает, с какими UID и группами процесс запущен на самом деле, после всех securityContext и настроек образа.

Для скриптов и быстрых вопросов — jsonpath и custom-columns:

$ kubectl get pods -n demo -o custom-columns='POD:.metadata.name,NODE:.spec.nodeName,REASON:.status.containerStatuses[0].lastState.terminated.reason,EXIT:.status.containerStatuses[0].lastState.terminated.exitCode'
POD                      NODE         REASON   EXIT
hello-798bd968d9-vkz9z   k8s-node-2   Error    1
hello-865b67cc76-682fc   k8s-node-1   <none>   <none>
hello-865b67cc76-6vcck   k8s-node-2   <none>   <none>
hello-865b67cc76-ntcj4   k8s-node-1   <none>   <none>

Код выхода расшифровывается по правилу shell: до 128 — код, который вернуло приложение, больше 128 — процесс убит сигналом с номером код − 128:

Код Что случилось
0 процесс завершился сам и успешно; для Deployment это всё равно перезапуск: restartPolicy: Always
1, 2 ошибка приложения: не прочитался конфиг, не открылся порт, паника Go даёт 2
126, 127 команда не исполняемая или не найдена: опечатка в command, нет shell в distroless-образе
137 128 + 9, SIGKILL: OOM killer или kubelet, не дождавшийся завершения после SIGTERM
139 128 + 11, SIGSEGV: ошибка памяти в самом процессе
143 128 + 15, SIGTERM: процесс корректно остановлен по сигналу

Логи: kubectl logs

kubectl logs читает не из приложения и не из containerd напрямую: запрос идёт в kube-apiserver, тот проксирует его в kubelet на нужном узле (порт 10250, подресурс pods/log), а kubelet читает файл, в который containerd пишет stdout и stderr контейнера. Как эти файлы устроены и ротируются, разберём в части 33; сейчас важно, что kubectl logs видит только то, что процесс написал в stdout и stderr, и только пока файлы лежат на узле.

$ kubectl logs -n demo hello-798bd968d9-vkz9z --previous
2026/11/02 13:58:02 listen: listen tcp: lookup tcp/8080	: unknown port

Основные флаги:

Флаг Что делает
--previous, -p логи прошлого запуска контейнера. Главный флаг при CrashLoopBackOff: текущий контейнер только что стартовал или ещё не запущен, причина падения — в предыдущем
-c <контейнер> контейнер в многоконтейнерном поде; без флага берётся аннотация kubectl.kubernetes.io/default-container или первый контейнер
--all-containers все контейнеры пода, включая init
--since=10m, --since-time=... только свежие строки
--tail=100 последние N строк; при -l по умолчанию 10 на под
-f следить за потоком
-l app.kubernetes.io/name=hello по селектору: логи всех подов Deployment сразу; с --prefix — с именем пода в начале строки
--timestamps время, которое записал containerd, а не приложение
deploy/hello вместо имени пода: kubectl сам выберет один под Deployment

--previous показывает ровно один предыдущий запуск: kubelet хранит на узле только последний завершившийся контейнер пода и его файл лога, более старые удаляет. Пока контейнер ждёт перезапуска, нового запуска нет, и kubectl logs без флага показывает тот же упавший запуск, что и --previous. Если под удалён или пересоздан контроллером на другом узле, его логи исчезли вместе с ним — ещё одна причина собирать их централизованно.


Внутрь контейнера: exec и эфемерные контейнеры

kubectl exec запускает новый процесс в namespaces работающего контейнера — через тот же kubelet и CRI-вызов Exec. В образе с shell это обычный способ посмотреть изнутри: kubectl exec -it <под> -- sh. В hello не выйдет даже это:

$ kubectl exec -n demo deploy/hello -- cat /etc/resolv.conf
error: Internal error occurred: Internal error occurred: error executing command in container: failed to exec in container: failed to start exec "d817a624cf51c53c50ac96b2a8d0818a31674ae132f8a9cc324a6a31d1900f23": OCI runtime exec failed: exec failed: unable to start container process: exec: "cat": executable file not found in $PATH

Это обычная ситуация с правильно собранным образом из части 5: в distroless- или scratch-образе нет shell, cat, curl и ps, и это хорошо для безопасности. Не нужно возвращать в образ отладочные утилиты — для этого есть эфемерные контейнеры.

Эфемерный контейнер — контейнер, который добавляют в уже работающий под через подресурс pods/ephemeralcontainers. Обычный список containers у пода неизменяем, а ephemeralContainers можно дополнять. У эфемерного контейнера нет портов, проб и ресурсов, он не перезапускается и не удаляется, пока жив под. Запускается он рядом с остальными контейнерами пода — в том же сетевом namespace, с тем же IP и localhost. Добавляет его kubectl debug:

$ kubectl debug -it -n demo hello-865b67cc76-682fc --image=nicolaka/netshoot:v0.15 --target=hello
Warning: Non-root user is configured for the entire target Pod, and some capabilities granted by debug profile may not work. Please consider using "--custom" with a custom profile that specifies "securityContext.runAsUser: 0".
Error from server (Forbidden): pods "hello-865b67cc76-682fc" is forbidden: violates PodSecurity "restricted:v1.36": allowPrivilegeEscalation != false (container "debugger-x2gvb" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "debugger-x2gvb" must set securityContext.capabilities.drop=["ALL"]; container "debugger-x2gvb" must not include "SYS_PTRACE" in securityContext.capabilities.add)

Отказ — от Pod Security Admission из части 30: эфемерный контейнер — часть спецификации пода, и demo под уровнем restricted проверяет его так же, как обычный. Настройки безопасности отладочного контейнера задаёт профиль kubectl debug. В kubectl 1.36 профиль по умолчанию — general: он добавляет SYS_PTRACE, чтобы strace и /proc/<pid>/root чужого процесса работали, и ничего не запрещает. Остальные варианты — baseline и restricted (совместимы с одноимёнными уровнями Pod Security Standards), netadmin (capabilities NET_ADMIN и NET_RAW для tcpdump и ip) и sysadmin (привилегированный контейнер). В неймспейсе restricted проходит только --profile=restricted:

$ kubectl debug -it -n demo hello-865b67cc76-682fc --image=nicolaka/netshoot:v0.15 --target=hello --profile=restricted
Targeting container "hello". If you don't see processes from this container it may be because the container runtime doesn't support this feature.
Defaulting debug container name to debugger-lxwkm.
All commands and output from this session will be recorded in container logs, including credentials and sensitive information passed through the command prompt.
If you don't see a command prompt, try pressing enter.
 hello-865b67cc76-682fc  ~  id
uid=10001 gid=10001 groups=10001
 hello-865b67cc76-682fc  ~  ps
PID   USER     TIME  COMMAND
    1 10001     0:00 /hello
   14 10001     0:00 zsh
   22 10001     0:00 ps
 hello-865b67cc76-682fc  ~  ss -ltnp
State  Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0      4096               *:8080            *:*    users:(("hello",pid=1,fd=4))
 hello-865b67cc76-682fc  ~  curl -s localhost:8080/healthz
ok
 hello-865b67cc76-682fc  ~  ls /proc/1/root/
bin    dev    hello  lib    proc   run    sys    usr
boot   etc    home   lib64  root   sbin   tmp    var
 hello-865b67cc76-682fc  ~  exit
Session ended, the ephemeral container will not be restarted but may be reattached using 'kubectl attach hello-865b67cc76-682fc -c debugger-lxwkm -n demo -i -t' if it is still running

netshoot — образ с набором сетевых утилит для отладки: curl, dig, nslookup, tcpdump, ss, ip, iperf3, nmap, mtr и другие. Тег указан явно: правило из части 5 «никаких latest» действует и для отладочных образов. --target=hello помещает эфемерный контейнер в PID namespace контейнера hello: виден его процесс под номером 1, а через /proc/1/root/ — его файловая система. Без --target эфемерный контейнер видит только собственные процессы, если в поде не включён shareProcessNamespace.

Почему всё это сработало без SYS_PTRACE: runAsUser: 10001 задан на уровне пода и действует на все его контейнеры, включая эфемерные, — о том же предупреждал kubectl debug в первой попытке. Отладчик работает с тем же UID, что и приложение, а заглядывать в /proc процессов своего пользователя ядро разрешает без capabilities. Если бы приложение работало от root, понадобился бы SYS_PTRACE, а с ним — неймспейс без restricted и baseline или отладка с узла. Строка All commands and output ... will be recorded in container logs — напоминание kubectl 1.36: ввод и вывод интерактивной сессии попадают в лог эфемерного контейнера, и пароль, набранный в ней, увидит каждый, у кого есть право на pods/log.

Что kubectl debug сделал в API — дописал элемент в спецификацию пода:

$ kubectl get pod hello-865b67cc76-682fc -n demo -o jsonpath='{.spec.ephemeralContainers}' | jq
[
  {
    "image": "nicolaka/netshoot:v0.15",
    "imagePullPolicy": "IfNotPresent",
    "name": "debugger-lxwkm",
    "resources": {},
    "securityContext": {
      "allowPrivilegeEscalation": false,
      "capabilities": {
        "drop": [
          "ALL"
        ]
      },
      "runAsNonRoot": true,
      "seccompProfile": {
        "type": "RuntimeDefault"
      }
    },
    "stdin": true,
    "targetContainerName": "hello",
    "terminationMessagePath": "/dev/termination-log",
    "terminationMessagePolicy": "File",
    "tty": true
  }
]

Удалить эфемерный контейнер нельзя: после выхода из shell он останется в статусе terminated до пересоздания пода. Поэтому на продуктивных подах отладку ведут осознанно, а по завершении под можно перезапустить через kubectl rollout restart.

Права. Всё перечисленное — отдельные подресурсы в RBAC (часть 29): pods/log (get) для логов, pods/exec (create) для exec, pods/ephemeralcontainers (patch) для debug, pods/portforward для port-forward. Право на exec в под — это право на всё, что может процесс внутри, включая его токен ServiceAccount и смонтированные секреты; выдают его тем, кому выдали бы и сами секреты.


Копия пода

Эфемерный контейнер не поможет, если основной контейнер падает сразу после старта: смотреть не в чем. Для этого kubectl debug умеет создать копию пода с изменениями:

$ kubectl debug -n demo hello-798bd968d9-vkz9z -it --copy-to=hello-debug \
    --container=hello --profile=restricted -- sh

Здесь копия запускает в контейнере hello вместо приложения sh — если в образе он есть. Для distroless заменяют образ:

$ kubectl debug -n demo hello-798bd968d9-vkz9z --copy-to=hello-debug \
    --set-image=hello=busybox:1.37 --container=hello --profile=restricted -it -- sh

--set-image=<контейнер>=<образ> (или *=<образ> для всех) меняет образ, --container говорит, в каком контейнере заменить команду; без него kubectl debug добавил бы в копию ещё один контейнер. Профиль нужен и здесь: general добавил бы SYS_PTRACE самому контейнеру hello копии, и PSA отклонил бы под. restricted заменяет securityContext контейнера своим набором, так что readOnlyRootFilesystem в копии пропадает. Общий PID namespace (--share-processes) в копии включён по умолчанию, так что из отладочного контейнера видны процессы остальных. Копия — это отдельный под без владельца: ReplicaSet о нём не знает, метки по умолчанию не копируются (--keep-labels копирует), а значит он не попадёт в эндпоинты сервиса и не получит пользовательский трафик. Пробы тоже по умолчанию сняты (--keep-liveness, --keep-readiness их сохраняют), чтобы kubelet не убивал копию. После отладки её удаляют руками: kubectl delete pod -n demo hello-debug.


На узле: debug node, crictl, journalctl

Когда проблема ниже пода — контейнер не создаётся, узел NotReady, тома не монтируются, — нужен доступ к самому узлу. SSH есть не всегда, а kubectl debug умеет запустить под прямо на узле:

$ kubectl debug node/k8s-node-2 -it --profile=sysadmin --image=ubuntu:26.04
Creating debugging pod node-debugger-k8s-node-2-8lwdc with container debugger on node k8s-node-2.
All commands and output from this session will be recorded in container logs, including credentials and sensitive information passed through the command prompt.
If you don't see a command prompt, try pressing enter.
root@k8s-node-2:/# chroot /host
# crictl ps -a --name hello --state exited | head -3
CONTAINER           IMAGE               CREATED             STATE               NAME                ATTEMPT             POD ID              POD                      NAMESPACE
40bef406c15b6       c16806bc0dd63       58 seconds ago      Exited              hello               4                   712c804be67d4       hello-798bd968d9-vkz9z   demo
# exit
root@k8s-node-2:/# exit
Session ended, resume using 'kubectl attach node-debugger-k8s-node-2-8lwdc -c debugger -n default -i -t' command

Под node-debugger-... создаётся в текущем неймспейсе (здесь default, без меток PSA) с nodeName, заданным напрямую (планировщик не участвует), в сетевом, PID и IPC namespace узла, а корневая файловая система узла смонтирована в /host через hostPath. Без --profile=sysadmin под не привилегированный, и chroot /host или чтение части /proc не сработают. После chroot /host вы фактически в shell узла: доступны crictl, journalctl, systemctl, файлы kubelet. Такой под — это root на узле, поэтому право создавать поды с hostPath и privileged даётся только администраторам, а Pod Security Admission в неймспейсах вроде demo его и не пропустит. Под удаляется руками: после выхода он остаётся в Completed.

crictl — консольный клиент CRI, того gRPC-интерфейса, через который kubelet управляет containerd (часть 2). Он видит то же, что kubelet, и работает, даже когда API-сервер недоступен. Адрес сокета берётся из /etc/crictl.yaml (runtime-endpoint: unix:///run/containerd/containerd.sock):

user@k8s-node-2:~$ sudo crictl pods --namespace demo
POD ID              CREATED             STATE               NAME                     NAMESPACE           ATTEMPT             RUNTIME
712c804be67d4       2 minutes ago       Ready               hello-798bd968d9-vkz9z   demo                0                   (default)
f61cf58e184f0       2 minutes ago       Ready               hello-865b67cc76-6vcck   demo                0                   (default)

user@k8s-node-2:~$ sudo crictl ps -a --pod 712c804be67d4
CONTAINER           IMAGE               CREATED              STATE               NAME                ATTEMPT             POD ID              POD                      NAMESPACE
40bef406c15b6       c16806bc0dd63       About a minute ago   Exited              hello               4                   712c804be67d4       hello-798bd968d9-vkz9z   demo

user@k8s-node-2:~$ sudo crictl logs 40bef406c15b6
2026/11/02 13:58:45 listen: listen tcp: lookup tcp/8080	: unknown port

user@k8s-node-2:~$ sudo crictl inspect 40bef406c15b6 | jq '.status | {state, exitCode, reason, logPath}'
{
  "state": "CONTAINER_EXITED",
  "exitCode": 1,
  "reason": "Error",
  "logPath": "/var/log/pods/demo_hello-798bd968d9-vkz9z_cab28704-1a6a-40d9-bd16-172760839f9d/hello/4.log"
}

crictl inspect показывает и то, чего в API нет: PID процесса на узле у работающего контейнера (.info.pid), путь к cgroup, спецификацию OCI, с которой runc запустил контейнер, точки монтирования. crictl images — образы на узле, crictl pull — проверить скачивание образа с узла тем же containerd, crictl stats — потребление по контейнерам.

Журналы самих служб — в journald: kubelet и containerd на узлах kubeadm работают как юниты systemd.

user@k8s-node-2:~$ sudo journalctl -u kubelet --since "10 min ago" --no-pager | grep hello-798bd968d9 | tail -1
Nov 02 13:58:56 k8s-node-2 kubelet[763948]: E1102 13:58:56.206825  763948 pod_workers.go:1338] "Error syncing pod, skipping" err="failed to \"StartContainer\" for \"hello\" with CrashLoopBackOff: \"back-off 1m20s restarting failed container=hello pod=hello-798bd968d9-vkz9z_demo(cab28704-1a6a-40d9-bd16-172760839f9d)\"" pod="demo/hello-798bd968d9-vkz9z" podUID="cab28704-1a6a-40d9-bd16-172760839f9d"

user@k8s-node-2:~$ sudo journalctl -u containerd -p warning --since today --no-pager
-- No entries --

Буква в начале строки kubelet — уровень: I info, W warning, E error. Уровень подробности задаётся logging.verbosity в /var/lib/kubelet/config.yaml (kubeadm ставит 0) или флагом -v; 4 и выше — для поиска конкретной ошибки и ненадолго.


Типовые состояния

Pending

Под создан, но не назначен на узел или назначен, но ещё не дошёл до создания песочницы. Если PodScheduled=False, причину называет событие планировщика — одно сообщение со сводкой по всем узлам:

$ kubectl events -n demo --for pod/big
LAST SEEN           TYPE      REASON             OBJECT    MESSAGE
60s (x2 over 88s)   Warning   FailedScheduling   Pod/big   0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 Insufficient memory. no new claims to deallocate, preemption: 0/3 nodes are available: 3 Preemption is not helpful for scheduling.

Читается так: под big просит 5 ГиБ памяти; из трёх узлов мастер отпал из-за taint, а на двух воркерах не хватает невыделенной памяти — планировщик считает сумму requests уже размещённых подов, а не реальное потребление (часть 10). Вытеснение (preemption) не поможет: на мастер под не пустит taint, а на воркерах не хватит памяти, даже если убрать все поды ниже приоритетом. Фраза no new claims to deallocate — от планирования динамических ресурсов (DRA), без запросов resourceClaims её можно не читать. Типовые фразы и что они значат:

Фраза в событии Причина Что делать
Insufficient cpu / Insufficient memory сумма requests на узлах исчерпана kubectl describe node → «Allocated resources»; уменьшить requests или добавить узел
had untolerated taint(s) на узле taint, у пода нет toleration kubectl describe node | grep Taints; узел мог получить node.kubernetes.io/disk-pressure сам
didn't match Pod's node affinity/selector nodeSelector или nodeAffinity не находит узлов с такими метками kubectl get nodes --show-labels
didn't match pod anti-affinity rules реплик больше, чем узлов, при обязательном anti-affinity ослабить до preferred… или добавить узлы
didn't match pod topology spread constraints whenUnsatisfiable: DoNotSchedule и перекос по зонам или узлам проверить maxSkew и метки топологии
pod has unbound immediate PersistentVolumeClaims PVC в Pending: нет StorageClass, драйвер CSI не создал том kubectl describe pvc, логи контроллера CSI-драйвера
node(s) had volume node affinity conflict локальный том привязан к другому узлу том и под должны встретиться на одном узле
node(s) were unschedulable узел в cordon после drain kubectl uncordon

ContainerCreating

Под назначен, kubelet создаёт песочницу и монтирует тома, но контейнер ещё не запущен. Дольше минуты — повод смотреть события:

Warning   FailedCreatePodSandBox   Pod/sandbox   Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "b099e20b0b04a67fe6cc7f85455d66eb87b745ce177e151e6d09bc89601b9c81": plugin type="calico" failed (add): stat /var/lib/calico/nodename: no such file or directory: check that the calico/node container is running and has mounted /var/lib/calico/

Это CNI: containerd вызвал плагин Calico, а тот не нашёл файла, который пишет агент calico-node при старте. Значит, calico-node на этом узле не запущен или не готов — смотреть kubectl get pods -n calico-system -o wide и его логи. Другие варианты того же события: исчерпан IP-пул, плагин не может достучаться до API-сервера.

Warning   FailedMount   Pod/no-volume-cm   MountVolume.SetUp failed for volume "config" : configmap "hello-settings" not found
Warning   FailedMount   Pod/data-reader    MountVolume.MountDevice failed for volume "pvc-8f1c..." : rpc error: code = DeadlineExceeded desc = context deadline exceeded

Первое — том из ConfigMap или Secret, которого нет в неймспейсе (часть 12). Второе — CSI-драйвер не смонтировал сетевой том за отведённое время: для NFS это недоступный сервер, файрвол на 2049 или экспорт, не разрешающий адрес узла (часть 22); подробности — в логах node-плагина драйвера на этом узле и в dmesg узла.

ErrImagePull и ImagePullBackOff

ErrImagePull — неудачная попытка скачать образ, ImagePullBackOff — kubelet ждёт перед следующей, и пауза растёт от 10 секунд до 5 минут. Причина — в тексте события Failed:

$ kubectl events -n demo --for pod/pull-missing --types=Warning
LAST SEEN           TYPE      REASON   OBJECT             MESSAGE
35s (x3 over 81s)   Warning   Failed   Pod/pull-missing   Failed to pull image "ghcr.io/example/hello:1.2": rpc error: code = NotFound desc = failed to pull and unpack image "ghcr.io/example/hello:1.2": failed to resolve image: ghcr.io/example/hello:1.2: not found
35s (x3 over 81s)   Warning   Failed   Pod/pull-missing   Error: ErrImagePull
8s (x4 over 80s)    Warning   Failed   Pod/pull-missing   Error: ImagePullBackOff
Текст ошибки Причина
failed to resolve image: ...hello:1.2: not found нет такого тега или опечатка в имени; проверить реестр
failed to fetch anonymous token: ... 401 Unauthorized частный реестр, а containerd пришёл без учётных данных: нет imagePullSecrets у пода или его ServiceAccount, либо секрет в другом неймспейсе
x509: certificate signed by unknown authority у ghcr.io сертификат публичный, так бывает со своим реестром с внутренним CA (раздел «Свой реестр» части 5): containerd не доверяет его CA, нужен файл в /etc/containerd/certs.d/registry.example.com/ на каждом узле
dial tcp: lookup ghcr.io: no such host узел не резолвит имя реестра: DNS узла, а не CoreDNS
toomanyrequests лимит публичного реестра; зеркало или свой реестр

Самая быстрая проверка — скачать образ с узла тем же containerd: sudo crictl pull <образ>. Для публичного образа это сразу отвечает, есть ли тег и доходит ли узел до реестра. Частный реестр анонимному клиенту отвечает 401 Unauthorized на любой тег, даже существующий, поэтому для него нужен crictl pull --creds <пользователь>:<токен>, и тогда разница между «скачивается с узла» и «не скачивается в под» указывает на учётные данные пода.

CrashLoopBackOff

Контейнер запускается и завершается, kubelet перезапускает его с растущей паузой: 10 с, 20, 40, 80 … до 5 минут; после 10 минут нормальной работы счётчик сбрасывается. Это не причина, а режим ожидания, и причина всегда в прошлом запуске:

  1. kubectl get pod ... -o yaml → lastState.terminated: код выхода и reason.
  2. kubectl logs --previous — что приложение написало перед смертью.
  3. Если логов нет и код 127 или 126 — проблема в command/args или образе.
  4. Если код 137 и reason: OOMKilled — следующий раздел. Если 137 без OOM — контейнер убил kubelet: смотреть события Killing и liveness-пробу, которая могла убивать контейнер, не успевший стартовать.
  5. Если код 0 — процесс просто закончил работу: это не долгоживущая программа, ей место в Job (часть 9).

Когда логов слишком мало, помогает копия пода с sh вместо приложения: запустить бинарник руками и посмотреть, что ему не нравится.

OOMKilled

$ kubectl get pod oom -n demo -o jsonpath='{.status.containerStatuses[0].lastState}' | jq
{
  "terminated": {
    "containerID": "containerd://4f06596a129437c311776738cf4f195302872e962c70d645c2ac68950af12fe3",
    "exitCode": 137,
    "finishedAt": "2026-11-02T13:46:55Z",
    "reason": "OOMKilled",
    "startedAt": "2026-11-02T13:46:55Z"
  }
}

Ядро убило процесс, потому что cgroup контейнера достигла memory.max, то есть лимита памяти (часть 10). Подтверждение на узле — в журнале ядра:

user@k8s-node-2:~$ sudo journalctl -k --since "10 min ago" --no-pager | grep -i "out of memory\|oom.group"
Nov 02 13:47:38 k8s-node-2 kernel: Memory cgroup out of memory: Killed process 763094 (tail) total-vm:69260kB, anon-rss:64948kB, file-rss:2060kB, shmem-rss:0kB, UID:0 pgtables:184kB oom_score_adj:982
Nov 02 13:47:38 k8s-node-2 kernel: Tasks in /kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod637609c0_506e_417f_b645_73a37ad52b03.slice/cri-containerd-feeeb009a4322979cfd70bb0fd0fca347a47184f9505a94a034f3f3a92f5f11d.scope are going to be killed due to memory.oom.group set

Вторая строка — о том, что kubelet на cgroup v2 включает memory.oom.group: при нехватке памяти ядро убивает не один самый жадный процесс, а все процессы контейнера разом, чтобы контейнер не остался полуживым. Решение — не «поднять лимит, пока не перестанет», а понять, сколько памяти приложению нужно (метрики — в следующей части), и выставить requests и limits по этой цифре. Для рантаймов со своей кучей (JVM, .NET, Node.js) важно, чтобы рантайм знал о лимите и не планировал кучу больше него. Отдельный случай — под выселен kubelet'ом из-за нехватки памяти на всём узле: это Evicted, а не OOMKilled.

Running, но не Ready

Процесс работает, но readiness-проба не проходит, и под исключён из эндпоинтов сервиса:

Warning   Unhealthy   Pod/ready-path   Readiness probe failed: HTTP probe failed with statuscode: 404
Warning   Unhealthy   Pod/ready-port   Readiness probe failed: Get "http://172.20.172.48:8081/readyz": dial tcp 172.20.172.48:8081: connect: connection refused

Первое — приложение отвечает, но не тем: путь в пробе не совпадает с путём в приложении (404) или приложение само считает себя неготовым (503, часто — не дотягивается до базы). Второе — порт в пробе не совпадает с портом приложения или приложение слушает 127.0.0.1 вместо всех адресов: kubelet ходит на IP пода, а не на localhost. Проверить изнутри пода — kubectl debug с netshoot и curl на тот же адрес.

Terminating навсегда

kubectl delete выставил deletionTimestamp, но объект не исчезает. Две независимые причины.

Finalizers. Объект с непустым metadata.finalizers не удаляется, пока каждый контроллер из списка не уберёт свою запись. PVC с kubernetes.io/pvc-protection ждёт, пока его не перестанут использовать поды; неймспейс ждёт удаления всех объектов внутри; CRD-объект ждёт своего оператора — и если оператор удалён раньше, ждать будет вечно:

$ kubectl get pvc data -n demo -o jsonpath='{.metadata.finalizers}'
["kubernetes.io/pvc-protection"]

Снимать finalizer руками (kubectl patch ... --type=merge -p '{"metadata":{"finalizers":null}}') можно, только поняв, что именно он защищает: это отказ от уборки, которую должен был сделать контроллер, — например, от удаления тома в хранилище.

Под, который kubelet не может остановить. Для пода finalizers редки; обычно kubelet не может завершить процесс или размонтировать том. Классика — NFS-том с опцией hard (часть 23), сервер которого недоступен: процесс висит в состоянии D (непрерываемое ожидание ввода-вывода), SIGKILL до него не доходит, umount тоже висит. Это не ошибка опции: hard гарантирует, что запись не потеряется молча, и при возвращении сервера всё продолжится. Лечится возвращением сервера. На узле это видно так:

user@k8s-node-1:~$ ps -eo pid,stat,wchan:32,cmd | awk '$2 ~ /D/'
  48211 D    rpc_wait_bit_killable            /hello
user@k8s-node-1:~$ sudo dmesg | tail -2
[512334.118] nfs: server nfs.example.com not responding, still trying

kubectl delete pod --grace-period=0 --force удаляет только объект в API и ничего не делает на узле: процесс и монтирование остаются. Для Deployment это терпимо, а для StatefulSet опасно — контроллер создаст под с тем же именем и тем же томом на другом узле, пока старый ещё пишет. Если узел мёртв, правильный путь — убедиться, что он выключен, и повесить на него taint node.kubernetes.io/out-of-service=nodeshutdown:NoExecute: тогда Kubernetes штатно отвяжет тома и пересоздаст поды.

Evicted

Kubelet сам выселяет поды в двух случаях. Первый — на узле кончается память, место на диске (nodefs — /var/lib/kubelet, imagefs — образы containerd) или PID'ы. Пороги по умолчанию: memory.available<100Mi, nodefs.available<10%, imagefs.available<15%, nodefs.inodesFree<5% и imagefs.inodesFree<5%. Первыми уходят поды, превысившие свои requests, в порядке класса QoS, а в сообщении будет The node was low on resource: .... Второй — под превысил собственный лимит ephemeral-storage или sizeLimit тома emptyDir. Так выглядит под, записавший 64 МиБ во временный каталог при лимите 32 МиБ:

$ kubectl get pods -n demo disk-eater
NAME         READY   STATUS                   RESTARTS   AGE
disk-eater   0/1     ContainerStatusUnknown   1          105s
$ kubectl get pod disk-eater -n demo -o jsonpath='{.status.phase} {.status.reason}{"\n"}{.status.message}{"\n"}'
Failed Evicted
Pod ephemeral local storage usage exceeds the total limit of containers 32Mi.
$ kubectl events -n demo --for pod/disk-eater
LAST SEEN   TYPE      REASON    OBJECT           MESSAGE
65s         Warning   Evicted   Pod/disk-eater   Pod ephemeral local storage usage exceeds the total limit of containers 32Mi.

Колонка STATUS здесь обманывает: она показывает причину завершения контейнера (ContainerStatusUnknown, kubelet убил его вместе с подом), а то, что под выселен, видно только в .status.reason и событии. Выселенный под остаётся в фазе Failed, контроллер создаёт замену, а сами объекты удаляет сборщик мусора только когда завершённых подов в кластере больше --terminated-pod-gc-threshold kube-controller-manager (12 500). Убрать вручную: kubectl delete pods -n demo --field-selector=status.phase=Failed. Причину лечат по случаю: кто пишет логи в файлы внутри контейнера, кому нужен emptyDir с sizeLimit, хватает ли диска под образы.

CreateContainerConfigError

kubelet не может собрать конфигурацию контейнера из того, на что ссылается спецификация:

Warning   Failed   Pod/no-env-cm    Error: configmap "hello-settings" not found
Warning   Failed   Pod/no-key       Error: couldn't find key DB_PASSWORD in Secret demo/hello-db
Warning   Failed   Pod/root-image   Error: container has runAsNonRoot and image will run as root (pod: "root-image_demo(01ba9ce7-a9a3-45ed-b522-127686918b02)", container: busybox)

Нет ConfigMap или Secret, нет ключа в нём (для env.valueFrom — если ссылка не помечена optional: true) или securityContext противоречит образу (часть 30). Как только недостающий объект появится, kubelet сам продолжит запуск.

Узел NotReady

Узел каждые 10 секунд продлевает свой объект Lease в неймспейсе kube-node-lease и обновляет status, когда тот меняется, но не реже раза в 5 минут. Если продлений нет дольше --node-monitor-grace-period (50 секунд в 1.36), контроллер жизненного цикла узлов ставит conditions в Unknown и taint node.kubernetes.io/unreachable; поды с этого узла через 300 секунд (стандартный tolerationSeconds) будут выселены. Так выглядит узел через минуту после systemctl stop kubelet:

$ kubectl describe node k8s-node-2 | sed -n '/Conditions/,/Addresses/p'
Conditions:
  Type                 Status    LastHeartbeatTime                 LastTransitionTime                Reason              Message
  ----                 ------    -----------------                 ------------------                ------              -------
  NetworkUnavailable   False     Sun, 01 Nov 2026 20:32:25 +0000   Sun, 01 Nov 2026 20:32:25 +0000   CalicoIsUp          Calico is running on this node
  MemoryPressure       Unknown   Mon, 02 Nov 2026 13:44:13 +0000   Mon, 02 Nov 2026 13:49:43 +0000   NodeStatusUnknown   Kubelet stopped posting node status.
  DiskPressure         Unknown   Mon, 02 Nov 2026 13:44:13 +0000   Mon, 02 Nov 2026 13:49:43 +0000   NodeStatusUnknown   Kubelet stopped posting node status.
  PIDPressure          Unknown   Mon, 02 Nov 2026 13:44:13 +0000   Mon, 02 Nov 2026 13:49:43 +0000   NodeStatusUnknown   Kubelet stopped posting node status.
  Ready                Unknown   Mon, 02 Nov 2026 13:44:13 +0000   Mon, 02 Nov 2026 13:49:43 +0000   NodeStatusUnknown   Kubelet stopped posting node status.
Addresses:
$ kubectl get node k8s-node-2 -o jsonpath='{.spec.taints}' | jq -c '.[]'
{"effect":"NoSchedule","key":"node.kubernetes.io/unreachable","timeAdded":"2026-11-02T13:49:43Z"}
{"effect":"NoExecute","key":"node.kubernetes.io/unreachable","timeAdded":"2026-11-02T13:49:43Z"}

kubelet остановлен в 13:48:53, а Unknown выставлен в 13:49:43 — ровно через 50 секунд. LastHeartbeatTime при этом на пять минут старше: полный status kubelet присылает, только когда что-то изменилось, а живость узла контроллер видит по Lease. NetworkUnavailable ставит не kubelet, а Calico, поэтому он остался False.

Ready=False с причиной — значит, kubelet жив и сам сообщает о проблеме. Например, когда на узле пропала конфигурация CNI, в строке Ready будет container runtime network not ready: NetworkReady=false reason:NetworkPluginNotReady message:Network plugin returns error: cni plugin not initialized. Типовые причины, от частых к редким:

  • kubelet не работает: systemctl status kubelet, journalctl -u kubelet. Частый случай после ручных правок — включённый swap или ошибка в /var/lib/kubelet/config.yaml.
  • containerd не работает или не отвечает: systemctl status containerd, crictl info.
  • CNI не готов — нет конфигурации в /etc/cni/net.d/, потому что calico-node на узле не запустился. Состояние сети containerd показывает crictl info | jq '.status.conditions'. Если файл конфигурации вернули на место переименованием (mv из другого каталога), containerd может его не заметить: событие переименования внутрь каталога он пропускает и перечитывает каталог только на следующей записи в него.
  • Истёк клиентский сертификат kubelet: в журнале x509: certificate has expired. Kubelet сам продлевает /var/lib/kubelet/pki/kubelet-client-current.pem, если узел был включён; после долгого простоя продление не успевает, и узел приходится заново присоединять. Подробно о сертификатах kubeadm — в части 35.
  • PLEG (Pod Lifecycle Event Generator) — цикл kubelet, который опрашивает containerd о состоянии всех контейнеров. Если один проход занимает больше трёх минут, kubelet считает себя нездоровым: PLEG is not healthy: pleg was last seen active 3m5s ago; threshold is 3m0s. Причины — перегруженный containerd, тысячи завершённых контейнеров, зависший на NFS процесс, медленный диск.
  • Давление на ресурсы — MemoryPressure, DiskPressure, PIDPressure в True; узел готов, но получает taint и перестаёт принимать новые поды.
  • Расхождение часов: TLS к API-серверу ломается при большом сдвиге времени; timedatectl на узле.

Сервис без эндпоинтов

Под работает, сервис есть, а запросы к нему получают connection refused или висят:

$ kubectl get endpointslices -n demo -l kubernetes.io/service-name=hello-typo
NAME               ADDRESSTYPE   PORTS     ENDPOINTS   AGE
hello-typo-5mzdd   IPv4          <unset>   <unset>     89s
$ kubectl get svc hello-typo -n demo -o jsonpath='{.spec.selector}'; echo
{"app.kubernetes.io/name":"helo"}

Пустой EndpointSlice (часть 15) — либо селектор сервиса не совпадает с метками подов (как здесь: опечатка), либо подходящие поды есть, но не Ready, и тогда они видны в EndpointSlice с ready: false. Если эндпоинты есть, а трафик не идёт, — targetPort не совпадает с портом контейнера или трафик режет NetworkPolicy (часть 20).

Ошибки DNS

Внутри пода имя не резолвится или резолвится медленно. Проверять из того же неймспейса:

$ kubectl debug -it -n demo hello-865b67cc76-682fc --image=nicolaka/netshoot:v0.15 --profile=restricted -- \
    dig +search +short hello
10.100.210.84
$ kubectl debug -it -n demo hello-865b67cc76-682fc --image=nicolaka/netshoot:v0.15 --profile=restricted -- \
    dig @10.96.0.10 kubernetes.default.svc.cluster.local +short
10.96.0.1

Если второй запрос не проходит — недоступен сам CoreDNS: его поды (kubectl get pods -n kube-system -l k8s-app=kube-dns), их логи, NetworkPolicy, которая забыла разрешить egress на UDP и TCP 53 к kube-system. Если внутренние имена работают, а внешние медленные — это ndots:5 (часть 16): имя api.example.org содержит меньше пяти точек, и резолвер сначала перебирает три суффикса из search, получая NXDOMAIN, и только потом спрашивает имя как есть — четыре запроса вместо одного, каждый по A и AAAA. Лечится точкой в конце имени в конфигурации приложения (api.example.org.) или dnsConfig.options с ndots: 2 у пода.


Сводная таблица

Симптом Частая причина С чего начать
Deployment 0/3 готово, подов нет ReplicaSet не может создать поды: квота, admission kubectl describe rs, kubectl events --for deploy/...
Pending нет ресурсов, taints, affinity, PVC не привязан kubectl events --for pod/... → FailedScheduling
ContainerCreating дольше минуты CNI, том не монтируется, нет ConfigMap/Secret для тома события FailedCreatePodSandBox, FailedMount
ErrImagePull, ImagePullBackOff тег, имя, imagePullSecrets, CA реестра текст события Failed, crictl pull на узле
CrashLoopBackOff ошибка приложения или конфигурации lastState.terminated, kubectl logs --previous
OOMKilled, код 137 лимит памяти мал или утечка lastState, journalctl -k на узле, метрики памяти
Running, 0/1 readiness не проходит события Unhealthy, curl из эфемерного контейнера
Terminating не уходит finalizer; процесс в D на недоступном NFS metadata.finalizers; ps и dmesg на узле
Evicted (или ContainerStatusUnknown) давление на память, диск или PID узла; лимит ephemeral-storage .status.reason и .status.message, kubectl describe node
CreateContainerConfigError нет ConfigMap, Secret или ключа текст события Failed
Узел NotReady kubelet, containerd, CNI, сертификат, PLEG describe node, journalctl -u kubelet
Сервис не отвечает селектор, неготовые поды, targetPort, политика kubectl get endpointslices -l kubernetes.io/service-name=...
Имена не резолвятся или медленно CoreDNS, egress на 53, ndots:5 dig из netshoot, логи CoreDNS

Смотрим руками

Разберём один случай от начала до конца. Выпущена версия hello:1.1; вместе с ней в ConfigMap hello-config, из которого hello берёт переменные окружения, руками вписали порт PORT. Через десять минут после kubectl set image раскатка не закончилась.

Объект. Deployment сообщает, что новая версия не готова:

$ kubectl rollout status deploy/hello -n demo --timeout=10s
error: deployment "hello" exceeded its progress deadline

$ kubectl get deploy hello -n demo -o jsonpath='{range .status.conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'
Available=True MinimumReplicasAvailable
Progressing=False ProgressDeadlineExceeded
$ kubectl get deploy hello -n demo
NAME    READY   UP-TO-DATE   AVAILABLE   AGE
hello   3/3     1            3           11m

Progressing=False с ProgressDeadlineExceeded — за progressDeadlineSeconds (600 секунд по умолчанию) раскатка не продвинулась. Старые поды работают (Available=True): при трёх репликах maxUnavailable по умолчанию (25 %) округляется до нуля, и rolling update из части 7 не убирает старый под, пока новый не готов. Считается не время с начала выкатки, а время с последнего продвижения: если за эти десять минут число готовых реплик выросло хоть раз — скажем, вернулся упавший узел со старыми подами, — отсчёт начинается заново.

События и под. Новая ReplicaSet создала один под, и он в CrashLoopBackOff — вывод выше: код выхода 1, restartCount растёт.

Контейнер. Логи прошлого запуска называют причину:

$ kubectl logs -n demo hello-798bd968d9-vkz9z --previous
2026/11/02 13:58:02 listen: listen tcp: lookup tcp/8080	: unknown port

Порт 8080 приложение не узнало, а между 8080 и двоеточием в сообщении стоит пробел, которого не должно быть. Посмотрим, что приложение получает в переменной. Shell в образе нет, а эфемерный контейнер не поможет: контейнер падает через миллисекунды после старта, и --target не успеет к нему подключиться. Делаем копию с busybox вместо приложения:

$ kubectl debug -n demo hello-798bd968d9-vkz9z --copy-to=hello-debug \
    --set-image=hello=busybox:1.37 --container=hello --profile=restricted -it -- sh
/ $ id
uid=10001 gid=10001 groups=10001
/ $ printenv PORT | cat -A
8080^I$
/ $ exit
$ kubectl delete pod -n demo hello-debug
pod "hello-debug" deleted from demo namespace

cat -A показывает невидимые символы: ^I после 8080 — табуляция, $ — конец строки. Значение скопировали из таблицы вместе с табуляцией, а kubectl и YAML честно её сохранили.

Узел, для полноты. Причина найдена, но, пока падающий под ещё существует, посмотрим, как он выглядит на узле — тем же kubectl debug node:

$ kubectl debug node/k8s-node-2 -it --profile=sysadmin --image=ubuntu:26.04
root@k8s-node-2:/# chroot /host
# ls /var/log/pods/demo_hello-798bd968d9-vkz9z_*/hello/
4.log
# exit
root@k8s-node-2:/# exit
$ kubectl get pods -o name | grep node-debugger | xargs kubectl delete
pod "node-debugger-k8s-node-2-8lwdc" deleted from default namespace

Один файл — от последнего запуска (номер 4), по которому kubectl logs и --previous и показывали ошибку. Всё, что старше, уже удалено.

Исправление. Откат делает сервис снова однородным, затем исправленный ConfigMap и повторная раскатка:

$ kubectl rollout undo deploy/hello -n demo
Warning: resource deployments/hello was previously managed with 'kubectl apply'. Rolling back will not update the kubectl.kubernetes.io/last-applied-configuration annotation, which may cause unexpected behavior on future 'kubectl apply' operations. Consider using 'kubectl apply' with your previous configuration file instead.
deployment.apps/hello rolled back
$ kubectl apply -f hello-config.yaml
configmap/hello-config configured
$ kubectl set image deploy/hello -n demo hello=ghcr.io/example/hello:1.1
deployment.apps/hello image updated
$ kubectl rollout status deploy/hello -n demo
Waiting for deployment spec update to be observed...
Waiting for deployment "hello" rollout to finish: 0 out of 3 new replicas have been updated...
Waiting for deployment "hello" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "hello" rollout to finish: 2 out of 3 new replicas have been updated...
Waiting for deployment "hello" rollout to finish: 1 old replicas are pending termination...
deployment "hello" successfully rolled out

Предупреждение при откате — о том, что rollout undo меняет Deployment в обход kubectl apply, и аннотация last-applied-configuration по-прежнему описывает версию 1.1. Правильная привычка — после отката исправить манифест в Git и применить его. Под hello-798bd968d9-vkz9z удалён вместе с файлами логов на узле, а через час исчезнут и события о нём. Если бы этот сбой случился ночью и прошёл сам, утром в кластере не нашлось бы ни одного следа. Именно поэтому дальше нужны метрики и централизованные логи.

Уборка: kubectl delete pod -n demo <отладочные поды> для всего, что создавалось в разделе «Типовые состояния»; эфемерные контейнеры в подах hello исчезнут при следующем перезапуске Deployment.


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


Источники


Дальше — часть 32: метрики и автомасштабирование. metrics-server и kubectl top, HorizontalPodAutoscaler и его алгоритм, Prometheus с kube-prometheus-stack, запросы PromQL и правила алертов.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.