Helm Charts для Kubernetes: как мы упаковали 15 микросервисов SaaS-платформы в единый пайплайн деплоя

Ситуация: 15 сервисов, 15 копипаст-манифестов

SaaS-платформа «ПлатформаX» обратилась к нам в itfresh.ru с проблемой, знакомой каждому DevOps-инженеру: 15 микросервисов, для каждого — отдельный набор YAML-манифестов Kubernetes. Deployment, Service, ConfigMap, Ingress, HPA — в сумме более 80 файлов, которые различались между собой на 5-10%.

Основные проблемы:

  • Копипаст — при добавлении нового label или annotation приходилось менять 15 файлов. Забыл один — получил инцидент в продакшене.
  • Три окружения (dev, staging, prod) — тройная копия манифестов с разными значениями replica count, resource limits, image tags. Итого 240+ файлов.
  • Нет версионирования — невозможно откатить конфигурацию к предыдущей версии. Rollback означал «вспомни, что было, и вручную поправь».
  • Onboarding — новый сервис добавлялся 2-3 дня, потому что нужно было скопировать манифесты, поменять 40 мест и ничего не забыть.

Helm: основные концепции

Helm — пакетный менеджер для Kubernetes. Три ключевых понятия:

  • Chart — пакет с шаблонами Kubernetes-манифестов и метаданными. Аналог deb/rpm пакета, но для K8s.
  • Release — конкретная установка чарта в кластер. Один чарт можно установить несколько раз с разными именами и параметрами.
  • Repository — хранилище чартов (как apt-репозиторий). Может быть Chart Museum, Harbor, OCI-реестр или просто HTTP-сервер с index.yaml.

Установка Helm тривиальна:

# Установка Helm 3
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash

# Проверка
helm version
# version.BuildInfo{Version:"v3.14.2", ...}

# Добавляем популярные репозитории
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update

# Поиск чарта
helm search repo postgresql
# NAME                    CHART VERSION   APP VERSION
# bitnami/postgresql      15.2.5          16.2.0

# Установка чарта в кластер
helm install my-postgres bitnami/postgresql \
  --namespace databases \
  --create-namespace \
  --set auth.postgresPassword=secretpass \
  --set primary.persistence.size=50Gi

# Список установленных релизов
helm list -A
# NAME          NAMESPACE   STATUS    CHART
# my-postgres   databases   deployed  postgresql-15.2.5

Структура чарта и шаблоны

Мы создали базовый чарт platformx-service, который покрывает 90% потребностей любого сервиса платформы:

platformx-service/
├── Chart.yaml              # Метаданные чарта
├── values.yaml             # Значения по умолчанию
├── values-dev.yaml         # Переопределения для dev
├── values-staging.yaml     # Переопределения для staging
├── values-prod.yaml        # Переопределения для prod
├── templates/
│   ├── _helpers.tpl        # Вспомогательные шаблоны
│   ├── deployment.yaml     # Deployment
│   ├── service.yaml        # Service
│   ├── ingress.yaml        # Ingress (опциональный)
│   ├── hpa.yaml            # HorizontalPodAutoscaler
│   ├── configmap.yaml      # ConfigMap
│   ├── secret.yaml         # Secret (из external-secrets)
│   ├── serviceaccount.yaml # ServiceAccount
│   ├── pdb.yaml            # PodDisruptionBudget
│   ├── NOTES.txt           # Текст после установки
│   └── tests/
│       └── test-connection.yaml
└── charts/                 # Зависимости (субчарты)

Chart.yaml описывает метаданные:

# Chart.yaml
apiVersion: v2
name: platformx-service
description: Universal Helm chart for PlatformaX microservices
type: application
version: 2.4.1          # Версия чарта (меняется при изменении шаблонов)
appVersion: "1.0.0"     # Версия приложения (переопределяется при деплое)
maintainers:
  - name: ITFresh DevOps
    email: devops@itfresh.ru
keywords:
  - platformx
  - microservice
dependencies:
  - name: redis
    version: "18.x.x"
    repository: https://charts.bitnami.com/bitnami
    condition: redis.enabled

