Рано или поздно kubectl get pods покажет вам не уютный Running, а что-нибудь вроде ImagePullBackOff или CrashLoopBackOff. Это нормально — даже опытные инженеры видят эти статусы каждую неделю. Хорошая новость в том, что почти все проблемы в Kubernetes диагностируются одними и теми же тремя командами, а большинство ошибок укладывается в короткий список типовых причин.

Запомните этот «золотой набор» — он понадобится в каждом разделе ниже:

bash
1# 1. Описание пода: статус, причина, события — стартовая точка ВСЕГДА
2kubectl describe pod <pod> -n myapp
3
4# 2. Логи приложения (и логи УПАВШЕГО контейнера через --previous)
5kubectl logs <pod> -n myapp
6kubectl logs <pod> -n myapp --previous
7
8# 3. Хронология событий по всему namespace
9kubectl get events -n myapp --sort-by=.lastTimestamp

kubectl describe показывает поле State (текущее состояние контейнера), Reason (почему оно такое) и блок Events внизу — это и есть три самых важных места, куда нужно смотреть. Дальше разберём типовые статусы по порядку.

ImagePullBackOff / ErrImagePull

Эти два статуса — про образ, который Kubernetes не смог скачать. Сначала появляется ErrImagePull (первая неудачная попытка), а после нескольких ретраев с нарастающей паузой (backoff) под переходит в ImagePullBackOff. Под при этом висит в состоянии Waiting и не стартует.

Смотрим причину:

bash
1kubectl describe pod <pod> -n myapp
2# В выводе ищем:
3#   State:   Waiting
4#   Reason:  ImagePullBackOff
5#   Events:  Failed to pull image "...": ... not found / unauthorized / no such host

Типовые причины:

  • Опечатка в имени или теге образа. Самое банальное и самое частое. Сверьте image: в манифесте с тем, что реально существует.
  • Приватный registry без доступа. Нужен imagePullSecrets (про секреты — см. главу 10).
  • Rate limiting на Docker Hub. Анонимные пользователи ограничены по числу скачиваний; в сообщении будет toomanyrequests.
  • Сетевые проблемы или опечатка в адресе registry no such host.

Главная грабля именно k3d

Узлы k3d работают на containerd — это собственный контейнерный движок Kubernetes, и он изолирован от вашего Docker-демона. Из этого следует контринтуитивная вещь: образ, который вы только что собрали локально через docker build, кластер не видит. Для Docker он есть, для кластера — нет, и вы получаете ImagePullBackOff, хотя «образ же собран» (OneUptime: Docker images with k3d). Здесь — только краткая выжимка для диагностики; подробно про доставку образа в k3d разобрано в главе про контейнеризацию.

Есть два правильных пути доставить образ в кластер.

Путь 1. Импортировать образ в узлы напрямую:

bash
1docker build -t myapp:dev .
2k3d image import myapp:dev -c dev

Путь 2 (рекомендуемый). Локальный registry. При создании кластера поднимаем встроенный registry, а в манифестах ссылаемся на полное имя с адресом и портом (k3d: Using Image Registries):

bash
1# registry создаётся вместе с кластером
2k3d cluster create dev --registry-create k3d-registry.localhost:5000
yaml
1# в Deployment важно полное имя: адрес registry + порт + тег
2containers:
3  - name: myapp
4    image: k3d-registry.localhost:5000/myapp:dev

Частая ошибка здесь — неполное имя образа: написать myapp:dev вместо k3d-registry.localhost:5000/myapp:dev, забыть порт или адрес. Кластер пойдёт искать образ не там, где надо, и снова упадёт в ImagePullBackOff.

Если вы используете Tilt, то про импорт можно забыть: docker_build в Tiltfile сам собирает и доставляет образ в кластер. Но есть условие — имя образа в Tiltfile должно точно совпадать со значением image: в манифесте. Не совпало — Tilt соберёт один образ, а Deployment попросит другой, и вы получите тот же ImagePullBackOff (подробнее про Tilt — см. главу 8).

CrashLoopBackOff

CrashLoopBackOff означает: контейнер запускается, падает, kubelet его перезапускает, он снова падает — и так по кругу. Чтобы не молотить рестартами вхолостую, kubelet увеличивает паузу между попытками экспоненциально: примерно 10 с → 20 с → 40 с и так далее, с потолком в 5 минут (GKE: Troubleshoot CrashLoopBackOff).

Сам по себе статус не говорит, почему упало. Главная команда диагностики — логи предыдущего, уже умершего экземпляра контейнера:

bash
1kubectl logs <pod> -n myapp --previous

