Zum Inhalt springen
Observability

Fehlende Zeitreihen in PromQL: warum die Erfolgsquote im Störfall verschwindet

Ein Dienst, der nur noch Fehler liefert, sieht in Grafana aus wie ein Dienst ohne Traffic. Die Linie hört auf, der Alert schweigt, und am nächsten Morgen erfährst du vom Ausfall durch einen Kundenanruf. Der Grund ist keine kaputte Datenquelle, sondern die Art, wie PromQL zwei Zeitreihen zusammenführt. Dieser Artikel zeigt den Fehler an einem Dashboard über Spring-Boot-Metriken, dann die Korrektur, und zum Schluss beides als Terraform, damit es nicht beim nächsten Klick wieder verloren geht.

Inhalt

Die Nacht, in der niemand etwas gemerkt hat

Der Aufbau ist gewöhnlich: ein paar Spring-Boot-Dienste in einem Kubernetes-Namespace shop-prod, jeder mit spring-boot-starter-actuator und der Micrometer-Anbindung an Prometheus. Drei davon interessieren hier, catalog, checkout und payment. Für den Betrieb gibt es ein Grafana-Dashboard mit einem Panel, das die Erfolgsquote pro Dienst zeigt, und einen Alert, der bei weniger als 99 Prozent anschlägt.

Um 02:14 verliert payment die Verbindung zu seiner Datenbank. Der Dienst läuft weiter, die Pods sind gesund, die Readiness-Probe ist zufrieden. Nur beantwortet er jede Anfrage mit einem HTTP 500.

Am Morgen steht im Dashboard eine ruhige Nacht. Die Linien von catalog und checkout verlaufen flach oben bei 100 Prozent. Die Linie von payment endet um 02:14 und kommt nicht wieder. Der Alert hat nie gefeuert. Entdeckt wurde der Ausfall um 08:40 durch einen Kunden, der anrief, weil er nicht bezahlen konnte.

Das Monitoring hat nicht geschwiegen, weil nichts los war. Es hat geschwiegen, weil alles kaputt war. Beide Zustände sehen gleich aus, solange die Query naiv gebaut ist.

Der erste Versuch: die Query, die jeder zuerst schreibt

So sah das Panel aus. Erfolgreiche Anfragen geteilt durch alle Anfragen, gruppiert nach Dienst:

sum(rate(http_server_requests_seconds_count{namespace="shop-prod", status!~"5.."}[5m])) by (job)
/
sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job)

Das liest sich richtig, und im Normalbetrieb ist es das auch. http_server_requests_seconds_count ist der Zähler hinter dem Micrometer-Timer http.server.requests, den Spring Boot für jede bediente Anfrage hochzählt. rate() macht daraus Anfragen pro Sekunde, sum(...) by (job) fasst über alle Pods eines Dienstes zusammen. Der Filter status!~"5.." lässt alles durch, was kein Serverfehler ist.

Die Gruppierung nach job ist dabei kein Zufall. Prometheus vergibt beim Einsammeln je Pod ein eigenes instance-Label. Ohne sum ... by (job) bekäme das Panel eine Linie pro Pod, und bei drei Replikaten wäre die Aussage über den Dienst schon verwässert, bevor der eigentliche Fehler überhaupt zuschlägt.

Der liegt woanders, und er zeigt sich erst, wenn payment ausschließlich 500er liefert.

Warum die Linie abbricht: wie PromQL zwei Serien zusammenführt

In PromQL existiert eine Zeitreihe nur, solange sie Datenpunkte hat. Sie fällt nicht auf null, sie hört auf. Nach etwa fünf Minuten ohne neuen Datenpunkt gilt sie als veraltet und taucht in keinem Ergebnis mehr auf.

Ab 02:14 kommt für payment keine einzige Anfrage mehr mit einem Status an, der nicht mit 5 beginnt. Damit gibt es für den Zähler oberhalb des Bruchstrichs keine Serie mehr. Unterhalb schon, denn dort zählen die 500er ja weiter mit.

Bei einer Division verrechnet Prometheus nicht einfach Zahlen. Es sucht zu jeder Serie links eine Serie rechts mit identischem Labelsatz. Nur wo beide Seiten zusammenpassen, entsteht ein Ergebnis. Fehlt eine Seite, entsteht kein Ergebnis, und zwar kein Ergebnis mit dem Wert null, sondern gar keines.

Vor 02:14                          Nach 02:14
Zähler:  payment -> 42.0           Zähler:  (keine Serie)
Nenner:  payment -> 42.0           Nenner:  payment -> 41.8
Treffer: payment -> 1.0            Treffer: (keine Serie)

Für den Graphen heißt das: keine Serie, keine Linie. Grafana kann nicht wissen, dass hinter der Lücke ein Totalausfall steckt. Es bekommt dieselbe leere Antwort wie bei einem Dienst, der nachts einfach keinen Verkehr hat.

