Transferify
Înapoi la blog

Scrie partea din configurația Envoy care e a ta și generează restul

Serviciile Transferify stau în spatele unui singur gateway, un proxy Envoy. Acesta verifică cererile cu serviciul nostru de autentificare, admite browserele după origine și trimite căile și metodele RPC către serviciile din spatele lui. Configurația lui era un singur fișier Envoy lung. Acum oamenii editează un fișier scurt, iar un program scrie din el fișierul pentru Envoy.

Programul se numește proxyfile. L-am construit la Transferify pentru propriul nostru gateway. E open source sub licența Apache-2.0, e scris în Go și se află la github.com/kitecorp/proxyfile. Articolul acesta explică de ce l-am scris, ce scrii tu și ce scrie el pentru tine, și cum am mutat propriul nostru gateway pe el fără să schimbăm ce face Envoy. Pentru una dintre părțile pe care le scrie, puntea de la Connect la gRPC, vezi Cum transformă Envoy apelurile Connect ale browserului în gRPC.

De ce crește mereu fișierul Envoy al unui gateway

Configurația Envoy e explicită prin design. Fiecare listener, cluster, rută, filtru și regulă de antet e scrisă complet. Asta i se potrivește lui Envoy, care o citește. E greu pentru oamenii care o schimbă, pentru că un gateway real se repetă: fiecare rută în fiecare mediu, fiecare cluster, fiecare regulă de antet.

Al nostru ajunsese la 552.000 de octeți în 799 de linii. Servea trei medii, iar virtual host-ul fiecărui mediu conținea aceleași 205 rute RPC. O copie era YAML. Celelalte două erau fiecare câte o singură linie de JSON, lungă de circa 167.000 și 169.000 de caractere. Un generator scria acele părți între comentarii de marcaj, în același fișier pe care oamenii îl editau de mână.

Faptele care erau ale noastre erau puține: serviciile și porturile lor, timeout-urile, verificarea de autentificare, originile browserelor pentru fiecare mediu, rutele după cale sau după metoda RPC, eșantionarea jurnalelor și tracing-ul. Erau risipite printre numele de tipuri ale Envoy, iar ca să schimbi unul trebuia mai întâi să-l găsești. Nimeni nu putea citi sau revizui fișierul.

Voiam să scriem doar ce e al nostru, cu motivul lângă fiecare valoare, și să lăsăm o unealtă să scrie restul, la fel de fiecare dată.

Înainte: un singur envoy.yaml de 552.000 de octeți în 799 de linii, scris parțial de mână și parțial de un generator, cu două linii de circa 167.000 și 169.000 de caractere. După: un Proxyfile de 169 de linii și un fișier de metode pe care îl scrie build-ul, din care proxyfile generează un envoy.yaml pe care nu îl editează nimeni.Înainte: un singur envoy.yaml de 552.000 de octeți în 799 de linii, scris parțial de mână și parțial de un generator, cu două linii de circa 167.000 și 169.000 de caractere. După: un Proxyfile de 169 de linii și un fișier de metode pe care îl scrie build-ul, din care proxyfile generează un envoy.yaml pe care nu îl editează nimeni.
Înainte și după trecerea noastră. Oamenii editează doar fișierul scurt. Cel lung e generat, iar build-ul refuză o editare manuală.

Cel mai mic proxyfile

Un proxyfile descrie un singur gateway. Doar patru chei sunt obligatorii: listen, services, origins și routes. Acesta e cel mai mic, din exemplele repository-ului:

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

Același gateway în 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 }
]

Se citește cum sună: un listener public pe portul 8000, un serviciu cu un port HTTP, un mediu care răspunde pentru localhost și admite originea de browser http://localhost:3000, și o rută care trimite fiecare cale către serviciu. Ambele fișiere dau același envoy.yaml, octet cu octet: 119 linii și 5.592 de octeți. Tot ce conține și nu spune proxyfile-ul e o valoare implicită, aceeași în fiecare gateway pe care îl scrie proxyfile.

render și check

render scrie fișierul Envoy. check generează în memorie și compară rezultatul cu fișierul de pe disc. Nu scrie nimic.

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

