Когда в репозитории живёт десяток микросервисов, каждый со своим 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-сервиса это:
- Хелперы наименования (имя приложения, fullname с префиксом релиза, общий набор labels);
- Стандартный шаблон Deployment с probes, ресурсами, environment variables;
- Service для ClusterIP-доступа внутри кластера;
- Ingress с типовыми аннотациями для nginx-controller или Istio;
- ConfigMap для прикладной конфигурации;
- HorizontalPodAutoscaler с настройками по CPU и памяти;
- PodDisruptionBudget для гарантии минимального числа подов;
- 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-пайплайн вызывает примерно одну команду и получает консистентный деплой.
Подводные камни, которые ловят на больших проектах
Несколько типичных ошибок, которые быстро обнаруживаются в монорепо с библиотечным чартом:
- Изменение selectorLabels между версиями приводит к ошибке "selector field is immutable". Селекторные labels должны быть зафиксированы навсегда с первого деплоя. Если очень нужно сменить - только через uninstall и переустановку с даунтаймом;
- Условные блоки на основе .Values.env == "prod" внутри шаблонов это плохая практика. Лучше выносить окружение-специфику в values, а шаблон оставлять одинаковым для всех сред;
- Если values-файл окружения содержит ключи, которых нет в schema, helm не выдаст ошибку. Стоит подключать values.schema.json для валидации структуры;
- Library chart нельзя устанавливать сам по себе - helm install common-lib падает. Это не баг, это by design, но удивляет тех, кто видит library chart впервые;
- Кэширование зависимостей через helm dependency update создаёт файлы в charts/ внутри прикладного чарта. Их нужно либо добавить в .gitignore, либо коммитить целиком для воспроизводимости;
- checksum/config-аннотация на Deployment, ссылающаяся на ConfigMap, должна вычисляться через include с относительным путём, а не через явное чтение файла. Иначе при изменении values, влияющих на ConfigMap, поды не перезапустятся автоматически.
Структура с library chart и переопределением values по окружениям не претендует на универсальный ответ для всех проектов. Для двух-трёх микросервисов её настройка избыточна, можно обойтись копированием. Но как только в репозитории появляется пятый сервис и третье окружение, время на поддержку дублированных чартов начинает расти быстрее, чем число сервисов. Library chart переламывает эту тенденцию. Десятый сервис добавляется в репозиторий как пять файлов по строке и один values.yaml. Изменение общей конвенции - одна правка в библиотечном чарте, и все десять сервисов начинают использовать обновлённую логику в следующем релизе. Это и есть та инженерная инфраструктура, ради которой Helm в принципе придуман.