| FOTO | AUTO | EDV | AUDIO |

IaC-Lab: Nutzvieh auf Azure

Dieses Lab zeigt die komplette Kette vom Code zum laufenden, selbstheilenden Webserver-Cluster auf Azure. Kein Server wird per SSH angefasst: Konfiguration steckt im Image, Instanzen entstehen und verschwinden automatisch.

Zurück zur IaC-Übersicht

Ziel und Architektur

                 Internet
                    │ HTTP :80
          ┌─────────▼──────────┐
          │ Standard Load Bal. │  Health Probe: GET /health
          │ + Outbound Rule    │
          └─────────┬──────────┘
      ┌─────────────┼─────────────┐
      ▼             ▼             ▼
  ┌────────┐   ┌────────┐   ┌────────┐
  │ web-0  │   │ web-1  │   │ web-2  │   VM Scale Set, Zonen 1/2/3
  │ Zone 1 │   │ Zone 2 │   │ Zone 3 │   Rolling Upgrade + Auto-Repair
  └────────┘   └────────┘   └────────┘
      ▲  alle aus demselben Golden Image
      │
  Azure Compute Gallery  ◄── Packer + Ansible (Image-Build)
Schicht Werkzeug Aufgabe Lebensdauer
Backend Azure CLI Storage Account für den Terraform-State einmalig
Base Terraform Netz, NSG, Compute Gallery, Image-Definition langlebig
Image Packer + Ansible Golden Image bauen, härten, versionieren pro Release
Workload Terraform Load Balancer, VM Scale Set austauschbar
Instanz-Bootstrap cloud-init Nur instanzspezifische Werte (Hostname, Umgebung) pro Instanz

Wo steckt hier das Nutzvieh-Prinzip?

Prinzip Umsetzung im Lab
Keine Handarbeit NSG erlaubt kein SSH, VMs haben keine öffentliche IP
Immutable Infrastructure Patches und Änderungen = neue Image-Version, keine apt upgrade auf Instanzen
Ersetzen statt reparieren Automatic Instance Repair ersetzt Instanzen mit fehlschlagender Health Probe
Austauschbare Namen Scale Set vergibt Instanznamen selbst
Git als Quelle der Wahrheit Image-Version steht in terraform.tfvars, Rollout per Commit
Drift sichtbar machen terraform plan zeigt jede Abweichung vom Code

Voraussetzungen

  • Azure-Subscription mit Rolle Owner oder Contributor + User Access Administrator (für Rollenzuweisung am State-Storage)
  • Azure CLI, angemeldet mit az login
  • Terraform >= 1.6 oder OpenTofu
  • Packer >= 1.10
  • Ansible >= 2.15
  • SSH-Schlüsselpaar (nur als Notfallzugang, wird von Azure für Linux-VMs verlangt)
export ARM_SUBSCRIPTION_ID=$(az account show --query id -o tsv)

Terraform (azurerm 4.x) und Packer lesen die Subscription aus dieser Variable.

Kosten: Drei kleine B-Series-Instanzen, ein Standard Load Balancer und eine Public IP liegen grob im niedrigen einstelligen Euro-Bereich pro Tag. Nach dem Üben mit terraform destroy aufräumen.

Repository-Struktur

iac-lab-azure/
├── bootstrap/
│   └── backend.sh          # State-Backend anlegen, erzeugt ../backend.hcl
├── base/
│   └── main.tf             # Netz, NSG, Compute Gallery
├── ansible/
│   └── web.yml             # Konfiguration des Golden Image
├── packer/
│   └── web.pkr.hcl         # Image-Build
└── workload/
    ├── main.tf             # Load Balancer + VM Scale Set
    ├── cloud-init.yaml     # Instanz-Bootstrap
    └── terraform.tfvars    # Image-Version, Anzahl Instanzen

Schritt 1: State-Backend

Der Terraform-State ist das Gedächtnis der Infrastruktur und liegt deshalb zentral, versioniert und ohne Shared Keys in einem Storage Account. Zugriff erfolgt ausschließlich über Entra ID.