Главная сила Helm — Go templates. Шаблон deployment.yaml использует значения из values.yaml и вспомогательные функции:

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "platformx-service.fullname" . }}
  labels:
    {{- include "platformx-service.labels" . | nindent 4 }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      {{- include "platformx-service.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
      labels:
        {{- include "platformx-service.selectorLabels" . | nindent 8 }}
    spec:
      serviceAccountName: {{ include "platformx-service.serviceAccountName" . }}
      {{- with .Values.imagePullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: {{ .Values.service.targetPort | default 8080 }}
              protocol: TCP
          {{- if .Values.health.enabled }}
          livenessProbe:
            httpGet:
              path: {{ .Values.health.livenessPath | default "/healthz" }}
              port: http
            initialDelaySeconds: {{ .Values.health.livenessDelay | default 15 }}
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: {{ .Values.health.readinessPath | default "/readyz" }}
              port: http
            initialDelaySeconds: {{ .Values.health.readinessDelay | default 5 }}
            periodSeconds: 5
          {{- end }}
          env:
            {{- range $key, $value := .Values.env }}
            - name: {{ $key }}
              value: {{ $value | quote }}
            {{- end }}
            {{- range $key, $value := .Values.envFromSecret }}
            - name: {{ $key }}
              valueFrom:
                secretKeyRef:
                  name: {{ $value.secretName }}
                  key: {{ $value.key }}
            {{- end }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
      {{- with .Values.nodeSelector }}
      nodeSelector:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.tolerations }}
      tolerations:
        {{- toYaml . | nindent 8 }}
      {{- end }}

Helpers и values.yaml: DRY-конфигурация

Файл _helpers.tpl содержит переиспользуемые шаблоны. Это ключ к DRY-подходу — определяем логику один раз, используем везде:

# templates/_helpers.tpl

{{/* Полное имя ресурса (с учётом release name) */}}
{{- define "platformx-service.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 по Kubernetes conventions */}}
{{- define "platformx-service.labels" -}}
helm.sh/chart: {{ include "platformx-service.chart" . }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: platformx
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{ include "platformx-service.selectorLabels" . }}
{{- end }}

{{/* Selector labels — минимальный набор для matchLabels */}}
{{- define "platformx-service.selectorLabels" -}}
app.kubernetes.io/name: {{ include "platformx-service.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{/* ServiceAccount name */}}
{{- define "platformx-service.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "platformx-service.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

Файл values.yaml задаёт значения по умолчанию. Для каждого сервиса мы переопределяем только то, что отличается:

# values.yaml — значения по умолчанию
replicaCount: 2

image:
  repository: registry.platformx.ru/services/api-gateway
  tag: "latest"
  pullPolicy: IfNotPresent

imagePullSecrets:
  - name: registry-credentials

service:
  type: ClusterIP
  port: 80
  targetPort: 8080

ingress:
  enabled: false
  className: nginx
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
  hosts:
    - host: api.platformx.ru
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: api-tls
      hosts:
        - api.platformx.ru

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

autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70
  targetMemoryUtilizationPercentage: 80

health:
  enabled: true
  livenessPath: /healthz
  readinessPath: /readyz
  livenessDelay: 15
  readinessDelay: 5

env:
  LOG_LEVEL: "info"
  LOG_FORMAT: "json"

envFromSecret: {}

redis:
  enabled: false

serviceAccount:
  create: true
  name: ""

podDisruptionBudget:
  enabled: true
  minAvailable: 1

Для конкретного сервиса — например, billing-service — создаётся минимальный файл переопределений:

# services/billing-service/values-prod.yaml
replicaCount: 4

image:
  repository: registry.platformx.ru/services/billing-service
  tag: "3.12.1"

resources:
  requests:
    cpu: 500m
    memory: 512Mi
  limits:
    cpu: 2000m
    memory: 2Gi

autoscaling:
  minReplicas: 4
  maxReplicas: 20

env:
  LOG_LEVEL: "warn"
  DB_HOST: "postgres-billing.prod.svc.cluster.local"
  KAFKA_BROKERS: "kafka-0:9092,kafka-1:9092,kafka-2:9092"

envFromSecret:
  DB_PASSWORD:
    secretName: billing-db-credentials
    key: password
  STRIPE_API_KEY:
    secretName: billing-stripe
    key: api-key

redis:
  enabled: true
  architecture: standalone
  auth:
    password: "" # Из external-secrets

Helm hooks и зависимости

Helm hooks позволяют выполнять действия в определённые моменты жизненного цикла релиза. Мы активно используем три типа:

# templates/hooks/db-migrate.yaml
# Запускает миграцию БД перед обновлением приложения
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "platformx-service.fullname" . }}-db-migrate
  labels:
    {{- include "platformx-service.labels" . | nindent 4 }}
  annotations:
    "helm.sh/hook": pre-upgrade,pre-install
    "helm.sh/hook-weight": "-5"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  backoffLimit: 3
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          command: ["./migrate", "up"]
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: {{ include "platformx-service.fullname" . }}-db
                  key: url

Зависимости (субчарты) объявляются в Chart.yaml и управляются через values.yaml. Billing-service, например, зависит от Redis для кэширования:

# Управление зависимостями
helm dependency update ./platformx-service
# Downloading redis from https://charts.bitnami.com/bitnami
# Saving redis-18.6.1.tgz to charts/

helm dependency list ./platformx-service
# NAME    VERSION   REPOSITORY                              STATUS
# redis   18.x.x    https://charts.bitnami.com/bitnami      ok

Условная активация через condition в Chart.yaml и значение в values.yaml — Redis поднимается только для тех сервисов, где redis.enabled: true. Остальные сервисы не тратят ресурсы на ненужный Redis.

Chart Museum и Harbor как Helm-репозиторий

Хранить чарты в Git-репозитории рядом с кодом — допустимо для маленьких команд, но для 15 сервисов с CI/CD нужен полноценный Helm-репозиторий. Мы развернули Chart Museum, а позже мигрировали на Harbor:

# Вариант 1: Chart Museum — легковесный Helm repo
docker run -d \
  --name chartmuseum \
  -p 8082:8080 \
  -e DEBUG=true \
  -e STORAGE=local \
  -e STORAGE_LOCAL_ROOTDIR=/charts \
  -e AUTH_ANONYMOUS_GET=true \
  -e BASIC_AUTH_USER=admin \
  -e BASIC_AUTH_PASS='Ch@rtMus3um!' \
  -v /data/chartmuseum:/charts \
  ghcr.io/helm/chartmuseum:v0.16.1

# Добавляем репозиторий
helm repo add platformx http://chartmuseum.internal:8082 \
  --username admin --password 'Ch@rtMus3um!'

# Пакуем и публикуем чарт
helm package ./platformx-service --version 2.4.1
# Successfully packaged chart: platformx-service-2.4.1.tgz

curl -u admin:'Ch@rtMus3um!' \
  --data-binary @platformx-service-2.4.1.tgz \
  http://chartmuseum.internal:8082/api/charts

# Вариант 2: Harbor — полноценный registry с Helm
# Harbor поддерживает OCI-формат для чартов (Helm 3.8+)
helm registry login harbor.platformx.ru \
  --username admin --password 'H@rb0rPass'

# Публикация через OCI
helm push platformx-service-2.4.1.tgz oci://harbor.platformx.ru/charts

# Установка из OCI
helm install billing-service \
  oci://harbor.platformx.ru/charts/platformx-service \
  --version 2.4.1 \
  -f services/billing-service/values-prod.yaml \
  --namespace billing

Мы выбрали Harbor, потому что он уже использовался как Docker-реестр. Единое место для Docker-образов и Helm-чартов упрощает управление доступом и аудит.

Helmfile: оркестрация 15 сервисов

Устанавливать 15 сервисов по одному через helm install — неудобно. Helmfile позволяет декларативно описать все релизы в одном файле:

# helmfile.yaml — единая точка управления всеми сервисами
repositories:
  - name: platformx
    url: https://harbor.platformx.ru/chartrepo/charts
    username: {{ requiredEnv "HELM_USER" }}
    password: {{ requiredEnv "HELM_PASS" }}
  - name: bitnami
    url: https://charts.bitnami.com/bitnami

environments:
  dev:
    values:
      - environments/dev/defaults.yaml
  staging:
    values:
      - environments/staging/defaults.yaml
  prod:
    values:
      - environments/prod/defaults.yaml

---
releases:
  # API Gateway
  - name: api-gateway
    namespace: platformx
    chart: platformx/platformx-service
    version: 2.4.1
    values:
      - services/api-gateway/values.yaml
      - services/api-gateway/values-{{ .Environment.Name }}.yaml
    set:
      - name: image.tag
        value: {{ requiredEnv "API_GATEWAY_TAG" | default "latest" }}

  # Billing Service
  - name: billing-service
    namespace: platformx
    chart: platformx/platformx-service
    version: 2.4.1
    values:
      - services/billing-service/values.yaml
      - services/billing-service/values-{{ .Environment.Name }}.yaml
    needs:
      - platformx/postgres-billing
      - platformx/redis-billing

  # User Service
  - name: user-service
    namespace: platformx
    chart: platformx/platformx-service
    version: 2.4.1
    values:
      - services/user-service/values.yaml
      - services/user-service/values-{{ .Environment.Name }}.yaml

  # ... остальные 12 сервисов по аналогии

  # Инфраструктурные зависимости
  - name: postgres-billing
    namespace: platformx
    chart: bitnami/postgresql
    version: 15.2.5
    values:
      - infrastructure/postgres-billing/values-{{ .Environment.Name }}.yaml

  - name: redis-billing
    namespace: platformx
    chart: bitnami/redis
    version: 18.6.1
    condition: billing.redis.enabled
    values:
      - infrastructure/redis-billing/values-{{ .Environment.Name }}.yaml

Деплой всех сервисов в staging одной командой:

# Применить все релизы в staging
helmfile -e staging apply

# Diff перед применением (показывает что изменится)
helmfile -e staging diff

# Применить только один сервис
helmfile -e staging -l name=billing-service apply

# Синхронизировать состояние (удалить лишние, добавить новые)
helmfile -e prod sync

Ключевая фича — needs: billing-service не будет развёрнут, пока не поднимутся PostgreSQL и Redis. Helmfile разрешает зависимости и деплоит в правильном порядке.

Тестирование чартов и семантическое версионирование

Мы встроили тестирование чартов в CI/CD пайплайн на трёх уровнях:

# 1. Lint — проверка синтаксиса и best practices
helm lint ./platformx-service -f services/billing-service/values-prod.yaml
# ==> Linting ./platformx-service
# [INFO] Chart.yaml: icon is recommended
# 1 chart(s) linted, 0 chart(s) failed

# 2. Template — рендеринг шаблонов без установки в кластер
helm template billing-service ./platformx-service \
  -f services/billing-service/values-prod.yaml \
  --namespace platformx | kubectl apply --dry-run=server -f -

# 3. ct (chart-testing) — полная валидация
ct lint --config ct.yaml --charts ./platformx-service
ct install --config ct.yaml --charts ./platformx-service \
  --namespace ct-test --helm-extra-args "--timeout 300s"

В CI/CD пайплайне (GitLab CI):

# .gitlab-ci.yml — пайплайн для Helm-чартов
stages:
  - lint
  - test
  - package
  - publish

helm-lint:
  stage: lint
  image: alpine/helm:3.14.2
  script:
    - helm lint ./platformx-service
    - helm template test ./platformx-service -f values-test.yaml | 
      kubectl apply --dry-run=client -f -
  rules:
    - changes:
        - platformx-service/**/*

helm-test:
  stage: test
  image: quay.io/helmpack/chart-testing:v3.10.1
  script:
    - ct lint-and-install --config ct.yaml --charts ./platformx-service
  rules:
    - if: $CI_MERGE_REQUEST_ID

helm-package:
  stage: package
  image: alpine/helm:3.14.2
  script:
    - helm package ./platformx-service --version ${CHART_VERSION}
  artifacts:
    paths:
      - "*.tgz"
  rules:
    - if: $CI_COMMIT_TAG =~ /^chart-v/

helm-publish:
  stage: publish
  image: alpine/helm:3.14.2
  script:
    - helm registry login harbor.platformx.ru -u $HELM_USER -p $HELM_PASS
    - helm push platformx-service-${CHART_VERSION}.tgz 
        oci://harbor.platformx.ru/charts
  rules:
    - if: $CI_COMMIT_TAG =~ /^chart-v/

Семантическое версионирование чарта:

  • PATCH (2.4.1 → 2.4.2) — исправление бага в шаблоне, обновление документации
  • MINOR (2.4.2 → 2.5.0) — новый опциональный параметр в values.yaml, новый шаблон (PDB, NetworkPolicy)
  • MAJOR (2.5.0 → 3.0.0) — ломающее изменение: переименование ключей в values.yaml, удаление параметра, изменение дефолтного поведения

Версия чарта (version в Chart.yaml) и версия приложения (appVersion) — независимы. Чарт может обновиться без изменения приложения, и наоборот.

Результаты и выводы

После внедрения Helm-инфраструктуры для «ПлатформаX» получили измеримые результаты:

МетрикаДо (сырые YAML)После (Helm + Helmfile)
Файлов конфигурации240+1 чарт + 15 values-файлов
Добавление нового сервиса2-3 дня30 минут
Обновление общей конфигурации15 файлов вручную1 изменение в чарте
Деплой всех сервисов45 минут (поштучно)5 минут (helmfile apply)
Откат«вспомни и поправь»helm rollback (30 секунд)
Инциденты из-за конфигурации3-4 в месяц0 за 3 месяца

Ключевые рекомендации:

  • Один универсальный чарт для однотипных сервисов — лучше, чем 15 копий. Различия выносите в values.
  • Helmfile — обязательный инструмент для проектов с 5+ сервисами. Без него helmfile diff и helmfile apply потеряют смысл.
  • Семантическое версионирование чартов — не опция, а необходимость. Без него невозможно безопасно обновлять чарт для 15 сервисов.
  • Тестируйте чарты в CI: helm lint + template dry-run + ct install ловят 95% ошибок до продакшена.

Если вашему проекту нужна упаковка Kubernetes-приложений в Helm — обращайтесь к нам в itfresh.ru.

Часто задаваемые вопросы

Главное отличие — в Helm 3 убран Tiller, серверный компонент, который работал внутри кластера с правами cluster-admin. Это было главной проблемой безопасности Helm 2. Теперь Helm работает напрямую с Kubernetes API, используя kubeconfig текущего пользователя. Также в Helm 3 релизы хранятся как Secrets в namespace установки, а не в отдельном namespace Tiller.
Если сервисы однотипные (одинаковый стек, похожая конфигурация) — один универсальный чарт с параметризацией через values.yaml. Если сервисы принципиально разные (например, Go-API и Spark-задача) — разные чарты. В проекте «ПлатформаX» один чарт покрывал 13 из 15 сервисов, для двух оставшихся (cron-jobs и websocket-gateway) пришлось создать отдельные.
Никогда не храните секреты в values.yaml в Git. Три подхода: 1) helm-secrets плагин с SOPS — шифрует values-файл через AWS KMS или PGP. 2) External Secrets Operator — Helm создаёт ExternalSecret-ресурс, ESO подтягивает значение из Vault или AWS Secrets Manager. 3) Sealed Secrets — шифруете секрет через kubeseal, в Git хранится зашифрованная версия. Мы используем ESO + HashiCorp Vault — наиболее гибкий и безопасный вариант.
Kustomize и Helm решают разные задачи. Kustomize — патчинг YAML без шаблонизации, подходит для простых переопределений между окружениями. Helm — полноценный пакетный менеджер с шаблонизацией, зависимостями, хуками и версионированием. Для 3-5 простых сервисов Kustomize достаточно. Для 15+ сервисов с зависимостями, хуками миграций и CI/CD пайплайном — Helm незаменим.
Команда helm rollback возвращает к предыдущей ревизии за секунды: helm rollback billing-service 5 откатит к ревизии 5. Helm хранит историю всех ревизий (по умолчанию 10). Посмотреть историю: helm history billing-service. Важно: rollback не откатывает миграции БД — если pre-upgrade hook уже применил миграцию, нужен отдельный down-скрипт.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#helm#kubernetes#helm charts#values.yaml#go templates#helmfile#chart museum#harbor helm
Комментарии 0

Оставить комментарий

загрузка...