QIAOPANG.COM · SELECTED ESSAYS (2013–2026) · 260 ESSAYSBrowse Chronologically (2013 → 2026) ↗
QiaoPang · Programmers LifeSelected Essays · Systems, Craft & Agentic Era
Timeline ↗

Abandoning Multi-Environment Entirely: Reshaping the Next-Generation Serverless Architecture with Rust and Pingora

Abandoning Multi-Environment Entirely: Reshaping the Next-Generation Serverless Architecture with Rust and Pingora

Great knowledge is broad and unhurried; small knowledge is narrow and fussy. Great speech blazes like fire; small speech natters on. — Zhuangzi, The Sorting Which Evens Things Out

Preface: In last month’s essay, I explained why I designed an enterprise-grade K8s architecture for my own “one-person company” — and then personally stood it up and abandoned it. The subtext of that essay was: K8s is too heavy for an independent developer. But if we step up one level in perspective, the problem isn’t just that it’s “heavy” — it’s that the entire paradigm may have gone off track.

This essay is not a tutorial, not a best-practices guide, but a thought experiment. I want to overturn every core assumption of the past decade of backend infrastructure and re-derive from first principles: if I were designing, from scratch, an infrastructure to support tens of millions of daily active users today, with no historical baggage, what should it look like?

The answer may make you uncomfortable.

1. Multi-Environment Is an Illusion

Every backend engineer has been through this pipeline: Dev → Staging → Preprod → Prod. Two or three environments, twice or three times the machine cost and operational overhead. You test on Staging for three days, roll confidently into Prod, and then a race condition that only triggers under real traffic forces you up at 3 a.m. to fix a bug.

This is not an isolated case. It’s a structural problem.

The industry gave it a nice-sounding name: Environment Drift. It sounds like a minor ailment that can be eliminated through stricter configuration management. But if you think carefully, you’ll see that environment drift isn’t a bug — it’s a feature. It’s the logical inevitability of multi-environment architecture.

Why? Because the difference between Staging and Prod is not just a few variables in a config file. What truly keeps two environments out of alignment is the stuff that only emerges in the real world:

  • The dirt-data distribution generated by real users (think of those UTF-8 encoding errors, emojis, zero-width characters)
  • The temporal pattern of real traffic (the QPS curve at lunchtime peak is completely different from that at 3 a.m.)
  • The real latency and error rate of third-party APIs
  • The real database lock contention patterns (your Staging DB has a hundred thousand rows; Prod has a billion)

You can’t “simulate” these things in Staging. Simulation itself is a distortion. The more faithful the simulation, the higher the cost; the higher the cost, the less motivated you are to update it frequently; the less it’s updated, the greater the drift. This is a vicious circle in which the harder you try, the further off-course you go.

Abandoning Multi-Environment Entirely: Reshaping the Next-Generation Serverless Architecture with Rust and Pingora — architecture diagram

The industry’s veneration of “Testing in Production” is a direct response to this dilemma. But in traditional practice, Testing in Prod means canary releases, percentage-based traffic splitting, feature flags — all efforts to contain the blast radius. The problem is: narrowing it down isn’t the same as eliminating it. Route 1% of traffic to a buggy new version, and if your DAU is ten million, that’s a hundred thousand users getting hit.

What if there were a way to test in production, but with an impact scope precise to a single request?

That’s the first core concept we need to discuss: request-level routing isolation based on Baggage Headers.

2. Baggage Header: The Genetic Sequence of Traffic

The OpenTelemetry spec contains a rather unassuming but extraordinarily powerful concept: Baggage. It lets you attach arbitrary key-value pairs to HTTP/gRPC request headers, and those pairs propagate automatically along the entire call chain.

Most teams use Baggage to pass distributed-tracing context. But if we push its use to the extreme — using Baggage to decide which version of code should handle a given request — the entire multi-environment architecture can be killed.

Physically Unified, Logically Isolated

Imagine a request header like this:

GET /api/v1/recommendations HTTP/1.1
Host: api.example.com
Baggage: x-env=pr-1234,x-traffic-type=shadow,x-tenant=acme-corp

This request carries three pieces of “genetic information”:

  • x-env=pr-1234: I want to hit the code version corresponding to PR #1234
  • x-traffic-type=shadow: I’m shadow traffic; if something breaks it doesn’t affect real users
  • x-tenant=acme-corp: I belong to the acme-corp tenant

When this request enters the system, there’s no need for any separate Staging environment. On the physical nodes, there’s no distinction between “test servers” and “production servers”. The system dynamically routes the request to the corresponding code version based on the Baggage.

What does that mean? You can run thousands of “logical environments” simultaneously in production, each one being a PR, a feature branch, or an A/B test variant. They share the same physical infrastructure, receive real production traffic, and yet are completely isolated from one another.

Abandoning Multi-Environment Entirely: Reshaping the Next-Generation Serverless Architecture with Rust and Pingora — architecture diagram

Full-Chain Staining: How Baggage Doesn’t Get Lost

The real power of Baggage lies in automatic propagation. A request enters through the API Gateway, passes through the user service, recommendation service, search service, and finally reaches the database — every hop automatically carries the Baggage. This means every service along the whole chain knows which “logical environment” it’s serving.