Hier liegt die teure Verwechslung: Ausfall und Feierabend sehen gleich aus.

Der Reflex, der es schlimmer macht: 1 minus Fehlerquote

Der erste Einfall, wenn man das begriffen hat, ist meistens dieser:

1 - (
  sum(rate(http_server_requests_seconds_count{namespace="shop-prod", status=~"5.."}[5m])) by (job)
  /
  sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job)
)

Statt die Erfolge zu zählen, zählt diese Fassung die Fehler und dreht das Ergebnis um. Im Ausfall funktioniert sie tatsächlich, denn die 500er-Serie existiert dann ja.

Der Fehler ist nur umgezogen. Solange alles gesund läuft, gibt es keine einzige Anfrage mit einem 5xx-Status, also existiert die Serie im Zähler nicht, also entsteht kein Ergebnis. Jetzt ist die Linie im Normalbetrieb weg und erscheint erst, wenn der erste Fehler auftritt.

Von beiden ist das die schlechtere Variante. Ein Panel, das nur bei Problemen etwas anzeigt, wirkt im Alltag defekt, und ein defektes Panel schaut nach zwei Wochen niemand mehr an.

Beide Fassungen scheitern am selben Punkt: Eine Seite der Division kann verschwinden, und dann verschwindet das Ergebnis mit.

Die Nullserie: die fehlenden Labels aus dem Nenner holen

Die Lösung besteht darin, dem Zähler eine Ersatzserie zur Seite zu stellen, die einspringt, wenn die echte fehlt. Diese Ersatzserie muss zwei Bedingungen erfüllen: Ihr Wert muss null sein, und sie muss genau die Labels tragen, nach denen gruppiert wird.

Beides liefert der Nenner, wenn man ihn mit null multipliziert:

(
  sum(rate(http_server_requests_seconds_count{namespace="shop-prod", status!~"5.."}[5m])) by (job)
  or
  sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job) * 0
)
/
sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job)

Der or-Operator arbeitet pro Labelsatz und nicht pro Ausdruck. Für jeden Dienst, den die linke Seite liefert, nimmt er die linke Seite. Für jeden Dienst, den nur die rechte Seite kennt, nimmt er die rechte. Solange payment erfolgreiche Anfragen hat, gewinnt der echte Wert. Fällt der Dienst aus, bleibt die Null übrig.

Entscheidend ist die Herkunft der Ersatzserie. Sie stammt aus derselben Metrik mit derselben Gruppierung, also trägt sie zwangsläufig dieselben Labels. Damit findet die Division wieder ihren Partner, und das Ergebnis ist eine Linie, die auf null fällt statt zu verschwinden.

Nebenbei erledigt sich damit auch ein Fall, an den man selten denkt: ein Dienst, der neu deployt wird und dessen erste Anfragen alle fehlschlagen. Ohne Nullserie erscheint er nie im Panel, mit ihr steht er sofort bei null.

Warum vector(0) hier nicht reicht

In vielen Antworten steht als Lösung or vector(0), und für ein einzelnes Stat-Panel stimmt das auch. Für dieses Panel nicht.

sum(rate(http_server_requests_seconds_count{namespace="shop-prod", status!~"5.."}[5m])) by (job)
or vector(0)

vector(0) erzeugt eine Serie ohne jedes Label. Sie kann also nicht für payment einspringen, weil sie nicht weiß, dass es payment gibt. Sie steht einfach zusätzlich im Ergebnis, als namenlose Zeile, und in der Division findet sie keinen Partner mit passendem Labelsatz. Das Panel bekommt dadurch eine Serie mehr und trotzdem keine Linie für den ausgefallenen Dienst.

vector(0) beantwortet die Frage, ob es überhaupt ein Ergebnis gibt. Die Nullserie aus dem Nenner beantwortet die Frage, welche Dienste es gibt und wie es jedem einzelnen geht.

Wer aus anderen Gründen bei vector(0) bleiben will, muss ihm die Labels von Hand geben:

label_replace(vector(0), "job", "payment", "", "")

Das funktioniert, verlangt aber, dass jeder Dienstname fest in der Query steht. Beim nächsten neuen Dienst fehlt er, und niemand merkt es. Deshalb ist der Weg über den Nenner der robustere: Er kennt die Dienste, weil er sie gerade selbst gezählt hat.

Derselbe Fehler im Alert, nur teurer

Im Dashboard kostet die fehlende Serie eine Lücke im Graphen. In einer Alertregel kostet sie den Alarm.

sum(rate(http_server_requests_seconds_count{namespace="shop-prod", status!~"5.."}[5m])) by (job)
/
sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job)
< 0.99

