Praxisprojekt: End-to-End Deployment mit Azure Arc-enabled Kubernetes: GitOps, Policy und Monitoring

Praxisprojekt: End-to-End Deployment mit Azure Arc-enabled Kubernetes: GitOps, Policy und Monitoring

Einleitung

Freitagabend, 22:30 Uhr. Maren, Ops-Leiterin bei einem mittelständischen Logistikdienstleister, sitzt mit dem Laptop auf der Couch. In ihrem Slack explodiert es: Der Label-Service im Lager Hamburg druckt seit einer Stunde falsche Versandlabels. DHL-Pakete bekommen DPD-Labels. Die Nachtschicht stapelt Pakete, die nicht raus können.

Maren weiß, was jetzt kommt. SSH auf den Server in Hamburg, docker ps, Logs durchforsten, Fix einspielen. Dann dasselbe für München, Frankfurt, Leipzig - zwölf Mal. Um 3 Uhr nachts ist sie durch. Am Montag hat sie dieselbe Diskussion mit dem CTO: "Das skaliert nicht. Wir brauchen was anderes."

Genau dieses "was anderes" ist Azure Arc-enabled Kubernetes. Und genau diesen Weg - vom SSH-Chaos zur vollautomatischen Multi-Cluster-Plattform - gehen wir in diesem Post.

Ich nehme dafür ein durchgängiges Szenario als roten Faden: Die fiktive PaketBlitz GmbH, ein Logistikdienstleister mit 12 Distributionszentren in ganz Deutschland. In jedem Lager läuft ein Kubernetes-Cluster für das Warehouse Management System (WMS) - die Software, die Wareneingänge erfasst, Lagerplätze verwaltet, Kommissionieraufträge steuert und Versandlabels druckt. Marens Albtraum eben.

Am Ende dieses Posts kann PaketBlitz:

  • Alle 12 Cluster zentral aus Azure verwalten (Azure Arc)
  • Software-Updates per Git-Push auf alle Lager gleichzeitig ausrollen (GitOps mit Flux v2)
  • Sicherheitsregeln automatisch durchsetzen - kein Lager kann sie umgehen (Azure Policy)
  • Neuen Code in Minuten von der IDE bis in die Lager bringen (CI/CD mit GitHub Actions)
  • Um 3 Uhr nachts einen Alert bekommen, bevor die Nachtschicht es merkt (Azure Monitor)

Keine Bullet-Point-Theorie. Wir bauen das Stück für Stück auf - mit echten CLI-Befehlen, YAML-Manifesten und den Entscheidungen, die PaketBlitz auf dem Weg treffen musste.


Das Szenario: PaketBlitz GmbH

Nach dem dritten Freitagabend-Einsatz innerhalb eines Monats zieht Maren die Reißleine. Sie geht zum CTO mit einer Rechnung: 47 Stunden manuelles Deployment im letzten Quartal, zwei Incidents, die durch inkonsistente Software-Versionen zwischen Lagern verursacht wurden, und ein Label-Fehler, der PaketBlitz 12.000 Euro an Rücksendekosten gekostet hat.

Die Entscheidung: Kubernetes auf jedem Standort, zentral verwaltet über Azure Arc. Kein Full-Blown Rechenzentrum - ein K3s-Cluster auf einem Intel NUC oder einem kleinen Server-Rack pro Lager. Kompakt, nah an den Scannern, Druckern und Förderbändern.

Was läuft auf jedem Cluster?

Fünf Services bilden das Herzstück jedes Lagers:

  • WMS-API (Go): Der Kernservice. Bestandsverwaltung, Kommissionierung, Warenein- und -ausgang. Wenn dieser Service steht, steht das Lager. Kommuniziert mit der lokalen PostgreSQL-Datenbank.
  • WMS-Frontend (React): Das, was die Lagermitarbeiter auf ihren Tablets und Handheld-Scannern sehen. Pick-Listen, Lagerplätze, Sendungsstatus. Muss schnell sein - die Kommissionierer laufen im Schnitt 15 km pro Schicht, da zählt jede Sekunde Ladezeit.
  • Label-Service (Python): Generiert Versandlabels für DHL, DPD, GLS und steuert die Thermodrucker. Der Service, der Maren die schlaflosen Nächte beschert hat - ein falsches Label und das Paket landet in Flensburg statt in Freiburg.
  • PostgreSQL: Lokale Datenbank für Lagerbestände und Aufträge. Bewusst lokal, damit das Lager auch bei Internetausfall weiterarbeiten kann. Wenn die Leitung nach Azure ausfällt, darf kein Lager stillstehen.
  • Telemetrie-Agent: Sammelt Metriken - Durchsatz, Fehlerrate, Scan-Geschwindigkeit - und sendet sie an Azure Monitor. Marens Augen und Ohren, wenn sie nicht vor Ort ist.

Warum Kubernetes im Lager?

"Können wir nicht einfach Docker Compose nehmen?" - Die Frage kam von Jens, dem Lagerleiter in Hamburg. Berechtigte Frage. Aber Maren hatte die Antwort schon durchlebt:

