Pingora: zpracování požadavku a vlastní logika proxy

Dnes
Doba čtení: 9 minut

Sdílet

Pingora
Autor: Cloudflare
V minulém díle jsme si představili projekt Pingora a skončili kompilací první minimalistické proxy. Dnes se podíváme na její základní architekturu a ukážeme si, jak funguje životní cyklus požadavku a jak proxy rozšířit.

Architektura

Co se dozvíte v článku
  1. Architektura
  2. Životní cyklus HTTP požadavku
  3. Kontext
  4. Příklad: základní klasifikace provozu dle klientské IP
  5. Odkazy na internetu a zdroje

Základem Pingory je server, který obsluhuje jednu nebo více služeb. Služba může být přímo odpovědná za zpracování příchozích požadavků, ale může také řešit pomocné úlohy mimo hlavní zpracování požadavku — například aktualizaci pravidel WAF, objevování upstreamových serverů a podobně. Samozřejmě může jít i o kombinaci obojího.

Pojďme si připomenout funkci main() z příkladu uvedeného v minulém díle:

use async_trait::async_trait;
use pingora::prelude::*;
use pingora_load_balancing::LoadBalancer;
use pingora_load_balancing::prelude::RoundRobin;
use pingora_proxy::{ ProxyHttp, Session, http_proxy_service};
use std::sync::Arc;

pub struct LB(Arc<LoadBalancer<RoundRobin>>);

fn main() {

    // Bootstrap pingora serveru
    let mut my_server = Server::new(None).unwrap();
    my_server.bootstrap();

    // Definice upstreamu pro naši http proxy službu
    let upstreams = match LoadBalancer::<RoundRobin>::try_from_iter(["127.0.0.1:8080"]) {
        Ok(upstreams) => Arc::new(upstreams),
        Err(e) => {
            eprintln!("Failed to create load balancer: {e}");
            return;
        }
    };

    // Samotné vytvoření služby
    let mut lb = http_proxy_service(&my_server.configuration, LB(Arc::clone(&upstreams)));
    lb.add_tcp("127.0.0.1:8000");

    // a její přidání do našeho serveru
    my_server.add_service(lb);

    // spuštění serveru
    my_server.run_forever();
}

Nejprve vytvoříme server my_server a provedeme jeho inicializaci pomocí funkce bootstrap(). Následně přidáme službu lb, která bude zodpovědná za zpracování HTTP požadavků.

Ta potřebuje dvě věci:

  • globální konfiguraci serveru,
  • upstream, na který se budou požadavky přeposílat.

Posledním krokem je registrace služby do serveru a spuštění hlavní runtimové smyčky serveru pomocí funkce run_forever(), která převezme řízení hlavního vlákna a běží, dokud není server korektně ukončen.

Možná vás při čtení kódu napadne, proč při inicializaci služby používáme:

let mut lb = http_proxy_service(&my_server.configuration, LB(Arc::clone(&upstreams)));

a nestačí jednodušší zápis:

let mut lb = http_proxy_service(&my_server.configuration, Arc::clone(&upstreams));

Důvod je jednoduchý. Funkce http_proxy_service() je definována následovně:

pub fn http_proxy_service<SV>(conf: &Arc<ServerConf>, inner: SV) -> Service<HttpProxy<SV, ()>>
where
    SV: ProxyHttp

Jinými slovy — potřebujeme typ, který splňuje trait  ProxyHttp.

Běžný Arc><...> tento trait samozřejmě neimplementuje a my jej navíc nemůžeme doplnit přímo, protože Arc pochází ze standardní knihovny a nevlastníme jej — Rust neumožňuje přidávat cizí trait na cizí typ (tzv. orphan rule).

Proto si vytvoříme vlastní wrapper:

pub struct LB(...)

Nad tímto typem následně doplníme trait ProxyHttp. Wrapper LB tak bude reprezentovat chování proxy služby v rámci životního cyklu požadavku.