Diese Regel kann bei einem Totalausfall nicht auslösen. Der Vergleich < 0.99 filtert Serien: Was übrig bleibt, wird zum Alarm. Gibt es keine Serie, bleibt nichts übrig, und Prometheus wertet ein leeres Ergebnis als Entwarnung. Je vollständiger der Ausfall, desto sicherer schweigt die Regel.

Daran ist dieser Fehler besonders unangenehm. Er versteckt sich nicht im Randfall, sondern schlägt zu, wenn es darauf ankommt. Ein Dienst mit sporadischen Fehlern löst brav aus. Ein Dienst, der komplett weg ist, nicht.

Mit der Nullserie im Zähler bleibt die Serie erhalten, fällt auf null und liegt damit unter der Schwelle. Die Regel feuert.

Die Grenze: wenn auch der Nenner verschwindet

Die Nullserie rettet den Fall, dass ein Dienst antwortet und dabei Fehler liefert. Sie rettet nicht den Fall, dass er gar nicht mehr antwortet.

Werden alle Pods von payment beendet, gibt es niemanden mehr, der irgendetwas zählen könnte. Prometheus findet kein Ziel, die Metrik verschwindet vollständig. Dann fehlt auch der Nenner, und aus ihm lässt sich keine Nullserie mehr ableiten. Das Panel steht wieder vor derselben Leere, nur eine Ebene tiefer.

Dafür braucht es eine zweite Regel, die nicht nach einem Wert fragt, sondern nach dem Vorhandensein:

absent(
  sum(rate(http_server_requests_seconds_count{namespace="shop-prod", job="payment"}[5m]))
)

absent() liefert genau dann ein Ergebnis, wenn der innere Ausdruck keines liefert. Der Preis dafür ist, dass der Dienstname hier fest in der Query stehen muss, denn eine Abwesenheit lässt sich nicht nach Labels gruppieren, die es gerade nicht gibt.

Praktisch heißt das: Für die Dienste, deren Ausfall wirklich weh tut, gibt es eine namentliche Regel. Für alle anderen trägt die Erfolgsquote mit Nullserie. Wer beides verwechselt, hat entweder eine Regel, die im Ernstfall schweigt, oder eine Liste von Dienstnamen, die niemand pflegt.

Ein dritter Weg führt über die Metrik up, die Prometheus für jedes Ziel selbst schreibt. Sie sagt, ob das Einsammeln funktioniert hat, und greift damit früher als jede fachliche Zahl. Dafür sagt sie nichts darüber aus, ob der Dienst sinnvolle Antworten gibt. Alle drei zusammen ergeben erst ein vollständiges Bild.

Nachbarfehler 1: Erfolg ist nicht dasselbe wie SUCCESS

Micrometer liefert neben status ein zweites Label, das die Sache scheinbar vereinfacht. outcome fasst den Statuscode zu einer Kategorie zusammen, und der Griff danach liegt nahe:

sum(rate(http_server_requests_seconds_count{namespace="shop-prod", outcome="SUCCESS"}[5m])) by (job)
/
sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job)

Das liest sich sauberer als die Regex und misst etwas anderes, als die meisten erwarten. outcome kennt fünf Werte, und SUCCESS steht ausschließlich für 2xx. Alles andere fällt aus dem Zähler: INFORMATIONAL für 1xx, REDIRECTION für 3xx, CLIENT_ERROR für 4xx und SERVER_ERROR für 5xx.

Damit zählt jeder Redirect als Nicht-Erfolg. Ein Endpunkt, der nach einem POST auf die Ergebnisseite umleitet, drückt die Quote, obwohl er tut, was er soll. Dasselbe gilt für 304 Not Modified, das bei einem Client mit funktionierendem Cache völlig normal ist. Ein Dashboard, das nach einem Deployment plötzlich 88 Prozent zeigt, ohne dass ein einziger Fehler passiert wäre, hat oft diese Ursache.

Richtig ist der Filter auf das, was wirklich ausgeschlossen werden soll:

sum(rate(http_server_requests_seconds_count{namespace="shop-prod", outcome!="SERVER_ERROR"}[5m])) by (job)

Diese Fassung ist zugleich robuster als status!~"5..", weil sie nicht davon abhängt, dass in status immer eine dreistellige Zahl steht. Bei abgebrochenen Verbindungen taucht dort je nach Umgebung auch schon mal ein nicht-numerischer Wert auf, und eine Regex auf 5.. trifft ihn nicht.

Bleibt die Frage, wie 4xx zu behandeln ist. Sie als Erfolg zu zählen ist vertretbar, denn eine ungültige Anfrage ist kein Ausfall des Dienstes. Bewusst entschieden sein sollte es trotzdem, denn eine kaputte Authentifizierung produziert 401er in Serie und bleibt in dieser Zählung unsichtbar. Wer das getrennt sehen will, baut ein zweites Panel auf outcome="CLIENT_ERROR", statt die Erfolgsquote zu überladen.