In the OpenTelemetry spec, this is called Context Propagation. But we need to go further than the standard. Standard Baggage propagation relies on application-layer HTTP Client libraries (like reqwest, hyper) or gRPC frameworks injecting it automatically. In our architecture, propagation must traverse not only inter-service calls but also:

  1. Message queues: producers serialize Baggage into message metadata/headers; consumers deserialize it and restore the context
  2. Async tasks: tasks spawned via tokio::spawn need to inherit the parent task’s Baggage via tracing::Span or a custom TaskLocal
  3. Database connections: the Data Bus layer needs to extract tenant and environment info from the current request’s Baggage to decide routing

Let’s describe this protocol using Rust traits:

/// Any component participating in request handling must implement this trait
pub trait BaggageAware {
    /// Extract Baggage context from an inbound request
    fn extract_baggage(&self, headers: &HeaderMap) -> BaggageContext;

    /// Inject Baggage context into an outbound request
    fn inject_baggage(&self, ctx: &BaggageContext, headers: &mut HeaderMap);

    /// Make routing decisions based on Baggage context
    fn resolve_target(&self, ctx: &BaggageContext) -> RouteDecision;
}

#[derive(Clone, Debug)]
pub struct BaggageContext {
    /// Target environment identifier, e.g. "pr-1234" or "production"
    pub env: EnvironmentId,
    /// Traffic type: normal, shadow, canary
    pub traffic_type: TrafficType,
    /// Tenant identifier, for data-layer isolation
    pub tenant_id: TenantId,
    /// Optional sampling-rate override (for boosting telemetry granularity during debugging)
    pub trace_sample_override: Option<f64>,
}

pub enum RouteDecision {
    /// Route to an existing running instance
    ExistingInstance(InstanceAddr),
    /// Need to cold-start a new instance
    ColdStart { artifact_id: ArtifactId, config: RuntimeConfig },
    /// Fall back to the default production version
    Fallback,
}

Notice this: RouteDecision::ColdStart doesn’t return an error — it triggers an entirely new execution path. Which brings us to the second key question.

3. Variable-TTL Runtime: Redefining Serverless

Traditional Serverless has three fatal problems — slow cold start, blown-up connection pools, and process-local state you can’t hold on to — and all of them trace back to the same flaw: lifecycle management is too crude.

AWS Lambda’s model is: request arrives, pull up container, execute function, destroy. The lifecycle is fully controlled by the platform, and the developer has no say. The direct consequences are three perennial headaches:

  1. Cold start: pulling up a container takes seconds (even longer for JVM runtimes); user-facing APIs simply can’t take it
  2. Connection pools: processes get killed at any moment, TCP connections can’t be reused, and under high concurrency the database is drowned in a flood of SYN packets
  3. Buffered writes become impossible: processes don’t live long enough for monitoring data and instrumentation events to accumulate in memory and be written out in batches, so each event becomes an independent I/O operation

What we need isn’t “serverless” — it’s “servers with adaptive lifecycles”.

Goodbye Docker: Pure Rust Static Binaries

In this architecture, we abandon Docker images entirely. The distributed artifact is a pure Rust executable statically linked with musl.

Why? Let’s do the math:

Metric Docker container (Alpine + Node.js) Docker container (Distroless + Go) Pure Rust musl binary
Artifact size ~150 MB ~30 MB 3-8 MB
Cold start time 800ms - 3s 200-500ms < 5ms
Memory usage (idle) ~60 MB ~15 MB < 2 MB
System calls (boot to ready) ~2000 ~500 < 50
Requires container runtime Yes Yes No

The numbers differ by orders of magnitude. For a 3 MB Rust binary, the time from fork + exec to accepting TCP connections is in the range of microseconds to single-digit milliseconds. No unpacking of image layers, no overlay filesystem, no cgroup initialization — just the OS loading a chunk of machine code into memory and executing it.

The build pipeline looks roughly like this:

# Build step in CI
# Cross-compile for the x86_64-unknown-linux-musl target using the cross toolchain
cross build --release --target x86_64-unknown-linux-musl

# Output: a fully statically linked binary
# No dynamic libc dependency, no .so files, nothing else needed
file target/x86_64-unknown-linux-musl/release/my-service
# my-service: ELF 64-bit LSB executable, x86-64, statically linked

# Further compress with upx (optional, at the cost of a few ms of decompression at startup)
upx --best target/x86_64-unknown-linux-musl/release/my-service
# 8.2 MB -> 2.9 MB

# Upload to S3 or object storage, tagged with the Git SHA
aws s3 cp target/x86_64-unknown-linux-musl/release/my-service \
  s3://artifacts/my-service/$(git rev-parse HEAD)

Firecracker MicroVMs: Millisecond-Level Isolation

Having removed Docker, what about isolation? The answer is Firecracker.

Firecracker is the microVM monitor (MicroVM Monitor) that AWS Lambda and Fargate use under the hood. Written in Rust and open source. The most impressive part: it can boot a complete Linux virtual machine in 125 milliseconds, with about 5 MB of memory overhead.

