К этому моменту у вас на k3d-кластере dev крутится myapp: образ собирается, манифесты применяются, Tilt пересобирает всё на лету (см. главу про Tilt). Локально всё хорошо. Но рано или поздно сервис нужно отдать наружу — в staging, а потом и в прод. И вот тут начинается интересное: как сделать так, чтобы прод-деплой не превратился в ручное копирование YAML с правкой пары строк «на глазок».

Эта глава — про мост между вашей локалкой и настоящим деплоем. Мы посмотрим, как описать несколько окружений без копипасты, почему latest в проде — это боль, как выглядит простейший CI-пайплайн и куда расти дальше (спойлер: GitOps).

Одни манифесты — три окружения: dev, staging, prod

Соблазн очевиден: скопировать папку k8s/ в k8s-prod/, поменять количество реплик, домен и тег образа. Один раз — терпимо. Через месяц у вас три почти одинаковые копии, в одной из которых забыли поправить лимит памяти, и поведение прода тихо разъезжается с тем, что вы тестировали. Это называется дрейф конфигурации (configuration drift), и борются с ним инструментами, которые держат одну базу и накладывают сверху только различия.

Отличаются окружения, как правило, в предсказуемых местах:

  • число реплик (replicas);
  • тег образа;
  • ресурсы (requests/limits);
  • адрес базы данных и прочие настройки;
  • домен в Ingress.

Есть два популярных подхода вынести эти различия. Базовый разбор configMapGenerator (Kustomize) и values-файлов (Helm) был в главе 10 — здесь смотрим на них именно под углом «несколько окружений и CI», не повторяя основ.

Helm: шаблоны + values

Helm — это шаблонизатор для Kubernetes. Манифесты превращаются в шаблоны (Go-templates), а конкретные значения подставляются из values.yaml. На каждое окружение — свой values-файл, который перекрывает дефолтные значения.

yaml
1# chart/values.yaml — дефолты (как для dev)
2replicaCount: 1
3image:
4  repository: k3d-registry.localhost:5000/myapp
5  tag: dev
6resources:
7  requests: { cpu: 50m, memory: 128Mi }
8ingress:
9  host: myapp.localhost
yaml
1# values-prod.yaml — только то, что отличается в проде
2replicaCount: 3
3image:
4  repository: ghcr.io/acme/myapp
5resources:
6  requests: { cpu: 250m, memory: 256Mi }
7  limits:   { cpu: 500m, memory: 512Mi }
8ingress:
9  host: api.myapp.com

Здесь хорошо виден переход от сквозного примера к проду: локально образ лежит в k3d-registry.localhost:5000/myapp:dev, а в проде — в боевом реестре ghcr.io/acme/myapp с immutable-тегом. Подробнее о том, что именно меняется при переезде, — в разделе про локалку и прод ниже.

Деплой прода — наложение одного файла поверх другого:

bash
1helm upgrade --install myapp ./chart \
2  -f chart/values.yaml \
3  -f values-prod.yaml \
4  --set image.tag=sha-abc123   # последний источник перекрывает предыдущие

Важный момент про приоритеты. Документация Helm описывает порядок так: values.yaml (дефолт) → values родительского чарта → пользовательский файл через -f--set. Каждый следующий перекрывает предыдущий, и при нескольких -f важен порядок — последний файл главнее. Это частые грабли: переставили -f местами и получили неожиданное значение реплик в проде.

Kustomize: патчи поверх base

Kustomize идёт от другого конца: никаких шаблонов, только чистый YAML и патчи поверх него. Он встроен в kubectl, отдельно ставить ничего не нужно. Структура — общий base/ и накладки overlays/:

text
1k8s/
2  base/
3    deployment.yaml
4    service.yaml
5    kustomization.yaml
6  overlays/
7    dev/
8      kustomization.yaml
9    prod/
10      kustomization.yaml
11      replicas-patch.yaml
yaml
1# overlays/prod/kustomization.yaml
2resources:
3  - ../../base
4namePrefix: prod-
5commonLabels:
6  env: prod
7images:
8  - name: myapp            # подменяет образ без правки Deployment
9    newName: ghcr.io/acme/myapp
10    newTag: sha-abc123
11patches:
12  - path: replicas-patch.yaml

Применяется так:

bash
1kubectl apply -k overlays/prod/
2# или собрать и посмотреть, что получится, до применения:
3kustomize build overlays/prod | kubectl apply -f -

Обратите внимание на поле images — это аккуратный способ подменить тег образа, не трогая сам Deployment. Пригодится в CI (см. разделы про теги образов и CI-пайплайн). Кроме него в kustomization.yaml живут resources, patches, namePrefix, commonLabels, configMapGenerator и другие трансформеры.