Nachbarfehler 2: das Panel wird beim Reinzoomen leer

Der dritte Fund im selben Dashboard betrifft die Bereichsangabe in eckigen Klammern. In mehreren Panels stand:

rate(http_server_requests_seconds_count{namespace="shop-prod"}[$__interval])

$__interval ist der Abstand zwischen zwei Punkten, den Grafana aus Zeitfenster und Panelbreite errechnet. Über sieben Tage sind das mehrere Minuten, alles sieht gut aus. Zoomt jemand auf eine Stunde hinein, schrumpft der Wert auf wenige Sekunden. rate() braucht aber mindestens zwei Datenpunkte im Fenster, sonst kann es keine Steigung bilden. Wird das Fenster kleiner als der Abstand zwischen zwei Messungen, liefert die Query nichts.

Das Tückische daran ist der Zeitpunkt. Der Fehler fällt beim Bauen des Dashboards nie auf, weil man dort meist über Tage schaut. Er fällt in dem Moment auf, in dem jemand während eines Vorfalls auf die letzten Minuten zoomt, und da soll das Panel etwas anzeigen.

Richtig ist:

rate(http_server_requests_seconds_count{namespace="shop-prod"}[$__rate_interval])

$__rate_interval ist definiert als max($__interval + Scrape-Intervall, 4 * Scrape-Intervall) und damit nie zu klein. Der Wert für das Scrape-Intervall kommt aus dem Feld Min step der Query, ersatzweise aus der Einstellung Scrape interval der Datenquelle, deren Vorgabe 15 Sekunden beträgt.

Hier liegt eine Stolperfalle, die den Wechsel wirkungslos macht: Wenn die Datenquelle 15 Sekunden annimmt, die Metriken aber tatsächlich im Minutentakt eingesammelt werden, rechnet das Makro mit einer zu feinen Auflösung, und das Fenster bleibt zu klein. Der Wechsel auf $__rate_interval hilft dann nicht. Also beim Umstellen zuerst nachsehen, was in der Datenquelle konfiguriert ist und in welchem Takt tatsächlich geschrieben wird.

Die vollständige Query

Alle drei Korrekturen zusammen, das ist die Fassung, die im Panel steht:

(
  sum(rate(http_server_requests_seconds_count{namespace="shop-prod", outcome!="SERVER_ERROR"}[$__rate_interval])) by (job)
  or
  sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[$__rate_interval])) by (job) * 0
)
/
sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[$__rate_interval])) by (job)

Drei Dinge sind daran anders als am ersten Versuch: der Filter über outcome statt über eine Regex auf status, die Nullserie hinter dem or, und $__rate_interval statt $__interval. Die Query ist damit länger und liest sich sperriger. Dafür zeigt sie im Ausfall eine Null und nicht nichts.

Das Ganze als Code: erst die naive Terraform-Fassung

Ein Dashboard, das von Hand in der Oberfläche entsteht, ist die zweite Hälfte des Problems. Die Korrektur von oben lebt so lange, bis jemand das Panel dupliziert, den Namespace austauscht und dabei die Nullserie nicht mitnimmt.

Also nach Terraform damit. Der naheliegende erste Wurf sieht so aus, und er hat einen Fehler:

terraform {
  required_providers {
    grafana = {
      source = "grafana/grafana"
    }
    google = {
      source = "hashicorp/google"
    }
  }
}

locals {
  erfolgsquote_query = <<-EOT
    (
      sum(rate(http_server_requests_seconds_count{namespace="shop-prod", outcome!="SERVER_ERROR"}[$__rate_interval])) by (job)
      or
      sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[$__rate_interval])) by (job) * 0
    )
    /
    sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[$__rate_interval])) by (job)
  EOT
}

Eine Query, an einer Stelle definiert, für Dashboard und Alert gemeinsam nutzbar. Das ist der richtige Gedanke und trotzdem falsch umgesetzt.

$__rate_interval ist ein Makro von Grafana. Grafana ersetzt es beim Ausführen der Query durch einen konkreten Wert. Cloud Monitoring kennt es nicht. Wandert dieselbe Zeichenkette in eine Alertregel, steht dort ein Bereich, den der Parser nicht auflösen kann, und die Regel ist kaputt. Im besten Fall scheitert schon das terraform apply, im schlechteren steht eine Regel in der Cloud, die nie auswertet.

Dazu kommt der fest verdrahtete Namespace, dreimal in derselben Zeichenkette. Beim Kopieren für die Staging-Umgebung wird eines der drei Vorkommen vergessen, und die Query mischt zwei Umgebungen.