backend.sh
#!/usr/bin/env bash
set -euo pipefail
 
LOCATION="germanywestcentral"
RG="rg-iac-tfstate"
SA="stiactfstate$(openssl rand -hex 4)"   # global eindeutig, 3-24 Zeichen
CONTAINER="tfstate"
 
az group create -n "$RG" -l "$LOCATION" -o none
 
az storage account create -n "$SA" -g "$RG" -l "$LOCATION" \
  --sku Standard_ZRS --kind StorageV2 \
  --min-tls-version TLS1_2 \
  --allow-blob-public-access false \
  --allow-shared-key-access false -o none
 
az storage account blob-service-properties update \
  --account-name "$SA" -g "$RG" \
  --enable-versioning true \
  --enable-delete-retention true --delete-retention-days 30 -o none
 
SA_ID=$(az storage account show -n "$SA" -g "$RG" --query id -o tsv)
ME=$(az ad signed-in-user show --query id -o tsv)
az role assignment create --role "Storage Blob Data Contributor" \
  --assignee "$ME" --scope "$SA_ID" -o none
 
echo "Warte auf Propagierung der Rollenzuweisung ..."
sleep 60
 
az storage container create -n "$CONTAINER" \
  --account-name "$SA" --auth-mode login -o none
 
cat > "$(dirname "$0")/../backend.hcl" <<EOF
resource_group_name  = "$RG"
storage_account_name = "$SA"
container_name       = "$CONTAINER"
use_azuread_auth     = true
EOF
 
echo "Backend bereit: $SA"

Schritt 2: Basis-Infrastruktur

Langlebige Bausteine, die sich selten ändern: Netz, Firewall-Regeln und die Compute Gallery, in der die Golden Images versioniert abgelegt werden.

Wichtig: Neu angelegte VNets erhalten bei Azure keinen impliziten Internetzugang mehr („private subnets“). Das Lab setzt deshalb default_outbound_access_enabled = false ausdrücklich und regelt ausgehenden Verkehr sauber über eine Outbound Rule am Load Balancer.

main.tf
terraform {
  required_version = ">= 1.6"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
  backend "azurerm" {}   # Werte aus backend.hcl
}
 
provider "azurerm" {
  features {}
}
 
variable "location" {
  type    = string
  default = "germanywestcentral"
}
 
variable "prefix" {
  type    = string
  default = "iaclab"
}
 
locals {
  tags = {
    managed_by = "terraform"
    project    = "iac-lab"
    layer      = "base"
  }
}
 
resource "azurerm_resource_group" "base" {
  name     = "rg-${var.prefix}-base"
  location = var.location
  tags     = local.tags
}
 
# --- Netz -------------------------------------------------------------
 
resource "azurerm_virtual_network" "vnet" {
  name                = "vnet-${var.prefix}"
  address_space       = ["10.42.0.0/16"]
  location            = azurerm_resource_group.base.location
  resource_group_name = azurerm_resource_group.base.name
  tags                = local.tags
}
 
resource "azurerm_subnet" "app" {
  name                            = "snet-app"
  resource_group_name             = azurerm_resource_group.base.name
  virtual_network_name            = azurerm_virtual_network.vnet.name
  address_prefixes                = ["10.42.1.0/24"]
  default_outbound_access_enabled = false   # Outbound nur explizit
}
 
resource "azurerm_network_security_group" "app" {
  name                = "nsg-${var.prefix}-app"
  location            = azurerm_resource_group.base.location
  resource_group_name = azurerm_resource_group.base.name
  tags                = local.tags
 
  security_rule {
    name                       = "allow-http-inbound"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "80"
    source_address_prefix      = "Internet"
    destination_address_prefix = "*"
  }
  # Bewusst KEINE SSH-Regel: Nutzvieh wird nicht gestreichelt.
  # Health Probes des Load Balancers erlaubt die Default-Regel
  # AllowAzureLoadBalancerInBound.
}
 
