Transferify
Torna al blog

Scrivere solo la propria parte della configurazione Envoy e generare il resto

I servizi di Transferify stanno dietro un unico gateway, un proxy Envoy. Il gateway controlla le richieste con il nostro servizio di autenticazione, ammette i browser in base all’origine e instrada percorsi e metodi RPC verso i servizi che stanno dietro di esso. La sua configurazione era un unico, lungo file Envoy. Ora le persone modificano un file breve, e un programma ne ricava il file per Envoy.

Il programma si chiama proxyfile. Lo abbiamo creato in Transferify per il nostro gateway. È open source con licenza Apache-2.0, scritto in Go, e si trova su github.com/kitecorp/proxyfile. Questo articolo spiega perché lo abbiamo scritto, che cosa si scrive a mano e che cosa scrive il programma, e come vi abbiamo trasferito il nostro gateway senza cambiare ciò che fa Envoy. Per una delle parti che scrive, il bridge da Connect a gRPC, si veda Come Envoy trasforma in gRPC le chiamate Connect del browser.

Perché il file Envoy di un gateway continua a crescere

La configurazione di Envoy è esplicita per scelta. Ogni listener, cluster, route, filtro e regola sulle intestazioni è scritto per intero. Questo va bene per Envoy, che la legge. È faticoso per le persone che la modificano, perché un gateway reale si ripete: ogni route in ogni ambiente, ogni cluster, ogni regola sulle intestazioni.

Il nostro era arrivato a 552.000 byte in 799 righe. Serviva tre ambienti, e il virtual host di ogni ambiente conteneva le stesse 205 route RPC. Una copia era in YAML. Le altre due erano ciascuna una sola riga di JSON, lunga circa 167.000 e 169.000 caratteri. Un generatore scriveva quelle parti tra commenti delimitatori, dentro lo stesso file che le persone modificavano a mano.

I dati davvero nostri erano pochi: i servizi e le loro porte, i timeout, il controllo di autenticazione, le origini dei browser per ogni ambiente, le route per percorso o per metodo RPC, il campionamento dei log e il tracing. Erano sparsi tra i nomi dei tipi di Envoy, e per cambiarne uno bisognava prima trovarlo. Nessuno riusciva a leggere o a revisionare il file.

Volevamo scrivere solo ciò che è nostro, con il motivo accanto a ogni valore, e lasciare che uno strumento scrivesse il resto, ogni volta allo stesso modo.

Prima: un unico envoy.yaml di 552.000 byte in 799 righe, scritto in parte a mano e in parte da un generatore, con due righe di circa 167.000 e 169.000 caratteri. Dopo: un Proxyfile di 169 righe e un file di metodi scritto dalla build, da cui proxyfile genera un envoy.yaml che nessuno modifica.Prima: un unico envoy.yaml di 552.000 byte in 799 righe, scritto in parte a mano e in parte da un generatore, con due righe di circa 167.000 e 169.000 caratteri. Dopo: un Proxyfile di 169 righe e un file di metodi scritto dalla build, da cui proxyfile genera un envoy.yaml che nessuno modifica.
Prima e dopo il nostro passaggio. Le persone modificano solo il file breve. Quello lungo è generato, e la build rifiuta una modifica a mano.

Il proxyfile più piccolo

Un proxyfile descrive un gateway. Sono obbligatorie solo quattro chiavi: listen, services, origins e routes. Questo è il più piccolo, tra gli esempi del repository:

# 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}

Lo stesso gateway in 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 }
]

Si legge come suona: un listener pubblico sulla porta 8000, un servizio con una porta HTTP, un ambiente che risponde per localhost e ammette l’origine del browser http://localhost:3000, e una route che invia ogni percorso al servizio. Entrambi i file danno lo stesso envoy.yaml, byte per byte: 119 righe e 5.592 byte. Tutto ciò che contiene e che il proxyfile non dice è un valore predefinito, lo stesso in ogni gateway che proxyfile scrive.

render e check

render scrive il file Envoy. check esegue il rendering in memoria e confronta il risultato con il file su disco. Non scrive nulla.