Что выбрать? Helm удобнее, когда нужна параметризация и переиспользование (особенно для сторонних приложений — БД, кэши из главы 9 часто ставят именно Helm-чартами). Kustomize проще и прозрачнее для своих сервисов: видно ровно тот YAML, что уйдёт в кластер. Распространён и гибрид: чужое ставим Helm, своё описываем Kustomize-overlays. Оба подхода понимают и Argo CD, и Flux — о них в разделе про GitOps.

Теги образов: почему latest — боль

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

Разберём механику боли:

  1. latest мутабельный. Содержимое тега меняется незаметно — сегодня под latest один код, завтра другой. Воспроизводимости ноль.
  2. Повторный apply не вызывает rollout. Kubernetes триггерит перевыкатку, только если изменился pod template. Строка image: myapp:latest не поменялась — значит, с точки зрения кластера деплоить нечего, хотя образ в registry уже другой.
  3. Рестарт пода может стянуть другой образ. Если под упал и поднимается заново, он может подтянуть новую (возможно, сломанную) версию latest — без всякого деплоя с вашей стороны. Прод «сам по себе» меняет версию.

Сюда же — imagePullPolicy. Его дефолт зависит от тега: для latest и образов без тега это Always, для конкретного тега — IfNotPresent, для digest — тоже IfNotPresent. С локальными образами IfNotPresent может, наоборот, «залипнуть» на старой версии — об этом подробнее в главе про частые проблемы.

Как правильно. Immutable-теги, которые однозначно указывают на конкретную сборку:

  • по короткому commit SHA: sha-abc1234;
  • по семверу: v1.2.3;
  • максимальная воспроизводимость — пин по digest:
yaml
1image: ghcr.io/acme/myapp@sha256:45b23dee...

Digest гарантирует, что «каждый раз запускается ровно тот же код». В CI всё это не нужно генерировать руками — есть docker/metadata-action, которая делает теги из git-контекста (type=sha, type=ref, type=semver) и заодно пишет OCI-лейблы вроде org.opencontainers.image.revision (commit SHA), .source, .version. Это связывает образ с конкретным коммитом — потом легко понять, что именно запущено в проде.

Базовый CI-пайплайн: build → push → apply

Простейшая модель деплоя называется push-based: коммит попадает в репозиторий → CI собирает образ → пушит в registry → CI же обновляет манифест и применяет его в кластер. Никакой магии, всё линейно.

На GitHub Actions это собирается из официальных docker-экшенов. Вот рабочий каркас для myapp:

yaml
1# .github/workflows/deploy.yml
2name: build-and-deploy
3on:
4  push:
5    branches: [main]
6
7jobs:
8  deploy:
9    runs-on: ubuntu-latest
10    permissions:
11      contents: read
12      packages: write
13    steps:
14      - uses: actions/checkout@v4
15
16      - uses: docker/setup-buildx-action@v3   # BuildKit: кэш, multi-arch
17
18      - uses: docker/login-action@v3          # логин в registry
19        with:
20          registry: ghcr.io
21          username: ${{ github.actor }}
22          password: ${{ secrets.GITHUB_TOKEN }}
23
24      - id: meta
25        uses: docker/metadata-action@v5       # immutable-теги из git
26        with:
27          images: ghcr.io/acme/myapp
28          tags: |
29            type=sha
30            type=ref,event=branch
31            type=semver,pattern={{version}}
32
33      - uses: docker/build-push-action@v6      # build + push
34        with:
35          push: true
36          tags: ${{ steps.meta.outputs.tags }}
37          labels: ${{ steps.meta.outputs.labels }}
38
39      - name: Deploy
40        run: |
41          echo "${{ secrets.KUBECONFIG }}" > kubeconfig
42          export KUBECONFIG=kubeconfig
43          kubectl set image deployment/myapp \
44            myapp=ghcr.io/acme/myapp:sha-${GITHUB_SHA::7}

Шаг деплоя на практике делают по-разному:

bash
1# вариант с Kustomize
2kustomize build overlays/prod | kubectl apply -f -
3
4# вариант с Helm
5helm upgrade --install myapp ./chart -f values-prod.yaml \
6  --set image.tag=sha-abc123
7
8# точечно обновить только образ
9kubectl set image deployment/myapp myapp=ghcr.io/acme/myapp:sha-$GIT_SHA

Главное — деплой обновляет образ на immutable-тег, а не на latest. Тогда новый pod template отличается от старого, и rollout честно происходит.

У push-модели есть встроенные слабости, которые стоит знать заранее:

  • CI хранит ключи от кластера (kubeconfig или облачные credentials). Это расширяет поверхность атаки: кто угодно с доступом к секретам CI получает доступ к проду.
  • Возможен дрейф между Git и кластером. Кто-то сделал kubectl edit руками — и состояние кластера разошлось с тем, что записано в репозитории. CI об этом не узнает.
  • Нет автоматического отката. Если деплой выкатил сломанную версию, возвращать всё придётся вручную.

