Раздел 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Из чего состоит чарт
Два соглашения из этой структуры стоит запомнить, потому что о них спрашивают.
Файлы в 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: откуда берутся значения и что кого перебивает
Значения складываются слоями, и порядок приоритета надо знать наизусть — от слабого к сильному:
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» задают регулярно, и правильный ответ — про разные подходы, а не про «лучше-хуже».
| Helm | Kustomize | |
|---|---|---|
| Принцип | Шаблоны с подстановкой значений | Базовые манифесты плюс наложение патчей |
| Исходники | Не валидный 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/ ставятся один раз и при обновлении не трогаются — их надо применять отдельно, иначе новая версия оператора приезжает со старой схемой.»