resource "azurerm_subnet_network_security_group_association" "app" {
  subnet_id                 = azurerm_subnet.app.id
  network_security_group_id = azurerm_network_security_group.app.id
}
 
# --- Compute Gallery für Golden Images --------------------------------
 
resource "azurerm_shared_image_gallery" "gallery" {
  name                = "gal_${var.prefix}"
  location            = azurerm_resource_group.base.location
  resource_group_name = azurerm_resource_group.base.name
  description         = "Golden Images für das IaC-Lab"
  tags                = local.tags
}
 
resource "azurerm_shared_image" "web" {
  name                = "img-web-ubuntu2404"
  gallery_name        = azurerm_shared_image_gallery.gallery.name
  resource_group_name = azurerm_resource_group.base.name
  location            = azurerm_resource_group.base.location
  os_type             = "Linux"
  hyper_v_generation  = "V2"
 
  identifier {
    publisher = "creutz"
    offer     = "iaclab"
    sku       = "web-ubuntu2404"
  }
  tags = local.tags
}
 
output "subnet_id" {
  value = azurerm_subnet.app.id
}
 
output "image_definition_id" {
  value = azurerm_shared_image.web.id
}
cd base
terraform init -backend-config=../backend.hcl -backend-config="key=lab/base.tfstate"
terraform plan -out=tfplan
terraform apply tfplan

Schritt 3: Golden Image mit Packer und Ansible

Hier entsteht das „Rind“: Ein Ubuntu-24.04-Basisimage wird mit Ansible konfiguriert, generalisiert und als versionierte Image-Version in die Gallery geschrieben. Ansible läuft nur beim Build, nie gegen produktive Instanzen.

Ansible-Playbook

web.yml
---
- name: Golden Image – Webserver
  hosts: all
  become: true
  vars:
    image_version: "dev"

  tasks:
    - name: Paketstand aktualisieren (Patchen passiert im Build, nicht im Betrieb)
      ansible.builtin.apt:
        update_cache: true
        upgrade: dist

    - name: nginx installieren
      ansible.builtin.apt:
        name: nginx
        state: present

    - name: unattended-upgrades entfernen (Instanzen bleiben identisch zum Image)
      ansible.builtin.apt:
        name: unattended-upgrades
        state: absent
        purge: true
 
    # Bewusst KEIN Health-Endpoint im Image: den legt cloud-init als letzten
    # Schritt an, damit eine Instanz erst nach dem Bootstrap Verkehr bekommt.

    - name: Image-Version dokumentieren
      ansible.builtin.copy:
        dest: /etc/image-release
        content: "{{ image_version }}\n"
        mode: "0644"

    - name: nginx beim Boot starten
      ansible.builtin.systemd:
        name: nginx
        enabled: true

Packer-Template

web.pkr.hcl
packer {
  required_plugins {
    azure = {
      source  = "github.com/hashicorp/azure"
      version = "~> 2"
    }
    ansible = {
      source  = "github.com/hashicorp/ansible"
      version = "~> 1"
    }
  }
}
 
variable "subscription_id" {
  type    = string
  default = env("ARM_SUBSCRIPTION_ID")
}
 
variable "image_version" {
  type        = string
  description = "SemVer der Image-Version, z. B. 1.0.0"
}
 
variable "location" {
  type    = string
  default = "germanywestcentral"
}
 
source "azure-arm" "web" {
  use_azure_cli_auth = true
  subscription_id    = var.subscription_id
  location           = var.location
  vm_size            = "Standard_B2s"
 
  os_type         = "Linux"
  image_publisher = "Canonical"
  image_offer     = "ubuntu-24_04-lts"
  image_sku       = "server"
 
  # Optional: SSH auf die temporäre Build-VM auf die eigene IP beschränken
  # allowed_inbound_ip_addresses = ["203.0.113.10"]
 
  shared_image_gallery_destination {
    subscription         = var.subscription_id
    resource_group       = "rg-iaclab-base"
    gallery_name         = "gal_iaclab"
    image_name           = "img-web-ubuntu2404"
    image_version        = var.image_version
    replication_regions  = [var.location]
    storage_account_type = "Standard_LRS"
  }
 
  azure_tags = {
    managed_by    = "packer"
    project       = "iac-lab"
    image_version = var.image_version
  }
}
 