But 125 milliseconds is still too slow for us — remember, our Rust binary itself only needs a few milliseconds to be ready. So instead of starting a VM on every request, we maintain a warm pool.

Abandoning Multi-Environment Entirely: Reshaping the Next-Generation Serverless Architecture with Rust and Pingora — architecture diagram

Key warm-pool parameters:

  • Pre-warm count: predicted from historical traffic patterns. Core services (like auth, user gateway) always keep N warm instances; low-frequency services (like test versions for a specific PR) are spun up on demand
  • TTL policy: not a fixed value but variable. No requests for 30 consecutive seconds? Shorten the TTL. QPS keeps rising? Extend the TTL. That’s where the name “Variable TTL” comes from
  • Memory cap: each MicroVM’s memory is dynamically adjusted via the balloon device — shrinking to a few MB when idle, expanding to the configured cap under load

Pseudo-code for the TTL decision logic:

fn compute_ttl(metrics: &InstanceMetrics) -> Duration {
    let base_ttl = Duration::from_secs(300); // base 5 minutes

    // Request rate over the last 30 seconds
    let recent_rps = metrics.requests_last_30s as f64 / 30.0;

    // L1 cache hit ratio in memory — the higher, the more valuable the local cache, the more worth extending life
    let cache_value = metrics.l1_cache_hit_ratio;

    // How many background batches still haven't been written out (e.g. monitoring data to Kafka)
    let pending_batches = metrics.pending_batch_tasks;

    let ttl = if recent_rps > 100.0 {
        // High load: extend significantly, up to 2 hours
        base_ttl * 24
    } else if recent_rps > 10.0 {
        // Medium load: extend moderately
        base_ttl * (1.0 + cache_value * 4.0) as u32
    } else if recent_rps > 0.0 {
        // Low-frequency long tail: keep base TTL
        base_ttl
    } else if pending_batches > 0 {
        // No new requests but there's still a batch of data not flushed: wait for it to finish
        Duration::from_secs(30)
    } else {
        // Completely idle: reclaim quickly
        Duration::from_secs(10)
    };

    ttl.min(Duration::from_secs(7200)) // hard cap of 2 hours
}

The Secret Weapon Inside the Process: L1 Cache + Background Batch Flushing

One of Serverless’s biggest headaches is that process-local state can’t be held onto. But in our Variable TTL model, a Rust Fn instance can live for minutes or even hours — which gives us room to maneuver:

L1 in-memory cache (moka): moka is the most mature concurrent cache library in the Rust ecosystem. Inspired by Java’s Caffeine, it supports TTL/TTI expiration, capacity limits, and async loading. Inside our Fn, it serves as the first-level cache for hot data.

use moka::future::Cache;
use std::time::Duration;

// Each Fn instance maintains its own L1 cache
// Capacity capped at 10,000 entries, TTL 60 seconds
let l1_cache: Cache<String, CatalogItem> = Cache::builder()
    .max_capacity(10_000)
    .time_to_live(Duration::from_secs(60))
    .time_to_idle(Duration::from_secs(30))
    // Async loading: automatically pulls from Data Bus on a cache miss
    .build();

// When in use:
let item = l1_cache
    .try_get_with(item_id.clone(), async {
        // Cache miss — query the database via Data Bus
        data_bus_client.query_catalog_item(&item_id).await
    })
    .await
    .map_err(|e| AppError::CacheMiss(e))?;

When an Fn instance continuously serves requests for a popular page, its L1 cache hit ratio can easily exceed 90%. That means 90% of requests need no cross-network I/O at all, and response latency drops from milliseconds to microseconds.

Background batch flushing: monitoring data (metrics, traces, logs) and business instrumentation events don’t need to be written one at a time. Inside each Fn instance, we spawn a lightweight background task with tokio::spawn, accumulate events in memory first, and flush them to Kafka in one shot once a batch is full or a time window has elapsed.

/// Background batch flush: write to Kafka every 5 seconds or once 1000 events accumulate
async fn batch_telemetry_writer(
    rx: mpsc::Receiver<TelemetryEvent>,
    kafka_producer: FutureProducer,
) {
    let mut buffer: Vec<TelemetryEvent> = Vec::with_capacity(1024);
    let mut interval = tokio::time::interval(Duration::from_secs(5));
    let mut rx = rx;

    loop {
        tokio::select! {
            Some(event) = rx.recv() => {
                buffer.push(event);
                if buffer.len() >= 1000 {
                    flush_batch(&kafka_producer, &mut buffer).await;
                }
            }
            _ = interval.tick() => {
                if !buffer.is_empty() {
                    flush_batch(&kafka_producer, &mut buffer).await;
                }
            }
        }
    }
}

These three strokes — static binaries killing cold start, warm pool + Variable TTL killing the connection-pool storm, L1 cache + batch flushing solving the state-retention problem — patch all three of traditional Serverless’s fatal weak spots.

4. Pingora: Not Just a Gateway, But a Scheduler

By now we have a bunch of ephemeral Rust runtime instances and Baggage-header-based routing intents. But who plays the “brain” — parsing Baggage, looking up the routing table, spinning up new instances when one is missing, and draining connections cleanly during scale-down?