Vorher sah ein Update so aus: RDP auf den Server, docker-compose pull, hoffen dass nichts bricht, manuell testen, weiter zum nächsten Lager. Bei 12 Lagern dauerte ein Rollout einen ganzen Arbeitstag - wenn alles glatt lief. Und es lief nie alles glatt. Lager Düsseldorf hatte eine ältere Docker-Version, Lager Leipzig einen vollen Disk, Lager Stuttgart einen Proxy, der die Registry blockierte.

Mit Kubernetes bekommt PaketBlitz das, was Maren braucht:

  • Deklarative Deployments: Der gewünschte Zustand ist in YAML definiert - nicht in einer Anleitung, die jemand bei Schritt 7 von 12 überspringt
  • Self-Healing: Stürzt ein Container ab, wird er automatisch neu gestartet. Kein Anruf um 3 Uhr nachts
  • Rolling Updates: Neue Versionen werden ohne Ausfallzeit ausgerollt. Die Nachtschicht merkt nichts
  • Einheitliche Plattform: Ob Lager Hamburg oder Lager München - überall dieselbe Infrastruktur, dieselben Befehle, dieselben Logs

Architektur-Übersicht

graph LR
    subgraph HH["🏭 Lager Hamburg"]
        direction TB
        HH_K3s["K3s Cluster"]
        HH_WMS["WMS-API + Frontend"]
        HH_Label["Label-Service"]
        HH_DB["PostgreSQL"]
        HH_HW["Scanner / Drucker"]
    end

    subgraph AZ["☁️ Azure Cloud"]
        direction TB
        ARC["Azure Arc"]
        GITOPS["GitOps · Flux v2"]
        POLICY["Azure Policy"]
        MONITOR["Azure Monitor"]
        ACR["ACR · Container Images"]
    end

    subgraph MUC["🏭 Lager München"]
        direction TB
        MUC_K3s["K3s Cluster"]
        MUC_WMS["WMS-API + Frontend"]
        MUC_Label["Label-Service"]
        MUC_DB["PostgreSQL"]
        MUC_HW["Scanner / Drucker"]
    end

    HH_K3s -- "Arc Agents" --> ARC
    ARC -- "Arc Agents" --> MUC_K3s

    style HH fill:#1a1a2e,stroke:#0ff,color:#fff
    style AZ fill:#0d1b2a,stroke:#00b4d8,color:#fff
    style MUC fill:#1a1a2e,stroke:#0ff,color:#fff

Die Kommunikation zwischen den Lagern und Azure läuft über die Azure Arc Agents, die eine sichere ausgehende Verbindung zu Azure herstellen. Kein eingehender Port muss geöffnet werden - wichtig, denn in Logistik-Netzwerken sind die Firewalls meistens restriktiv.

Lokale Kommunikation im Lager

Innerhalb jedes Lagers kommunizieren die Komponenten so:

  • Ingress Controller (Traefik): Stellt das WMS-Frontend für die Tablets der Lagermitarbeiter im lokalen Netzwerk bereit
  • ClusterIP Services: WMS-API und Label-Service sind nur cluster-intern erreichbar
  • Lokale Datenbank: PostgreSQL läuft im Cluster, damit das Lager auch bei Internetausfall weiterarbeiten kann. Aufträge werden lokal verarbeitet und bei Verbindung mit der Zentrale synchronisiert

Schritt 1: Kubernetes-Cluster mit Azure Arc verbinden

Maren startet mit dem Lager Hamburg - ihrem Pilotstandort. Jens, der Lagerleiter dort, ist skeptisch ("Schon wieder was Neues?"), aber kooperativ. Er hat den K3s-Cluster letzte Woche aufgesetzt. Jetzt soll der Cluster ins Azure Portal - damit Maren von ihrem Schreibtisch in der Zentrale aus sehen kann, was auf dem Intel NUC unter dem Schreibtisch in Hamburg passiert.

Voraussetzungen

Bevor du loslegst, brauchst du:

  • Einen laufenden Kubernetes-Cluster (K3s, AKS, RKE2, MicroK8s - jede CNCF-zertifizierte Distribution funktioniert)
  • Mindestens einen Linux-Node (linux/amd64 oder linux/arm64)
  • ~850 MB freien Speicher und ~7% einer CPU für die Arc Agents
  • Eine kubeconfig mit Zugriff auf den Cluster
  • Azure CLI mit der connectedk8s Extension

Resource Provider registrieren

Die Resource Provider müssen einmalig für die Subscription registriert werden:

# Resource Provider registrieren
az provider register --namespace Microsoft.Kubernetes
az provider register --namespace Microsoft.KubernetesConfiguration
az provider register --namespace Microsoft.ExtendedLocation

# Registrierungsstatus prüfen (kann bis zu 10 Minuten dauern)
az provider show -n Microsoft.Kubernetes -o table
az provider show -n Microsoft.KubernetesConfiguration -o table
az provider show -n Microsoft.ExtendedLocation -o table

CLI-Extensions installieren

# Azure CLI aktualisieren
az upgrade