proxyfile render gateway.yaml -o envoy.yaml
proxyfile check gateway.yaml -o envoy.yaml

Nessuno dei due stampa nulla, ed entrambi terminano con 0. Dopo una modifica a mano di envoy.yaml, o una modifica di gateway.yaml senza un nuovo rendering, check termina con 1 e scrive:

envoy.yaml differs from what proxyfile render writes

Lo stesso input dà sempre gli stessi byte, ed è per questo che la coppia funziona. Il file generato va nel commit accanto alla sua sorgente, entrambi si revisionano nella stessa pull request, e check nella build fallisce quando qualcuno modifica a mano l’output o dimentica il rendering. Il file generato lo dice nella prima riga:

# Generated by proxyfile. Do not edit: change the proxyfile beside it and run proxyfile render; proxyfile check fails on a hand edit.

render scrive i propri output solo quando l’intero input è privo di problemi, quindi un errore non lascia mai un file scritto a metà. Il codice di uscita 2 indica un errore d’uso. Senza un nome di file, proxyfile legge Proxyfile, proxyfile.conf, proxyfile.yaml o proxyfile.yml dalla directory di lavoro, e deve esisterne esattamente uno.

Che aspetto ha un errore

Ogni chiave viene controllata. Basta scrivere male listen nel file più piccolo, e render rifiuta, non scrive nulla e indica il file, la riga e la colonna:

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

Una chiave sconosciuta, in qualunque punto, è un problema, e il messaggio elenca le chiavi note in quel punto. Una route verso un servizio non dichiarato dà:

gateway.yaml:9:32: route "app": the service "shop" is not declared

I controlli avvengono per fasi: lettura e sintassi, poi il formato di ogni chiave, poi le regole che confrontano le chiavi tra loro. Un’esecuzione segnala tutti i problemi della prima fase che ne trova, così si corregge un gruppo alla volta, non un problema per esecuzione.

In HOCON anche un errore di sintassi ha una riga e una colonna. Gli altri problemi indicano invece il percorso della chiave, perché il lettore HOCON non conserva una riga per un valore. Scrivere la porta come http = "3000" dà:

gateway.conf: services.app.http: "http" must be a whole number from 1 to 65535, not quoted or block text

In HOCON un valore tra virgolette è testo, e una porta deve essere un numero.

Valori predefiniti decisi una volta sola

Ciò per cui il formato non ha una chiave è uguale in ogni gateway che proxyfile scrive. La configurazione di Envoy vive in frammenti YAML dentro il programma, e ogni frammento ha commenti che spiegano perché è scritto così. Alcuni valori predefiniti a cui teniamo:

  • Nessun percorso in alcuna riga di log. Il gateway scrive tre tipi di riga di log JSON sullo standard output, e nessuna contiene un percorso, una query, un indirizzo, uno user agent o un referrer, perché i percorsi possono contenere link e token. Con log viene registrata ogni risposta a partire da status_from, anche ogni risposta con un response flag di Envoy, e una quota delle altre, scelta in base alle prime otto cifre esadecimali di x-request-id.
  • Nuovi tentativi solo quando la richiesta non ha mai raggiunto il servizio. I retries di una route valgono solo per le condizioni connect-failure e reset-before-request di Envoy, quindi una richiesta che il servizio ha visto non viene mai inviata due volte.
  • Un 502 vuoto quando un servizio non è raggiungibile. La risposta di Envoy di per sé è un 503, oppure un 502 con un corpo che descrive il guasto. Con proxyfile il client riceve un 502 con il corpo vuoto, e i dettagli finiscono nella riga di log gateway_upstream_failure.
  • x-request-id su ogni risposta, quello del client se ne ha inviato uno, così chi chiama può citare l’id di una chiamata fallita. È il request_id del log delle richieste.
  • Nessuna riscrittura dei percorsi. Envoy non normalizza i percorsi e non unisce le barre, quindi sono le regole sui percorsi del servizio stesso a decidere che cosa significa un percorso.
  • Le origini estranee si fermano al gateway. Un’origine di browser non ammessa riceve 403 prima che CORS, il controllo di autenticazione o un servizio la vedano: preflight, richieste e upgrade WebSocket allo stesso modo. Per un’origine ammessa, il gateway risponde da sé a ogni preflight.
  • La correzione di stato del bridge Connect. Ogni route RPC trasforma una chiamata Connect in gRPC. Per quattro codici il bridge di Envoy scrive ancora lo stato HTTP di una vecchia tabella dei codici di errore di Connect, quindi un piccolo filtro Lua imposta quello attuale: canceled 499, deadline_exceeded 504, failed_precondition 400 e unimplemented 501. L’articolo sul bridge spiega perché questo conta per log e allarmi.

