Когда в репозитории живёт десяток микросервисов, каждый со своим Helm-чартом, наступает момент, когда первое же изменение в стратегии деплоя превращается в марафон по одинаковым правкам. Поменять стандарт labels - десять файлов. Добавить общий sidecar для логирования - десять Deployment-шаблонов. Привести healthcheck к единой схеме - снова десять мест. И в каждом из этих десяти мест найдётся свой нюанс, потому что чарты делали разные люди в разное время. Самый болезненный момент в том, что чарты вроде бы одинаковые, но не настолько, чтобы можно было просто скопировать правку. Решение этой задачи существует, и оно встроено в Helm с третьей версии - это library chart. Грамотно построенная структура с одним библиотечным чартом и тонкими прикладными чартами для каждого микросервиса избавляет от копипасты и при этом сохраняет гибкость на уровне отдельных сервисов и окружений. Разбор того, как такая структура выглядит в реальном проекте, и есть тема этой статьи.

Зачем вообще нужен library chart и чем он отличается от обычного

Library chart это специальный тип чарта, который не разворачивается сам по себе. У него нет своих Deployment или Service в финальном выводе. Вместо этого он содержит набор именованных шаблонов (define-блоков), которые могут включаться в любые другие чарты через include. Прикладной чарт микросервиса получает доступ ко всему этому богатству через зависимость, описанную в Chart.yaml.

Главное отличие от subchart - именно в том, что subchart рендерится в собственные манифесты, а library chart только предоставляет шаблоны. Это критично, потому что в монорепо с десятью микросервисами нужны именно общие шаблоны, а не общий поднимаемый объект. Каждый сервис должен иметь собственный Deployment, собственный Service, собственный ConfigMap - но все они должны выглядеть одинаково и соответствовать одним и тем же конвенциям.

В Chart.yaml библиотечного чарта прописывается специальный тип:

apiVersion: v2
name: common-lib
description: Общая библиотека шаблонов для микросервисов
type: library
version: 0.4.2

Тип library обязателен. Без него Helm попробует развернуть чарт как обычный, и тут же выдаст ошибку из-за отсутствия шаблонов на верхнем уровне. В прикладном чарте библиотека подключается как обычная зависимость:

apiVersion: v2
name: orders-service
description: Сервис заказов
type: application
version: 1.12.0
appVersion: "2.7.3"
dependencies:
  - name: common-lib
    version: 0.4.x
    repository: "file://../../charts/common-lib"

Репозиторий file:// здесь работает в пределах монорепо - Helm подтянет библиотечный чарт прямо из соседнего каталога. Для разнесённых репозиториев на этом месте была бы OCI-ссылка на registry.

Структура каталогов, которая держит порядок

Раскладка монорепо с микросервисами и library chart выглядит примерно так. Структура опирается на опыт нескольких реальных проектов и хорошо ложится на инструменты вроде Helmfile или ArgoCD:

monorepo/
├── charts/
│   └── common-lib/
│       ├── Chart.yaml
│       ├── values.yaml
│       └── templates/
│           ├── _helpers.tpl
│           ├── _deployment.tpl
│           ├── _service.tpl
│           ├── _ingress.tpl
│           ├── _configmap.tpl
│           ├── _hpa.tpl
│           ├── _pdb.tpl
│           └── _serviceaccount.tpl
├── services/
│   ├── orders-service/
│   │   ├── src/
│   │   ├── Dockerfile
│   │   └── helm/
│   │       ├── Chart.yaml
│   │       ├── values.yaml
│   │       └── templates/
│   │           ├── deployment.yaml
│   │           ├── service.yaml
│   │           ├── ingress.yaml
│   │           └── hpa.yaml
│   ├── payments-service/
│   │   └── helm/
│   │       ├── Chart.yaml
│   │       ├── values.yaml
│   │       └── templates/
│   └── notifications-service/
│       └── helm/
├── environments/
│   ├── dev/
│   │   ├── orders-service.values.yaml
│   │   ├── payments-service.values.yaml
│   │   └── notifications-service.values.yaml
│   ├── staging/
│   │   ├── orders-service.values.yaml
│   │   ├── payments-service.values.yaml
│   │   └── notifications-service.values.yaml
│   └── prod/
│       ├── orders-service.values.yaml
│       ├── payments-service.values.yaml
│       └── notifications-service.values.yaml
└── helmfile.yaml