Eine Query, zwei Ziele: die Korrektur in Terraform

Beide Probleme lösen sich über eine Vorlage mit Platzhaltern, die zweimal unterschiedlich gefüllt wird:

locals {
  namespace = "shop-prod"

  # %[1]s ist der Namespace, %[2]s der Bereich für rate().
  # Der Bereich ist die einzige Stelle, an der sich Dashboard und Alert
  # unterscheiden dürfen, weil $__rate_interval nur in Grafana existiert.
  erfolgsquote_vorlage = <<-EOT
    (
      sum(rate(http_server_requests_seconds_count{namespace="%[1]s", outcome!="SERVER_ERROR"}[%[2]s])) by (job)
      or
      sum(rate(http_server_requests_seconds_count{namespace="%[1]s"}[%[2]s])) by (job) * 0
    )
    /
    sum(rate(http_server_requests_seconds_count{namespace="%[1]s"}[%[2]s])) by (job)
  EOT

  erfolgsquote_dashboard = format(local.erfolgsquote_vorlage, local.namespace, "$__rate_interval")
  erfolgsquote_alert     = format(local.erfolgsquote_vorlage, local.namespace, "5m")
}

format() mit den Indizes %[1]s und %[2]s erlaubt, denselben Wert mehrfach einzusetzen, ohne ihn mehrfach zu übergeben. Der Namespace steht damit an genau einer Stelle.