Další věcí, které si pravděpodobně všimnou ostřílení rustoví vývojáři, je absence jakékoliv inicializace async frameworku Tokio, i když jsme ho v rámci řešení závislostí balíčků (viz minulý díl) instalovali. Většinou totiž v podobných typech aplikací můžeme vidět v úvodní main.rs něco jako:

#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
    .
    .
}

Pingora typicky inicializuje Tokio runtime interně, takže běžná aplikace nepotřebuje explicitní  #[tokio::main].

Životní cyklus HTTP požadavku

Jak jsme si naznačili v předchozí kapitole, životní cyklus požadavku je v Pingoře modelován pomocí traitu ProxyHttp. Ten musíme navázat na konkrétní typ, který bude reprezentovat chování naší proxy služby.

V našem případě jsme zvolili jednoduchý wrapper:

pub struct LB(Arc<LoadBalancer<RoundRobin>>);

Jde o takzvanou tuple struct, která obsahuje jedinou položku — sdílený odkaz (Arc) na load balancer.

Typ Arc zde používáme proto, že stejná instance load balanceru může být současně sdílena mezi paralelně obsluhovanými požadavky / tasky.

Rust přitom už při kompilaci kontroluje pravidla vlastnictví, sdílení a přístupu k datům. Programátor tak musí explicitně řešit životnost a bezpečné sdílení hodnot mezi jednotlivými částmi aplikace.

Trait ProxyHttp je definován přibližně takto:

pub trait ProxyHttp {
    type CTX;

    // Required methods
    fn new_ctx(&self) -> Self::CTX;

