Local Kubernetes Dev — Part 15: Common problems and how to fix them
Полевой справочник по статусам Kubernetes, которые вы встречаете каждую неделю — ImagePullBackOff, CrashLoopBackOff, Pending, OOMKilled и другие — диагностика тремя командами и решения по короткому списку типовых причин.
Рано или поздно kubectl get pods покажет вам не уютный Running, а что-нибудь вроде ImagePullBackOff или CrashLoopBackOff. Это нормально — даже опытные инженеры видят эти статусы каждую неделю. Хорошая новость в том, что почти все проблемы в Kubernetes диагностируются одними и теми же тремя командами, а большинство ошибок укладывается в короткий список типовых причин.
Запомните этот «золотой набор» — он понадобится в каждом разделе ниже:
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=.lastTimestampkubectl describe показывает поле State (текущее состояние контейнера), Reason (почему оно такое) и блок Events внизу — это и есть три самых важных места, куда нужно смотреть. Дальше разберём типовые статусы по порядку.
ImagePullBackOff / ErrImagePull
Эти два статуса — про образ, который Kubernetes не смог скачать. Сначала появляется ErrImagePull (первая неудачная попытка), а после нескольких ретраев с нарастающей паузой (backoff) под переходит в ImagePullBackOff. Под при этом висит в состоянии Waiting и не стартует.
Смотрим причину:
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. Импортировать образ в узлы напрямую:
1docker build -t myapp:dev .
2k3d image import myapp:dev -c devПуть 2 (рекомендуемый). Локальный registry. При создании кластера поднимаем встроенный registry, а в манифестах ссылаемся на полное имя с адресом и портом (k3d: Using Image Registries):
1# registry создаётся вместе с кластером
2k3d cluster create dev --registry-create k3d-registry.localhost:50001# в 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).
Сам по себе статус не говорит, почему упало. Главная команда диагностики — логи предыдущего, уже умершего экземпляра контейнера:
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.
Полезный приём, когда контейнер падает мгновенно и логи пустые: временно переопределить команду на «ничего не делать», чтобы контейнер выжил, и зайти внутрь руками.
1# временно в Deployment, чтобы контейнер не падал и можно было залезть внутрь
2command: ["sleep", "infinity"]1kubectl exec -it <pod> -n myapp -- sh
2# внутри: проверяем env, пробуем запустить uvicorn руками и читаем настоящую ошибку
3env | grep DB_
4uvicorn app.main:app --host 0.0.0.0 --port 8080Pending
Под в статусе Pending ещё не назначен ни на один узел — планировщик (scheduler) не нашёл, куда его поставить. Команда та же:
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, а если это просто маленький локальный кластер — добавить узлы или пересоздать его побольше:
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).
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 это выглядит так:
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 не нашёл ни одного пода:
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):
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):
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). Итог — «сервис молча не отвечает, а под вроде бы живой».
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, а не в приложении:
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.
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 не нужен:
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):
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 руками
Сначала смотрим, что вообще занимает место:
1docker system df # сводка: образы, контейнеры, тома, build cache
2docker system df -v # детально, построчноЗатем чистим по нарастающей (Docker: docker system prune):
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тома не трогает, и это правильно.
Самый чистый сброс
Если хочется гарантированно освободить всё, что занял кластер, и начать с чистого листа — проще удалить и пересоздать сам кластер. Это выносит все узлы-контейнеры, их образы и слои разом:
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/Events → kubectl logs --previous даёт детали падения. Девяносто процентов случаев — это что-то из разобранного выше. С остальными помогут глава про отладку и наблюдаемость и официальная документация ниже.
Источники
- Debug Pods — Kubernetes
- Configure Liveness, Readiness and Startup Probes — Kubernetes
- Garbage Collection — Kubernetes
- Troubleshoot CrashLoopBackOff events — GKE
- Live Update Reference — Tilt
- Using Image Registries — k3d
- How to Fix OOMKilled Kubernetes Error (Exit Code 137) — Komodor
- How to Debug Kubernetes Service Not Reaching Pods — OneUptime
- How to Use Docker Images with k3d (k3s in Docker) — OneUptime
- docker system prune — Docker Docs