Helmfile — обёртка для helm, которая позволяет в одном месте описывать множество helm релизов, параметризовать их чарты для нескольких окружений, а также задавать порядок их деплоя.


О самом helmfile и примерах его использования можно почитать в readme и best practices guide.


Мы же познакомимся с неочевидными способами описать релизы в helmfile


Допустим, у нас есть пачка helm-чартов (для примера пусть будет postgres и некое backend приложение) и несколько окружений (несколько kubernetes кластеров, несколько namespace'ов или несколько и того, и другого). Берём helmfile, читаем документацию и начинаем описывать наши окружения и релизы:


    .
    +-- envs
    ¦   +-- devel
    ¦   ¦   L-- values
    ¦   ¦       +-- backend.yaml
    ¦   ¦       L-- postgres.yaml
    ¦   L-- production
    ¦       L-- values
    ¦           +-- backend.yaml
    ¦           L-- postgres.yaml
    L-- helmfile.yaml

helmfile.yaml


environments:
  devel:
  production:

releases:
  - name: postgres
    labels:
      app: postgres
    wait: true
    chart: stable/postgresql
    version: 8.4.0
    values:
      - envs/{{ .Environment.Name }}/values/postgres.yaml
  - name: backend
    labels:
      app: backend
    wait: true
    chart: private-helm-repo/backend
    version: 1.0.5
    needs:
      - postgres
    values:
      - envs/{{ .Environment.Name }}/values/backend.yaml

У нас получилось 2 окружения: devel, production — в каждом находятся свои значения для helm чартов релизов. Мы будем деплоить в них так:


helmfile -n <namespace> -e <env> apply

Разные версии helm чартов в разных окружениях


Что делать, если нам надо выкатывать разные версии бэкенда в разные окружения? Как параметризовать версию релиза? На помощь приходят значения окружения, доступные через {{ .Values }}


helmfile.yaml


environments:
  devel:
+   values:
+   - charts:
+       versions:
+         backend: 1.1.0
  production:
+   values:
+   - charts:
+       versions:
+         backend: 1.0.5
...
  - name: backend
    labels:
      app: backend
    wait: true
    chart: private-helm-repo/backend
-   version: 1.0.5
+   version: {{ .Values.charts.versions.backend }}
...

Разный набор приложений в разных окружениях


Отлично, но что если нам не надо в production выкатывать postgres, потому что мы знаем, что не надо базу данных пихать в k8s и для прода у нас есть замечательный отдельный кластер postgres? Для решения этой проблемы у нас есть лейблы (labels)


helmfile -n <namespace> -e devel apply
helmfile -n <namespace> -e production -l app=backend apply

Это здорово, но лично я предпочту описывать, какие приложения разворачивать в окружении не с помощью аргументов запуска, а в описании самих окружений. Что делать? Можно поместить описание релизов в отдельную папку, в описании окружения завести список нужных релизов и "подцеплять" только нужные релизы, игнорируя остальные


    .
    +-- envs
    ¦   +-- devel
    ¦   ¦   L-- values
    ¦   ¦       +-- backend.yaml
    ¦   ¦       L-- postgres.yaml
    ¦   L-- production
    ¦       L-- values
    ¦           +-- backend.yaml
    ¦           L-- postgres.yaml
+   +-- releases
+   ¦   +-- backend.yaml
+   ¦   L-- postgres.yaml
    L-- helmfile.yaml

helmfile.yaml



  environments:
    devel:
      values:
      - charts:
          versions:
            backend: 1.1.0
      - apps:
        - postgres
        - backend

    production:
      values:
      - charts:
          versions:
            backend: 1.0.5
      - apps:
        - backend

- releases:
-    - name: postgres
-      labels:
-        app: postgres
-      wait: true
-      chart: stable/postgresql
-      version: 8.4.0
-      values:
-        - envs/{{ .Environment.Name }}/values/postgres.yaml
-    - name: backend
-      labels:
-        app: backend
-      wait: true
-      chart: private-helm-repo/backend
-     version: {{ .Values.charts.versions.backend }}
-     needs:
-       - postgres
-     values:
-       - envs/{{ .Environment.Name }}/values/backend.yaml
+ ---
+ bases:
+ {{- range .Values.apps }}
+   - releases/{{ . }}.yaml
+ {{- end }}

releases/postgres.yaml


releases:
  - name: postgres
    labels:
      app: postgres
    wait: true
    chart: stable/postgresql
    version: 8.4.0
    values:
      - envs/{{ .Environment.Name }}/values/postgres.yaml

releases/backend.yaml


releases:
  - name: backend
    labels:
      app: backend
    wait: true
    chart: private-helm-repo/backend
    version: {{ .Values.charts.versions.backend }}
    needs:
      - postgres
    values:
      - envs/{{ .Environment.Name }}/values/backend.yaml



Заметка


При использовании bases: необходимо обязательно использовать yaml разделитель ---, чтобы можно было шаблонизировать releases (и остальные части, типа helmDefaults) значениями из environments




В таком случае релиз postgres даже не попадёт в описание для production. Очень удобно!


Переопределяемые глобальные значения для релизов


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


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


    .
    +-- envs
+   ¦   +-- default
+   ¦   ¦   L-- values
+   ¦   ¦       +-- backend.yaml
+   ¦   ¦       L-- postgres.yaml
    ¦   +-- devel
    ¦   ¦   L-- values
    ¦   ¦       +-- backend.yaml
    ¦   ¦       L-- postgres.yaml
    ¦   L-- production
    ¦       L-- values
    ¦           +-- backend.yaml
    ¦           L-- postgres.yaml
    +-- releases
    ¦   +-- backend.yaml
    ¦   L-- postgres.yaml
    L-- helmfile.yaml

releases/backend.yaml


releases:
  - name: backend
    labels:
      app: backend
    wait: true
    chart: private-helm-repo/backend
    version: {{ .Values.charts.versions.backend }}
    needs:
      - postgres
    values:
+     - envs/default/values/backend.yaml
      - envs/{{ .Environment.Name }}/values/backend.yaml

envs/default/values/backend.yaml


affinity:
  podAntiAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
    - weight: 1
      podAffinityTerm:
        labelSelector:
          matchExpressions:
          - key: app.kubernetes.io/name
            operator: In
            values:
            - backend
        topologyKey: "kubernetes.io/hostname"

Определение глобальных значений для helm чартов всех релизов на уровне окружения


Допустим, у нас в нескольких релизах создаются несколько ingress — мы могли бы вручную для каждого чарта определить hosts:, но в нашем случае домен один и тот же, так почему же его не вынести в некую глобальную переменную и просто подставлять её значение в чарты? Для этого те файлы с values, которые мы хотим параметризовать, должны будут иметь расширение .gotmpl, чтобы helmfile знал, что его надо прогнать через шаблонизатор.


    .
    +-- envs
    ¦   +-- default
    ¦   ¦   L-- values
-   ¦   ¦       +-- backend.yaml
-   ¦   ¦       +-- postgres.yaml
+   ¦   ¦       +-- backend.yaml.gotmpl
+   ¦   ¦       L-- postgres.yaml.gotmpl
    ¦   +-- devel
    ¦   ¦   L-- values
    ¦   ¦       +-- backend.yaml
    ¦   ¦       L-- postgres.yaml
    ¦   L-- production
    ¦       L-- values
    ¦           +-- backend.yaml
    ¦           L-- postgres.yaml
    +-- releases
    ¦   +-- backend.yaml
    ¦   L-- postgres.yaml
    L-- helmfile.yaml

helmfile.yaml


  environments:
    devel:
      values:
      - charts:
          versions:
            backend: 1.1.0
      - apps:
        - postgres
        - backend
+     - global:
+         ingressDomain: k8s.devel.domain

    production:
      values:
      - charts:
          versions:
            backend: 1.0.5
      - apps:
        - backend
+     - global:
+         ingressDomain: production.domain
  ---
  bases:
  {{- range .Values.apps }}
    - releases/{{ . }}.yaml
  {{- end }}

envs/default/values/backend.yaml.gotmpl


ingress:
  enabled: true
  paths:
    - /api
  hosts:
    - {{ .Values.global.ingressDomain }}

envs/default/values/postgres.yaml.gotmpl


ingress:
  enabled: true
  paths:
    - /
  hosts:
    - postgres.{{ .Values.global.ingressDomain }}



Заметка


Очевидно, что ingress в чарте postgres — это нечто крайне сомнительное, поэтому в статье это приведено просто в качестве сферического примера в вакууме и для того, чтобы не вводить в статью какой-то новый релиз только ради описания ingress




Подстановка секретов (secrets) из значений окружения


По аналогии с вышеприведённым примером можно подставлять и зашифрованные с помощью helm secrets значения. Вместо того, чтобы для каждого релиза создавать свой файл secrets, в котором определять для чарта зашифрованные значения, мы можем просто определить в релизном default.yaml.gotmpl значения, которые будут браться из переменных, заданных на уровне окружений. А значения, которые нам не надо ни от кого скрывать, можно уже спокойно переопределить в значениях релиза в конкретном окружении.


    .
    +-- envs
    ¦   +-- default
    ¦   ¦   L-- values
    ¦   ¦       +-- backend.yaml
    ¦   ¦       L-- postgres.yaml
    ¦   +-- devel
    ¦   ¦   +-- values
    ¦   ¦   ¦   +-- backend.yaml
    ¦   ¦   ¦   L-- postgres.yaml
+   ¦   ¦   L-- secrets.yaml
    ¦   L-- production
    ¦       +-- values
    ¦       ¦   +-- backend.yaml
    ¦       ¦   L-- postgres.yaml
+   ¦       L-- secrets.yaml
    +-- releases
    ¦   +-- backend.yaml
    ¦   L-- postgres.yaml
    L-- helmfile.yaml

helmfile.yaml


  environments:
    devel:
      values:
      - charts:
          versions:
            backend: 1.1.0
      - apps:
        - postgres
        - backend
      - global:
          ingressDomain: k8s.devel.domain
+     secrets:
+       - envs/devel/secrets.yaml

    production:
      values:
      - charts:
          versions:
            backend: 1.0.5
      - apps:
        - backend
      - global:
          ingressDomain: production.domain
+     secrets:
+       - envs/production/secrets.yaml
  ---
  bases:
  {{- range .Values.apps }}
    - releases/{{ . }}.yaml
  {{- end }}

envs/devel/secrets.yaml


secrets:
    elastic:
        password: ENC[AES256_GCM,data:hjCB,iv:Z1P6/6xBJgJoKLJ0UUVfqZ80o4L84jvZfM+uH9gBelc=,tag:dGqQlCZnLdRAGoJSj63rBQ==,type:int]
...

envs/production/secrets.yaml


secrets:
    elastic:
        password: ENC[AES256_GCM,data:ZB/VpTFk8f0=,iv:EA//oT1Cb5wNFigTDOz3nA80qD9UwTjK5cpUwLnEXjs=,tag:hMdIUaqLRA8zuFBd82bz6A==,type:str]
...

envs/default/values/backend.yaml.gotmpl


elasticsearch:
  host: elasticsearch
  port: 9200
  password: {{ .Values | getOrNil "secrets.elastic.password" | default "password" }}

envs/devel/values/backend.yaml


elasticsearch:
  host: elastic-0.devel.domain

envs/production/values/backend.yaml


elasticsearch:
  host: elastic-0.production.domain



Заметка


Кстати, getOrNil — специальная функция для go шаблонов в helmfile, которая, даже если .Values.secrets не будет существовать, не выкинет ошибку, а позволит в результате с помощью функции default подставить значение по-умолчанию




Заключение


Описанные вещи кажутся довольно очевидными, но информация по удобному описанию деплоя в несколько окружений с помощью helmfile очень скудна, а я люблю IaC(Infrastructure-as-Code) и хочу иметь чёткое описание стейта деплоя.


В заключение хочу добавить, что переменные для окружения default можно в свою очередь параметризовать переменными окружения ОС некоего раннера, с которого будет запускаться деплой, и таким образом получить динамические окружения


helmfile.yaml


environments:
  default:
    values:
    - global:
        clusterDomain: {{ env "CLUSTER_DOMAIN" | default "cluster.local" }}
        ingressDomain: {{ env "INGRESS_DOMAIN" }}