Pingora
repository·main·Indexed 12 days ago
https://github.com/cloudflare/pingoraA high-performance, programmable Rust framework for building networked systems such as HTTP proxies and load balancers. Used by Cloudflare to handle massive scales of internet traffic, version 0.8.0 provides a suite of crates including pingora-proxy for HTTP proxy logic, pingora-load-balancing for distribution algorithms, and TinyUFO for high-throughput in-memory caching.
What's inside Pingora
- TinyUFO is a high-performance, in-memory cache designed for high throughput and high hit ratios. It implements state-of-the-art algorithms, specifically S3-FIFO and TinyLFU, to optimize cache performance. Its lock-free design allows it to significantly outperform traditional LRU and other TinyLFU-based libraries in terms of operations per second.
How connection pooling and reuse works in Pingora
mainPingora automatically manages a connection pool to improve performance and scalability. When a request to a
Peer(upstream server) is completed, the connection is kept alive and added to a pool for reuse by subsequent requests. This avoids the latency and compute overhead of establishing new connections for every request.Note on Reusability: A connection is considered non-reusable if errors occur during the request.
The Downstream Connection Lifecycle
mainPingora handles incoming connections using a task-per-connection model. When a connection arrives, the service spawns a dedicated task on its runtime. The lifecycle of this task typically follows these stages:
- UninitStream: The connection begins as an uninitialized stream, performing the
::handshake(). - Service Handling: The connection moves to
Service::handle_event()to manage protocol events. - App Logic: The connection is processed by the user-defined application logic via
App::process_new().
The task remains active as long as there are events to handle, reusing the task context to process subsequent events until the connection is closed.
- UninitStream: The connection begins as an uninitialized stream, performing the
How Service Listeners and TransportStacks function
mainAt startup, the
Serverassigns a set of downstream endpoints to eachService. A single service can listen to multiple endpoints.- Endpoints: Defined by address/ports and optional TLS settings.
- TransportStack: Each endpoint is converted into a listening socket known as a
TransportStack. ATransportStackencapsulates the Listener, TLS Acceptor, and UpgradeFDs. - Execution: Each
TransportStackis assigned to an asynchronous task within the service's executor viarun_endpoint().
How the Pingora Server and Services architecture works
mainThe Pingora system is organized into a hierarchy starting with a
Server.- Server: The top-level entity responsible for spawning
Servicesand listening for termination events. When the server receives a termination signal, it propagates it to all active services. - Services: These are the core functional units. A service is tied to a specific protocol and configuration.
Each service operates within its own isolated threadpool or Tokio runtime. Worker threads are not shared between different services. Depending on configuration, a service runtime may use a work-stealing scheduler (Tokio default) or a non-work-stealing, isolated single-threaded runtime.
- Server: The top-level entity responsible for spawning
Customizing HTTP proxying with the ProxyHttp trait
mainThe
HttpProxystruct (frompingora-proxy) manages the high-level HTTP proxying workflow. To customize the behavior of the proxy at various stages of a request's lifecycle, you must implement theProxyHttptrait.Customization points include:
- Request Filtering: Intercepting the initial request.
- Upstream Peer Selection: Determining which upstream server (the Peer) to connect to.
- Upstream Request Filtering: Modifying the request before it is sent to the upstream.
- Upstream Response Filtering: Intercepting the response from the upstream.
- Response Filtering: Modifying the response before it is sent to the downstream client.
- Upstream Response Body Filtering: Modifying the body of the upstream response.
- Response Body Filtering: Modifying the body of the response sent to the downstream.
- Logging: Final stage of the request lifecycle.
How the Pingora Proxy architecture is structured
mainThe Pingora
Serverdoes not have a native concept of a 'Proxy'. Instead, it operates onServices. A proxy is implemented by creating a struct that implements specific traits and is then wrapped in aService.For an HTTP proxy, the hierarchy of responsibility is:
HttpProxy(struct): Handles the high-level proxying workflow. It is customized using theProxyHttptrait.HttpServerApp(trait): Handles protocol-specific details like selecting between H1 and H2 streams and managing H2 handshakes.ServerApp(trait): Handles dispatching application instances as individual tasks per Session.Service<A>(struct): Handles dispatching application instances as individual tasks per Listener.
┌─────────────┐ ┌──────────────────────────────────────┐ │ HttpProxy │ │Handles high level Proxying workflow, │ │ (struct) │─ ─ ─ ─ │ customizable via ProxyHttp trait │ └──────┬──────┘ └──────────────────────────────────────┘ │ ┌──────▼──────┐ ┌──────────────────────────────────────┐ │HttpServerApp│ │ Handles selection of H1 vs H2 stream │ │ (trait) │─ ─ ─ ─ │ handling, incl H2 handshake │ └──────┬──────┘ └──────────────────────────────────────┘ │ ┌──────▼──────┐ ┌──────────────────────────────────────┐ │ ServerApp │ │ Handles dispatching of App instances │ │ (trait) │─ ─ ─ ─ │ as individual tasks, per Session │ └──────┬──────┘ └──────────────────────────────────────┘ │ ┌──────▼──────┐ ┌──────────────────────────────────────┐ │ Service<A> │ │ Handles dispatching of App instances │ │ (struct) │─ ─ ─ ─ │ as individual tasks, per Listener │ └─────────────┘ └──────────────────────────────────────┘Understand the Pingora proxy request lifecycle
mainThe Pingora proxy lifecycle follows a specific sequence of phases for handling a request from inception to completion. When caching is not involved, the flow is divided into request filtering, upstream connection, upstream request handling, and response filtering.
1. Request Phase
- new request: The lifecycle begins when a new request is received.
- early_request_filter: Initial filtering stage.
- request_filter: Main request filtering stage. If a response is sent here, the flow moves directly to logging.
- upstream_peer: The phase where the proxy determines the upstream destination.
2. Upstream Connection & Request Phase
- Connect: The IO operation to connect to the upstream.
- If connection fails and is retryable, it returns to upstream_peer.
- If connection fails and is not retryable, it moves to fail_to_proxy, sends an error response, and goes to logging.
- connected_to_upstream: Successful connection established.
- upstream_request_filter: Filtering applied to the request destined for the upstream.
- request_body_filter: Processing the request body.
- SendReq: The IO operation to send the request to the upstream.
- RecvResp: The IO operation to read the response from the upstream.
3. Response Phase
- adjust_upstream_modules: A feature point for adjusting upstream modules after receiving a response.
- upstream_response_filter: Filtering applied to the upstream response.
- response_filter: Main response filtering stage.
- upstream_response_body_filter: Processing the upstream response body.
- response_body_filter: Final response body processing.
- logging: Final stage where request details are logged before the request is marked as done.
Error Handling
- IOFailure: Occurs during
SendReqorRecvResp. Leads toerror_while_proxy. - error_while_proxy: If the error is retryable, the process returns to upstream_peer. If not, it moves to fail_to_proxy.
- Response Filter Errors: Any error during a response filter leads to
error_while_proxy.
graph TD; start("new request")-->early_request_filter; early_request_filter-->request_filter; request_filter-->upstream_peer; upstream_peer-->Connect{{IO: connect to upstream}}; Connect--connection success-->connected_to_upstream; Connect--connection failure-->fail_to_connect; connected_to_upstream-->upstream_request_filter; upstream_request_filter --> request_body_filter; request_body_filter --> SendReq{{IO: send request to upstream}}; SendReq-->RecvResp{{IO: read response from upstream}}; RecvResp-.feature: adjust_upstream_modules.->adjust_upstream_modules; adjust_upstream_modules-->upstream_response_filter-->response_filter-->upstream_response_body_filter-->response_body_filter-->logging-->endreq("request done"); fail_to_connect --can retry-->upstream_peer; fail_to_connect --can't retry-->fail_to_proxy--send error response-->logging; RecvResp--failure-->IOFailure; SendReq--failure-->IOFailure; error_while_proxy--can retry-->upstream_peer; error_while_proxy--can't retry-->fail_to_proxy; request_filter --send response-->logging Error>any response filter error]-->error_while_proxy IOFailure>IO error]-->error_while_proxyShare state across multiple requests
mainWhile
CTXis used for state scoped to a single request, you can share data across all requests (such as global counters, caches, or shared resources) using standard Rust concurrency primitives.Common patterns include:
- Proxy Struct Members: Storing state within the
MyProxystruct itself (e.g., usingMutex<T>orAtomicUsize). - Global Statics: Using
staticvariables with synchronization (e.g.,static COUNTER: Mutex<usize>). - Shared Ownership: Using
Arcto share data across threads/requests.
Because these resources are accessed concurrently by multiple requests, you must use thread-safe types like
Mutex,RwLock, orAtomictypes to prevent data races.// Example: Using a global static and a struct member for cross-request state static REQ_COUNTER: Mutex<usize> = Mutex::new(0); pub struct MyProxy { beta_counter: Mutex<usize>, } #[async_trait] impl ProxyHttp for MyProxy { type CTX = MyCtx; // ... async fn upstream_peer( &self, _session: &mut Session, ctx: &mut Self::CTX, ) -> Result<Box<HttpPeer>> { // Increment global counter let mut req_counter = REQ_COUNTER.lock().unwrap(); *req_counter += 1; // Increment per-proxy member counter let mut beta_count = self.beta_counter.lock().unwrap(); *beta_count += 1; // ... } }- Proxy Struct Members: Storing state within the
How Upstream connections are managed with Connectors
mainWhile
Listenershandle incoming Downstream connections (client to proxy),Connectorshandle outgoing connections to Upstream Peers (proxy to server).In Pingora, a
Connectoris a pattern responsible for:- Establishing connections with a Peer.
- Connection Pooling: Maintaining a pool to allow reuse across multiple requests from the same or different downstream clients.
- Health Monitoring: Measuring connection health (e.g., performing regular pings for H2).
- Protocol Handling: Managing protocols with multiple poolable layers (like H2).
- Optimization: Handling caching and compression if relevant to the protocol and enabled.
Configure HTTP upstream request header policy
mainPingora's default behavior is to strip downstream hop-by-hop request fields and fields nominated by the
Connectionheader before forwarding to an HTTP upstream. This ensures RFC compliance and prevents routing metadata leakage.Header Policy Modes
1. Preserve all behavior (RFC-non-compliant)
To bypass all stripping and use the previous passthrough behavior, use
HttpUpstreamRequestPolicy::preserve(). Warning: You are responsible for all valid hop-by-hop handling in yourupstream_request_filter().2. Retain only
Connection-nominated fieldsTo keep the default behavior but stop stripping fields that are explicitly nominated in the
Connectionheader, setstrip_connection_nominated = false.3. Preserve HTTP/1 upgrade handshakes
To preserve complete HTTP/1 upgrade handshakes (including all metadata) while maintaining ordinary request normalization, set the
h1_upgradepolicy toH1UpgradePolicy::Preserve. Warning: This is RFC-non-compliant.Security Constraints
When
strip_connection_nominatedis enabled, Pingora rejects requests ifConnectionnominates sensitive routing headers likeHost,X-Forwarded-For,X-Forwarded-Host,X-Forwarded-Proto, or pseudo-headers like:authority. It also rejects requests with ten or more connection nominations.// Preserve all passthrough behavior (RFC-non-compliant) use pingora_core::upstreams::peer::HttpUpstreamRequestPolicy; peer.options.http_upstream_request_policy = HttpUpstreamRequestPolicy::preserve(); // Retain only fields nominated in Connection peer.options .http_upstream_request_policy .strip_connection_nominated = false; // Preserve complete HTTP/1 upgrade handshakes (RFC-non-compliant) use pingora_core::upstreams::peer::H1UpgradePolicy; peer.options.http_upstream_request_policy.h1_upgrade = H1UpgradePolicy::Preserve;Understand panic behavior and best practices
mainIn Pingora, a panic is isolated to the specific request that triggered it.
- Isolation: A panicking request does not crash the server or affect other ongoing requests.
- Resource Cleanup: Sockets acquired by the panicking request are automatically dropped (closed).
- Runtime Handling: Panics are captured by the
tokioruntime and ignored to prevent server failure.
Best Practice: Do not use panics for expected failure modes like network timeouts. Panics should be reserved exclusively for unexpected logic errors.