Niciuna nu afișează nimic, și amândouă se încheie cu 0. După o editare manuală a envoy.yaml sau o schimbare în gateway.yaml fără un nou render, check se încheie cu 1 și spune:

envoy.yaml differs from what proxyfile render writes

Aceeași intrare dă mereu aceiași octeți, și de aceea funcționează perechea asta. Pui fișierul generat într-un commit lângă sursa lui, le revizuiești pe amândouă în același pull request și lași check din build-ul tău să eșueze când cineva editează de mână rezultatul sau uită să ruleze render. Fișierul generat spune asta pe prima linie:

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

render își scrie fișierele doar când toată intrarea e fără probleme, așa că o greșeală nu lasă niciodată în urmă un fișier scris pe jumătate. Codul de ieșire 2 înseamnă o eroare de utilizare. Fără un nume de fișier, proxyfile citește Proxyfile, proxyfile.conf, proxyfile.yaml sau proxyfile.yml din directorul de lucru, și trebuie să existe exact unul dintre ele.

Cum arată o greșeală

Fiecare cheie e verificată. Scrie greșit listen în cel mai mic fișier, și render refuză, nu scrie nimic și numește fișierul, linia și coloana:

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

O cheie necunoscută, oriunde ar fi, e o problemă, iar mesajul enumeră cheile cunoscute în acel loc. O rută către un serviciu care nu e declarat dă:

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

Verificările rulează în etape: citirea și sintaxa, apoi formatul fiecărei chei, apoi regulile care compară cheile între ele. O rulare raportează toate problemele primei etape care găsește vreuna, așa că repari câte un lot o dată, nu câte o problemă la fiecare rulare.

În HOCON, și o eroare de sintaxă are o linie și o coloană. Celelalte probleme numesc în schimb calea cheii, pentru că cititorul HOCON nu păstrează o linie pentru o valoare. Dacă scrii portul ca http = "3000", obții:

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

În HOCON o valoare între ghilimele e text, iar un port trebuie să fie un număr.

Valori implicite decise o singură dată

Ce nu are o cheie în format e la fel în fiecare gateway pe care îl scrie proxyfile. Configurarea Envoy stă în fragmente YAML în interiorul programului, iar fiecare fragment are comentarii care spun de ce e scris așa. Câteva valori implicite la care ținem:

  • Nicio cale în vreo linie de jurnal. Gateway-ul scrie trei feluri de linii de jurnal JSON pe ieșirea standard, și niciuna nu conține o cale, un query, o adresă, un user agent sau un referrer, pentru că o cale poate purta linkuri și tokenuri. Cu log, fiecare răspuns de la status_from în sus e înregistrat, la fel fiecare răspuns cu un response flag Envoy, plus o parte din rest, aleasă după primele opt cifre hexazecimale din x-request-id.
  • Reîncercări doar când cererea n-a ajuns niciodată la serviciu. Valoarea retries a unei rute se aplică doar condițiilor connect-failure și reset-before-request din Envoy, așa că o cerere pe care serviciul a văzut-o nu e trimisă niciodată de două ori.
  • Un 502 gol când un serviciu nu poate fi contactat. Răspunsul propriu al Envoy e un 503, sau un 502 cu un corp care descrie eșecul. Cu proxyfile, clientul primește un 502 cu corpul gol, iar detaliile ajung în linia de jurnal gateway_upstream_failure.
  • x-request-id pe fiecare răspuns, cel al clientului, dacă l-a trimis, așa că un apelant poate cita id-ul unui apel eșuat. E request_id din jurnalul cererilor.
  • Nicio rescriere a căii. Envoy nici nu normalizează o cale, nici nu comasează slash-urile, așa că regulile de cale ale serviciului decid ce înseamnă o cale.
  • Originile străine se opresc la gateway. O origine de browser care nu e admisă primește 403 înainte ca CORS, verificarea de autentificare sau vreun serviciu s-o vadă: la fel pentru preflight-uri, cereri și upgrade-uri WebSocket. Pentru o origine admisă, gateway-ul răspunde singur la fiecare preflight.
  • Corectura de status pentru puntea Connect. Fiecare rută RPC transformă un apel Connect în gRPC. Pentru patru coduri, puntea din Envoy încă scrie statusul HTTP dintr-un tabel mai vechi al codurilor de eroare Connect, așa că un mic filtru Lua îl setează pe cel actual: canceled 499, deadline_exceeded 504, failed_precondition 400 și unimplemented 501. Articolul despre punte explică de ce contează asta pentru jurnale și alarme.

