Der Großteil der Zuverlässigkeit eines Service wird in seinem Repository entschieden, darin, wie sich jede Funktion verhält, wenn eine Abhängigkeit langsam ist, nicht im Cluster, auf dem er läuft. Ein SRE muss Anwendungscode deshalb gut genug lesen können, um ihn zu reviewen und zu ändern: den Netzwerkaufruf ohne Timeout finden, die Retry-Schleife, die während eines Ausfalls die Last verdreifacht, den Health Check, der alle Instances gleichzeitig aus dem Betrieb nimmt. Dieser Beitrag ist die Review-Liste samt Fixes, und er endet mit dem einen Stück Code, das dem SRE ganz allein gehört: Alerts, die auf die Burn Rate des Error Budgets pagen, geschrieben in Terraform.

Dies ist Teil 3 von Budget for Failure und der erste Teil auf 201-Level. Teil 1 hat die Rolle definiert und Teil 2 hat ihr Zahlen gegeben: ein Verfügbarkeits-SLO von 99,9 Prozent über 30 Tage, gemessen an einem Application Load Balancer. Dieses Beispiel läuft hier weiter. Die Codebeispiele sind Python; die Muster sind in jeder Sprache dieselben.

Wie viel Code ein SRE braucht

Du musst nicht der stärkste Feature-Entwickler im Team sein. Du brauchst drei Fähigkeiten. Den Request-Pfad lesen: vom Handler ausgehend einen Nutzer-Request durch jeden Netzwerkaufruf verfolgen, den er macht, und sagen, was passiert, wenn jeder dieser Aufrufe dreißig Sekunden dauert. Einen Pull Request auf Betriebsverhalten reviewen, was eine andere Lesart ist als ein Review auf Korrektheit. Und selbst produktionsreife Änderungen schreiben: den Timeout, die Metrik, den Rollback-Hook. Ein SRE, der ein Ticket anlegt und Entwickler bittet, einen Timeout zu ergänzen, hat das Problem beschrieben. Einer, der den Pull Request schickt, hat es behoben.

Jeder Netzwerkaufruf braucht eine Deadline

Der häufigste Reliability-Defekt im Anwendungscode ist ein Aufruf, der ewig warten kann. Pythons requests-Bibliothek hat überhaupt keinen Default-Timeout. Das AWS SDK für Python hat als Default 60 Sekunden für Connect und 60 Sekunden für Read, mit automatischen Retries obendrauf. Ein Handler, der DynamoDB mit diesen Defaults aufruft, hinter einem Load Balancer mit 60 Sekunden Idle Timeout, kann einen Worker noch minutenlang blockieren, nachdem der Nutzer längst einen 504 bekommen hat.

Wenn eine Abhängigkeit langsamer wird, machen Aufrufe ohne Deadline aus einer langsamen Abhängigkeit einen toten Service: Jeder Worker steht wartend herum, der Pool ist erschöpft, und auch Requests, die die langsame Abhängigkeit nie gebraucht hätten, schlagen fehl. Setze die Limits bewusst:

import boto3
from botocore.config import Config

sdk = Config(
    connect_timeout=1,
    read_timeout=2,
    retries={"mode": "standard", "max_attempts": 3},
)
ddb = boto3.client("dynamodb", config=sdk)

Wähle den Read Timeout aus der gemessenen Latenz der Abhängigkeit, etwas über ihrem 99,9. Perzentil, nicht aus einer runden Zahl. Prüfe dann, ob die Limits ineinander passen. Wenn das nutzerseitige Budget 3 Sekunden beträgt, kann der Handler nicht drei sequenzielle Aufrufe machen, die je 2 Sekunden mit 3 Versuchen dauern dürfen. Die äußere Deadline muss nach unten durchgereicht werden, damit innere Aufrufe aufgeben, wenn niemand mehr auf die Antwort wartet.

Retries: der Fix, der Ausfälle verursacht

Ein Retry macht aus einem vorübergehenden Fehler einen Erfolg und aus einem anhaltenden Fehler einen größeren. Drei Regeln halten ihn auf der richtigen Seite.

Auf einer Ebene wiederholen. Wenn der Client dreimal wiederholt, die API ihren Datenbankaufruf dreimal wiederholt und das SDK darunter noch dreimal, werden aus einer Nutzeraktion bis zu 27 Requests gegen eine Abhängigkeit, die ohnehin schon kämpft. Wähle die Ebene, die dem Fehler am nächsten ist, und lass die anderen schnell fehlschlagen.