В этой раскладке три ключевых уровня. Библиотечный чарт лежит в charts/common-lib/ и содержит все общие шаблоны. Прикладные чарты живут рядом с кодом каждого сервиса в services/{name}/helm/. Окружение-специфичные values отделены от чартов и хранятся в environments/{env}/ - это позволяет менять параметры конкретного сервиса в конкретной среде, не трогая ни сам чарт, ни базовые values.

Что обычно лежит в library chart

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

  1. Хелперы наименования (имя приложения, fullname с префиксом релиза, общий набор labels);
  2. Стандартный шаблон Deployment с probes, ресурсами, environment variables;
  3. Service для ClusterIP-доступа внутри кластера;
  4. Ingress с типовыми аннотациями для nginx-controller или Istio;
  5. ConfigMap для прикладной конфигурации;
  6. HorizontalPodAutoscaler с настройками по CPU и памяти;
  7. PodDisruptionBudget для гарантии минимального числа подов;
  8. ServiceAccount с понятным именем и привязками к IAM-роли.

Каждый шаблон оборачивается в define-блок. Вот как выглядит общий шаблон Deployment:

{{- define "common-lib.deployment" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "common-lib.fullname" . }}
  labels:
    {{- include "common-lib.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount | default 2 }}
  selector:
    matchLabels:
      {{- include "common-lib.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "common-lib.selectorLabels" . | nindent 8 }}
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
    spec:
      serviceAccountName: {{ include "common-lib.serviceAccountName" . }}
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.image.pullPolicy | default "IfNotPresent" }}
          ports:
            - name: http
              containerPort: {{ .Values.service.port | default 8080 }}
              protocol: TCP
          env:
            {{- with .Values.env }}
            {{- toYaml . | nindent 12 }}
            {{- end }}
          envFrom:
            - configMapRef:
                name: {{ include "common-lib.fullname" . }}
          {{- if .Values.probes.liveness.enabled }}
          livenessProbe:
            httpGet:
              path: {{ .Values.probes.liveness.path | default "/health/live" }}
              port: http
            initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds | default 30 }}
            periodSeconds: {{ .Values.probes.liveness.periodSeconds | default 10 }}
          {{- end }}
          {{- if .Values.probes.readiness.enabled }}
          readinessProbe:
            httpGet:
              path: {{ .Values.probes.readiness.path | default "/health/ready" }}
              port: http
            initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds | default 5 }}
            periodSeconds: {{ .Values.probes.readiness.periodSeconds | default 5 }}
          {{- end }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
{{- end }}

В этом шаблоне видна вся идея библиотечного подхода - один раз корректно описанный Deployment покрывает 90% случаев. Прикладной чарт сервиса теперь содержит всего одну строчку для разворачивания такого Deployment:

{{ include "common-lib.deployment" . }}

Этот файл лежит в services/orders-service/helm/templates/deployment.yaml, и больше ничего там не нужно. Helm возьмёт define-блок из библиотечного чарта, подставит values текущего сервиса и сгенерирует готовый манифест.

Хелперы наименования и labels

Самое часто включаемое в любой шаблон это набор хелперов для имён и меток. Они должны быть единообразными по всему проекту, иначе на проде окажется зоопарк из несовместимых селекторов. Файл charts/common-lib/templates/_helpers.tpl выглядит так:

{{/*
Имя чарта - используется для наименования объектов.
*/}}
{{- define "common-lib.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Полное имя релиза. Если установлен fullnameOverride - используется он.
Иначе - префикс из имени релиза + имя чарта.
*/}}
{{- define "common-lib.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}

{{/*
Стандартный набор labels, который применяется ко всем ресурсам.
*/}}
{{- define "common-lib.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{ include "common-lib.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOf | default "platform" }}
{{- end }}

{{/*
Selector labels - те, которые используются в selector.matchLabels.
Их менять после первого деплоя нельзя - Kubernetes этого не позволит.
*/}}
{{- define "common-lib.selectorLabels" -}}
app.kubernetes.io/name: {{ include "common-lib.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{/*
Имя ServiceAccount.
*/}}
{{- define "common-lib.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "common-lib.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

Разделение labels на полный набор (common-lib.labels) и селекторные (common-lib.selectorLabels) - принципиально важная деталь. Селекторные labels попадают в spec.selector.matchLabels у Deployment, и Kubernetes не позволяет менять их после первого развёртывания. Если по ошибке положить туда что-то изменяющееся между релизами (например, версию чарта), при следующем helm upgrade прилетит ошибка вида "selector field is immutable", и единственный выход - удалять и пересоздавать ресурс с даунтаймом.

Прикладной чарт микросервиса и его values.yaml

Прикладной чарт получается удивительно тонким. Вот как выглядит весь services/orders-service/helm/templates/:

# templates/deployment.yaml
{{ include "common-lib.deployment" . }}
# templates/service.yaml
{{ include "common-lib.service" . }}
# templates/configmap.yaml
{{ include "common-lib.configmap" . }}
# templates/ingress.yaml
{{- if .Values.ingress.enabled }}
{{ include "common-lib.ingress" . }}
{{- end }}
# templates/hpa.yaml
{{- if .Values.autoscaling.enabled }}
{{ include "common-lib.hpa" . }}
{{- end }}

Весь "код" сервиса умещается в пять файлов по одной строке. Если в проекте появляется потребность в специфичном ресурсе, который не покрывается библиотекой (например, кастомный CronJob или особенный NetworkPolicy), он добавляется в виде отдельного шаблона прямо в этом чарте - библиотека гибкая ровно в той части, в которой она единообразна.

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

replicaCount: 2

image:
  repository: registry.company.io/orders-service
  tag: ""  # подтянется из appVersion
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 8080

ingress:
  enabled: false
  className: nginx
  hosts: []

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

probes:
  liveness:
    enabled: true
    path: /health/live
    initialDelaySeconds: 30
  readiness:
    enabled: true
    path: /health/ready
    initialDelaySeconds: 5

autoscaling:
  enabled: false
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

env:
  - name: SERVICE_NAME
    value: orders-service
  - name: LOG_LEVEL
    value: info

serviceAccount:
  create: true
  annotations: {}

Заметна одна важная деталь - в этом базовом values нет ничего, что зависит от окружения. Никаких database_url, api_endpoints, replicaCount=20. Только разумные дефолты, которые работают в development-окружении одного разработчика. Всё боевое подкладывается отдельно.

Переопределение values по окружениям

Окружение-специфичные параметры живут в каталоге environments/. Каждый файл переопределяет ровно то, что должно отличаться в этом окружении. Для production-сервиса заказов это может выглядеть так:

# environments/prod/orders-service.values.yaml
replicaCount: 6

image:
  tag: "2.7.3"
  pullPolicy: Always

ingress:
  enabled: true
  className: nginx
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/rate-limit: "100"
  hosts:
    - host: api.company.com
      paths:
        - path: /orders
          pathType: Prefix
  tls:
    - secretName: orders-tls
      hosts:
        - api.company.com

resources:
  requests:
    cpu: 500m
    memory: 1Gi
  limits:
    cpu: 2000m
    memory: 2Gi

autoscaling:
  enabled: true
  minReplicas: 6
  maxReplicas: 30
  targetCPUUtilizationPercentage: 60

env:
  - name: SERVICE_NAME
    value: orders-service
  - name: LOG_LEVEL
    value: warn
  - name: DATABASE_URL
    valueFrom:
      secretKeyRef:
        name: orders-db-credentials
        key: connection-string

partOf: commerce

Для staging тот же сервис будет иметь свой файл с другими параметрами - меньше реплик, мягче лимиты, отдельный домен. Для dev - вообще минимальные значения и отключенный Ingress. Развёртывание идёт через helm с явным указанием обоих values-файлов:

helm upgrade --install orders \
  ./services/orders-service/helm \
  -f ./services/orders-service/helm/values.yaml \
  -f ./environments/prod/orders-service.values.yaml \
  --namespace orders-prod \
  --create-namespace

Helm объединяет values по правилу "последний переопределяет предыдущий". Это даёт чистую иерархию - чем дальше от базы, тем выше приоритет. Сначала defaults библиотечного чарта, потом values самого сервиса, потом окружение, потом флаги --set из CI.

Helmfile как удобная обёртка над пачкой релизов

При десяти микросервисах и трёх окружениях ручной запуск helm upgrade для каждой пары становится утомительным. Helmfile решает это, декларативно описывая, что должно быть развёрнуто. Корневой helmfile.yaml выглядит так:

environments:
  dev:
    values:
      - environment: dev
        namespace: services-dev
  staging:
    values:
      - environment: staging
        namespace: services-staging
  prod:
    values:
      - environment: prod
        namespace: services-prod

releases:
  - name: orders
    namespace: "{{ .Values.namespace }}"
    chart: ./services/orders-service/helm
    values:
      - ./services/orders-service/helm/values.yaml
      - ./environments/{{ .Values.environment }}/orders-service.values.yaml

  - name: payments
    namespace: "{{ .Values.namespace }}"
    chart: ./services/payments-service/helm
    values:
      - ./services/payments-service/helm/values.yaml
      - ./environments/{{ .Values.environment }}/payments-service.values.yaml

  - name: notifications
    namespace: "{{ .Values.namespace }}"
    chart: ./services/notifications-service/helm
    values:
      - ./services/notifications-service/helm/values.yaml
      - ./environments/{{ .Values.environment }}/notifications-service.values.yaml

Развёртывание всех сервисов в production одной командой:

helmfile -e prod apply

Для одного сервиса используется фильтр:

helmfile -e prod -l name=orders apply

Helmfile сам разрешит зависимости, скачает библиотечный чарт, отрендерит все шаблоны и применит изменения. CI/CD-пайплайн вызывает примерно одну команду и получает консистентный деплой.

Подводные камни, которые ловят на больших проектах

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

  1. Изменение selectorLabels между версиями приводит к ошибке "selector field is immutable". Селекторные labels должны быть зафиксированы навсегда с первого деплоя. Если очень нужно сменить - только через uninstall и переустановку с даунтаймом;
  2. Условные блоки на основе .Values.env == "prod" внутри шаблонов это плохая практика. Лучше выносить окружение-специфику в values, а шаблон оставлять одинаковым для всех сред;
  3. Если values-файл окружения содержит ключи, которых нет в schema, helm не выдаст ошибку. Стоит подключать values.schema.json для валидации структуры;
  4. Library chart нельзя устанавливать сам по себе - helm install common-lib падает. Это не баг, это by design, но удивляет тех, кто видит library chart впервые;
  5. Кэширование зависимостей через helm dependency update создаёт файлы в charts/ внутри прикладного чарта. Их нужно либо добавить в .gitignore, либо коммитить целиком для воспроизводимости;
  6. checksum/config-аннотация на Deployment, ссылающаяся на ConfigMap, должна вычисляться через include с относительным путём, а не через явное чтение файла. Иначе при изменении values, влияющих на ConfigMap, поды не перезапустятся автоматически.

Структура с library chart и переопределением values по окружениям не претендует на универсальный ответ для всех проектов. Для двух-трёх микросервисов её настройка избыточна, можно обойтись копированием. Но как только в репозитории появляется пятый сервис и третье окружение, время на поддержку дублированных чартов начинает расти быстрее, чем число сервисов. Library chart переламывает эту тенденцию. Десятый сервис добавляется в репозиторий как пять файлов по строке и один values.yaml. Изменение общей конвенции - одна правка в библиотечном чарте, и все десять сервисов начинают использовать обновлённую логику в следующем релизе. Это и есть та инженерная инфраструктура, ради которой Helm в принципе придуман.