Transferify
Zurück zum Blog

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.

Der Weg eines Connect-Aufrufs. Der Browser sendet eine Connect-Anfrage an Envoy. In Envoy durchläuft sie ext_authz, das die ursprüngliche Anfrage prüft, dann die Connect-gRPC-Brücke, die sie in gRPC umwandelt, dann den Router, der sie an den Backend-Dienst sendet, der dieses RPC bereitstellt. Die Antwort geht zurück durch die Brücke, die sie in eine Connect-Antwort umwandelt.Der Weg eines Connect-Aufrufs. Der Browser sendet eine Connect-Anfrage an Envoy. In Envoy durchläuft sie ext_authz, das die ursprüngliche Anfrage prüft, dann die Connect-gRPC-Brücke, die sie in gRPC umwandelt, dann den Router, der sie an den Backend-Dienst sendet, der dieses RPC bereitstellt. Die Antwort geht zurück durch die Brücke, die sie in eine Connect-Antwort umwandelt.
Ein Connect-Aufruf. Die Autorisierung prüft die Anfrage so, wie der Browser sie gesendet hat; danach wandelt die Brücke sie für das Backend in gRPC um und die gRPC-Antwort des Backends zurück in Connect.

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 Inhaltstyp application/.... 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-Message und Grpc-Status-Details-Bin fü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 BrowsergRPC, zum Backend
POST /pkg.Svc/MethodPOST /pkg.Svc/Method
Content-Type: application/protoContent-Type: application/grpc+proto
Connect-Protocol-Version: 1entfernt
Connect-Timeout-Ms: 15000grpc-timeout: 15000m
Content-Encoding: gzipgrpc-encoding: gzip
Accept-Encoding: gzipgrpc-accept-encoding: gzip
keinerTE: trailers
Content-Lengthentfernt
die NachrichtFrame-Header + die Nachricht
Authorization und andere Headerdieselben, 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 BackendConnect, zum Browser
Status 200Status 200
content-type: application/grpc+protocontent-type: application/proto
Frame-Header + die Nachrichtdie Nachricht
grpc-encoding: gzipContent-Encoding: gzip
Trailer grpc-status: 0verworfen
andere grpc-*-Header und -Trailerverworfen
Trailer foo: barHeader 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 BackendConnect-Antwort, zum Browser
grpc-status: 7Status 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:

CodeEnvoy 1.40.2Connect-Spezifikation heute
canceled408 Request Timeout499 Client Closed Request
deadline_exceeded408 Request Timeout504 Gateway Timeout
failed_precondition412 Precondition Failed400 Bad Request
unimplemented404 Not Found501 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 code aus dem JSON-Fehler-Body lesen und den HTTP-Status nach der aktuellen Tabelle setzen. Er muss den Body lesen, weil der Status allein canceled nicht von deadline_exceeded unterscheiden 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.

Nach oben