Nu există nicio cale de a trece configurație Envoy brută printr-un proxyfile. Când un gateway are nevoie de altă valoare pentru una dintre aceste valori implicite, e nevoie de o cheie nouă.

Verificarea de autentificare

Cu auth, gateway-ul întreabă unul dintre serviciile tale despre fiecare cerere, înainte ca cererea să ajungă la un serviciu. Întreabă în modul HTTP, așa că serviciul de autentificare e un endpoint HTTP obișnuit și nu are nevoie de niciun tip specific Envoy. Iată un gateway cu o verificare, cu numele inventate din 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%}

Comentariul de lângă upstream_idle e genul de motiv pe care îl voiam lângă fiecare valoare. O verificare trimisă pe o conexiune pe care serviciul de autentificare tocmai o închide eșuează, așa că gateway-ul ar trebui să închidă o conexiune inactivă înaintea serviciului.

Ce primește verificarea și ce face răspunsul ei:

  • Primește o cerere către calea din ask, niciodată calea sau query-ul clientului, pentru că o cale poate purta linkuri și tokenuri. Primește antetele pe care le numește send, iar modul HTTP adaugă Host al clientului, metoda, Authorization și Content-Length: 0.
  • x-gateway-timeout-ms conține termenul-limită, aici 1500, ca serviciul de autentificare să-și poată ține propriile apeluri în interiorul lui.
  • Un 200 lasă cererea să treacă. Antetele răspunsului pe care le numește accept sunt setate pe cererea către serviciu și înlocuiesc orice valoare de acolo. Un 5xx contează ca apel eșuat, despre care decide when_unavailable. Orice alt status se întoarce la client ca refuz.
  • Un client nu poate falsifica ce spune verificarea. Gateway-ul elimină din cererile clienților fiecare antet accept, și antetele cu care întreabă verificarea, înainte de rutare și înainte de orice filtru.
  • Cu when_unavailable: allow, o cerere care n-a putut fi verificată trece cu x-envoy-auth-failure-mode-allowed setat, iar gateway-ul scrie pentru ea o linie de jurnal gateway_auth_unavailable.
  • auth: off pe o rută, ca ruta de webhook de mai sus, își trimite cererile mai departe fără verificare.

Rute RPC, dintr-o listă de metode

Pentru serviciile gRPC și Connect nu scrii câte o rută pentru fiecare metodă. Pui o intrare rpc în routes și dai lista de metode. În HOCON lista poate sta într-un fișier separat:

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

Iar rpc-methods.conf lângă el:

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

proxyfile scrie câte o rută pentru fiecare metodă, potrivită pe calea exactă pe care o poartă un apel, aici /example.backend.v1.ReportService/ListReports, cu puntea Connect pornită pentru ea. Rutele RPC sunt sortate după cale și scrise acolo unde stă intrarea rpc, așa că o rută simplă de deasupra intrării e încercată prima, iar una de dedesubt după ele. În to, un nume exact de serviciu câștigă, apoi cel mai lung model de pachet care se potrivește, iar * vine ultimul. Cu o verificare de autentificare, fiecare rută RPC îi spune verificării și action și reports ale metodei sale, în x-gateway-action și x-gateway-reports.

Cuvântul required contează. Un include HOCON simplu sare peste un fișier care lipsește. include required(...) îl refuză și îl numește:

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

--rpc-table scrie în plus rutele rezolvate ca JSON, câte un obiect pe linie, cu serviciul, metoda, acțiunea, rapoartele și clusterul, pentru ca un alt program să le citească. În propriul nostru repository, un pas al build-ului scrie rpc-methods.conf din contractele noastre protobuf, iar Proxyfile-ul îl include.

YAML sau HOCON

Ambele forme au aceleași chei, sensuri și reguli și dau aceiași octeți. Alege-o pe cea pe care echipa ta o editează mai ușor.

YAML vine cu un JSON Schema, așa că un editor completează și verifică cheile în timp ce scrii. Pune linia yaml-language-server de mai sus prima în fișier pentru editoarele care folosesc YAML language server, cum e VS Code cu extensia YAML. Într-un IDE JetBrains, adaugă o asociere JSON Schema pentru același URL. proxyfile schema afișează schema, iar fiecare release o conține ca proxyfile.schema.json. Schema descrie doar forma YAML.

HOCON îți dă include-uri și substituții. Un include e citit relativ la fișierul care îl conține. O substituție ca accept = ${auth.send} copiază o valoare a proxyfile-ului în alta. Citește fișierele, niciodată mediul, așa că aceleași fișiere dau aceiași octeți pe orice mașină. Trei reguli de știut: routes e un array, pentru că prima rută care se potrivește câștigă, iar HOCON nu cere unui obiect să-și păstreze ordinea cheilor; o cheie care conține . sau * se pune între ghilimele, ca în "*" = backend; iar un număr între ghilimele e text.

Fișierul gateway-ului nostru e HOCON.

Cum am trecut la proxyfile fără să schimbăm ce face Envoy

Sursa scrisă de mână a gateway-ului nostru e acum un Proxyfile de 169 de linii de HOCON. Dintre ele, 49 sunt comentarii, iar alte 10 au un comentariu lângă valoare. Lângă el stă rpc-methods.conf cu cele 205 metode ale noastre, pe care îl scrie build-ul.

Trecerea nu avea voie să schimbe nicio rută și niciun antet. Așa că a venit cu o dovadă: un test care rulează versiunea publicată proxyfile v1.2.0 pe Proxyfile, parsează în arbori fișierul generat și fișierul pe care îl înlocuiește și cere ca cei doi arbori să fie egali, inclusiv ordinea listelor. Cere și ca tabelul RPC generat să fie cel vechi, octet cu octet. Dovada e temporară. Fișierul vechi a rămas fixture de test până la primul deploy în producție al celui nou, iar acum, când producția rulează fișierul generat, o schimbare ulterioară elimină fixture-ul și testul.

Ce rămâne e verificarea din build. Build-ul descarcă release-ul proxyfile fixat, îl verifică cu fișierul de checksum-uri al release-ului, scrie fișierul de metode și rulează proxyfile render. Un pas al task-ului check din build rulează proxyfile check și eșuează la o editare manuală a envoy.yaml, la o schimbare a Proxyfile-ului fără render și la un fișier de metode editat. Build-ul imaginii rulează apoi envoy --mode validate pe fișierul pe care îl livrează.

Fișierul generat e tot mare: 522.056 de octeți în 1.112 linii, pentru că Envoy are în continuare nevoie de fiecare rută în fiecare mediu. Dar nimeni nu îl editează. Fiecare rută RPC e acum o linie pe mediu, iar cea mai lungă linie are 959 de caractere, așa că un diff al fișierului generat arată rutele care s-au schimbat. Atributele Git îl marchează ca generat, așa că GitHub îl restrânge într-un pull request.

Drumul de la un proxyfile la un Envoy care rulează. Tu editezi proxyfile-ul. proxyfile render scrie envoy.yaml, iar ambele fișiere intră într-un commit în git. În build, proxyfile check generează din nou în memorie și eșuează când envoy.yaml din commit e diferit. În build-ul nostru se construiește apoi imaginea cu fișierul din commit, Envoy îl validează, iar gateway-ul îl rulează.Drumul de la un proxyfile la un Envoy care rulează. Tu editezi proxyfile-ul. proxyfile render scrie envoy.yaml, iar ambele fișiere intră într-un commit în git. În build, proxyfile check generează din nou în memorie și eșuează când envoy.yaml din commit e diferit. În build-ul nostru se construiește apoi imaginea cu fișierul din commit, Envoy îl validează, iar gateway-ul îl rulează.
proxyfile scrie fișiere în git, iar build-ul le verifică. Livrarea fișierului e treaba build-ului, nu a lui proxyfile.

Envoy 1.40 încarcă fiecare exemplu

proxyfile scrie pentru Envoy 1.40. Verificarea obligatorie a repository-ului rulează testele Go. Ele compară arborele scris pentru fiecare exemplu cu unul scris de mână, inclusiv ordinea listelor, țin fișierele din commit egale cu ce scrie render și cer ca formele YAML și HOCON ale fiecărui exemplu să dea aceleași fișiere. Apoi verificarea rulează envoy --mode validate din imaginea Envoy 1.40.2 pe fiecare exemplu generat. Testele dovedesc că fiecare fișier e cel intenționat. Doar Envoy dovedește că se încarcă.

Împreună, exemplele acoperă formatul: examples/full scrie fiecare cheie în afară de address al unui serviciu și de tracing, examples/partial ia cealaltă variantă a fiecărei chei opționale, iar examples/tracing scrie tracing.

Instalare

Fiecare release are un fișier de program pentru Linux și macOS pe amd64 și arm64, cu checksums.txt și proxyfile.schema.json alături. Descarcă-l pe cel pentru sistemul tău, verifică-l și pune-l în 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

Pe un Mac cu Apple silicon, fișierul e proxyfile-darwin-arm64. Într-un Dockerfile, copiază programul din imagine, care conține /proxyfile și nimic altceva:

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

Cu Go instalat:

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

Apoi proxyfile version afișează proxyfile v1.2.0.

Ce nu face proxyfile

  • Scrie configurație pentru Envoy și pentru niciun alt proxy.
  • Scrie fișiere. Nu are un pas de deploy și nu e un control plane, așa că nimic nu se schimbă într-un Envoy care rulează până când build-ul tău nu livrează fișierul nou.
  • Nu primește configurație Envoy brută, cum am văzut mai sus.
  • Presupune că în fața gateway-ului stă un proxy sau un load balancer. Un gateway expus direct internetului are nevoie de o cheie care nu există încă.
  • Un serviciu e o adresă IP, 127.0.0.1 dacă nu scrii alta, nu un nume de host, pentru că Envoy nu caută niciun nume pentru un cluster static.

README-ul din repository conține tot formatul: fiecare cheie, fiecare regulă, fiecare valoare implicită și motivul fiecăreia.

Întrebări frecvente

Face proxyfile deploy la configurație sau reîncarcă Envoy?

Nu. Scrie fișiere. Pui envoy.yaml generat într-un commit lângă proxyfile, iar build-ul tău îl livrează ca pe orice alt fișier. proxyfile nu are un pas de deploy și nu e un control plane.

Pot pune configurație Envoy brută într-un proxyfile?

Nu. O cheie necunoscută, oriunde ar fi, e o problemă, iar mesajul enumeră cheile cunoscute în acel loc. Tot ce nu are o cheie în format e o valoare implicită, aceeași în fiecare gateway pe care îl scrie proxyfile, iar o altă valoare cere o cheie nouă.

Formele YAML și HOCON dau același rezultat?

Da. Fiecare cheie are același nume, același sens și aceleași reguli în ambele, iar fișierul generat e același, octet cu octet. Numele fișierului decide forma, niciodată conținutul. Un fișier numit Proxyfile sau terminat în .conf e HOCON, iar unul terminat în .yaml sau .yml e YAML.

Depinde rezultatul de mașina pe care rulează?

Nu. Aceeași intrare dă mereu aceiași octeți. Substituțiile HOCON citesc doar proxyfile-ul și fișierele incluse de el, niciodată o variabilă de mediu. Un tag de tracing care numește o variabilă de mediu e citit de Envoy când pornește, nu de proxyfile.

Pentru ce versiune de Envoy scrie proxyfile?

Envoy 1.40. Verificarea obligatorie a repository-ului rulează envoy --mode validate din imaginea Envoy 1.40.2 pe fiecare exemplu pe care îl generează.

Pagini similare

Înapoi sus