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

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

В части 26 мы разложили манифесты hello на базу и наложения и собирали окружения Kustomize, не меняя сами манифесты. Это хорошо работает для своих приложений, но у Kustomize нет понятия «параметры»: тот, кто ставит приложение, должен знать его манифесты, чтобы их патчить. Helm решает другую задачу — упаковать приложение так, чтобы его можно было поставить одной командой, задав десяток понятных параметров, обновить, откатить и удалить целиком. Драйверы csi-driver-nfs в части 23 и csi-driver-smb в части 24 мы ставили манифестами выпуска, а их Helm-чарты только упомянули одной фразой. Сегодня разберёмся: что такое чарт, как работает шаблонизатор, где Helm хранит состояние, что изменилось в Helm 4, и напишем полный чарт для hello.


Helm 4

Helm — проект CNCF (фонда Cloud Native Computing Foundation, под которым живут Kubernetes, containerd, etcd и другие), утилита командной строки без серверной части. Вторая версия ставила в кластер компонент Tiller с широкими правами; с третьей его нет, и helm работает от имени пользователя с его kubeconfig и его правами RBAC (часть 29) — ровно как kubectl.

В ноябре 2025 года вышел Helm 4, первая мажорная версия за шесть лет. На стенде — актуальная 4.3.1 (выпуск 15 октября 2026 года; ветка Helm 3 ещё получает исправления, последняя — 3.22.1). Чарты прежнего формата (apiVersion: v2) работают без изменений, а различия в основном в поведении команд:

Что Helm 3 Helm 4
Как изменения попадают в кластер client-side: Helm сам вычисляет патч из старого манифеста, нового и живого объекта server-side apply для новых релизов (флаг --server-side, по умолчанию auto: новые установки — SSA, обновления — тем методом, каким релиз ставился раньше)
Откат при неудаче --atomic --rollback-on-failure (старое имя работает с предупреждением)
Пересоздание объектов --force --force-replace (старое имя работает с предупреждением)
Ожидание готовности --wait: свои проверки по типам объектов --wait принимает стратегию: watcher (по умолчанию при --wait, через kstatus), legacy (как в Helm 3), hookOnly (по умолчанию без флага — ждать только хуки)
Пост-обработка --post-renderer — путь к любой программе --post-renderer — имя плагина типа post-renderer
Плагины только внешние программы плюс плагины на WebAssembly (переносимый байт-код, исполняемый в изолированной среде); установка проверяет подпись
helm registry login принимал URL принимает только имя хоста: на https://… — invalid registry

kstatus — библиотека проекта Kubernetes SIGs, которая по полям status и metadata.generation любого объекта (включая пользовательские) определяет, дошёл ли он до желаемого состояния: Deployment — когда все реплики обновлены и доступны, Job — когда завершился, объект CRD — по условию Ready в status.conditions. Старая проверка Helm 3 знала только встроенные типы.

Релизы, поставленные Helm 3, Helm 4 читает и обновляет: формат хранения тот же. Но они остаются на client-side apply, пока при обновлении явно не передать --server-side=true: метод записан в самом релизе (поле apply_method, у новых установок — ssa), и auto берёт его оттуда. Старые имена флагов работают с предупреждением вида Flag --atomic has been deprecated, use --rollback-on-failure instead.


Чарт

Чарт — каталог (или архив .tgz этого каталога) определённой структуры:

hello/
├── Chart.yaml            ← имя, версия, зависимости
├── Chart.lock            ← точные версии зависимостей (создаёт helm dependency update)
├── values.yaml           ← параметры по умолчанию
├── values.schema.json    ← JSON Schema для проверки параметров (необязательно)
├── .helmignore           ← что не класть в архив
├── charts/               ← скачанные зависимости-подчарты
├── crds/                 ← определения CRD (особый случай, ниже)
└── templates/
    ├── _helpers.tpl      ← именованные шаблоны, сами объектов не создают
    ├── NOTES.txt         ← текст, который печатается после установки
    ├── deployment.yaml
    ├── service.yaml
    ├── ...
    └── tests/
        └── test-connection.yaml

Chart.yaml описывает чарт:

apiVersion: v2              # формат чарта Helm 3 и 4
name: hello
description: Учебное HTTP-приложение
type: application           # или library — только именованные шаблоны для других чартов
version: 0.1.0              # версия ЧАРТА, SemVer (MAJOR.MINOR.PATCH); меняется при любой правке чарта
appVersion: "1.0"           # версия ПРИЛОЖЕНИЯ внутри, информационно
kubeVersion: ">=1.33.0-0"   # на каких кластерах чарт согласен ставиться
dependencies:
  - name: redis
    version: "~1.4.0"
    repository: oci://ghcr.io/example/charts
    condition: redis.enabled

Две версии — частый источник путаницы. version — версия упаковки: поменяли шаблон или значение по умолчанию — подняли version, иначе реестр не примет новый архив под тем же номером, а у пользователей окажется два разных чарта 0.1.0. appVersion — версия программы внутри, по соглашению используется как тег образа по умолчанию. kubeVersion проверяется до рендеринга: на кластере старше требуемого Helm откажется работать сразу — Error: chart requires kubeVersion: >=1.33.0-0 which is incompatible with Kubernetes v1.32.0.

Зависимости — другие чарты, которые ставятся вместе с этим как его часть (подчарты). Пример выше — гипотетический чарт Redis (хранилища «ключ-значение» в памяти) из нашего реестра, включаемый параметром redis.enabled. helm dependency update скачивает их в charts/ и записывает точные версии и дайджесты в Chart.lock; helm dependency build восстанавливает charts/ строго по Chart.lock. Параметры подчарта задаются в values.yaml родителя под ключом с его именем (redis:), а ключ global: виден всем подчартам.