Traditional options are Nginx (performance good but not programmable, slow to reload) and Envoy (full-featured but complex to configure, memory-heavy). But in our scenario, the gateway needs to do far more than a traditional reverse proxy:

  1. Deep-parse the Baggage Header and make millisecond-level routing decisions
  2. Park the request if the target instance doesn’t exist, rather than returning a 502
  3. Trigger the sidecar system to spin up a new MicroVM + Rust Fn
  4. Once the instance is ready, resume the parked request — completely transparent to the client
  5. During scale-down, perform connection draining with zero packet loss

Cloudflare’s open-source Pingora is the only framework today that satisfies all five of these requirements.

Why Pingora

Pingora isn’t an off-the-shelf gateway product; it’s an HTTP proxy framework written in Rust. Cloudflare used it to replace its Nginx clusters, serving tens of millions of requests per second worldwide. Its design philosophy is: expose the proxy logic as Rust traits, letting you programmatically control the entire lifecycle of every HTTP request.

The core trait is ProxyHttp, which offers over a dozen overridable hooks, programmable all the way from DNS resolution to response filtering:

use async_trait::async_trait;
use pingora::prelude::*;
use pingora::proxy::{ProxyHttp, Session};
use pingora::upstreams::peer::HttpPeer;
use std::sync::Arc;
use tokio::sync::Notify;

pub struct OrchestrationProxy {
    /// Routing table of live instances: env_id -> (address, health status)
    route_table: Arc<DashMap<EnvironmentId, InstanceInfo>>,
    /// Firecracker controller client
    vm_controller: Arc<VmController>,
    /// Cold-start wait notifiers
    cold_start_notifiers: Arc<DashMap<EnvironmentId, Arc<Notify>>>,
}

#[async_trait]
impl ProxyHttp for OrchestrationProxy {
    type CTX = RequestContext;

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

    /// Runs before connecting to upstream — this is our core scheduling logic
    async fn upstream_peer(
        &self,
        session: &mut Session,
        ctx: &mut Self::CTX,
    ) -> Result<Box<HttpPeer>> {
        // 1. Parse Baggage from the request header
        let baggage = extract_baggage(session.req_header());
        let env_id = baggage.env.clone();
        ctx.baggage = Some(baggage);

        // 2. Look up the routing table
        if let Some(instance) = self.route_table.get(&env_id) {
            // Fast path: instance exists and is healthy
            if instance.is_healthy() {
                return Ok(Box::new(HttpPeer::new(
                    instance.addr,
                    false, // No TLS needed at this layer, the MicroVM is on the same physical machine
                    String::new(),
                )));
            }
        }

        // 3. Slow path: the instance doesn't exist, trigger a cold start
        //    The key point: we don't return an error, we **park the current request**
        let notifier = self.cold_start_notifiers
            .entry(env_id.clone())
            .or_insert_with(|| Arc::new(Notify::new()))
            .clone();

        // Trigger the VM controller to spin up a new instance (async, non-blocking)
        let controller = self.vm_controller.clone();
        let route_table = self.route_table.clone();
        let env = env_id.clone();
        tokio::spawn(async move {
            match controller.launch_instance(&env).await {
                Ok(instance_info) => {
                    route_table.insert(env, instance_info);
                    notifier.notify_waiters(); // Wake up all waiting requests
                }
                Err(e) => {
                    tracing::error!("Cold start failed for {}: {}", env, e);
                    notifier.notify_waiters(); // Wake them up too, so they take the fallback
                }
            }
        });

        // 4. Park the current request, waiting for the cold start to complete
        //    Set a timeout: if it can't start within 500ms, take the fallback
        tokio::select! {
            _ = notifier.notified() => {},
            _ = tokio::time::sleep(Duration::from_millis(500)) => {
                tracing::warn!("Cold start timeout for {}, falling back", env_id);
            }
        }

        // 5. Look up the routing table again
        if let Some(instance) = self.route_table.get(&env_id) {
            Ok(Box::new(HttpPeer::new(
                instance.addr, false, String::new()
            )))
        } else {
            // Fallback to the production version
            let prod = self.route_table.get(&EnvironmentId::production())
                .expect("Production instance must always exist");
            Ok(Box::new(HttpPeer::new(
                prod.addr, false, String::new()
            )))
        }
    }

    /// Request filter: inject tracing info
    async fn request_filter(
        &self,
        session: &mut Session,
        ctx: &mut Self::CTX,
    ) -> Result<bool> {
        // Log the routing decision into the span for easier debugging
        if let Some(ref baggage) = ctx.baggage {
            session.req_header_mut().insert_header(
                "x-routed-env",
                &baggage.env.to_string(),
            )?;
        }
        Ok(false) // false = continue proxying, true = short-circuit return
    }
}

Look closely at the upstream_peer implementation. A traditional gateway can only return 502 when the upstream is missing. But in Pingora, we harness Rust’s async nature to park the HTTP request. From the client’s perspective, the request is merely “a few hundred milliseconds slower to respond,” not “failed.” During those hundreds of milliseconds, the system quietly boots a brand-new MicroVM, loads the Rust binary for the corresponding PR branch, and starts receiving traffic.

That’s the essential difference between a traditional gateway and an orchestration-aware gateway: a traditional gateway is a router, an orchestration-aware gateway is a scheduler.