Backoff mit Jitter. Clients, die nach festem Zeitplan wiederholen, kommen gleichzeitig an und werfen den sich erholenden Service erneut um. Die Analyse im AWS Architecture Blog dazu ist die Standardreferenz; ihre "Full Jitter"-Variante schläft eine zufällige Zeit zwischen null und einer exponentiell wachsenden Obergrenze:

import random, time

def call_with_retry(fn, attempts=3, base=0.1, cap=2.0, deadline=None):
    for attempt in range(attempts):
        try:
            return fn()
        except RetryableError:
            if attempt == attempts - 1:
                raise
            pause = random.uniform(0, min(cap, base * 2 ** attempt))
            if deadline and time.monotonic() + pause > deadline:
                raise
            time.sleep(pause)

Nur wiederholen, was sich gefahrlos wiederholen lässt. Ein Read mit Timeout ist sicher. Ein "Karte belasten" mit Timeout ist es nicht, es sei denn, die Operation ist idempotent: Der Client schickt einen eindeutigen Schlüssel mit dem Request, und der Server speichert das Ergebnis darunter, sodass eine Wiederholung das erste Ergebnis zurückgibt, statt zweimal abzubuchen. Im Review ist jeder Retry um einen Write herum der Anlass zu fragen, wo der Idempotency Key ist.

Kleiner scheitern: Breaker, Shedding, Degradation

Timeouts und Retries behandeln eine Abhängigkeit, der es kurz schlecht geht. Für eine, die down ist, braucht der Code einen Weg, mit dem Versuchen aufzuhören, und eine Entscheidung, was der Nutzer in der Zwischenzeit sieht.

  • Circuit Breaker. Nach einer Serie von Fehlern die Abhängigkeit für ein kurzes Intervall nicht mehr aufrufen und sofort fehlschlagen. Das schützt deinen Worker-Pool und gibt der Abhängigkeit Raum, sich zu erholen.
  • Load Shedding. Wenn der Service gesättigt ist, den Überschuss früh mit einem 429 oder 503 abweisen, statt alles anzunehmen und alles langsam zu bedienen. Eine billige, schnelle Abweisung von 20 Prozent der Requests schlägt einen Timeout für 100 Prozent.
  • Graceful Degradation. Pro Abhängigkeit entscheiden, ob sie hart oder weich ist. Ist der Empfehlungsservice down, soll die Produktseite ohne Empfehlungen rendern, nicht einen 500 zurückgeben. Jede Abhängigkeit, die von hart zu weich wird, fällt aus der Verfügbarkeitsmultiplikation heraus, die Teil 4 vollständig durchrechnet.

Health Checks, die die Wahrheit sagen

Ein Health Check ist Code, und er ist häufig in eine von zwei Richtungen falsch. Ein flacher Check, der 200 zurückgibt, solange der Prozess lebt, hält eine kaputte Instance in der Rotation. Ein tiefer Check, der von jeder Instance aus die Datenbank testet, hat einen schlimmeren Fehlermodus: Wenn die Datenbank kurz hakt, fällt jede Instance im selben Moment durch ihren Check, und der Load Balancer hat kein gesundes Target mehr. Ein Application Load Balancer in diesem Zustand fällt offen und routet trotzdem an alle Targets, was dich rettet, aber ein Orchestrator, der ungesunde Instances ersetzen soll, beginnt während eines Datenbank-Incidents, eine gesunde Flotte zu killen.

Die übliche Antwort sind zwei Endpoints. Liveness sagt, der Prozess läuft und soll nicht neu gestartet werden. Readiness sagt, diese Instance kann nützliche Arbeit leisten, und prüft nur, was lokal zu ihr gehört: Konfiguration geladen, Connection Pool erstellt, Caches warm. Geteilte Abhängigkeiten werden über den SLI überwacht, nicht über Health Checks pro Instance.

Den SLI aus dem Code emittieren

Der Load-Balancer-SLI aus Teil 2 sieht Statuscodes. Er kann einen erfolgreichen Checkout nicht von einem 200 unterscheiden, das eine Fehlermeldung im Body trägt, und er kann nicht nach Kundenstufe oder Operation aufteilen. Die Anwendung kann das. Schreib für jeden Request eine strukturierte Logzeile mit der Operation, dem Ergebnis, wie das Business es definiert, der Dauer und der Trace-ID. Auf AWS macht das CloudWatch Embedded Metric Format aus derselben Logzeile Metriken, ohne zusätzlichen API-Aufruf:

{
  "_aws": {
    "Timestamp": 1787130000000,
    "CloudWatchMetrics": [{
      "Namespace": "Checkout",
      "Dimensions": [["Operation"]],
      "Metrics": [
        { "Name": "Requests", "Unit": "Count" },
        { "Name": "Failures", "Unit": "Count" },
        { "Name": "Duration", "Unit": "Milliseconds" }
      ]
    }]
  },
  "Operation": "PlaceOrder",
  "Requests": 1,
  "Failures": 0,
  "Duration": 184,
  "trace_id": "1-66c2f1d0-3f1b2c4d5e6f7a8b9c0d1e2f",
  "customer_tier": "enterprise"
}

Halte Felder mit hoher Kardinalität wie Trace-ID und Kunde als Log-Properties, nicht als Metrik-Dimensionen; jeder eindeutige Dimensionswert ist eine separat abgerechnete Metrik. Das ist die Instrumentierung, die in das Service-Template aus Teil 1 gehört, damit Entwickler sie bekommen, ohne fragen zu müssen.

Change zum sicheren Pfad machen

Das Ledger aus Teil 2 zeigte ein einzelnes Deploy, das 31 Prozent eines Monatsbudgets verbrauchte. Die codeseitigen Verteidigungen sind die Arbeit mit der höchsten Rendite auf dieser Liste.

  • Progressiver Rollout mit automatischem Rollback. Schick der neuen Version 10 Prozent des Traffics, vergleiche ihre Fehlerquote und Latenz mit der alten Version und rolle ohne Menschen zurück, wenn sie schlechter ist. CodeDeploy, ECS-Deployments und Lambda-Aliase können alle bei einem CloudWatch-Alarm stoppen und zurückrollen. Ein schlechtes Release kostet dann Minuten von 10 Prozent des Traffics.
  • Deploy von Release trennen. Code dunkel hinter einem Feature Flag ausliefern und schrittweise einschalten. Ein Flag auszuschalten ist schneller als jedes Rollback.
  • Rückwärtskompatible Schema-Änderungen. Expand, migrate, contract: die neue Spalte anlegen, in beide schreiben, die Leser umziehen, dann die alte in einem späteren Release entfernen. Eine Migration, die verlangt, dass alter und neuer Code nie nebeneinander laufen, macht jeden Rollout zum Ausfall und jedes Rollback unmöglich.

Alerts as Code: auf Burn Rate pagen, nicht auf CPU

Das letzte Stück Code, das einem SRE gehört, ist das Alerting selbst. Der Alarmsatz auf einem typischen AWS-Account paged auf Ursachen: CPU über 80 Prozent, Memory über 90, irgendeine 5xx-Anzahl über 10. Er ist laut, wo nichts falsch ist (eine Auto Scaling Group bei 95 Prozent CPU, die ihr Latenzziel einhält), und still, wo etwas falsch ist (ein Deploy, das 2 Prozent der Checkouts fehlschlagen lässt, während jede Host-Metrik flach bleibt). Das SLO aus Teil 2 liefert eine bessere Bedingung zum Pagen: Verbrauchen wir das Error Budget so schnell, dass wir, sich selbst überlassen, das Ziel verfehlen? Diese Geschwindigkeit ist die Burn Rate.

burn rate = observed error ratio / (1 - SLO)

SLO 99.9%  ->  budget ratio 0.001
error ratio 0.1%   ->  burn rate 1      ->  budget lasts 30 days
error ratio 0.6%   ->  burn rate 6      ->  budget lasts 5 days
error ratio 1.44%  ->  burn rate 14.4   ->  budget lasts 50 hours
error ratio 100%   ->  burn rate 1000   ->  budget lasts 43 minutes

Ein Alert braucht einen Schwellenwert und ein Fenster, und beide zusammen entscheiden, wie viel Budget weg ist, wenn er auslöst:

budget consumed at alert = burn rate x alert window / SLO window

14.4 x 1 hour  / 720 hours =  2%
6    x 6 hours / 720 hours =  5%
1    x 3 days  / 30 days   = 10%

Diese drei Zeilen sind die Empfehlung aus dem Alerting-Kapitel des Google SRE Workbook, und sie sind ein vernünftiger Default für ein 30-Tage-SLO: pagen, wenn 2 Prozent des Budgets in einer Stunde oder 5 Prozent in sechs Stunden weggingen, und ein Ticket öffnen, wenn 10 Prozent in drei Tagen weggingen. Schnelle, schwere Probleme bekommen einen schnellen Page. Langsame Lecks, die Sorte, die ein Schwellenwert-Alarm nie fängt, bekommen ein Ticket für den nächsten Arbeitstag.