Без --previous вы увидите логи свежезапущенного контейнера, который, скорее всего, ещё ничего не успел написать. А --previous достаёт стектрейс именно того экземпляра, который упал. Дополнительно в kubectl describe pod смотрите блок Last State: Terminated и его Exit Code.

Типовые причины:

  • Баг в приложении — исключение при старте, ненулевой exit code. Для нашего myapp классика — не заданы переменные подключения к БД (DB_HOST, DB_PASSWORD и т.д.), и FastAPI падает при попытке подключиться к PostgreSQL на старте.
  • Отсутствует env-переменная или конфиг (ConfigMap/Secret не примонтирован).
  • Зависимость недоступна — PostgreSQL ещё не поднялся, а сервис не умеет ждать.
  • OOM — приложению не хватило памяти (см. раздел OOMKilled ниже).
  • Слишком строгая liveness-проба убивает контейнер раньше, чем он успевает прогреться. Лечится initialDelaySeconds или отдельной startup-пробой (Kubernetes: Probes).
  • Контейнер завершился с exit code 0. Контринтуитивно, но для долгоживущего сервиса это тоже CrashLoopBackOff: контроллер ждёт, что процесс работает постоянно, а тот «успешно завершился». Обычно причина — неверный entrypoint/command, который отрабатывает и выходит вместо запуска uvicorn.

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

yaml
1# временно в Deployment, чтобы контейнер не падал и можно было залезть внутрь
2command: ["sleep", "infinity"]
bash
1kubectl exec -it <pod> -n myapp -- sh
2# внутри: проверяем env, пробуем запустить uvicorn руками и читаем настоящую ошибку
3env | grep DB_
4uvicorn app.main:app --host 0.0.0.0 --port 8080

Pending

Под в статусе Pending ещё не назначен ни на один узел — планировщик (scheduler) не нашёл, куда его поставить. Команда та же:

bash
1kubectl describe pod <pod> -n myapp
2# В Events ищем:
3#   Warning  FailedScheduling  ... 0/1 nodes are available: 1 Insufficient cpu, 1 Insufficient memory

Сообщение FailedScheduling обычно прямо называет причину (Kubernetes: Debug Pods):

  • Недостаточно CPU или памяти под requests пода — самая частая причина на локальном кластере. Если вы прописали myapp запрос в 4 CPU, а у вашей k3d-VM всего 2 — под никогда не запланируется.
  • Не совпадает nodeSelector / affinity — под просит узел с лейблом, которого нет.
  • Taints без tolerations — узел «помечен» так, что обычные поды на него не садятся.
  • Непривязанный PVC — под ждёт том, который не может быть создан.
  • hostPort — порт уже занят на узле, и под некуда поставить.

Решения по порядку: снизить requests до разумного, починить selector/PVC, а если это просто маленький локальный кластер — добавить узлы или пересоздать его побольше:

bash
1# добавить агентов (рабочие узлы) в существующий кластер
2k3d node create extra --cluster dev --role agent
3
4# или пересоздать кластер с несколькими агентами
5k3d cluster delete dev
6k3d cluster create dev --agents 2 --registry-create k3d-registry.localhost:5000

Для myapp адекватные requests на локалке — что-то скромное, например 100m CPU и 128Mi памяти; не копируйте бездумно «продовые» цифры в маленький кластер.

OOMKilled

OOMKilled — это «Out Of Memory Killed»: процесс превысил лимит памяти, и ядро его прибило сигналом SIGKILL. Опознать просто по exit code 137 = 128 + 9, где 9 — это и есть номер сигнала SIGKILL (Komodor: OOMKilled / Exit Code 137).

bash
1kubectl describe pod <pod> -n myapp
2# Last State:  Terminated
3# Reason:      OOMKilled
4# Exit Code:   137

Важно: именно поле Reason: OOMKilled отличает нехватку памяти от других случаев SIGKILL. Часто OOM становится скрытой причиной CrashLoopBackOff из предыдущего раздела — контейнер падает по памяти, рестартует, снова упирается в лимит.

Есть два сценария:

  • Container-level OOM. Контейнер превысил свой resources.limits.memory. Лечится либо поднятием лимита, либо починкой утечки/неэффективности в коде.
  • Node-level OOM. Памяти не хватило всему узлу, и kubelet начинает вытеснять (evict) поды. На k3d это особенно коварно: узлы живут внутри Docker-VM (Docker Desktop, colima и т. п.), и если суммарные лимиты ваших подов больше памяти этой VM, поды будут убиваться по OOM, даже когда каждое приложение в пределах своего лимита.

Для myapp это выглядит так:

yaml
1resources:
2  requests:
3    memory: "128Mi"
4  limits:
5    memory: "256Mi"   # FastAPI + пара воркеров обычно укладываются; следите за утечками

Если ловите node-level OOM — либо уменьшите суммарные лимиты подов, либо выдайте Docker-VM больше памяти в его настройках. Не оверкоммитьте: сумма лимитов всех подов не должна превышать память VM.

Сервис не отвечает (selector / порты / readiness)

Отдельный жанр проблем: поды Running, всё «зелёное», а запрос к Service не доходит до приложения. Почти всегда виноват один из трёх разрывов в цепочке Service → endpoints → Pod.

Первая команда — посмотреть endpoints. Если там пусто, Service не нашёл ни одного пода:

bash
1kubectl get endpoints myapp -n myapp
2# NAME    ENDPOINTS   AGE
3# myapp   <none>      5m      <- плохо: подов за сервисом нет
4
5# на новых кластерах то же самое через EndpointSlices:
6kubectl get endpointslices -n myapp -l kubernetes.io/service-name=myapp

Причина 1. Selector mismatch. Лейблы и селекторы регистрозависимы: app: Web и app: web — это разные вещи, и Service просто не подберёт под. Сверьте лейблы подов с селектором сервиса (OneUptime: Service not reaching pods):

bash
1kubectl get pods -n myapp --show-labels
2kubectl get svc myapp -n myapp -o jsonpath='{.spec.selector}'
3# селектор сервиса должен совпадать с лейблами подов символ в символ

Причина 2. Несовпадение портов. targetPort в Service должен указывать на containerPort, который реально слушает приложение. Для myapp это 8080 (Kubernetes: Debug Pods):

bash
1kubectl get svc myapp -n myapp -o yaml | grep -A3 ports
2# сверяем targetPort с containerPort в Deployment (у нас везде 8080)

Причина 3. Readiness-проба не проходит. Это самая хитрая из трёх. Если readiness-проба красная, под показывает 0/1 Ready, и Kubernetes убирает его из endpoints — трафик не идёт. При этом контейнер не перезапускается и в kubectl get pods выглядит как Running. Этим readiness принципиально отличается от liveness: liveness-фейл рестартит контейнер, а readiness-фейл лишь выводит его из-под трафика (Kubernetes: Probes). Итог — «сервис молча не отвечает, а под вроде бы живой».

bash
1kubectl get pods -n myapp
2# NAME            READY   STATUS    RESTARTS
3# myapp-xxxx      0/1     Running   0          <- 0/1: readiness не прошла
4kubectl describe pod <pod> -n myapp | grep -A5 Readiness

Хороший способ локализовать проблему — постучаться в обход Service, прямо в под через port-forward. Если так работает, а через Service нет — дело в selector/endpoints/readiness, а не в приложении:

bash
1kubectl port-forward <pod> -n myapp 8080:8080
2curl http://localhost:8080/healthz
3
4# проверить DNS-имя сервиса изнутри кластера
5kubectl run debug --rm -it --image=busybox:1.36 -n myapp -- \
6  nslookup myapp.myapp.svc.cluster.local

Подробнее про Service, порты и Ingress — см. главу 11.

Tilt не подхватывает изменения

Вы сохранили файл, ждёте, что Tilt мгновенно обновит контейнер, — а ничего не происходит или Tilt каждый раз делает полную пересборку образа вместо быстрого Live Update. Разберёмся, как это устроено.

Live Update в Tiltfile состоит из шагов, и порядок важен (Tilt: Live Update Reference):

  • fall_back_on(...) — всегда первым; перечисляет файлы, изменение которых форсит полный rebuild (например, requirements.txt).
  • sync('./app', '/code/app') — копирует изменённые файлы внутрь работающего контейнера (/code — это WORKDIR из Dockerfile главы 6).
  • run('...') — выполняется после всех sync (например, переустановить зависимости).
  • перезапуск процесса — нужен, если приложение не умеет hot-reload. Для Kubernetes это делается не отдельным шагом, а обёрткой docker_build_with_restart из расширения restart_process (подробно — в главе 8); встроенный шаг restart_container() из reference остаётся актуальным в основном для Docker Compose.
python
1docker_build(
2    'k3d-registry.localhost:5000/myapp', '.',
3    live_update=[
4        fall_back_on('requirements.txt'),   # 1) форс полного rebuild
5        sync('./app', '/code/app'),         # 2) копируем код
6        run('pip install -r requirements.txt',
7            trigger=['requirements.txt']),  # 3) run после sync
8    ],
9)