Connection Draining: Zero-Packet-Loss Scale-Down

When the underlying Rust runtime’s TTL expires, or a scale-down policy fires, the draining procedure must be zero-packet-loss:

Abandoning Multi-Environment Entirely: Reshaping the Next-Generation Serverless Architecture with Rust and Pingora — architecture diagram

Implementing the DRAIN signal is dead simple — it’s a single-byte write on a Unix Domain Socket. Pingora receives it asynchronously in its event loop, and the whole process happens in memory, with no network I/O whatsoever.

5. Control Plane and Data Plane: The Art of Splitting a Streaming Architecture

By now you may be asking: this architecture sounds beautiful, but can it really be used in a high-concurrency streaming media scenario? Does video byte streaming also go through these ephemeral Fns?

Of course not. This is one of the most important judgments in architecture design: knowing what to do, and even more, knowing what not to do.

The Absolute Forbidden Zone: the Data Plane

Video stream delivery (HLS/DASH chunk delivery) has three hard requirements:

  1. Extremely high TCP throughput: a single 4K stream is 20-50 Mbps; tens of thousands of concurrent users means Tbps-level bandwidth
  2. Zero-copy (sendfile): the OS uses the sendfile syscall to DMA the file straight from disk to the NIC, bypassing userspace memory copies
  3. A stable TCP congestion window: video playback requires a continuous stable data flow; processes that constantly spawn and die break TCP slow start and congestion control, causing playback to stutter

Abandoning Multi-Environment Entirely: Reshaping the Next-Generation Serverless Architecture with Rust and Pingora — architecture diagram

This line must be drawn very clearly: 100% of all video byte streams are offloaded to the edge CDN; not a single byte passes through our Fn layer.

The Perfect Battleground: the Control Plane

Conversely, the API control plane is where this architecture truly shines:

User authentication and Token validation: JWT signature verification is a pure CPU-intensive computation. Using Rust’s ring or jsonwebtoken library to do RS256 verification, a single core can easily hit 50,000+ ops/sec. Cache verified Tokens in the Fn instance’s L1 cache (with the Token’s jti as key and a TTL set to the Token’s remaining validity), and repeat requests hit the cache directly — saving even the signature verification.

Metadata delivery and homepage waterfall feed: this is where the local in-memory cache enabled by Variable TTL pays off most. The data for a homepage waterfall feed typically changes on a minute-scale, but QPS can be in the hundreds of thousands. Using moka to cache recent recommendation results inside the Fn, the cache hit ratio can reach 95%+, meaning fewer than 5% of requests per second need to penetrate to the data layer.

DRM playback licensing: this is the most ingenious application of Baggage routing. Different versions of DRM authentication logic (say, testing a new Widevine L1 policy) can be precisely routed to the corresponding Fn instance via the Baggage Header. Something breaks? The blast radius is locked to those few requests carrying the test header, and production traffic remains untouched.

Danmaku and comments: WebSocket long-lived connections work well under the Fn’s Variable TTL model. As long as there are active connections, the TTL won’t trigger reclamation. When a user exits the live room, the connection drops, the Fn’s TTL naturally shortens, and it’s reclaimed within seconds. It’s naturally suited to ebb-and-flow scenarios like danmaku.

6. Data Bus: SQL Firewall and Data-Layer Isolation

We’ve achieved statelessness and request-level isolation at the compute layer. But if the Fns of all logical environments connect directly to the same PostgreSQL instance — and a test-traffic DELETE FROM users WHERE id = 1 hits the production database — all the carefully designed isolation upstream is wasted.

Data isolation is the final and hardest piece of the whole architecture puzzle.

To solve this, we introduce a fully Rust-written Data Bus between the Fn (compute layer) and PostgreSQL (storage layer). It’s not a simple connection-pool proxy (that’s PgBouncer’s job), but a database firewall with SQL semantic understanding and request-level authorization capabilities.

Abandoning Multi-Environment Entirely: Reshaping the Next-Generation Serverless Architecture with Rust and Pingora — architecture diagram

Why Not AST Rewriting

The first instinct may be: parse all SQL at the Data Bus layer and automatically inject permission conditions (like WHERE tenant_id = $current_tenant). This “transparent rewriting” idea is appealing, but in engineering practice it’s a nightmare.

Three reasons:

  1. The bottomless pit of SQL dialects. PostgreSQL’s SQL syntax is enormous — window functions, recursive CTEs, LATERAL JOIN, RETURNING clauses, PL/pgSQL function calls — every one of these can become an edge case for the rewrite logic. You think you’ve handled SELECT, then you discover that INSERT ... ON CONFLICT ... DO UPDATE SET ... WHERE ... also needs the condition injected.
  2. The privilege-escalation risk of nested subqueries. An auto-injected WHERE tenant_id = X might land only on the outermost SELECT, while an inner subquery or CTE references another tenant’s data. That’s not a bug — that’s a security hole.
  3. Semantic ambiguity. The same table can have different isolation semantics in different contexts. The audit_logs table may be globally shared, while user_profiles is tenant-isolated. How does the auto-rewrite engine know? You end up having to write a declarative rules system anyway — at which point you might as well just have the developer write it explicitly in the SQL.

