Dies ist eine alte Version des Dokuments!
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 - name: Health-Endpoint für den Load Balancer ansible.builtin.copy: dest: /var/www/html/health content: "ok\n" mode: "0644" - 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", "/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
${environment} ersetzt Terraform per templatefile(), $(hostname) wird erst auf der Instanz ausgewertet.
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:
planim Merge Request,applynach 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
