Écrivez la partie de votre configuration Envoy qui vous est propre, et générez le reste
Les services de Transferify se trouvent derrière une seule passerelle, un proxy Envoy. Elle vérifie les requêtes auprès de notre service d’authentification, admet les navigateurs selon leur origine et achemine les chemins et les méthodes RPC vers les services qui se trouvent derrière elle. Sa configuration était autrefois un long fichier Envoy. Aujourd’hui, des personnes modifient un fichier court, et un programme en tire le fichier d’Envoy.
Ce programme, c’est proxyfile. Nous l’avons construit chez Transferify pour notre propre passerelle. Il est open source sous licence Apache-2.0, écrit en Go, et se trouve sur github.com/kitecorp/proxyfile. Cet article explique pourquoi nous l’avons écrit, ce que vous écrivez et ce qu’il écrit pour vous, et comment nous y avons fait passer notre propre passerelle sans changer ce que fait Envoy. Pour l’une des parties qu’il écrit, le pont de Connect vers gRPC, voir Comment Envoy transforme les appels Connect du navigateur en gRPC.
Pourquoi le fichier Envoy d’une passerelle ne cesse de grossir
La configuration d’Envoy est explicite par conception. Chaque listener, cluster, route, filtre et règle d’en-tête y est écrit en entier. Cela convient à Envoy, qui la lit. C’est pénible pour les personnes qui la modifient, car une vraie passerelle se répète : chaque route dans chaque environnement, chaque cluster, chaque règle d’en-tête.
La nôtre avait atteint 552 000 octets en 799 lignes. Elle servait trois environnements, et l’hôte virtuel de chaque environnement contenait les mêmes 205 routes RPC. Une copie était en YAML. Les deux autres tenaient chacune sur une seule ligne de JSON, longue d’environ 167 000 et 169 000 caractères. Un générateur écrivait ces parties entre des commentaires délimiteurs, dans le fichier même que des personnes modifiaient à la main.
Les éléments qui nous étaient propres étaient peu nombreux : les services et leurs ports, les délais d’expiration, la vérification d’authentification, les origines de navigateur par environnement, les routes par chemin ou par méthode RPC, l’échantillonnage des journaux et le traçage. Ils étaient dispersés parmi les noms de types d’Envoy, et pour en changer un, il fallait d’abord le trouver. Personne ne pouvait lire ni relire le fichier.
Nous voulions n’écrire que ce qui nous est propre, avec la raison à côté de chaque valeur, et laisser un outil écrire le reste, de la même façon à chaque fois.
Le plus petit proxyfile
Un proxyfile décrit une passerelle. Seules quatre clés sont obligatoires : listen, services, origins et routes. Voici le plus petit, tiré des exemples du dépôt :
# yaml-language-server: $schema=https://raw.githubusercontent.com/kitecorp/proxyfile/v1.2.0/schema/proxyfile.schema.json
# The smallest proxyfile: one service, one route, and the browser origins it admits.
listen: 8000
services:
app: {http: 3000}
origins:
all: {hosts: [localhost], origins: ["http://localhost:3000"]}
routes:
- {name: app, prefix: /, to: app}
La même passerelle en HOCON :
# The smallest proxyfile, in HOCON: one service, one route, and the browser origins it admits.
listen = 8000
services {
app { http = 3000 }
}
origins {
all { hosts = [localhost], origins = ["http://localhost:3000"] }
}
routes = [
{ name = app, prefix = /, to = app }
]
Il dit ce qu’il fait : un listener public sur le port 8000, un service avec un port HTTP, un environnement qui répond pour localhost et admet l’origine de navigateur http://localhost:3000, et une route qui envoie chaque chemin au service. Les deux fichiers donnent le même envoy.yaml, octet pour octet : 119 lignes et 5 592 octets. Tout ce qui s’y trouve sans que le proxyfile le dise est une valeur par défaut, la même dans chaque passerelle qu’écrit proxyfile.
render et check
render écrit le fichier Envoy. check fait le rendu en mémoire et compare le résultat au fichier sur le disque. Il n’écrit rien.
proxyfile render gateway.yaml -o envoy.yaml
proxyfile check gateway.yaml -o envoy.yaml
Les deux n’affichent rien et se terminent avec le code 0. Après une modification à la main de envoy.yaml, ou un changement de gateway.yaml sans nouveau rendu, check se termine avec le code 1 et affiche :
envoy.yaml differs from what proxyfile render writes
La même entrée donne toujours les mêmes octets, et c’est pour cela que cette paire fonctionne. Vous commitez le fichier généré à côté de sa source, vous relisez les deux dans une même pull request, et vous laissez check faire échouer votre build quand quelqu’un modifie la sortie à la main ou oublie de refaire le rendu. Le fichier généré le dit sur sa première ligne :
# Generated by proxyfile. Do not edit: change the proxyfile beside it and run proxyfile render; proxyfile check fails on a hand edit.
render n’écrit ses sorties que lorsque toute l’entrée est exempte de problèmes, si bien qu’une erreur ne laisse jamais derrière elle un fichier à moitié écrit. Le code de sortie 2 signale une erreur d’utilisation. Sans nom de fichier, proxyfile lit Proxyfile, proxyfile.conf, proxyfile.yaml ou proxyfile.yml dans le répertoire de travail, et exactement l’un d’eux doit exister.
À quoi ressemble une erreur
Chaque clé est vérifiée. Faites une faute de frappe dans listen dans le plus petit fichier, et render refuse, n’écrit rien, et nomme le fichier, la ligne et la colonne :
gateway.yaml:3:1: "listen" is required here
gateway.yaml:3:1: unknown key "liste"; known here: admin, auth, cors, limits, listen, log, namespace, origins, remove_from_clients, routes, rpc, services, timeouts, tracing
Une clé inconnue, où qu’elle soit, est un problème, et le message liste les clés connues à cet endroit. Une route vers un service qui n’est pas déclaré donne :
gateway.yaml:9:32: route "app": the service "shop" is not declared
Les vérifications se font par étapes : la lecture et la syntaxe, puis le format de chaque clé, puis les règles qui comparent les clés entre elles. Une exécution signale tous les problèmes de la première étape qui en trouve, si bien que vous corrigez un lot à la fois, et non un problème par exécution.
En HOCON, une erreur de syntaxe a elle aussi une ligne et une colonne. Les autres problèmes nomment plutôt le chemin de la clé, car le lecteur HOCON ne garde pas de ligne pour une valeur. Écrire le port sous la forme http = "3000" donne :
gateway.conf: services.app.http: "http" must be a whole number from 1 to 65535, not quoted or block text
En HOCON, une valeur entre guillemets est du texte, et un port doit être un nombre.
Des valeurs par défaut décidées une fois
Ce pour quoi le format n’a pas de clé est identique dans chaque passerelle qu’écrit proxyfile. La configuration d’Envoy vit dans des fragments YAML à l’intérieur du programme, et chaque fragment porte des commentaires qui disent pourquoi il est écrit ainsi. Quelques valeurs par défaut auxquelles nous tenons :
- Aucun chemin dans aucune ligne de journal. La passerelle écrit trois sortes de lignes de journal JSON sur la sortie standard, et aucune ne contient de chemin, de chaîne de requête, d’adresse, d’agent utilisateur ni de référent, car un chemin peut porter des liens et des jetons. Avec
log, chaque réponse dont le statut atteint ou dépassestatus_fromest journalisée, chaque réponse portant un indicateur de réponse Envoy aussi, ainsi qu’une part du reste, choisie d’après les huit premiers chiffres hexadécimaux dex-request-id. - De nouvelles tentatives seulement quand la requête n’a jamais atteint le service. Les
retriesd’une route ne s’appliquent qu’aux conditionsconnect-failureetreset-before-requestd’Envoy, si bien qu’une requête que le service a vue n’est jamais envoyée deux fois. - Un 502 vide quand un service est injoignable. La réponse propre d’Envoy est un 503, ou un 502 avec un corps qui décrit l’échec. Avec proxyfile, le client reçoit un 502 au corps vide, et les détails vont dans la ligne de journal
gateway_upstream_failure. x-request-idsur chaque réponse, celui du client quand il en a envoyé un, pour qu’un appelant puisse citer l’identifiant d’un appel en échec. C’est lerequest_iddu journal des requêtes.- Aucune réécriture de chemin. Envoy ne normalise pas les chemins et ne fusionne pas les barres obliques, si bien que ce sont les règles de chemin du service lui-même qui décident de ce que signifie un chemin.
- Les origines étrangères s’arrêtent à la passerelle. Une origine de navigateur non admise reçoit
403avant que CORS, la vérification d’authentification ou un service ne la voient : requêtes preflight, requêtes et mises à niveau WebSocket, sans distinction. Pour une origine admise, la passerelle répond elle-même à chaque requête preflight. - La correction de statut du pont Connect. Chaque route RPC transforme un appel Connect en gRPC. Pour quatre codes, le pont d’Envoy écrit encore le statut HTTP d’une ancienne table des codes d’erreur de Connect, si bien qu’un petit filtre Lua fixe le statut actuel :
canceled499,deadline_exceeded504,failed_precondition400 etunimplemented501. L’article sur le pont explique pourquoi cela compte pour les journaux et les alarmes.
Il n’existe aucun moyen de faire passer de la configuration Envoy brute par un proxyfile. Quand une passerelle a besoin d’une autre valeur pour l’une de ces valeurs par défaut, il faut une nouvelle clé.
La vérification d’authentification
Avec auth, la passerelle interroge l’un de vos services sur chaque requête avant que la requête n’atteigne un service. Elle l’interroge en mode HTTP, si bien que le service d’authentification est un point d’accès HTTP ordinaire et n’a besoin d’aucun type propre à Envoy. Voici une passerelle avec une vérification, qui reprend les noms inventés du dépôt :
# yaml-language-server: $schema=https://raw.githubusercontent.com/kitecorp/proxyfile/v1.2.0/schema/proxyfile.schema.json
listen: 8000
timeouts:
upstream_idle: 45s # under the accounts service's own idle timeout, so the gateway closes first
services:
shop: {http: 8080}
accounts: {http: 7070}
auth:
ask: accounts /auth/check
timeout: 1500ms
enabled: true
when_unavailable: allow # if accounts cannot answer, let the request through and log it
only_with_header: authorization # a request without a token goes through unchecked
send: [authorization]
accept: [x-user-*] # the answer headers that reach the service
origins:
live:
hosts: [api.example.com]
origins: ["https://example.com"]
routes:
- {name: accounts-webhooks, path: /api/webhooks, method: POST, to: accounts, rewrite: /hooks/payments, auth: off}
- {name: shop, prefix: /, to: shop, retries: 1}
log: {status_from: 400, sample: 1%}
Le commentaire à côté de upstream_idle est le genre de raison que nous voulions à côté de chaque valeur. Une vérification envoyée sur une connexion que le service d’authentification est en train de fermer échoue, donc la passerelle doit fermer une connexion inactive avant que le service ne le fasse.
Ce que reçoit la vérification, et ce que fait sa réponse :
- Elle reçoit une requête vers le chemin de
ask, jamais le chemin ni la chaîne de requête du client, car un chemin peut porter des liens et des jetons. Elle reçoit les en-têtes que nommesend, et le mode HTTP ajoute leHostdu client, la méthode,AuthorizationetContent-Length: 0. x-gateway-timeout-mscontient l’échéance, ici 1500, pour que le service d’authentification puisse y faire tenir ses propres appels.- Un
200laisse passer la requête. Les en-têtes de la réponse que nommeacceptsont posés sur la requête vers le service, et remplacent toute valeur qui s’y trouve. Un5xxcompte comme un appel en échec, dont décidewhen_unavailable. Tout autre statut retourne au client comme refus. - Un client ne peut pas falsifier ce que dit la vérification. La passerelle retire des requêtes des clients chaque en-tête
accept, ainsi que les en-têtes qu’elle utilise pour interroger la vérification, avant le routage et avant tout filtre. - Avec
when_unavailable: allow, une requête qui n’a pas pu être vérifiée passe avecx-envoy-auth-failure-mode-allowedpositionné, et la passerelle écrit pour elle une ligne de journalgateway_auth_unavailable. auth: offsur une route, comme la route de webhook ci-dessus, transmet ses requêtes sans la vérification.
Des routes RPC à partir d’une liste de méthodes
Pour les services gRPC et Connect, vous n’écrivez pas une route par méthode. Vous placez une entrée rpc dans routes et vous donnez la liste des méthodes. En HOCON, la liste peut vivre dans un fichier à part :
include required("rpc-methods.conf") # written by the build; required: a missing file is an error
listen = 8000
services {
web { http = 3000 }
backend { grpc = 5000 }
}
origins {
only { hosts = [gateway.example.net], origins = ["https://app.example.net"] }
}
routes = [
{ rpc { to { "*" = backend } } }
{ name = web, prefix = /, to = web }
]
Et rpc-methods.conf à côté :
rpc {
methods = [
{ service = "example.backend.v1.ReportService", method = ListReports, action = read, reports = [export, share] }
]
}
proxyfile écrit une route par méthode, qui correspond au chemin exact que porte un appel, ici /example.backend.v1.ReportService/ListReports, avec le pont Connect activé pour elle. Les routes RPC sont triées par chemin et écrites là où se trouve l’entrée rpc, si bien qu’une route ordinaire placée au-dessus de l’entrée est essayée d’abord, et une route placée en dessous ensuite. Dans to, un nom de service exact l’emporte, puis le plus long motif de paquet correspondant, et * vient en dernier. Avec une vérification d’authentification, chaque route RPC transmet aussi à la vérification l’action et les reports de sa méthode, dans x-gateway-action et x-gateway-reports.
Le mot required compte. Un include HOCON ordinaire ignore un fichier manquant. include required(...) le refuse en le nommant :
rpc-methods.conf: cannot read include file: open rpc-methods.conf: no such file or directory
--rpc-table écrit aussi les routes résolues en JSON, un objet par ligne avec le service, la méthode, l’action, les rapports et le cluster, pour qu’un autre programme les lise. Dans notre propre dépôt, une étape du build écrit rpc-methods.conf à partir de nos contrats protobuf, et le Proxyfile l’inclut.
YAML ou HOCON
Les deux formes ont les mêmes clés, les mêmes sens et les mêmes règles, et donnent les mêmes octets. Choisissez celle que votre équipe modifie le plus facilement.
YAML est livré avec un JSON Schema, si bien qu’un éditeur complète et vérifie les clés pendant la saisie. Placez la ligne yaml-language-server montrée plus haut en tête du fichier pour les éditeurs qui utilisent le serveur de langage YAML, comme VS Code avec l’extension YAML. Dans un IDE JetBrains, ajoutez une association JSON Schema pour la même URL. proxyfile schema affiche le schéma, et chaque version publiée le fournit sous le nom proxyfile.schema.json. Le schéma ne décrit que la forme YAML.
HOCON vous offre les inclusions et les substitutions. Une inclusion est lue par rapport au fichier qui la contient. Une substitution comme accept = ${auth.send} recopie une valeur du proxyfile dans une autre. Elle lit les fichiers, jamais l’environnement, si bien que les mêmes fichiers donnent les mêmes octets sur toutes les machines. Trois règles à connaître : routes est un tableau, car la première route qui correspond l’emporte et HOCON n’impose pas à un objet de garder l’ordre de ses clés ; une clé qui contient . ou * se met entre guillemets, comme dans "*" = backend ; et un nombre entre guillemets est du texte.
Le fichier de notre propre passerelle est en HOCON.
Comment nous avons changé sans modifier ce que fait Envoy
La source écrite à la main de notre passerelle est désormais un Proxyfile de 169 lignes de HOCON. Parmi ces lignes, 49 sont des commentaires, et 10 autres portent un commentaire à côté de leur valeur. À côté se trouve rpc-methods.conf avec nos 205 méthodes, que le build écrit.
Le changement ne devait modifier aucune route ni aucun en-tête. Il est donc venu avec une preuve : un test qui exécute la version publiée proxyfile v1.2.0 sur le Proxyfile, analyse en arbres le fichier généré et celui qu’il remplace, et exige que les deux arbres soient égaux, ordre des listes compris. Il exige aussi que la table RPC générée soit l’ancienne, octet pour octet. La preuve est temporaire. L’ancien fichier est resté comme fixture de test jusqu’au premier déploiement en production du nouveau, et maintenant que la production exécute le fichier généré, une modification ultérieure retire la fixture et le test.
Ce qui reste, c’est la vérification du build. Le build télécharge la version épinglée de proxyfile, la contrôle avec le fichier de sommes de contrôle de cette version, écrit le fichier de méthodes et exécute proxyfile render. Une étape de la tâche check du build exécute proxyfile check, et elle échoue sur une modification à la main de envoy.yaml, sur un changement du Proxyfile sans rendu et sur un fichier de méthodes modifié. Le build de l’image exécute ensuite envoy --mode validate sur le fichier qu’il livre.
Le fichier généré reste gros : 522 056 octets en 1 112 lignes, car Envoy a toujours besoin de chaque route dans chaque environnement. Mais personne ne le modifie. Chaque route RPC tient désormais sur une ligne par environnement, et la plus longue ligne fait 959 caractères, si bien qu’un diff du fichier généré montre les routes qui ont changé. Les attributs Git le marquent comme généré, si bien que GitHub le replie dans une pull request.
Envoy 1.40 charge chaque exemple
proxyfile écrit pour Envoy 1.40. La vérification obligatoire du dépôt exécute les tests Go. Ils comparent l’arbre écrit pour chaque exemple à un arbre écrit à la main, ordre des listes compris, maintiennent les sorties commitées identiques à ce qu’écrit render, et exigent que les formes YAML et HOCON de chaque exemple donnent les mêmes fichiers. La vérification exécute ensuite envoy --mode validate depuis l’image d’Envoy 1.40.2 sur chaque exemple généré. Les tests prouvent que chaque fichier est celui voulu. Seul Envoy prouve qu’il se charge.
Ensemble, les exemples couvrent tout le format : examples/full écrit chaque clé sauf l’address d’un service et tracing, examples/partial prend l’autre côté de chaque clé facultative, et examples/tracing écrit tracing.
Installation
Chaque version publiée comporte un fichier de programme pour Linux et macOS sur amd64 et arm64, accompagné de checksums.txt et de proxyfile.schema.json. Téléchargez celui de votre système, vérifiez-le et placez-le dans un dossier de votre PATH :
curl -fsSLO https://github.com/kitecorp/proxyfile/releases/download/v1.2.0/proxyfile-linux-amd64
curl -fsSLO https://github.com/kitecorp/proxyfile/releases/download/v1.2.0/checksums.txt
shasum -a 256 -c --ignore-missing checksums.txt
install -m 0755 proxyfile-linux-amd64 /usr/local/bin/proxyfile
Sur un Mac avec puce Apple, le fichier est proxyfile-darwin-arm64. Dans un Dockerfile, copiez le programme depuis l’image, qui contient /proxyfile et rien d’autre :
COPY --from=ghcr.io/kitecorp/proxyfile:1.2.0 /proxyfile /usr/local/bin/proxyfile
Avec Go installé :
go install github.com/kitecorp/proxyfile/cmd/proxyfile@v1.2.0
proxyfile version affiche alors proxyfile v1.2.0.
Ce que proxyfile ne fait pas
- Il écrit de la configuration pour Envoy, et pour aucun autre proxy.
- Il écrit des fichiers. Il n’a pas d’étape de déploiement et n’est pas un plan de contrôle, donc rien ne change dans un Envoy en marche tant que votre build ne livre pas le nouveau fichier.
- Il n’accepte aucune configuration Envoy brute, comme vu plus haut.
- Il suppose qu’un proxy ou un répartiteur de charge se trouve devant la passerelle. Une passerelle exposée directement à Internet a besoin d’une clé qui n’existe pas encore.
- Un service est une adresse IP,
127.0.0.1sauf si vous en écrivez une autre, et non un nom d’hôte, car Envoy ne résout aucun nom pour un cluster statique.
Le README du dépôt contient tout le format : chaque clé, chaque règle, chaque valeur par défaut et la raison de chacune.
Questions fréquentes
proxyfile déploie-t-il la configuration ou recharge-t-il Envoy ?
Non. Il écrit des fichiers. Vous commitez le fichier envoy.yaml généré à côté du proxyfile, et votre build le livre comme n’importe quel autre fichier. proxyfile n’a pas d’étape de déploiement et n’est pas un plan de contrôle.
Puis-je mettre de la configuration Envoy brute dans un proxyfile ?
Non. Une clé inconnue, où qu’elle soit, est un problème, et le message liste les clés connues à cet endroit. Tout ce pour quoi le format n’a pas de clé est une valeur par défaut, la même dans chaque passerelle qu’écrit proxyfile, et une autre valeur demande une nouvelle clé.
Les formes YAML et HOCON donnent-elles le même résultat ?
Oui. Chaque clé a le même nom, le même sens et les mêmes règles dans les deux, et le fichier généré est le même, octet pour octet. C’est le nom du fichier qui décide de la forme, jamais son contenu. Un fichier nommé Proxyfile ou terminé par .conf est en HOCON, et un fichier terminé par .yaml ou .yml est en YAML.
Le résultat dépend-il de la machine où proxyfile s’exécute ?
Non. La même entrée donne toujours les mêmes octets. Les substitutions HOCON ne lisent que le proxyfile et ses inclusions, jamais une variable d’environnement. Un tag de traçage qui nomme une variable d’environnement est lu par Envoy à son démarrage, pas par proxyfile.
Pour quelle version d’Envoy proxyfile écrit-il ?
Envoy 1.40. La vérification obligatoire du dépôt exécute envoy --mode validate depuis l’image d’Envoy 1.40.2 sur chaque exemple qu’il génère.