Warum zwei Fenster

Ein einzelnes langes Fenster hat ein Reset-Problem. Wenn die Fehler zehn Minuten lang bei 15 Prozent lagen und dann aufhörten, bleibt der Ein-Stunden-Durchschnitt für den Großteil der folgenden Stunde über der 14,4x-Schwelle. Der Alarm feuert weiter auf einen Engineer, der auf ein gesundes System schaut.

Der Fix ist, ein zweites, kurzes Fenster zu verlangen, das gleichzeitig über derselben Schwelle liegt. Die Richtlinie im Workbook ist ein kurzes Fenster von einem Zwölftel des langen. Das lange Fenster sagt, der Verbrauch ist signifikant; das kurze sagt, er passiert noch. Zusammen:

AktionLanges FensterKurzes FensterBurn RateFehlerquote bei 99,9 %Verbrauchtes Budget
Page1 Stunde5 Minuten14,41,44 %2 %
Page6 Stunden30 Minuten60,6 %5 %
Ticket3 Tage6 Stunden10,1 %10 %

Die Erkennung ist schnell, wo sie es sein muss. Bei einem Totalausfall überschreitet der Ein-Stunden-Durchschnitt 1,44 Prozent nach etwa 52 Sekunden, und das Fünf-Minuten-Fenster ist längst drüber. Endet der Ausfall, fällt das Fünf-Minuten-Fenster innerhalb von Minuten unter die Schwelle und der Page löst sich auf, obwohl die Stunde noch verschmutzt ist.

Der Aufbau in CloudWatch mit Terraform

CloudWatch hat die zwei Bausteine, die das braucht: Metric-Math-Alarme, um auf eine Fehlerquote statt auf eine rohe Anzahl zu alarmieren, und Composite Alarms, um langes und kurzes Fenster per AND zu verknüpfen. Ein Metrik-Alarm pro Fenster, mit einer einzigen Evaluationsperiode so lang wie das Fenster, und ohne angehängte Actions:

locals {
  slo    = 0.999
  budget = 1 - local.slo
  lb     = "app/checkout/0123456789abcdef"

  windows = {
    fast_long  = { period = 3600,  burn = 14.4 }
    fast_short = { period = 300,   burn = 14.4 }
    slow_long  = { period = 21600, burn = 6 }
    slow_short = { period = 1800,  burn = 6 }
  }

  elb_metrics = {
    req  = "RequestCount"
    t5xx = "HTTPCode_Target_5XX_Count"
    e5xx = "HTTPCode_ELB_5XX_Count"
  }
}

resource "aws_cloudwatch_metric_alarm" "burn" {
  for_each = local.windows

  alarm_name          = "checkout-burn-${each.key}"
  alarm_description   = "Error ratio above ${each.value.burn}x burn over ${each.value.period}s"
  comparison_operator = "GreaterThanThreshold"
  threshold           = each.value.burn * local.budget
  evaluation_periods  = 1
  treat_missing_data  = "notBreaching"

  metric_query {
    id          = "ratio"
    expression  = "(FILL(t5xx, 0) + FILL(e5xx, 0)) / req"
    label       = "error ratio"
    return_data = true
  }

  dynamic "metric_query" {
    for_each = local.elb_metrics
    content {
      id = metric_query.key
      metric {
        namespace   = "AWS/ApplicationELB"
        metric_name = metric_query.value
        stat        = "Sum"
        period      = each.value.period
        dimensions  = { LoadBalancer = local.lb }
      }
    }
  }
}

Der Schwellenwert ist die Burn Rate multipliziert mit der Budget-Ratio: 14,4 x 0,001 = 0,0144 für das schnelle Paar, 0,006 für das langsame. Ändere slo auf 0.9995, und jeder Schwellenwert zieht nach. Dann der Composite Alarm, der als Einziger paged:

resource "aws_cloudwatch_composite_alarm" "slo_page" {
  alarm_name        = "checkout-availability-slo-page"
  alarm_description = "Burning 30-day error budget at 14.4x (1h) or 6x (6h). Runbook: https://wiki.example.com/runbooks/checkout-slo"

  alarm_rule = join(" OR ", [
    "(ALARM(${aws_cloudwatch_metric_alarm.burn["fast_long"].alarm_name}) AND ALARM(${aws_cloudwatch_metric_alarm.burn["fast_short"].alarm_name}))",
    "(ALARM(${aws_cloudwatch_metric_alarm.burn["slow_long"].alarm_name}) AND ALARM(${aws_cloudwatch_metric_alarm.burn["slow_short"].alarm_name}))",
  ])

  alarm_actions = [aws_sns_topic.pager.arn]
  ok_actions    = [aws_sns_topic.pager.arn]
}

