CI/CD, инфраструктура и облака Helm, Kustomize и GitOps: ArgoCD и Flux
0%

Helm, Kustomize и GitOps: ArgoCD и Flux

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 — четыре принципа:

  1. Декларативность. Система описана состоянием, а не набором команд.
  2. Версионируемость и неизменяемость. Желаемое состояние хранится в системе с историей и неизменяемыми ревизиями.
  3. Автоматическое применение. Агенты сами подтягивают одобренные изменения.
  4. Непрерывное согласование. Агенты постоянно сравнивают факт с желаемым и исправляют расхождение.

Четвёртый пункт — тот, который отличает GitOps от «CI c kubectl apply». Push-модель применяет изменение один раз в момент коммита; pull-модель держит инвариант непрерывно.

Практические выгоды pull-модели, ради которых всё затевается:

  • у CI больше нет доступа к кластеру. Скомпрометированный раннер не даёт доступа к проду — это одно из самых весомых улучшений периметра, см. https://courses.digitable.life/post/devops/17-security-in-pipeline/;
  • состояние прода можно прочитать без доступа к проду — оно в Git;
  • аудит бесплатный: кто, что и когда изменил — это git log, а не разбор аудит-логов API-сервера;
  • восстановление кластера сводится к «поднять пустой кластер и указать на репозиторий»;
  • дрейф либо исправляется, либо становится видимым — а не тихо копится.

Дрейф и цикл согласования

Честные ограничения, которые продавцы GitOps обычно не упоминают:

  • не всё декларативно. Разовые миграции БД, императивные операции с внешними системами, «выключить фичу прямо сейчас» — плохо ложатся на модель. Для миграций всё равно нужны хуки и Job-ы;
  • ощущение задержки. Между коммитом и применением проходят секунды-минуты; в инциденте это раздражает и провоцирует ручной kubectl edit — то есть тот самый дрейф;
  • репозиторий состояния становится критичным сервисом. Падение GitHub теперь мешает выкатке (но не работе прода — это важное различие);
  • два репозитория вместо одного. Код и манифесты разделены, и связь «коммит → выкаченная версия» нужно восстанавливать отдельно.

ArgoCD: архитектура и рабочие конфиги

Ключ к пониманию нагрузки: 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 и волнами — за две-три, но потом снимает нагрузку с платформенной команды, потому что разработчики обслуживают себя сами.

Типичные ошибки

  1. Ветка на среду. См. выше — переходите на каталоги.
  2. Код приложения и манифесты в одном репозитории. Каждый коммит кода дёргает агента, история смешивается, а откат манифестов утягивает откат кода. Разделяйте.
  3. selfHeal: true с первого дня без предупреждения команды. Инженер меняет что-то в инциденте, изменение откатывается через 3 минуты, инженер меняет снова — так проходит полчаса.
  4. Отсутствие ignoreDifferences для полей, которыми управляет кластер. HPA двигает replicas, cert-manager заполняет Secret, mutating webhook добавляет sidecar — и приложение вечно OutOfSync.
  5. prune: true без finalizers или наоборот. Первое чревато осиротевшими объектами, второе — нежданным удалением namespace вместе с PVC. Проверяйте на stage.
  6. Тег latest или main в манифесте. Git тогда перестаёт описывать состояние: тот же коммит развернёт разные образы. Всегда неизменяемый тег, лучше — дайджест.
  7. Секреты в открытом виде «пока временно». Git помнит всё; вычистить придётся git filter-repo и ротацией всех значений.
  8. Полный опрос вместо webhook. Дефолтный интервал ArgoCD — 3 минуты, Flux interval вы задаёте сами; на большом репозитории постоянный клон нагружает и агента, и хостинг. Настройте webhook и увеличьте интервал.
  9. CRD внутри Helm-релиза приложения. Helm не обновляет CRD при upgrade (только ставит при install) — CRD выносят в отдельную Kustomization/Application с ранней волной.
  10. Нет алерта на OutOfSync и на ошибку синхронизации. Тихо не применившийся коммит — это худший режим отказа: все уверены, что выкатили.
  11. Один гигантский 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 — там, где платформенная команда хочет минимальный, компонуемый и дешёвый по ресурсам механизм с автоматизацией тегов образов.
  • Основная стоимость внедрения не в железе (десятки долларов в месяц), а в миграции и смене привычек команды.

Источники

Что дальше

Мы научились доставлять приложения в уже существующий кластер. Но сам кластер, сети, базы, балансировщики и права доступа тоже должны описываться декларативно и версионироваться — иначе инфраструктура остаётся последним местом, где живут ручные изменения. Об этом следующая статья: Terraform и инфраструктура как код: HCL, state, модули, drift, альтернативы.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов