Transferify
Înapoi la blog

Cum transformă Envoy apelurile Connect ale browserului în gRPC

Aplicația web Transferify comunică cu backend-urile ei prin protocolul Connect. Backend-urile servesc gRPC simplu. Niciuna dintre părți nu trebuie să înțeleagă protocolul celeilalte, pentru că Envoy, care rulează în fața fiecărui backend, traduce între ele.

Articolul acesta explică cum decide Envoy că o cerere e un apel Connect, cum pornim traducerea doar pentru rutele RPC, ce devine fiecare antet și fiecare octet la intrare și la ieșire, și arată un tabel în care Envoy și specificația Connect de astăzi nu se potrivesc. Fiecare detaliu de mai jos a fost citit din codul sursă al Envoy 1.40.2 și din specificația Connect.

Drumul unui apel Connect. Browserul trimite o cerere Connect către Envoy. În Envoy, cererea trece prin ext_authz, care verifică cererea originală, apoi prin puntea Connect-gRPC, care o transformă în gRPC, apoi prin router, care o trimite serviciului backend care servește acel RPC. Răspunsul se întoarce prin punte, care îl transformă într-un răspuns Connect.Drumul unui apel Connect. Browserul trimite o cerere Connect către Envoy. În Envoy, cererea trece prin ext_authz, care verifică cererea originală, apoi prin puntea Connect-gRPC, care o transformă în gRPC, apoi prin router, care o trimite serviciului backend care servește acel RPC. Răspunsul se întoarce prin punte, care îl transformă într-un răspuns Connect.
Un apel Connect. Autorizarea verifică cererea așa cum a trimis-o browserul; apoi puntea o transformă în gRPC pentru backend și transformă înapoi în Connect răspunsul gRPC al backend-ului.

Ce este puntea

envoy.filters.http.connect_grpc_bridge e un filtru HTTP din Envoy. Browserul vorbește Connect, iar backend-urile servesc gRPC; filtrul rescrie cererile și răspunsurile între cele două. Așa că backend-urile nu au nevoie de o bibliotecă Connect, iar browserul nu are nevoie de gRPC-Web.

Envoy tratează o cerere ca apel Connect când oricare dintre aceste condiții e adevărată:

  • Tipul de conținut e application/connect+<codec>. E un apel de streaming.
  • Cererea are un antet Connect-Protocol-Version și un tip de conținut application/.... E un apel unar.
  • Query-ul conține connect=v1. E un GET Connect, pe care Envoy îl transformă într-un POST.

Orice altă cerere trece prin filtru neschimbată.

Cum pornim puntea

Filtrul e configurat cu disabled: true pentru întregul listener și e pornit pe fiecare rută cu typed_per_filter_config, doar pe rutele RPC. O rută REST nu trece niciodată prin punte, chiar dacă o cerere către ea are antete Connect sau ?connect=v1.

Filtrele rulează în această ordine: ext_authz, apoi puntea, apoi routerul. Așa că autorizarea verifică cererea Connect originală, iar puntea traduce doar cereri deja autorizate.

Există câte o rută pentru fiecare RPC, potrivită pe calea exactă /<package>.<Service>/<Method> și generată din contractele noastre protobuf. Rutele fiecărui pachet merg către serviciul backend care îl implementează.

Envoy marchează această extensie ca alpha: „functional but has not had substantial production burn time” (funcțională, dar încă puțin folosită în producție). Am acceptat asta și am verificat-o pe un singur endpoint în producție înainte să mutăm pe ea orice alt RPC.

De ce Connect și nu gRPC-Web

Ambele variante au nevoie de un traducător în Envoy, connect_grpc_bridge sau grpc_web, și ambele folosesc aceiași clienți protobuf-es generați. Doar o linie din aplicația web diferă: createConnectTransport sau createGrpcWebTransport. Diferența e în ce circulă prin rețea.

  • Un apel Connect unar e HTTP obișnuit. Corpul conține doar mesajul, iar o eroare se întoarce cu un status HTTP real și un corp JSON pe care îl poți citi în devtools din browser. Cu gRPC-Web fiecare apel e împachetat în cadre, statusul e mereu 200, iar rezultatul circulă în antete grpc-status sau într-un cadru de trailer din interiorul corpului.
  • Jurnalele de acces și alarmele citesc statusul HTTP. Puntea scrie statusul erorii, așa că un RPC eșuat e înregistrat și numărat ca un apel REST eșuat. Cu gRPC-Web, fiecare RPC eșuat ar fi înregistrat ca 200.
  • CORS cere mai puțină configurare. gRPC-Web are nevoie ca Grpc-Status, Grpc-Message și Grpc-Status-Details-Bin să fie expuse paginii; Connect nu are nevoie de nimic nou.
  • Connect acceptă și GET (?connect=v1), care poate fi pus în cache, pentru metodele fără efecte secundare.
  • connect-web folosește implicit protocolul Connect, iar documentația lui recomandă gRPC-Web doar când backend-ul nu poate vorbi Connect.

Nu am păstrat pregătită o variantă de rezervă cu gRPC-Web: ar fi însemnat un al doilea filtru, trei antete CORS, o ramură în aplicația web și un test, toate nefolosite. Dacă puntea se poartă vreodată greșit, trecerea costă cam o zi de lucru, pe aceiași clienți generați.

Prețul alegerii e JSON-ul. Puntea nu traduce corpurile JSON: redenumește application/json în application/grpc+json și trimite octeții mai departe neschimbați, iar grpc-java citește doar protobuf binar. Așa că aplicația web folosește codecul binar și doar corpurile erorilor se pot citi în devtools.

Cererea, câmp cu câmp

O cerere Connect unară devine o cerere gRPC către backend, prin HTTP/2 fără TLS (h2c):

Connect, de la browsergRPC, către backend
POST /pkg.Svc/MethodPOST /pkg.Svc/Method
Content-Type: application/protoContent-Type: application/grpc+proto
Connect-Protocol-Version: 1eliminat
Connect-Timeout-Ms: 15000grpc-timeout: 15000m
Content-Encoding: gzipgrpc-encoding: gzip
Accept-Encoding: gzipgrpc-accept-encoding: gzip
niciunulTE: trailers
Content-Lengtheliminat
mesajulantetul de cadru + mesajul
Authorization și alte antetela fel, ca metadate gRPC

Puntea adună tot corpul într-un buffer, apoi pune în fața lui antetul de cadru gRPC de 5 octeți: un octet de flag, apoi lungimea mesajului pe 4 octeți, big-endian. Când corpul e comprimat, octetul de flag arată asta. Un timeout prea lung pentru 8 cifre de milisecunde trece la secunde, minute sau ore (S, M, H).

Un GET Connect, GET /pkg.Svc/Method?connect=v1&encoding=proto&base64=1&compression=...&message=<base64url>, devine un POST. Query-ul e eliminat, message e decodat în corpul cu antet de cadru, encoding stabilește tipul de conținut application/grpc+<encoding>, iar compression stabilește grpc-encoding (identity când lipsește).

Răspunsul, câmp cu câmp

Un răspuns gRPC reușit devine un răspuns Connect:

gRPC, de la backendConnect, către browser
status 200status 200
content-type: application/grpc+protocontent-type: application/proto
antetul de cadru + mesajulmesajul
grpc-encoding: gzipContent-Encoding: gzip
trailer grpc-status: 0omis
alte antete și trailere grpc-*omise
trailer foo: barantet trailer-foo: bar

Puntea adună tot răspunsul într-un buffer înainte să elimine antetul de cadru și scoate din nou Content-Encoding când cadrul nu a fost comprimat.

O eroare e convertită în același fel. grpc-java trimite un apel eșuat doar ca trailere:

Trailere gRPC, de la backendRăspuns Connect, către browser
grpc-status: 7status 403, "code": "permission_denied"
grpc-message: not allowed"message": "not allowed"
grpc-status-details-bin: <base64>"details": [{"type": ..., "value": "<base64>"}]

Tipul de conținut al răspunsului e application/json, iar corpul lui arată așa:

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

type e URL-ul tipului, exact cum l-a scris serverul. Envoy nu scrie niciun câmp debug.

Există două limite. O cerere unară mai mare decât limita de buffer a Envoy primește un 413 local, iar un răspuns mai mare decât ea, un 500. Iar un răspuns care nu e deloc gRPC, de exemplu un 404 de la backend pentru un RPC pe care nu îl servește, trece neschimbat; clientul connect-web, nu puntea, transformă acel HTTP 404 în unimplemented.

Tabelul de statusuri din Envoy e mai vechi decât cel din Connect

Pentru patru coduri de eroare, Envoy 1.40.2 răspunde cu alt status HTTP decât cel pe care îl indică astăzi specificația Connect:

CodEnvoy 1.40.2Specificația Connect astăzi
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 adăugat filtrul în februarie 2023 (envoyproxy/envoy#24836) și a copiat tabelul Connect așa cum era atunci. Connect a schimbat aceste patru rânduri pe 11 martie 2024 (connectrpc.com#130). Două zile mai târziu a relaxat regula: statusul HTTP nu mai „must match” (trebuie să corespundă) codului de eroare, ci „should match” (ar trebui să corespundă) (connectrpc.com#149), pentru că clienții citesc codul din corpul JSON și folosesc statusul HTTP doar când nu există corp. Corespondența din Envoy nu s-a schimbat de atunci, așa că e încă în limitele specificației, doar mai veche.

Clienții nu sunt afectați, pentru că citesc codul din corp. Tot ce citește doar statusul HTTP, de exemplu un jurnal de acces sau o alarmă pe răspunsurile 5xx, vede un 4xx acolo unde specificația de astăzi spune 5xx: un termen-limită depășit arată ca o eroare a clientului.

Un gateway poate corecta asta în două locuri:

  • În Envoy însuși: tabelul se află într-o singură funcție, statusCodeToConnectUnaryStatus, așa că actualizarea celor patru rânduri e o schimbare mică în filtru.
  • În configurația gateway-ului: un filtru listat înaintea punții vede răspunsul după ce puntea l-a convertit, așa că poate citi code din corpul JSON al erorii și poate seta statusul HTTP după tabelul actual. Trebuie să citească corpul, pentru că statusul singur nu poate deosebi canceled de deadline_exceeded: Envoy răspunde cu 408 pentru amândouă.

Întrebări frecvente

Au nevoie backend-urile de o bibliotecă Connect?

Nu. Ele servesc gRPC simplu. Filtrul connect_grpc_bridge din Envoy rescrie fiecare cerere Connect într-o cerere gRPC la intrare, iar răspunsul gRPC într-un răspuns Connect la ieșire.

De ce Connect și nu gRPC-Web?

Amândouă au nevoie de un traducător în Envoy și amândouă folosesc aceiași clienți generați. Diferența e în ce circulă prin rețea. Un apel Connect unar e HTTP obișnuit, așa că un apel eșuat se întoarce cu un status HTTP real și un corp JSON pe care jurnalele, alarmele și devtools din browser le pot citi. Cu gRPC-Web fiecare apel răspunde cu 200, iar rezultatul circulă în interiorul corpului.

Poate puntea să traducă corpurile JSON ale cererilor?

Nu. Redenumește application/json în application/grpc+json și trimite octeții mai departe neschimbați, iar un server gRPC care citește doar protobuf binar, cum e grpc-java, nu îi poate citi. Așa că browserul trimite protobuf binar și doar corpurile erorilor sunt JSON.

Funcționează o cerere GET Connect prin punte?

Da. Un GET al cărui query conține connect=v1 e transformat într-un POST. Envoy decodează mesajul din query în corpul cererii și elimină query-ul.

Înapoi sus