# connectedk8s Extension installieren
az extension add --name connectedk8s

# Für GitOps und Extensions
az extension add --name k8s-configuration
az extension add --name k8s-extension

Resource Group erstellen und Cluster verbinden

# Resource Group erstellen
az group create \
  --name PaketBlitz-RG \
  --location westeurope

# Kubernetes-Cluster mit Azure Arc verbinden
az connectedk8s connect \
  --name PaketBlitz-Lager-Hamburg \
  --resource-group PaketBlitz-RG \
  --location westeurope

Der az connectedk8s connect Befehl deployt die Azure Arc Agents in den azure-arc Namespace deines Clusters. Das sind mehrere Pods, die die Verbindung zu Azure herstellen und halten:

# Arc Agents prüfen
kubectl get deployments,pods -n azure-arc

Du solltest folgende Deployments sehen:

  • cluster-metadata-operator - Synchronisiert Cluster-Metadaten (Version, Node-Anzahl)
  • clusterconnect-agent - Stellt die ausgehende Verbindung zu Azure her
  • config-agent - Verarbeitet Konfigurationsänderungen (GitOps, Extensions)
  • controller-manager - Orchestriert die Arc-Komponenten
  • extension-manager - Verwaltet Cluster-Extensions (Flux, Monitor, Policy)
  • metrics-agent - Sammelt und sendet Metriken an Azure
  • resource-sync-agent - Synchronisiert Ressourcenstatus

Verbindung verifizieren

# Alle verbundenen Cluster auflisten
az connectedk8s list \
  --resource-group PaketBlitz-RG \
  --output table

Output:

Name                   Location     ResourceGroup
---------------------  -----------  ---------------
PaketBlitz-Lager-Hamburg  westeurope   PaketBlitz-RG

Tipp: Nach dem Onboarding dauert es bis zu 10 Minuten, bis Cluster-Metadaten wie Kubernetes-Version und Node-Anzahl im Azure Portal auf der Overview-Seite erscheinen.

Maren refresht das Azure Portal. Da ist er - PaketBlitz-Lager-Hamburg, Status: Connected, Kubernetes v1.29.4, 1 Node. Zum ersten Mal sieht sie einen Lager-Cluster, ohne sich per VPN und SSH durchwühlen zu müssen. Jens bekommt einen Screenshot per Teams: "Dein NUC ist jetzt in der Cloud sichtbar 🎉". Seine Antwort: "Solange ihr mir den nicht abschaltet."

Cluster hinter einem Proxy verbinden

In vielen Logistik-Netzwerken ist der Internetzugang nur über einen Proxy möglich. Azure Arc unterstützt das:

# Cluster mit Proxy-Konfiguration verbinden
az connectedk8s connect \
  --name PaketBlitz-Lager-Hamburg \
  --resource-group PaketBlitz-RG \
  --proxy-https https://proxy.paketblitz.internal:3128 \
  --proxy-http http://proxy.paketblitz.internal:3128 \
  --proxy-skip-range 10.0.0.0/16,kubernetes.default.svc,.svc.cluster.local,.svc

Schritt 2: GitOps mit Flux v2 - Automatisiertes WMS-Deployment

Der Cluster ist verbunden - aber bisher sieht Maren ihn nur im Portal. Das eigentliche Problem ist noch nicht gelöst: Wie kommt die WMS-Software auf den Cluster? Und vor allem: Wie kommt das nächste Update auf alle 12 Cluster gleichzeitig, ohne dass Maren jeden einzeln anfassen muss?

Bisher lief das so: Maren hat ein Deployment-Script, das per SSH auf jeden Cluster geht und kubectl apply ausführt. Das Script ist 340 Zeilen lang, bricht regelmäßig ab, und niemand außer ihr versteht es. Wenn Maren krank ist, deployt niemand.

GitOps löst das fundamental anders: Ein Git-Repository ist die einzige Wahrheit. Was im Repo steht, läuft auf dem Cluster. Kein SSH, kein Script, kein Maren-Wissen nötig. Flux v2 läuft direkt auf dem Cluster und holt sich Änderungen selbstständig - rein Pull-basiert.

Wie funktioniert GitOps mit Flux v2 auf Azure Arc?

Der Ablauf, den Maren aufbaut:

  1. Das Ops-Team definiert den gewünschten Zustand jedes Clusters als YAML-Manifeste, Helm Charts oder Kustomize-Dateien in einem Git-Repository
  2. Die microsoft.flux Cluster Extension wird automatisch auf dem Cluster installiert
  3. Flux-Controller (Source, Kustomize, Helm, Notification) laufen auf dem Cluster und gleichen den Ist-Zustand kontinuierlich mit dem Soll-Zustand ab
  4. Änderungen im Git-Repository werden automatisch auf den Cluster angewendet - rein Pull-basiert, kein Zugriff auf den Cluster von außen nötig

Das bedeutet für Jens in Hamburg: Kein IT-Techniker muss per SSH auf seinen NUC zugreifen. Der Cluster holt sich seine Konfiguration selbstständig aus dem Git-Repo. Maren kann Urlaub machen.

