Notiz

HTTP/2 im internen Traffic: eine bekannte Klasse von Fallstricken

Eine Verbindung für alle Requests, ein geteiltes Flow-Control-Fenster und ein Pool, der Totes nie aussortiert: warum HTTP/2-Services „ohne Fehler“ hängen — und was Go und gRPC dagegen taten.

HTTP/2 ist im Browser-Web längst der Standard und wanderte von dort schrittweise in die Kommunikation zwischen Services — meist unbemerkt, huckepack mit einem Library- oder Plattform-Upgrade. Gestern sprach Ihr Client mit dem Nachbarservice noch HTTP/1.1, heute, nach einem Versions-Bump, schon HTTP/2. Am Code hat sich nichts geändert, alles läuft… bis zur ersten Leerlaufphase, zum ersten Lastschub oder zum ersten verlorenen Paket.

Dieser Beitrag handelt von der Sammlung an Fallstricken, die HTTP/2 in den internen Traffic mitbringt. Die Sammlung ist altbekannt: Die gRPC-Community hat sie vor rund zehn Jahren durchlitten und sich mit Schutzmechanismen gepanzert, die Go-Community etwas später und mit lauten Issues. Wenn Ihr Stack HTTP/2 aber „gratis“ bekommen hat, fehlen diese Schutzmechanismen höchstwahrscheinlich.

Was HTTP/2 ändert — und warum jedes Feature nach hinten losgeht

Gegenüber HTTP/1.1 gibt es drei grundlegende Änderungen, jede mit ihrer Kehrseite.

Eine Verbindung statt eines Pools. Der Client öffnet einen einzigen TCP-Socket und multiplext alle Requests parallel hinein. Das spart Handshakes und Ports — aber nun teilen alle Requests ein Schicksal: Was auch immer diesem Socket zustößt, reißt sämtliche Requests des Services auf einmal mit. Bei HTTP/1.1 kostete eine abgestandene Pool-Verbindung einen fehlgeschlagenen Request; bei HTTP/2 kann sie den ganzen Service kosten.

Stream-Multiplexing. Requests laufen verschränkt, jeder mit eigener Stream-ID. Praktisch — aber der Sitzungszustand (Stream-Zähler, Limits, Queues) wird zur geteilten Ressource, die lecken oder ausgehen kann. Ein Buchhaltungsfehler in einem Request trifft alle folgenden.

Flow Control auf Session-Ebene. Neben dem Fenster pro Stream gibt es ein gemeinsames Fenster für die ganze Verbindung: Der Empfänger bestätigt konsumierte Bytes (WINDOW_UPDATE), der Sender darf das Fenster nicht überschreiten. Hört der Empfänger auf zu lesen, läuft das Fenster auf null und jeder Stream der Verbindung friert ein. Das ist so gewollt: Die RFC warnt offen, dass nachlässiger Umgang mit Flow Control in Deadlocks endet.

Aus diesen drei Eigenschaften ergeben sich vier typische Szenarien, die in Produktion identisch aussehen: „Der Service antwortet nicht mehr, nur ein Neustart half.“

Fallstrick 1: die halb offene Verbindung

Zwischen zwei Services steht in Produktion fast immer jemand: ein Load Balancer, ein Proxy, conntrack, NAT. Viele davon haben Idle-Timeouts, und sie beenden Verbindungen unterschiedlich — manche mit ehrlichen RSTs in beide Richtungen, manche werfen den Eintrag einfach stumm aus ihrer Tabelle. Dazu der banale Verlust eines FIN-Pakets beim Schließen. Das Ergebnis ist dasselbe: Der Client hält einen Socket im Zustand ESTABLISHED, während am anderen Ende niemand mehr ist. Pures TCP bemerkt das erst beim Schreiben, nach zig Minuten Retransmissions. Mit HTTP/1.1 hätte so eine Verbindung einen Request gekostet; mit HTTP/2 multiplexen alle in den toten Socket. Eine Möglichkeit, diese Schicht aus der Anwendung zu ziehen, ist ein Sidecarless-Mesh.

Fallstrick 2: der Flow-Control-Deadlock

Ein Server unter Last (GC-Pause, schwere Operation, erschöpfter Thread-Pool) hört auf, Request-Bodies zu lesen. Das gemeinsame Session-Fenster steht auf null, alle Sender stehen still. Kommt der Server nie zum Lesen zurück, ist die Verbindung für immer verkeilt — und sieht auf TCP-Ebene dabei völlig gesund aus. Das diagnostische Zeichen auf der Serverseite: Sockets in CLOSE_WAIT mit ungelesenen Bytes in Recv-Q — die Clients haben aufgegeben und sind gegangen, der Server hat nie gelesen und nie geschlossen.

Fallstrick 3: der Pool, der Totes nie aussortiert

Der heimtückischste Fallstrick sitzt im Client. Man würde erwarten: Requests laufen in Timeouts, also Verbindung verwerfen und neu öffnen. Viele Implementierungen brauchen zum Verwerfen aber einen Socket-Fehler — und der kommt nicht: Der Socket lebt (Fallstrick 1) oder lebt formal (Fallstrick 2). Einzelne Requests scheitern, die Session bleibt „gültig“, der Pool gibt sie weiter aus. Das Lehrbuchbeispiel ist golang/go#39750: Eine tote HTTP/2-Verbindung bleibt für immer im Pool gecacht, weil neue Requests sie beim Einsteigen als „busy“ markieren — und der Aufräumer für Idle-Verbindungen sie deshalb ebenfalls nicht anfasst.

Fallstrick 4: Gedränge in einem einzigen Rohr