Начать новый чарт проще всего с заготовки helm create hello: она создаёт рабочий чарт для nginx со всеми типичными шаблонами, который потом правят под себя. Мы напишем чарт с нуля, чтобы каждая строка была понятна.


Шаблоны

Файлы в templates/ — шаблоны языка Go text/template (стандартный шаблонизатор Go: подстановки в {{ }}, условия, циклы, переменные) с библиотекой функций Sprig (около сотни функций для строк, списков, словарей, дат, хэшей) и несколькими функциями самого Helm. Helm прогоняет каждый файл через шаблонизатор, склеивает результат, разбирает как YAML и отправляет в API.

В шаблоне доступны встроенные объекты:

Объект Что содержит
.Values итоговые параметры (после слияния всех источников)
.Release.Name, .Release.Namespace имя релиза и неймспейс установки
.Release.IsInstall, .Release.IsUpgrade, .Release.Revision что сейчас происходит и номер ревизии
.Chart.Name, .Chart.Version, .Chart.AppVersion поля Chart.yaml
.Capabilities.KubeVersion, .Capabilities.APIVersions версия кластера и доступные API (для helm template — заданные флагами)
.Files доступ к прочим файлам чарта (.Files.Get "config/app.conf")
.Template.Name, .Template.BasePath путь текущего шаблона

Синтаксис, который встречается постоянно:

{{ .Values.replicaCount }}                     подстановка
{{ .Values.image.tag | default .Chart.AppVersion }}   конвейер: значение по умолчанию
{{ .Values.host | quote }}                     в кавычках
{{- if .Values.ingress.enabled }} ... {{- end }}      условие; "-" съедает пробелы и перевод строки
{{- range .Values.extraEnv }} ... {{- end }}          цикл
{{- with .Values.nodeSelector }} ... {{- end }}       сменить "." на значение, если оно не пустое
{{ include "hello.labels" . | nindent 4 }}     именованный шаблон с отступом 4 и новой строкой
{{ toYaml .Values.resources | nindent 12 }}    вставить кусок values как YAML
{{ required "host обязателен" .Values.host }}  остановить рендеринг с ошибкой, если пусто

Почти все ошибки в чартах — ошибки отступов: шаблонизатор ничего не знает о YAML и вставляет текст как есть. Поэтому вставку блоков всегда пишут через nindent N (перевод строки плюс N пробелов), а результат проверяют helm template.

include вызывает именованный шаблон — блок {{ define "имя" }}…{{ end }}, по соглашению лежащий в _helpers.tpl (файлы, начинающиеся с _, Helm не выводит как манифесты). В отличие от встроенного в Go template, результат include можно передать дальше по конвейеру, например в nindent, поэтому в чартах используют только его.


Свой чарт для hello

Чарт целиком. Сначала параметры:

# hello/values.yaml
replicaCount: 2

image:
  repository: ghcr.io/example/hello
  tag: ""                 # пусто — берётся appVersion из Chart.yaml
  digest: ""              # если задан, важнее тега
  pullPolicy: IfNotPresent

imagePullSecrets: []

config:
  LOG_LEVEL: info
  GREETING: hello

service:
  port: 80

ingress:
  enabled: true
  className: traefik
  host: hello.apps.example.com
  clusterIssuer: letsencrypt-prod

resources:
  requests:
    cpu: 50m
    memory: 32Mi
  limits:
    memory: 64Mi

podDisruptionBudget:
  enabled: true
  minAvailable: 1

nodeSelector: {}
tolerations: []

Именованные шаблоны: полное имя объектов и наборы меток. Метки app.kubernetes.io/* — рекомендованные Kubernetes; селектор строится только из неизменных (name и instance), чтобы обновление чарта никогда не меняло неизменяемый селектор Deployment.

{{/* hello/templates/_helpers.tpl */}}