Interception and Strict Validation: the More Elegant Path

We choose a more engineering-oriented strategy: developers must explicitly write permission constraints; the Data Bus merely acts as the auditor.

Specifically:

  1. Business developers, when writing SQL, must explicitly include tenant constraints in the WHERE clause (e.g. WHERE tenant_id = $1)
  2. The Data Bus parses the SQL’s AST and checks whether the expected constraint column is present
  3. It strictly compares the constraint parameter’s value against the identity info in the request’s Baggage
  4. If they match, let it through; any deviation is intercepted and a 403 returned
use sqlparser::ast::{Expr, BinaryOp, Statement, SetExpr, Select};
use sqlparser::dialect::PostgreSqlDialect;
use sqlparser::parser::Parser;

/// SQL security audit result
#[derive(Debug)]
pub enum AuditResult {
    /// SQL passed the audit; records the position of the constraint parameter
    Approved {
        /// Position of the tenant constraint parameter in the prepared statement ($1, $2, ...)
        tenant_param_index: usize,
        /// Hash of the SQL skeleton, for later caching
        skeleton_hash: u64
    },
    /// SQL was rejected
    Rejected { reason: String },
}

/// Audit a SQL statement, checking whether it contains the correct tenant-isolation constraint
pub fn audit_sql(sql: &str, required_column: &str) -> AuditResult {
    let dialect = PostgreSqlDialect {};
    let ast = match Parser::parse_sql(&dialect, sql) {
        Ok(ast) => ast,
        Err(e) => return AuditResult::Rejected {
            reason: format!("SQL parse error: {}", e)
        },
    };

    for statement in &ast {
        match statement {
            Statement::Query(query) => {
                if let SetExpr::Select(select) = query.body.as_ref() {
                    match find_tenant_constraint(select, required_column) {
                        Some(param_index) => {
                            let hash = compute_skeleton_hash(sql);
                            return AuditResult::Approved {
                                tenant_param_index: param_index,
                                skeleton_hash: hash,
                            };
                        }
                        None => return AuditResult::Rejected {
                            reason: format!(
                                "Missing required constraint on column '{}'",
                                required_column
                            ),
                        },
                    }
                }
            }
            Statement::Insert { .. } | Statement::Update { .. } | Statement::Delete { .. } => {
                // Perform stricter auditing for write operations...
                // INSERT must include the tenant_id column
                // UPDATE/DELETE's WHERE must include a tenant_id constraint
            }
            _ => {
                return AuditResult::Rejected {
                    reason: "Only SELECT/INSERT/UPDATE/DELETE are allowed".into(),
                };
            }
        }
    }

    AuditResult::Rejected { reason: "Empty statement".into() }
}

/// Find a constraint like `tenant_id = $N` in the SELECT's WHERE clause
fn find_tenant_constraint(select: &Select, column: &str) -> Option<usize> {
    let selection = select.selection.as_ref()?;
    walk_expr_for_constraint(selection, column)
}

/// Recursively walk the expression tree looking for an equality constraint on the target column
fn walk_expr_for_constraint(expr: &Expr, column: &str) -> Option<usize> {
    match expr {
        // Match the `column = $N` or `$N = column` pattern
        Expr::BinaryOp { left, op: BinaryOp::Eq, right } => {
            // Left side is a column name, right side is a parameter placeholder
            if is_column(left, column) {
                return extract_param_index(right);
            }
            // Right side is a column name, left side is a parameter placeholder
            if is_column(right, column) {
                return extract_param_index(left);
            }
            None
        }
        // Recursively search sub-expressions joined by AND
        Expr::BinaryOp { left, op: BinaryOp::And, right } => {
            walk_expr_for_constraint(left, column)
                .or_else(|| walk_expr_for_constraint(right, column))
        }
        // Parenthesized expression: unwrap and continue searching
        Expr::Nested(inner) => walk_expr_for_constraint(inner, column),
        _ => None,
    }
}

The philosophy here is Shift Left — moving the security responsibility forward into the developer’s hands, rather than relying on backend magic to auto-fix. Writing an extra WHERE tenant_id = $1 costs the developer almost nothing, but the security guarantee it buys is real, auditable, and free of gray areas.

The Performance Nuke: AST Hash Pre-Compilation Cache

You might worry: parsing the SQL AST on every request would introduce a massive performance bottleneck, wouldn’t it?

Indeed. sqlparser takes about 50-200 microseconds to parse a moderately complex SQL statement. At 100k QPS, that means SQL parsing alone would tie up 5-20 CPU cores. Unacceptable.

The solution is a hash-based pre-compilation cache:

Abandoning Multi-Environment Entirely: Reshaping the Next-Generation Serverless Architecture with Rust and Pingora — architecture diagram

Core data structures:

use dashmap::DashMap;
use std::hash::{Hash, Hasher};
use std::collections::hash_map::DefaultHasher;

