Organizing deployment across multiple k8s environments using helmfile

Helmfile β€” a wrapper for helm, which allows you to describe multiple helm releases in one place, parameterize their charts for several environments, and set the order of their deployment.

You can read about helmfile and examples of its use in the readme and best practices guide.

We will explore some less obvious ways to describe releases in helmfile.

Let's assume we have a batch of helm charts (for example, postgres and some backend application) and several environments (multiple Kubernetes clusters, multiple namespaces, or both). We take helmfile, read the documentation, and start describing our environments and releases:

    .
    β”œβ”€β”€ envs
    β”‚   β”œβ”€β”€ devel
    β”‚   β”‚   └── values
    β”‚   β”‚       β”œβ”€β”€ backend.yaml
    β”‚   β”‚       └── postgres.yaml
    β”‚   └── production
    β”‚       └── values
    β”‚           β”œβ”€β”€ backend.yaml
    β”‚           └── postgres.yaml
    └── 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

We have created 2 environments: devel, production β€” each contains its own values for helm chart releases. We will deploy to them like this:

helmfile -n  -e  apply

Different versions of helm charts in different environments

What to do if we need to roll out different versions of the backend in different environments? How to parameterize the release version? Environment-specific values come to our aid, accessible via {{ .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 }}
...

Different sets of applications in different environments

Great, but what if we do not want to deploy postgres because we know that we shouldn't put a database in k8s, and we have a wonderful separate postgres cluster for production? To solve this problem, we have labels production helmfile -n -e devel apply helmfile -n -e production -l app=backend apply

helmfile -n  -e devel apply
helmfile -n  -e production -l app=backend apply

That's great, but personally, I would prefer to describe which applications to deploy in the environment not through launch arguments, but in the description of the environments themselves. What can be done? You can place the release descriptions in a separate folder, maintain a list of the necessary releases in the environment description, and "connect" only the required releases, ignoring the others.

    .
    β”œβ”€β”€ envs
    β”‚   β”œβ”€β”€ devel
    β”‚   β”‚   └── values
    β”‚   β”‚       β”œβ”€β”€ backend.yaml
    β”‚   β”‚       └── postgres.yaml
    β”‚   └── production
    β”‚       └── values
    β”‚           β”œβ”€β”€ backend.yaml
    β”‚           └── postgres.yaml
+   β”œβ”€β”€ releases
+   β”‚   β”œβ”€β”€ backend.yaml
+   β”‚   └── postgres.yaml
    └── 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

Note

When using bases: it is necessary to use yaml separator ---, so that it is possible to template releases (and other parts, like helmDefaults) with values from environments

In that case, the postgres release would not even be included in the description for production. Very convenient!

Overridable global values for releases

Of course, it is great that you can specify values for helm charts for each environment, but what if we have several environments described, and we want to set the same value for all affinity, but do not want to configure it as default in the charts themselves that are stored in the repositories.

In this case, we could specify 2 files with values for each release: the first with default values that will define the values of the chart itself, and the second with values for the environment, which will consequently override the defaults.

    .
    β”œβ”€β”€ envs
+   β”‚Β Β  β”œβ”€β”€ default
+   β”‚Β Β  β”‚Β Β  └── values
+   β”‚Β Β  β”‚Β Β      β”œβ”€β”€ backend.yaml
+   β”‚Β Β  β”‚Β Β      └── postgres.yaml
    β”‚Β Β  β”œβ”€β”€ devel
    β”‚Β Β  β”‚Β Β  └── values
    β”‚Β Β  β”‚Β Β      β”œβ”€β”€ backend.yaml
    β”‚Β Β  β”‚Β Β      └── postgres.yaml
    β”‚Β Β  └── production
    β”‚Β Β      └── values
    β”‚Β Β          β”œβ”€β”€ backend.yaml
    β”‚Β Β          └── postgres.yaml
    β”œβ”€β”€ releases
    β”‚Β Β  β”œβ”€β”€ backend.yaml
    β”‚Β Β  └── postgres.yaml
    └── 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"