{{- define "hello.fullname" -}}
{{- if contains .Chart.Name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}

{{- define "hello.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}

{{- define "hello.labels" -}}
{{ include "hello.selectorLabels" . }}
app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
{{- end -}}

{{- define "hello.image" -}}
{{- if .Values.image.digest -}}
{{ .Values.image.repository }}@{{ .Values.image.digest }}
{{- else -}}
{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}
{{- end -}}
{{- end -}}

trunc 63 — потому что имя объекта, попадающее в метки и DNS, ограничено 63 символами. Проверка contains избавляет от имён вида hello-hello, когда релиз назван так же, как чарт.

# hello/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "hello.fullname" . }}
  labels:
    {{- include "hello.labels" . | nindent 4 }}
data:
  {{- range $key, $value := .Values.config }}
  {{ $key }}: {{ $value | quote }}
  {{- end }}
# hello/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "hello.fullname" . }}
  labels:
    {{- include "hello.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "hello.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "hello.labels" . | nindent 8 }}
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
    spec:
      {{- with .Values.imagePullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      automountServiceAccountToken: false
      securityContext:
        runAsNonRoot: true
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: hello
          image: {{ include "hello.image" . | quote }}
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: 8080
          envFrom:
            - configMapRef:
                name: {{ include "hello.fullname" . }}
          readinessProbe:
            httpGet:
              path: /healthz
              port: http
          livenessProbe:
            httpGet:
              path: /healthz
              port: http
            periodSeconds: 20
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
      {{- with .Values.nodeSelector }}
      nodeSelector:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.tolerations }}
      tolerations:
        {{- toYaml . | nindent 8 }}
      {{- end }}

Аннотация checksum/config — тот же приём, что хэш в имени ConfigMap у Kustomize, только иначе: в шаблон пода кладётся SHA-256 от отрендеренного ConfigMap. Поменялся параметр в config — поменялась аннотация — поменялся шаблон пода — Deployment выкатил новые поды. Поля securityContext подробно разберём в части 30, automountServiceAccountToken: false — в части 29: приложению не нужен доступ к API, и токен в под не монтируется.

# hello/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: {{ include "hello.fullname" . }}
  labels:
    {{- include "hello.labels" . | nindent 4 }}
spec:
  selector:
    {{- include "hello.selectorLabels" . | nindent 4 }}
  ports:
    - name: http
      port: {{ .Values.service.port }}
      targetPort: http
# hello/templates/ingress.yaml
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ include "hello.fullname" . }}
  labels:
    {{- include "hello.labels" . | nindent 4 }}
  annotations:
    cert-manager.io/cluster-issuer: {{ .Values.ingress.clusterIssuer | quote }}
spec:
  ingressClassName: {{ .Values.ingress.className }}
  rules:
    - host: {{ required "ingress.host обязателен" .Values.ingress.host | quote }}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: {{ include "hello.fullname" . }}
                port:
                  name: http
  tls:
    - hosts:
        - {{ .Values.ingress.host | quote }}
      secretName: {{ include "hello.fullname" . }}-tls
{{- end }}
# hello/templates/pdb.yaml
{{- if and .Values.podDisruptionBudget.enabled (gt (int .Values.replicaCount) 1) }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: {{ include "hello.fullname" . }}
  labels:
    {{- include "hello.labels" . | nindent 4 }}
spec:
  minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
  selector:
    matchLabels:
      {{- include "hello.selectorLabels" . | nindent 6 }}
{{- end }}

PodDisruptionBudget создаётся только при двух репликах и больше: с одной репликой и minAvailable: 1 он запретил бы kubectl drain узла навсегда (часть 13).

# hello/templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
  name: {{ include "hello.fullname" . }}-test
  labels:
    {{- include "hello.labels" . | nindent 4 }}
  annotations:
    helm.sh/hook: test
    helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
spec:
  restartPolicy: Never
  automountServiceAccountToken: false
  securityContext:
    runAsNonRoot: true
    runAsUser: 65534
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: wget
      image: busybox:1.37
      command: ["wget", "-qO-", "http://{{ include "hello.fullname" . }}:{{ .Values.service.port }}/healthz"]
      securityContext:
        allowPrivilegeEscalation: false
        capabilities:
          drop: ["ALL"]
{{/* hello/templates/NOTES.txt */}}
hello {{ .Chart.AppVersion }} установлен как релиз {{ .Release.Name }} в {{ .Release.Namespace }}.
{{- if .Values.ingress.enabled }}
Адрес: https://{{ .Values.ingress.host }}/
{{- else }}
Проверка: kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "hello.fullname" . }} 8080:{{ .Values.service.port }}
{{- end }}

И схема параметров — она проверяется при install, upgrade, template и lint, и опечатка в -f или --set даёт понятную ошибку вместо молча проигнорированного ключа:

{
  "$schema": "https://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["image", "ingress"],
  "properties": {
    "replicaCount": { "type": "integer", "minimum": 1 },
    "image": {
      "type": "object",
      "required": ["repository"],
      "properties": {
        "repository": { "type": "string" },
        "tag": { "type": "string" },
        "digest": { "type": "string", "pattern": "^(sha256:[a-f0-9]{64})?$" }
      }
    },
    "ingress": {
      "type": "object",
      "properties": {
        "enabled": { "type": "boolean" },
        "host": { "type": "string" }
      }
    }
  }
}

Откуда берутся значения

Итоговые .Values собираются из нескольких источников, каждый следующий перекрывает предыдущий:

 values.yaml чарта (и подчартов)
          │
          ▼
 -f / --values файл1, файл2 ...      ← по порядку в командной строке
          │
          ▼
 --set, --set-string, --set-json, --set-file   ← точечные правки
          │
          ▼
 .Values в шаблонах

Словари сливаются по ключам, списки заменяются целиком. На практике значения окружения держат в файлах — values-dev.yaml, values-prod.yaml в Git рядом с описанием развёртывания, — а --set оставляют для разовых правок вроде --set image.tag=1.1 из CI. Причина простая: --set не оставляет следа, кроме самого релиза, и через месяц никто не вспомнит, почему в prod три реплики.

У --set своя грамматика: точка — вложенность (ingress.host=…), запятая — разделитель пар, индекс в квадратных скобках — элемент списка (tolerations[0].key=…). Значения приводятся к типам: true и false становятся логическими, целые числа — числами, а остальное остаётся строкой. Поэтому --set image.tag=1.10 даст строку "1.10", а --set image.tag=2 — число 2, и наша схема (о ней ниже) остановит установку:

$ helm template hello ./hello -f values-prod.yaml --set image.tag=2
Error: values don't meet the specifications of the schema(s) in the following chart(s):
hello:
- at '/image/tag': got number, want string

Без схемы число молча ушло бы в шаблон. Поэтому теги и всё, что должно остаться строкой, передают через --set-string.

При helm upgrade по умолчанию берутся значения чарта плюс то, что передано в этой команде; значения прошлого релиза не переносятся. Флаг --reuse-values берёт прошлые значения пользователя вместо значений чарта и тем самым игнорирует новые умолчания из обновлённого чарта, а --reset-then-reuse-values берёт значения нового чарта и накладывает поверх прошлые пользовательские. Надёжнее всего не полагаться ни на то, ни на другое и всегда передавать полный набор файлов.


Релиз и где Helm хранит состояние

Релиз — установленный экземпляр чарта с именем, уникальным в неймспейсе. Один чарт можно поставить много раз под разными именами. Каждое install, upgrade и rollback создаёт новую ревизию релиза.