Non c’è modo di far passare configurazione Envoy grezza attraverso un proxyfile. Quando un gateway ha bisogno di un valore diverso per uno di questi valori predefiniti, serve una nuova chiave.

Il controllo di autenticazione

Con auth, il gateway interroga uno dei Suoi servizi su ogni richiesta prima che la richiesta raggiunga un servizio. Lo interroga in modalità HTTP, quindi il servizio di autenticazione è un normale endpoint HTTP e non ha bisogno di alcun tipo specifico di Envoy. Ecco un gateway con un controllo, con i nomi inventati del repository:

# 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%}

Il commento accanto a upstream_idle è il tipo di motivo che volevamo accanto a ogni valore. Un controllo inviato su una connessione che il servizio di autenticazione sta chiudendo fallisce, quindi il gateway dovrebbe chiudere una connessione inattiva prima che lo faccia il servizio.

Che cosa riceve il controllo, e che cosa fa la sua risposta:

  • Riceve una richiesta verso il percorso di ask, mai il percorso o la query del client, perché i percorsi possono contenere link e token. Riceve le intestazioni che send nomina, e la modalità HTTP aggiunge Host del client, il metodo, Authorization e Content-Length: 0.
  • x-gateway-timeout-ms contiene la scadenza, qui 1500, così il servizio di autenticazione può farci stare le proprie chiamate.
  • Un 200 lascia passare la richiesta. Le intestazioni della risposta nominate da accept vengono impostate sulla richiesta verso il servizio, sostituendo qualsiasi valore presente. Un 5xx conta come chiamata fallita, su cui decide when_unavailable. Qualsiasi altro stato torna al client come rifiuto.
  • Un client non può falsificare ciò che dice il controllo. Il gateway rimuove ogni intestazione accept, e le intestazioni che usa per interrogare il controllo, dalle richieste dei client prima dell’instradamento e prima di qualsiasi filtro.
  • Con when_unavailable: allow, una richiesta che non è stato possibile controllare passa con x-envoy-auth-failure-mode-allowed impostato, e il gateway scrive per essa una riga di log gateway_auth_unavailable.
  • auth: off su una route, come la route dei webhook qui sopra, inoltra le sue richieste senza il controllo.

Route RPC da un elenco di metodi

Per i servizi gRPC e Connect non si scrive una route per ogni metodo. Si inserisce una voce rpc in routes e si fornisce l’elenco dei metodi. In HOCON l’elenco può stare in un file a sé:

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 }
]

E rpc-methods.conf accanto:

rpc {
  methods = [
    { service = "example.backend.v1.ReportService", method = ListReports, action = read, reports = [export, share] }
  ]
}

proxyfile scrive una route per metodo, che corrisponde al percorso esatto di una chiamata, qui /example.backend.v1.ReportService/ListReports, con il bridge Connect attivato. Le route RPC vengono ordinate per percorso e scritte dove si trova la voce rpc, quindi una route semplice sopra la voce viene provata per prima e una sotto dopo. In to vince un nome di servizio esatto, poi il pattern di pacchetto più lungo che corrisponde, e * viene per ultimo. Con un controllo di autenticazione, ogni route RPC comunica al controllo anche action e reports del proprio metodo, in x-gateway-action e x-gateway-reports.

La parola required è importante. Un semplice include di HOCON salta un file mancante. include required(...) lo rifiuta indicandone il nome:

rpc-methods.conf: cannot read include file: open rpc-methods.conf: no such file or directory