/// Audit metadata in the pre-compilation cache
#[derive(Clone, Debug)]
struct AuditMeta {
    /// Whether it passed the security audit
    is_safe: bool,
    /// Index of the tenant constraint parameter in the prepared statement
    check_param_index: usize,
    /// Corresponding PostgreSQL Prepared Statement name
    prepared_stmt_name: String,
    /// Timestamp of the first successful audit (for observability)
    first_seen: Instant,
    /// Cumulative hit count (for hotness statistics)
    hit_count: AtomicU64,
}

/// The core cache of the Data Bus
struct DataBus {
    /// SQL skeleton Hash -> audit metadata
    /// DashMap is the fastest concurrent HashMap in the Rust ecosystem
    audit_cache: DashMap<u64, AuditMeta>,
    /// PostgreSQL connection pool
    pg_pool: deadpool_postgres::Pool,
}

impl DataBus {
    /// Handle one SQL request
    async fn execute(
        &self,
        sql: &str,
        params: &[&(dyn ToSql + Sync)],
        baggage: &BaggageContext,
    ) -> Result<Vec<Row>, DataBusError> {
        let hash = compute_skeleton_hash(sql);

        // Fast path: cache hit
        if let Some(meta) = self.audit_cache.get(&hash) {
            meta.hit_count.fetch_add(1, Ordering::Relaxed);

            // O(1) parameter validation: check whether the tenant_id parameter matches the Baggage's identity
            let tenant_param = params.get(meta.check_param_index)
                .ok_or(DataBusError::ParamIndexOutOfBounds)?;

            if !verify_tenant_param(tenant_param, &baggage.tenant_id) {
                return Err(DataBusError::TenantMismatch {
                    expected: baggage.tenant_id.clone(),
                    sql_skeleton_hash: hash,
                });
            }

            // Execute directly using the Prepared Statement
            let conn = self.pg_pool.get().await?;
            let rows = conn.query(&meta.prepared_stmt_name, params).await?;
            return Ok(rows);
        }

        // Slow path: first time seeing this SQL skeleton
        match audit_sql(sql, "tenant_id") {
            AuditResult::Approved { tenant_param_index, skeleton_hash } => {
                // Register the Prepared Statement
                let stmt_name = format!("databus_{}", skeleton_hash);
                let conn = self.pg_pool.get().await?;
                conn.execute(
                    &format!("PREPARE {} AS {}", stmt_name, sql),
                    &[]
                ).await?;

                // Write to cache
                self.audit_cache.insert(skeleton_hash, AuditMeta {
                    is_safe: true,
                    check_param_index: tenant_param_index,
                    prepared_stmt_name: stmt_name.clone(),
                    first_seen: Instant::now(),
                    hit_count: AtomicU64::new(1),
                });

                // Validate and execute
                let tenant_param = params.get(tenant_param_index)
                    .ok_or(DataBusError::ParamIndexOutOfBounds)?;
                if !verify_tenant_param(tenant_param, &baggage.tenant_id) {
                    return Err(DataBusError::TenantMismatch {
                        expected: baggage.tenant_id.clone(),
                        sql_skeleton_hash: skeleton_hash,
                    });
                }

                let rows = conn.query(&stmt_name, params).await?;
                Ok(rows)
            }
            AuditResult::Rejected { reason } => {
                tracing::error!(
                    sql = sql,
                    reason = reason.as_str(),
                    "SQL rejected by Data Bus audit"
                );
                Err(DataBusError::AuditRejected(reason))
            }
        }
    }
}

/// Compute the skeleton Hash of a SQL statement
/// "Skeleton" means ignoring parameter values, keeping only the structure
/// SELECT * FROM users WHERE id = $1 AND name = $2
/// and SELECT * FROM users WHERE id = $1 AND name = $2
/// have the same skeleton (they're identical anyway, because parameterized queries are used)
fn compute_skeleton_hash(sql: &str) -> u64 {
    let mut hasher = DefaultHasher::new();
    sql.hash(&mut hasher);
    hasher.finish()
}

Performance numbers: the slow path is about 200 microseconds (parse + audit + register Prepared Statement); the fast path is about 50 nanoseconds (hash computation + DashMap lookup + parameter comparison). The fast path is 4000x faster than the slow path. At steady state, 99.9% of requests take the fast path.

More importantly, this mechanism immunizes the system against SQL injection at the architectural foundation. Because all SQL executes through Prepared Statements, parameter values are never concatenated into the SQL string. The Data Bus’s audit ensures every SQL statement has the correct parameterized constraints, while PostgreSQL’s Prepared Statement ensures a strict separation between parameter values and SQL structure.

Dynamic Environment Isolation at the Data Layer

The final piece: when test traffic carrying x-env: pr-1234 reaches the Data Bus, it must not hit the production database.

Based on the environment identifier in the Baggage, the Data Bus routes non-production traffic to a Shadow Database. This shadow database is instantly cloned using PostgreSQL’s Copy-on-Write technology:

-- Use pg_createcluster or a cloud provider's snapshot clone capability
-- AWS Aurora's clone operation is O(1), no matter how large the database
-- Because the underlying storage layer is Copy-on-Write

-- Or use PostgreSQL 15+'s CREATE DATABASE ... STRATEGY WAL_LOG
CREATE DATABASE shadow_pr_1234 TEMPLATE production_db;