Helm не держит своей базы данных и сервера: состояние лежит в самом кластере, по умолчанию — в Secret в неймспейсе релиза, по одному на ревизию. Имя — sh.helm.release.v1.<релиз>.v<ревизия>, тип — helm.sh/release.v1. Внутри поле release: JSON с описанием релиза, сжатый gzip и закодированный в base64 (а API добавляет ко всем данным Secret свой base64). В JSON лежит всё, что нужно для отката без исходного чарта: шаблоны, values.yaml и схема чарта (chart), пользовательские значения (config), отрендеренный манифест (manifest), хуки (hooks), статус и время (info) и способ применения (apply_method).

 helm upgrade hello ./hello -f values-prod.yaml
     │
     ├─ 1. прочитать текущую ревизию из Secret sh.helm.release.v1.hello.v3
     ├─ 2. собрать values, отрендерить шаблоны
     ├─ 3. Secret ...v4 со статусом pending-upgrade
     ├─ 4. хуки pre-upgrade
     ├─ 5. применить объекты (SSA: PATCH application/apply-patch+yaml,
     │     fieldManager=helm; объекты, исчезнувшие из манифеста, — удалить)
     ├─ 6. --wait: ждать готовности (kstatus)
     ├─ 7. хуки post-upgrade
     └─ 8. v4 → deployed, v3 → superseded

Последствия этого устройства:

  • Права на релиз — это права на Secret в неймспейсе. Кто может читать Secret, видит все значения релиза, включая пароли, если их передавали через values.
  • Хранится не больше 10 ревизий на релиз (флаг --history-max), старшие удаляются.
  • Если helm прервали на середине (закрыли ноутбук, упала сеть), релиз остаётся в состоянии pending-install или pending-upgrade, и следующая операция откажется работать с ошибкой UPGRADE FAILED: another operation (install/upgrade/rollback) is in progress. Лечится helm rollback на последнюю удачную ревизию. На стенде helm upgrade --wait убит через 5 секунд — в истории осталась ревизия pending-upgrade с описанием Preparing upgrade, следующий upgrade получил эту ошибку, а helm rollback прошёл и записал новую ревизию deployed.
  • Удалив Secret релиза, вы «забудете» релиз, но объекты в кластере останутся. Это иногда используют для передачи объектов другому релизу; для этого с Helm 3.17 есть флаг --take-ownership, который забирает существующие объекты без удаления.

Каждому объекту Helm ставит метку app.kubernetes.io/managed-by: Helm и аннотации meta.helm.sh/release-name и meta.helm.sh/release-namespace. По ним он отказывается трогать объект с тем же именем, принадлежащий другому релизу или созданный руками.


Установка, обновление, откат, удаление

helm install   hello ./hello -n demo -f values-prod.yaml          установить
helm upgrade   hello ./hello -n demo -f values-prod.yaml          обновить
helm upgrade --install hello ./hello -n demo -f values-prod.yaml  установить или обновить (для CI)
helm rollback  hello 2 -n demo                                    вернуться к ревизии 2 (создаст новую)
helm uninstall hello -n demo                                      удалить объекты и историю

Полезные флаги:

  • --wait — не возвращать управление, пока объекты не станут готовы, не дольше --timeout (по умолчанию 5 минут). Без него Helm считает установку успешной, как только API принял объекты, даже если поды не стартуют.
  • --rollback-on-failure (бывший --atomic) — при неудаче откатить: на install — удалить установленное, на upgrade — вернуть предыдущую ревизию. Включает --wait сам.
  • --cleanup-on-fail — при неудачном upgrade удалить объекты, созданные этим обновлением.
  • --create-namespace — создать неймспейс, если его нет. Удобно, но неймспейс тогда не принадлежит релизу и не получит ваших меток Pod Security (часть 30) — лучше создавать его отдельно.
  • --version — версия чарта из репозитория; без неё берётся последняя, что для рабочего окружения плохая идея.
  • --dry-run=server — всё, кроме записи: шаблоны рендерятся, объекты проходят валидацию и admission на API-сервере.

Откат в Helm — не «отмена»: helm rollback hello 2 берёт манифест ревизии 2, применяет его и записывает как новую ревизию. История линейна и показывает, что произошло. helm uninstall удаляет объекты релиза (кроме помеченных аннотацией helm.sh/resource-policy: keep, ресурсов хуков и CRD) и Secret-ы истории; --keep-history оставит историю, и релиз потом можно будет откатить.

Про server-side apply в Helm 4 важно одно следствие. Helm записывается владельцем (fieldManager) полей, которые есть в чарте. Если кто-то поменял такое поле руками через kubectl edit или kubectl scale, при следующем helm upgrade API-сервер вернёт конфликт владельцев полей, и Helm остановится:

$ kubectl -n demo scale deploy hello --replicas=4
deployment.apps/hello scaled
$ helm upgrade hello ./hello -n demo -f values-prod.yaml --set-string image.tag=1.1
level=WARN msg="upgrade failed" name=hello error="conflict occurred while applying object demo/hello apps/v1, Kind=Deployment: Apply failed with 1 conflict: conflict with \"kubectl\" with subresource \"scale\" using apps/v1: .spec.replicas"
Error: UPGRADE FAILED: conflict occurred while applying object demo/hello apps/v1, Kind=Deployment: Apply failed with 1 conflict: conflict with "kubectl" with subresource "scale" using apps/v1: .spec.replicas

В истории релиза при этом появляется ревизия со статусом failed. --force-conflicts забирает поля себе (повтор с ним вернул replicas: 3 из values); правильнее разобраться, кто их меняет. Поля, которых в чарте нет, — например replicas, если им управляет автомасштабирование, — Helm больше не трогает вовсе, и это удобнее трёхстороннего слияния Helm 3, где такие поля приходилось специально убирать из шаблонов.


Проверка до применения: lint, template, diff