build {
  sources = ["source.azure-arm.web"]
 
  # Warten, bis cloud-init der Build-VM fertig ist (sonst apt-Locks)
  provisioner "shell" {
    inline = ["cloud-init status --wait"]
  }
 
  provisioner "ansible" {
    playbook_file    = "${path.root}/../ansible/web.yml"
    use_proxy        = false
    ansible_env_vars = ["ANSIBLE_HOST_KEY_CHECKING=False"]
    extra_arguments  = ["--extra-vars", "image_version=${var.image_version}"]
  }
 
  # Generalisieren: Muss der letzte Schritt sein
  provisioner "shell" {
    execute_command = "chmod +x {{ .Path }}; {{ .Vars }} sudo -E sh '{{ .Path }}'"
    inline_shebang  = "/bin/sh -x"
    inline = [
      "apt-get clean",
      "cloud-init clean --logs --seed --machine-id",
      "/usr/sbin/waagent -force -deprovision+user && export HISTSIZE=0 && sync"
    ]
  }
}
cd packer
packer init .
packer build -var image_version=1.0.0 .

Packer legt eine temporäre Ressourcengruppe mit Build-VM an, konfiguriert sie, erzeugt die Image-Version und räumt die Build-Ressourcen wieder weg.

Schritt 4: Workload – Load Balancer und Scale Set

Der eigentliche Betrieb: ein zonenredundantes VM Scale Set hinter einem Standard Load Balancer. Das Scale Set tauscht Instanzen bei neuer Image-Version automatisch batchweise aus (Rolling Upgrade) und ersetzt kranke Instanzen selbstständig (Automatic Instance Repair).

cloud-init

cloud-init übernimmt nur, was pro Instanz verschieden ist. Alles Gemeinsame steckt bereits im Image.

cloud-init.yaml
#cloud-config
runcmd:
  - |
    cat > /var/www/html/index.html <<EOF
    <!doctype html>
    <html><body style="font-family:sans-serif">
      <h1>IaC-Lab (${environment})</h1>
      <p>Instanz: $(hostname)</p>
      <p>Image-Version: $(cat /etc/image-release)</p>
    </body></html>
    EOF
  # Letzter Schritt: erst jetzt meldet sich die Instanz beim Load Balancer gesund
  - echo ok > /var/www/html/health

${environment} ersetzt Terraform per templatefile(), $(hostname) wird erst auf der Instanz ausgewertet.

Der Health-Endpunkt entsteht bewusst erst am Ende von cloud-init. Bis dahin schlägt die Health Probe fehl, und der Load Balancer schickt keine Anfragen an eine Instanz mit unvollständigem Bootstrap. Mehr dazu unter cloud-init.

Terraform

main.tf
terraform {
  required_version = ">= 1.6"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
  backend "azurerm" {}
}
 
provider "azurerm" {
  features {}
}
 
# --- Variablen --------------------------------------------------------
 
variable "location" {
  type    = string
  default = "germanywestcentral"
}
 
variable "prefix" {
  type    = string
  default = "iaclab"
}
 
variable "environment" {
  type    = string
  default = "lab"
}
 
variable "image_version" {
  type        = string
  description = "Image-Version aus der Compute Gallery – der Rollout-Schalter"
}
 
variable "instance_count" {
  type    = number
  default = 3
}
 
variable "vm_size" {
  type    = string
  default = "Standard_B2s"
}
 
variable "ssh_public_key_path" {
  type    = string
  default = "~/.ssh/id_ed25519.pub"
}
 
locals {
  tags = {
    managed_by = "terraform"
    project    = "iac-lab"
    layer      = "workload"
  }
}
 
