Wie Envoy die Connect-Aufrufe des Browsers in gRPC umwandelt
Die Web-App von Transferify spricht mit ihren Backends über das Connect-Protokoll. Die Backends sprechen reines gRPC. Keine Seite muss das Protokoll der anderen verstehen, denn Envoy, das vor jedem Backend läuft, übersetzt zwischen ihnen.
Dieser Beitrag erklärt, wie Envoy entscheidet, dass eine Anfrage ein Connect-Aufruf ist, wie wir die Übersetzung nur für RPC-Routen einschalten und was aus jedem Header und jedem Byte auf dem Hinweg und auf dem Rückweg wird. Dazu kommt eine Tabelle, in der Envoy und die heutige Connect-Spezifikation nicht übereinstimmen. Jedes Detail unten stammt aus dem Quellcode von Envoy 1.40.2 und aus der Connect-Spezifikation.
Was die Brücke ist
envoy.filters.http.connect_grpc_bridge ist ein HTTP-Filter von Envoy. Der Browser spricht Connect, und die Backends sprechen gRPC; der Filter schreibt Anfragen und Antworten zwischen beiden um. Die Backends brauchen also keine Connect-Bibliothek, und der Browser braucht kein gRPC-Web.
Envoy behandelt eine Anfrage als Connect-Aufruf, wenn eine dieser Bedingungen zutrifft:
- Der Inhaltstyp ist
application/connect+<codec>. Das ist ein Streaming-Aufruf. - Die Anfrage hat einen
Connect-Protocol-Version-Header und einen Inhaltstypapplication/.... Das ist ein unärer Aufruf. - Der Query-String enthält
connect=v1. Das ist ein Connect-GET, den Envoy in einen POST umwandelt.
Jede andere Anfrage läuft unverändert durch den Filter.
Wie wir die Brücke einschalten
Der Filter ist für den ganzen Listener mit disabled: true konfiguriert und wird pro Route mit typed_per_filter_config eingeschaltet, nur auf RPC-Routen. Eine REST-Route läuft nie durch die Brücke, auch wenn eine Anfrage an sie Connect-Header oder ?connect=v1 trägt.
Die Filter laufen in dieser Reihenfolge: ext_authz, dann die Brücke, dann der Router. Die Autorisierung prüft also die ursprüngliche Connect-Anfrage, und die Brücke übersetzt nur Anfragen, die bereits autorisiert sind.
Es gibt eine Route pro RPC, die auf den exakten Pfad /<package>.<Service>/<Method> passt und aus unseren Protobuf-Verträgen erzeugt wird. Die Routen jedes Pakets gehen an den Backend-Dienst, der es implementiert.
Envoy stuft diese Erweiterung als Alpha ein: „functional but has not had substantial production burn time“ (funktioniert, ist aber im Produktivbetrieb noch wenig erprobt). Wir haben das akzeptiert und sie an einem einzigen Endpunkt in Produktion erprobt, bevor wir irgendein anderes RPC darauf umgestellt haben.
Warum Connect und nicht gRPC-Web
Beide Varianten brauchen einen Übersetzer in Envoy, connect_grpc_bridge oder grpc_web, und beide nutzen dieselben generierten protobuf-es-Clients. Nur eine Zeile in der Web-App unterscheidet sich: createConnectTransport oder createGrpcWebTransport. Der Unterschied liegt darin, was über die Leitung geht.
- Ein unärer Connect-Aufruf ist gewöhnliches HTTP. Der Body ist nur die Nachricht, und ein Fehler kommt mit einem echten HTTP-Status und einem JSON-Body zurück, den du in den Devtools des Browsers lesen kannst. Bei gRPC-Web wird jeder Aufruf in Frames verpackt, der Status ist immer 200, und das Ergebnis steht in
grpc-status-Headern oder in einem Trailer-Frame im Body. - Zugriffslogs und Alarme lesen den HTTP-Status. Die Brücke schreibt den Status des Fehlers, also wird ein fehlgeschlagenes RPC wie ein fehlgeschlagener REST-Aufruf geloggt und gezählt. Bei gRPC-Web würde jedes fehlgeschlagene RPC als 200 geloggt.
- CORS braucht weniger Einrichtung. Bei gRPC-Web müssen
Grpc-Status,Grpc-MessageundGrpc-Status-Details-Binfür die Seite freigegeben werden; Connect braucht nichts Neues. - Connect unterstützt auch GET (
?connect=v1), das sich cachen lässt, für Methoden ohne Nebenwirkungen. - connect-web nutzt standardmäßig das Connect-Protokoll, und seine Dokumentation empfiehlt gRPC-Web nur, wenn das Backend kein Connect sprechen kann.
Einen gRPC-Web-Fallback halten wir nicht bereit: Das wären ein zweiter Filter, drei CORS-Header, ein Zweig in der Web-App und ein Test, die alle niemand nutzt. Falls die Brücke je Probleme macht, kostet der Wechsel etwa einen Arbeitstag, mit denselben generierten Clients.
Der Preis dieser Wahl ist JSON. Die Brücke übersetzt keine JSON-Bodies: Sie benennt application/json in application/grpc+json um und reicht die Bytes unverändert weiter, und grpc-java liest nur binäres Protobuf. Die Web-App nutzt also den binären Codec, und nur Fehler-Bodies sind in den Devtools lesbar.
Die Anfrage, Feld für Feld
Eine unäre Connect-Anfrage wird zu einer gRPC-Anfrage an das Backend, über HTTP/2 ohne TLS (h2c):
| Connect, vom Browser | gRPC, zum Backend |
|---|---|
POST /pkg.Svc/Method | POST /pkg.Svc/Method |
Content-Type: application/proto | Content-Type: application/grpc+proto |
Connect-Protocol-Version: 1 | entfernt |
Connect-Timeout-Ms: 15000 | grpc-timeout: 15000m |
Content-Encoding: gzip | grpc-encoding: gzip |
Accept-Encoding: gzip | grpc-accept-encoding: gzip |
| keiner | TE: trailers |
Content-Length | entfernt |
| die Nachricht | Frame-Header + die Nachricht |
Authorization und andere Header | dieselben, als gRPC-Metadaten |
Die Brücke puffert den ganzen Body und setzt dann den 5 Byte langen gRPC-Frame-Header davor: ein Flag-Byte, dann die Länge der Nachricht als 4 Bytes, Big-Endian. Ist der Body komprimiert, zeigt das Flag-Byte das an. Ein Timeout, der für 8 Ziffern Millisekunden zu lang ist, wechselt zu Sekunden, Minuten oder Stunden (S, M, H).
Ein Connect-GET, GET /pkg.Svc/Method?connect=v1&encoding=proto&base64=1&compression=...&message=<base64url>, wird zu einem POST. Der Query-String wird verworfen, message wird in den Body samt Frame-Header dekodiert, encoding setzt den Inhaltstyp application/grpc+<encoding>, und compression setzt grpc-encoding (identity, wenn es fehlt).
Die Antwort, Feld für Feld
Eine erfolgreiche gRPC-Antwort wird zu einer Connect-Antwort:
| gRPC, vom Backend | Connect, zum Browser |
|---|---|
Status 200 | Status 200 |
content-type: application/grpc+proto | content-type: application/proto |
| Frame-Header + die Nachricht | die Nachricht |
grpc-encoding: gzip | Content-Encoding: gzip |
Trailer grpc-status: 0 | verworfen |
andere grpc-*-Header und -Trailer | verworfen |
Trailer foo: bar | Header trailer-foo: bar |
Die Brücke puffert die ganze Antwort, bevor sie den Frame-Header entfernt, und entfernt Content-Encoding wieder, wenn der Frame nicht komprimiert war.
Ein Fehler wird genauso umgewandelt. grpc-java sendet einen fehlgeschlagenen Aufruf nur als Trailer:
| gRPC-Trailer, vom Backend | Connect-Antwort, zum Browser |
|---|---|
grpc-status: 7 | Status 403, "code": "permission_denied" |
grpc-message: not allowed | "message": "not allowed" |
grpc-status-details-bin: <base64> | "details": [{"type": ..., "value": "<base64>"}] |
Der Inhaltstyp der Antwort ist application/json, und ihr Body lautet:
{
"code": "permission_denied",
"message": "not allowed",
"details": [{
"type": "type.googleapis.com/google.rpc.ErrorInfo",
"value": "<base64>"
}]
}
type ist die Typ-URL, genau so, wie der Server sie geschrieben hat. Envoy schreibt kein debug-Feld.
Es gibt zwei Grenzen. Eine unäre Anfrage, die größer als das Pufferlimit von Envoy ist, bekommt lokal eine 413, und eine Antwort, die größer ist, eine 500. Und eine Antwort, die gar kein gRPC ist, zum Beispiel eine 404 vom Backend für ein RPC, das es nicht bereitstellt, läuft unverändert durch; der connect-web-Client, nicht die Brücke, macht aus diesem HTTP-404 ein unimplemented.
Envoys Statustabelle ist älter als die von Connect
Bei vier Fehlercodes antwortet Envoy 1.40.2 mit einem anderen HTTP-Status, als die Connect-Spezifikation heute nennt:
| Code | Envoy 1.40.2 | Connect-Spezifikation heute |
|---|---|---|
canceled | 408 Request Timeout | 499 Client Closed Request |
deadline_exceeded | 408 Request Timeout | 504 Gateway Timeout |
failed_precondition | 412 Precondition Failed | 400 Bad Request |
unimplemented | 404 Not Found | 501 Not Implemented |
Envoy hat den Filter im Februar 2023 hinzugefügt (envoyproxy/envoy#24836) und die Tabelle von Connect so übernommen, wie sie damals war. Connect hat diese vier Zeilen am 11. März 2024 geändert (connectrpc.com#130). Zwei Tage später hat es die Regel gelockert, nach der der HTTP-Status zum Fehlercode passen muss: Aus „must match“ (muss passen) wurde „should match“ (soll passen) (connectrpc.com#149), weil Clients den Code aus dem JSON-Body lesen und den HTTP-Status nur nutzen, wenn es keinen Body gibt. Envoys Zuordnung hat sich seitdem nicht geändert, sie liegt also weiter innerhalb der Spezifikation und ist nur älter.
Clients sind nicht betroffen, weil sie den Code aus dem Body lesen. Alles, was nur den HTTP-Status liest, etwa ein Zugriffslog oder ein Alarm auf 5xx-Antworten, sieht eine 4xx, wo die heutige Spezifikation 5xx sagt: Eine abgelaufene Deadline sieht aus wie ein Fehler des Clients.
Ein Gateway kann das an zwei Stellen korrigieren:
- In Envoy selbst: Die Tabelle steckt in einer einzigen Funktion,
statusCodeToConnectUnaryStatus, also ist das Aktualisieren ihrer vier Zeilen eine kleine Änderung am Filter. - In der eigenen Konfiguration des Gateways: Ein Filter, der vor der Brücke aufgeführt ist, sieht die Antwort, nachdem die Brücke sie umgewandelt hat, und kann also
codeaus dem JSON-Fehler-Body lesen und den HTTP-Status nach der aktuellen Tabelle setzen. Er muss den Body lesen, weil der Status alleincancelednicht vondeadline_exceededunterscheiden kann: Envoy antwortet bei beiden mit 408.
Häufige Fragen
Brauchen die Backends eine Connect-Bibliothek?
Nein. Sie sprechen reines gRPC. Der Filter connect_grpc_bridge von Envoy schreibt jede Connect-Anfrage auf dem Hinweg in eine gRPC-Anfrage um und die gRPC-Antwort auf dem Rückweg in eine Connect-Antwort.
Warum Connect und nicht gRPC-Web?
Beide brauchen einen Übersetzer in Envoy, und beide nutzen dieselben generierten Clients. Der Unterschied liegt auf der Leitung. Ein unärer Connect-Aufruf ist gewöhnliches HTTP, also kommt ein fehlgeschlagener Aufruf mit einem echten HTTP-Status und einem JSON-Body zurück, den Logs, Alarme und die Devtools des Browsers lesen können. Bei gRPC-Web antwortet jeder Aufruf mit 200, und das Ergebnis steht im Body.
Kann die Brücke JSON-Bodies von Anfragen übersetzen?
Nein. Sie benennt application/json in application/grpc+json um und reicht die Bytes unverändert weiter, und ein gRPC-Server, der nur binäres Protobuf liest, etwa grpc-java, kann sie nicht lesen. Der Browser sendet also binäres Protobuf, und nur Fehler-Bodies sind JSON.
Funktioniert eine Connect-GET-Anfrage über die Brücke?
Ja. Ein GET, dessen Query-String connect=v1 enthält, wird in einen POST umgewandelt. Envoy dekodiert die Nachricht aus dem Query-String in den Body der Anfrage und verwirft den Query-String.