helm lint ./hello проверяет структуру чарта, синтаксис шаблонов и схему значений. helm template рендерит чарт локально и печатает манифесты, не обращаясь к кластеру:

$ helm template hello ./hello -n demo -f values-prod.yaml --kube-version 1.36.0

Из-за этого функция lookup (чтение живого объекта из кластера) в helm template возвращает пустоту, а .Capabilities заполняется умолчаниями или флагами --kube-version и --api-versions. Вывод helm template можно отдать kubectl apply -f - или положить в Git, и это вполне рабочая схема для тех, кто хочет только шаблонизатор без хранения релизов.

Для «что изменится в кластере» нужен плагин helm-diff: он рендерит новую версию и сравнивает её с манифестом текущего релиза (или, с --three-way-merge, с живыми объектами, чтобы увидеть и ручные правки). В Helm 4 плагины ставятся с проверкой подписи, и автор helm-diff публикует подписанные архивы (рядом с каждым — файл .prov) и свой открытый ключ pubkey.asc. Проверка идёт по связке ключей GnuPG (свободной реализации стандарта подписи и шифрования OpenPGP), и без неё установка не пройдёт:

$ helm plugin install https://github.com/databus23/helm-diff/releases/latest/download/helm-diff-linux-amd64.tgz
Verifying plugin signature...
Error: plugin verification failed: open /home/user/.gnupg/pubring.gpg: no such file or directory

Ключ автора скачивается со страницы того же выпуска и переводится из текстового вида в двоичную связку (gpg --dearmor), которую Helm умеет читать; gpg --show-keys перед этим покажет, чей это ключ:

$ curl -sSLO https://github.com/databus23/helm-diff/releases/latest/download/pubkey.asc
$ gpg --show-keys pubkey.asc | head -3
pub   rsa4096 2026-06-15 [SC] [expires: 2028-06-14]
      C5645EF47482257A1F806D2BEA17A2A206AFF8CD
uid                      Fabian Ruff (helm-diff release key) <fabian@progra.de>
$ gpg --dearmor < pubkey.asc > helm-diff.gpg
$ helm plugin install https://github.com/databus23/helm-diff/releases/latest/download/helm-diff-linux-amd64.tgz --keyring ./helm-diff.gpg
Verifying plugin signature...
Using Key With Fingerprint: C5645EF47482257A1F806D2BEA17A2A206AFF8CD
Plugin Hash Verified: sha256:504e99410611daa70702362e1b10dc74929df8a912cb3f6b5d21d1ee9d0cb7d9
Installed plugin: diff
$ helm plugin list
NAME    VERSION  TYPE    APIVERSION  PROVENANCE  SOURCE
diff    3.15.15  cli/v1  legacy      unknown     unknown

--verify=false пропускает проверку, но тогда в Helm попадает непроверенный исполняемый файл. Сравниваем релиз на 1.0 с обновлением до 1.1 (вывод сокращён до Deployment):

$ helm diff upgrade hello ./hello -n demo -f values-prod.yaml --set-string image.tag=1.1
…
demo, hello, Deployment (apps) has changed:
  # Source: hello/templates/deployment.yaml
  apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: hello
    labels:
      app.kubernetes.io/name: hello
      app.kubernetes.io/instance: hello
-     app.kubernetes.io/version: "1.0"
+     app.kubernetes.io/version: "1.1"
      app.kubernetes.io/managed-by: Helm
      helm.sh/chart: hello-0.1.0
…
        annotations:
-         checksum/config: ab2f9bbf23579c4ac7887de51dac11673fb2c6513f1355444e4e379dd4c8dc7d
+         checksum/config: 3d144dda669f1e95d4553b25f616f923469148ecc258d833e75c808fe995f7d6
…
-           image: "ghcr.io/example/hello:1.0"
+           image: "ghcr.io/example/hello:1.1"
…

Метка app.kubernetes.io/version меняется у всех пяти объектов чарта, и вместе с ней — checksum/config: хэш считается от всего отрендеренного ConfigMap, включая метки, а не только от data. Здесь это не страшно, поды и так перезапускаются из-за нового образа, но если в метки ConfigMap попадает что-то часто меняющееся, поды будут перезапускаться без изменения конфигурации. Перед любым helm upgrade рабочего релиза helm diff upgrade с теми же аргументами — хорошая привычка.


Хуки и тесты

Хук — объект из templates/ с аннотацией helm.sh/hook. Helm не применяет его вместе с остальными, а создаёт в определённый момент жизненного цикла релиза и ждёт его завершения (для Job — успешного окончания, для пода — фазы Succeeded):

Хук Когда
pre-install, post-install до и после создания объектов при установке
pre-upgrade, post-upgrade до и после обновления
pre-rollback, post-rollback до и после отката
pre-delete, post-delete до и после удаления
test только по команде helm test

Типичный пример — миграция схемы базы данных Job-ом в pre-upgrade: если она упала, обновление не начнётся. Порядок нескольких хуков задаёт helm.sh/hook-weight (строка с числом, меньшие первыми), а судьбу объекта после выполнения — helm.sh/hook-delete-policy: before-hook-creation (удалить прошлый экземпляр перед созданием нового, это поведение по умолчанию), hook-succeeded, hook-failed.

Две особенности хуков, о которых узнают на практике. Они не входят в релиз: helm uninstall их не удаляет, а helm diff и helm get manifest их не показывают. И Helm ждёт их по --timeout: хук-Job, застрявший в Pending из-за нехватки ресурсов, держит весь upgrade.

Тесты — хуки test: поды, которые проверяют, что установленное работает. Наш test-connection.yaml делает запрос к /healthz через Service. Запускаются отдельно, после установки:

$ helm test hello -n demo
NAME: hello
LAST DEPLOYED: Sat Oct 31 04:49:07 2026
NAMESPACE: demo
STATUS: deployed
REVISION: 4
DESCRIPTION: Rollback to 2
TEST SUITE:     hello-test
Last Started:   Sat Oct 31 04:49:15 2026
Last Completed: Sat Oct 31 04:49:23 2026
Phase:          Succeeded

Под теста после успеха удалён (hook-succeeded), поэтому kubectl get pod hello-test его уже не найдёт; при неудаче он останется, и его журнал можно прочитать.


Чарты в OCI-реестре

Исторически чарты раздавали репозиториями Helm — HTTP-сервером с файлом index.yaml (список всех чартов и версий) и архивами рядом. Его добавляют командой helm repo add, и так распространяется большинство публичных чартов. Современный способ — хранить чарт в обычном OCI-реестре, том же, что для образов (часть 5): чарт становится OCI-артефактом с манифестом, конфигурацией типа application/vnd.cncf.helm.config.v1+json и одним слоем — архивом чарта. Никакого index.yaml, права и аутентификация — те же, что у образов, версии — теги.

$ helm package ./hello
Successfully packaged chart and saved it to: /home/user/hello-0.1.0.tgz

$ helm registry login ghcr.io -u example
Password:
Login Succeeded

$ helm push hello-0.1.0.tgz oci://ghcr.io/example/charts
Pushed: ghcr.io/example/charts/hello:0.1.0
Digest: sha256:afdb173bddd50518e7fccf77906afcd549376dc53ab384c446f7bd6844babb5e

$ helm install hello oci://ghcr.io/example/charts/hello --version 0.1.0 -n demo -f values-prod.yaml
Pulled: ghcr.io/example/charts/hello:0.1.0
Digest: sha256:afdb173bddd50518e7fccf77906afcd549376dc53ab384c446f7bd6844babb5e
NAME: hello
…

На стенде чарт загружался не в ghcr.io, а во временный реестр CNCF Distribution (реестр образов с открытым кодом, бывший Docker Registry; образ registry:3), запущенный подом в кластере и доступный через kubectl port-forward; к командам там добавлялся флаг --plain-http, потому что этот реестр работал без TLS. Манифест загруженного артефакта, прочитанный из реестра напрямую:

$ curl -s -H 'Accept: application/vnd.oci.image.manifest.v1+json' \
    localhost:5000/v2/charts/hello/manifests/0.1.0 | jq '{config: .config.mediaType, layers: [.layers[].mediaType], annotations}'
{
  "config": "application/vnd.cncf.helm.config.v1+json",
  "layers": [
    "application/vnd.cncf.helm.chart.content.v1.tar+gzip"
  ],
  "annotations": {
    "org.opencontainers.image.created": "2026-10-31T04:52:42Z",
    "org.opencontainers.image.description": "Учебное HTTP-приложение",
    "org.opencontainers.image.title": "hello",
    "org.opencontainers.image.version": "0.1.0"
  }
}

