Kubernetes для самых маленьких. Часть 26: Как работает Kustomize?

Все части серии «Kubernetes для самых маленьких»

За двадцать пять частей у приложения hello накопился десяток манифестов: Deployment из части 7, Service из части 15, Ingress с сертификатом из частей 18 и 19, ConfigMap и Secret из части 12, PodDisruptionBudget из части 13, NetworkPolicy из части 20. Пока окружение одно, их достаточно применить kubectl apply -f. Но как только появляется второе — тестовый стенд с одной репликой и маленькими лимитами рядом с рабочим, где реплик три и имя хоста другое, — встаёт вопрос, как держать два почти одинаковых набора файлов и не дать им разойтись. С этой части начинается блок об упаковке, и первый инструмент в нём — Kustomize: он встроен в kubectl и работает с теми же самыми манифестами, ничего в них не меняя.


Задача и два подхода

Отличия между окружениями обычно небольшие: число реплик, requests и limits, тег образа, имя хоста в Ingress, неймспейс, пара параметров конфигурации, секреты. Скопировать каталог и поправить руками — работает ровно до первой правки в общей части, которую забудут перенести во вторую копию.

Есть два принципиально разных способа убрать копирование.

Шаблоны. Манифест превращается в текст с подстановками: replicas: {{ .Values.replicas }}, а значения приходят из отдельного файла. Так устроен Helm (часть 27). Мощно, но манифест перестаёт быть YAML-ом: его нельзя применить, провалидировать или открыть в редакторе со схемой, пока не прогнан шаблонизатор, а логика с условиями и циклами живёт внутри текста.

Наложения. Манифесты остаются обычными валидными объектами Kubernetes — base, база. Для каждого окружения пишется overlay, наложение: «возьми базу, поменяй вот это поле вот здесь». Результат вычисляется структурно, по дереву YAML, а не текстовой заменой. Так устроен Kustomize — проект SIG CLI (группа сообщества Kubernetes, отвечающая за kubectl), с 2019 года встроенный в kubectl как kubectl apply -k и kubectl kustomize.

Kustomize не ставит ничего в кластер и ничего там не хранит. Это функция «каталог с kustomization.yaml → поток YAML-документов»: он читает файлы, применяет преобразования и печатает результат. Дальше результат применяется тем же kubectl apply, что и раньше, и API-сервер о Kustomize ничего не знает. Поэтому всё, что было в предыдущих частях про server-side apply, resourceVersion и поля managedFields (часть 3), остаётся в силе без оговорок.


kustomization.yaml

Каталог становится «кастомизацией», когда в нём лежит файл kustomization.yaml. Минимальный вариант для существующих манифестов hello:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yaml
  - service.yaml
  - ingress.yaml

apiVersion и kind здесь формальные: файл не отправляется в API, это конфигурация самого Kustomize. resources — список того, что войдёт в результат: файлы с манифестами (в одном файле может быть несколько документов через ---), каталоги с собственным kustomization.yaml и удалённые адреса вида https://github.com/org/repo//deploy?ref=v1.4.0. Удалённую базу всегда закрепляют на теге или коммите в ref: без него каждая сборка берёт текущее состояние ветки, и результат меняется без единой правки в вашем репозитории.

Порядок, в котором Kustomize обрабатывает файл, фиксирован и не зависит от того, в каком порядке поля записаны:

 resources, components     ← загрузить объекты (рекурсивно собрать базы)
          │
          ▼
 generators                ← configMapGenerator, secretGenerator
          │
          ▼
 transformers              ← namespace, namePrefix/nameSuffix, labels, annotations,
          │                  patches, images, replicas
          ▼
 replacements              ← скопировать значения из одних полей в другие
          │
          ▼
 исправление ссылок        ← переименованные ConfigMap, Secret, Service
          │                  подставляются туда, где на них ссылаются
          ▼
 вывод (YAML-поток)

Последний шаг важен: Kustomize знает, в каких полях каких объектов стоят ссылки на имена — configMapRef и secretKeyRef в контейнере, volumes[].configMap.name, backend.service.name в Ingress, scaleTargetRef в HorizontalPodAutoscaler (объект автомасштабирования, часть 32) и ещё несколько десятков. Если имя объекта изменилось из-за префикса или хэша, ссылки на него поправятся сами.


Структура каталога

Всё, что будет в этой части, целиком:

hello/
├── base/
│   ├── kustomization.yaml
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── ingress.yaml
│   └── config/
│       └── app.env
├── components/
│   └── ha/
│       ├── kustomization.yaml
│       ├── pdb.yaml
│       └── patch-anti-affinity.yaml
└── overlays/
    ├── dev/
    │   ├── kustomization.yaml
    │   └── patch-resources.yaml
    └── prod/
        ├── kustomization.yaml
        ├── patch-resources.yaml
        ├── patch-ingress-host.yaml
        └── config/
            └── app.env

