Helmfile für Multi-Cluster-Deployments: Environments und Selektoren
Zurück zum Blog

Helmfile für Multi-Cluster-Deployments: Environments und Selektoren

6 Min. Lesezeit
Lesen auf English

Von einem Cluster zu dreien

Als wir Helmfile vor knapp drei Jahren in unseren Stack aufgenommen haben, war die Ausgangslage überschaubar: ein Cluster, eine Handvoll Releases, eine helmfile.yaml. Das funktionierte hervorragend -- deklaratives Helm-Management, umgebungsspezifische Values, ein einzelner Befehl für das gesamte Deployment. Wir haben damals beschrieben, wie Helmfile den imperativen Ansatz mit einzelnen helm upgrade-Befehlen durch eine deklarative YAML-Datei ersetzt.

Mittlerweile betreiben wir bei encircle360 drei K3s-Cluster: Development, Staging und Production. Jeder Cluster läuft auf eigenen Nodes, wie wir das in unserem Erfahrungsbericht zu K3s im Produktiveinsatz beschrieben haben. Die Cluster teilen sich den Großteil der Releases -- dieselben Spring-Boot-Services, denselben Ingress-Controller, dasselbe Monitoring -- aber die Konfigurationen unterscheiden sich. Unterschiedliche Domains, unterschiedliche Ressourcenlimits, unterschiedliche Replica-Counts, unterschiedliche Secrets.

Die Frage war: Wie verwalten wir drei Cluster mit einer einzigen Helmfile-Konfiguration, ohne in Kopier-und-Einfüge-Chaos zu enden?

Environments als Grundlage

Helmfile bringt das Konzept der Environments von Haus aus mit. In unserem ersten Helmfile-Artikel haben wir sie kurz erwähnt. Für Multi-Cluster-Setups sind sie das zentrale Werkzeug.

Ein Environment in Helmfile entspricht einem Deployment-Ziel -- in unserem Fall einem Cluster. Jedes Environment bringt eigene globale Variablen mit, die in allen Release-Definitionen verfügbar sind.

# helmfile.yaml

environments:
  dev:
    values:
      - environments/dev.yaml
    kubeContext: k3s-dev
  staging:
    values:
      - environments/staging.yaml
    kubeContext: k3s-staging
  production:
    values:
      - environments/production.yaml
    kubeContext: k3s-production

Die kubeContext-Direktive ist entscheidend. Sie sorgt dafür, dass Helmfile automatisch den richtigen kubectl-Kontext verwendet, wenn man ein Environment wählt. Kein manuelles kubectl config use-context vor jedem Deployment. Ein helmfile -e production apply spricht immer das Production-Cluster an, unabhängig davon, welcher Kontext gerade aktiv ist.

Die Environment-Values-Dateien definieren globale Variablen, die sich zwischen den Clustern unterscheiden:

# environments/production.yaml

clusterName: production
domain: encircle360.com
registry: registry.encircle360.com
ingressClass: traefik
certManager:
  issuer: letsencrypt-prod
resources:
  defaultCpuRequest: 250m
  defaultMemoryRequest: 256Mi
  defaultCpuLimit: 500m
  defaultMemoryLimit: 512Mi
replicas:
  default: 2
# environments/dev.yaml

clusterName: dev
domain: dev.encircle360.com
registry: registry.encircle360.com
ingressClass: traefik
certManager:
  issuer: letsencrypt-staging
resources:
  defaultCpuRequest: 100m
  defaultMemoryRequest: 128Mi
  defaultCpuLimit: 250m
  defaultMemoryLimit: 256Mi
replicas:
  default: 1

Diese Werte stehen dann in den Release-Definitionen als Go-Template-Variablen zur Verfügung. Statt pro Cluster eigene Values-Dateien zu pflegen, referenzieren die Templates die globalen Variablen:

releases:
  - name: api-service
    namespace: application
    chart: ./charts/spring-boot-app
    version: 2.3.0
    values:
      - values/api-service.yaml
      - values/{{ .Environment.Name }}/api-service.yaml

Die erste Values-Datei enthält die clusterübergreifenden Defaults. Die zweite -- optional -- enthält umgebungsspezifische Überschreibungen. So bleibt die Konfiguration DRY, und spezifische Anpassungen sind klar separiert.

Selektoren für gezielte Deployments

Nicht bei jedem Deployment will man alle Releases aktualisieren. Wenn der Monitoring-Stack stabil läuft und nur ein einzelner Service ein Update braucht, wäre ein vollständiges helmfile apply überdimensioniert. Helmfile löst das mit Labels und Selektoren.