--rpc-table scrive anche le route risolte in JSON, un oggetto per riga con servizio, metodo, azione, report e cluster, perché un altro programma le possa leggere. Nel nostro repository un passo della build scrive rpc-methods.conf dai nostri contratti protobuf, e il Proxyfile lo include.

YAML o HOCON

Le due forme hanno le stesse chiavi, gli stessi significati e le stesse regole, e danno gli stessi byte. Conviene scegliere quella che il proprio team modifica più comodamente.

YAML ha un JSON Schema, così un editor completa e controlla le chiavi durante la digitazione. La riga yaml-language-server mostrata sopra va messa per prima nel file per gli editor che usano lo YAML language server, come VS Code con l’estensione YAML. In un IDE JetBrains si aggiunge una mappatura JSON Schema per lo stesso URL. proxyfile schema stampa lo schema, e ogni release lo include come proxyfile.schema.json. Lo schema descrive solo la forma YAML.

HOCON offre include e sostituzioni. Un include viene letto rispetto al file che lo contiene. Una sostituzione come accept = ${auth.send} copia un valore del proxyfile in un altro. Legge i file, mai l’ambiente, quindi gli stessi file danno gli stessi byte su ogni macchina. Tre regole da sapere: routes è un array, perché vince la prima route che corrisponde e HOCON non obbliga un oggetto a mantenere l’ordine delle sue chiavi; una chiave che contiene . o * va tra virgolette, come in "*" = backend; e un numero tra virgolette è testo.

Il file del nostro gateway è in HOCON.

Come abbiamo fatto il passaggio senza cambiare ciò che fa Envoy

La sorgente scritta a mano del nostro gateway è ora un Proxyfile di 169 righe di HOCON. Di queste righe, 49 sono commenti, e altre 10 hanno un commento accanto al valore. Accanto c’è rpc-methods.conf con i nostri 205 metodi, scritto dalla build.

Il passaggio non doveva cambiare una sola route o intestazione. Per questo è arrivato con una prova: un test che esegue la versione pubblicata proxyfile v1.2.0 sul Proxyfile, analizza in alberi il file generato e il file che sostituisce, e richiede che i due alberi siano uguali, compreso l’ordine delle liste. Richiede anche che la tabella RPC generata sia quella vecchia, byte per byte. La prova è temporanea. Il vecchio file è rimasto come fixture di test fino al primo deploy in produzione di quello nuovo, e ora che la produzione esegue il file generato, una modifica successiva rimuove la fixture e il test.

Ciò che resta è il controllo nella build. La build scarica la release di proxyfile fissata, la verifica con il file dei checksum della release stessa, scrive il file dei metodi ed esegue proxyfile render. Un passo del task check della build esegue proxyfile check, che fallisce con una modifica a mano di envoy.yaml, con una modifica del Proxyfile senza rendering e con un file dei metodi modificato. La build dell’immagine esegue poi envoy --mode validate sul file che distribuisce.

Il file generato è ancora grande: 522.056 byte in 1.112 righe, perché Envoy ha sempre bisogno di ogni route in ogni ambiente. Ma nessuno lo modifica. Ogni route RPC ora occupa una riga per ambiente, e la riga più lunga è di 959 caratteri, quindi un diff del file generato mostra le route che sono cambiate. Gli attributi Git lo marcano come generato, così GitHub lo comprime in una pull request.

Il percorso da un proxyfile a un Envoy in esecuzione. Il proxyfile lo modifica Lei. proxyfile render scrive envoy.yaml, ed entrambi i file vanno nel commit in git. Nella build, proxyfile check ripete il rendering in memoria e fallisce se l’envoy.yaml nel commit è diverso. Nella nostra build si costruisce poi l’immagine con il file del commit, Envoy lo convalida e il gateway lo esegue.Il percorso da un proxyfile a un Envoy in esecuzione. Il proxyfile lo modifica Lei. proxyfile render scrive envoy.yaml, ed entrambi i file vanno nel commit in git. Nella build, proxyfile check ripete il rendering in memoria e fallisce se l’envoy.yaml nel commit è diverso. Nella nostra build si costruisce poi l’immagine con il file del commit, Envoy lo convalida e il gateway lo esegue.
proxyfile scrive file in git, e la build li controlla. Distribuire il file è compito della build, non di proxyfile.