Git-Repository Struktur

Für PaketBlitz erstellen wir ein klar strukturiertes Repo:

paketblitz-wms-gitops/
├── infrastructure/
│   ├── namespace.yaml
│   ├── postgres/
│   │   ├── statefulset.yaml
│   │   ├── service.yaml
│   │   └── pvc.yaml
│   └── traefik/
│       └── helmrelease.yaml
├── apps/
│   ├── wms-api/
│   │   ├── deployment.yaml
│   │   ├── service.yaml
│   │   └── configmap.yaml
│   ├── wms-frontend/
│   │   ├── deployment.yaml
│   │   ├── service.yaml
│   │   └── ingress.yaml
│   └── label-service/
│       ├── deployment.yaml
│       └── service.yaml
└── kustomization.yaml

Die Trennung in infrastructure/ und apps/ ist bewusst: Die Infrastruktur (Datenbank, Ingress) muss stehen, bevor die Applikationen starten können. Flux unterstützt das mit Dependencies zwischen Kustomizations.

Beispiel: WMS-API Deployment

# apps/wms-api/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: wms-api
  namespace: paketblitz
  labels:
    app: wms-api
    component: backend
spec:
  replicas: 2
  selector:
    matchLabels:
      app: wms-api
  template:
    metadata:
      labels:
        app: wms-api
        component: backend
    spec:
      containers:
        - name: wms-api
          image: paketblitzacr.azurecr.io/wms-api:2.8.1
          ports:
            - containerPort: 8080
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "1000m"
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: wms-db-credentials
                  key: connection-string
            - name: WAREHOUSE_ID
              valueFrom:
                configMapKeyRef:
                  name: wms-config
                  key: warehouse-id
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 15
          readinessProbe:
            httpGet:
              path: /readyz
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10

Flux-Konfiguration erstellen

Jetzt verbinden wir den Cluster mit dem Git-Repository. Der folgende Befehl installiert automatisch die microsoft.flux Extension und erstellt die GitOps-Konfiguration:

az k8s-configuration flux create \
  --resource-group PaketBlitz-RG \
  --cluster-name PaketBlitz-Lager-Hamburg \
  --cluster-type connectedClusters \
  --name wms-config \
  --namespace cluster-config \
  --scope cluster \
  --url https://github.com/paketblitz-ops/paketblitz-wms-gitops \
  --branch main \
  --kustomization name=infra path=./infrastructure prune=true \
  --kustomization name=apps path=./apps prune=true dependsOn=\["infra"\]

Was passiert hier im Detail:

  • --scope cluster: Die Konfiguration hat Cluster-weite Berechtigungen
  • --url: Das Git-Repository mit den Manifesten
  • prune=true: Ressourcen, die aus dem Repo gelöscht werden, werden auch vom Cluster entfernt - wichtig für saubere Rollbacks
  • dependsOn=["infra"]: Die Apps-Kustomization wartet, bis PostgreSQL und Traefik laufen

Status prüfen

# Flux-Konfiguration anzeigen
az k8s-configuration flux show \
  --resource-group PaketBlitz-RG \
  --cluster-name PaketBlitz-Lager-Hamburg \
  --cluster-type connectedClusters \
  --name wms-config

# Flux-Pods im Cluster prüfen
kubectl get pods -n flux-system

# GitRepository- und Kustomization-Status prüfen
kubectl get gitrepositories,kustomizations -A

Ein erfolgreicher Output sieht so aus:

NAMESPACE        NAME                  READY   STATUS
cluster-config   wms-config-infra      True    Applied revision: main/a3f2b1c
cluster-config   wms-config-apps       True    Applied revision: main/a3f2b1c

Alle 12 Lager auf einen Schlag

Der Clou von GitOps auf Azure Arc: Du kannst dieselbe Konfiguration auf alle Lager gleichzeitig anwenden. Für jedes neue Lager:

# Lager München verbinden
az connectedk8s connect \
  --name PaketBlitz-Lager-Muenchen \
  --resource-group PaketBlitz-RG \
  --location westeurope

# Dieselbe GitOps-Konfiguration anwenden
az k8s-configuration flux create \
  --resource-group PaketBlitz-RG \
  --cluster-name PaketBlitz-Lager-Muenchen \
  --cluster-type connectedClusters \
  --name wms-config \
  --namespace cluster-config \
  --scope cluster \
  --url https://github.com/paketblitz-ops/paketblitz-wms-gitops \
  --branch main \
  --kustomization name=infra path=./infrastructure prune=true \
  --kustomization name=apps path=./apps prune=true dependsOn=\["infra"\]

Noch besser: Mit Azure Policy kannst du das komplett automatisieren, sodass jeder neue Arc-Cluster automatisch die GitOps-Konfiguration bekommt. Dazu kommen wir in Schritt 4.

Maren rollt innerhalb von zwei Wochen alle 12 Lager aus. Dasselbe Repo, derselbe Befehl, Copy-Paste mit angepasstem Clusternamen. Am Ende zeigt das Azure Portal 12 verbundene Cluster, alle mit Status "Compliant". Ihr altes 340-Zeilen-Deployment-Script? Gelöscht. Commit-Message: "🔥 Good riddance."