Das OR innerhalb eines Composite Alarms löst auch die Unterdrückung. Ein harter Ausfall erfüllt sowohl das schnelle als auch das langsame Paar; weil beide in einen Alarm münden, bekommt der Engineer einen Page, nicht zwei.

Die Drei-Tage-Ticket-Stufe ist auf dem managed Pfad einfacher. Lebt das SLO in CloudWatch Application Signals, hängst du Burn-Rate-Look-back-Fenster daran (bis zu 10.080 Minuten, also sieben Tage) und alarmierst auf der Burn-Rate-Metrik, die es veröffentlicht. Die Schwellenwertformel von AWS lautet X% x SLO interval length / look-back window, und sie trägt eine Falle: 14,4 ist nur für ein 30-Tage-Intervall korrekt. Zwei Prozent eines 28-Tage-Budgets in einer Stunde sind 13,44. Leite Schwellenwerte aus den Prozentsätzen ab; kopiere nie die Multiplikatoren.

Das Production Readiness Review

Zusammengenommen ist das das Review, das ein SRE mit Entwicklern durchführt, bevor ein Service echten Traffic bekommt, und das er wiederholt, wenn sich die Architektur ändert:

  • Hat jeder ausgehende Aufruf einen Timeout, und passen die Timeouts in die Deadline des Aufrufers?
  • Gibt es genau eine Retry-Ebene, mit Backoff und Jitter, und sind wiederholte Writes idempotent?
  • Für jede Abhängigkeit: hart oder weich, und was sieht der Nutzer, wenn sie down ist?
  • Vermeiden Health Checks geteilte Abhängigkeiten?
  • Emittiert der Service Request-, Failure- und Duration-Metriken pro Operation und propagiert eine Trace-ID?
  • Gibt es ein SLO, einen Owner dafür und einen Burn-Rate-Alert, der eine benannte Rotation mit Runbook-Link paged?
  • Lässt sich ein Release in unter fünf Minuten ohne Schema-Änderung zurückrollen?
  • Welches Limit wird bei zehnfachem Traffic zuerst erreicht, und was passiert dann?

Failure Modes und worauf zu achten ist

Wenig Traffic bricht die Burn-Rate-Rechnung. Bei 100 Requests pro Stunde sind 14,4x Burn auf einem 99,9-Prozent-SLO 1,44 fehlgeschlagene Requests, zwei unglückliche Timeouts pagen also jemanden. Die Optionen im SRE Workbook sind synthetischer Traffic, das Zusammenfassen kleiner Services zu einem SLO, ein niedrigeres Ziel oder ein längeres Fenster. In Metric Math kannst du mit IF(req > 200, ratio, 0) auf Volumen gaten, um den Preis der Blindheit unterhalb des Gates.

Fehlende Daten sind nicht per Default gute Nachrichten. treat_missing_data = "notBreaching" ist richtig für eine Quote, die ohne Traffic verschwindet, und falsch, wenn der Traffic verschwunden ist, weil DNS kaputt ist. Kombiniere den SLO-Alarm mit einem schlichten Alarm auf RequestCount, der auf null fällt, in Stunden, in denen er das nie sollte.

Buckets sind keine gleitenden Fenster. Ein CloudWatch-Datenpunkt mit einer Periode von einer Stunde ist ein Bucket, der Alarm auf dem langen Fenster kann also später reagieren als eine ideale gleitende Berechnung. Das kurze Fenster hält die Erkennung schwerer Incidents schnell; betrachte das Timing bei Slow Burns als Näherung.

Resilience-Code, der nie ausgeführt wird. Ein Circuit Breaker, der in Produktion noch nie geöffnet hat, ist eine Vermutung. Fault Injection, erst in einer Testumgebung und dann in Produktion innerhalb des Error Budgets, ist der Weg, auf dem sich die Fallback-Pfade Vertrauen verdienen.

Alles oben setzt voraus, dass die Plattform hält. Timeouts und Retries sind gegen einen Provider geschrieben, der größtenteils funktioniert. Was dieser Provider tatsächlich verspricht, wo seine Failure Domains liegen und was sich ändert, wenn der Provider dein eigenes Rechenzentrum ist, ist Teil 4.

Lies das als Nächstes

Für Consulting zu AWS, Cloud-Architektur, SRE-Praxis, CI/CD-Pipelines, Linux und mehr, starte auf ercanermis.com.

Referenzen