Раздел 3 · Kubernetes — глава 3.2

Helm: чарты, шаблоны и релизы

Helm спрашивают почти на каждом собеседовании, и почти всегда одинаково: «пользовались?» — «да» — «а что такое хуки, чем appVersion отличается от version и что происходит при upgrade?». Три вопроса, на которых обычно и заканчивается уверенность. Разберём так, чтобы понимать механику: как устроен чарт, как работает шаблонизатор и почему он так больно ошибается на отступах, где Helm хранит состояние и что именно он делает при обновлении.

§1Зачем он вообще нужен

Проблема, которую решает Helm, появляется быстро. У тебя есть сервис, а у него — Deployment, Service, Ingress, ConfigMap, HPA, ServiceAccount и пара сетевых политик. Восемь файлов. Теперь нужно то же самое для трёх окружений, где отличаются образ, число реплик, домен, лимиты и половина переменных.

Первый порыв — скопировать каталог три раза. Дальше эти три копии немедленно расходятся, и через полгода никто не знает, чем прод отличается от стейджа и почему.

Helm даёт три вещи:

  • Параметризацию. Один комплект шаблонов, а различия окружений вынесены в отдельные файлы значений. Разница между средами становится видимой — это буквально диф двух небольших файлов.
  • Упаковку и версионирование. Чарт — это архив с номером версии, который можно положить в репозиторий и установить где угодно одной командой. Отсюда и весь мир готовых чартов: поднять Prometheus, Keycloak или PostgreSQL — одна строка, а не сорок файлов из чужой документации.
  • Управление жизненным циклом. Helm помнит, что именно он поставил, поэтому умеет обновлять, откатываться на предыдущую ревизию и корректно удалять весь комплект целиком — включая то, что ты уже забыл.

Последний пункт — главное отличие от голого kubectl apply. Применённые вручную манифесты никто не отслеживает: удалил файл из каталога, применил заново — объект остался жить в кластере навсегда. Helm ведёт учёт и такой ресурс уберёт.

§2Из чего состоит чарт

myapp/ ├── Chart.yaml метаданные: имя, версии, зависимости ├── values.yaml значения ПО УМОЛЧАНИЮ (документация чарта де-факто) ├── templates/ │ ├── deployment.yaml │ ├── service.yaml │ ├── ingress.yaml │ ├── _helpers.tpl вспомогательные шаблоны; файлы с _ не рендерятся │ ├── NOTES.txt текст, который печатается после установки │ └── tests/ проверки, запускаемые командой helm test ├── charts/ вложенные чарты-зависимости ├── crds/ CRD — ставятся ОСОБЫМ образом, см. §13 └── .helmignore что не класть в архив

Два соглашения из этой структуры стоит запомнить, потому что о них спрашивают.

Файлы в templates/, имя которых начинается с подчёркивания, не превращаются в манифесты. Они существуют только для того, чтобы объявлять в них именованные шаблоны, — отсюда и повсеместный _helpers.tpl.

values.yaml — это не «настройки», а публичный интерфейс чарта. Всё, что в нём перечислено, — это то, что пользователю разрешено менять, и заодно единственная документация. Поэтому там должны быть перечислены все параметры, даже те, что по умолчанию пустые, с комментариями.

§3Chart.yaml и две версии, которые путают

apiVersion: v2              # v2 = Helm 3; v1 — старый формат
name: myapp
type: application           # или library — чарт без манифестов, только хелперы
version: 1.4.2              # версия ЧАРТА
appVersion: "2.10.0"        # версия ПРИЛОЖЕНИЯ внутри
description: Сервис заказов
dependencies:
  - name: postgresql
    version: "15.2.1"
    repository: https://charts.bitnami.com/bitnami
    condition: postgresql.enabled

Разница между version и appVersion — любимый вопрос, и путаница тут естественная.

version — версия самого чарта, то есть упаковки. Меняется, когда ты правишь шаблоны, добавляешь параметр, чинишь отступ. Обязана быть по правилам семантического версионирования, потому что по ней Helm разрешает зависимости.

appVersion — версия приложения, которое чарт устанавливает. Обычно совпадает с тегом образа и обычно подставляется в шаблон как значение по умолчанию. На разрешение зависимостей не влияет вообще, это чисто информационное поле — потому и берётся в кавычки, чтобы 1.10 не превратилось в число.

