Helm charts best practices: как структурировать values и шаблоны так, чтобы чарт не превратился в неподдерживаемый монолит

Helm charts best practices: как структурировать values и шаблоны так, чтобы чарт не превратился в неподдерживаемый монолит

Коротко:

  • Плоский values.yaml без структуры - главная причина конфликтов при слиянии и непонятных переопределений в разных окружениях.
  • Общие фрагменты шаблонов выносятся в _helpers.tpl через именованные шаблоны - это единственный способ не копипастить labels и annotations по десяти файлам.
  • Library chart - отдельный пакет с переиспользуемыми шаблонами для нескольких сервисов; subchart - зависимость внутри одного деплоя.
  • Окружения разделяются через наложение values-файлов, а не через условия if .Values.env == "prod" внутри шаблонов.
  • Проверяй шаблоны локально через helm template и helm lint до любого деплоя в кластер.

Откуда берется хрупкий чарт

Обычно всё начинается невинно: один values.yaml, несколько манифестов в папке templates/, и сервис деплоится. Проблемы появляются позже - когда нужно поддерживать три окружения, добавить второй сервис с похожей структурой, или когда в команду приходит новый человек и пытается понять, почему ingress.yaml содержит сто строк условий.

Монолитный чарт - это не проблема одного файла. Это накопленные решения «сделаем сейчас, разберемся потом»: дублированные labels в каждом манифесте, логика окружений прямо в шаблонах, values с плоской структурой без явного смысла. Когда такой чарт нужно обновить или переиспользовать, оказывается, что проще написать заново.

Дальше - конкретные приемы, которые не дают этому случиться.

Структура values.yaml: иерархия вместо плоского списка

Самая частая ошибка в организации values - это плоская структура, где все ключи живут на одном уровне:

replicaCount: 2
image: myapp
imageTag: 1.0.0
servicePort: 8080
ingressEnabled: true
ingressHost: myapp.example.com
resourcesLimitsCpu: 500m
resourcesLimitsMemory: 256Mi

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

replicaCount: 2

image:
  repository: myapp
  tag: 1.0.0
  pullPolicy: IfNotPresent

service:
  port: 8080
  type: ClusterIP

ingress:
  enabled: true
  host: myapp.example.com
  tls: []

resources:
  limits:
    cpu: 500m
    memory: 256Mi
  requests:
    cpu: 100m
    memory: 128Mi

Такой файл читается сверху вниз - понятно, что за что отвечает, и легко найти нужное место для переопределения. Вложенность хорошо работает с helm upgrade --set ingress.host=new.example.com и с наложением дополнительных values-файлов через -f.

Как разделять окружения

Антипаттерн, который ломает читаемость шаблонов быстрее всего - это условия вида:

{{- if eq .Values.env "production" }}
  replicas: 5
{{- else }}
  replicas: 1
{{- end }}

Логика окружений ползет в шаблон, шаблон перестает быть декларативным, а новый инженер вынужден читать каждый блок, чтобы понять, что реально развернется в prod.

Правильный подход - базовый values.yaml с разумными дефолтами, и отдельные файлы переопределений для каждой среды:

values.yaml          # базовые значения
values-staging.yaml  # переопределения для staging
values-prod.yaml     # переопределения для production

Деплой в конкретную среду выглядит так:

helm upgrade --install myapp ./chart \
  -f values.yaml \
  -f values-prod.yaml

Второй файл перекрывает только то, что отличается. Шаблоны остаются чистыми - никаких условий на имя окружения.

_helpers.tpl: не просто labels

В большинстве чартов файл _helpers.tpl используется только для стандартных labels: app.kubernetes.io/name, app.kubernetes.io/version и аналогичных. Это хорошее начало, но возможности именованных шаблонов намного шире.

Любой повторяющийся фрагмент стоит выносить в хелпер. Например, блок с environment variables из ConfigMap и Secret часто одинаковый для всех контейнеров в чарте:

{{- define "myapp.envFrom" -}}
envFrom:
  - configMapRef:
      name: {{ include "myapp.fullname" . }}-config
  - secretRef:
      name: {{ include "myapp.fullname" . }}-secret
{{- end }}

После этого в любом манифесте достаточно вызова:

containers:
  - name: app
    image: ...
    {{- include "myapp.envFrom" . | nindent 4 }}

Если структура envFrom изменится - меняешь одно место, а не пять файлов.

