Transferify
Retour au blog

Comment Envoy transforme les appels Connect du navigateur en gRPC

L’application web Transferify dialogue avec ses services back-end au moyen du protocole Connect. Les services back-end répondent en gRPC simple. Aucun des deux côtés n’a besoin de comprendre le protocole de l’autre, car Envoy, qui s’exécute devant chaque service back-end, traduit de l’un à l’autre.

Cet article explique comment Envoy décide qu’une requête est un appel Connect, comment nous n’activons la traduction que sur les routes RPC, ce que devient chaque en-tête et chaque octet à l’aller et au retour, et un tableau où Envoy et la spécification Connect actuelle ne sont pas d’accord. Chaque détail ci-dessous a été lu dans le code source d’Envoy 1.40.2 et dans la spécification Connect.

Le trajet d’un appel Connect. Le navigateur envoie une requête Connect à Envoy. À l’intérieur d’Envoy, elle passe par ext_authz, qui vérifie la requête d’origine, puis par le pont Connect-gRPC, qui la transforme en gRPC, puis par le routeur, qui l’envoie au service back-end qui sert ce RPC. La réponse repasse par le pont, qui la transforme en réponse Connect.Le trajet d’un appel Connect. Le navigateur envoie une requête Connect à Envoy. À l’intérieur d’Envoy, elle passe par ext_authz, qui vérifie la requête d’origine, puis par le pont Connect-gRPC, qui la transforme en gRPC, puis par le routeur, qui l’envoie au service back-end qui sert ce RPC. La réponse repasse par le pont, qui la transforme en réponse Connect.
Un appel Connect. L’autorisation vérifie la requête telle que le navigateur l’a envoyée ; le pont la transforme ensuite en gRPC pour le service back-end, puis retransforme en Connect la réponse gRPC du service.

Ce qu’est le pont

envoy.filters.http.connect_grpc_bridge est un filtre HTTP d’Envoy. Le navigateur parle Connect et les services back-end répondent en gRPC ; le filtre réécrit les requêtes et les réponses de l’un à l’autre. Les services back-end n’ont donc besoin d’aucune bibliothèque Connect, et le navigateur n’a pas besoin de gRPC-Web.

Envoy traite une requête comme un appel Connect quand l’une de ces conditions est vraie :

  • Le type de contenu est application/connect+<codec>. C’est un appel en streaming.
  • La requête a un en-tête Connect-Protocol-Version et un type de contenu application/.... C’est un appel unaire.
  • La chaîne de requête contient connect=v1. C’est un Connect GET, qu’Envoy transforme en POST.

Toute autre requête traverse le filtre sans modification.

Comment nous l’activons

Le filtre est configuré avec disabled: true pour tout le listener, puis activé route par route avec typed_per_filter_config, uniquement sur les routes RPC. Une route REST n’est jamais pontée, même quand une requête qui lui est adressée porte des en-têtes Connect ou ?connect=v1.

Les filtres s’exécutent dans cet ordre : ext_authz, puis le pont, puis le routeur. L’autorisation vérifie donc la requête Connect d’origine, et le pont ne traduit que des requêtes déjà autorisées.

Il y a une route par RPC, appariée sur le chemin exact /<package>.<Service>/<Method> et générée à partir de nos contrats protobuf. Les routes de chaque package vont vers le service back-end qui l’implémente.

Envoy qualifie cette extension d’alpha : « fonctionnelle, mais sans durée d’exploitation significative en production ». Nous l’avons accepté, et nous l’avons éprouvée sur un point d’accès en production avant d’y déplacer un autre RPC.

Pourquoi Connect et pas gRPC-Web