Schritt 3: CI/CD mit GitHub Actions

GitOps kümmert sich um das Deployment - aber wer baut die Container-Images und aktualisiert die Manifeste im Git-Repo? Dafür brauchst du eine CI/CD-Pipeline.

Der Workflow

Dienstag, 14:15 Uhr. Lisa, Backend-Entwicklerin bei PaketBlitz, committet einen Fix für den Kommissionier-Algorithmus. Bisher hat die WMS-API bei gleichzeitigen Pick-Aufträgen manchmal denselben Lagerplatz doppelt zugewiesen - zwei Kommissionierer standen dann vor demselben Regal und einer ging leer aus. Lisas Fix: ein optimistisches Locking auf der Lagerplatz-Zuweisung.

Sie pusht auf main. Was passiert in den nächsten 8 Minuten?

graph LR
    A["👩‍💻 Developer\ngit push\n(wms-api)"] -->|push| B["⚙️ GitHub\nActions\nBuild + Test"]
    B -->|docker push| C["📦 ACR\nContainer\nRegistry"]
    C -->|update tag| D["📂 GitOps\nRepo\nUpdate"]
    D -->|"Flux Pull"| E["🏭 Alle 12 Lager\nRolling Update\nder WMS-API"]

    style A fill:#1a1a2e,stroke:#0ff,color:#fff
    style B fill:#1a1a2e,stroke:#00b4d8,color:#fff
    style C fill:#0d1b2a,stroke:#00b4d8,color:#fff
    style D fill:#0d1b2a,stroke:#0ff,color:#fff
    style E fill:#162447,stroke:#0ff,color:#fff
  1. 14:15 - Lisa pusht ihren Fix ins Source-Repo
  2. 14:16 - GitHub Actions triggert: Build, Unit Tests, Docker-Image wird gebaut und in die Azure Container Registry gepusht
  3. 14:19 - Ein zweiter Job aktualisiert das Image-Tag im GitOps-Repo
  4. 14:20 - Flux erkennt die Änderung und startet das Rolling Update auf allen 12 Clustern
  5. 14:23 - Alle Lager laufen auf der neuen Version. Lisa bekommt eine Slack-Notification: "wms-api v20260325-a3f2b1c deployed to 12/12 clusters ✅"

Lisa hat keinen einzigen Server angefasst. Maren hat nichts gemerkt. Die Lagermitarbeiter schon gar nicht - das Rolling Update hat null Downtime verursacht.

GitHub Actions Workflow

# .github/workflows/build-wms-api.yaml
name: Build WMS-API

on:
  push:
    branches: [main]
    paths:
      - 'services/wms-api/**'
      - 'Dockerfile.wms-api'

env:
  ACR_NAME: paketblitzacr
  IMAGE_NAME: wms-api

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.meta.outputs.version }}

    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Login to Azure Container Registry
        uses: azure/docker-login@v2
        with:
          login-server: ${{ env.ACR_NAME }}.azurecr.io
          username: ${{ secrets.ACR_USERNAME }}
          password: ${{ secrets.ACR_PASSWORD }}

      - name: Generate Image Tag
        id: meta
        run: |
          VERSION=$(date +%Y%m%d)-${GITHUB_SHA::8}
          echo "version=${VERSION}" >> $GITHUB_OUTPUT
          echo "Building image with tag: ${VERSION}"

      - name: Build and Push Docker Image
        run: |
          docker build \
            -f Dockerfile.wms-api \
            -t ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} \
            .
          docker push ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}

  update-manifests:
    needs: build-and-push
    runs-on: ubuntu-latest

    steps:
      - name: Checkout GitOps Repo
        uses: actions/checkout@v4
        with:
          repository: paketblitz-ops/paketblitz-wms-gitops
          token: ${{ secrets.GITOPS_TOKEN }}

      - name: Update Deployment Manifest
        run: |
          NEW_TAG="${{ needs.build-and-push.outputs.image-tag }}"
          sed -i "s|image: paketblitzacr.azurecr.io/wms-api:.*|image: paketblitzacr.azurecr.io/wms-api:${NEW_TAG}|" \
            apps/wms-api/deployment.yaml

      - name: Commit and Push Changes
        run: |
          git config user.name "GitHub Actions"
          git config user.email "ci@paketblitz.de"
          git add apps/wms-api/deployment.yaml
          git commit -m "release: wms-api ${{ needs.build-and-push.outputs.image-tag }}"
          git push

Wichtig: Verwende niemals das Tag latest in deinen Deployment-Manifesten. Benutze immer eine spezifische Version (hier: Datum + Commit-SHA), damit Flux die Änderung erkennt und ein Rolling Update triggert. Mit latest passiert nichts - Flux sieht keinen Diff im Manifest.

Alternative: Flux Image Automation

Statt die Manifeste manuell per CI/CD zu aktualisieren, kannst du Flux auch die Image-Updates automatisch verwalten lassen. Dafür aktivierst du die Image Automation und Image Reflector Controller:

az k8s-extension create \
  --resource-group PaketBlitz-RG \
  --cluster-name PaketBlitz-Lager-Hamburg \
  --cluster-type connectedClusters \
  --name flux \
  --extension-type microsoft.flux \
  --config image-automation-controller.enabled=true \
  --config image-reflector-controller.enabled=true

Flux scannt dann die ACR regelmäßig nach neuen Image-Tags und aktualisiert die Manifeste automatisch. Für PaketBlitz hat Maren sich bewusst dagegen entschieden: In einem Lager willst du nicht, dass ein neues Image automatisch um 11 Uhr morgens deployt wird, wenn gerade 200 Kommissionieraufträge in der Queue stehen. Der explizite CI/CD-Ansatz gibt dem Team die Kontrolle über den Release-Zeitpunkt - Deployments passieren nur, wenn jemand bewusst auf main pusht.


Schritt 4: Azure Policy - Governance auf allen Clustern

Drei Wochen nach dem GitOps-Rollout passiert, was Maren befürchtet hat: Ein Junior-Dev hat zum Debuggen einen Nginx-Container direkt von Docker Hub auf den Lager-Cluster in Stuttgart deployt. Ungepatchtes Image, kein Resource Limit, läuft als Root. Nicht böswillig - einfach Unwissenheit. Aber genau so entstehen Sicherheitslücken.

Maren will keine Verbotsschilder aufhängen. Sie will technische Leitplanken, die verhindern, dass so etwas überhaupt möglich ist. Azure Policy für Kubernetes nutzt OPA Gatekeeper im Hintergrund, um genau das umzusetzen: Regeln als Admission Controller, die bei jedem kubectl apply greifen.

Azure Policy Extension installieren

Für Arc-enabled Kubernetes Cluster installierst du die Policy Extension:

# Azure Policy Extension installieren
az k8s-extension create \
  --cluster-type connectedClusters \
  --cluster-name PaketBlitz-Lager-Hamburg \
  --resource-group PaketBlitz-RG \
  --extension-type Microsoft.PolicyInsights \
  --name azurepolicy

# Prüfen, ob die Pods laufen
kubectl get pods -n kube-system | grep azure-policy
kubectl get pods -n gatekeeper-system

Nützliche Built-in Policies für PaketBlitz

Azure stellt eine große Bibliothek an Built-in Policies für Kubernetes bereit. Hier die wichtigsten für unser Szenario:

1. Nur Images aus der PaketBlitz ACR erlauben

Die Stuttgart-Regel. Diese Policy stellt sicher, dass niemand - weder versehentlich noch absichtlich - ein Image von Docker Hub oder einer anderen Registry deployt. Nur was durch die eigene CI/CD-Pipeline gelaufen und in der PaketBlitz ACR gelandet ist, darf auf die Cluster:

az policy assignment create \
  --name "AllowedContainerImages" \
  --display-name "Nur PaketBlitz ACR Images erlauben" \
  --policy "febd0533-8e55-448f-b837-bd0e06f16469" \
  --scope "/subscriptions/{subscriptionId}/resourceGroups/PaketBlitz-RG" \
  --params '{"allowedContainerImagesRegex": {"value": "^paketblitzacr\\.azurecr\\.io/.+$"}}'

2. Privilegierte Container verhindern

Kein Container im Lager-Cluster braucht Root-Rechte. Diese Policy verhindert, dass jemand versehentlich einen privilegierten Container deployt:

az policy assignment create \
  --name "NoPrivilegedContainers" \
  --display-name "Keine privilegierten Container" \
  --policy "95edb821-ddaf-4404-9732-666045e056b4" \
  --scope "/subscriptions/{subscriptionId}/resourceGroups/PaketBlitz-RG"

3. Resource Limits erzwingen

Jens hat das auf die harte Tour gelernt: Ein WMS-Frontend mit Memory Leak hat einmal seinen gesamten NUC in Hamburg zum Swappen gebracht - 20 Minuten lang ging gar nichts. Diese Policy erzwingt, dass jeder Container CPU- und Memory-Limits definiert:

az policy assignment create \
  --name "EnforceResourceLimits" \
  --display-name "CPU und Memory Limits erzwingen" \
  --policy "e345eecc-fa47-480f-9e88-67dcc122b164" \
  --scope "/subscriptions/{subscriptionId}/resourceGroups/PaketBlitz-RG" \
  --params '{
    "cpuLimit": {"value": "2"},
    "memoryLimit": {"value": "2Gi"}
  }'

GitOps at Scale: Neue Lager automatisch konfigurieren

Das ist der Moment, in dem Maren dem CTO eine Demo gibt und er zum ersten Mal nickt.

PaketBlitz plant für Q3 ein 13. Distributionszentrum in Rostock. Bisher hätte das bedeutet: Maren fliegt hin, setzt den Cluster auf, installiert alles manuell, testet drei Tage. Mit Azure Policy passiert das automatisch: Sobald der K3s-Cluster in Rostock mit Arc verbunden wird, greifen die Policies und Flux wird konfiguriert - ohne dass jemand einen einzigen az k8s-configuration Befehl ausführen muss:

az policy assignment create \
  --name "GitOpsWMSConfig" \
  --display-name "WMS GitOps-Konfiguration auf allen Lagern" \
  --policy "a6f3a54-7e23-4b4c-ae71-6c6a27d08f3c" \
  --scope "/subscriptions/{subscriptionId}/resourceGroups/PaketBlitz-RG" \
  --params '{
    "configurationName": {"value": "wms-config"},
    "namespace": {"value": "cluster-config"},
    "scope": {"value": "cluster"},
    "sourceKind": {"value": "GitRepository"},
    "url": {"value": "https://github.com/paketblitz-ops/paketblitz-wms-gitops"},
    "branch": {"value": "main"}
  }'

So funktioniert der Compliance-Zyklus: Der Azure Policy Agent auf jedem Cluster prüft alle 15 Minuten auf neue oder geänderte Policy Assignments. Constraint Templates und Constraints werden automatisch angewendet. Verstöße werden an Azure zurückgemeldet und erscheinen im Compliance Dashboard - dort sieht das PaketBlitz-Ops-Team auf einen Blick, ob alle 12 Lager compliant sind.


Schritt 5: Monitoring mit Azure Monitor

Donnerstagnacht, 2:47 Uhr. Der Label-Service im Lager Leipzig stürzt ab - ein Edge Case in der GLS-Label-Generierung, der nur bei Paketen über 31,5 kg auftritt. Die Nachtschicht in Leipzig druckt gerade 400 Labels für einen Großauftrag. Ohne Monitoring hätte es niemand vor 6 Uhr morgens bemerkt, wenn der Schichtleiter die Schlange wartender Pakete entdeckt.

Aber Maren hat vorgebaut. Ihr Handy vibriert um 2:49 Uhr: "Alert: label-service Pod CrashLoopBackOff in PaketBlitz-Lager-Leipzig since 2:47". Um 2:55 hat sie den Stacktrace in den Logs, um 3:10 hat Lisa einen Hotfix gepusht, um 3:18 läuft der Label-Service wieder. Die Nachtschicht hat 30 Minuten verloren statt 3 Stunden.

Damit das funktioniert, braucht PaketBlitz drei Bausteine: Container Insights für Logs, Managed Prometheus für Metriken und Azure Managed Grafana für Dashboards.

Log Analytics Workspace erstellen

Zuerst brauchst du einen zentralen Workspace, in den alle Lager ihre Logs und Metriken senden:

az monitor log-analytics workspace create \
  --resource-group PaketBlitz-RG \
  --workspace-name paketblitz-logs \
  --location westeurope

Container Insights Extension auf Arc-Cluster installieren

Für jeden Arc-enabled Lager-Cluster installierst du Container Insights als Extension:

az k8s-extension create \
  --name azuremonitor-containers \
  --extension-type Microsoft.AzureMonitor.Containers \
  --cluster-name PaketBlitz-Lager-Hamburg \
  --resource-group PaketBlitz-RG \
  --cluster-type connectedClusters \
  --configuration-settings logAnalyticsWorkspaceResourceID="/subscriptions/{subId}/resourceGroups/PaketBlitz-RG/providers/Microsoft.OperationalInsights/workspaces/paketblitz-logs"

Deployment verifizieren

# Container Insights Agent prüfen - läuft als DaemonSet auf jedem Node
kubectl get ds ama-logs --namespace=kube-system

# Prometheus Metrics Agent prüfen (falls installiert)
kubectl get ds ama-metrics-node --namespace=kube-system

Managed Grafana für Dashboards

Für die visuelle Darstellung erstellt PaketBlitz ein Grafana-Dashboard, das alle Lager auf einen Blick zeigt:

# Azure Managed Grafana erstellen
az grafana create \
  --name paketblitz-grafana \
  --resource-group PaketBlitz-RG \
  --location westeurope

# Azure Monitor Workspace für Prometheus
az monitor account create \
  --name paketblitz-metrics \
  --resource-group PaketBlitz-RG \
  --location westeurope

Was Maren jetzt sieht

Marens Morgenroutine hat sich verändert. Statt 12 SSH-Sessions öffnet sie das Azure Portal und hat in einer Ansicht:

  • Container Insights: Live-Logs aller WMS-Pods über alle 12 Lager. Wenn in Frankfurt die WMS-API Errors wirft, sieht sie es sofort - nicht erst, wenn der Lagerleiter anruft
  • Grafana Dashboards: CPU, Memory, Network pro Lager, nebeneinander. Sie sieht sofort, dass Hamburg zur Peak-Time (8-10 Uhr) bei 78% CPU liegt, während München bei 45% dümpelt
  • Azure Policy Compliance: Grüne Häkchen bei allen 12 Lagern. Kein Lager hat unautorisierte Images oder Container ohne Resource Limits
  • GitOps Status: Alle Cluster auf main/a3f2b1c. Kein Lager hängt auf einer alten Version fest