Правила хорошего хелпера

  • Имя всегда начинается с префикса чарта: myapp.labels, а не просто labels. Без префикса возникают коллизии при использовании library charts.
  • Каждый хелпер делает одно дело. Не смешивай labels и annotations в одном шаблоне - их часто нужно использовать по отдельности.
  • Избегай сложных вычислений прямо в хелпере. Если нужна нетривиальная логика, лучше разбить на два маленьких шаблона, чем держать один большой с вложенными условиями.

Когда нужен library chart

Представь ситуацию: у команды пять микросервисов, у каждого свой чарт, и в каждом - почти одинаковые шаблоны Deployment, Service и HorizontalPodAutoscaler. Синхронизировать их руками невозможно - через два месяца чарты разойдутся, и у одного сервиса окажутся устаревшие annotations.

Library chart решает эту задачу. Это специальный пакет с типом library в Chart.yaml, который содержит только именованные шаблоны и не деплоит никаких ресурсов самостоятельно. Сервисные чарты подключают его как зависимость и переиспользуют готовые шаблоны.

Минимальный Chart.yaml для библиотечного чарта:

apiVersion: v2
name: common
type: library
version: 1.2.0

Подключение в сервисном чарте:

dependencies:
  - name: common
    version: "1.2.x"
    repository: "oci://registry.example.com/charts"

После helm dependency update шаблоны из common доступны через вызов include "common.deployment" ..

Library chart оправдан, когда больше двух-трех чартов разделяют одни и те же шаблонные паттерны. Для одного сервиса это избыточно.

Subcharts: зависимости внутри одного деплоя

Subchart - это вложенный чарт, который деплоится вместе с родительским. Типичный пример: приложение плюс Redis или PostgreSQL как зависимость. В Chart.yaml родителя:

dependencies:
  - name: redis
    version: "17.x.x"
    repository: "https://charts.bitnami.com/bitnami"
    condition: redis.enabled

Ключевой момент - поле condition. Оно позволяет включать или отключать зависимость через values.yaml:

redis:
  enabled: true
  architecture: standalone

Values для subchart передаются через пространство имен, совпадающее с именем зависимости. В примере выше все ключи внутри redis: попадают в Redis chart как его собственные values. Это важно знать, когда нужно переопределить, скажем, размер persistent volume для Redis в prod-окружении.

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

Subchart создает жесткую связь между версиями. Если Bitnami обновил Redis chart и поменял структуру values - твой values-prod.yaml может молча перестать работать так, как ожидалось. Именно поэтому версии зависимостей стоит фиксировать точно, а не указывать "*".

Второй момент: глобальные values. В Helm есть специальная секция global: в values.yaml, которая видна и родителю, и всем subchart без явной передачи. Это удобно для общих параметров вроде registry или imagePullSecrets, но легко превращается в неявные зависимости, о которых не догадывается новый человек в команде.

Осторожно с global: значения в секции global доступны всем вложенным чартам автоматически. Это удобно, но создает неявные зависимости. Документируй использование global-values явно в README чарта.

Типичные ошибки при организации шаблонов

ОшибкаПоследствиеКак исправить
Дублирование labels в каждом манифесте вручнуюLabels расходятся при изменении; сложно найти все ресурсы сервисаВынести в _helpers.tpl, использовать через include
Условия if .Values.env == "prod" в шаблонахЛогика окружений разбросана по шаблонам; трудно отследить, что деплоится кудаРазделять через отдельные values-файлы
Плоская структура values без группировкиКонфликты при слиянии; непонятно, какие параметры связаныИерархическая структура по сущностям
Нефиксированные версии зависимостейОбновление Bitnami ломает деплой без предупрежденияФиксировать точные версии или диапазон x.y.x
Большой монолитный шаблон deployment.yaml с сотней строкСложно читать, тяжело тестировать, merge conflicts при командной работеРазбить на отдельные файлы по ресурсам; общее в хелперы

Проверка шаблонов до деплоя

Ни один шаблон не должен попасть в кластер непроверенным. Два базовых инструмента, которые должны быть в CI:

helm lint ./chart проверяет синтаксис и базовые правила оформления. Находит очевидные ошибки, undefined variables и нарушения структуры.

helm template ./chart -f values-prod.yaml рендерит шаблоны локально без подключения к кластеру. Вывод можно отправить в kubectl apply --dry-run=client -f -, чтобы проверить корректность Kubernetes-манифестов.