On AWS Aurora, cloning a TB-scale database takes only a few minutes (no data is copied; only pointers to storage pages are copied). The shadow database and the production database share unmodified storage pages; only pages written by test traffic produce new copies.

Abandoning Multi-Environment Entirely: Reshaping the Next-Generation Serverless Architecture with Rust and Pingora — architecture diagram

Business code is completely unaware of all of this. It just sends SQL to the Data Bus as usual, and the Data Bus decides which database to hit based on the Baggage. Connection strings, auth credentials, connection-pool management — the Data Bus handles it all behind the scenes.

7. The Panoramic View: The Full Path from Request to Response

Let’s string all the components together and look at the complete journey of a request from entering the system to returning a response:

Abandoning Multi-Environment Entirely: Reshaping the Next-Generation Serverless Architecture with Rust and Pingora — architecture diagram

8. The Cost and Boundaries of This Architecture

Having written all this, I must pour a bucket of cold water. Any architecture article that doesn’t discuss trade-offs is being disingenuous.

The Costs You’ll Pay

Debugging complexity explodes. When a bug only triggers under a specific Baggage combination and only appears at a specific TTL stage, your observability tooling must be world-class. Distributed tracing is no longer a nice-to-have — it’s a basic survival tool. The Baggage propagation at every hop, every routing decision, every cache hit or miss must be logged in the Trace.

The operations cognitive threshold is extremely high. K8s, complex as it is, has a huge community, a mature toolchain, and countless tutorials. This architecture requires you to be simultaneously expert in Rust, the Pingora framework, Firecracker virtualization, PostgreSQL internals, and every aspect of distributed systems. You can’t hire ready-made engineers to maintain this system — because it’s too new.

Firecracker’s hard limits. Firecracker only supports Linux (requires KVM), doesn’t support GPU passthrough (at least not yet), and can’t run locally on macOS. Your local dev experience may need to rely on a remote dev server or a local Linux VM like Lima/UTM.

The test pyramid is inverted. The traditional “lots of unit tests + a few integration tests + a tiny handful of E2E tests” pyramid is overturned in this architecture. Because the core value lies in full-chain request-level isolation, you need a large number of end-to-end tests to verify cross-component behaviors like Baggage propagation, routing decisions, and data isolation. Unit tests still matter, but they’re no longer the mainstay of quality assurance.

Who This Architecture Is Not For

  • Teams with < 5 people and no Rust experience: the learning curve is too steep
  • Internal tools with QPS < 1000: overkill; a Docker Compose + Nginx is enough
  • Heavily regulated industries requiring physical environment isolation: certain compliance requirements mandate that test and production live in physically isolated networks; Baggage routing’s logical isolation doesn’t satisfy such requirements
  • Teams that already have a mature, stable K8s platform: the risk and cost of migration far outweigh the benefits

9. The Dawn of the Post-Kubernetes Era

Let’s return to the question from the start of the article: if we started from scratch today, with no historical baggage, what should infrastructure look like?

I believe the direction of the answer is clear:

The core problems Kubernetes solved — container orchestration, service discovery, rolling updates — largely become unnecessary in an architecture centered on binary distribution and request-level routing. You don’t need the pile of abstractions like Pod, Deployment, Service, Ingress, because the compute unit is just a few-MB binary, routing is a single Baggage parse, and orchestration is a few dozen lines of Rust in Pingora.

Multi-environment architecture — Dev, Staging, Preprod, Prod — with the combination of Baggage routing and Copy-on-Write database cloning, collapses from physical isolation into logical isolation. Four times the machine cost and four times the operational overhead collapse into a single set of dynamic logical partitions on one physical cluster.

The database returns from being an all-around player to the purest I/O engine. Connection-pool management, permission validation, SQL security auditing — responsibilities that used to be crammed into ORMs or database middleware — are taken over by the focused Data Bus component. The database only needs to do one thing well: read and write data quickly and safely.

That is the full picture of the architecture composed of Baggage Header routing + Pingora smart gateway + Firecracker pure Rust runtime + Data Bus SQL firewall. It’s not a patch job on Kubernetes — it’s a fresh rethink from first principles.

Of course, tearing everything down and starting over never happens overnight. Kubernetes won’t disappear tomorrow, just as Subversion lived on for another decade after Git appeared. But the direction is already showing signs: infrastructure is becoming increasingly “invisible,” and compute is returning to its purest, most efficient form — a chunk of machine code, a request, a clean execution.

Perhaps when we look back in ten years, the YAML orchestration of the Kubernetes era will feel like looking at Ant’s XML build scripts today — a necessary stage in history that will eventually be surpassed.

And that purer future is being written, line by line, in Rust right now.

MORE IN Systems & Architecture

Further Reading in This Column

View all essays in “Systems & Architecture” →

The Future of Logs: Rectifying Names

For a decade we built observability into a beautiful tower on a foundation of drifting strings—field names by convention, cardinality by luck, bills by endurance. This essay proposes a radical fix: use protobuf to give every observed event a proper name, unify logs, metrics, and audit into one versioned, owner-tagged, retention-aware event catalog, and keep an escape hatch called ForensicEvent for the unknown unknowns.

New Hope (2025–2026)10,772 chars · 24 min read