Defining global values for helm charts of all releases at the environment level

Suppose we have multiple ingress resources created in several releases β€” we could manually specify for each chart hosts:, but in our case the domain is the same, so why not extract it into a global variable and simply substitute its value in the charts? For this, the files with values that we want to parameterize must have the extension .gotmpl, so that helmfile knows it should pass through the templater.

    .
    β”œβ”€β”€ envs
    β”‚Β Β  β”œβ”€β”€ default
    β”‚Β Β  β”‚Β Β  └── values
-   β”‚Β Β  β”‚Β Β      β”œβ”€β”€ backend.yaml
-   β”‚Β Β  β”‚Β Β      β”œβ”€β”€ postgres.yaml
+   β”‚Β Β  β”‚Β Β      β”œβ”€β”€ backend.yaml.gotmpl
+   β”‚Β Β  β”‚Β Β      └── postgres.yaml.gotmpl
    β”‚Β Β  β”œβ”€β”€ devel
    β”‚Β Β  β”‚Β Β  └── values
    β”‚Β Β  β”‚Β Β      β”œβ”€β”€ backend.yaml
    β”‚Β Β  β”‚Β Β      └── postgres.yaml
    β”‚Β Β  └── production
    β”‚Β Β      └── values
    β”‚Β Β          β”œβ”€β”€ backend.yaml
    β”‚Β Β          └── postgres.yaml
    β”œβ”€β”€ releases
    β”‚Β Β  β”œβ”€β”€ backend.yaml
    β”‚Β Β  └── postgres.yaml
    └── 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 }}

Note

It is obvious that ingress in the Postgres chart is quite dubious, so this is presented in the article merely as a spherical example in a vacuum and to avoid introducing a new release just to describe ingress.

Substituting secrets from environment values.

Similarly to the example above, encrypted values can also be substituted using helm secrets. Instead of creating a separate secrets file for each release to define encrypted values for the chart, we can simply define values in the release's default.yaml.gotmpl that will be taken from variables set at the environment level. Values that we don't need to keep secret can be safely overridden in the release values for the specific environment.

    .
    β”œβ”€β”€ envs
    β”‚   β”œβ”€β”€ default
    β”‚   β”‚   └── values
    β”‚   β”‚       β”œβ”€β”€ backend.yaml
    β”‚   β”‚       └── postgres.yaml
    β”‚   β”œβ”€β”€ devel
    β”‚   β”‚   β”œβ”€β”€ values
    β”‚   β”‚   β”‚   β”œβ”€β”€ backend.yaml
    β”‚   β”‚   β”‚   └── postgres.yaml
+   β”‚   β”‚   └── secrets.yaml
    β”‚   └── production
    β”‚       β”œβ”€β”€ values
    β”‚       β”‚   β”œβ”€β”€ backend.yaml
    β”‚       β”‚   └── postgres.yaml
+   β”‚       └── secrets.yaml
    β”œβ”€β”€ releases
    β”‚   β”œβ”€β”€ backend.yaml
    β”‚   └── postgres.yaml
    └── 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

Note

By the way, getOrNil β€” a special function for Go templates in helmfile, which, even if .Values.secrets does not exist, will not throw an error, but instead will allow using the function default to substitute a default value.

Conclusion

The things described may seem quite obvious, but information on conveniently describing deployments across multiple environments using helmfile is very sparse, and I love IaC (Infrastructure-as-Code) and want a clear description of the deployment state.

In conclusion, I would like to add that the default environment variables can also be parameterized with the OS environment variables of a certain runner from which the deployment will be initiated, and thus dynamic environments can be obtained.

helmfile.yaml

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

Source: habr.com

Buy reliable website hosting with DDoS protection, VPS VDS servers πŸ”₯ Buy reliable website hosting with DDoS protection, VPS VDS servers | ProHoster