Zwei Details, die beim ersten Anlauf gern beißen. Erstens: In HCL leitet ${ eine Interpolation ein. Die geschweiften Klammern der Label-Selektoren sind unproblematisch, weil ihnen kein Dollarzeichen vorausgeht, und $__rate_interval ist ebenfalls harmlos, weil auf das Dollarzeichen keine geschweifte Klammer folgt. Zweitens: Sobald format() im Spiel ist, ist jedes Prozentzeichen in der Vorlage ein Formatzeichen. In dieser Query kommt keines vor, in einer Query mit Prozentangaben im Legendentext schon.

Das Dashboard als Ressource

resource "grafana_folder" "shop" {
  title = "Shop Produktion"
  uid   = "shop-prod"
}

resource "grafana_dashboard" "erfolgsquote" {
  folder    = grafana_folder.shop.uid
  overwrite = true

  config_json = jsonencode({
    uid      = "shop-erfolgsquote"
    title    = "Shop, Erfolgsquote pro Dienst"
    timezone = "browser"
    time     = { from = "now-24h", to = "now" }

    panels = [
      {
        type    = "timeseries"
        title   = "Erfolgsquote pro Dienst"
        gridPos = { h = 10, w = 24, x = 0, y = 0 }

        fieldConfig = {
          defaults = {
            unit = "percentunit"
            min  = 0
            max  = 1
            custom = {
              spanNulls   = false
              lineWidth   = 2
              fillOpacity = 5
            }
            thresholds = {
              mode = "absolute"
              steps = [
                { color = "red", value = null },
                { color = "green", value = 0.99 },
              ]
            }
          }
          overrides = []
        }

        targets = [
          {
            refId        = "A"
            expr         = local.erfolgsquote_dashboard
            legendFormat = "{{job}}"
            interval     = "1m"
          },
        ]
      },
    ]
  })
}

Zwei Einstellungen darin sind wichtiger, als sie aussehen.

spanNulls = false sorgt dafür, dass Grafana echte Lücken nicht überbrückt. Steht es auf true, zieht die Anzeige eine gerade Linie über einen Zeitraum ohne Daten hinweg, und der Ausfall verschwindet optisch ein zweites Mal, diesmal in der Darstellung statt in der Query. Ein Panel, das den Ernstfall zeigen soll, darf nicht interpolieren.

interval = "1m" ist das Feld Min step aus der Oberfläche. Es setzt die Untergrenze für die Auflösung und ist damit der Wert, mit dem $__rate_interval rechnet. Wenn die Metriken im Minutentakt eingesammelt werden, gehört er hierher, sonst bleibt die Umstellung auf $__rate_interval wirkungslos.

Die Alertregel in Cloud Monitoring

Die Regel bekommt dieselbe Query, nur mit festem Bereich:

resource "google_monitoring_alert_policy" "erfolgsquote" {
  display_name = "Erfolgsquote unter 99 Prozent (shop-prod)"
  combiner     = "OR"

  conditions {
    display_name = "Erfolgsquote unter Schwelle"

    condition_prometheus_query_language {
      query               = "(${trimspace(local.erfolgsquote_alert)}) < 0.99"
      duration            = "300s"
      evaluation_interval = "60s"
      alert_rule          = "ShopErfolgsquoteNiedrig"
      rule_group          = "shop-prod"
    }
  }

  alert_strategy {
    auto_close = "1800s"
  }
}

resource "google_monitoring_alert_policy" "payment_ohne_daten" {
  display_name = "payment liefert überhaupt keine Daten mehr"
  combiner     = "OR"

  conditions {
    display_name = "Keine Zeitreihe vorhanden"

    condition_prometheus_query_language {
      query               = "absent(sum(rate(http_server_requests_seconds_count{namespace="${local.namespace}", job="payment"}[5m])))"
      duration            = "600s"
      evaluation_interval = "60s"
      alert_rule          = "ShopPaymentOhneDaten"
      rule_group          = "shop-prod"
    }
  }

  alert_strategy {
    auto_close = "1800s"
  }
}

Anmerkungen zu den Feldern, die beim ersten Mal Zeit kosten:

evaluation_interval muss ein positives Vielfaches von 30 Sekunden sein. Andere Werte lehnt die API ab, und die Fehlermeldung aus dem terraform apply benennt das nicht besonders deutlich.

duration ist die Zeit, die die Bedingung am Stück zutreffen muss, bevor der Alarm von wartend auf feuernd wechselt. Ohne Angabe gilt null, dann löst schon ein einzelner Ausschlag aus. Fünf Minuten sind für eine Erfolgsquote ein brauchbarer Startwert, weil ein rate() über fünf Minuten ohnehin träge reagiert.

Die Klammern um die eingesetzte Query sind kein Zierrat. Die Vorlage endet mit einer Division, und der Vergleich soll auf das Gesamtergebnis wirken, nicht auf den Nenner allein. Rechnerisch bindet die Division ohnehin stärker, aber eine Query, deren Korrektheit von der Kenntnis der Operatorrangfolge abhängt, ist eine Query, die beim nächsten Umbau kippt. trimspace() entfernt dabei den Zeilenumbruch, den das Heredoc am Ende hinterlässt.

Die zweite Regel deckt den Fall aus dem Abschnitt zur Grenze ab. Ihre duration liegt bewusst höher, weil ein Deployment kurzzeitig dazu führen kann, dass keine Daten ankommen, und dafür soll niemand nachts geweckt werden.

Den Störfall auslösen, bevor er von selbst kommt

Der eigentliche Fehler in dieser Geschichte war nicht die Query. Der eigentliche Fehler war, dass niemand den Zustand je hergestellt hat, für den das Dashboard gebaut wurde.

Bei einem Spring-Boot-Dienst reicht dafür ein Filter, der hinter einer Property sitzt und im Normalbetrieb gar nicht erst als Bean entsteht:

package com.beispiel.shop.stoerfall;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
@ConditionalOnProperty(name = "stoerfall.aktiv", havingValue = "true")
class StoerfallFilter implements Filter {

    @Override
    public void doFilter(ServletRequest anfrage, ServletResponse antwort, FilterChain kette)
            throws IOException, ServletException {

        String pfad = ((HttpServletRequest) anfrage).getRequestURI();

        // Der Actuator muss erreichbar bleiben, sonst verschwindet die Metrik
        // vollständig und der Test prüft den falschen Fall.
        if (pfad.startsWith("/actuator")) {
            kette.doFilter(anfrage, antwort);
            return;
        }

        HttpServletResponse http = (HttpServletResponse) antwort;
        http.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        http.getWriter().write("Störfall aktiv");
    }
}

Die Ausnahme für /actuator ist der Teil, den man beim ersten Versuch vergisst, und sie entscheidet darüber, ob der Test überhaupt etwas aussagt. Würgt der Filter auch den Metrik-Endpunkt ab, kann Prometheus nichts mehr einsammeln. Dann verschwindet die Metrik komplett, und man beobachtet den Fall aus dem Abschnitt zur Grenze statt den Fall, um den es hier geht. Beide sehen im Panel gleich aus, haben aber unterschiedliche Ursachen und unterschiedliche Gegenmittel.

Eingeschaltet wird der Störfall über die Property, ohne neues Image:

kubectl set env deployment/payment STOERFALL_AKTIV=true -n shop-prod

Dann fünf Minuten warten und ins Dashboard schauen. Mit der alten Query bricht die Linie ab. Mit der neuen fällt sie auf null, und der Alert geht nach den konfigurierten fünf Minuten von wartend auf feuernd. Zurückgedreht wird mit demselben Befehl und STOERFALL_AKTIV=false.

Wer ganz ohne zusätzlichen Code auskommen will, nimmt dem Dienst stattdessen die Datenbank weg. Das kommt dem nächtlichen Vorfall am nächsten, weil dabei die Fehler entstehen, die der Dienst im Ernstfall wirklich produziert. Dafür lässt es sich schlechter dosieren, und in einer geteilten Umgebung merken es andere.

Die Regel als Test festhalten

Damit die Korrektur einen Umbau überlebt, gehört sie in einen Test. promtool bringt dafür alles mit und braucht kein laufendes Prometheus.

Zuerst die Regel in einer eigenen Datei, shop-alerts.yml:

groups:
  - name: shop-prod
    rules:
      - alert: ShopErfolgsquoteNiedrig
        expr: |
          (
            sum(rate(http_server_requests_seconds_count{namespace="shop-prod", outcome!="SERVER_ERROR"}[5m])) by (job)
            or
            sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job) * 0
          )
          /
          sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job)
          < 0.99
        for: 5m