Les deux choix demandent un traducteur dans Envoy, connect_grpc_bridge ou grpc_web, et les deux utilisent les mêmes clients protobuf-es générés. Une seule ligne diffère dans l’application web : createConnectTransport ou createGrpcWebTransport. La différence tient à ce qui circule sur le réseau.

  • Un appel Connect unaire est du HTTP ordinaire. Le corps est le message nu, et une erreur revient avec un vrai statut HTTP et un corps JSON que vous pouvez lire dans les outils de développement du navigateur. Avec gRPC-Web, chaque appel est encadré, le statut vaut toujours 200, et le résultat voyage dans des en-têtes grpc-status ou dans une trame de trailer à l’intérieur du corps.
  • Les journaux d’accès et les alarmes lisent le statut HTTP. Le pont écrit le statut de l’erreur : un RPC en échec est donc journalisé et compté comme un appel REST en échec. Avec gRPC-Web, chaque RPC en échec serait journalisé comme un 200.
  • CORS demande moins de configuration. gRPC-Web exige que Grpc-Status, Grpc-Message et Grpc-Status-Details-Bin soient exposés à la page ; Connect n’a besoin de rien de nouveau.
  • Connect prend aussi en charge GET (?connect=v1), qui peut être mis en cache, pour les méthodes sans effet de bord.
  • connect-web utilise le protocole Connect par défaut, et sa documentation ne suggère gRPC-Web que lorsque le service back-end ne peut pas parler Connect.

Nous n’avons préparé aucun repli gRPC-Web : ce serait un second filtre, trois en-têtes CORS, une branche dans l’application web et un test que rien n’utilise. Si le pont se comportait un jour mal, le basculement coûterait environ une journée de travail sur les mêmes clients générés.

Le prix de ce choix, c’est le JSON. Le pont ne traduit pas les corps JSON : il renomme application/json en application/grpc+json et transmet les octets inchangés, et grpc-java ne lit que du protobuf binaire. L’application web utilise donc le codec binaire, et seuls les corps d’erreur sont lisibles dans les outils de développement.

La requête, champ par champ

Une requête Connect unaire devient une requête gRPC vers le service back-end, en HTTP/2 sans TLS (h2c) :

Connect, depuis le navigateurgRPC, vers le service back-end
POST /pkg.Svc/MethodPOST /pkg.Svc/Method
Content-Type: application/protoContent-Type: application/grpc+proto
Connect-Protocol-Version: 1retiré
Connect-Timeout-Ms: 15000grpc-timeout: 15000m
Content-Encoding: gzipgrpc-encoding: gzip
Accept-Encoding: gzipgrpc-accept-encoding: gzip
aucunTE: trailers
Content-Lengthretiré
le messageen-tête de trame + le message
Authorization et les autres en-têtesles mêmes, comme métadonnées gRPC

Le pont met tout le corps en mémoire tampon, puis place devant lui l’en-tête de trame gRPC de 5 octets : un octet d’indicateur, puis la longueur du message sur 4 octets, en big-endian. Quand le corps est compressé, l’octet d’indicateur le signale. Un délai trop long pour 8 chiffres de millisecondes passe en secondes, minutes ou heures (S, M, H).

Un Connect GET, GET /pkg.Svc/Method?connect=v1&encoding=proto&base64=1&compression=...&message=<base64url>, devient un POST. La chaîne de requête est abandonnée, message est décodé dans le corps encadré, encoding fixe le type de contenu application/grpc+<encoding>, et compression fixe grpc-encoding (identity en son absence).

La réponse, champ par champ

Une réponse gRPC réussie devient une réponse Connect :

gRPC, depuis le service back-endConnect, vers le navigateur
statut 200statut 200
content-type: application/grpc+protocontent-type: application/proto
en-tête de trame + le messagele message
grpc-encoding: gzipContent-Encoding: gzip
trailer grpc-status: 0supprimé
autres en-têtes et trailers grpc-*supprimés
trailer foo: baren-tête trailer-foo: bar

Le pont met toute la réponse en mémoire tampon avant de retirer l’en-tête de trame, et supprime de nouveau Content-Encoding quand la trame n’était pas compressée.

Une erreur est convertie de la même façon. grpc-java envoie un appel en échec sous forme de trailers seulement :

Trailers gRPC, depuis le service back-endRéponse Connect, vers le navigateur
grpc-status: 7statut 403, "code": "permission_denied"
grpc-message: not allowed"message": "not allowed"
grpc-status-details-bin: <base64>"details": [{"type": ..., "value": "<base64>"}]

Le type de contenu de la réponse est application/json, et son corps se lit ainsi :