Для более глубокой проверки используй kubeconform - он валидирует манифесты против актуальных JSON Schema для нужной версии Kubernetes API. В отличие от kubeval, активно поддерживается и понимает CRD.

Пример пайплайна проверки в CI:

helm dependency update ./chart
helm lint ./chart
helm template ./chart -f values.yaml -f values-prod.yaml \
  | kubeconform -strict -kubernetes-version 1.29.0

Этот набор поймает синтаксические ошибки, undefined values и несовместимость с API нужной версии кластера.

Как масштабировать структуру при росте числа сервисов

Когда сервисов становится много, монорепозиторий чартов или отдельный репозиторий с общим library chart - это не вопрос вкуса, а инженерное решение.

Типичная структура монорепо для команды с несколькими сервисами:

charts/
  common/          # library chart с общими шаблонами
    Chart.yaml
    templates/
      _deployment.tpl
      _service.tpl
      _helpers.tpl
  service-a/       # сервисный чарт
    Chart.yaml
    charts/        # здесь лежит common после helm dep update
    templates/
    values.yaml
    values-prod.yaml
  service-b/
    ...

При таком подходе изменение в common требует обновления версии и запуска helm dependency update в каждом сервисном чарте. Это хорошо: изменения явные и контролируемые, нет магического наследования.

Если чарты публикуются в OCI-реестр (GitHub Container Registry, Harbor, AWS ECR), версионирование становится частью CI: push в main повышает патч-версию, релизный тег создает минорную или мажорную.

Чеклист: признаки здорового чарта

  • В values.yaml параметры сгруппированы по сущностям, есть комментарии для неочевидных ключей
  • Общие labels, annotations и фрагменты вынесены в _helpers.tpl, а не дублируются в каждом файле
  • Логика окружений реализована через отдельные values-файлы, а не через условия в шаблонах
  • Версии всех зависимостей зафиксированы в Chart.lock
  • В CI запускается helm lint и helm template | kubeconform
  • README содержит минимально необходимое: что деплоит чарт, какие values обязательны, как подключить окружения
  • Нет шаблонных файлов длиннее 100-150 строк без явной необходимости
  • Если несколько сервисов используют похожие паттерны - есть library chart или план его создания

Именование ресурсов и аннотации: системный подход

Когда чарт деплоит несколько ресурсов, именование становится отдельной задачей. Если называть Pod, ConfigMap и Secret по-разному в каждом манифесте, через несколько месяцев в кластере накапливаются ресурсы, связь которых непонятна без чтения исходников.

Хорошая практика: все ресурсы одного чарта получают имя через один хелпер. Обычно это {{ include "myapp.fullname" . }}, который собирает имя из release name и chart name. ConfigMap становится myapp-config, Secret - myapp-secret, ServiceAccount - myapp. Связь очевидна без документации.

С аннотациями та же логика. Если в проекте используется Argo CD, Flux или другой GitOps-инструмент, он часто добавляет собственные аннотации к ресурсам. Стоит заранее выделить блок для аннотаций в values.yaml и пробрасывать их через хелпер:

{{- define "myapp.annotations" -}}
{{- with .Values.commonAnnotations }}
{{- toYaml . }}
{{- end }}
{{- end }}

Тогда добавить аннотацию для конкретного окружения можно через values-prod.yaml без изменения шаблонов.

Документирование values: минимум, который реально помогает

Одна из причин, по которой инженеры избегают чужих чартов - непонятные параметры без объяснений. Написать «что это» занимает десять секунд, а экономит двадцать минут разбирательств.

Несколько правил, которые работают на практике:

  • Комментируй не очевидные ключи прямо над ними в values.yaml. Очевидный replicaCount не нуждается в пояснении, но terminationGracePeriodSeconds стоит прокомментировать.
  • Для булевых флагов вроде autoscaling.enabled всегда указывай, что именно включается и какие другие параметры становятся актуальными при значении true.
  • Если параметр обязателен и не имеет разумного дефолта, выстави пустую строку и добавь комментарий: # required: DNS-имя сервиса для ingress. Это лучше, чем скрытый дефолт, который работает только в одном окружении.

Для более крупных чартов удобно держать раздел в README с таблицей параметров. Но только если её реально обновляют. Устаревшая документация хуже, чем её отсутствие.

