Ein Haustier wird einmal angeschafft und dann über Jahre gepflegt. Nutzvieh hat einen geplanten, kurzen und vollständig automatisierten Lebenszyklus. Diese Seite beschreibt jede Phase und zeigt, wie sie sich auf Azure mit Terraform umsetzen lässt – aufbauend auf dem IaC-Lab und der IaC-Pipeline.
Zurück zur IaC-Übersicht
┌──────────┐ ┌───────────┐ ┌───────────┐ ┌──────────┐
│ Build │──►│ Provision │──►│ Bootstrap │──►│ Betrieb │
│ (Packer) │ │(Terraform)│ │(cloud-init)│ │ Health, │
└──────────┘ └───────────┘ └───────────┘ │ Repair, │
▲ │ Scaling │
│ ┌──────────┐ └────┬─────┘
│ │ Rückbau │◄──── Scale-in ────────┤
│ │ │◄──── Update/Rollout ──┘
│ └──────────┘
└──── neue Image-Version (Patch, Feature)
| Phase | Auslöser | Werkzeug | Menschlicher Eingriff |
|---|---|---|---|
| Build | Codeänderung oder Patch-Zyklus | Packer + Ansible | Review im Merge Request |
| Provision | Merge + Apply | Terraform | Apply-Freigabe |
| Bootstrap | Start einer Instanz | cloud-init | keiner |
| Betrieb | laufend | Health Probe, Automatic Repair | keiner |
| Skalierung | Last | Autoscale | keiner |
| Update | neue Image-Version | Rolling Upgrade oder Blue/Green | Apply-Freigabe |
| Rückbau | Scale-in, Update, Service-Ende | Scale Set, Terraform | nur bei Service-Ende |
Nutzvieh funktioniert nur, wenn eine Instanz jederzeit ohne Verlust gelöscht werden kann. Alles, was überdauern muss, lebt außerhalb der Instanz.
| Zustand | Pet-Welt | Cattle-Welt |
|---|---|---|
| Anwendungsdaten | Lokale Platte | Managed Database, Storage Account |
| Sitzungen | Im Webserver-Speicher | Redis-Cache oder zustandslose Tokens |
| Logs | /var/log | Zentral gesammelt (Azure Monitor Agent, Splunk-Forwarder im Image) |
| Konfiguration | Handgepflegt auf dem Host | Im Image, instanzspezifisch per cloud-init |
| Secrets | Dateien auf dem Host | Key Vault, Abruf per Managed Identity zur Laufzeit |
| Uploads/Dateien | Lokales Verzeichnis | Blob Storage oder Azure Files |
Die zustandsbehafteten Ressourcen sind die eigentlichen „Haustiere“ der Architektur – und werden entsprechend geschützt:
resource "azurerm_postgresql_flexible_server" "db" {
# ...
lifecycle {
prevent_destroy = true # Terraform verweigert jedes Löschen oder Ersetzen
}
}
In der Change-Klassifizierung ist jedes Ersetzen ohnehin ein Normal Change. prevent_destroy ist die zweite, technische Sicherung.
Die Compute Gallery verlangt Versionen im Format Major.Minor.Patch mit ganzzahligen Teilen. Ein praktisches Schema:
| Teil | Bedeutung | Beispiel |
|---|---|---|
| Major | Grundlegender Wechsel, z. B. neues Betriebssystem | 2 = Ubuntu 26.04 |
| Minor | Funktionale Änderung im Playbook | 2.3 |
| Patch | Build-Datum für reine Patch-Builds | 2.3.20261001 |
So ist an jeder Instanz ablesbar, welchen Funktionsstand und welchen Patchstand sie hat.
Sicherheitsupdates werden nicht auf laufenden Instanzen eingespielt, sondern in ein neues Image gebaut. Ein monatlicher Zeitplan in GitLab baut das Image und öffnet automatisch einen Merge Request für den Rollout. Der Rollout selbst bleibt ein geprüfter Standard Change.
image:monthly: stage: image extends: .id_apply script: - VERSION="$(cut -d. -f1-2 packer/VERSION).$(date +%Y%m%d)" - az login --service-principal -u "$ARM_CLIENT_ID" -t "$ARM_TENANT_ID" --federated-token "$AZURE_ID_TOKEN" -o none - az account set -s "$ARM_SUBSCRIPTION_ID" - (cd packer && packer init . && packer build -color=false -var image_version="$VERSION" .) - git switch -c "patch-image-$VERSION" - sed -i -E "s/(image_version *= *\")[^\"]+/\1$VERSION/" workload/terraform.tfvars - git -c user.name="IaC-Pipeline" -c user.email="ci@localhost" commit -am "Rollout Patch-Image $VERSION" - git push "https://oauth2:${CI_PUSH_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD -o merge_request.create -o merge_request.target="$CI_DEFAULT_BRANCH" -o merge_request.title="Patch-Image $VERSION" rules: - if: $CI_PIPELINE_SOURCE == "schedule" && $SCHEDULE == "monthly-image"
Hinweise:
CI_PUSH_TOKEN ist ein Projekt-Zugriffstoken mit Schreibrecht auf das Repository, als maskierte und geschützte Variable hinterlegt. Es ist ein GitLab-Geheimnis, kein Azure-Zugang.SCHEDULE (monthly-image bzw. drift); die Regel des Drift-Jobs wird entsprechend auf $SCHEDULE == „drift“ erweitert.Scale Sets können neue Image-Versionen aus der Compute Gallery auch selbstständig ausrollen. Das ist bequem, umgeht aber Merge Request, Plan und Freigabe. Für nachvollziehbare Changes ist die explizite Version im Code die bessere Wahl.
Im Lab bereits aktiv: Schlägt die Health Probe einer Instanz über die Grace Period hinaus fehl, ersetzt das Scale Set sie. Siehe IaC-Lab, Schritt 5.
Beim Verkleinern soll das Scale Set die ältesten Instanzen zuerst entfernen – sie sind am längsten gelaufen und am weitesten vom frischen Zustand entfernt. Außerdem bekommt jede Instanz vor dem Löschen eine Vorwarnung, um laufende Anfragen sauber abzuschließen.
Ergänzung im Scale Set des Labs:
resource "azurerm_linux_virtual_machine_scale_set" "web" {
# ... wie im Lab
scale_in {
rule = "OldestVM"
}
termination_notification {
enabled = true
timeout = "PT5M"
}
}
Die Instanz erfährt vom bevorstehenden Ende über den Scheduled Events-Endpunkt des Azure Instance Metadata Service. Ein kleiner Dienst im Image fragt ihn regelmäßig ab, lässt die Health Probe bei einem Terminate-Ereignis fehlschlagen (der Load Balancer schickt dann keine neuen Anfragen mehr), wartet laufende Verbindungen ab und bestätigt das Ereignis. Dieser Dienst gehört ins Ansible-Playbook.
Das Scale Set passt die Anzahl der Instanzen selbst an die Last an:
resource "azurerm_monitor_autoscale_setting" "web" {
name = "as-${var.prefix}-web"
resource_group_name = azurerm_resource_group.web.name
location = azurerm_resource_group.web.location
target_resource_id = azurerm_linux_virtual_machine_scale_set.web.id
profile {
name = "cpu"
capacity {
default = 3
minimum = 3
maximum = 9
}
rule {
metric_trigger {
metric_name = "Percentage CPU"
metric_resource_id = azurerm_linux_virtual_machine_scale_set.web.id
time_grain = "PT1M"
statistic = "Average"
time_window = "PT5M"
time_aggregation = "Average"
operator = "GreaterThan"
threshold = 70
}
scale_action {
direction = "Increase"
type = "ChangeCount"
value = "1"
cooldown = "PT5M"
}
}
rule {
metric_trigger {
metric_name = "Percentage CPU"
metric_resource_id = azurerm_linux_virtual_machine_scale_set.web.id
time_grain = "PT1M"
statistic = "Average"
time_window = "PT10M"
time_aggregation = "Average"
operator = "LessThan"
threshold = 30
}
scale_action {
direction = "Decrease"
type = "ChangeCount"
value = "1"
cooldown = "PT10M"
}
}
}
}
Wichtig: Terraform und Autoscale dürfen nicht um die Instanzanzahl streiten. Sonst meldet der Drift-Check jede Skalierung als Abweichung, und jeder Apply setzt die Anzahl zurück. Deshalb im Scale Set:
lifecycle {
ignore_changes = [instances]
}
Ab jetzt ist die Instanzanzahl Sache des Autoscalers. Terraform verwaltet nur noch die Grenzen (minimum/maximum).
| Strategie | Ablauf | Ausfall | Mehrkosten | Rollback | Umsetzung auf Azure |
|---|---|---|---|---|---|
| Recreate | Alles löschen, neu bauen | ja | keine | erneuter Neubau | upgrade_mode = „Manual“ + Reimage |
| Rolling | Instanzen batchweise austauschen | nein | gering | Rolling zurück, Minuten | upgrade_mode = „Rolling“ (Lab) |
| Blue/Green | Zweite Herde aufbauen, Verkehr umschalten | nein | doppelt, kurz | Umschalten, Sekunden | Zwei Scale Sets, Load-Balancer-Regel schalten |
| Canary | Kleinen Teil des Verkehrs auf neue Version leiten | nein | gering | Anteil auf null setzen | Gewichtete Verteilung per Application Gateway oder Front Door |
Rolling ist der pragmatische Standard. Blue/Green lohnt sich, wenn ein Rollback in Sekunden nötig ist oder die neue Version vor dem Umschalten unter echten Bedingungen getestet werden soll. Der Azure Load Balancer kann Verkehr nicht gewichtet verteilen; für Canary braucht es eine Schicht-7-Komponente.
Statt eines einzelnen Scale Sets beschreibt eine Map die aktiven „Farben“. Eine Variable legt fest, welche Farbe Produktivverkehr bekommt. Die jeweils andere Farbe ist über Port 8080 zum Testen erreichbar.
variable "deployments" {
description = "Aktive Herden: Farbe => Image-Version und Größe"
type = map(object({
image_version = string
instances = number
}))
}
variable "live" {
description = "Farbe, die den Produktivverkehr erhält"
type = string
}
data "azurerm_shared_image_version" "web" {
for_each = var.deployments
name = each.value.image_version
image_name = "img-web-ubuntu2404"
gallery_name = "gal_${var.prefix}"
resource_group_name = "rg-${var.prefix}-base"
}
resource "azurerm_lb_backend_address_pool" "web" {
for_each = var.deployments
name = "be-${each.key}"
loadbalancer_id = azurerm_lb.web.id
}
# Produktivverkehr: Port 80 -> Farbe "live"
resource "azurerm_lb_rule" "live" {
name = "http-live"
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[var.live].id]
probe_id = azurerm_lb_probe.http.id
disable_outbound_snat = true
lifecycle {
precondition {
condition = contains(keys(var.deployments), var.live)
error_message = "Die Farbe in 'live' muss in 'deployments' enthalten sein."
}
}
}
# Testzugang: Port 8080 -> die jeweils andere Farbe
resource "azurerm_lb_rule" "preview" {
for_each = { for k, v in var.deployments : k => v if k != var.live }
name = "http-preview-${each.key}"
loadbalancer_id = azurerm_lb.web.id
protocol = "Tcp"
frontend_port = 8080
backend_port = 80
frontend_ip_configuration_name = "fe"
backend_address_pool_ids = [azurerm_lb_backend_address_pool.web[each.key].id]
probe_id = azurerm_lb_probe.http.id
disable_outbound_snat = true
}
resource "azurerm_lb_outbound_rule" "egress" {
for_each = var.deployments
name = "egress-${each.key}"
loadbalancer_id = azurerm_lb.web.id
protocol = "All"
backend_address_pool_id = azurerm_lb_backend_address_pool.web[each.key].id
frontend_ip_configuration {
name = "fe"
}
}
resource "azurerm_linux_virtual_machine_scale_set" "web" {
for_each = var.deployments
name = "vmss-${var.prefix}-web-${each.key}"
instances = each.value.instances
source_image_id = data.azurerm_shared_image_version.web[each.key].id
# ... restliche Einstellungen wie im Lab, außerdem:
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[each.key].id]
}
}
}
Die Einzelressourcen azurerm_lb_rule.http, azurerm_lb_outbound_rule.egress, azurerm_lb_backend_address_pool.web und das Scale Set aus dem Lab werden durch diese Varianten ersetzt. Damit Terraform die vorhandenen Ressourcen beim Umbau übernimmt, statt sie zu löschen und neu zu bauen, kommen moved-Blöcke in den Code (siehe Terraform – Refactoring):
moved {
from = azurerm_linux_virtual_machine_scale_set.web
to = azurerm_linux_virtual_machine_scale_set.web["blue"]
}
moved {
from = azurerm_lb_backend_address_pool.web
to = azurerm_lb_backend_address_pool.web["blue"]
}
moved {
from = azurerm_lb_outbound_rule.egress
to = azurerm_lb_outbound_rule.egress["blue"]
}
moved {
from = azurerm_lb_rule.http
to = azurerm_lb_rule.live
}
Der Plan zeigt dann has moved to für diese Ressourcen. Die Load-Balancer-Regel bekommt zusätzlich einen neuen Namen (http-live) und wird deshalb ersetzt – das ist unkritisch, Scale Set und Instanzen bleiben unberührt. Das setzt deployments = { blue = … } und live = „blue“ als ersten Stand voraus.
Port 8080 muss in der NSG freigegeben werden – sinnvollerweise nur für die eigene IP-Adresse.
Ablauf eines Blue/Green-Rollouts – jeder Schritt ist ein Merge Request:
# 1. Ausgangslage: nur Blau
deployments = {
blue = { image_version = "1.1.0", instances = 3 }
}
live = "blue"
# 2. Grün dazu – erreichbar unter http://<ip>:8080, Produktion unverändert
deployments = {
blue = { image_version = "1.1.0", instances = 3 }
green = { image_version = "1.2.0", instances = 3 }
}
live = "blue"
# 3. Umschalten – Produktivverkehr geht auf Grün, Blau wird Testzugang
live = "green"
# 4. Beobachten. Rollback wäre: live = "blue"
# 5. Blau entfernen – Rückbau der alten Herde
deployments = {
green = { image_version = "1.2.0", instances = 3 }
}
live = "green"
Der nächste Rollout läuft dann umgekehrt: Blau kommt mit der neuen Version zurück.
Das Ende einzelner Instanzen ist Routine und passiert ohne menschlichen Eingriff: beim Scale-in, beim Rolling Upgrade, beim Automatic Repair. Da keine Daten auf der Instanz liegen, gibt es nichts zu sichern.
Alte Image-Versionen kosten Speicher und sind ein Sicherheitsrisiko, wenn sie versehentlich wieder verwendet werden. Aufbewahrt werden die aktuell verwendeten Versionen und eine kleine Anzahl der jüngsten als Rollback-Reserve:
#!/usr/bin/env bash set -euo pipefail RG="rg-iaclab-base"; GAL="gal_iaclab"; IMG="img-web-ubuntu2404" KEEP=3 # Rollback-Reserve: die jüngsten N Versionen bleiben immer erhalten # Alle im Code referenzierten Versionen (auch bei Blue/Green) IN_USE=$(grep -oE '[0-9]+\.[0-9]+\.[0-9]+' workload/terraform.tfvars | sort -u) az sig image-version list -g "$RG" -r "$GAL" -i "$IMG" --query "[].name" -o tsv \ | sort -V | head -n -"$KEEP" \ | while read -r v; do if grep -qx "$v" <<<"$IN_USE"; then echo "Behalte $v (in Verwendung)" continue fi echo "Lösche $v" az sig image-version delete -g "$RG" -r "$GAL" -i "$IMG" -e "$v" done
Das Skript eignet sich als monatlicher Pipeline-Job direkt nach dem Patch-Image-Build. Zum ersten Ausprobieren die delete-Zeile durch ein echo ersetzen.
Das Ende eines ganzen Services ist ein bewusster Normal Change. Checkliste:
service/owner)prevent_destroy an Datenressourcen bewusst per Merge Request entfernenterraform destroy über die Pipeline, nicht vom Laptop – der Rückbau ist genauso nachvollziehbar wie der Aufbau