    async fn upstream_peer(
        &self,
        session: &mut Session,
        ctx: &mut Self::CTX,
    ) -> Result<Box<HttpPeer>>;
    .
    .
    fn init_downstream_modules(&self, modules: &mut HttpModules) {
    .

Pro svou proxy potřebujeme přidat pouze dvě povinné metody:

  • new_ctx()  — vytvoří request-local context pro konkrétní HTTP požadavek,
  • upstream_peer()  — asynchronně vybere a vrátí upstreamový server, na který bude požadavek směrován.

Mnoho callbacků má výchozí implementaci a přepisujeme jen ty, které potřebujeme. Na rozdíl od mnoha moderních web frameworků totiž Pingora nepoužívá klasický middleware chain, ale spíše model založený na callback fázích podobný Nginxu.

Jednotlivé fáze zpracování tak reprezentují callbacky, které framework volá v konkrétních okamžicích životního cyklu požadavku.

V našem případě vypadal trait ProxyHttp následovně:

#[async_trait]
impl ProxyHttp for LB {

    type CTX = ();

    fn new_ctx(&self) -> Self::CTX {
        ()
    }

    async fn upstream_peer( &self, _session: &mut Session, _ctx: &mut Self::CTX) -> Result<Box<HttpPeer>> {

        let upstream = match self.0.select(b"", 256) {
            Some(upstream) => upstream,
            None => return Err(Error::new(ErrorType::HTTPStatus(504)))
        };

        println!("upstream peer is: {upstream:?}");

        let peer = Box::new(HttpPeer::new(upstream, false, "".to_string()));
        Ok(peer)
    }
}

Kontext CTX představuje stav vytvořený pro konkrétní HTTP požadavek, který je následně předáván jednotlivým fázím během jeho životního cyklu. Naše proxy pouze přeposílá požadavky na upstreamový server, takže si vystačíme s prázdným obsahem:

    type CTX = ();

    fn new_ctx(&self) -> Self::CTX {
        ()
    }

Funkce upstream_peer() má k dispozici odkaz na naši proxy službu ( &self), aktuální Session a mutable referenci na request-local kontext CTX. Session reprezentuje downstreamové HTTP spojení a aktuálně zpracovávaný request context. Přes ni se můžeme dostat k požadavku, hlavičkám, URI, tělu požadavku a dalším informacím, které mohou ovlivnit výběr upstreamu.

V našem jednoduchém příkladu žádná data z požadavku nepotřebujeme. Z load balanceru pouze vybereme (jeden) upstream:

        let upstream = match self.0.select(b"", 256) {
            Some(upstream) => upstream,
            None => return Err(Error::new(ErrorType::HTTPStatus(504)))
        };

Použijeme metodu select(), kde pro jednoduchost používáme prázdný hash key. Druhý parametr je počet iterací, který se používá u některých algoritmů pro loadbalancer a pro tento příklad ho můžeme opomenout. Metoda select() vrací Option, protože teoreticky nemusí být žádný backend k dispozici. V takovém případě vrátíme klientovi chybu s HTTP kódem 504.

Pokud upstream najdeme, vytvoříme z něj objekt typu HttpPeer. Ten neobsahuje odpověď klientovi, ale popis cíle, kam má Pingora požadavek přeposlat — tedy adresu upstreamu, informaci o TLS a případné SNI. Framework zároveň interně řeší pooling a znovupoužití upstreamu, takže vytvoření HttpPeer neznamená nutně otevření nového TCP spojení.

        let peer = Box::new(HttpPeer::new(upstream, false, "".to_string()));
        Ok(peer)

Kontext

Několikrát jsme se tu zmínili, že Pingora se v návrhu životního cyklu požadavku inspirovala modelem známým z Nginxu/OpenResty. Máme fáze zpracování, které reprezentují jednotlivé callbacky a jejichž implementace je ve většině případů nepovinná. Často ale potřebujeme držet stav, který inicializujeme hned na začátku zpracování a který je pak k dispozici pro každou jednotlivou fázi. Ty ho mohou číst, měnit nebo se dle něj rozhodovat — a můžeme v něm držet třeba informace týkající se naší infrastruktury, požadavku samotného, případně flow.

Pingora proto používá tzv. per-request context, který inicializujeme v povinné metodě new_ctx() traitu ProxyHttp. Mutable referenci na tento kontext pak dostává každý callback v celém zpracování požadavku. Kontext existuje pouze po dobu zpracování konkrétního požadavku a po dokončení je zahozen.

Všimněme si také, že kontext není součástí relace nebo typu LB, ale je to svébytný objekt. Z hlediska architektury jsou tak oddělené stavy transportní vrstvy, samotné proxy a aplikačního stavu konkrétního požadavku.

U jednoduché proxy může být kontext prázdný (jako v našem úvodním příkladu):

type CTX = ();

U pokročilejší služby ale může jít o vlastní strukturu s více poli.

Příklad: základní klasifikace provozu dle klientské IP

Pojďme se teď podívat na konkrétní využití kontextu v praxi a rozšiřme si proxy o další funkce. Demonstrujeme si na krátkém příkladě, jak se kontext chová, a také rozšíříme naši proxy o další dva callbacky. Celé zpracování požadavku v krátkosti shrnuje následující diagram. Pro jednoduchost budeme veškerý kód opět psát přímo do  main.rs.

Pingora diagram

Autor: Jan Dvořák

Poznámka: Náš diagram pro jednoduchost zobrazuje pouze tři fáze které používáme plus inicializaci kontextu. Pingora jich nabízí mnohem více, odkaz na kompletní flow je v sekci odkazů.

Představme si, že bychom rádi klasifikovali každý HTTP požadavek na základě IP adresy klienta a dle této klasifikace mohli dále aplikovat různé filtry, rate-limiting apod. V našem případě se spokojíme s vytvořením nové hlavičky X-Zone a přeposláním na klienta. Pro zjednodušení budeme uvažovat pouze o IPv4 klientech, IPv6 klienti budou klasifikováni výchozí hodnotou.

Požadavky dle IP rozdělíme do zón dle následujících kritérií:

  • localhost: klienti přicházející z loopbacku
  • private: klienti kteří volají API z privátních rozsahů
  • podnet: klienti nebo služby, kteří naši proxy volají z definované Kubernetes sítě (definujeme si jeden rozsah reprezentující virtuální síť podů v rámci Kubernetes)
  • public: klienti volající naši proxy z veřejných rozsahů (z internetu)
  • unknown: výchozí hodnota, klient nebyl nijak klasifikován

Teoreticky bychom si mohli vytvořit pomocnou funkci, která převede IP na zónu a kterou voláme pokaždé, kdy to potřebujeme. Vhodnější ale bude určit zónu pouze na začátku zpracování a uložit ji právě do kontextu.

Definice zón

Zóny bude nejlépe reprezentovat výčtový typ (enum), který hned definujeme:

#[derive(Debug)]
pub enum Zone {
    Localhost,
    Private,
    Public,
    PodNet,
    Unknown
}

Unknown reprezentuje iniciální hodnotu nezařazeného HTTP požadavku, případně takový požadavek, který nebyl poslán v rámci protokolu IPv4. Typ Zone také obalíme makrem Debug, abychom mohli jednoduše logovat jeho hodnoty.

Dále potřebujeme typ, který bude reprezentovat celý náš kontext. Prozatím půjde o jednoduchou strukturu s jediným polem zone, kterou můžeme později snadno rozšířit.

#[derive(Debug)]
pub struct Context {
    zone: Zone,
}

Všimněme si, že naši strukturu Context nikde neobalujeme typem Arc případně Mutex, tak jako to bylo v případě typu LB! Důvod je prostý: kontext není sdílen mezi paralelně běžícími požadavky. V rámci jednoho zpracování je předáván jednotlivým callbackům jako mutable reference. Typový systém Rustu zajišťuje, že mutable reference ke kontextu nemůže být současně použita z více míst.

Klasifikace v rámci ProxyHttp workflow

Nyní máme všechny základní typy připravené a můžeme svůj kontext inicializovat. Nejprve změníme asociovaný typ traitu z prázdné hodnoty na naši strukturu Context. Dále v rámci implementace povinné funkce traitu new_ctx() vytvoříme jeho novou instanci a zone naplníme hodnotou  Zone::Unknown.

#[async_trait]
impl ProxyHttp for LB {

    type CTX = Context;

    fn new_ctx(&self) -> Self::CTX {
        Context { zone: Zone::Unknown }
    }
    .
    .

Pingora tedy před vlastním spuštěním zpracování požadavku zavolá v rámci traitu metodu new_ctx() a vytvoří novou instanci struktury Context. V callbackových funkcích pak můžeme psát například:

    async fn request_filter(&self, session: &mut Session,  ctx: &mut Self::CTX) -> Result {
.
.
        ctx.zone = Zone::from(client_ip);
.

Poznámka: Zone::from zde není definovaný — implementaci si ukážeme v dalším díle. Slouží jen jako ukázka, jak se dostat k obsahu kontextu.

Školení Kubernetes

V dnešním díle jsme si důkladně prošli příklad z minulého dílu a vysvětlili si některá specifika návrhu a idiomatické vzory jazyka Rust — od orphan rule přes absenci #[tokio::main] až po roli wrapperu LB.

Ukázali jsme si také, jak Pingora modeluje životní cyklus požadavku pomocí traitu ProxyHttp a jak do tohoto modelu zapadá per-request kontext. Na závěr jsme si připravili půdu pro praktický příklad: definovali jsme typy Zone a Context a naznačili, jak kontext inicializovat a předávat jednotlivým fázím zpracování. V příštím díle klasifikaci dokončíme — ukážeme si implementaci request_filter(), konverzi IP adresy na zónu a celý příklad si zkompilujeme a otestujeme.

Odkazy na internetu a zdroje

  1. Rust orphan rule
  2. Arc type
  3. Tokio examples
  4. Pingora fáze zpracování
  5. Pingora ProxyHttp trait
Neutrální ikona do widgetu na odběr článků ze seriálů

Zajímá vás toto téma? Chcete se o něm dozvědět víc?

Objednejte si upozornění na nově vydané články do vašeho mailu. Žádný článek vám tak neuteče.


Autor článku

Pracuje jako nezávislý systémový inženýr a konzultant. V rámci své praxe se podílí na projektech v enterprise prostředí, kde se věnuje především síťování, bezpečnosti a integraci identit.