Dazu der Test, shop-alerts-test.yml:

rule_files:
  - shop-alerts.yml

evaluation_interval: 1m

tests:
  - interval: 1m
    input_series:
      # Zehn Minuten gesund, danach kommt für die Erfolge kein Datenpunkt mehr.
      - series: 'http_server_requests_seconds_count{namespace="shop-prod", job="payment", outcome="SUCCESS", status="200"}'
        values: '0+60x10 _ _ _ _ _ _ _ _ _ _'
      # Ab Minute 10 zählen nur noch die Serverfehler.
      - series: 'http_server_requests_seconds_count{namespace="shop-prod", job="payment", outcome="SERVER_ERROR", status="500"}'
        values: '0x10 0+60x10'

    promql_expr_test:
      - expr: |
          (
            sum(rate(http_server_requests_seconds_count{namespace="shop-prod", outcome!="SERVER_ERROR"}[5m])) by (job)
            or
            sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job) * 0
          )
          /
          sum(rate(http_server_requests_seconds_count{namespace="shop-prod"}[5m])) by (job)
        eval_time: 20m
        exp_samples:
          - labels: '{job="payment"}'
            value: 0

    alert_rule_test:
      - eval_time: 20m
        alertname: ShopErfolgsquoteNiedrig
        exp_alerts:
          - exp_labels:
              job: payment

Die Werte-Syntax ist kompakt und beim ersten Lesen ungewohnt. 0+60x10 erzeugt elf Punkte, die bei null beginnen und je 60 zunehmen, also einen Zähler bei 60 Anfragen pro Minute. Der Unterstrich steht für einen fehlenden Datenpunkt, und darum geht es hier: Ab Minute 10 liefert die Erfolgsserie nichts mehr, während die Fehlerserie zu laufen beginnt.

Ausgeführt wird das mit:

promtool test rules shop-alerts-test.yml

Der promql_expr_test ist der wichtigere der beiden Blöcke. Er prüft, dass zum Zeitpunkt 20 Minuten überhaupt noch ein Sample mit dem Label job="payment" existiert, und dass sein Wert null ist. Das ist die Eigenschaft, die die naive Query nicht hat. Nimmt man die Nullserie wieder heraus, schlägt der Test mit einer leeren Ergebnismenge fehl, und zwar bevor jemand um 02:14 darauf angewiesen ist.

Der Aufruf passt in jede Pipeline und braucht weder Cluster noch Datenbank. Das ist der billigste Teil dieses ganzen Artikels und der, der am längsten hält.

Die Muster auf einen Blick

Fall Falsch Richtig
Erfolgsquote, Dienst liefert nur Fehler erfolg / gesamt (erfolg or gesamt * 0) / gesamt
Erfolgsquote, umgedreht gedacht 1 - (fehler / gesamt) dieselbe Nullserie, sonst fehlt die Linie im gesunden Fall
Ersatzwert ohne Gruppierung or vector(0) or <nenner> * 0, weil nur der die Labels trägt
Ersatzwert mit festen Namen label_replace(vector(0), ...) tragfähig, aber jeder neue Dienst muss von Hand nach
Dienst komplett verschwunden Erfolgsquote allein zusätzlich absent(...) als eigene Regel
Erfolg definieren outcome="SUCCESS" outcome!="SERVER_ERROR", sonst zählen 3xx als Fehler
Mehrere Pods pro Dienst Selektor ohne Gruppierung sum(...) by (job), sonst eine Linie je Instanz
Bereich für rate() [$__interval] [$__rate_interval], plus Min step passend zum Takt
Lücken im Graphen spanNulls = true spanNulls = false, sonst wird die Lücke überzeichnet
Query an zwei Stellen einmal für Grafana, einmal getippt für den Alert eine Vorlage, zwei Belegungen über format()

Wann eine fehlende Serie kein Problem ist

Nicht jede Lücke muss geschlossen werden, und wer jede Query mit or ... * 0 versieht, handelt sich neue Probleme ein.

Bei einer Metrik mit vielen möglichen Labelwerten erzeugt die Nullserie für jeden davon eine Linie, auch für die, die seit Wochen nichts tun. Wer die Erfolgsquote nach uri statt nach job aufschlüsselt, hat das Problem sofort: Aus einem übersichtlichen Panel werden dreißig Linien bei null, eine für jeden Endpunkt, den seit dem letzten Release niemand aufgerufen hat. Hier ist die Lücke die ehrlichere Darstellung.

