Helm, Kustomize и GitOps: ArgoCD и Flux
В https://courses.digitable.life/post/devops/06-kubernetes/ мы разобрали объекты Kubernetes, а в https://courses.digitable.life/post/devops/07-k3s-and-lightweight/ — где взять кластер подешевле. Осталось главное эксплуатационное решение: как в этот кластер попадают манифесты.
Первая версия ответа всегда одинаковая — в CI дописывают шаг:
- run: kubectl set image deployment/api api=registry.example.com/api:${{ github.sha }}
Это работает ровно до третьего окружения. Дальше начинается: «а почему в стейдже 2 реплики, а мы вроде ставили 4?», «кто выключил PodDisruptionBudget?», «мы откатили релиз, но ConfigMap остался новый», «прод упал, а в Git всё зелёное». Эта статья — про то, как перейти от «CI толкает изменения» к «кластер сам подтягивает желаемое состояние», и какими инструментами это делается на практике.
Три способа получить YAML для конкретной среды
Манифест Kubernetes — декларация. Проблема в том, что деклараций нужно много почти одинаковых: dev, stage, prod, три региона, десять сервисов. Копипаста из 40 файлов в 40 других — это гарантированный дрейф. Способов победить копипасту ровно три.
1. Шаблонизация. Пишем текст с дырками и подставляем значения. Так работает Helm (Go templates), Jsonnet, Ansible-шаблоны. Плюс — максимальная гибкость: можно ветвление, циклы, условная генерация целых объектов. Минус — исходник перестаёт быть валидным YAML, его нельзя ни отвалидировать схемой, ни прочитать глазами без рендера.
2. Наложение (overlay). Держим валидный базовый манифест и накладываем на него патчи для каждой среды. Так работает Kustomize. Плюс — на любом шаге вы смотрите на настоящий YAML. Минус — нет условной логики вообще: нельзя сказать «если включён Redis, добавь ещё три объекта», можно только «замени вот это поле».
3. Генерация кодом. Манифесты — вывод программы на настоящем языке: cdk8s (TypeScript/Python), Pulumi, kubernetes-клиенты. Плюс — типы, тесты, IDE. Минус — вы завели ещё один сервис-репозиторий с зависимостями, который надо собирать, и порог входа выше, чем у любого YAML.
На практике в отрасли доминируют первые два, часто одновременно: чужие компоненты (ingress-контроллер, Prometheus, cert-manager) ставят Helm-чартами, потому что вендоры их так публикуют, а собственные сервисы описывают Kustomize или простеньким внутренним чартом.
Helm: пакетный менеджер, который ещё и хранит состояние
Helm решает две задачи, которые полезно различать, потому что вторая — источник большинства проблем.
Задача первая: упаковка. Чарт — это каталог с Chart.yaml, values.yaml и templates/, который можно заархивировать, подписать, положить в реестр и версионировать. Это ровно то, что делает apt или npm, только для набора объектов Kubernetes.
Задача вторая: управление релизом. helm install не просто применяет манифесты — он записывает в кластер запись о релизе и потом умеет upgrade, rollback, uninstall, зная, что именно он ставил в прошлый раз.
Структура чарта
charts/api/
├── Chart.yaml # метаданные и зависимости
├── values.yaml # значения по умолчанию (документация де-факто)
├── values.schema.json # JSON Schema для валидации values — сильно недооценён
├── templates/
│ ├── _helpers.tpl # именованные шаблоны: labels, имена, selector
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── hpa.yaml
│ ├── ingress.yaml
│ ├── NOTES.txt # что вывести после install
│ └── tests/
│ └── smoke.yaml # helm test
└── charts/ # распакованные зависимости (helm dependency build)
Chart.yaml с зависимостью и условием:
apiVersion: v2
name: api
description: Публичный HTTP API продукта
type: application
version: 1.8.3 # версия ЧАРТА — меняется при правке шаблонов
appVersion: "2026.7.2" # версия ПРИЛОЖЕНИЯ — просто метка, семантики нет
dependencies:
- name: postgresql
version: "15.5.x"
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled # выключается через values
- name: common
version: "2.x.x"
repository: oci://registry.example.com/charts
tags: [internal]
Разница между version и appVersion — типовая путаница. Правило простое: изменили шаблоны или дефолты — поднимайте version; выкатываете новый билд приложения — меняйте appVersion и тег образа. GitOps-агент следит именно за version.
Шаблон, который не стыдно показать
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "api.fullname" . }}
labels: {{- include "api.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels: {{- include "api.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
# перекатывает поды при изменении конфига — без этого ConfigMap
# обновится, а поды продолжат жить со старым содержимым
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
labels: {{- include "api.selectorLabels" . | nindent 8 }}
spec:
serviceAccountName: {{ include "api.serviceAccountName" . }}
securityContext:
runAsNonRoot: true
seccompProfile: {type: RuntimeDefault}
containers:
- name: api
# required падает на этапе рендера, а не молча ставит пустой образ
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports: [{name: http, containerPort: 8080}]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ required "нужен .Values.db.secretName" .Values.db.secretName }}
key: url
resources: {{- toYaml .Values.resources | nindent 12 }}
readinessProbe:
httpGet: {path: /readyz, port: http}
periodSeconds: 5
livenessProbe:
httpGet: {path: /healthz, port: http}
periodSeconds: 15
{{- with .Values.nodeSelector }}
nodeSelector: {{- toYaml . | nindent 8 }}
{{- end }}
Три приёма отсюда стоит забрать в любой чарт:
checksum/configв аннотациях пода — единственный надёжный способ перекатить поды при смене ConfigMap/Secret. Kubernetes сам этого не делает: смонтированный ConfigMap обновится в файловой системе (с задержкой до минуты), но процесс о нём не узнает;required "сообщение" .Values.x— превращает забытое значение в понятную ошибку рендера вместо загадочного пода вImagePullBackOff;toYaml ... | nindent N— правильный способ вставить произвольную структуру; ручное форматирование блокаresourcesчерез шаблоны — путь к сломанному отступу в пятницу вечером.
Где Helm кусается
Списки в values не сливаются, а заменяются целиком. Словари Helm объединяет глубоко, а массивы — нет. Если в values.yaml есть env: [A, B, C], а в values-prod.yaml вы напишете env: [D], в проде будет ровно [D]. Отсюда практическое правило: всё, что должно доопределяться по средам, моделируйте словарями, а не списками.
Состояние живёт в кластере. Helm 3 хранит релиз в Secret с именем sh.helm.release.v1.<release>.v<revision> в namespace релиза: gzip + base64 от JSON со всем отрендеренным манифестом. Смотреть можно так:
$ kubectl get secret -n prod -l owner=helm --sort-by=.metadata.name
NAME TYPE DATA AGE
sh.helm.release.v1.api.v41 helm.sh/release.v1 1 6d
sh.helm.release.v1.api.v42 helm.sh/release.v1 1 2d
sh.helm.release.v1.api.v43 helm.sh/release.v1 1 35m
$ helm history api -n prod --max 3
REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION
41 Mon Jul 13 11:02:14 2026 superseded api-1.8.1 2026.7.0 Upgrade complete
42 Wed Jul 15 09:41:55 2026 superseded api-1.8.2 2026.7.1 Upgrade complete
43 Thu Jul 16 14:20:03 2026 deployed api-1.8.3 2026.7.2 Upgrade complete
Отсюда два следствия. Первое: у Secret есть предел ~1 МБ, и большой чарт (особенно с CRD) в него не влезает — лечится --set crds.install=false и вынесением CRD за пределы релиза. Второе: если кто-то удалил эти Secret, Helm считает, что релиза не было, и helm upgrade падает с has no deployed releases. Классический сценарий — прерванный upgrade оставил ревизию в статусе pending-upgrade. Лечение:
# помечает застрявшую ревизию как проваленную, чтобы upgrade снова заработал
helm rollback api 42 -n prod --wait --timeout 5m
# или, если ревизий нет вовсе:
helm upgrade --install api ./charts/api -n prod --force
Helm не знает о дрейфе. Если руками поправить Deployment, helm upgrade c тем же чартом ничего не заметит — он сравнивает старый и новый рендер (three-way merge с живым объектом сглаживает многое, но не всё). Именно эту дыру закрывает GitOps-агент.
Рабочий цикл разработчика чарта
# рендер локально — то, что реально уедет в кластер
helm template api ./charts/api -f values-prod.yaml | kubectl apply --dry-run=server -f -
# показать diff перед выкаткой (плагин helm-diff, must-have)
helm plugin install https://github.com/databus23/helm-diff
helm diff upgrade api ./charts/api -n prod -f values-prod.yaml
# безопасная выкатка: не оставит полурелиз
helm upgrade --install api ./charts/api -n prod \
-f values-prod.yaml \
--atomic --wait --timeout 8m \
--set image.tag=2026.7.2
# статическая проверка результата
helm template api ./charts/api | kubeconform -strict -summary -
Про --atomic: без него провалившийся upgrade оставляет кластер в промежуточном состоянии. С ним Helm сам сделает rollback. Про --wait: он ждёт готовности объектов, но «готовность» для него — это Ready-поды, а не работающий трафик; настоящую проверку релиза делайте отдельно (см. https://courses.digitable.life/post/devops/04-cd-and-release-strategies/).
Публикация чарта в OCI-реестр — сегодня дефолт, отдельный chart-репозиторий с index.yaml уже архаика:
helm package ./charts/api --version 1.8.3
helm push api-1.8.3.tgz oci://registry.example.com/charts
helm show values oci://registry.example.com/charts/api --version 1.8.3
Kustomize: патчи вместо шаблонов
Kustomize встроен в kubectl (kubectl kustomize, kubectl apply -k), но встроенная версия обычно отстаёт от отдельного бинарника на несколько релизов — в CI ставьте отдельный kustomize.
Каноническая раскладка:
deploy/
├── base/
│ ├── kustomization.yaml
│ ├── deployment.yaml
│ ├── service.yaml
│ └── hpa.yaml
└── overlays/
├── stage/
│ ├── kustomization.yaml
│ └── patch-resources.yaml
└── prod/
├── kustomization.yaml
├── patch-resources.yaml
└── pdb.yaml
# base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- hpa.yaml
labels:
- pairs:
app.kubernetes.io/name: api
includeSelectors: true # заменяет устаревший commonLabels
# overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: prod
resources:
- ../../base
- pdb.yaml
# декларативная замена тега — то, что будет менять автоматика
images:
- name: registry.example.com/api
newTag: "2026.7.2"
replicas:
- name: api
count: 6
# генератор добавит хеш к имени: api-config-6t9k2fh4dm
# при изменении содержимого имя меняется → Deployment перекатывается сам
configMapGenerator:
- name: api-config
literals:
- LOG_LEVEL=info
- FEATURE_NEW_BILLING=true
patches:
- path: patch-resources.yaml
target: {kind: Deployment, name: api}
# JSON 6902 — когда нужно точечно, без дублирования структуры
- target: {kind: Deployment, name: api}
patch: |-
- op: add
path: /spec/template/spec/topologySpreadConstraints
value:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels: {app.kubernetes.io/name: api}
# overlays/prod/patch-resources.yaml — валидный фрагмент, не шаблон
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
template:
spec:
containers:
- name: api # имя обязательно: это merge key для списка
resources:
requests: {cpu: "500m", memory: 512Mi}
limits: {memory: 1Gi}
Проверка результата:
$ kustomize build deploy/overlays/prod | grep -E '^(kind| name| image| replicas)'
kind: ConfigMap
name: api-config-6t9k2fh4dm
kind: Service
name: api
kind: Deployment
name: api
kind: PodDisruptionBudget
name: api
# diff против живого кластера — эквивалент helm diff
kubectl diff -k deploy/overlays/prod
Главный подарок Kustomize — хеш в имени генерируемых ConfigMap и Secret. Он решает ту же проблему, что checksum/config в Helm, но бесплатно и без шаблонов. Обратная сторона: если на такой ConfigMap кто-то ссылается вручную из объекта вне kustomization, ссылка сломается (disableNameSuffixHash: true отключает, но тогда возвращается исходная проблема с перекатом).
Главная боль — strategic merge patch для CRD. Kubernetes знает merge-ключи для встроенных типов (containers сливается по name), а для CRD — нет, и список будет заменён целиком. Лечится либо JSON6902-патчами, либо подгрузкой OpenAPI-схемы через kustomize openapi.
Helm или Kustomize — честное сравнение
| Критерий | Helm | Kustomize |
|---|---|---|
| Исходник — валидный YAML | нет, Go-шаблон | да |
| Условная логика, циклы | полноценные | отсутствуют |
| Распространение чужого софта | стандарт де-факто | почти никто не публикует |
| Версионирование и подпись пакета | встроено (OCI, provenance) | нет, версия = git-ревизия |
| Состояние релиза | Secret в кластере | нет состояния вообще |
| Откат | helm rollback |
git revert |
| Порог входа | средний: шаблоны, nindent, области видимости |
низкий на старте, растёт с числом патчей |
| Отладка «почему такой YAML» | helm template --debug |
kustomize build, всегда читаемо |
| Типичная боль | «список заменился целиком» | «патч не применился, потому что поменялась база» |
Практический вывод, который работает у большинства команд: чужое ставим Helm, своё описываем Kustomize. Если своих сервисов много и они однотипны — заводится один внутренний library-чарт, а сервисы получают по 20-строчному values.yaml. Смешивать в третий раз не надо: kustomize умеет inflate-ить Helm-чарты (helmCharts: в kustomization), но эта функция требует --enable-helm и часто ломает воспроизводимость сборки.
GitOps: что это на самом деле
Термин ввела Weaveworks в 2017 году, и его быстро размыли до «у нас YAML в Git». Рабочее определение даёт OpenGitOps — четыре принципа:
- Декларативность. Система описана состоянием, а не набором команд.
- Версионируемость и неизменяемость. Желаемое состояние хранится в системе с историей и неизменяемыми ревизиями.
- Автоматическое применение. Агенты сами подтягивают одобренные изменения.
- Непрерывное согласование. Агенты постоянно сравнивают факт с желаемым и исправляют расхождение.
Четвёртый пункт — тот, который отличает GitOps от «CI c kubectl apply». Push-модель применяет изменение один раз в момент коммита; pull-модель держит инвариант непрерывно.
во внешней системе| P4["Ключ от прода
лежит в CI"] P3 -.->|ручное изменение| P5["Дрейф остаётся
навсегда"] end subgraph PULL["Pull-модель: агент подтягивает"] direction TB G1[Коммит] --> G2[(Git-репозиторий
состояния)] G2 -->|агент опрашивает| G3[Агент внутри кластера] G3 -->|apply| G4[(Кластер)] G4 -->|читает факт| G3 G3 -.->|расхождение| G5["Автоисправление
или алерт"] end style P4 fill:#e76f51,color:#fff style P5 fill:#e76f51,color:#fff style G5 fill:#2a9d8f,color:#fff style G2 fill:#3d8bcd,color:#fff
Практические выгоды pull-модели, ради которых всё затевается:
- у CI больше нет доступа к кластеру. Скомпрометированный раннер не даёт доступа к проду — это одно из самых весомых улучшений периметра, см. https://courses.digitable.life/post/devops/17-security-in-pipeline/;
- состояние прода можно прочитать без доступа к проду — оно в Git;
- аудит бесплатный: кто, что и когда изменил — это
git log, а не разбор аудит-логов API-сервера; - восстановление кластера сводится к «поднять пустой кластер и указать на репозиторий»;
- дрейф либо исправляется, либо становится видимым — а не тихо копится.
Честные ограничения, которые продавцы GitOps обычно не упоминают:
- не всё декларативно. Разовые миграции БД, императивные операции с внешними системами, «выключить фичу прямо сейчас» — плохо ложатся на модель. Для миграций всё равно нужны хуки и Job-ы;
- ощущение задержки. Между коммитом и применением проходят секунды-минуты; в инциденте это раздражает и провоцирует ручной
kubectl edit— то есть тот самый дрейф; - репозиторий состояния становится критичным сервисом. Падение GitHub теперь мешает выкатке (но не работе прода — это важное различие);
- два репозитория вместо одного. Код и манифесты разделены, и связь «коммит → выкаченная версия» нужно восстанавливать отдельно.
ArgoCD: архитектура и рабочие конфиги
рендер helm/kustomize
кеш манифестов] RS --> CTRL[argocd-application-controller
StatefulSet, шардируется
сравнение и применение] CTRL -->|watch + apply| K1[(Кластер prod)] CTRL -->|watch + apply| K2[(Кластер stage)] API[argocd-server
API и UI] --> RS API --> REDIS[(Redis: кеш
манифестов и состояний)] CTRL --> REDIS RS --> REDIS DEX[dex / OIDC] --> API DEV -->|SSO| API NOTIF[notifications-controller] -->|Slack, Telegram| CHAN[Каналы] CTRL --> NOTIF APPSET[applicationset-controller] -->|генерирует Application| CTRL style REPO fill:#3d8bcd,color:#fff style CTRL fill:#2a9d8f,color:#fff style REDIS fill:#5c6b7a,color:#fff
Ключ к пониманию нагрузки: repo-server ест CPU (каждый рендер чарта — это форк процесса), application-controller ест память (он держит в кеше все объекты всех кластеров), Redis — это кеш, а не база: его потеря приводит к полному перерендеру, а не к потере данных.
Application — базовая единица
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: api-prod
namespace: argocd
finalizers:
# без этого удаление Application осиротит объекты в кластере
- resources-finalizer.argocd.argoproj.io
spec:
project: payments
source:
repoURL: git@github.com:example/k8s-state.git
targetRevision: main
path: apps/api/overlays/prod
destination:
server: https://k8s-prod.internal:6443
namespace: api
syncPolicy:
automated:
prune: true # удалять объекты, исчезнувшие из Git
selfHeal: true # возвращать ручные правки к состоянию из Git
allowEmpty: false
syncOptions:
- CreateNamespace=true
- ServerSideApply=true # спасает от превышения лимита аннотации last-applied
- PruneLast=true # сначала выкатить новое, потом удалять старое
- RespectIgnoreDifferences=true
retry:
limit: 5
backoff: {duration: 15s, factor: 2, maxDuration: 5m}
# поля, которые правит не Git, а сам кластер
ignoreDifferences:
- group: apps
kind: Deployment
jsonPointers: ["/spec/replicas"] # если реплики ведёт HPA
- group: ""
kind: Secret
name: api-tls
jsonPointers: ["/data"] # заполняет cert-manager
selfHeal: true — включатель четвёртого принципа GitOps. Без него ArgoCD показывает OutOfSync и ждёт человека; с ним ручной kubectl scale откатится на следующем тике. Начинать разумно без selfHeal (чтобы команда привыкла), а включать через месяц — иначе первый же инцидент превратится в борьбу инженера с контроллером.
ServerSideApply=true стоит включать почти всегда: клиентский apply хранит предыдущий манифест в аннотации kubectl.kubernetes.io/last-applied-configuration, а у неё лимит 262 144 байта, в который упираются большие CRD (Prometheus Operator — классический пример).
Жизненный цикл синхронизации
Два разных понятия, которые новички путают: Sync status («совпадает ли кластер с Git») и Health status («работает ли то, что выкачено»). Приложение бывает Synced + Degraded — выкатили ровно то, что в Git, а оно не поднялось. Алертить надо на оба, но по-разному: OutOfSync дольше 15 минут — это процессная проблема, Degraded — инцидент.
Порядок выкатки: волны и хуки
# Job миграции: до основного релиза, удаляется после успеха
apiVersion: batch/v1
kind: Job
metadata:
name: api-migrate
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: HookSucceeded
argocd.argoproj.io/sync-wave: "-1"
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: registry.example.com/api:2026.7.2
command: ["/app/migrate", "up"]
Волны (sync-wave) — целые числа, меньшие применяются раньше, и ArgoCD ждёт Healthy всей волны, прежде чем начать следующую. Типовая раскладка: -2 — namespace и CRD, -1 — миграции и секреты, 0 — сервисы приложения, 1 — Ingress и мониторинг.
ApplicationSet: не пишите 200 Application руками
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: services
namespace: argocd
spec:
goTemplate: true
generators:
# матрица: каждый сервис × каждый кластер с меткой env=prod
- matrix:
generators:
- git:
repoURL: git@github.com:example/k8s-state.git
revision: main
directories:
- path: apps/*
- clusters:
selector:
matchLabels: {env: prod}
template:
metadata:
name: '{{.path.basename}}-{{.name}}'
spec:
project: default
source:
repoURL: git@github.com:example/k8s-state.git
targetRevision: main
path: '{{.path.path}}/overlays/prod'
destination:
server: '{{.server}}'
namespace: '{{.path.basename}}'
syncPolicy:
automated: {prune: true, selfHeal: true}
Обязательно задайте политику удаления: по умолчанию удаление каталога из Git снесёт приложение целиком. spec.syncPolicy.applicationsSync: create-update запрещает ApplicationSet удалять Application — на первое время это правильная предосторожность.
Мультиарендность делается через AppProject: он ограничивает, из каких репозиториев, в какие кластеры и какие типы ресурсов команда может выкатывать.
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata: {name: payments, namespace: argocd}
spec:
sourceRepos: ["git@github.com:example/k8s-state.git"]
destinations:
- {server: https://k8s-prod.internal:6443, namespace: "payments-*"}
clusterResourceWhitelist: [] # никаких кластерных объектов
namespaceResourceBlacklist:
- {group: "", kind: ResourceQuota}
- {group: "", kind: LimitRange}
roles:
- name: deployer
policies:
- p, proj:payments:deployer, applications, sync, payments/*, allow
groups: ["okta:payments-team"]
Flux: то же самое, но в виде набора контроллеров
Flux устроен иначе: вместо одного продукта — набор контроллеров GitOps Toolkit, каждый со своими CRD. Единого UI в комплекте нет (в этом основное отличие в ощущениях от работы).
| Контроллер | CRD | Отвечает за |
|---|---|---|
| source-controller | GitRepository, OCIRepository, HelmRepository, Bucket |
скачать и проверить артефакт источника |
| kustomize-controller | Kustomization |
собрать overlay и применить в кластер |
| helm-controller | HelmRelease |
установить и обновить чарт |
| notification-controller | Alert, Provider, Receiver |
уведомления и входящие webhook |
| image-reflector / image-automation | ImageRepository, ImagePolicy, ImageUpdateAutomation |
сканировать реестр и коммитить новый тег в Git |
Установка — одна команда, которая сама коммитит собственные манифесты в ваш репозиторий (это и есть «bootstrap»: Flux управляет собой тем же способом, что и всем остальным):
flux bootstrap github \
--owner=example --repository=k8s-state \
--branch=main --path=clusters/prod \
--components-extra=image-reflector-controller,image-automation-controller \
--token-auth=false
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata: {name: k8s-state, namespace: flux-system}
spec:
interval: 1m
url: ssh://git@github.com/example/k8s-state
ref: {branch: main}
secretRef: {name: flux-system}
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata: {name: api-prod, namespace: flux-system}
spec:
interval: 10m
retryInterval: 1m
timeout: 5m
path: ./apps/api/overlays/prod
prune: true
wait: true
sourceRef: {kind: GitRepository, name: k8s-state}
targetNamespace: api
dependsOn:
- {name: infra-controllers} # аналог sync-wave, но явный граф
healthChecks:
- {apiVersion: apps/v1, kind: Deployment, name: api, namespace: api}
# подстановка переменных без шаблонизатора
postBuild:
substituteFrom:
- {kind: ConfigMap, name: cluster-vars}
decryption:
provider: sops
secretRef: {name: sops-age}
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata: {name: ingress-nginx, namespace: ingress}
spec:
interval: 30m
chart:
spec:
chart: ingress-nginx
version: "4.11.x"
sourceRef: {kind: HelmRepository, name: ingress-nginx, namespace: flux-system}
install: {remediation: {retries: 3}}
upgrade: {remediation: {retries: 3, remediateLastFailure: true}}
driftDetection:
mode: enabled # Flux сам сравнивает живые объекты с рендером чарта
values:
controller:
replicaCount: 3
metrics: {enabled: true}
Отдельная сильная сторона Flux — автоматическое обновление образов из реестра, которого в ArgoCD нет в основном продукте (есть отдельный argocd-image-updater с более скромными возможностями):
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImagePolicy
metadata: {name: api, namespace: flux-system}
spec:
imageRepositoryRef: {name: api}
filterTags:
pattern: '^main-[a-f0-9]+-(?P<ts>[0-9]+)$'
extract: '$ts'
policy: {numerical: {order: asc}}
---
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageUpdateAutomation
metadata: {name: api, namespace: flux-system}
spec:
interval: 5m
sourceRef: {kind: GitRepository, name: k8s-state}
git:
checkout: {ref: {branch: main}}
commit:
author: {email: flux@example.com, name: fluxbot}
messageTemplate: 'chore(deploy): {{range .Updated.Images}}{{println .}}{{end}}'
push: {branch: main}
update: {path: ./apps/api/overlays/stage, strategy: Setters}
Дальше в манифесте ставится маркер # {"$imagepolicy": "flux-system:api"} рядом с тегом, и Flux сам коммитит обновление. Это удобно для stage и почти всегда плохо для прода: в прод изменение должно приходить через PR, а не автоматическим коммитом в main.
Диагностика:
$ flux get kustomizations -A
NAMESPACE NAME REVISION SUSPENDED READY MESSAGE
flux-system flux-system main@sha1:9c1f2ab False True Applied revision: main@sha1:9c1f2ab
flux-system infra-controllers main@sha1:9c1f2ab False True Applied revision: main@sha1:9c1f2ab
flux-system api-prod main@sha1:9c1f2ab False False Deployment/api/api dry-run failed: ...
$ flux reconcile kustomization api-prod --with-source
$ flux suspend helmrelease ingress-nginx -n ingress # заморозить на время инцидента
ArgoCD против Flux: как выбирать
| Критерий | ArgoCD | Flux |
|---|---|---|
| Модель | одно приложение с UI и API | набор независимых контроллеров |
| UI из коробки | да, богатый, с деревом ресурсов и логами | нет (сторонние: Capacitor, Headlamp-плагин) |
| Кому нужен доступ | разработчики заходят в UI сами | обычно только платформенная команда через CLI |
| RBAC и мультиарендность | зрелые: AppProject, SSO, политики | через Kubernetes RBAC и namespace |
| Мультикластер | один инстанс управляет многими кластерами | обычно Flux в каждом кластере |
| Обновление образов | отдельный компонент, ограниченный | встроено и хорошо работает |
| Секреты (SOPS) | через плагины/ksops или сторонний оператор | нативная поддержка decryption |
| Обнаружение дрейфа Helm | да, сравнивает рендер с живыми объектами | да, driftDetection в HelmRelease |
| Потребление ресурсов | заметное: 5+ компонентов, Redis | скромное: 4-6 контроллеров |
| Порог входа | ниже — видно глазами, что происходит | выше — всё через CRD и kubectl describe |
| Отладка | UI показывает diff и события | flux get, kubectl describe, события |
Практическая эвристика:
- много продуктовых команд, разработчики должны видеть выкатки сами, несколько кластеров из одной точки → ArgoCD. UI здесь не роскошь, а способ не быть узким горлышком;
- платформенная команда, много кластеров-близнецов, всё через PR, важен минимум ресурсов, нужна автоматизация тегов образов → Flux;
- инфраструктура одного кластера → берите то, что уже знает команда: разница в этом масштабе не окупает переучивание.
Оба проекта — graduated в CNCF, оба живые. Стоит знать контекст: Weaveworks, создатель Flux, закрылась в феврале 2024 года, и сопровождение проекта перешло к сообществу и компании ControlPlane — Flux после этого продолжил выпускать релизы, но проверьте текущее состояние репозитория, прежде чем строить на нём платформу на пять лет. У Argo корпоративная поддержка шире (Intuit, Akuity, Codefresh).
Секреты в GitOps
Главный вопрос новичка: «в Git же нельзя класть пароли». Правильно — нельзя класть открытые пароли. Вариантов четыре.
| Подход | Что лежит в Git | Плюсы | Минусы |
|---|---|---|---|
| SOPS + age/KMS | зашифрованное значение | простота, работает офлайн, diff читаемый по ключам | ротация ключа = перешифровать всё; Flux умеет нативно, ArgoCD — нет |
| Sealed Secrets | шифротекст под ключ конкретного кластера | нулевые внешние зависимости | секрет нельзя расшифровать для другого кластера; восстановление кластера требует бэкапа ключа |
| External Secrets Operator | только ссылка на путь в хранилище | секрет вообще не в Git, ротация автоматическая | нужен Vault/AWS Secrets Manager — плюс сервис и его стоимость |
| CSI Secrets Store | ссылка, значение монтируется в под | секрет не превращается в объект Secret | сложнее, привязка к драйверу облака |
Рабочая рекомендация: если у вас уже есть Vault или облачный менеджер секретов — берите External Secrets Operator, это самый чистый вариант. Если хранилища нет и заводить его дорого — SOPS с age-ключами, положенными в кластер вручную (и в корпоративный менеджер паролей — на случай пересоздания кластера).
# генерируем ключ и кладём его в кластер (один раз, не в Git)
age-keygen -o age.agekey
kubectl -n flux-system create secret generic sops-age --from-file=age.agekey=age.agekey
# шифруем только поля data/stringData, метаданные остаются читаемыми
sops --encrypt --age $(grep 'public key' age.agekey | awk '{print $4}') \
--encrypted-regex '^(data|stringData)$' \
secret.yaml > secret.enc.yaml
# .sops.yaml в корне репозитория — правила шифрования по путям
creation_rules:
- path_regex: clusters/prod/.*\.enc\.yaml$
encrypted_regex: '^(data|stringData)$'
age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
Промоушен между средами
Самая частая ошибка проектирования репозитория состояния — ветка на среду (dev, stage, main). Это выглядит естественно и разваливается на первом же хотфиксе: промоушен превращается в merge, merge тащит несвязанные изменения, cherry-pick порождает конфликты, а «что сейчас в проде» перестаёт быть очевидным.
Работающий подход — каталог на среду в одной ветке плюс изменение тега образа как отдельный коммит.
Практическая механика: CI собирает образ и открывает PR в репозиторий состояния, меняя одну строку. Для Kustomize это kustomize edit set image, для Helm — правка values-prod.yaml.
# .github/workflows/promote.yml — фрагмент задачи промоушена
promote-stage:
runs-on: ubuntu-24.04
needs: build
steps:
- uses: actions/checkout@v4
with:
repository: example/k8s-state
token: ${{ secrets.STATE_REPO_TOKEN }} # доступа к кластеру НЕТ
- name: Обновить тег образа
run: |
cd apps/api/overlays/stage
kustomize edit set image registry.example.com/api=registry.example.com/api:${{ needs.build.outputs.tag }}
- name: Открыть PR
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.STATE_REPO_TOKEN }}
branch: promote/api-${{ needs.build.outputs.tag }}
title: "deploy(stage): api ${{ needs.build.outputs.tag }}"
body: "Автоматический промоушен. Diff манифестов приложен ботом ниже."
Полезно добавить в этот PR комментарий ботом с результатом kustomize build | kubectl diff -f - — тогда ревьюер видит не «поменялась строка с тегом», а реальный diff объектов кластера. У ArgoCD для этого есть проект argocd-diff-preview, у Flux — flux diff kustomization.
Отдельный вопрос — как узнать, что в проде. Ответ в GitOps приятно скучный: git log -1 apps/api/overlays/prod. Дополнительно стоит прописывать в манифест аннотацию с git-SHA исходного кода, чтобы связать выкаченный образ с коммитом приложения.
Сколько это стоит и сколько отнимает времени
Инструменты бесплатны, платите вы за ресурсы и за внимание инженеров. Порядки величин для ориентира — обязательно замерьте у себя, разброс большой.
| Масштаб | ArgoCD (запросы ресурсов) | Flux (запросы ресурсов) | Комментарий |
|---|---|---|---|
| 1 кластер, ~30 приложений | ~1 vCPU / 2 ГиБ | ~0.3 vCPU / 0.6 ГиБ | оба помещаются в общий пул нод |
| 3 кластера, ~200 приложений | ~2–3 vCPU / 6–8 ГиБ, HA-режим | ~0.5 vCPU / 1–1.5 ГиБ на кластер | у ArgoCD растёт controller и Redis |
| 20+ кластеров, 1000+ приложений | шардирование контроллера, 3+ реплики, выделенные ноды | Flux в каждом кластере, центральной точки нет | здесь появляется управление самим ArgoCD как продуктом |
Пересчёт в деньги на примере среднего сценария (2–3 vCPU и 8 ГиБ под GitOps-инструментарий, HA):
| Где крутим | Что берём | Цена/мес (порядок) |
|---|---|---|
| Выделенная VM у Hetzner | CCX23 (4 vCPU, 16 ГБ) | ~€25 |
| Нода в managed-кластере AWS | m6i.xlarge on-demand, eu-central-1 | ~140 $ плюс 73 $ за control plane EKS |
| Управляемый GitOps как SaaS | подписка на кластер/приложение | обычно кратно дороже железа — берут ради поддержки и мультитенантности |
Вывод, который часто теряется: прямые расходы на GitOps малы. Основная стоимость — эксплуатационная нагрузка:
- первичная миграция: перенести существующие релизы под управление агента без даунтайма — 1–3 недели работы одного инженера на среднюю платформу;
- обучение: команда должна перестать делать
kubectl editв проде. Это культурный, а не технический вопрос, и он занимает месяцы; - сам GitOps-инструмент становится сервисом, который надо обновлять, мониторить и чинить. У ArgoCD это заметнее (больше движущихся частей и своя база пользователей/прав).
Порог входа честно: Kustomize + Flux осваивается платформенным инженером за неделю; ArgoCD с ApplicationSet, проектами, SSO и волнами — за две-три, но потом снимает нагрузку с платформенной команды, потому что разработчики обслуживают себя сами.
Типичные ошибки
- Ветка на среду. См. выше — переходите на каталоги.
- Код приложения и манифесты в одном репозитории. Каждый коммит кода дёргает агента, история смешивается, а откат манифестов утягивает откат кода. Разделяйте.
selfHeal: trueс первого дня без предупреждения команды. Инженер меняет что-то в инциденте, изменение откатывается через 3 минуты, инженер меняет снова — так проходит полчаса.- Отсутствие
ignoreDifferencesдля полей, которыми управляет кластер. HPA двигаетreplicas, cert-manager заполняет Secret, mutating webhook добавляет sidecar — и приложение вечноOutOfSync. prune: trueбезfinalizersили наоборот. Первое чревато осиротевшими объектами, второе — нежданным удалением namespace вместе с PVC. Проверяйте на stage.- Тег
latestилиmainв манифесте. Git тогда перестаёт описывать состояние: тот же коммит развернёт разные образы. Всегда неизменяемый тег, лучше — дайджест. - Секреты в открытом виде «пока временно». Git помнит всё; вычистить придётся
git filter-repoи ротацией всех значений. - Полный опрос вместо webhook. Дефолтный интервал ArgoCD — 3 минуты, Flux
intervalвы задаёте сами; на большом репозитории постоянный клон нагружает и агента, и хостинг. Настройте webhook и увеличьте интервал. - CRD внутри Helm-релиза приложения. Helm не обновляет CRD при upgrade (только ставит при install) — CRD выносят в отдельную Kustomization/Application с ранней волной.
- Нет алерта на
OutOfSyncи на ошибку синхронизации. Тихо не применившийся коммит — это худший режим отказа: все уверены, что выкатили. - Один гигантский Application на весь кластер. Любая ошибка блокирует всё; дробите по домену.
Практика продакшена: минимальный чеклист
- репозиторий состояния отдельный, с обязательным PR-ревью и защищённой
main; - рендер манифестов проверяется в CI:
kustomize build/helm template+kubeconform -strict+ политики Kyverno/OPA в режиме audit; - бот публикует diff объектов в PR;
- у CI нет креденшелов кластера — только токен на репозиторий состояния;
- в каждом приложении:
ignoreDifferencesдля управляемых кластером полей, health-check, разумные волны; - алерты:
OutOfSync > 15м,SyncFailed,Degraded, отставание источника от Git; - бэкап: ключи SOPS/Sealed Secrets, конфигурация самого агента (bootstrap-каталог Flux или
argocd admin export); - отработана процедура «отключить агента» (
flux suspend, ArgoCD — снятьautomated) на время инцидента и вернуть обратно с явным ревью дрейфа; - восстановление кластера с нуля отрепетировано хотя бы раз: пустой кластер + bootstrap = рабочая среда.
Мини-итог
- Копипаста манифестов побеждается тремя способами: шаблонизацией (Helm), наложением патчей (Kustomize) и генерацией кодом. Первые два доминируют и хорошо уживаются: чужое — чартами, своё — оверлеями.
- Helm даёт упаковку, версионирование и откаты, но хранит состояние в кластере и не видит дрейфа. Kustomize не хранит состояния и всегда показывает настоящий YAML, но не умеет условной логики.
- GitOps — это не «YAML в Git», а непрерывное согласование факта с декларацией плюс отсутствие доступа CI к кластеру.
- ArgoCD выигрывает там, где GitOps нужен многим командам и нужен UI; Flux — там, где платформенная команда хочет минимальный, компонуемый и дешёвый по ресурсам механизм с автоматизацией тегов образов.
- Основная стоимость внедрения не в железе (десятки долларов в месяц), а в миграции и смене привычек команды.
Источники
- OpenGitOps Principles v1.0 — каноническое определение четырёх принципов, CNCF App Delivery TAG.
- Helm Docs: Charts и Chart Best Practices — официальные рекомендации по структуре чарта.
- Kustomize Reference — полный список полей
kustomization.yaml. - Argo CD Documentation — особенно разделы Sync Options, Resource Tracking и High Availability.
- Flux Documentation — GitOps Toolkit, bootstrap, image automation.
- Kubernetes: Server-Side Apply — почему
ServerSideApply=trueрешает проблему больших манифестов. - SOPS и External Secrets Operator — два основных способа обращаться с секретами в репозитории состояния.
- Weaveworks, «GitOps: Operations by Pull Request» (2017) — исходная статья, задавшая термин: https://www.weave.works/blog/gitops-operations-by-pull-request.
Что дальше
Мы научились доставлять приложения в уже существующий кластер. Но сам кластер, сети, базы, балансировщики и права доступа тоже должны описываться декларативно и версионироваться — иначе инфраструктура остаётся последним местом, где живут ручные изменения. Об этом следующая статья: Terraform и инфраструктура как код: HCL, state, модули, drift, альтернативы.