Praxis-Tipp für PaketBlitz: Erstelle ein Alert Rule, das triggert, wenn der WMS-API Pod in einem Lager länger als 2 Minuten nicht Ready ist. In der Logistik zählt jede Minute Ausfallzeit.


Sicherheit: Kommunikation und Secrets absichern

Der CTO hat eine Frage, die Maren erwartet hat: "Und was ist mit Sicherheit? Die Lager-Netzwerke hängen am selben Netz wie die Fördertechnik." Berechtigte Sorge - ein kompromittierter Container im Lager könnte theoretisch SPS-Steuerungen erreichen, die die Förderbänder steuern.

Netzwerk absichern

Maren hat drei Verteidigungslinien eingezogen:

  • Site-to-Site VPN: Zwischen jedem Lager und dem Azure VNet für verschlüsselte Kommunikation
  • Azure Private Link: Für Arc-enabled Kubernetes mit Private Endpoints, damit der Traffic nicht über das öffentliche Internet läuft
  • Kubernetes Network Policies: Im Cluster selbst, um die Pod-zu-Pod-Kommunikation einzuschränken (z.B. darf der Label-Service nicht direkt auf die Datenbank zugreifen)

Secret Management mit Azure Key Vault

Datenbank-Passwörter, ACR-Zugangsdaten, API-Keys - das gehört nicht in Kubernetes Secrets (die sind nur Base64-kodiert, nicht verschlüsselt). Verwende Azure Key Vault:

# Key Vault Extension installieren
az k8s-extension create \
  --cluster-name PaketBlitz-Lager-Hamburg \
  --resource-group PaketBlitz-RG \
  --cluster-type connectedClusters \
  --extension-type Microsoft.AzureKeyVaultSecretsProvider \
  --name akvsecretsprovider

Cleanup: Cluster und Konfigurationen entfernen

Nicht jede Geschichte hat ein Happy End: PaketBlitz schließt das Lager in Dortmund - zu klein, zu alt, der Mietvertrag läuft aus. Maren muss den Cluster sauber aus Arc entfernen, bevor Jens dort den NUC vom Strom nimmt. Die Reihenfolge ist dabei entscheidend:

# 1. Zuerst Flux-Konfigurationen löschen
az k8s-configuration flux delete \
  --resource-group PaketBlitz-RG \
  --cluster-name PaketBlitz-Lager-Hamburg \
  --cluster-type connectedClusters \
  --name wms-config \
  --yes

# 2. Dann die Extensions löschen
az k8s-extension delete \
  --resource-group PaketBlitz-RG \
  --cluster-name PaketBlitz-Lager-Hamburg \
  --cluster-type connectedClusters \
  --name flux \
  --yes

# 3. Zuletzt den Cluster aus Arc entfernen
az connectedk8s delete \
  --name PaketBlitz-Lager-Hamburg \
  --resource-group PaketBlitz-RG

Wichtig: Lösche immer in dieser Reihenfolge: Flux-Konfigurationen → Extensions → Cluster. Andersherum kann der Cluster in einem instabilen Zustand bleiben, weil die Finalizer der Extensions nicht mehr aufgeräumt werden können.


Fazit

Sechs Monate nach dem Rollout sitzt Maren an einem Freitagabend auf der Couch. Ihr Handy ist still. Kein Slack-Alert, kein SSH-Marathon, kein Label-Chaos. In der Zwischenzeit hat PaketBlitz:

  • 47 Updates über alle 12 Lager ausgerollt - durchschnittliche Dauer: 8 Minuten pro Release
  • Das 13. Lager in Rostock in Betrieb genommen: Cluster aufgesetzt, az connectedk8s connect ausgeführt, Feierabend. Azure Policy hat den Rest erledigt
  • Zero Downtime bei allen Deployments. Die Lagermitarbeiter wissen nicht einmal, dass Updates passieren
  • 3 Incidents in unter 15 Minuten gelöst - weil das Monitoring sofort Alarm geschlagen hat, nicht erst der Schichtleiter am nächsten Morgen

Was hat das möglich gemacht?

  • Azure Arc bringt alle Cluster in ein einheitliches Management-Plane - egal ob auf einem NUC in Hamburg oder einem Server-Rack in München
  • GitOps mit Flux v2 hat Marens 340-Zeilen-Deployment-Script ersetzt. Der gewünschte Zustand lebt im Git-Repo, nicht in ihrem Kopf
  • Azure Policy sorgt dafür, dass kein Lager aus der Reihe tanzt - der Stuttgart-Vorfall mit dem Docker-Hub-Image kann nicht mehr passieren
  • CI/CD mit GitHub Actions bringt Lisas Code in Minuten von der IDE in alle 12 Lager
  • Azure Monitor gibt Maren die Gewissheit, dass sie schlafen kann - und trotzdem weiß, wenn etwas schiefläuft

Der CTO hat letzte Woche in einem Meeting gesagt: "Das Beste an der neuen Plattform ist, dass ich gar nicht mehr daran denken muss." Maren hat gelächelt. Genau das war der Plan.


Weiterlesen