base/ — общее для всех окружений: обычные манифесты, которые можно применить и без Kustomize. overlays/dev и overlays/prod — по каталогу на окружение, в каждом только отличия. components/ha — переиспользуемый кусок «сделать отказоустойчивым», который подключается к любому overlay; о нём ближе к концу.

Окружения на стенде: dev живёт в неймспейсе demo-dev и отвечает на hello-dev.apps.example.com, prod — в учебном неймспейсе demo на hello.apps.example.com.


База

# base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: hello
  template:
    metadata:
      labels:
        app.kubernetes.io/name: hello
    spec:
      containers:
        - name: hello
          image: ghcr.io/example/hello:1.0
          ports:
            - name: http
              containerPort: 8080
          envFrom:
            - configMapRef:
                name: hello-config
          env:
            - name: PUBLIC_HOST
              value: set-by-kustomize
          readinessProbe:
            httpGet:
              path: /healthz
              port: http
          resources:
            requests:
              cpu: 50m
              memory: 32Mi
            limits:
              memory: 64Mi
# base/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: hello
spec:
  selector:
    app.kubernetes.io/name: hello
  ports:
    - name: http
      port: 80
      targetPort: http
# base/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: hello
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-staging
spec:
  ingressClassName: traefik
  rules:
    - host: hello-dev.apps.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: hello
                port:
                  name: http
  tls:
    - hosts:
        - hello-dev.apps.example.com
      secretName: hello-tls
# base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - deployment.yaml
  - service.yaml
  - ingress.yaml

labels:
  - pairs:
      app.kubernetes.io/part-of: hello
    includeTemplates: true

configMapGenerator:
  - name: hello-config
    envs:
      - config/app.env
# base/config/app.env
LOG_LEVEL=debug
GREETING=hello

Обратите внимание: в базе нет namespace. Неймспейс — свойство окружения, и его задаёт overlay. База, в которой неймспейс прописан в каждом манифесте, заставит overlay переписывать его патчами.


Метки: labels, а не commonLabels

Kustomize умеет добавлять метки и аннотации всем объектам. Исторически для этого было поле commonLabels, и его до сих пор можно встретить в половине примеров в интернете. Оно устарело: документация Kustomize прямо называет его deprecated и предлагает labels. Причина не в синтаксисе, а в поведении.

commonLabels добавляет метку везде, включая селекторы: в spec.selector.matchLabels Deployment и в spec.selector Service. Селектор Deployment — неизменяемое поле (часть 7): если через полгода кто-то добавит в commonLabels новую метку, kubectl apply упадёт с ошибкой field is immutable, и Deployment придётся удалять и создавать заново — с простоем. Причём упадёт не всё сразу: объекты применяются по одному, и Service к этому моменту уже получит новый селектор, которому не соответствует ни один под, — трафик пропадёт ещё до того, как кто-то заметит ошибку (как это выглядит — в разделе «Смотрим руками»). Кроме того, метка в селекторе Service сужает его, и под, созданный не этой кастомизацией, перестанет получать трафик. Начиная с Kustomize v5.0.0 commonLabels объявлено устаревшим, и kubectl kustomize на каждую сборку с ним печатает предупреждение # Warning: 'commonLabels' is deprecated. Please use 'labels' instead.

labels по умолчанию меняет только metadata.labels самих объектов и управляет остальным явно:

Поле в элементе labels По умолчанию Что добавляет
pairs — сами метки
includeTemplates false ещё и в spec.template.metadata.labels — метки попадут на поды, селекторы не трогаются
includeSelectors false ещё и в селекторы и шаблоны — поведение commonLabels
fields — свой список путей (path, kind, create), куда ещё положить метки

Правило простое: метки-описания (part-of, environment, team) — через labels с includeTemplates: true, чтобы по ним можно было искать поды; в селекторы — только то, что не изменится никогда, и это лучше прописать в базе руками, как app.kubernetes.io/name выше. Для аннотаций есть commonAnnotations; аннотации ни в какие селекторы не входят, и это поле не устарело.

Старые поля переписываются в новые командой kustomize edit fix отдельного бинаря Kustomize: она заменяет устаревшие конструкции в kustomization.yaml текущего каталога их современными эквивалентами. Заменяет она их честно, с сохранением поведения:

$ kustomize edit fix
# Warning: 'commonLabels' is deprecated. Please use 'labels' instead. Run 'kustomize edit fix' to update your Kustomization automatically.

Fixed fields:
  patchesJson6902 -> patches
  patchesStrategicMerge -> patches
  commonLabels -> labels
…
$ cat kustomization.yaml
resources:
- deployment.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
labels:
- includeSelectors: true
  pairs:
    team: web

commonLabels: {team: web} стал labels с includeSelectors: true — метка по-прежнему попадает в селекторы, и для уже работающих объектов это правильно: убрать её из селектора — такое же изменение неизменяемого поля, как и добавить. Поэтому includeSelectors: false ставят с самого начала, пока объектов в кластере ещё нет.


Overlay для dev

# overlays/dev/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

namespace: demo-dev
nameSuffix: -dev

resources:
  - ../../base