Почему изменения «не подхватываются» или происходит полный rebuild:

  • Синкаемый путь лежит вне build-context. Правило простое: «if Tilt is watching it, you can sync it» — но синкать можно только то, что находится внутри контекста сборки (второй аргумент docker_build). Файл снаружи Tilt проигнорирует.
  • Файл в контексте, но не покрыт ни одним sync() — изменение есть, а копировать его правило не велит.
  • Изменён файл из fall_back_on — это by design приводит к полному rebuild, не к Live Update.
  • run() стоит раньше sync() — порядок шагов нарушен.

И отдельная, очень частая на FastAPI ловушка: без restart_process синкнутый код попадает в контейнер, но процесс его не перечитывает. Файл уже новый, а uvicorn крутит старый код в памяти — выглядит ровно как «изменения не применились».

Решение зависит от того, как запущено приложение. Самый простой вариант для myapp — запускать uvicorn с авто-релоудом, тогда он сам подхватит синкнутые файлы и restart_process не нужен:

dockerfile
1# uvicorn сам перечитает код после sync — Live Update без перезапуска процесса
2CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--reload"]

Если же приложение запускается без --reload (или это custom_build / Docker Compose), перезапуск процесса для Kubernetes-ресурса делается обёрткой docker_build_with_restart из расширения restart_process (та же, что в главе 8):

python
1load('ext://restart_process', 'docker_build_with_restart')
2
3docker_build_with_restart(
4    'k3d-registry.localhost:5000/myapp', '.',
5    entrypoint=['uvicorn', 'app.main:app', '--host', '0.0.0.0', '--port', '8080'],
6    live_update=[
7        sync('./app', '/code/app'),   # код синкается, обёртка перезапустит процесс
8    ],
9)

k3d / Docker съел диск или память

Через пару недель активной разработки вы вдруг обнаруживаете, что диск кончается, а Docker отъел десятки гигабайт. Виноваты накопившиеся образы и слои сборки.

Почему «само не чистится»

У kubelet есть встроенный сборщик мусора для образов, но он ленивый. По умолчанию imageGCHighThresholdPercent = 85%, imageGCLowThresholdPercent = 80%: пока диск узла не превысит 85% занятости, чистка не запускается вообще. Когда превысит — kubelet удаляет самые давно неиспользуемые образы, пока не опустится до 80%. GC образов прогоняется примерно раз в 5 минут, контейнеров — раз в минуту (Kubernetes: Garbage Collection).

Вывод: место «копится» специально, и кажется, будто GC сломан, — на самом деле он просто ждёт порога 85%.

Чистим Docker руками

Сначала смотрим, что вообще занимает место:

bash
1docker system df          # сводка: образы, контейнеры, тома, build cache
2docker system df -v       # детально, построчно

Затем чистим по нарастающей (Docker: docker system prune):

bash
1docker system prune               # остановленные контейнеры, неиспользуемые сети,
2                                  # dangling-образы и build cache
3docker builder prune              # только кэш сборок
4docker system prune -a            # ВСЕ неиспользуемые образы, не только dangling
5docker system prune -a --volumes  # плюс анонимные тома (осторожно с данными!)

Две важные грабли:

  • docker system prune -a может снести образы, которые нужны кластеру. Если вы импортировали образ в k3d через k3d image import, агрессивная очистка удалит исходник из Docker — и при следующем рестарте пода словите ImagePullBackOff или придётся импортировать заново.
  • --volumes трогает анонимные тома; если в них лежали данные (например, локальный PostgreSQL для myapp) — они исчезнут. По умолчанию prune тома не трогает, и это правильно.

Самый чистый сброс

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

bash
1k3d cluster delete dev
2k3d cluster create dev --registry-create k3d-registry.localhost:5000

Про память

Память узлов k3d упирается в ресурсы Docker-VM, как и в разделе OOMKilled. Если кластеру в принципе мало памяти — увеличивать нужно не «лимиты в Kubernetes», а выделенную Docker-VM память в настройках Docker Desktop / colima. Поды живут внутри этой VM и не могут получить больше, чем есть у неё.

Общий алгоритм при любой проблеме

Он один и тот же: kubectl get pods показывает статус → kubectl describe pod объясняет причину в Reason/Eventskubectl logs --previous даёт детали падения. Девяносто процентов случаев — это что-то из разобранного выше. С остальными помогут глава про отладку и наблюдаемость и официальная документация ниже.

Источники

Воюете с непонятными ошибками Kubernetes локально?
Застряли на ImagePullBackOff, CrashLoopBackOff или Service, который не отвечает, на локальном кластере? Помогу отладить ваш Kubernetes и выстроить надёжный локальный воркфлоу.