# --- Verweise auf die Base-Schicht ------------------------------------
 
data "azurerm_subnet" "app" {
  name                 = "snet-app"
  virtual_network_name = "vnet-${var.prefix}"
  resource_group_name  = "rg-${var.prefix}-base"
}
 
data "azurerm_shared_image_version" "web" {
  name                = var.image_version
  image_name          = "img-web-ubuntu2404"
  gallery_name        = "gal_${var.prefix}"
  resource_group_name = "rg-${var.prefix}-base"
}
 
resource "azurerm_resource_group" "web" {
  name     = "rg-${var.prefix}-web"
  location = var.location
  tags     = local.tags
}
 
# --- Load Balancer ----------------------------------------------------
 
resource "azurerm_public_ip" "lb" {
  name                = "pip-${var.prefix}-lb"
  location            = azurerm_resource_group.web.location
  resource_group_name = azurerm_resource_group.web.name
  allocation_method   = "Static"
  sku                 = "Standard"
  zones               = ["1", "2", "3"]
  tags                = local.tags
}
 
resource "azurerm_lb" "web" {
  name                = "lb-${var.prefix}-web"
  location            = azurerm_resource_group.web.location
  resource_group_name = azurerm_resource_group.web.name
  sku                 = "Standard"
  tags                = local.tags
 
  frontend_ip_configuration {
    name                 = "fe"
    public_ip_address_id = azurerm_public_ip.lb.id
  }
}
 
resource "azurerm_lb_backend_address_pool" "web" {
  name            = "be-web"
  loadbalancer_id = azurerm_lb.web.id
}
 
resource "azurerm_lb_probe" "http" {
  name            = "probe-http"
  loadbalancer_id = azurerm_lb.web.id
  protocol        = "Http"
  port            = 80
  request_path    = "/health"
}
 
resource "azurerm_lb_rule" "http" {
  name                           = "http"
  loadbalancer_id                = azurerm_lb.web.id
  protocol                       = "Tcp"
  frontend_port                  = 80
  backend_port                   = 80
  frontend_ip_configuration_name = "fe"
  backend_address_pool_ids       = [azurerm_lb_backend_address_pool.web.id]
  probe_id                       = azurerm_lb_probe.http.id
  disable_outbound_snat          = true   # Outbound regelt die Outbound Rule
}
 
resource "azurerm_lb_outbound_rule" "egress" {
  name                    = "egress"
  loadbalancer_id         = azurerm_lb.web.id
  protocol                = "All"
  backend_address_pool_id = azurerm_lb_backend_address_pool.web.id
 
  frontend_ip_configuration {
    name = "fe"
  }
}
 
# --- VM Scale Set: die Herde ------------------------------------------
 
resource "azurerm_linux_virtual_machine_scale_set" "web" {
  name                = "vmss-${var.prefix}-web"
  location            = azurerm_resource_group.web.location
  resource_group_name = azurerm_resource_group.web.name
  sku                 = var.vm_size
  instances           = var.instance_count
  zones               = ["1", "2", "3"]
  tags                = local.tags
 
  source_image_id = data.azurerm_shared_image_version.web.id
  custom_data = base64encode(templatefile("${path.module}/cloud-init.yaml", {
    environment = var.environment
  }))
 
  # Von Azure verlangt; dient nur als Notfallzugang (NSG blockt SSH)
  admin_username                  = "azureadmin"
  disable_password_authentication = true
  admin_ssh_key {
    username   = "azureadmin"
    public_key = file(pathexpand(var.ssh_public_key_path))
  }
 
  # Neue Image-Version => batchweiser Austausch der Instanzen
  upgrade_mode    = "Rolling"
  health_probe_id = azurerm_lb_probe.http.id
 
  rolling_upgrade_policy {
    max_batch_instance_percent              = 34
    max_unhealthy_instance_percent          = 34
    max_unhealthy_upgraded_instance_percent = 34
    pause_time_between_batches              = "PT30S"
  }
 
  # Kranke Instanz => wird ersetzt, nicht repariert
  automatic_instance_repair {
    enabled      = true
    grace_period = "PT10M"
  }
 
  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }
 
  network_interface {
    name    = "nic"
    primary = true
 
    ip_configuration {
      name                                   = "ipcfg"
      primary                                = true
      subnet_id                              = data.azurerm_subnet.app.id
      load_balancer_backend_address_pool_ids = [azurerm_lb_backend_address_pool.web.id]
    }
  }
 
  depends_on = [azurerm_lb_rule.http]
}
 