Envoy 1.40 carica ogni esempio

proxyfile scrive per Envoy 1.40. Il controllo obbligatorio del repository esegue i test Go. Questi confrontano l’albero scritto per ogni esempio con uno scritto a mano, compreso l’ordine delle liste, mantengono gli output nel commit uguali a ciò che scrive render, e richiedono che le forme YAML e HOCON di ogni esempio diano gli stessi file. Poi il controllo esegue envoy --mode validate dall’immagine di Envoy 1.40.2 su ogni esempio generato. I test provano che ogni file è quello voluto. Solo Envoy prova che si carica.

Nel loro insieme gli esempi coprono il formato: examples/full scrive ogni chiave tranne address di un servizio e tracing, examples/partial prende l’altro lato di ogni chiave facoltativa, e examples/tracing scrive tracing.

Installazione

Ogni release ha un file di programma per Linux e macOS su amd64 e arm64, con accanto checksums.txt e proxyfile.schema.json. Basta scaricare quello per il proprio sistema, verificarlo e metterlo nel 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

Su un Mac con Apple silicon il file è proxyfile-darwin-arm64. In un Dockerfile si copia il programma dall’immagine, che contiene /proxyfile e nient’altro:

COPY --from=ghcr.io/kitecorp/proxyfile:1.2.0 /proxyfile /usr/local/bin/proxyfile

Con Go installato:

go install github.com/kitecorp/proxyfile/cmd/proxyfile@v1.2.0

proxyfile version stampa poi proxyfile v1.2.0.

Che cosa non fa proxyfile

  • Scrive configurazione per Envoy, e per nessun altro proxy.
  • Scrive file. Non ha una fase di deploy e non è un control plane, quindi in un Envoy in esecuzione non cambia nulla finché la build non distribuisce il nuovo file.
  • Non accetta configurazione Envoy grezza, come visto sopra.
  • Presuppone che davanti al gateway ci sia un proxy o un load balancer. Un gateway esposto direttamente a Internet ha bisogno di una chiave che non esiste ancora.
  • Un servizio è un indirizzo IP, 127.0.0.1 se non se ne scrive un altro, non un nome host, perché Envoy non risolve alcun nome per un cluster statico.

Il README del repository contiene l’intero formato: ogni chiave, ogni regola, ogni valore predefinito e il motivo di ciascuno.

Domande frequenti

proxyfile distribuisce la configurazione o ricarica Envoy?

No. Scrive file. Il file envoy.yaml generato va nel commit accanto al proxyfile, e la build lo distribuisce come qualsiasi altro file. proxyfile non ha una fase di deploy e non è un control plane.

Si può inserire configurazione Envoy grezza in un proxyfile?

No. Una chiave sconosciuta, in qualunque punto, è un problema, e il messaggio elenca le chiavi note in quel punto. Tutto ciò per cui il formato non ha una chiave è un valore predefinito, lo stesso in ogni gateway che proxyfile scrive, e un valore diverso richiede una nuova chiave.

Le forme YAML e HOCON producono lo stesso risultato?

Sì. Ogni chiave ha lo stesso nome, lo stesso significato e le stesse regole in entrambe, e il file generato è lo stesso, byte per byte. È il nome del file a decidere la forma, mai il contenuto. Un file chiamato Proxyfile o che termina in .conf è HOCON, e uno che termina in .yaml o .yml è YAML.

Il risultato dipende dalla macchina su cui gira proxyfile?

No. Lo stesso input dà sempre gli stessi byte. Le sostituzioni HOCON leggono solo il proxyfile e i file che include, mai una variabile d’ambiente. Un tag di tracing che nomina una variabile d’ambiente viene letto da Envoy all’avvio, non da proxyfile.

Per quale versione di Envoy scrive proxyfile?

Envoy 1.40. Il controllo obbligatorio del repository esegue envoy --mode validate dall’immagine di Envoy 1.40.2 su ogni esempio che genera.

Pagine correlate

Torna su