Именно эти три пункта подталкивают к GitOps (см. следующий раздел).

Что переезжает из локалки, а что — только для прода

Хорошая новость: большая часть вашей работы из предыдущих глав переиспользуется. В этом и смысл production-like локалки (глава 2) — паритет окружений.

Переиспользуется как есть:

  • тот же Dockerfile и тот же образ (глава 6) — собранный артефакт один на все окружения;
  • те же Helm-чарты или Kustomize-base — меняются только values/overlays;
  • liveness/readiness-пробы (глава 13) — нужны и локально, и в проде;
  • если используете Skaffold — один skaffold.yaml с профилями. Профили Skaffold накладываются поверх базовой секции (заменой полей или JSON-патчем) и активируются через -p, переменную окружения или kube-context. Идея авторов прямая: «используйте локально те же команды, что и для удалённого деплоя».

Добавляется только для прода:

  • реальный registry и immutable-теги вместо k3d-registry.localhost:5000 и dev;
  • продакшен-ресурсы: настоящие requests/limits, HPA (горизонтальное автомасштабирование), PodDisruptionBudget, anti-affinity, несколько реплик;
  • настоящие Secrets, TLS (например, через cert-manager), боевой Ingress и DNS (глава 11);
  • мониторинг, NetworkPolicy, RBAC;
  • продакшен-тайминги проб (локальные значения обычно агрессивнее).

И отдельно — что в прод тащить НЕЛЬЗЯ. Все dev-ускорители живут только на локалке:

  • Live Update и file-sync из Tilt (глава 8);
  • hot-reload (uvicorn --reload) — в проде uvicorn запускается без него;
  • открытые debug-порты и dev-тулинг.

Эти вещи удобны в inner dev loop, но в проде они — дыра в безопасности и источник нестабильности. Профили Skaffold, отдельный values-prod или prod-overlay как раз и нужны, чтобы dev-only механизмы физически не попали в прод-манифесты.

GitOps как следующий шаг

Push-модель из предыдущего раздела работает, но её слабости (ключи в CI, дрейф, ручной откат) никуда не делись. Следующая ступень эволюции — GitOps.

Идея простая: Git становится единственным источником истины. Вы не пушите изменения в кластер из CI — вместо этого внутри кластера живёт агент (Argo CD или Flux), который сам тянет (pull) изменения из Git и непрерывно приводит кластер к описанному в репозитории состоянию.

Argo CD описывает себя как «декларативный GitOps-инструмент continuous delivery для Kubernetes». Он работает как контроллер: «непрерывно отслеживает приложения и сравнивает текущее живое состояние с желаемым целевым». Если кто-то поправил кластер руками — агент увидит расхождение (drift) и вернёт всё к тому, что записано в Git. Понимает он и Helm, и Kustomize, и обычный YAML.

Push (CI) против pull (GitOps):

Push (CI)Pull (GitOps)
Кто применяетCI извнеагент внутри кластера
Где живут ключи к кластерув CIвнутри кластера
Дрейфне отслеживаетсядетектируется и устраняется
Откатвручнуюоткатить коммит в Git

Связка получается такая: CI собирает образ с immutable-тегом и пушит в registry → бот (или тот же CI) обновляет тег в Git-манифесте (image.tag в Helm values или newTag в Kustomize images) → GitOps-агент замечает коммит и применяет изменение в кластер. CI больше не держит kubeconfig — его задача заканчивается на пуше образа и коммите в манифесты.

Argo CD и Flux — оба проекта CNCF выпускного уровня (graduated). Argo CD даёт богатый web-UI и app-центричную модель; Flux — более k8s-native, через набор CRD-контроллеров. Для первого знакомства Argo CD обычно проще за счёт наглядного интерфейса.

Разворачивать GitOps в этой статье мы не будем — это тема для отдельного материала. Но знать про него полезно уже сейчас: когда ваш push-пайплайн начнёт упираться в дрейф и ручные откаты, вы будете понимать, куда двигаться.

Итого: одна база манифестов + наложения на окружения (Helm values или Kustomize overlays), immutable-теги вместо latest, простой пайплайн build → push → apply, и осознанное разделение «что из локалки переезжает, а что остаётся только в проде». А дальше — GitOps, когда дорастёте. В следующей главе разберём частые проблемы, на которые натыкаются по дороге.

Источники

Хотите чистый путь от локалки к прод-деплоям?
Нужна помощь превратить быструю локалку в чистый пайплайн деплоя — манифесты на несколько окружений, immutable-теги образов, CI или путь к GitOps? Помогу спроектировать.