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», параметры, которые потребитель задаёт, не зная устройства манифестов. Это ниша шаблонов, и для распространения приложения людям, которые не будут читать его манифесты, сложилась своя упаковка — чарт.
Источники
- Kubernetes: Declarative Management of Kubernetes Objects Using Kustomize —
kubectl kustomize,kubectl apply -k, генераторы, base и overlays. - Kustomize: справочник по kustomization.yaml — все поля; отдельно labels и commonLabels (устарело с v5.0.0), patches, images, configMapGenerator, secretGenerator, generatorOptions, replacements, buildMetadata, helmCharts.
- Kustomize: Components — компоненты и порядок их применения.
- Выпуск kustomize v5.8.3 — отдельный бинарь и контрольные суммы.
- Strategic Merge Patch и Kubernetes: Update API Objects in Place Using kubectl patch — ключи слияния списков, директива
$patch. - RFC 6902: JSON Patch и RFC 6901: JSON Pointer — операции и экранирование
~0,~1. - Kubernetes: ConfigMaps — Immutable ConfigMaps — неизменяемые ConfigMap и нагрузка на API-сервер.
- Kubernetes: Declarative Management of Kubernetes Objects Using Configuration Files —
last-applied-configuration,--prune; kubectl diff и kubectl kustomize — флаги и коды возврата. - SOPS, KSOPS и External Secrets Operator — секреты вне открытого текста в Git.
Дальше — Helm: чарты, шаблоны и values, релизы и где Helm хранит их историю, установка, обновление и откат, собственный чарт для hello, OCI-реестр для чартов и изменения в Helm 4.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.