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
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 |
| 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 |
az loginexport 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.
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
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.
#!/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"
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.
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
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.
--- - 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 {
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.
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 übernimmt nur, was pro Instanz verschieden ist. Alles Gemeinsame steckt bereits im Image.
#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 {
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}"
}
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
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.
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.
az vmss scale -g $RG -n $VMSS --new-capacity 1 # jemand "optimiert" von Hand terraform plan # zeigt: instances 1 -> 3 terraform apply # Code gewinnt
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.
instance_count = 6 in terraform.tfvars, committen, anwenden. Das ist die ganze Kapazitätsplanung auf Instanzebene.
cd workload && terraform destroy cd ../base && terraform destroy # Optional auch das State-Backend: az group delete -n rg-iac-tfstate --yes
plan im Merge Request, apply nach Freigabe, Anmeldung per Workload Identity Federation (OIDC) statt Secrets → IaC-Pipelineapplyazurerm_monitor_autoscale_setting