Простой способ проверить документацию чарта: попроси нового члена команды задеплоить чарт в тестовое окружение только по README и values.yaml, без объяснений. Там, где он застрянет, документация неполная. Это занимает тридцать минут и находит реальные пробелы лучше любого ревью.

Сравнение подходов к переиспользованию шаблонов

Вопрос «как переиспользовать шаблоны между несколькими сервисами» имеет несколько ответов, и каждый подходит для разного масштаба.

ПодходКогда подходитОсновной минус
Копирование шаблонов между чартамиОдин-два сервиса, прототипРасхождение версий со временем
Общий _helpers.tpl внутри одного чартаОдин чарт с несколькими ресурсамиНе помогает при разных чартах
Library chart как зависимостьТри и больше сервисов с общими паттернамиТребует версионирования и реестра
Umbrella chart с subchartsГруппа сервисов, деплоящихся вместеЖесткая связь версий всех компонентов

На практике команды часто начинают с копирования, потом переходят к library chart, когда синхронизация руками становится болью. Umbrella chart выбирают, когда несколько сервисов образуют единый продукт и разворачиваются только вместе.

Версионирование чарта и совместимость с приложением

В Chart.yaml есть два поля версии: version и appVersion. Их часто путают или синхронизируют по умолчанию, что создает проблемы.

appVersion - это версия приложения, которое деплоит чарт. Обычно это тег Docker-образа. Это поле меняется при каждом релизе приложения.

version - это версия самого чарта, то есть шаблонов и структуры values.yaml. Она должна меняться, только если изменилась инфраструктурная часть: добавили новый параметр, изменили структуру шаблона, добавили зависимость.

Если привязать обе версии к одному тегу, каждый релиз приложения выглядит как изменение инфраструктуры. Это ломает возможность откатиться к предыдущей версии чарта независимо от версии приложения - а это именно то, что нужно при откате после неудачного изменения конфигурации.

Простое правило: appVersion меняет CI при сборке образа, version меняет инженер при изменении шаблонов или values. Изменение в разных пайплайнах.

FAQ

Что такое library chart и зачем он нужен?

Library chart - это Helm-пакет типа library, который содержит только именованные шаблоны и не создает никаких Kubernetes-ресурсов при деплое. Его подключают как зависимость в обычные чарты, чтобы переиспользовать общие шаблоны (Deployment, Service, labels) без копипаста между сервисами.

Чем subchart отличается от library chart?

Subchart - это обычный чарт, который деплоит реальные ресурсы и включается как зависимость в родительский чарт. Типичный пример - Redis или PostgreSQL рядом с приложением. Library chart не деплоит ничего сам по себе - он только предоставляет переиспользуемые шаблонные блоки.

Как правильно разделять values для разных окружений?

Держи в базовом values.yaml безопасные дефолты, подходящие для разработки. Для staging и prod создавай отдельные файлы переопределений и передавай их через -f values-prod.yaml при деплое. Не добавляй имя окружения как параметр внутрь шаблонов.

Нужно ли фиксировать версии зависимостей через Chart.lock?

Да. Файл Chart.lock генерируется автоматически при helm dependency update и фиксирует точные версии загруженных зависимостей. Его нужно коммитить в репозиторий - это гарантирует воспроизводимость сборки в CI и защищает от неожиданных обновлений upstream-чартов.

Как проверить шаблоны без реального кластера?

Связка helm template и kubeconform покрывает большинство случаев: рендеринг шаблонов с нужными values и валидация против JSON Schema нужной версии Kubernetes. Для проверки установки можно использовать helm install --dry-run --debug, но он требует доступа к кластеру для проверки некоторых ресурсов.

Когда стоит разбивать один большой чарт на несколько?

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

Есть ли смысл использовать Helm для очень простых сервисов?

Для одного-двух манифестов без параметров Helm добавляет сложность без реальной пользы. Но как только появляется хотя бы два окружения с разными values или потребность в rollback через helm rollback, пакетирование в чарт начинает окупаться.

Итог

Хорошо организованный чарт - это не про красоту файловой структуры. Это про то, что изменение в одном месте не ломает пять других, новый инженер может разобраться за час, а деплой в prod отличается от staging только одним дополнительным файлом values.

Иерархические values, именованные шаблоны в хелперах, library charts для общих паттернов и фиксированные версии зависимостей - каждый из этих приемов решает конкретную проблему. Вместе они дают чарт, который не страшно обновлять через полгода и который выдержит рост команды и числа сервисов.