labels:
  - pairs:
      app.kubernetes.io/instance: hello-dev
      environment: dev
    includeTemplates: true

images:
  - name: ghcr.io/example/hello
    newTag: "1.1"

patches:
  - path: patch-resources.yaml
# overlays/dev/patch-resources.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello
spec:
  template:
    spec:
      containers:
        - name: hello
          resources:
            limits:
              memory: 48Mi

Что здесь происходит по полям.

resources: [../../base] — overlay ссылается на базу каталогом. Kustomize соберёт базу целиком (с её генераторами и метками), а затем применит к результату всё остальное. Базой может быть другой overlay — так строятся иерархии вроде base → prod → prod-eu, но больше двух уровней обычно читать уже трудно.

namespace выставляет metadata.namespace всем объектам, у которых он есть по смыслу (кластерные ресурсы вроде ClusterRole не трогает), и правит subjects[].namespace у ServiceAccount в RoleBinding. Сам неймспейс он не создаёт: либо добавьте манифест Namespace в resources, либо создавайте его отдельно — для учебного стенда kubectl create namespace demo-dev.

namePrefix и nameSuffix приписывают строку к именам всех объектов: hello превратится в hello-dev, hello-config — в hello-config-dev (и дальше получит хэш). Ссылки поправятся: Ingress будет смотреть на Service hello-dev, Deployment — на ConfigMap hello-config-dev-…. Суффикс нужен, когда два окружения делят неймспейс; если неймспейсы разные, как у нас, он необязателен, но с ним в выводе kubectl get сразу видно, что откуда.

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

images:
  - name: ghcr.io/example/hello     # что искать
    newName: ghcr.io/example/hello  # на что заменить имя (другой реестр)
    newTag: "1.1"                   # новый тег
  - name: busybox
    digest: sha256:3fbc6327…        # закрепить на дайджесте вместо тега

Тег в кавычках — не формальность: без них YAML прочитает 1.1 как число, а поле newTag строковое, и сборка остановится с ошибкой invalid Kustomization: json: cannot unmarshal number into Go struct field Image.images.newTag of type string. Ошибка здесь лучше тихого преобразования: превратись число в строку молча, тег 1.10 стал бы 1.1. Для рабочего окружения лучше digest: тег можно перезаписать в реестре, дайджест — содержимое образа, и он не меняется (часть 5). То же самое делает команда kustomize edit set image ghcr.io/example/hello:1.1 — её обычно вызывает CI после сборки образа.

patches — список патчей; о них отдельный раздел.


Патчи

Патч описывает изменение уже загруженного объекта. Kustomize понимает два формата, и оба задаются одним полем patches (раньше были отдельные patchesStrategicMerge и patchesJson6902, теперь они устарели).

Strategic merge

Патч в виде частичного манифеста, как patch-resources.yaml выше: apiVersion, kind и metadata.name определяют цель, а остальные поля накладываются на неё. «Strategic» — потому что слияние знает схему Kubernetes: список containers сливается не по позиции и не заменяется целиком, а по ключу name. Поэтому в патче достаточно указать name: hello и одно поле limits.memory — остальные поля контейнера и остальные контейнеры пода останутся как были. Ключи слияния для списков описаны в схеме API: containers и initContainers — по name, ports — по containerPort, env — по name, volumes — по name.

Удалить поле можно специальной директивой:

spec:
  template:
    spec:
      containers:
        - name: hello
          readinessProbe:
            $patch: delete

Ограничение: strategic merge работает для встроенных типов. Для пользовательских ресурсов (CRD, часть 28) Kustomize схемы не знает, списки в них заменяются целиком, и там удобнее второй формат.

JSON Patch (RFC 6902)

Список операций над путями в документе: add, remove, replace, move, copy, test. Путь записывается как JSON Pointer — сегменты через /, элементы списка по индексу. В JSON Patch цели в самом патче нет, поэтому её указывают полем target:

# overlays/prod/patch-ingress-host.yaml
- op: replace
  path: /spec/rules/0/host
  value: hello.apps.example.com
- op: replace
  path: /spec/tls/0/hosts/0
  value: hello.apps.example.com
- op: replace
  path: /metadata/annotations/cert-manager.io~1cluster-issuer
  value: letsencrypt-prod

~1 в последнем пути — экранированный / из имени аннотации (а ~0 — экранированная тильда), так требует RFC 6901. Индексы — слабое место формата: если в базе правила Ingress поменяются местами, патч молча заменит не тот хост. Поэтому JSON Patch берут там, где strategic merge не справляется: для элементов списков без ключа слияния, для удаления элемента по позиции, для CRD.

target: один патч на много объектов

target работает и для strategic merge, и тогда патч применяется ко всем подходящим объектам. Отбирать можно по group, version, kind, name (регулярное выражение), namespace, labelSelector и annotationSelector:

patches:
  - path: patch-ingress-host.yaml
    target:
      kind: Ingress
      name: hello
  - target:
      kind: Deployment
      labelSelector: app.kubernetes.io/part-of=hello
    patch: |-
      - op: add
        path: /spec/template/spec/securityContext
        value:
          runAsNonRoot: true
          seccompProfile:
            type: RuntimeDefault

Второй патч записан прямо в kustomization.yaml полем patch, без отдельного файла: удобно для коротких правок. Он добавит securityContext всем Deployment приложения — о самих этих полях подробно в части 30.


Генераторы ConfigMap и Secret и хэш в имени

В базе ConfigMap не написан руками, а сгенерирован из файла config/app.env. Генератор принимает три источника, их можно смешивать:

configMapGenerator:
  - name: hello-config
    envs:                       # файл KEY=VALUE, каждая строка — ключ
      - config/app.env
    files:                      # файл целиком становится значением ключа
      - config/settings.yaml    # ключ settings.yaml
      - motd=config/banner.txt  # ключ motd
    literals:                   # прямо здесь
      - FEATURE_X=on

Главное, что делает генератор, — добавляет к имени суффикс-хэш от содержимого: hello-config превращается в hello-config-k675bbfg64. Хэш — первые десять символов SHA-256 от типа, имени из генератора и данных объекта, в которых часть символов заменена так, чтобы не было гласных и случайно не сложилось слово. Метки, неймспейс, префиксы и суффиксы в хэш не входят: у hello-config-dev-bg9h65b2f2 в dev и у hello-config-bg9h65b2f2, собранного из той же базы без суффикса, хэш одинаковый. Ссылка в Deployment (configMapRef.name: hello-config) переписывается на новое имя.

Зачем это нужно. В части 12 мы видели, что переменные окружения из ConfigMap читаются один раз при старте контейнера, а изменение ConfigMap не перезапускает поды; для этого ставили Reloader. С генератором перезапуск получается сам собой:

 правка config/app.env
        │
        ▼
 новый хэш → новое имя ConfigMap: hello-config-bg9h65b2f2
        │
        ▼
 в Deployment меняется spec.template (ссылка на новое имя)
        │
        ▼
 изменился шаблон пода → Deployment создаёт новый ReplicaSet
        │
        ▼
 rolling update: поды по одному пересоздаются с новой конфигурацией

Получается ещё и откат конфигурации: старый ConfigMap с прежним хэшем остался в кластере, и kubectl rollout undo вернёт под на него, потому что предыдущий ReplicaSet ссылается на старое имя:

$ kubectl -n demo-dev rollout undo deploy/hello-dev
Warning: resource deployments/hello-dev was previously managed with 'kubectl apply'. Rolling back will not update the kubectl.kubernetes.io/last-applied-configuration annotation, which may cause unexpected behavior on future 'kubectl apply' operations. Consider using 'kubectl apply' with your previous configuration file instead.
deployment.apps/hello-dev rolled back
$ kubectl -n demo-dev get deploy hello-dev -o jsonpath='{.spec.template.spec.containers[0].envFrom[0].configMapRef.name}'
hello-config-dev-k675bbfg64

Предупреждение по делу: такой откат — мера на минуты. Файлы в репозитории остались новыми, и следующий kubectl apply -k вернёт новую конфигурацию; постоянный откат — это откат коммита и повторная сборка. Обратная сторона — старые ConfigMap копятся: kubectl apply -k не удаляет объекты, исчезнувшие из вывода. Их чистят руками, kubectl apply --prune с селектором по меткам (удаляет объекты с этими метками, которых нет во входных данных) или, лучше, GitOps-инструментом — контроллером, который сам применяет содержимое Git-репозитория в кластер и помнит, какие объекты создал (часть 41).

Поведение настраивается полем generatorOptions (для всех генераторов файла) или options у отдельного генератора:

generatorOptions:
  disableNameSuffixHash: false  # true — без хэша (тогда и без автоперезапуска)
  labels:
    app.kubernetes.io/component: config
  immutable: true               # ConfigMap станет неизменяемым

immutable: true хорошо сочетается с хэшем: каждая версия конфигурации — отдельный неизменяемый объект, а kubelet не тратит запросы на слежение за изменениями неизменяемых ConfigMap и Secret.

В overlay генератор можно не только создать, но и изменить базовый — полем behavior:

# overlays/prod/kustomization.yaml (фрагмент)
configMapGenerator:
  - name: hello-config
    behavior: merge          # create (по умолчанию) | replace | merge
    envs:
      - config/app.env       # LOG_LEVEL=info

merge добавит и переопределит ключи из файла overlay, оставив остальные из базы; replace заменит данные целиком.

secretGenerator устроен так же и создаёт Secret с тем же хэшем; дополнительно у него есть type (по умолчанию Opaque, можно kubernetes.io/tls с ключами tls.crt и tls.key). Но генератор берёт значения из файлов рядом с kustomization.yaml, а значит, эти файлы лежат в репозитории открытым текстом — base64 в Secret шифрованием не является, это мы обсуждали в части 12. Варианты, при которых в Git не попадает открытый секрет: файл генератора создаётся в CI из хранилища секретов и в репозиторий не коммитится (.gitignore); или используется SOPS (утилита, шифрующая значения в YAML-файле ключом age, PGP или KMS) через плагин-генератор KSOPS; или секрет вообще не описывается в Kustomize, а приходит из внешнего хранилища секретов через External Secrets Operator — контроллер, который по описанию в кластере сам читает значение из хранилища и создаёт из него обычный Secret. Подробнее о секретах в GitOps — в части 41.


replacements

Иногда значение одного поля нужно скопировать в другое. Пример: приложению нужен публичный адрес, по которому оно доступно, чтобы строить абсолютные ссылки. Хост уже есть в Ingress, и держать его в двух местах — значит однажды поправить только одно. В базовом Deployment для этого заведена переменная PUBLIC_HOST с заглушкой, а значение в неё положит Kustomize:

# base/kustomization.yaml (дополнение)
replacements:
  - source:
      kind: Ingress
      name: hello
      fieldPath: spec.rules.0.host
    targets:
      - select:
          kind: Deployment
          name: hello
        fieldPaths:
          - spec.template.spec.containers.[name=hello].env.[name=PUBLIC_HOST].value

source — откуда взять значение, targets — куда положить. Путь записывается через точки, а в списках элемент можно выбрать по значению поля: containers.[name=hello]. У цели есть options: create: true создаёт поле, если его нет, а delimiter и index позволяют заменить только часть строки (например, тег внутри image). Надёжнее, как здесь, держать поле-заглушку в базе: тогда опечатка в пути даст ошибку сборки, а не молча созданное лишнее поле.

Тонкость: replacements из базы выполняются при сборке базы, до патчей overlay. Здесь это значит, что в prod приложение получит hello-dev.apps.example.com — хост из базы, а не исправленный патчем. Поэтому в prod тот же replacement повторяется в overlay: он выполнится после патча хоста и перезапишет значение окончательным. Общее правило — replacement должен стоять на том уровне, где источник уже имеет окончательное значение. Это частая ошибка, и проверяется она только просмотром вывода kubectl kustomize: если убрать replacements из overlay prod ниже, сборка пройдёт без единого предупреждения, а в Deployment окажется value: hello-dev.apps.example.com.

Раньше похожую задачу решало поле vars с подстановками $(VAR); оно устарело, replacements — его замена.


Components

Overlay наследует базу, а наследование одиночное: нельзя сказать «prod — это база плюс отказоустойчивость плюс мониторинг», если отказоустойчивость нужна ещё и в stage, а мониторинг — ещё и в dev. Для таких сквозных кусков есть components — каталоги с kind: Component, которые подмешиваются в любой overlay:

# components/ha/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component

resources:
  - pdb.yaml

patches:
  - path: patch-anti-affinity.yaml
    target:
      kind: Deployment
      name: hello
# components/ha/pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: hello
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: hello
# components/ha/patch-anti-affinity.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello
spec:
  replicas: 3
  template:
    spec:
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: hello

Отличие от базы: компонент применяется внутри того overlay, который его подключил, после загрузки ресурсов, и его патчи видят объекты overlay. Поэтому компонент может патчить то, чего в нём самом нет. apiVersion у компонента — v1alpha1, хотя возможность давно стабильна в использовании; это особенность версионирования Kustomize, а не признак экспериментальности. PodDisruptionBudget и разнесение подов по узлам разобраны в части 13.


Overlay для prod

# overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

namespace: demo

resources:
  - ../../base

components:
  - ../../components/ha

labels:
  - pairs:
      app.kubernetes.io/instance: hello-prod
      environment: prod
    includeTemplates: true

images:
  - name: ghcr.io/example/hello
    digest: sha256:2d8774f801594f2b1b115963e54ec54b1b49f95c2dece1d6b0fd1e59ab447c7b

configMapGenerator:
  - name: hello-config
    behavior: merge
    envs:
      - config/app.env

patches:
  - path: patch-resources.yaml
  - path: patch-ingress-host.yaml
    target:
      kind: Ingress
      name: hello

replacements:
  - source:
      kind: Ingress
      name: hello
      fieldPath: spec.rules.0.host
    targets:
      - select:
          kind: Deployment
          name: hello
        fieldPaths:
          - spec.template.spec.containers.[name=hello].env.[name=PUBLIC_HOST].value
# overlays/prod/patch-resources.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello
spec:
  template:
    spec:
      containers:
        - name: hello
          resources:
            requests:
              cpu: 100m
              memory: 64Mi
            limits:
              memory: 128Mi
# overlays/prod/config/app.env
LOG_LEVEL=info

Число реплик в prod приходит из компонента ha. Если бы нужно было только оно, хватило бы поля replicas — короткой формы патча:

replicas:
  - name: hello
    count: 3

kubectl и отдельный kustomize

Две команды kubectl:

  • kubectl kustomize <каталог> — собрать и напечатать результат, ничего не отправляя в кластер. Эквивалент kustomize build <каталог>.
  • kubectl apply -k <каталог> — собрать и применить. Так же работают kubectl diff -k, kubectl delete -k, kubectl get -k.

Внутри kubectl — библиотека Kustomize конкретной версии, и она обновляется вместе с kubectl, то есть раз в четыре месяца и с запаздыванием. В kubectl 1.36 встроен Kustomize v5.8.1, а отдельный бинарь к концу октября 2026 года — уже v5.8.3. Он ставится одним файлом со страницы выпусков проекта на GitHub, выходит чаще и умеет больше: команды kustomize edit (set image, add resource, fix) для правки kustomization.yaml из скриптов, kustomize create, kustomize cfg, kustomize localize. Например, kustomize edit set image переписывает блок images:

$ kustomize edit set image ghcr.io/example/hello:1.0
$ sed -n '/^images/,/^patches/p' kustomization.yaml
images:
- name: ghcr.io/example/hello
  newTag: "1.0"

patches:

Попутно команда переформатирует весь файл (отступы списков, порядок полей), так что в Git после неё дифф больше одной строки. Если в CI собирают отдельным бинарём, а руками проверяют через kubectl, версии стоит выровнять: между версиями бывают различия в выводе, особенно в порядке объектов и обработке устаревших полей.

Helm внутри Kustomize. Поле helmCharts в kustomization.yaml позволяет развернуть Helm-чарт как ресурс и затем пропатчить результат. Оно выключено по умолчанию, потому что запускает внешнюю программу helm: без флага сборка останавливается с ошибкой must specify --enable-helm. Флаг есть у kustomize build и kubectl kustomize (а с ним --helm-command, если helm лежит не в PATH), у kubectl apply -k его нет, поэтому собирают в два шага: kubectl kustomize --enable-helm каталог | kubectl apply -f -. Скачанный чарт Kustomize складывает в подкаталог charts/ рядом с kustomization.yaml и в следующий раз берёт оттуда. Обратная связка — Kustomize как пост-обработчик вывода Helm — разобрана в части 27.

Ограничение загрузки. По умолчанию кастомизация не может читать файлы выше своего каталога, кроме как через resources других кастомизаций (LoadRestrictionsRootOnly). Поэтому configMapGenerator в overlay не может сослаться на ../../base/config/app.env напрямую — сборка остановится с ошибкой вида security; file '…/hello/base/config/app.env' is not in or below '…/hello/overlays/prod'. Флаг --load-restrictor LoadRestrictionsNone снимает ограничение, но ломает переносимость каталога; правильнее держать файл рядом с тем kustomization.yaml, который его использует.


Смотрим руками

Версия встроенного Kustomize:

$ kubectl version --client
Client Version: v1.36.5
Kustomize Version: v5.8.1

Собираем dev и смотрим, что получилось:

$ kubectl kustomize hello/overlays/dev
apiVersion: v1
data:
  GREETING: hello
  LOG_LEVEL: debug
kind: ConfigMap
metadata:
  labels:
    app.kubernetes.io/instance: hello-dev
    app.kubernetes.io/part-of: hello
    environment: dev
  name: hello-config-dev-k675bbfg64
  namespace: demo-dev
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app.kubernetes.io/instance: hello-dev
    app.kubernetes.io/part-of: hello
    environment: dev
  name: hello-dev
  namespace: demo-dev
spec:
  ports:
  - name: http
    port: 80
    targetPort: http
  selector:
    app.kubernetes.io/name: hello
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app.kubernetes.io/instance: hello-dev
    app.kubernetes.io/part-of: hello
    environment: dev
  name: hello-dev
  namespace: demo-dev
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: hello
  template:
    metadata:
      labels:
        app.kubernetes.io/instance: hello-dev
        app.kubernetes.io/name: hello
        app.kubernetes.io/part-of: hello
        environment: dev
    spec:
      containers:
      - env:
        - name: PUBLIC_HOST
          value: hello-dev.apps.example.com
        envFrom:
        - configMapRef:
            name: hello-config-dev-k675bbfg64
        image: ghcr.io/example/hello:1.1
        name: hello
…

Видно всё сразу: суффикс -dev у всех имён, хэш у ConfigMap и новая ссылка на него в envFrom, неймспейс, тег 1.1 вместо 1.0, метки в metadata и в шаблоне пода, но не в selector. Объекты выводятся в определённом порядке: сначала Namespace, CRD, ServiceAccount, роли, ConfigMap и Secret, потом Service, потом рабочие нагрузки, а всё остальное (PodDisruptionBudget, Ingress) — после них, вебхуки — в самом конце. Так при применении потока сверху вниз зависимости уже существуют.

Удобный приём — сравнить два окружения целиком:

$ diff <(kubectl kustomize hello/overlays/dev) <(kubectl kustomize hello/overlays/prod) | head -16
4c4
<   LOG_LEVEL: debug
---
>   LOG_LEVEL: info
8c8
<     app.kubernetes.io/instance: hello-dev
---
>     app.kubernetes.io/instance: hello-prod
10,12c10,12
<     environment: dev
<   name: hello-config-dev-k675bbfg64
<   namespace: demo-dev
---
>     environment: prod
>   name: hello-config-758kt8k87t
>   namespace: demo

Применяем dev. Образ hello лежит в частном реестре, поэтому в новом неймспейсе сначала нужен Secret ghcr-pull из части 5, привязанный к ServiceAccount default. Проще всего скопировать его из default, отрезав служебные поля утилитой jq (она разбирает JSON и строит из него новый по выражению):

$ kubectl create namespace demo-dev
namespace/demo-dev created
$ kubectl get secret ghcr-pull -n default -o json \
    | jq '{apiVersion, kind, type, data, metadata: {name: .metadata.name, namespace: "demo-dev"}}' \
    | kubectl apply -f -
secret/ghcr-pull created
$ kubectl -n demo-dev patch serviceaccount default -p '{"imagePullSecrets":[{"name":"ghcr-pull"}]}'
serviceaccount/default patched

$ kubectl apply -k hello/overlays/dev
configmap/hello-config-dev-k675bbfg64 created
service/hello-dev created
deployment.apps/hello-dev created
ingress.networking.k8s.io/hello-dev created

$ kubectl -n demo-dev get deploy,rs,cm -l app.kubernetes.io/part-of=hello
NAME                        READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/hello-dev   1/1     1            1           20s

NAME                                  DESIRED   CURRENT   READY   AGE
replicaset.apps/hello-dev-b5554cfcd   1         1         1       20s

NAME                                    DATA   AGE
configmap/hello-config-dev-k675bbfg64   2      20s

Объекты, созданные kubectl apply -k, можно и перечислить по той же кастомизации: kubectl get -k hello/overlays/dev соберёт её и покажет живые объекты с этими именами.

Меняем конфигурацию: LOG_LEVEL=warn в base/config/app.env. Сначала смотрим, что изменится, — kubectl diff -k прогоняет результат через server-side dry-run и показывает разницу с живыми объектами:

$ kubectl diff -k hello/overlays/dev
diff -u -N /tmp/LIVE-2213282542/apps.v1.Deployment.demo-dev.hello-dev /tmp/MERGED-2826293843/apps.v1.Deployment.demo-dev.hello-dev
--- /tmp/LIVE-2213282542/apps.v1.Deployment.demo-dev.hello-dev	2026-10-31 04:37:55.015820213 +0000
+++ /tmp/MERGED-2826293843/apps.v1.Deployment.demo-dev.hello-dev	2026-10-31 04:37:55.015820213 +0000
@@ -6,7 +6,7 @@
     kubectl.kubernetes.io/last-applied-configuration: |
       {"apiVersion":"apps/v1","kind":"Deployment",…}
   creationTimestamp: "2026-10-31T04:37:30Z"
-  generation: 1
+  generation: 2
   labels:
     app.kubernetes.io/instance: hello-dev
     app.kubernetes.io/part-of: hello
@@ -41,7 +41,7 @@
           value: hello-dev.apps.example.com
         envFrom:
         - configMapRef:
-            name: hello-config-dev-k675bbfg64
+            name: hello-config-dev-bg9h65b2f2
         image: ghcr.io/example/hello:1.1
         imagePullPolicy: IfNotPresent
         name: hello
diff -u -N /tmp/LIVE-2213282542/v1.ConfigMap.demo-dev.hello-config-dev-bg9h65b2f2 /tmp/MERGED-2826293843/v1.ConfigMap.demo-dev.hello-config-dev-bg9h65b2f2
--- /tmp/LIVE-2213282542/v1.ConfigMap.demo-dev.hello-config-dev-bg9h65b2f2	2026-10-31 04:37:54.940817253 +0000
+++ /tmp/MERGED-2826293843/v1.ConfigMap.demo-dev.hello-config-dev-bg9h65b2f2	2026-10-31 04:37:54.940817253 +0000
@@ -0,0 +1,14 @@
+apiVersion: v1
+data:
+  GREETING: hello
+  LOG_LEVEL: warn
+kind: ConfigMap
…

Одна строка в шаблоне пода (плюс generation, который сервер увеличит при изменении spec) и новый ConfigMap целиком — для diff он «появляется из ничего». Код возврата kubectl diff — 1, когда разница есть, и 0, когда нет: на этом строят проверки в CI. Применяем и смотрим, как Deployment отреагировал:

$ kubectl apply -k hello/overlays/dev
configmap/hello-config-dev-bg9h65b2f2 created
service/hello-dev unchanged
deployment.apps/hello-dev configured
ingress.networking.k8s.io/hello-dev unchanged

$ kubectl -n demo-dev rollout status deploy/hello-dev
Waiting for deployment spec update to be observed...
Waiting for deployment spec update to be observed...
Waiting for deployment "hello-dev" rollout to finish: 0 out of 1 new replicas have been updated...
Waiting for deployment "hello-dev" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "hello-dev" rollout to finish: 1 old replicas are pending termination...
deployment "hello-dev" successfully rolled out

$ kubectl -n demo-dev get rs,cm -l app.kubernetes.io/part-of=hello
NAME                                   DESIRED   CURRENT   READY   AGE
replicaset.apps/hello-dev-7df889c657   1         1         1       6s
replicaset.apps/hello-dev-b5554cfcd    0         0         0       37s

NAME                                    DATA   AGE
configmap/hello-config-dev-bg9h65b2f2   2      6s
configmap/hello-config-dev-k675bbfg64   2      37s

Новый ReplicaSet, под пересоздан без участия Reloader, старый ConfigMap на месте — на нём держится откат. Если старые версии больше не нужны:

$ kubectl -n demo-dev delete cm hello-config-dev-k675bbfg64
configmap "hello-config-dev-k675bbfg64" deleted from demo-dev namespace

Теперь то, чем опасен commonLabels. Добавим в overlay dev устаревшее поле с новой меткой и применим:

$ printf 'commonLabels:\n  team: web\n' >> hello/overlays/dev/kustomization.yaml
$ kubectl apply -k hello/overlays/dev
# Warning: 'commonLabels' is deprecated. Please use 'labels' instead. Run 'kustomize edit fix' to update your Kustomization automatically.
configmap/hello-config-dev-bg9h65b2f2 configured
service/hello-dev configured
ingress.networking.k8s.io/hello-dev configured
The Deployment "hello-dev" is invalid: spec.selector: Invalid value: {"matchLabels":{"app.kubernetes.io/name":"hello","team":"web"}}: field is immutable
$ kubectl -n demo-dev get endpointslice -l kubernetes.io/service-name=hello-dev
NAME              ADDRESSTYPE   PORTS     ENDPOINTS   AGE
hello-dev-6rxtn   IPv4          <unset>   <unset>     71s

Deployment отвергнут, но ConfigMap, Service и Ingress уже применены: kubectl apply не транзакция, а последовательность отдельных запросов. У Service в селекторе теперь team: web, у подов такой метки нет, и в EndpointSlice не осталось ни одного адреса — приложение перестало получать трафик, хотя поды работают как прежде. Обратите внимание и на имя ConfigMap: хэш не изменился, метки в него не входят. Возвращаем kustomization.yaml как было, применяем снова — и адрес пода возвращается в EndpointSlice:

$ kubectl -n demo-dev get endpointslice -l kubernetes.io/service-name=hello-dev
NAME              ADDRESSTYPE   PORTS   ENDPOINTS      AGE
hello-dev-6rxtn   IPv4          8080    172.20.211.3   72s

Prod с компонентом и replacements:

$ kubectl kustomize hello/overlays/prod | grep -E 'replicas|image:|PUBLIC_HOST' -A1
  replicas: 3
  selector:
--
        - name: PUBLIC_HOST
          value: hello.apps.example.com
--
        image: ghcr.io/example/hello@sha256:2d8774f801594f2b1b115963e54ec54b1b49f95c2dece1d6b0fd1e59ab447c7b
        name: hello

Три реплики из компонента, образ по дайджесту, хост из уже исправленного Ingress. Применяем prod (объекты hello, созданные в demo руками в прошлых частях, перед этим удалены, чтобы старые метки и селекторы не мешали) и смотрим, что знает о Kustomize кластер, — ничего, кроме того, что kubectl записал как менеджера полей:

$ kubectl apply -k hello/overlays/prod
configmap/hello-config-758kt8k87t created
service/hello created
deployment.apps/hello created
poddisruptionbudget.policy/hello created
ingress.networking.k8s.io/hello created

$ kubectl -n demo get deploy hello -o jsonpath='{.metadata.managedFields[*].manager}'
kubectl-client-side-apply kube-controller-manager

Никаких объектов «кастомизации» и аннотаций с её происхождением по умолчанию нет. Если хочется видеть, из какого файла пришёл объект, в kustomization.yaml добавляют buildMetadata: [originAnnotations], и Kustomize проставит аннотацию config.kubernetes.io/origin с путём к исходному файлу.


Когда Kustomize, а когда нет

Kustomize хорош, когда у вас свои манифесты и несколько окружений с предсказуемыми отличиями: всё остаётся обычным YAML, ревью диффа в Git показывает именно то изменение, которое уйдёт в кластер, а сборка детерминирована и не требует ничего, кроме kubectl. Он же удобен как «последняя миля» поверх чужого набора манифестов: взять официальный YAML какого-нибудь компонента ресурсом по URL с тегом и наложить свои патчи, не форкая его.

Неудобен он там, где нужна логика: «если включён мониторинг — создай ServiceMonitor» (объект, которым Prometheus Operator описывает, откуда собирать метрики, — часть 32), «для каждого элемента списка — отдельный Ingress», параметры, которые потребитель задаёт, не зная устройства манифестов. Это ниша шаблонов, и для распространения приложения людям, которые не будут читать его манифесты, сложилась своя упаковка — чарт.


Источники


Дальше — Helm: чарты, шаблоны и values, релизы и где Helm хранит их историю, установка, обновление и откат, собственный чарт для hello, OCI-реестр для чартов и изменения в Helm 4.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.