Jedes Release kann beliebige Labels tragen:

releases:
  - name: api-service
    namespace: application
    chart: ./charts/spring-boot-app
    labels:
      tier: application
      team: backend
    values:
      - values/api-service.yaml

  - name: web-frontend
    namespace: application
    chart: ./charts/angular-app
    labels:
      tier: application
      team: frontend
    values:
      - values/web-frontend.yaml

  - name: prometheus
    namespace: monitoring
    chart: prometheus-community/kube-prometheus-stack
    labels:
      tier: infrastructure
      team: platform
    values:
      - values/monitoring.yaml

  - name: ingress-nginx
    namespace: ingress
    chart: ingress-nginx/ingress-nginx
    labels:
      tier: infrastructure
      team: platform
    values:
      - values/ingress.yaml

Über den --selector-Parameter (oder kurz -l) filtert man bei der Ausführung:

# Nur Application-Tier deployen
helmfile -e production -l tier=application apply

# Nur Releases des Backend-Teams
helmfile -e staging -l team=backend apply

# Nur ein einzelnes Release
helmfile -e dev -l name=api-service apply

# Kombination: Infrastructure-Tier im Staging
helmfile -e staging -l tier=infrastructure diff

In unserer täglichen Arbeit verwenden wir Selektoren ständig. Ein typischer Workflow sieht so aus: Das Backend-Team aktualisiert seinen Service, schiebt die Chart-Version und Values-Änderung ins Repository, und die Pipeline führt helmfile -e staging -l team=backend apply aus. Der Rest des Clusters bleibt unangetastet.

helmfile diff: Änderungen vor dem Deployment prüfen

Bevor wir auf Production deployen, nutzen wir helmfile diff als Pflichtschritt. Der Befehl vergleicht den gewünschten Zustand aus der Helmfile-Konfiguration mit dem Ist-Zustand im Cluster und zeigt die Unterschiede als farbcodierten Diff an.

# Vollständiger Diff gegen Production
helmfile -e production diff

# Diff nur für Application-Releases
helmfile -e production -l tier=application diff

Die Ausgabe ähnelt einem git diff auf Kubernetes-Manifest-Ebene. Man sieht genau, welche Felder sich ändern -- neue Umgebungsvariablen, geänderte Ressourcenlimits, aktualisierte Image-Tags. In unseren CI/CD-Pipelines läuft helmfile diff als eigener Job, dessen Ausgabe im Pipeline-Log sichtbar ist. Das gibt dem Team die Möglichkeit, die Änderungen zu prüfen, bevor helmfile apply sie tatsächlich anwendet.

Für den Production-Cluster ist das nicht optional. Wir haben zu oft erlebt, dass eine vermeintlich harmlose Values-Änderung unerwartete Nebenwirkungen hatte -- ein fehlender Wert, der zu einem leeren String wurde, ein geänderter Port, der den Health-Check brach. helmfile diff fängt das ab, bevor es den Cluster erreicht.

Secrets-Management mit vals

Secrets gehören nicht im Klartext ins Git-Repository. Für Helmfile gibt es mehrere Ansätze, und wir haben uns für vals entschieden -- ein Tool, das Secrets zur Laufzeit aus externen Quellen injiziert.

vals unterstützt verschiedene Backends: SOPS-verschlüsselte Dateien, HashiCorp Vault, AWS Secrets Manager und andere. In unserem Setup verwenden wir SOPS mit age als Verschlüsselungstool. Die verschlüsselten Secrets liegen im Repository, und vals entschlüsselt sie während des helmfile apply.

# values/production/secrets.yaml (verschluesselt mit SOPS)

databasePassword: ENC[AES256_GCM,data:abc123...,type:str]
apiKey: ENC[AES256_GCM,data:def456...,type:str]

In der helmfile.yaml aktiviert man vals als Secrets-Backend:

helmDefaults:
  wait: true
  timeout: 300

environments:
  production:
    secrets:
      - environments/production-secrets.yaml

Der Vorteil gegenüber dem älteren helm-secrets-Plugin: vals ist unabhängig von Helm und lässt sich auch außerhalb von Helmfile nutzen. Die Secrets liegen verschlüsselt im Repository, durchlaufen den normalen Code-Review-Prozess und sind trotzdem nie im Klartext sichtbar -- außer während des Deployments, wo vals sie entschlüsselt und an Helm weitergibt.

Verzeichnisstruktur für Multi-Cluster

