Kustomize base/custom/patch: DRY Helm values для нескольких окружений

Published: 2026-02-07

Шесть окружений, один git-репозиторий, никакого дублирования Helm values. Вот цель. Трёхуровневая структура Kustomize её достигает. Этот пост — полное описание структуры, что относится к каждому уровню, как работают патчи, частые ошибки и как всё валидировать локально перед пушем.

Три уровня

fluxcd/base/          ← Уровень 1: общие HelmRelease, все общие values
fluxcd/custom/        ← Уровень 2: опциональные компоненты (не для каждого окружения)
projects/{env}/hub/   ← Уровень 3: env-оверлей = base+custom + дельта-патчи

Объект HelmRelease начинается в base/. Он содержит всё, что одинаково во всех окружениях: репозиторий и тег образа, запросы/лимиты ресурсов, probe-настройки, параметры serviceMonitor, конфиг TLS, feature flags.

Ценность этой структуры: когда обновляется версия чарта, меняется одна строка в base/. Когда корректируются лимиты ресурсов, меняется один блок в base/. Патчи никогда не затрагивают версии чартов или общие values — только дельты конкретного окружения.

Что идёт в base

Всё стабильное и общее. Пример — base/monitoring/kube-prom-stack/helmrelease.yaml:

yamlapiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: kube-prom-stack
spec:
  interval: 30m
  timeout: 15m
  chart:
    spec:
      chart: kube-prometheus-stack
      version: "85.2.0"
      sourceRef:
        kind: HelmRepository
        name: prometheus-community
        namespace: flux-system
  values:
    grafana:
      enabled: false      # Grafana живёт только на infra, никогда на spoke
    defaultRules:
      create: false
    kubeScheduler:
      enabled: false
    kubeProxy:
      enabled: false
    prometheus:
      prometheusSpec:
        retention: 7d
        storageSpec:
          volumeClaimTemplate:
            spec:
              resources:
                requests:
                  storage: 20Gi

Обратите внимание на grafana.enabled: false. Grafana живёт только на infra (hub). Все spoke отправляют метрики в Prometheus hub через remote_write. На spoke работает только стек, но не слой визуализации.

Версия чарта — в base/. Обновление kube-prometheus-stack с 85.2.0 до 86.0.0 — один коммит, одна строка, автоматически применяется ко всем окружениям.

Что идёт в custom

Компоненты, нужные не всем окружениям. Дерево custom/ повторяет структуру base/:

custom/
├── apps/      ← allure, sonarqube, vault, trivy-operator, terraform-operator
├── db/        ← cassandra, clickhouse, minio, mongodb, redis
├── elk/       ← apm-server, kibana, otel-collector
├── monitoring/← abot, cctp, exporters, apps-rules
├── queue/     ← kafka, kafka-ui, rabbitmq
└── web/       ← apisix-ext (только для demo)

dev и test включают полный стек баз данных. sre и loadgds — облегчённые, без Kafka, Mongo, Cassandra, ClickHouse.

Каждая поддиректория custom/ имеет свой kustomization.yaml со списком HelmRelease-файлов:

yaml# custom/db/kustomization.yaml
resources:
  - redis/
  - mongodb/
  - cassandra/
  - clickhouse/
  - minio/

Таким образом, custom/db/ — самостоятельная цель Kustomize. Hub-оверлей может ссылаться на неё как на директорию, что разрешено load-restrictor.

Что идёт в патчи (только дельта)

Патчи содержат только то, что отличается между окружениями. Никогда не дублируйте base-values в патчах:

Что меняется Пример
Имя хоста для ingress elasticsearch.dev.test.example.com
StorageClass local-path (on-prem) или yc-network-ssd (Yandex Cloud)
Учётные данные ES_PASSWORD, REDIS_PASSWORD
LoadBalancer IP externalIP per-cluster
Toleration только для managed-узлов YC
Количество реплик sre нужно 3, dev — 1
Лимиты ресурсов loadgds нужен большой Elasticsearch, dev — минимальный

Минимальный патч:

yaml# projects/dev/kustomization/hub/patches/elasticsearch.yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: elasticsearch
  namespace: dev
spec:
  values:
    master:
      persistence:
        storageClass: local-path
    esConfig:
      elasticsearch.yml: |
        xpack.security.enabled: true
    esJavaOpts: "-Xmx2g -Xms2g"

Строка namespace: dev в metadata обязательна — Kustomize использует её для нахождения нужного объекта. Без неё Kustomize не знает, к какому из HelmRelease с именем elasticsearch применить патч (их может быть по одному на окружение в одной kustomization).

Сравните с тем же чартом на sre (Yandex Cloud, больше ресурсов):

yaml# projects/sre/kustomization/hub/patches/elasticsearch.yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: elasticsearch
  namespace: sre
spec:
  values:
    master:
      persistence:
        storageClass: yc-network-ssd
      resources:
        requests:
          memory: 8Gi
          cpu: 2
    esJavaOpts: "-Xmx6g -Xms6g"
    replicas: 3