helm push берёт имя и версию из архива, путь в адресе — «каталог» в реестре. Вложенные пути вида ghcr.io/<владелец>/charts/<чарт> GitHub принимает: так, например, публикует свои чарты prometheus-community (helm show chart oci://ghcr.io/prometheus-community/charts/kube-prometheus-stack скачивает его без входа). helm registry login в Helm 4 принимает только имя хоста: на https://ghcr.io или путь в реестре он ответит invalid registry. Учётные данные helm registry login сохраняет в ~/.config/helm/registry/config.json. Пароль здесь — токен GitHub с правом write:packages, тот же, что для загрузки образов в части 5; для CI заводят отдельный токен и хранят его в секретах конвейера, а не используют личный. Целостность и происхождение чарта можно дополнительно подтверждать подписью: helm package --sign создаёт рядом файл .prov, а helm install --verify его проверяет; для OCI-артефактов распространена и подпись Cosign — утилитой проекта Sigstore, которая хранит подпись в том же реестре рядом с артефактом.


Post-renderer: Kustomize поверх Helm

Бывает, что чужой чарт почти подходит, но в нём нет параметра для нужного поля: скажем, метки или toleration для одного DaemonSet или securityContext контейнера. Форкать чарт — значит потом сопровождать форк. Post-renderer — программа, которой Helm отдаёт отрендеренные манифесты на стандартный ввод и забирает результат со стандартного вывода, прежде чем применить; результат же и записывается в релиз. Естественный кандидат на эту роль — Kustomize.

В Helm 3 --post-renderer принимал путь к любой исполняемой программе. В Helm 4 это плагин типа postrenderer/v1. Плагин — каталог с plugin.yaml и скриптом:

# kustomize-pr/plugin.yaml
apiVersion: v1
type: postrenderer/v1
name: kustomize-pr
version: 0.1.0
runtime: subprocess
runtimeConfig:
  platformCommand:
    - command: ${HELM_PLUGIN_DIR}/render.sh
#!/bin/sh
# kustomize-pr/render.sh: $1 — каталог с kustomization.yaml и патчами
set -eu
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
cp -r "$1"/. "$work"/
cat > "$work/helm-output.yaml"
kubectl kustomize "$work"

Каталог с патчами живёт рядом с values окружения, а его kustomization.yaml ссылается на файл, который скрипт создаёт из вывода Helm:

# deploy/post-render/kustomization.yaml
resources:
  - helm-output.yaml
patches:
  - target:
      kind: DaemonSet
      name: csi-nfs-node
    patch: |-
      apiVersion: apps/v1
      kind: DaemonSet
      metadata:
        name: csi-nfs-node
      spec:
        template:
          metadata:
            labels:
              team: storage

Здесь патч добавляет подам DaemonSet метку, для которой в чарте нет параметра; так же добавляют toleration, аннотацию, переменную окружения или ограничение ресурсов.

$ helm plugin install ./kustomize-pr
Installing plugin from local directory (development mode)
Installed plugin: kustomize-pr
$ helm plugin list
NAME          VERSION  TYPE             APIVERSION  PROVENANCE  SOURCE
diff          3.15.15  cli/v1           legacy      unknown     unknown
kustomize-pr  0.1.0    postrenderer/v1  v1          local dev   unknown
$ helm repo add csi-driver-nfs https://raw.githubusercontent.com/kubernetes-csi/csi-driver-nfs/master/charts
"csi-driver-nfs" has been added to your repositories
$ helm upgrade csi-driver-nfs csi-driver-nfs/csi-driver-nfs -n kube-system --version 4.13.6 \
    --post-renderer kustomize-pr --post-renderer-args "$PWD/deploy/post-render"

Тот же post-renderer работает и с helm template, так что результат можно посмотреть, ничего не устанавливая:

$ helm template csi-driver-nfs csi-driver-nfs/csi-driver-nfs -n kube-system --version 4.13.6 \
    --post-renderer kustomize-pr --post-renderer-args "$PWD/deploy/post-render" \
    | sed -n '/^kind: DaemonSet/,/containers:/p' | sed -n '/template:/,$p'
  template:
    metadata:
      labels:
        app: csi-nfs-node
        app.kubernetes.io/instance: csi-driver-nfs
        app.kubernetes.io/managed-by: Helm
        app.kubernetes.io/name: csi-driver-nfs
        app.kubernetes.io/version: 4.13.6
        helm.sh/chart: csi-driver-nfs-4.13.6
        team: storage
    spec:
      containers:

Плагин из локального каталога ставится в режиме разработки без проверки подписи (PROVENANCE local dev); для команды его лучше упаковать и подписать, как любой другой. Два правила при работе с post-renderer. Все, кто обновляет этот релиз, должны вызывать его одинаково: результат post-renderer записывается в манифест релиза, и следующий upgrade без него молча уберёт патч — на стенде метка, добавленная так подам hello, исчезла с них после обычного helm upgrade. И post-renderer видит все манифесты, включая Secret с паролями, поэтому в этой роли используют только то, чему доверяют.


Подводные камни

CRD. Определения пользовательских ресурсов (часть 28) в каталоге crds/ Helm применяет только при первой установке, до шаблонов, без шаблонизации, и никогда не обновляет и не удаляет. Это сознательное решение: удаление CRD удаляет все объекты этого типа во всём кластере, и случайный helm uninstall не должен уничтожать, скажем, все сертификаты cert-manager. Следствие — при обновлении чарта оператора новые поля CRD сами не появятся. Поэтому крупные проекты либо кладут CRD в templates/ с отдельным параметром и аннотацией helm.sh/resource-policy: keep, либо просят применить CRD отдельно kubectl apply --server-side -f crds.yaml перед helm upgrade. Перед обновлением любого оператора стоит прочитать, какой путь выбран в его чарте.

Неизменяемые поля. Если новая версия чарта меняет spec.selector Deployment, volumeClaimTemplates StatefulSet, spec.clusterIP Service или поля Job, helm upgrade упадёт с field is immutable. Выхода три: не допускать таких изменений в своих чартах (селектор — только из неизменных меток, как в _helpers.tpl выше); для чужих — удалить объект вручную перед upgrade (для StatefulSet — kubectl delete sts --cascade=orphan, чтобы поды и тома остались); или --force-replace, который удаляет и создаёт объекты заново — с простоем.

Секреты в values. Пароль в values-prod.yaml в Git — тот же открытый секрет, что и в манифесте; к тому же он оседает в Secret истории релиза. Варианты: плагин helm-secrets расшифровывает файл значений, зашифрованный SOPS (утилитой, которая шифрует значения в YAML ключом age, PGP или облачного KMS, оставляя ключи читаемыми), прямо перед вызовом Helm (helm secrets upgrade … -f secrets.yaml); или чарт вообще не принимает пароль, а ссылается на Secret, который создаёт External Secrets Operator — контроллер, копирующий значения из внешнего хранилища секретов в обычные Secret. Второй вариант лучше: пароль не проходит через Helm и не попадает в историю релиза.

latest и плавающие версии. helm install без --version и зависимости с диапазоном ~1.4.0 без Chart.lock дают разный результат в разные дни. В рабочих окружениях версия чарта фиксируется, Chart.lock коммитится.

Чужой чарт — чужой код. Шаблоны могут создать что угодно, включая ClusterRoleBinding на cluster-admin. Перед установкой незнакомого чарта стоит прочитать его вывод helm template хотя бы на предмет RBAC и привилегированных подов.


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

Значения рабочего окружения — в отдельном файле рядом с описанием развёртывания:

# values-prod.yaml
replicaCount: 3
ingress:
  host: hello.apps.example.com
$ helm version
version.BuildInfo{Version:"v4.3.1", GitCommit:"c0d08d0d6d9424d4a14febb5e8a3e51b0dd00dff", GitTreeState:"clean", GoVersion:"go1.27.2", KubeClientVersion:"v1.37"}

$ helm lint ./hello -f values-prod.yaml
==> Linting ./hello
[INFO] Chart.yaml: icon is recommended

1 chart(s) linted, 0 chart(s) failed

Схема ловит ошибку в значениях ещё до обращения к кластеру:

$ helm template hello ./hello --set replicaCount=0
Error: values don't meet the specifications of the schema(s) in the following chart(s):
hello:
- at '/replicaCount': minimum: got 0, want 1

Установка с ожиданием и откатом при неудаче. Образ hello частный, поэтому в неймспейсе demo, как в части 26, заранее лежит Secret ghcr-pull, привязанный к ServiceAccount default (часть 5):

$ helm install hello ./hello -n demo -f values-prod.yaml --wait --rollback-on-failure
NAME: hello
LAST DEPLOYED: Sat Oct 31 04:46:22 2026
NAMESPACE: demo
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
NOTES:
hello 1.0 установлен как релиз hello в demo.
Адрес: https://hello.apps.example.com/

$ helm list -n demo
NAME    NAMESPACE  REVISION  UPDATED                                  STATUS    CHART        APP VERSION
hello   demo       1         2026-10-31 04:46:22.689654138 +0000 UTC  deployed  hello-0.1.0  1.0

Что лежит в кластере:

$ kubectl -n demo get secret -l owner=helm
NAME                          TYPE                 DATA   AGE
sh.helm.release.v1.hello.v1   helm.sh/release.v1   1      17s

$ kubectl -n demo get secret sh.helm.release.v1.hello.v1 --show-labels
NAME                          TYPE                 DATA   AGE   LABELS
sh.helm.release.v1.hello.v1   helm.sh/release.v1   1      17s   modifiedAt=1793421992,name=hello,owner=helm,status=deployed,version=1

$ kubectl -n demo get secret sh.helm.release.v1.hello.v1 -o jsonpath='{.data.release}' \
    | base64 -d | base64 -d | gunzip | jq '{name, version, namespace, info: .info.status, chart: .chart.metadata.version, config}'
{
  "name": "hello",
  "version": 1,
  "namespace": "demo",
  "info": "deployed",
  "chart": "0.1.0",
  "config": {
    "ingress": {
      "host": "hello.apps.example.com"
    },
    "replicaCount": 3
  }
}

Два base64 -d — один снимает кодирование API, второй — кодирование самого Helm. config — ровно то, что пришло от пользователя (-f и --set), без умолчаний чарта. То же самое без ручного разбора показывают helm get values hello -n demo, helm get manifest hello -n demo и helm get all hello -n demo.

Кому принадлежат поля после установки Helm 4:

$ kubectl -n demo get deploy hello -o jsonpath='{range .metadata.managedFields[*]}{.manager}{"\t"}{.operation}{"\n"}{end}'
helm	Apply
kube-controller-manager	Update

Apply — значит server-side apply; у релизов Helm 3 здесь было бы helm Update. Обновление с новым тегом и затем откат:

$ helm upgrade hello ./hello -n demo -f values-prod.yaml --set-string image.tag=1.1 --wait
Release "hello" has been upgraded. Happy Helming!
NAME: hello
LAST DEPLOYED: Sat Oct 31 04:46:45 2026
NAMESPACE: demo
STATUS: deployed
REVISION: 2
DESCRIPTION: Upgrade complete
…
$ helm upgrade hello ./hello -n demo -f values-prod.yaml --set-string image.tag=1.2 --wait --timeout 2m --rollback-on-failure
level=WARN msg="upgrade failed" name=hello error="resource Deployment/demo/hello not ready. status: InProgress, message: Updated: 1/3\ncontext deadline exceeded"
Error: UPGRADE FAILED: release hello failed, and has been rolled back due to rollback-on-failure being set: resource Deployment/demo/hello not ready. status: InProgress, message: Updated: 1/3
context deadline exceeded

$ helm history hello -n demo
REVISION  UPDATED                   STATUS      CHART        APP VERSION  DESCRIPTION
1         Sat Oct 31 04:46:22 2026  superseded  hello-0.1.0  1.0          Install complete
2         Sat Oct 31 04:46:45 2026  superseded  hello-0.1.0  1.0          Upgrade complete
3         Sat Oct 31 04:47:06 2026  failed      hello-0.1.0  1.0          Upgrade "hello" failed: resource Deployment/demo/hello not ready. status: InProgress, message: Updated: ...
4         Sat Oct 31 04:49:07 2026  deployed    hello-0.1.0  1.0          Rollback to 2

Тега 1.2 в реестре нет, новый под ушёл в ImagePullBackOff, за две минуты Deployment не стал готов — kstatus так и сообщает: status: InProgress, message: Updated: 1/3, — и Helm сам вернул ревизию 2 новой ревизией 4. APP VERSION во всех строках — 1.0: это appVersion чарта, а не тег, переданный через --set-string. Старые поды при этом не пострадали: rolling update не убирает старые реплики, пока новые не готовы (часть 7), так что пользователи сбоя не заметили. Без --rollback-on-failure релиз остался бы в failed с подвисшим новым ReplicaSet, и откатывать пришлось бы руками: helm rollback hello 2 -n demo.

Удаление:

$ helm uninstall hello -n demo
release "hello" uninstalled
$ kubectl -n demo get secret -l owner=helm,name=hello
No resources found in demo namespace.

Helm или Kustomize

Kustomize Helm
Входные данные валидные манифесты шаблоны, которые без рендеринга не YAML
Параметры нет, только патчи values.yaml со схемой
Логика (условия, циклы) нет есть
Состояние в кластере нет релиз и история ревизий в Secret
Откат через Git и повторное применение helm rollback
Удаление лишних объектов нет (apply не удаляет) да, по манифесту прошлой ревизии
Распространение каталог в Git архив в репозитории или OCI-реестре
Встроен в kubectl да нет

Правило, которое работает в большинстве команд: свои приложения — Kustomize (или простой чарт, если параметров действительно много); чужие компоненты — их официальный Helm-чарт с values-файлом в Git, плюс post-renderer, если чарт чего-то не умеет. Инструменты не конкурируют: Argo CD — GitOps-контроллер, который сам применяет в кластер содержимое Git-репозитория (часть 41), — умеет и то и другое.


Источники


Дальше — CRD и операторы: как добавить в API собственный тип объекта, как устроен контроллер, который превращает его в поды и сервисы, и что делают за администратора операторы PostgreSQL и RabbitMQ.

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

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