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

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

Наш герой — сервис `myapp`:

  • HTTP API на порту 8080;
  • написан на Python 3.12 + FastAPI, запускается через uvicorn;
  • в режиме разработки используется горячая перезагрузка: uvicorn --reload;
  • зависит от PostgreSQL.

Локально мы будем поднимать кластер на k3d (это лёгкий Kubernetes, который запускается в Docker), кластер назовём dev, рабочее пространство (namespace) — myapp, а образы будем складывать во встроенный реестр k3d по адресу k3d-registry.localhost:5000. Не пугайтесь незнакомых слов — все термины разберём по мере появления.

Что такое inner dev loop

Inner dev loop (внутренний цикл разработки) — это тот цикл, который один разработчик прокручивает у себя на машине снова и снова, пока пишет код: изменил код → собрал → запустил/перезапустил → проверил результат → поправил. Это происходит до того, как вы поделитесь изменениями с командой (через commit и push). Документация Telepresence формулирует это так: «один разработчик должен уметь настроить и использовать внутренний цикл, чтобы быстро писать и тестировать изменения», и главное — «чем быстрее цикл обратной связи, тем быстрее разработчик может рефакторить и тестировать снова».

Ключевое слово здесь — скорость обратной связи. Когда вы разрабатываете myapp локально без всякого Kubernetes, цикл выглядит примерно так:

  1. меняете функцию в Python-файле;
  2. uvicorn --reload сам подхватывает изменение за доли секунды;
  3. открываете браузер или дёргаете curl localhost:8080/...;
  4. видите результат.

Весь цикл — секунды. Вы можете повторять его десятки раз в час, почти не отвлекаясь от мыслей о задаче. Это и есть здоровый inner loop.

Важно не путать его с outer loop (внешним циклом). Outer loop начинается после git push: это CI-пайплайн, сборка артефактов, доставка в кластер через GitOps-инструменты вроде ArgoCD или Helm, прогон интеграционных тестов на общем окружении. Эта серия — почти целиком про inner loop: как сделать его быстрым и при этом похожим на прод.

Почему в Kubernetes цикл становится медленным

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

  1. упаковать код в контейнер (docker build);
  2. написать/обновить манифест Kubernetes;
  3. отправить образ в реестр (docker push);
  4. задеплоить контейнер в кластер и дождаться, пока под поднимется.

Теперь цикл для myapp выглядит так:

bash
1# 1. собрать образ
2docker build -t k3d-registry.localhost:5000/myapp:dev .
3
4# 2. отправить в реестр
5docker push k3d-registry.localhost:5000/myapp:dev
6
7# 3. применить манифесты
8kubectl apply -f k8s/
9
10# 4. дождаться нового пода и посмотреть, что он живой
11kubectl rollout status deployment/myapp -n myapp
12kubectl logs -f deployment/myapp -n myapp

Каждый такой проход — это, по оценке oneuptime, порядка 2–5 минут на итерацию: редактирование кода, сборка образа, push в реестр, обновление манифестов, ожидание перезапуска пода, проверка. Сравните это с секундами при uvicorn --reload— разница на порядки. Тот же источник отмечает, что синхронизация файлов прямо в работающий под с горячей перезагрузкой возвращает итерацию к 1–5 секундам, то есть сокращает время цикла на 95% и более. Именно ради этого ускорения существуют инструменты вроде Tilt, которым мы посвятим отдельную главу.

Есть и отдельный, очень коварный источник трения: локальный кластер не видит образы из вашего локального Docker-демона. Кажется логичным, что раз вы только что собрали myapp:dev через docker build, кластер сразу его подхватит, но это не так — и образ приходится либо отправлять в доступный кластеру реестр, либо загружать туда специальной командой. Как именно это делается в k3d, подробно разбираем в главе про контейнеризацию. Если шаг доставки пропустить, вы получите под в статусе ImagePullBackOff — kubelet бесконечно пытается стянуть образ, которого в реестре нет.

Грабли с тегом :latest

Если в манифесте указать образ как myapp:latest, Kubernetes по умолчанию выставит imagePullPolicy: Always — kubelet будет тянуть образ из реестра при каждом запуске, даже если локально он уже есть. Используйте конкретные теги (myapp:dev, myapp:abc123) и при необходимости imagePullPolicy: IfNotPresent.

Разрыв «локально работает / в кластере падает»