Практический вывод: поправил опечатку в шаблоне, не трогая образ, — растёт version, appVersion остаётся. Обновил приложение на новую версию — растут оба.

§4Шаблоны: что вообще доступно внутри

Helm использует шаблонизатор Go плюс библиотеку функций Sprig. Работает он тупо: берёт файл, подставляет значения, отдаёт результат в Kubernetes. Про YAML он при этом ничего не знает — для него это просто текст, и именно отсюда растут все проблемы с отступами из §5.

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

ОбъектЧто в нём
.ValuesИтоговые значения: values.yaml чарта, переопределённые файлами и ключами --set
.ReleaseПро эту установку: .Name, .Namespace, .Revision, .IsInstall, .IsUpgrade
.ChartСодержимое Chart.yaml: .Name, .Version, .AppVersion
.CapabilitiesЧто умеет целевой кластер: версия, доступные API. Так пишут чарты, совместимые с разными версиями
.FilesДоступ к файлам чарта — чтобы, например, положить целый конфиг в ConfigMap
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "myapp.fullname" . }}
  labels: {{- include "myapp.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  template:
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
                                                ↑ вот тут и используется appVersion
          resources: {{- toYaml .Values.resources | nindent 12 }}

Точка и потеря контекста

Точка — это текущий контекст, и на верхнем уровне она означает корень со всеми объектами. Проблема в том, что range и with переопределяют её, и это ошибка номер один у начинающих:

# НЕ РАБОТАЕТ
{{- range .Values.ports }}
  - port: {{ .port }}
    name: {{ $.Release.Name }}-{{ .name }}
             ↑ без $ здесь было бы .Release.Name внутри элемента списка,
               то есть пусто — потому что точка теперь указывает на элемент
{{- end }}

$ — это всегда корневой контекст, независимо от того, насколько глубоко ты внутри блоков. Запомнить это стоит один раз: увидел range или with — дальше к глобальным объектам обращайся через $.

У with есть и полезная сторона — он и сокращает запись, и работает как проверка: блок не выполнится вовсе, если значение пустое.

{{- with .Values.nodeSelector }}
nodeSelector:
  {{- toYaml . | nindent 2 }}      # здесь точка — это уже nodeSelector
{{- end }}

§5Пробелы и отступы: где ломается чаще всего

Раздел, который экономит часы. Помним: шаблонизатор работает с текстом, а YAML к отступам беспощаден.

Минус в фигурных скобках съедает пробельные символы. {{- убирает всё пустое слева, включая перевод строки; -}} — справа. Без этого каждая управляющая конструкция оставляет после себя пустую строку, и в лучшем случае получается некрасиво, а в худшем — ломается структура.

# без минуса — остаются пустые строки от каждой директивы
spec:
  {{ if .Values.enabled }}
  replicas: 3
  {{ end }}

# с минусом — чисто
spec:
  {{- if .Values.enabled }}
  replicas: 3
  {{- end }}

nindent вместо indent. Разница одна: nindent сначала ставит перевод строки, а потом отступ. Именно это нужно почти всегда, потому что вставляемый блок должен начинаться с новой строки.

labels: {{- include "myapp.labels" . | nindent 4 }}
# превращается в:
# labels:
#     app: myapp
#     version: 1.4.2

toYaml для целых блоков. Когда в значениях лежит структура — ресурсы, nodeSelector, аннотации, — её не расписывают вручную, а сериализуют целиком: {{- toYaml .Values.resources | nindent 12 }}. Число в nindent считается от начала строки, и его надо подбирать по месту вставки.

quote и подводный камень со строками. YAML сам решает, что такое true, no, 1.10 и 2e3, и решает не всегда так, как ты хотел. Значения, которые обязаны остаться строками, оборачивают: {{ .Values.tag | quote }}. Классика — версия 1.10, которая без кавычек становится числом 1.1.

В бою

Не гадай, что получилось, — посмотри: helm template myapp ./myapp -f values-prod.yaml отрендерит всё локально, ничего не устанавливая. Именно так и надо отлаживать шаблоны: правишь, рендеришь, читаешь результат. А helm template ... | kubectl apply --dry-run=server -f - дополнительно проверит результат настоящим apiserver — поймает и опечатки в полях, и нарушения admission-политик.

§6Именованные шаблоны и полезные функции

Повторяющиеся куски выносят в _helpers.tpl через define:

{{- define "myapp.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end }}

Обрезка до 63 символов здесь не паранойя: это ограничение Kubernetes на длину имени, и без неё чарт сломается при длинном имени релиза. В сгенерированных helm create хелперах эта строчка есть всегда.

Вставляют такие шаблоны двумя способами, и разницу спрашивают:

  • {{ template "имя" . }} — просто выводит результат, и с ним ничего нельзя сделать дальше.
  • {{ include "имя" . }} — возвращает строку, которую можно передать по конвейеру: | nindent 4, | quote, куда угодно.

Поэтому практическое правило простое: всегда include, template остался из старых времён.

Функций много, но в реальной жизни используются несколько:

{{ .Values.tag | default .Chart.AppVersion }}    # значение по умолчанию
{{ required "укажите image.repository!" .Values.image.repository }}
                                                  # упасть с внятной ошибкой
{{ .Values.name | quote }}                        # обернуть в кавычки
{{ toYaml .Values.resources | nindent 12 }}       # блок целиком
{{ include "myapp.labels" . | nindent 4 }}
{{ tpl .Values.someTemplateString . }}            # отрендерить строку ИЗ values как шаблон
{{ .Values.password | b64enc }}                   # для Secret
{{ lookup "v1" "Secret" .Release.Namespace "my-secret" }}   # прочитать из кластера

Про lookup стоит знать отдельно: он читает существующие объекты прямо из кластера и позволяет, например, не перегенерировать пароль при каждом обновлении. Важная оговорка — при helm template и --dry-run он возвращает пустоту, потому что подключения к кластеру нет. Логика, построенная на нём, ведёт себя по-разному при рендере и при установке, и это регулярно удивляет.

§7values: откуда берутся значения и что кого перебивает

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

1. values.yaml внутри чарта ← самый слабый 2. values.yaml родительского чарта (для подчарта — секция с его именем) 3. -f my-values.yaml ← несколько файлов: каждый -f override.yaml следующий перебивает предыдущий 4. --set key=value ← самый сильный --set-string, --set-file, --set-json
helm upgrade --install myapp ./myapp \
  -f values-common.yaml \
  -f values-prod.yaml \
  --set image.tag=v2.10.1 \
  --set-string 'config.version=1.10'      # принудительно строкой

Синтаксис --set имеет собственные правила, о которых спотыкаются: точки разделяют уровни вложенности, запятые — отдельные пары, а если точка или запятая нужна внутри значения, её экранируют обратным слэшем.

--set nodeSelector."kubernetes\.io/os"=linux    # точка в имени ключа
--set args={--verbose,--port=8080}              # список
--set 'ingress.hosts[0].host=shop.example.com'  # элемент списка

Грабли

Списки не сливаются, а заменяются целиком. Словари объединяются по ключам, а вот если в values.yaml чарта есть список из трёх элементов и ты в своём файле указываешь список из одного — итогом будет один элемент, а не четыре. Это осознанное решение (иначе из списка нельзя было бы ничего убрать), но оно постоянно ловит. Отсюда практика: в чартах массивы по умолчанию делают пустыми, а всё, что должно доопределяться, оформляют словарями.

§8Зависимости и подчарты

Чарт может тянуть за собой другие — так и ставят приложение вместе с его базой одной командой.

dependencies:
  - name: postgresql
    version: "15.x.x"                     # допустима вилка версий
    repository: https://charts.bitnami.com/bitnami
    condition: postgresql.enabled         # ставить, только если включено
    alias: orders-db                      # поставить дважды под разными именами
$ helm dependency update ./myapp    # скачать в charts/ и записать Chart.lock
$ helm dependency build  ./myapp    # поставить строго по Chart.lock

Chart.lock здесь играет ту же роль, что и любой другой lock-файл: фиксирует конкретные версии, чтобы сборка была воспроизводимой. Его коммитят.

Значения подчарту передаются через секцию с его именем, а общие для всех — через global:

# values.yaml родительского чарта
postgresql:                      # всё это уедет в подчарт postgresql
  enabled: true
  auth:
    database: orders

global:                          # видно и родителю, и всем подчартам
  imageRegistry: registry.internal.company
  storageClass: fast-ssd

Про подчарты стоит знать честную оговорку: они удобны для разработки, но для продовой базы обычно неудачны. Жизненный цикл базы не совпадает с жизненным циклом приложения — обновление приложения не должно трогать СУБД, а helm uninstall не должен сносить данные. Поэтому в проде базу чаще ставят отдельно или оператором, а зависимость оставляют выключенной для локального запуска.

§9Релиз: что Helm помнит и где

Установленный экземпляр чарта называется релизом. Одну и ту же чарт можно поставить много раз под разными именами — это будут разные релизы со своим состоянием.

Вопрос «где Helm хранит состояние» задают часто, и правильный ответ короткий: в самом кластере, в объектах Secret, в том же namespace, что и релиз. У них тип helm.sh/release.v1, а внутри — сжатый и закодированный слепок: манифесты, значения, метаданные ревизии.

$ kubectl get secret -n prod -l owner=helm
NAME                          TYPE                 AGE
sh.helm.release.v1.myapp.v1   helm.sh/release.v1   40d
sh.helm.release.v1.myapp.v2   helm.sh/release.v1   12d
sh.helm.release.v1.myapp.v3   helm.sh/release.v1   2d
                        ↑ по одному секрету на каждую ревизию

Отсюда несколько следствий, которые полезно назвать:

  • Нет никакого внешнего состояния и никакого сервера — вся правда в кластере. Работать с релизом можно с любой машины, где есть доступ.
  • Права Helm равны твоим правам в кластере. Это главное изменение третьей версии: во второй в кластере жил серверный компонент Tiller с широкими правами, и он был известной дырой в безопасности. Его убрали, и теперь всё выполняется от имени пользователя.
  • История ревизий занимает место, поэтому по умолчанию хранятся последние десять (--history-max).
  • Релизы разных namespace не видят друг друга — это, кстати, ответ на вопрос «почему helm list ничего не показывает»: забыли -n.
$ helm list -n prod
$ helm history myapp -n prod
REVISION  UPDATED       STATUS      CHART        APP VERSION  DESCRIPTION
1         40d ago       superseded  myapp-1.2.0  2.8.0        Install complete
2         12d ago       superseded  myapp-1.4.0  2.10.0       Upgrade complete
3         2d ago        failed      myapp-1.4.2  2.10.1       Upgrade failed: timed out

$ helm rollback myapp 2 -n prod        # вернуться на рабочую ревизию

§10Хуки: как выполнить что-то до или после

Хуки нужны, когда одних манифестов мало: прогнать миграцию базы перед обновлением приложения, дождаться готовности внешней системы, прибрать за собой при удалении.

Технически хук — это обычный ресурс (чаще всего Job) с аннотацией, которая говорит Helm, когда его запустить:

apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "myapp.fullname" . }}-migrate
  annotations:
    "helm.sh/hook": pre-upgrade,pre-install
    "helm.sh/hook-weight": "-5"              # меньше = раньше
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          command: ["./migrate", "up"]
МоментКогда срабатывает
pre-install / post-installДо и после первой установки
pre-upgrade / post-upgradeДо и после обновления. Сюда чаще всего вешают миграции
pre-rollback / post-rollbackВокруг отката
pre-delete / post-deleteВокруг удаления — например, снять финальный бэкап
testЗапускается вручную командой helm test

Ключевая деталь механики, которую и проверяют вопросом: Helm ждёт завершения хука, прежде чем идти дальше. Job упал — обновление не состоится, и это ровно то, что нужно для миграций: не выкатывать код, если схема не обновилась.

Вторая деталь, менее очевидная: ресурсы хуков не входят в состав релиза. Helm их создаёт, но не отслеживает как часть комплекта, и при helm uninstall они не удаляются. Поэтому hook-delete-policy — не украшение, а необходимость, иначе Job-ы будут копиться, а следующая установка упадёт с ошибкой «такой Job уже существует». Практичное сочетание — before-hook-creation (удалить прошлый перед созданием нового) и hook-succeeded (убрать за собой после успеха), при этом упавший Job остаётся, и его лог можно прочитать.

Спросят

«Как накатывать миграции базы через Helm?» — «Job с аннотацией pre-upgrade и pre-install, с hook-weight, чтобы задать порядок относительно других хуков. Helm дожидается завершения, и если Job упал, обновление не пойдёт — приложение не выкатится на несовместимую схему. Обязательно ставлю hook-delete-policy, потому что ресурсы хуков не считаются частью релиза и сами не убираются. И отдельно: сами миграции должны быть обратно совместимыми, потому что во время rolling update старая и новая версии приложения работают одновременно с одной схемой.»

§11Что на самом деле делает upgrade

Здесь стоит понимать механику, потому что она объясняет половину странного поведения.

При обновлении Helm сравнивает три состояния: манифесты предыдущей ревизии, новые отрендеренные манифесты и то, что сейчас реально лежит в кластере. По этому трёхстороннему сравнению он вычисляет патч.

Смысл третьего участника — не затирать чужие изменения. Если кто-то или что-то (например, HPA) поменял поле, которого нет в чарте, Helm его не тронет. А вот если поле есть в чарте и оно поменялось — победит чарт. И если поле было в предыдущей ревизии, а из новой исчезло, Helm его уберёт.

# идемпотентная команда: поставит, если нет; обновит, если есть.
# именно её и используют в пайплайнах
$ helm upgrade --install myapp ./myapp -n prod \
    --create-namespace \
    -f values-prod.yaml \
    --atomic --timeout 5m

Три флага, которые стоит понимать:

  • --wait — не возвращать управление, пока поды не станут готовы. Без него команда завершается успехом сразу после отправки манифестов, даже если приложение потом упадёт, — и пайплайн зеленеет при сломанной выкатке.
  • --atomic — то же самое плюс автоматический откат при неудаче. Включает --wait. Для автоматических выкаток это правильный выбор.
  • --timeout — сколько ждать. По умолчанию пять минут; медленно стартующим приложениям надо больше, иначе --atomic откатит вполне рабочую выкатку.

§12Отладка

# отрендерить локально — основной инструмент разработки чарта
$ helm template myapp ./myapp -f values-prod.yaml

# то же, но с проверкой на реальном кластере (без установки)
$ helm install myapp ./myapp --dry-run --debug -f values-prod.yaml

# проверить чарт на типичные ошибки
$ helm lint ./myapp -f values-prod.yaml

# что РЕАЛЬНО установлено сейчас — а не что в файлах
$ helm get manifest myapp -n prod
$ helm get values myapp -n prod          # только переопределённые
$ helm get values myapp -n prod --all    # все, включая умолчания
$ helm get notes myapp -n prod

# посмотреть, что изменится ДО применения (плагин, ставится отдельно)
$ helm diff upgrade myapp ./myapp -f values-prod.yaml -n prod

Плагин helm-diff стоит поставить сразу: он показывает построчную разницу между тем, что установлено, и тем, что будет. Для прода это единственный способ не выкатывать вслепую, и его же внутри использует Argo CD для отображения расхождений.

Разделение между helm get values с флагом и без него тоже полезно: без флага видно ровно то, что задал пользователь (то есть чем это окружение отличается от умолчаний), с флагом — итоговый набор целиком.

§13Грабли, о которых стоит рассказать самому

«field is immutable»

Обновление падает с этой ошибкой, когда чарт меняет поле, которое Kubernetes запрещает менять после создания, — чаще всего spec.selector у Deployment или почти всё в спеке StatefulSet, кроме образа и реплик. Helm тут ни при чём, это ограничение самого Kubernetes.

Лечение: удалить объект и дать Helm создать заново (kubectl delete deploy --cascade=orphan, чтобы поды пережили) либо, для StatefulSet, действовать как в главе 3.1, §4. Флаг --force в этой ситуации применяют часто и зря: он делает не патч, а замену объекта, то есть удаление и создание, — для Deployment это означает одномоментный простой.

«another operation is in progress»

Классика: предыдущий upgrade прервали по Ctrl+C или он упал по таймауту, и релиз завис в состоянии pending-upgrade. Helm отказывается делать что-либо дальше.

Правильный выход — helm rollback myapp <последняя рабочая ревизия>, он приведёт состояние в порядок. Грубый, но иногда единственный — удалить секрет зависшей ревизии. Профилактика — --atomic, который не оставляет релиз в подвешенном состоянии.

CRD живут по своим правилам

Файлы в каталоге crds/ обрабатываются особым образом: Helm ставит их перед всем остальным, но не обновляет при upgrade и не удаляет при uninstall. Это сделано намеренно — удаление CRD уничтожает все объекты этого типа в кластере, и такое не должно происходить случайно.

Практическое следствие: обновление версии оператора через Helm не обновит его CRD, и новые поля просто не заработают. Их нужно применять отдельно, руками или отдельным шагом пайплайна. Это очень частая причина «поставил новую версию, а новая возможность не появилась».

Секреты в values

Пароли в values-prod.yaml, лежащем в git, — самая частая беда. Варианты нормального решения: helm-secrets с SOPS (файл значений зашифрован, расшифровывается на лету при установке), External Secrets Operator (чарт создаёт только ссылку, значение приезжает из Vault) или передача через переменные пайплайна. Плохо — просто не коммитить файл: он всё равно окажется у кого-то на диске и в истории.

Helm не отслеживает то, что создал не он

Если объект создали вручную, а потом такой же появился в чарте, helm upgrade упадёт с жалобой на существующий ресурс без нужных ему аннотаций владения. Лечится либо удалением объекта, либо проставлением аннотаций meta.helm.sh/release-name и release-namespace плюс метки app.kubernetes.io/managed-by: Helm — это способ «усыновить» существующий ресурс.

§14Helm против Kustomize и место в GitOps

Вопрос «почему Helm, а не Kustomize» задают регулярно, и правильный ответ — про разные подходы, а не про «лучше-хуже».

HelmKustomize
ПринципШаблоны с подстановкой значенийБазовые манифесты плюс наложение патчей
ИсходникиНе валидный YAML — шаблон надо отрендеритьОбычный валидный YAML, читается как есть
ЛогикаЕсть: условия, циклы, функцииПрактически нет — это осознанное решение
РаспространениеРепозитории чартов, версии, зависимостиСсылки на базы, в том числе удалённые
Учёт установленногоЕсть: релизы, история, откатНет — это просто генератор манифестов
Сильная сторонаРаспространять чужим людям сложное приложениеСвои манифесты и небольшие различия между средами

На практике часто берут оба: чужой софт ставят чартами, потому что альтернативы нет, а свои сервисы описывают Kustomize, потому что читать обычный YAML приятнее, чем шаблон.

В GitOps Helm обычно используется иначе, чем в командной строке. Argo CD и Flux берут чарт, рендерят его и дальше работают с готовыми манифестами, приводя кластер к ним. Механизм релизов Helm при этом не используется вовсе: состояние отслеживает сам агент, а история и откат — это git. Отсюда и практика, которую стоит упомянуть: если у тебя GitOps, то helm rollback тебе не нужен, откат — это revert коммита.

§15Как это звучит в ответе

«Helm решает три задачи: параметризация манифестов, чтобы различия между окружениями были видимым дифом, а не тремя разъехавшимися копиями каталога; упаковка с версией, чтобы чужое приложение ставилось одной командой; и учёт установленного, чтобы можно было обновить, откатиться и удалить весь комплект целиком — чего голый kubectl apply не умеет, потому что не помнит, что он создавал.

Чарт — это Chart.yaml с метаданными, values.yaml как публичный интерфейс и каталог шаблонов. В Chart.yaml две разные версии: version — версия самого чарта, по ней разрешаются зависимости, а appVersion — версия приложения внутри, чисто информационная и обычно подставляемая как тег образа по умолчанию.

Шаблоны — это Go templates, и главное про них то, что шаблонизатор работает с текстом и про YAML ничего не знает. Отсюда вся возня с {{- для съедания пробелов и nindent для отступа вставляемых блоков. Ещё одна типичная ошибка — потеря контекста внутри range и with: точка там указывает уже на элемент, и к глобальным объектам надо обращаться через $.

Значения складываются слоями: умолчания чарта, потом файлы через -f в порядке указания, потом --set. Важная деталь — словари сливаются по ключам, а списки заменяются целиком.

Состояние Helm хранит в самом кластере, в секретах типа helm.sh/release.v1, по одному на ревизию, в namespace релиза. Никакого серверного компонента нет: в третьей версии убрали Tiller, и теперь всё выполняется с правами пользователя. При обновлении делается трёхстороннее сравнение — старая ревизия, новые манифесты и фактическое состояние в кластере, — чтобы не затирать чужие изменения вроде того, что сделал HPA.

Хуки — это ресурсы с аннотацией вроде pre-upgrade; Helm дожидается их завершения, и упавший хук останавливает обновление, что и нужно для миграций. Но они не входят в состав релиза и сами не убираются, поэтому обязателен hook-delete-policy.

Из практического: в пайплайне пишу helm upgrade --install с --atomic и увеличенным --timeout, чтобы неудачная выкатка откатывалась сама и не оставляла релиз в подвешенном состоянии. Перед выкаткой смотрю helm diff. И помню, что CRD из каталога crds/ ставятся один раз и при обновлении не трогаются — их надо применять отдельно, иначе новая версия оператора приезжает со старой схемой.»