Mit drei Clustern, Dutzenden Releases und umgebungsspezifischen Values wächst die Verzeichnisstruktur. Wir haben mehrere Ansätze ausprobiert und sind bei folgender Struktur gelandet:

helmfile/
  helmfile.yaml
  environments/
    dev.yaml
    dev-secrets.yaml
    staging.yaml
    staging-secrets.yaml
    production.yaml
    production-secrets.yaml
  values/
    api-service.yaml          # Cluster-uebergreifende Defaults
    web-frontend.yaml
    monitoring.yaml
    ingress.yaml
    dev/
      api-service.yaml        # Dev-spezifische Overrides
    staging/
      api-service.yaml
    production/
      api-service.yaml
      web-frontend.yaml
  charts/
    spring-boot-app/
    angular-app/
  .sops.yaml                  # SOPS-Konfiguration

Die Grundidee: Defaults stehen in values/<release>.yaml, umgebungsspezifische Abweichungen in values/<env>/<release>.yaml. Nicht jedes Release braucht umgebungsspezifische Values -- wenn die Defaults ausreichen, existiert die Datei schlicht nicht. Helmfile kommt damit zurecht, wenn man missingFileHandler: Warn setzt.

helmDefaults:
  missingFileHandler: Warn

Für größere Setups unterstützt Helmfile auch das Aufteilen in mehrere Dateien mit helmfiles:

helmfiles:
  - path: helmfile.d/infrastructure.yaml
  - path: helmfile.d/applications.yaml
  - path: helmfile.d/monitoring.yaml

Jede Teildatei enthält ihre eigenen Releases und kann unabhängig deployed werden. Wir nutzen das noch nicht, da unsere Gesamtkonfiguration mit rund dreißig Releases noch in einer Datei überschaubar bleibt. Aber die Option steht bereit, wenn die Komplexität weiter wächst.

CI/CD-Integration: Drei Cluster, eine Pipeline

Die Pipeline-Integration folgt einem klaren Muster. Jeder Cluster hat seine eigene Deployment-Stage, aber alle nutzen dieselbe Helmfile-Konfiguration:

# Development: Automatisch bei jedem Push auf develop
helmfile -e dev apply

# Staging: Automatisch bei Merge auf main
helmfile -e staging apply

# Production: Manuell ausgeloest nach Staging-Validierung
helmfile -e production diff    # Review-Schritt
helmfile -e production apply   # Nach Freigabe

Der kubectl-Kontext wird in der Pipeline über die kubeconfig gesetzt. Jeder Cluster hat seinen eigenen Service Account mit eingeschränkten Rechten. Die Pipeline braucht keine Logik, um zwischen Clustern zu unterscheiden -- das übernimmt Helmfile über die Environments.

Fazit

Helmfile skaliert von einem einzelnen Cluster zu einer Multi-Cluster-Landschaft, ohne dass man das Grundkonzept ändern muss. Environments bilden die Cluster ab, Selektoren ermöglichen gezielte Deployments, und vals hält die Secrets sicher. Die Verzeichnisstruktur mit clusterübergreifenden Defaults und umgebungsspezifischen Overrides vermeidet Duplikation, ohne die Übersicht zu verlieren.

Wer bereits Helmfile für ein einzelnes Cluster nutzt -- wie wir es in unserem Einführungsartikel beschrieben haben -- kann den Schritt zu mehreren Clustern schrittweise gehen. Zuerst Environments definieren, dann Values aufteilen, dann Selektoren einführen. Jeder Schritt bringt sofort Nutzen und erfordert keinen Umbau der bestehenden Konfiguration.

In unseren Projekten hat sich dieses Setup über die letzten Monate bewährt. Drei K3s-Cluster, rund dreißig Releases pro Cluster, ein Git-Repository als Single Source of Truth. Änderungen durchlaufen Pull Requests, helmfile diff zeigt den Impact, und helmfile apply bringt den gewünschten Zustand auf den Cluster. Deklarativ, nachvollziehbar und -- mit etwas Struktur -- auch bei mehreren Clustern übersichtlich.

Teilen

Patrick Hütter

Geschrieben von

Patrick Hütter

Gründer & Software-Architekt

Softwarearchitekt, Ingenieur und Unternehmer. Patrick baut seit über einem Jahrzehnt Produkte und Plattformen — von Enterprise-Backends über Cloud-Native-Infrastruktur bis hin zu KI-gestützten Anwendungen. Als Gründer von encircle360 verbindet er technische Tiefe mit unternehmerischem Denken und treibt Open-Source-Projekte voran, die echten Impact haben.