Медленный цикл — это полбеды. Вторая половина — это баги, которые в принципе невозможно увидеть на ноутбуке без кластера. Почему? Потому что среда, в которой вы обычно проверяете код (просто uvicorn или docker compose), не воспроизводит важные механизмы Kubernetes. Команда Testkube формулирует это прямо: «CI-контейнеры не имеют ограничений ресурсов, сетевых политик, RBAC или правил service mesh, которые есть в проде». Отсюда целый класс багов, видимых только в кластере:

  • OOMKilled — под убит за превышение лимита памяти. Локально память «бесконечная», и myapp спокойно ест сколько хочет; в кластере выставлен лимит, и под умирает.
  • Throttling по CPU. Если задать лимит CPU и приложение его превышает, Kubernetes жёстко притормаживает (throttle) контейнер — отсюда скачки задержек и неожиданные провалы проверок здоровья.
  • Блокировки сетевыми политиками (network policy). В проде трафик между подами может быть ограничен; локально его никто не ограничивает, и myapp ходит в PostgreSQL без проблем — ровно до выкатки.
  • Провалы readiness-проб — Kubernetes считает под неготовым и не пускает на него трафик, а вы ломаете голову, почему сервис «есть, но не отвечает».
  • RBAC: access denied — локально авторизация обычно разрешает всё, а в проде включён строгий RBAC, и сервис, который локально спокойно ходил в Kubernetes API, получает отказ уже после деплоя.

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

Идея shift-left: ловить проблемы окружения как можно раньше

Shift-left («сдвиг влево») — это принцип: ловить проблемы как можно раньше, ближе к началу конвейера разработки, а не в конце. Звучит как банальность, но для Kubernetes у него есть конкретный смысл. Дело не просто в том, чтобы тестировать пораньше — дело в том, в какой среде вы тестируете. Testkube подмечает это очень метко: «Тестировать раньше в CI-контейнере, который не совпадает с вашим кластером, — это не shift-left. Это просто провал быстрее в неправильном окружении».

Ключевое понятие тут — точность воспроизведения окружения (environment fidelity). Настоящий shift-left для Kubernetes означает раннюю проверку против реальных условий кластера: реальные ограничения ресурсов (а не «безлимитный» CI-контейнер), реальные сетевые политики, реальные сервисы в namespace (а не моки), и проверку того, что манифесты вообще дают здоровые поды. Эта серия живёт на уровне лёгкого локального кластера (kind, k3s/k3d) — настоящий Kubernetes, пусть и без полного паритета с продом.

Кстати, о здоровых подах. Официальный блог Kubernetes относит к частым граблям пропуск resource requests/limits и недооценку проб здоровья — без проб «Kubernetes считает, что нагрузка работает, даже если приложение внутри не отвечает». Различие между liveness- и readiness-пробами мы разбираем канонически в главе про приближение к проду; как смотреть статус пробы при отладке — в главе про наблюдаемость.

Что мы построим к концу серии

Сложим всё вместе. У нас две проблемы: цикл разработки в Kubernetes медленный (минуты вместо секунд) и есть разрыв между локалкой и продом (баги, видимые только в кластере). Решение — собрать локальное окружение, которое одновременно быстрое и похожее на прод. К концу у вас будет рабочая связка из четырёх частей:

  1. Локальный кластер на k3d — настоящий Kubernetes у вас на машине, а не его имитация. Поднимем его в главе про k3d.
  2. Контейнеризация и реальные манифесты. Dockerfile для myapp и манифесты Kubernetes — те же примитивы (Deployment, Service, пробы, лимиты ресурсов), что и в проде, а не docker-compose как замена кластера.
  3. Инструмент быстрого цикла Tilt с live-update, который синхронизирует код в работающий под и возвращает итерацию к секундам.
  4. Паритет с продом зависимости вроде PostgreSQL внутри кластера, правильная работа с конфигами и секретами, сеть и Ingress, а также пробы здоровья, requests/limits ресурсов и прочие приёмы, делающие локалку по-настоящему похожей на прод.

Иными словами, мы реализуем shift-left на практике: будем ловить проблемы окружения локально, ещё до того, как код уедет в общий кластер. А чтобы понять, кто из инструментов за что отвечает и почему мы выбрали именно k3d и Tilt, начнём со следующей главы — про то, что вообще значит «production-like окружение».

Источники

Хотите быстрое production-like локальное окружение Kubernetes?
Нужна помощь с ускорением inner dev loop в Kubernetes или закрытием разрыва между локалкой и продом? Помогу спроектировать быстрое production-like локальное окружение.