output "url" {
  value = "http://${azurerm_public_ip.lb.ip_address}"
}
terraform.tfvars
image_version  = "1.0.0"
instance_count = 3
cd workload
terraform init -backend-config=../backend.hcl -backend-config="key=lab/workload.tfstate"
terraform plan -out=tfplan
terraform apply tfplan

Schritt 5: Das Prinzip erleben

Lastverteilung

URL=$(terraform output -raw url)
for i in $(seq 1 10); do curl -s "$URL" | grep Instanz; done

Die Antworten kommen von wechselnden Instanzen. Welche es ist, spielt keine Rolle.

Selbstheilung

Eine Instanz „erkranken“ lassen, ohne sich anzumelden:

RG=rg-iaclab-web; VMSS=vmss-iaclab-web
ID=$(az vmss list-instances -g $RG -n $VMSS --query "[0].instanceId" -o tsv)
 
az vmss run-command invoke -g $RG -n $VMSS --instance-id "$ID" \
  --command-id RunShellScript --scripts "systemctl stop nginx"
 
watch -n 30 "az vmss list-instances -g $RG -n $VMSS -o table"

Der Load Balancer nimmt die Instanz sofort aus der Verteilung. Nach Ablauf der Grace Period löscht das Scale Set sie und ersetzt sie durch eine frische Instanz aus dem Image. Niemand debuggt, niemand loggt sich ein.

Drift erkennen und beseitigen

az vmss scale -g $RG -n $VMSS --new-capacity 1   # jemand "optimiert" von Hand
terraform plan                                    # zeigt: instances 1 -> 3
terraform apply                                   # Code gewinnt

Neues Release ausrollen

Eine Änderung (z. B. zusätzliches Paket oder Härtung) wird ins Playbook geschrieben, dann:

cd packer && packer build -var image_version=1.1.0 .
cd ../workload
sed -i 's/image_version  = "1.0.0"/image_version  = "1.1.0"/' terraform.tfvars
git commit -am "Rollout Image 1.1.0"
terraform plan -out=tfplan && terraform apply tfplan

Parallel die Schleife aus „Lastverteilung“ laufen lassen: Die Image-Version wechselt batchweise von 1.0.0 auf 1.1.0, ohne dass der Dienst ausfällt. Ein Rollback ist derselbe Vorgang mit der alten Versionsnummer.

Skalieren

instance_count = 6 in terraform.tfvars, committen, anwenden. Das ist die ganze Kapazitätsplanung auf Instanzebene.

Aufräumen

cd workload && terraform destroy
cd ../base  && terraform destroy
# Optional auch das State-Backend:
az group delete -n rg-iac-tfstate --yes

Nächste Ausbaustufen

  • Pipeline statt Laptop: plan im Merge Request, apply nach Freigabe, Anmeldung per Workload Identity Federation (OIDC) statt Secrets → IaC-Pipeline
  • Policy as Code: Checkov oder Trivy prüfen den Terraform-Code vor dem apply
  • Wiederverwendbare Module statt Copy & Paste zwischen Umgebungen (dev/test/prod)
  • Autoscaling nach CPU oder Requests mit azurerm_monitor_autoscale_setting
  • Image-Pipeline mit CIS-Härtung und Schwachstellen-Scan vor Freigabe der Version
  • Einordnung in Change Enablement und CMDB → IaC und ITIL v4