{
  "code": "permission_denied",
  "message": "not allowed",
  "details": [{
    "type": "type.googleapis.com/google.rpc.ErrorInfo",
    "value": "<base64>"
  }]
}

type est l’URL de type exactement telle que le serveur l’a écrite. Envoy n’écrit aucun champ debug.

Il y a deux limites. Une requête unaire plus grande que la limite de tampon d’Envoy reçoit un 413 local, et une réponse plus grande qu’elle un 500. Et une réponse qui n’est pas du gRPC du tout, par exemple un 404 du service back-end pour un RPC qu’il ne sert pas, traverse sans modification ; c’est le client connect-web, et non le pont, qui transforme ce HTTP 404 en unimplemented.

La table de statuts d’Envoy est plus ancienne que celle de Connect

Pour quatre codes d’erreur, Envoy 1.40.2 répond un statut HTTP différent de celui que la spécification Connect nomme aujourd’hui :

CodeEnvoy 1.40.2Spécification Connect aujourd’hui
canceled408 Request Timeout499 Client Closed Request
deadline_exceeded408 Request Timeout504 Gateway Timeout
failed_precondition412 Precondition Failed400 Bad Request
unimplemented404 Not Found501 Not Implemented

Envoy a ajouté le filtre en février 2023 (envoyproxy/envoy#24836) et a copié la table de Connect telle qu’elle était alors. Connect a modifié ces quatre lignes le 11 mars 2024 (connectrpc.com#130). Deux jours plus tard, il a assoupli la règle selon laquelle le statut HTTP « doit correspondre » au code d’erreur en « devrait correspondre » (connectrpc.com#149), parce que les clients lisent le code dans le corps JSON et n’utilisent le statut HTTP que lorsqu’il n’y a pas de corps. Le mappage d’Envoy n’a pas changé depuis : il reste donc conforme à la spécification, simplement plus ancien.

Les clients ne sont pas touchés, car ils lisent le code dans le corps. Tout ce qui ne lit que le statut HTTP, comme un journal d’accès ou une alarme sur les réponses 5xx, voit un 4xx là où la spécification actuelle dit 5xx : un délai écoulé ressemble à une erreur du client.

Une passerelle peut corriger cela à deux endroits :

  • Dans Envoy lui-même : la table se trouve dans une seule fonction, statusCodeToConnectUnaryStatus, et mettre à jour ses quatre lignes est donc une petite modification du filtre.
  • Dans la configuration propre de la passerelle : un filtre placé avant le pont voit la réponse après que le pont l’a convertie, et peut donc lire code dans le corps d’erreur JSON et fixer le statut HTTP d’après la table actuelle. Il doit lire le corps, car le statut seul ne permet pas de distinguer canceled de deadline_exceeded : Envoy répond 408 dans les deux cas.

Questions fréquentes

Les services back-end ont-ils besoin d’une bibliothèque Connect ?

Non. Ils répondent en gRPC simple. Le filtre connect_grpc_bridge d’Envoy réécrit chaque requête Connect en requête gRPC à l’aller, et la réponse gRPC en réponse Connect au retour.

Pourquoi Connect et pas gRPC-Web ?

Les deux ont besoin d’un traducteur dans Envoy et utilisent les mêmes clients générés. La différence se joue sur le réseau. Un appel Connect unaire est du HTTP ordinaire : un appel qui échoue revient donc avec un vrai statut HTTP et un corps JSON que les journaux, les alarmes et les outils de développement du navigateur savent lire. Avec gRPC-Web, chaque appel répond 200 et le résultat voyage dans le corps.

Le pont peut-il traduire des corps de requête JSON ?

Non. Il renomme application/json en application/grpc+json et transmet les octets inchangés, et un serveur gRPC qui ne lit que du protobuf binaire, comme grpc-java, ne peut pas les lire. Le navigateur envoie donc du protobuf binaire, et seuls les corps d’erreur sont en JSON.

Une requête Connect GET fonctionne-t-elle à travers le pont ?

Oui. Une requête GET dont la chaîne de requête contient connect=v1 est transformée en POST. Envoy décode le message depuis la chaîne de requête vers le corps de la requête et abandonne la chaîne de requête.

Retour en haut