Патчи разные; base/ идентична. Обновление версии чарта в base/ автоматически затронет оба окружения.

Hub kustomization.yaml: сборка оверлея

yaml# projects/dev/kustomization/hub/kustomization.yaml
namespace: dev
resources:
  - ../../../../base/
  - ../../../../custom/db/
  - ../../../../custom/elk/
  - ../../../../custom/monitoring/
  - spoke.yaml        # Flux Kustomization для spoke-слоя
  - monitoring.yaml   # Flux Kustomization для monitoring-слоя

patches:
  - path: patches/elasticsearch.yaml
  - path: patches/kube-prom-stack.yaml
  - path: patches/redis.yaml
  # Добавление kubeConfig во ВСЕ HelmRelease
  - target:
      kind: HelmRelease
    patch: |-
      - op: add
        path: /spec/kubeConfig
        value:
          secretRef:
            name: dev-kubeconfig

JSON-патч kubeConfig применяется ко всем HelmRelease автоматически — никакого дублирования на каждый чарт. Это единственное место в hub-оверлее, где упоминается dev-kubeconfig.

namespace: dev в верхней части устанавливает namespace для всех ресурсов в этом оверлее, если у них нет собственного. Именно так все HelmRelease оказываются в namespace dev на hub без повторения namespace: dev в каждом файле.

Правило «только директории»

load-restrictor Kustomize блокирует ссылки за пределы текущей директории при локальном запуске kubectl kustomize. Всегда ссылайтесь на директории с kustomization.yaml внутри, никогда на отдельные файлы:

yaml# Правильно
resources:
  - ../../../../base/
  - ../../../../custom/db/

# Неправильно — сломает kubectl kustomize локально
resources:
  - ../../../../custom/db/redis/helmrelease.yaml

Это также делает структуру самодокументирующейся: заглянув в любой kustomization.yaml, сразу понятно, какие компоненты включены, без подсчёта YAML-файлов.

Стратегии патчей: strategic merge vs JSON patch

Kustomize поддерживает два формата патчей:

Strategic merge patch (используем для большинства патчей): выглядит как частичный YAML-объект, сливается с base по тому же пути:

yaml# Merge: только перечисленные ключи изменяются, остальное из base сохраняется
spec:
  values:
    master:
      replicas: 3

JSON patch (используем для структурных операций — например, добавления kubeConfig):

yaml- op: add
  path: /spec/kubeConfig
  value:
    secretRef:
      name: dev-kubeconfig

Используйте strategic merge для переопределения значений. JSON patch — когда нужны add, remove или replace на пути, который может не существовать.

Нюанс со strategic merge для блока values: HelmRelease: Kustomize сливает на уровне YAML-ключей, а не на уровне Helm values. Если в base:

yamlvalues:
  redis:
    password: placeholder
    replicas: 1

А в патче:

yamlvalues:
  redis:
    password: "${REDIS_PASSWORD}"

Результат:

yamlvalues:
  redis:
    password: "${REDIS_PASSWORD}"
    replicas: 1   # сохранено из base

Работает ожидаемо. Но если нужно удалить ключ — strategic merge не удаляет ключи без директивы $patch: delete. Для удаления ключей используйте JSON patch с op: remove.

Локальная валидация рендера

bash# Полный hub-оверлей
kubectl kustomize fluxcd/projects/dev/kustomization/hub/

# Проверить, что kubeConfig добавлен во все HelmRelease
kubectl kustomize fluxcd/projects/dev/kustomization/hub/ | grep -A 5 "kubeConfig"

# Проверить values конкретного чарта после патчинга
kubectl kustomize fluxcd/projects/dev/kustomization/hub/ | \
  yq '. | select(.metadata.name == "elasticsearch") | .spec.values'

# Diff между двумя окружениями
diff \
  <(kubectl kustomize fluxcd/projects/dev/kustomization/hub/) \
  <(kubectl kustomize fluxcd/projects/test/kustomization/hub/)

Запускайте это перед каждым коммитом, который затрагивает патчи. Ловит YAML-ошибки и конфликты слияния до того, как они попадут в Flux.

Для CI добавьте в GitLab pipeline:

yamlkustomize-validate:
  stage: validate
  image: bitnami/kubectl:latest
  script:
    - for env in dev test sre loadgds demo; do
        echo "Validating $env...";
        kubectl kustomize fluxcd/projects/${env}/kustomization/hub/ > /dev/null;
      done

Выполняется ~5 секунд и ловит 90% ошибок патчей до merge.

Добавление нового окружения

  1. Скопируйте директорию projects/{env}/ существующего окружения
  2. Обновите namespace: в kustomization.yaml
  3. Обновите имя секрета в патче kubeConfig
  4. Обновите имена хостов и StorageClass в патчах
  5. Добавьте новый SealedSecret kubeconfig
  6. Сделайте коммит и дайте Flux подхватить изменения

Весь процесс занимает 20–30 минут, и новое окружение получает те же версии Helm-чартов и те же values, что и все существующие. Никаких ручных установок Helm, никаких скопированных values-файлов.