Auch ganz ohne Störung: N Worker, die parallel große Bodies durch eine Session schreiben, teilen sich ein Flow-Control-Fenster und einen TCP-Puffer. Ein Lastschub erzeugt selbst die Bedingungen für den Deadlock aus Fallstrick 2 — und läuft dann hinein. Das Profil „Batch-Writer mit zehn Threads“ auf HTTP/2 ohne Fenster-Tuning ist eine Einladung zum Incident.

Wie die Erwachsenen diese Fallstricke entschärft haben

Alle reifen HTTP/2-Ökosysteme sind bei derselben Sammlung an Schutzmechanismen gelandet:

SchutzWas er bringtWer ihn zum Default machte
PING-Health-ChecksEine halb offene Verbindung fällt in Sekunden auf, nicht in einer halben StundegRPC (Keepalive), Go (ReadIdleTimeout)
Altersgrenze für VerbindungenDie Verbindung wird rotiert, bevor sie abstehen kanngRPC (MAX_CONNECTION_AGE, mit Jitter gegen Reconnect-Stürme)
Timeout für hängende Streams (Server)Ein Stream ohne Fortschritt wird zurückgesetzt und gibt sein Fenster an die Session zurückJetty (streamIdleTimeout), gRPC-Server
Vernünftige FenstergrößenParallele große Bodies erschöpfen das Session-Fenster nichtTuning je Workload; Defaults sind oft klein

Aufschlussreich ist, warum diese Schutzmechanismen entstanden: Die gRPC-Dokumentation nennt die Gründe direkt — Proxys, die Idle-Verbindungen abschießen, und verlorene FINs, nach denen TCP bis zu einer halben Stunde schweigt. Genau die Fallstricke 1 und 3 aus der Liste oben, gelernt auf fremder Produktion vor vielen Jahren.

Unser Fall: alle Fallstricke in einem Incident

Eine frische Illustration aus eigener Praxis. Nach einem Upgrade von Solr 9 auf 10 wechselte die Client-Bibliothek SolrJ auf HTTP/2 (Cleartext h2c). Der Indexierungs-Service begann, sich alle paar Tage komplett aufzuhängen — jeder Request an Solr im Timeout, heilbar nur per Neustart. Sein Nachbar, der denselben Content mit demselben Client schreibt, lief ohne einen einzigen Fehler.

Die Auflösung deckte sich Punkt für Punkt mit der Liste: Der Nachbar schickt Requests einzeln (ein einzelner POST kann das Session-Fenster nicht erschöpfen), während der Scheduler des Indexers nach jeder Pause seinen Backlog in zehn parallele Worker durch eine Session kippt (Fallstrick 4) — exakt dann, wenn Solr mit Commits beschäftigt ist und aufhört zu lesen (Fallstrick 2). Auf dem Server: eine Sammlung von CLOSE_WAIT-Sockets mit ungelesenen Kilobytes; auf dem Client fand sich auch der halb offene Socket (Fallstrick 1). Und der SolrJ-Client, jung und ohne PING-Ausstattung, hat die verkeilte Session kein einziges Mal aus dem Pool geworfen (Fallstrick 3). Die Anfangstheorie — „das Netz war's, conntrack killt Idle-Verbindungen“ — überlebte die Fakten nicht. Das Netz war unschuldig, was für diese Problemklasse typisch ist: Verdächtigt wird zuerst immer das Netz.

Die Behandlung stammte ebenfalls aus dem Standardbaukasten: serverseitiges streamIdleTimeout, ein größeres Session-Fenster, ein höherer Idle-Timeout auf dem Server — und strategisch der Umzug des Batch-Writers auf HTTP/1.1: Für sein Lastprofil schlagen isolierte Pool-Verbindungen das geteilte Rohr.

Checkliste vor HTTP/2 zwischen Services

  • Schickt Ihr Client PINGs über eine untätige Verbindung? Falls nein — wie erfährt er von einem halb offenen Socket?
  • Kann der Pool eine Session nach einer Serie von Timeouts aussortieren, nicht nur nach einem Socket-Fehler?
  • Ist das Verbindungsalter begrenzt? Was passiert bei einem Deploy oder Scale-out des Servers — bemerken die Clients die neuen Endpoints?
  • Setzt der Server Streams ohne Fortschritt zurück, oder lebt ein hängender Stream ewig und hält das Fenster?
  • Wie viele parallele große Bodies drückt Ihr Lastprofil in eine Session? Reicht das Fenster?
  • Und eine ehrliche Frage zum Schluss: Brauchen Sie hier überhaupt HTTP/2? Für „ein Dutzend RPS zwischen zwei Pods“ ist ein Pool aus HTTP/1.1-Verbindungen langweiliger — und spürbar überlebensfähiger.

Fazit

HTTP/2 ist ein gutes Protokoll mit einem ehrlichen Trade-off: Verbindungsökonomie im Tausch gegen das geteilte Schicksal der Requests. Es rechnet mit reifer Ausstattung drumherum — Pings, Rotation, Stream-Timeouts. Im Browser-Web und bei gRPC ist sie ab Werk dabei. Kommt HTTP/2 dagegen „gratis“ in Ihren internen Traffic, liefert niemand die Ausstattung mit — und Sie bekommen eine Fehlerklasse mit einem unverwechselbaren Symptom: „Alles hängt, keine Fehler, ein Neustart half.“ Jetzt wissen Sie, wie sie aussieht und wo Sie hinschauen: ss -tan auf beiden Seiten plus Recv-Q/Send-Q verraten mehr als jedes Log. Und wann ein solcher Incident als geschlossen gelten darf, behandelt eine eigene Notiz zu Severity und Definition of Recovered.

© 2026 axyi.ru · CC BY 4.0