Auch bei Zählern, die absichtlich selten laufen, etwa einem nächtlichen Abgleich, ist das Fehlen der Serie die richtige Aussage. Eine Null würde behaupten, es sei gemessen worden, dass nichts passiert. Tatsächlich wurde nur nichts gemessen. Das ist ein Unterschied, und in einem Panel, das jemand um drei Uhr morgens liest, ist es ein wichtiger.

Die Frage, an der sich das entscheiden lässt, lautet: Ist die Abwesenheit von Daten hier ein normaler Zustand oder ein Befund? Ist sie normal, bleibt die Lücke. Ist sie ein Befund, muss sie sichtbar werden, entweder als Null oder über absent().

FAQ

Warum fällt eine Prometheus-Serie nicht einfach auf null?
Weil Prometheus nicht weiß, dass sie fallen sollte. Ein Zähler existiert nur, solange etwas ihn schreibt. Kommen keine Datenpunkte mehr, gilt die Serie nach etwa fünf Minuten als veraltet und verschwindet aus jedem Ergebnis. Eine Null wäre eine Aussage über die Realität, und die kann Prometheus nicht treffen, wenn niemand mehr misst.

Was genau macht or in PromQL?
Er arbeitet pro Labelsatz, nicht pro Ausdruck. Für jede Kombination von Labels, die die linke Seite liefert, gilt die linke Seite. Alle Labelsätze, die nur rechts vorkommen, werden ergänzt. Deshalb funktioniert or als Ersatzwert nur, wenn die rechte Seite dieselben Labels trägt wie die linke.

Reicht nicht or vector(0)?
Für ein Stat-Panel, das eine einzelne Zahl zeigt, ja. Für ein Panel mit Gruppierung nach Dienst nicht, weil vector(0) keine Labels hat und deshalb für keinen bestimmten Dienst einspringen kann. Es erscheint als zusätzliche namenlose Serie und ändert an der fehlenden Linie nichts.

Wie merke ich, dass eine Alertregel dieses Problem hat?
Der Prüfsatz lautet: Angenommen, der überwachte Zustand tritt vollständig ein. Existiert die Serie dann noch? Bei jeder Regel, deren Ausdruck eine Division oder einen Filter über einen Labelwert enthält, lohnt sich die Frage. Belastbar beantworten lässt sie sich mit einem promtool-Test, in dem die Serie mit Unterstrichen endet.

Soll ich nach status oder nach outcome filtern?
Nach outcome, aber mit Ausschluss statt Einschluss. outcome!="SERVER_ERROR" meint genau das, was eine Erfolgsquote meinen soll. outcome="SUCCESS" erfasst nur 2xx und wertet damit jeden Redirect als Misserfolg.

Warum bringt $__rate_interval bei manchen Datenquellen nichts?
Weil das Makro mit dem Scrape-Intervall rechnet, das in der Datenquelle oder im Feld Min step hinterlegt ist. Steht dort 15 Sekunden, während die Metriken im Minutentakt eingesammelt werden, bleibt das errechnete Fenster zu klein. Der Wert muss zum tatsächlichen Takt passen, sonst ändert der Wechsel nichts.

Muss ich jetzt jedes Panel umbauen?
Nein. Betroffen sind Ausdrücke, in denen eine Seite einer Division oder eines Vergleichs verschwinden kann, typischerweise durch einen Filter auf einen Labelwert wie den Statuscode. Ein einfaches sum(rate(...)) ohne solchen Filter hat das Problem nicht.

Fazit

Ein Dashboard ist erst dann geprüft, wenn jemand den Störfall hergestellt und nachgesehen hat, ob es ihn anzeigt. Vorher weiß man nur, dass es im Normalbetrieb gut aussieht, und das ist die eine Lage, in der niemand hinschaut.

Die drei Korrekturen in diesem Artikel kosten zusammen eine halbe Stunde: die Nullserie aus dem Nenner, der Filter über outcome statt über eine Regex, und $__rate_interval mit passendem Min step. Die vierte Maßnahme ist die, die den Rest zusammenhält, und sie kostet am wenigsten: Störfall einschalten, fünf Minuten warten, hinschauen.

Der konkrete nächste Schritt: Nimm die Alertregel, die dir am wichtigsten ist, und schreib einen promtool-Test, in dem die überwachte Serie mitten im Testlauf aufhört. Wenn die Regel dann nicht feuert, hast du sie gefunden.

Quellen

Alle Queries, Terraform-Beispiele und Code-Ausschnitte in diesem Artikel sind eigene.

$ lang DE EN ES