Systems & security · a first-principles course · all six parts

The AI gateway,
from the ground up

One system, built from nothing assumed. Six parts, each topic in the same rigorous shape — prerequisite → problem → how it works (why, why, why) → evidence → alternatives & when not → where it lives → how it's attacked → config → remember. Read straight through, or jump with the bar that follows you down the page.

The learning path — read in order; each part builds on the last

  1. Part 1 · The map, then encryption & the cheap first filter (you are here)
    1. 1.1 The 10,000-ft architecture
    2. 1.2 TLS & mTLS
    3. 1.3 Bloom filters (why O(1))
Part 1 of 6

The map, the encryption, and the cheap first filter

Start with the whole system, then zoom into two blocks: how traffic is protected (TLS/mTLS) and how the very first inspection gate stays nearly free (Bloom filters).

1.1  The 10,000-ft view: what are we even building?

Before any single idea makes sense, you need the map. Everything in this course is one block inside the picture below. We will keep coming back to it and zooming in.

The thing we are building is an AI gateway: a piece of infrastructure that sits between users and a large-language-model service, and inspects every prompt on the way in (to block data leaks and attacks) and every response on the way out. It runs on Kubernetes. Follow one request from the top of the figure to the bottom.

The whole system at 10,000 ft: an AI gateway running on KubernetesFollow one request top to bottom. Every later topic zooms into one of these blocks.clients / internetsend prompts over HTTPSKUBERNETES CLUSTER (nodes running pods)Ingress / Load Balancerone address → many podsENVOY — DATA PLANEmoves EVERY request · microseconds · C++TLSterminateauthNmTLS/JWTratelimitINSPECTcontentroutea bad request is blocked here (403) — it never reaches the modelAI model service (pods)clean text onlyCONTROLPLANE (Go)holds thedesiredpolicy/configpushes viaxDSconfig lives in etcd (YAML/CRDs) → control plane → pushed to data plane live. No restarts.
One request, top to bottom. Data plane in blue moves it; the control plane in orange decides the rules.

The two halves: control plane vs data plane

The single most important split in the whole system is between the data plane and the control plane. Almost every confusion later dissolves once this is clear.

The data plane is the part that touches every single request. In our picture that is Envoy (a proxy written in C++). If a million prompts arrive, the data plane runs a million times. So it must be brutally fast — microseconds per request — and it must never block. This is why it is C++ and why later topics (zero-copy parsing, non-blocking I/O, caches) all live here: they are all in service of "do the per-request work in microseconds."

The control plane is the part that decides the rules but does not touch individual requests. In our picture that is a Go service. When an operator changes a policy ("also block credit-card numbers"), they change it in the control plane. The control plane runs rarely — seconds or minutes apart — so it can be written in a comfortable, higher-level language (Go) and do slow, careful work.

Why split them at all?Because their requirements are opposite. The data plane needs raw speed and can't afford to think; the control plane needs to think and can afford to be slow. Bolting them together would force one language and one speed on two jobs that want the opposite. And crucially: if the control plane dies, the data plane keeps running on its last-known config. Traffic does not stop just because the "brain" is rebooting. That fault-isolation is the entire payoff of the split.

What is written where — and what you can change

A fair question a learner always has but rarely gets answered: where does all this "config" actually live, and what can I touch? Here is the honest chain:

So "can I modify it?" — yes: you edit YAML and apply it. "What does it take?" — a valid change to a Kubernetes object, which flows through etcd → controller → xDS → data plane in seconds, without dropping a connection. You almost never edit Envoy's raw config or restart anything by hand; that's the whole point of the control plane.

Where each thing livesDesired app state (replicas, images): Kubernetes Deployment YAML. Routing & policy (which host/path goes where, rate limits, inspection rules): mesh CRDs like VirtualService / HTTPRoute (Part 4). Secrets (TLS certs, keys): Kubernetes Secret objects. All of it: persisted in etcd, pushed to Envoy via xDS. You edit YAML; you never hand-edit the live proxy.
RememberThe data plane moves every request and must be fast; the control plane decides the rules and can be slow. You edit YAML → etcd → control plane → xDS → data plane, live. Keep this map open as we zoom in.

1.2  TLS and mTLS: how the traffic is protected, and how the gateway reads it anyway

Prerequisite — three things to know firstSymmetric encryption: one shared secret key both scrambles and unscrambles the data. Fast, but both sides must somehow already share the key. Asymmetric encryption: a pair of keys — a public key that anyone can use to encrypt (or to check a signature) and a private key, kept secret, that decrypts (or signs). Slow, but it solves the "how do we agree on a secret over a hostile wire" problem. A certificate is just a public key plus an identity (a hostname), signed by a Certificate Authority (CA) — a third party your machine already trusts. The signature is the CA vouching: "this public key really belongs to this name."

The problem TLS solves

The network between a user and your server is hostile. Anyone on the path — a coffee-shop router, an ISP, a compromised switch — can by default read your traffic, modify it, or impersonate either end. TLS (Transport Layer Security, the "S" in HTTPS) fixes all three: it gives you confidentiality (eavesdroppers see gibberish), integrity (tampering is detected), and authentication (you know you're really talking to the server you meant).

How the handshake works — and why each step exists

Every HTTPS connection begins with a handshake. Walk it slowly, because every step answers a specific attack:

Why asymmetric first, then symmetric?Asymmetric crypto solves the impossible-looking problem — agreeing on a secret over a wire everyone can read — but it is orders of magnitude slower per byte. Symmetric crypto is fast but needs a pre-shared secret. So TLS uses asymmetric crypto once, during the handshake, purely to bootstrap a symmetric session key, then uses that fast symmetric key for all the real data. The handshake is the expensive part; that's why connection reuse and session resumption matter so much for performance.

Termination: why the gateway can read the prompt

"TLS termination" means a proxy holds the private key for the hostname and ends the encrypted tunnel at itself: it decrypts the request, and for a moment the prompt exists as plaintext in the proxy's memory. That plaintext moment is the only reason inspection is possible at all — you cannot scan ciphertext. The proxy then opens a separate TLS connection onward to the model and re-encrypts. Two tunnels, not one.

The cost, in numbersA full TLS 1.3 handshake adds one network round-trip before any data flows (TLS 1.2 needed two); over a 50 ms link that's ~50 ms of pure setup. That is why proxies keep upstream connections alive and reuse them, and why session resumption exists — to avoid paying the handshake on every request. Symmetric encryption itself is nearly free on modern CPUs (AES-NI hardware instructions do gigabytes per second), which is why we can afford to encrypt everything.

mTLS: from one-way trust to mutual trust

Normal TLS is one-way: the server proves who it is, the client stays anonymous. That's right for the public internet (your bank shouldn't need a certificate from you). But inside a cluster, where dozens of services call each other, you want the opposite default: every service should prove its identity to every other, and no service should trust another just because it's "on the network." That is mTLS (mutual TLS): the client also presents a certificate, and the server verifies it too.

TLS vs mTLS: one-way trust vs mutual trustTLS proves the server is who it claims. mTLS also proves the client is — both show a cert.TLS (normal HTTPS)clientserver1. server sends its cert2. client verifies it → trusts serveronly the SERVER proves identity. the client stays anonymous.mTLS (service-to-service, zero trust)service Aservice Bserver cert (as before)+ CLIENT cert — B verifies A tooboth sides authenticate → neither trusts the network. this is how services inside the mesh talk.
TLS proves only the server. mTLS adds a client certificate so both ends authenticate — the basis of zero trust.
Why mutual, inside the mesh?Because the old model — "the network is safe, so anything already inside can be trusted" — is exactly how one compromised pod turns into a total breach. Zero trust flips it: identity is proven cryptographically on every hop, so a foothold in one service can't impersonate another. In a service mesh, sidecar proxies do mTLS automatically for all service-to-service traffic, so app code doesn't have to.
Alternatives & when NOT to terminateTLS passthrough is the alternative: the proxy forwards the encrypted bytes without decrypting (L4). Use it when you must not see the content — regulatory end-to-end encryption, or when the backend needs the original client certificate. The trade-off is stark: passthrough means you cannot inspect. For an AI gateway whose whole job is inspection, termination is mandatory; but that decision is exactly what creates the plaintext-in-memory target, so it's a deliberate trade, not a default.
How an attacker probes TLS — several concrete moves1. Man-in-the-middle (MITM): sit on the path and relay traffic, presenting your own certificate. Defeated by chain verification — the attacker can't forge a CA signature for the real hostname. 2. Downgrade attack: tamper with the ClientHello to force an old, weak protocol (e.g. SSLv3/POODLE). Defeated by disabling old versions and by TLS 1.3's downgrade protection. 3. SSL stripping: keep the user on plain http:// and quietly proxy to HTTPS, so they never get an encrypted link. Defeated by HSTS (the server declaring "always use HTTPS"). 4. Certificate tricks: expired, self-signed, or wrong-host certs — harmless unless a client is misconfigured to accept them, which is why "just disable cert verification to make it work" is a genuine vulnerability, not a shortcut. 5. Stealing the private key: the crown jewel. Whoever holds the terminating key can decrypt everything — which is why that key, and the plaintext beside it, is the most defended thing in the system.
Config: what's written whereIn Nginx, termination is a few directives in a server block: listen 443 ssl; plus ssl_certificate and ssl_certificate_key pointing at PEM files. In Envoy it's a transport_socket on the listener referencing a TLS context; in Kubernetes those cert/key files come from a Secret, and mTLS across the mesh is turned on with a PeerAuthentication policy (a CRD) rather than per-service code. You change security posture by editing that YAML — not by touching application code.
RememberTLS = confidentiality + integrity + identity, bootstrapped by slow asymmetric crypto that hands off to fast symmetric crypto. Termination decrypts so you can inspect — creating the highest-value target in the system. mTLS makes both sides prove identity, which is how a zero-trust mesh stops one breach from spreading.

1.3  Bloom filters: how to ask "have I seen this?" in constant time

PrerequisiteA hash function takes any input and deterministically maps it to a number in a fixed range (same input always gives the same number; different inputs usually give different numbers). Array indexing — jumping straight to slot i of an array — is a single, constant-time operation. Big-O in one line: O(1) means the work stays the same no matter how big the data gets; O(n) means the work grows with the data.

The problem

The inspection pipeline must, for every request and within a microsecond budget, ask a cheap screening question: "could this text contain any of the thousands of known-bad patterns we watch for?" The obvious approaches are too slow or too big. Comparing against every pattern is O(n) in the number of patterns. Keeping an exact set of everything seen costs a lot of memory. We want something that is constant-time and tiny, and we're willing to accept a small, controlled imperfection to get it.

What a Bloom filter is

A Bloom filter is a probabilistic set. It can tell you two things about membership: "definitely not in the set" (with certainty) or "possibly in the set" (with a small chance of being wrong). It never says "definitely yes," and — crucially — it never produces a false negative: if something really is in the set, the filter will never say no.

How it works, step by step

A Bloom filter: a fixed number of checks, no matter how big the setIt answers 'have I seen this before?' with a bit-array and a few hashes — never by scanning the set.bit array (all start 0):00111203140506170819110011012113014015ADD 'SSN': hash it with k=3 functions → set those 3 bits to 1h1→1 h2→4 h3→7QUERY 'foo': hash with the SAME 3 functions → check those 3 bitsany bit 0? → DEFINITELY not in the set. all bits 1? → MAYBE (could be a false positive).WHY O(1): you always do exactly 3 hashes + 3 bit-checks —whether the set holds 10 items or 10 billion. The work never grows with n.
A bit array plus k fixed hash functions. Adding sets k bits; querying checks the same k bits.

You keep a bit array of m bits, all zero to start, and k independent hash functions. To add an item, hash it with all k functions to get k positions, and set those bits to 1. To query an item, hash it the same way and look at those same k bits:

Why it's O(1) — the part that's usually skipped

Why constant time, exactly?Look at what a query does: compute k hashes, and read k array slots. That's it. k is a fixed small number you chose up front (often 5–10) — it does not depend on how many items are in the set. Whether the filter holds 10 items or 10 billion, a lookup is the same k hashes and k bit-reads. Work that doesn't grow with n is O(1) by definition. Contrast the alternatives: scanning every pattern is O(n); even a hash set, while roughly O(1), touches far more memory and pointers. The Bloom filter's whole trick is trading a little accuracy for a guaranteed fixed, cache-friendly cost.

Evidence: the false-positive rate is a knob, not a mystery

The math, with a real numberWith m bits, k hashes, and n items stored, the false-positive probability is approximately (1 − e^(−kn/m))^k. The practical takeaway is that it's tunable: give the filter more bits per item and it gets more accurate. A common sizing — about 10 bits per item with k = 7 — yields roughly a 0.8% false-positive rate. So ~99.2% of the time a "maybe" is right, and a "no" is always right. You dial m and k to hit whatever error budget you want; there is no false-negative term to worry about because there can't be one.

Where it lives in our architecture

Back to the map. Inside the data plane's INSPECT block, the Bloom filter is the cheap first gate. Because ~99% of real traffic is clean, the filter answers "definitely no known-bad pattern here" for the vast majority of requests in a handful of nanoseconds, and they skip the expensive scan entirely. Only the small slice that comes back "maybe" pays for the real, exact matcher (Aho-Corasick, in Part 3). The Bloom filter's job is not to be the final judge — it's to make the common case nearly free.

Alternatives & when NOT to use oneUse an exact hash set instead when you cannot tolerate any false positive, when the set is small enough that memory doesn't matter, or when you need to delete items or list them (a plain Bloom filter supports neither). Use a counting Bloom filter or a cuckoo filter when you need deletion. Skip it entirely when your set has only a few hundred entries — the constant factors aren't worth it; just use a normal set. The Bloom filter earns its place specifically when the set is huge, memory is tight, the query is hot, and a small false-positive rate is acceptable because a second exact check follows.
The attacker's angleA false positive sends a request to the expensive path. An attacker who understood your filter could, in principle, craft inputs that deliberately trigger "maybe" to push load onto the costly matcher — an algorithmic denial-of-service. Mitigations: keyed/randomized hashes so the attacker can't predict collisions, plus a hard time/rate budget on the expensive path so a flood of "maybes" can't run it away. Note the failure direction is safe by design: the filter never lets a real bad pattern through as "definitely no," so an attacker can waste CPU but cannot use false positives to bypass inspection.
RememberA Bloom filter answers "definitely no" or "maybe" using a bit array and k fixed hashes — so a lookup is always k hashes + k bit-reads, O(1) regardless of set size. It trades a small, tunable false-positive rate (never a false negative) for tiny memory and constant time, which is exactly why it's the cheap first gate before the real scanner.
In Part 1 we drew the whole system and saw traffic arrive encrypted. Now we zoom into the data plane: first, what layer the proxy reads at (and why that decides everything), then the first real gatekeeper on the request — rate limiting, built for real in Nginx and Envoy.
Part 2 of 6

Reading the request: layers, and the first gatekeeper

Where a proxy operates (L4 vs L7) sets the ceiling on what it can do. Then: exactly how rate limiting is implemented — token buckets, shared memory, and local-vs-global — in the two proxies you'll actually run.

2.1  L4 vs L7: where the proxy operates decides what it can do

Prerequisite — the layersNetwork traffic is built in layers, each wrapping the one above. The ones we care about: L3 (IP) carries source and destination IP addresses — enough to get a packet across the internet. L4 (TCP/UDP) adds ports and turns packets into a reliable connection (a byte stream). L7 (HTTP) is the actual request — method, URL path, headers, body. A useful mental model: a packet is one envelope, a connection is an ongoing phone call, a request is the sentence spoken on that call.

The problem

A proxy has to make decisions: where does this go, is it allowed, can I inspect it? What it's able to decide depends entirely on how far up the stack it reads. And here's the catch that ties back to Part 1: the HTTP request is inside the TLS encryption. So a proxy that doesn't terminate TLS literally cannot see the URL or body — no matter how much it wants to.

Why the layer matters: what a proxy can read depends on how deep it looksOne request is nested headers. A proxy only sees down to the layer it operates at.IP headersrc/dst IPTCP headersrc/dst portTLS (encrypted)HTTP: POST /v1/chatheaders · body🔒L4 reads down to here → routes by IP:port. The HTTP is inside TLS, so it's invisible here.L7 proxy terminates TLS, then reads the HTTP → routes & inspects by content.content-based decisions (path routing, auth, inspection) are IMPOSSIBLE at L4 — they need L7.
The request is nested. An L4 proxy sees IP and port; the HTTP it would route on is sealed inside TLS.

How each works — and why the difference is fundamental

An L4 proxy operates on the connection. It reads the IP/port 4-tuple, picks a backend, and then just shovels bytes back and forth. It never parses HTTP; it often never decrypts anything. That makes it extremely cheap and protocol-agnostic — it'll happily proxy HTTP, a database wire protocol, or anything else. Why it's fast: forwarding a byte stream is almost no work per request.

An L7 proxy operates on the request. To do that it must terminate TLS (Part 1), then parse the HTTP — find the method, the path, the headers, the body. Now it can route by URL, add or check headers, retry failed requests, enforce authentication, and — the whole point of our gateway — inspect the content. Why it costs more: TLS termination plus parsing every request is real per-request work.

Why can't L4 just inspect anyway?Two reasons, stacked. First, at L4 the proxy is looking at TCP segments, not a reassembled HTTP message — the request might be split across many packets it isn't buffering. Second, and decisively, the HTTP is encrypted inside TLS. Without the private key and a TLS termination step, the payload is indistinguishable from random noise. So "inspect the prompt" is not a feature you can bolt onto an L4 proxy — it structurally requires moving up to L7.
The cost, concretelyAn L4 proxy can sustain enormous connection counts on tiny CPU because per-request work is near zero — this is why cloud load balancers at the edge are usually L4. An L7 proxy pays for TLS termination (one handshake round-trip per new connection) plus HTTP parsing, so it does fewer requests per core — still tens of thousands per core on modern hardware, but meaningfully more than L4. That gap is exactly why large systems layer them: a fast L4 balancer spreads connections across a fleet of L7 proxies that do the smart work.
When to use whichUse L4 when you need raw throughput, when the protocol isn't HTTP, or when you must not decrypt (regulatory end-to-end encryption, or the backend needs the original client cert — TLS passthrough). Use L7 whenever a decision depends on the content of the request. Don't reach for L7 "just in case" — the extra parsing and the plaintext-in-memory exposure are real costs you take on only when you need content awareness.
How an attacker sees the layer choiceAn L4 proxy is blind to application-layer attacks — SQL injection, prompt injection, malicious payloads all sail through as opaque bytes, because it never reads them. An L7 proxy can catch those, but parsing HTTP is itself an attack surface: HTTP request smuggling exploits disagreements between two HTTP parsers (e.g. front proxy vs backend) about where one request ends and the next begins, letting an attacker sneak a hidden request past your inspection. Defense: strict, consistent HTTP parsing and rejecting ambiguous framing.
Config: what's written whereIn Nginx, L7 lives in the http { } block (with location routing); L4 lives in the stream { } block (raw TCP/UDP proxying). In Envoy, L7 is the http_connection_manager filter (which owns the routes and HTTP filters); L4 is the tcp_proxy filter. Choosing the layer is choosing which of these you configure on the listener.
RememberL4 sees IP and port and forwards bytes — fast, content-blind. L7 terminates TLS and parses the request — so it can route and inspect by content, at higher cost. Anything that depends on what the request says must be L7.

2.2  Rate limiting: how Nginx and Envoy actually build it

PrerequisiteA rate is events per unit time (e.g. 10 requests/second). The subtlety is burst vs average: real clients don't arrive smoothly — they send a few requests at once, then pause. A limiter that only enforces a smooth average would reject legitimate bursts; one that only caps totals lets abusive bursts through. Good rate limiting controls both the long-run rate and how big a momentary burst it tolerates.

The problem

A backend has finite capacity. Without limits, one buggy client or one attacker can flood it and take everyone down, and there's no fairness between clients. Rate limiting caps how much any one client can consume, sheds excess load early (before it reaches the expensive parts), and gives you a knob for fairness and abuse control.

How it works — the two classic algorithms

Almost every real limiter is a variation of two ideas:

Rate limiting algorithm #1: the token bucketA bucket refills with tokens at a steady rate. Each request spends one. No token → rejected.refill: R tokens / sec (the sustained rate)capacity B = the max burst it can holdrequeststake 1 tokenpass ✓bucket empty → 429 reject(or delay the request)sustained rate = R (refill). short bursts up to B are absorbed. beyond that, requests are shed.
Token bucket: refills at rate R, holds up to B. Each request spends a token; empty bucket rejects.
Why this is cheap enough to run per requestA token bucket doesn't need a background timer ticking tokens in. It stores just two numbers per key — the last-refill timestamp and the current token count — and on each request computes "tokens to add = elapsed × R", clamps to B, then tries to spend one. That's a few arithmetic operations and one memory lookup: O(1) per request, which is why it can sit in the hot path of a proxy doing 100k req/s.

How Nginx builds it — the limit_req module

Nginx's limit_req implements a leaky-bucket limiter. You declare a shared-memory zone keyed by some variable (usually the client IP), give it a rate, then apply it:

nginx.conf
# define a 10 MB shared zone keyed by client IP, sustaining 10 req/sec
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;

server {
  location /v1/ {
    # allow a burst of 20 queued requests; nodelay = serve the burst
    # immediately instead of spacing it out, then enforce 10r/s
    limit_req zone=perip burst=20 nodelay;
    proxy_pass http://model_upstream;
  }
}
Why a shared-memory zone?Nginx runs multiple worker processes (one per CPU core, typically). If each worker kept its own counter, a client's real limit would be multiplied by the number of workers — the same "local limits don't add up" bug we'll see with Envoy. The zone is a slab of shared memory all workers read and write, so the count for a given IP is consistent across the whole Nginx instance. burst is the queue depth (how many over-rate requests may wait); nodelay serves that burst at once rather than trickling it out. There's also limit_conn, which caps concurrent connections rather than request rate.

How Envoy builds it — local vs global

Envoy offers two rate-limit filters, and the difference matters enormously at scale.

Local rate limiting is a token bucket inside each Envoy instance — no external dependency, just config:

envoy: local_ratelimit (token bucket, per instance)
token_bucket:
  max_tokens: 100          # bucket capacity B (burst)
  tokens_per_fill: 100     # add 100 tokens...
  fill_interval: 1s        # ...every second → sustained R = 100/s

It's fast and simple, but it's per instance. Run three Envoys and your "100/s" limit is really 300/s — usually not what you meant.

How Envoy enforces it: local (per-instance) vs global (shared)With many proxy instances, per-instance limits don't add up to a real global limit.LOCAL — each Envoy has its own bucketEnvoy 1limit 100Envoy 2limit 100Envoy 3limit 1003 × 100 = 300 actually allowed ✗the 'global' limit is silently 3× too highGLOBAL — all Envoys ask one shared serviceEnvoy 1Envoy 2Envoy 3Rate Limit Service(gRPC)Redis: one 100 ✓shared, consistentlocal = cheap, approximate (no extra hop). global = accurate across all instances (one hop to the RLS).
Local limits multiply by instance count. Global limiting asks one shared service so the budget is consistent.

Global rate limiting fixes that. Each Envoy, instead of deciding locally, sends the request's descriptors (key–value labels it extracts, e.g. {client_id: acme}) to an external Rate Limit Service (RLS) over gRPC. The RLS — typically backed by Redis for a shared counter — returns OK or over-limit, so all Envoys share one consistent budget.

Why keep both, then?Because they trade accuracy for latency. Local costs nothing extra (no network hop) but is only approximate across instances — perfect for a coarse safety limit that stops any single proxy from being overwhelmed. Global is accurate to a single shared number but adds one round-trip to the RLS on the hot path. A common pattern is to use both: a generous local limit as a cheap backstop, and a precise global limit for real per-customer quotas.
Alternatives & when NOT to rate-limitRate limiting caps requests per time. Sometimes what you actually want is concurrency limiting — capping how many requests are in flight at once — which protects better against slow requests piling up. Envoy's adaptive concurrency goes further, adjusting the limit automatically based on observed backend latency. And don't rate-limit blindly: trusted internal service-to-service traffic usually shouldn't be throttled at all, and over-aggressive limits cause more outages (self-inflicted 429 storms) than they prevent.
How attackers work around rate limits1. Distributed floods: a per-IP limit is defeated by a botnet of thousands of IPs, each staying under the cap. Defense: key limits on something harder to spread — an API key or authenticated user — not just IP. 2. Header spoofing: if you key on X-Forwarded-For, an attacker sets that header to random values and gets a fresh budget each time. Defense: only trust X-Forwarded-For from your own edge, and strip it otherwise. 3. Weaponizing the limit: if an attacker can send requests as a victim's key, they can deliberately trip the limit and get the victim throttled — turning your defense into their denial-of-service. Defense: authenticate before you attribute, and separate identities cleanly.
Where it lives in the chainRate limiting sits in the L7 filter chain right after authentication and before inspection — you want to know who the client is (to key the limit correctly) and to shed excess load before paying for the expensive content scan. In Envoy that's the local_ratelimit / ratelimit HTTP filter ordered ahead of your inspection filter; in Nginx it's a limit_req directive in the relevant location.
RememberRate limiting is a token bucket (rate R + burst B) or a leaky bucket, costing O(1) per request. Nginx's limit_req uses a shared-memory zone so its worker processes agree; Envoy offers a per-instance local bucket (cheap, approximate) and a global RLS over gRPC+Redis (accurate across instances, one extra hop). Key the limit on identity, not just IP.
In Part 1 the Bloom filter was the cheap first gate; in Part 2 we read the request at L7 and shed load with rate limiting. Now the heart of the gateway: the exact content scan — how it matches thousands of patterns fast (Aho-Corasick), and how all the stages fit together into one sub-millisecond, fail-closed pipeline.
Part 3 of 6

Inspecting content: one automaton, then the whole pipeline

First the algorithm that makes exact scanning fast enough to run on every request, then the deliberately-ordered pipeline it lives in — with the millisecond budget and the fail-closed rule that ties back to the whole map.

3.1  Aho-Corasick: matching thousands of patterns in one pass

Prerequisite — two structuresA trie (prefix tree) stores a set of strings as a tree where every edge is a character and every path from the root spells a string; strings that share a prefix share the same early nodes. A finite state machine is a set of states with labelled transitions — you're always "in" exactly one state, and each input character moves you to another. Also know the naive baseline: to find k patterns in text of length n by checking each pattern separately costs about O(n · k) — hopeless when k is in the thousands.

The problem

Our inspection needs to check every request against a big dictionary of literal patterns — known secret prefixes (like an API-key header shape), PII formats, banned strings — thousands of them, within a microsecond budget. Scanning the text once per pattern is far too slow. We want to find every occurrence of every pattern in essentially one pass over the text.

What Aho-Corasick is, and how it works

Aho-Corasick compiles the entire pattern set into a single automaton once, up front, and then scans any text through it in linear time. It has two ingredients:

Aho-Corasick: one automaton for ALL patterns, scanned in a single passPatterns {he, she, hers}. Solid = goto edges (the trie). Dashed = a failure link (the clever part).·hersshehersshe'he' ✓'hers' ✓'she' ✓failure linkafter 'sh-e', 'he' is a suffix — jump there, don't re-readScan the text ONCE. On a mismatch, follow the failure link instead of backing up.Cost = O(text length + matches), INDEPENDENT of how many patterns you loaded.
Patterns {{he, she, hers}} become one automaton. The dashed failure link is what makes the scan single-pass.
Why is this O(n), not O(n·k)?Because the text pointer only ever moves forward. Every character you read advances the automaton by one goto edge; a failure link changes state without consuming a new character, and the theory guarantees the total number of failure-link hops across the whole scan is bounded by the number of characters read. So you touch each character a constant number of times regardless of how many patterns are loaded. Total work is O(n + z) where n is text length and z is the number of matches found — the pattern count k vanishes from the runtime (it only affects the one-time build and the memory for the automaton). This is the same insight as the KMP single-pattern algorithm, generalized to many patterns at once.
The numbersSay you watch 10,000 patterns and a request is 1 KB. Naive scanning is on the order of 10,000 × 1,000 ≈ 10 million character comparisons per request. Aho-Corasick is ~1,000 state transitions — the length of the text — roughly 10,000× less work, and that ratio grows as you add patterns. The automaton is built once and reused for every request, so the build cost amortizes to nothing.
Alternatives & when NOT to use itIf you have one pattern, use a single-pattern matcher (KMP or Boyer-Moore) — simpler. If your patterns are true regular expressions (not literal strings), you need a regex automaton; a naive backtracking regex engine risks catastrophic backtracking (ReDoS), so production systems reach for Hyperscan — Intel's SIMD-accelerated multi-regex library that, like Aho-Corasick, guarantees linear-time matching. If your dictionary is only a handful of strings, the constant factors aren't worth it. Aho-Corasick is the right tool specifically for many literal strings, matched hot, in guaranteed linear time.
The attacker's angleTwo things. Evasion: literal matching is defeated by disguising the pattern — Unicode homoglyphs (a Cyrillic "а" for a Latin "a"), zero-width characters, mixed encodings. That's precisely why normalization must run before the matcher (next section). Complexity attacks: a virtue of Aho-Corasick is that it is immune to catastrophic backtracking — an attacker cannot craft an input that makes matching blow up exponentially, the way they can against a naive regex. That algorithmic predictability is itself a security property: your inspection latency stays bounded even under adversarial input.
Config: the pattern dictionaryThe patterns are data, not code — a dictionary shipped as config. Adding a rule means updating that dictionary, which triggers a rebuild of the automaton, which the control plane then pushes to every data-plane instance (the xDS flow from Part 1). You never redeploy the proxy binary to add a pattern; you change the list and it propagates live.
RememberAho-Corasick compiles all patterns into one automaton and scans text in O(n + matches) — the pattern count drops out of the runtime because the text pointer never moves backward, thanks to failure links. Use it for many literal strings; use Hyperscan for many regexes; normalize first so disguised patterns can't slip past.

3.2  The full pipeline: normalize → Bloom → Aho-Corasick → verdict

Prerequisite — the pieces, now assembledYou already have the parts: normalization (folding text to a canonical form), the Bloom filter (Part 1: O(1) "definitely no / maybe"), Aho-Corasick (3.1: exact multi-pattern scan), a ~1 ms budget, and fail-closed behaviour (from the security track: errors must block, never pass). This section is about the order they run in, and why that order is the whole design.

The problem

Every prompt must get a verdict — allow, redact, or block — before it reaches the model, within about a millisecond, safely, and without adding latency the user can feel. No single technique does all of that, so we stage them.

The full inspection pipeline: prompt to verdict in ~1 millisecondOrder is deliberate: block evasion first, then cheapest filter, then exact scan, then decide.promptraw text1 NORMALIZEunicode fold,decode, lower2 BLOOMO(1) prefilter3 AHO-CORASICKexact multi-scan4 VERDICTdecide99% clean exit here → skip the scanallow → modelredact → modelblock → 403whole path ≤ ~1 ms → so it's C++, zero-copyany stage errors → FAIL CLOSED (block)normalize FIRST so homoglyph/encoding tricks can't slip past the literal matcher.
Each stage is placed for a reason: evasion-proof first, cheapest filter next, exact scan on the survivors, then decide.

How it works — and why each stage is where it is

Why fail closed, and why this order can't be reshuffledThe stages form a funnel from cheap-and-broad to expensive-and-exact, so total cost is dominated by the cheap stages that handle the common case — that's how you hit the millisecond budget. Reorder it and you break something: scan-before-normalize is bypassable; scan-before-Bloom is too slow; decide-before-scan is wrong. And the whole funnel is wrapped in fail-closed: if any stage errors or blows its time budget, the request is blocked, not waved through — because a scanner that fails open means trusting traffic it never actually checked.
Where the millisecond goesNormalization is a linear pass — microseconds. The Bloom check is a handful of memory reads — nanoseconds — and it ends the story for ~99% of requests. Aho-Corasick runs only on the ~1% that survive, at linear speed — microseconds. Summed, the common path is comfortably under a millisecond. That budget is why this code is C++ with zero-copy parsing and cached verdicts (Parts 5 and the cache topic): every allocation or copy on this path is multiplied by your entire request volume.
Alternatives & when patterns aren't enoughLiteral/dictionary matching catches known bad strings. It cannot catch a novel, semantic attack — a cleverly-worded prompt injection that uses no banned token. For that you add an ML classifier (e.g. Prompt Shields) alongside the pattern stage: slower and probabilistic, but it generalizes. The two are complementary, not competing — patterns for known formats (fast, exact, cheap), ML for semantic intent (broader, costlier). Use pattern matching alone when the threats are known formats; add the model when you must catch things you didn't enumerate.
How attackers probe the pipeline — several ways1. Evasion: homoglyphs, zero-width joiners, base64 chunks — beaten by normalization running first. 2. Splitting: spread a secret across multiple requests so no single one trips a pattern — needs stateful, per-session context, not just per-request scanning. 3. Timing / DoS: flood inputs designed to hit the expensive path — bounded by the hard time budget plus fail-closed, so worst case is rejection, not bypass. 4. Indirect injection: plant the payload in a document the model will retrieve (RAG), so the user's own prompt looks innocent — which is why retrieved content must be inspected on the same pipeline, not trusted because it's "internal."
Config: adding a rule, end to endA rule has two parts, both config: the pattern (added to the Aho-Corasick dictionary and the Bloom filter) and the policy (does a match redact or block?). You edit that policy as YAML/CRD; the control plane recompiles the automaton and rebuilds the Bloom set, then pushes both to every data-plane instance over xDS — live, no restart. So "also block credit-card numbers" is a config change that propagates in seconds, exactly as promised by the 10,000-ft map in Part 1.
RememberThe pipeline is a funnel: normalize (kill evasion) → Bloom (cheap 99% exit) → Aho-Corasick (exact scan on the rest) → verdict (allow/redact/block), all under ~1 ms and wrapped in fail-closed. Order is load-bearing, and pattern matching pairs with an ML classifier to cover both known formats and novel intent.
Everything so far — the proxy, TLS, inspection — runs on Kubernetes. Now we open that box: what the cluster actually is, how a Service gives pods a stable address, and how a VirtualService adds the L7 routing the gateway needs. Watch the same control-plane→data-plane flow return at every level.
Part 4 of 6

Kubernetes for real: cluster, Service, VirtualService

The 10,000-ft cluster (control plane vs nodes), then two zooms: the Service that hides ever-changing pods behind one address, and the VirtualService that adds L7 traffic control — with the exact YAML for each.

4.1  The cluster: a control plane that decides, nodes that run

PrerequisiteA cluster is a pool of machines (nodes) managed as one. You package your app as containers that run inside pods (Part on the K8s hierarchy: a Deployment owns a ReplicaSet owns Pods). You never say "run this on that specific server" — you declare what you want and Kubernetes decides where. For that to work, something has to store your intent, choose machines, and make it real on each one.

The problem, and the answer: two kinds of machine

Turning a heap of servers into one declarative system needs a division of labour. Kubernetes splits every cluster into a control plane (the brain — decides and records) and worker nodes (the muscle — actually run your pods). This is the same brain/muscle split as the Envoy control-plane/data-plane idea from Part 1, one level down: it's the pattern Kubernetes is built on.

The Kubernetes cluster: a control plane that decides, nodes that runYou talk to the API server. It records intent in etcd. Controllers and kubelets make it real.kubectl apply YAMLCONTROL PLANE — decides & records (the brain)API serverthe only front dooretcdall cluster stateschedulerpicks a node per podcontrollersreconcile to desiredWORKER NODE — runs the pods (muscle)kubeletkube-proxypodpodkubelet ↔ API server:watch desired, report statusWORKER NODE — runs the pods (muscle)kubeletkube-proxypodpodkubelet ↔ API server:watch desired, report statusyou never place a pod by hand: your YAML → etcd → scheduler picks a node → that node's kubelet starts it.
The control plane records intent and decides placement; each worker node's kubelet makes it real and reports back.

What each control-plane piece does — and why it's separate

On each worker node: the kubelet watches the API server for pods assigned to its node, starts their containers via the container runtime, and reports health back; kube-proxy programs the node's networking for Services (next section).

Why go through all this indirection?Because it makes the cluster declarative and self-healing. You never imperatively place or restart anything; you record desired state in etcd through the API server, and controllers + kubelets drive reality to match — forever. A node dies? Its pods are rescheduled elsewhere automatically. That resilience is the entire reason to tolerate the moving parts.
This is why managed Kubernetes existsBecause the control plane is both critical and fiddly to run (etcd backups, API-server HA, upgrades), cloud providers sell it as a service — EKS, AKS, GKE run and guarantee the control plane for you, and you only manage worker nodes. That the control plane can be cleanly separated and outsourced is direct evidence of how well-isolated these responsibilities are.
When NOT to reach for KubernetesAll of this is overhead. For a single small service with steady load, a plain VM or a serverless function is simpler and cheaper — you don't need a scheduler and reconcile loops to run one container. Kubernetes earns its complexity when you have many services, need self-healing and rolling updates, and want one declarative control surface across a fleet.
The attacker's crown jewelsThe API server is the highest-value target in the cluster — control it and you control everything, so it's guarded by authentication and RBAC (role-based access control: who may do what to which objects). etcd holds every Secret in the cluster, so it must be encrypted at rest and tightly network-restricted — read etcd and you've read all the credentials. Over-broad RBAC (handing out cluster-admin) is one of the most common real-world cluster compromises.
What's written whereYour kubeconfig file holds the credentials and address your kubectl uses to reach the API server. Everything else — Deployments, Services, Secrets, and the custom resources we'll add — are API objects you kubectl apply as YAML; the API server validates them and stores them in etcd. There is no separate database or config file to edit; the cluster's entire desired state is those objects.
RememberA Kubernetes cluster = a control plane (API server = the only door, etcd = the memory, scheduler = placement, controllers = reconcile) plus worker nodes (kubelet runs pods, kube-proxy wires Services). You declare intent through the API server into etcd; controllers and kubelets make it real and keep it real.

4.2  Services: a stable address over ever-changing pods

PrerequisitePods are ephemeral: they crash, get rescheduled, scale up and down — and each new pod gets a new IP. So you can never hard-code a pod's IP. A virtual IP (VIP) is an address that doesn't belong to any one machine but is made to route to whichever real target is healthy. And cluster DNS (CoreDNS) turns names into addresses inside the cluster.

The problem

Your gateway needs to call "the model service," which is really 3 pods that keep getting replaced with new IPs. If it dialed a pod IP directly, the first reschedule would break it. Something must provide a fixed address that always lands on a live pod.

What a Service is, and how it works

A Service is that fixed address: a stable VIP and DNS name that load-balances across a dynamic set of pods chosen by a label selector.

A Service: one stable address in front of ever-changing podsPods die and get new IPs constantly. A Service is a fixed virtual IP + name that finds a live one.any clientin the clusterSERVICE (ClusterIP)stable VIP + DNS:model.default.svc → 10.0.0.5alwayspod 1 app=modelpod (dead)pod 3 app=modelselector app=model → Endpoints list (live pod IPs), kept updated automaticallykube-proxy programs each node's kernel so the VIP transparently routes to a HEALTHY pod.a pod dies → it's dropped from Endpoints → the VIP never notices. The address never changes.
The selector tracks live pods into Endpoints; kube-proxy makes the VIP route to a healthy one. The address never changes.
service.yaml
apiVersion: v1
kind: Service
metadata: {{ name: model }}
spec:
  selector: {{ app: model }}     # which pods this Service fronts
  ports: [{{ port: 80, targetPort: 8080 }}]
  type: ClusterIP            # internal-only stable VIP
Why a VIP instead of just DNS pointing at pods?Because DNS is cached — clients and libraries hold onto resolved IPs for seconds or minutes, so if DNS returned pod IPs directly, a rescheduled pod would blackhole traffic until caches expired. The VIP sidesteps this: the name resolves to one stable VIP, and the kernel re-picks a live pod per connection, instantly reflecting pod churn. Stability at the name layer, freshness at the routing layer.
The Service types (pick by reach)ClusterIP — a VIP reachable only inside the cluster (the default). NodePort — also opens a fixed port on every node. LoadBalancer — additionally provisions a real cloud load balancer with an external IP (this is what fronts the whole gateway from the internet in the Part 1 map). Headless (clusterIP: None) — no VIP at all; DNS returns the individual pod IPs, for stateful apps that must address specific pods.
Alternatives & when notUse a headless Service for StatefulSets (databases, brokers) where each pod has a stable identity and clients need to reach a specific one. Reaching for raw pod IPs is almost always wrong — you lose the stable address and the health-aware routing that are the entire point.
The flat-network problemBy default the cluster network is flat: any pod can reach any Service's VIP. That's convenient and dangerous — a single compromised pod can probe and talk to everything, which is lateral movement. Two defenses stack here: NetworkPolicy objects restrict which pods may talk to which (a firewall in the cluster), and mTLS from the security track means even a reachable Service won't trust an unauthenticated caller. This is exactly why zero-trust matters inside the cluster, not just at the edge.
Modifying itYou change a Service by editing that YAML and kubectl apply-ing it — swap the selector to point at different pods, add ports, or change the type to expose it more widely. The VIP stays put; only what it fronts changes. No pod restarts are involved.
RememberA Service is a stable VIP + DNS name that a label selector keeps pointed at live pods, with kube-proxy programming the kernel to route the VIP to a healthy one. It exists because pod IPs are ephemeral; DNS alone can't keep up because it's cached, so the VIP gives stability at the name and freshness at the route.

4.3  VirtualServices: L7 traffic rules a Service can't express

PrerequisiteA Service load-balances at L4 — it round-robins connections to pods with no idea what HTTP request they carry (recall L4 vs L7 from Part 2). A service mesh (Istio) puts a small Envoy sidecar proxy next to every pod, so all traffic flows through Envoys that can reason at L7 and do mTLS automatically. A CRD (Custom Resource Definition) lets the mesh add new object types to Kubernetes — like VirtualService — that you manage with the same kubectl apply as built-in objects.

The problem

A plain Service can't do the things real traffic management needs: send 10% of requests to a new version (canary), route /v2/ somewhere else, match on a header for A/B tests, add retries and timeouts, or mirror traffic for testing. All of those are L7 decisions, and a Service is L4. You want to express them declaratively, without touching app code.

What a VirtualService is, and how it works

A VirtualService (Istio; the vendor-neutral equivalent is the Gateway API's HTTPRoute) is a CRD describing L7 routing for a host: match rules and weighted destinations. A companion DestinationRule names the subsets (e.g. v1, v2) it can split between.

A VirtualService: L7 routing rules a plain Service can't expressA Service round-robins at L4. A VirtualService adds HTTP rules: split, match, retry, timeout.VirtualService (YAML)host: model90% → v110% → v2 (canary)you writeistiodmesh control planexDSEnvoy sidecarenforces the rulesv1 pods (90%)v2 pods (10%)DestinationRule names the subsets(v1, v2) the split points at.same control→data flowas Part 1, now L7.canary, A/B by header, retries, timeouts, mirroring — all declarative, all pushed live via xDS.
You write the rule; istiod translates it to Envoy config and pushes it via xDS to the sidecars, which enforce it.
virtual-service.yaml
kind: VirtualService
spec:
  hosts: [ model ]
  http:
  - route:
    - destination: {{ host: model, subset: v1 }}
      weight: 90            # 90% to the stable version
    - destination: {{ host: model, subset: v2 }}
      weight: 10            # 10% canary to the new version
Why this is just Part 1's flow againTrace it: you kubectl apply the VirtualService → it's stored in etcd via the API server → the mesh control plane istiod watches it, translates the rule into low-level Envoy config, and streams it to every sidecar over xDS → the sidecars enforce the split, live. This is exactly the control-plane-writes-config, data-plane-enforces-it pattern from the very first diagram — now operating at L7, per pod. Once you see that, every mesh feature is "a CRD istiod compiles into Envoy config."
What it buys youAll declarative, no redeploys: canary and weighted rollout, A/B routing by header or cookie, traffic mirroring (shadow real traffic to a new build), retries with budgets, timeouts, fault injection for testing, and circuit breaking. Changing a rollout from 90/10 to 50/50 is a one-line edit that propagates in seconds.
Alternatives & when NOT toAt the cluster edge, a plain Kubernetes Ingress gives you basic host/path routing with far less machinery; the newer Gateway API is the emerging standard that generalizes both Ingress and mesh routing. And a mesh is not free — a sidecar per pod costs memory, latency, and real operational complexity. If you don't need L7 traffic policy, mTLS everywhere, or fine-grained observability, a mesh is over-engineering; a Service (and maybe an Ingress) is enough.
The routing and control-plane risksA misconfigured VirtualService can open a route that shouldn't exist or send traffic to the wrong subset — routing bugs are security bugs. And istiod itself is a high-value target: it controls where all traffic goes and issues the mesh's mTLS certificates, so compromising it means rerouting or impersonating anything. Defenses: tight RBAC on who can edit routing CRDs, and the mesh's own mTLS so a rogue workload can't silently insert itself.
The full chain, one more timeYou write two CRDs — a VirtualService (the rules) and a DestinationRule (the subsets) — as YAML. istiod reads them from the API server, compiles them to Envoy config, and pushes via xDS to the sidecars, which enforce. So "shift 10% more traffic to v2" is a YAML edit, applied through the same API server into the same etcd, propagated the same way as everything else in this course.
RememberA Service gives you a stable L4 address; a VirtualService adds declarative L7 rules — splits, matches, retries, timeouts — on top. You write it as a CRD, istiod compiles it to Envoy config and pushes it via xDS to the sidecars. It's the Part 1 control-plane→data-plane pattern, at L7, per pod.
Every part so far leaned on the phrase "fast enough for the hot path." Now we cash that in: why C++ is fast — with numbers, and an honest account of how big the edge really is — and then the single most useful tool for keeping the request path allocation-free, string_view, including the trap that makes it dangerous.
Part 5 of 6

Why C++ is fast — proven — and string_view

The evidence behind the speed claim (the memory hierarchy, GC pauses, an honest benchmark ranking), then string_view taken fully apart: how it works, why it's free, its alternatives, and exactly when not to use it.

5.1  Why C++ is fast — and the evidence, not just the claim

Prerequisite"Fast" has three separate meanings: throughput (requests/second), latency (time per request, especially the tail — p99, p999), and predictability (no surprise pauses). Also recall the mindset topic: a compiled language turns into native machine code the CPU runs directly, while a managed runtime adds a garbage collector, a just-in-time compiler, and safety checks on top. The question is what those additions cost, and where.

The problem with the claim

We've said repeatedly "the data plane is C++ because it's fast." That's lazy unless we can say why and by how much. So let's break down the real sources of C++'s speed, put numbers on them, and — importantly — be honest about where the advantage is small.

Where the speed actually comes from

Why C++ is fast, in one picture: it controls the memory hierarchyA miss to RAM costs ~100× an L1 hit — so keeping data in cache matters more than clever code.CPU coreregisters< 1 nsL1~1 nsL2~4 nsL3~12 nsRAM~100 nscloser to the core = faster but tiny; farther = huge but slowa RAM miss ≈ 100× an L1 hit → cache behaviour, not clever code, sets real-world speed.C++ lets you lay data out contiguously so it STAYS in cache; that's the biggest lever.C++: contiguous arrays,no pointer-chasing → cache hitsmanaged: boxed objects on the heap,pointer-chasing → cache misses
The real lever: a RAM miss costs ~100× an L1 hit, and C++ lets you keep data in cache. GC-free means no pauses.
Why memory layout dominates — the chainModern CPUs are starved for data, not instructions. Reading from L1 cache takes ~1 ns; reading from main RAM takes ~100 ns — a cache miss is ~100× slower than a hit. So the number that decides real-world speed is often how many cache misses you take, not how many instructions you run. C++ lets you store data in contiguous arrays the CPU can prefetch and stream through cache. Managed languages tend to box objects individually on the heap and chase pointers between them, so iterating a collection scatters across RAM and misses cache constantly. That's why the layout control matters more than any single clever optimization.
The numbers, and an honest rankingLatency facts: L1 ~1 ns, L2 ~4 ns, L3 ~12 ns, RAM ~100 ns, SSD ~100 µs, a network round-trip ~ms — each tier is orders of magnitude apart. GC facts: managed runtimes can pause for milliseconds to collect; if your p99 latency budget is ~1 ms, a single 10 ms GC pause blows it entirely — which is exactly why a data plane avoids GC. Honest benchmark ordering: for hot numeric loops, C++ and Rust sit at the top; modern JIT'd Java and Go are often within ~1.5–3× and frequently "fast enough"; Python (interpreted) is typically 10–100× slower. So C++'s durable edge over Java/Go is not a 10× throughput gap — it's (1) no GC pauses → tighter tail latency, (2) memory control → cache locality and smaller footprint, (3) no warmup. On the per-request hot path of a proxy, those three are decisive; elsewhere they often don't matter.
Alternatives & when NOT to use C++Go is nearly as fast, far simpler and safer, with superb concurrency — which is exactly why the control plane is Go. Rust gives C++-class speed with memory safety and is increasingly the choice for new systems code. Java is excellent for throughput-oriented services. Do not reach for C++ off the hot path: its cost — memory-safety bugs, slower development, harder hiring — is enormous, and you should pay it only where the microsecond budget and tail-latency SLO genuinely demand it. "It might be faster" is not a good enough reason; "this runs on every request under a 1 ms p99" is.
The price of the speedThe same manual memory control that buys cache locality removes the runtime's safety net: C++ is where use-after-free and buffer overflows live (Part on use-after-free). And this code holds decrypted prompts, so a memory bug here isn't a crash — it's an information-disclosure breach. The speed is real, but it's bought with a security burden you must actively pay down: RAII and smart pointers by default, AddressSanitizer in CI, and fuzzing on the parsers. Fast and safe is a discipline, not a default.
Practical knobsCompile with optimization (-O2, sometimes -O3); use profile-guided optimization (compile, run on real traffic, recompile using the profile) for another few percent; enable link-time optimization to inline across files. But the biggest wins aren't flags — they're writing cache-friendly, allocation-light code (which leads straight into the next section).
RememberC++ is fast because it runs native with no GC pauses and lets you control memory layout for cache locality — and a RAM miss costs ~100× a cache hit, so layout is the real lever. Be honest: vs Go/Java the edge is tail latency + memory control, not a 10× throughput gap. Use it only on the hot path, and pay down its safety cost deliberately.

5.2  string_view: the borrow that makes parsing free — and the trap

PrerequisiteA std::string owns a heap buffer of characters; copying or assigning one allocates and copies those bytes. A pointer + length together can describe a run of characters without owning them. And recall the ownership language from the C++ track: a view (or borrow) refers to data someone else owns — cheap to pass, but only valid while the owner keeps the data alive.

The problem

Parsing one HTTP request means pulling out many substrings — method, path, protocol, and every header name and value. If each of those is a std::string, each one allocates a heap buffer and copies bytes that are already sitting in memory in the receive buffer. That's dozens of tiny allocations per request, multiplied by your entire request volume — death by a thousand mallocs, right on the hot path we just spent 5.1 protecting.

What string_view is, and how it works

A std::string_view is a non-owning pair: a const char* pointer and a size_t length — about 16 bytes total. It points into characters that live somewhere else and copies none of them.

string_view: read the bytes in place, don't copy themParsing pulls out many substrings. Copying each allocates; a view just points into the buffer.the receive buffer (already in memory):POST/v1/chat HTTP/1.1 Host: model ...std::string m = parse();malloc a NEW buffer + copy 'POST' into it→ a second copy of the bytesstring_view m = ...;just { ptr → buffer, len = 4 }→ 0 allocations, 0 copiesat 100k req/s × 10 fields, that's ~1,000,000 allocations/second avoided.but the view OWNS nothing: if the buffer is freed or changes, the view DANGLES (use-after-free).
std::string copies the bytes into a fresh heap buffer; string_view is just a pointer + length into the existing one.
The numbersA std::string substring pays a heap allocation (tens of nanoseconds) plus a byte copy; a string_view substring is a pointer adjustment (~1 ns). Parsing a request with ~10 fields at 100k req/s means ~1,000,000 allocations per second avoided by using views — and each avoided allocation also avoids the matching free and the memory-fragmentation pressure. A zero-copy parser built on string_view can do the entire parse with no heap allocations at all.
When NOT to use it — and the alternativesA view owns nothing, so it's only valid while its backing bytes are. The rules: never return a string_view to a local temporary (the buffer dies at the return, the view dangles); never store a view that outlives its buffer (e.g. past the request). Also, a string_view is not guaranteed null-terminated, so don't hand it to a C API that expects a \0. Use the alternatives when those bite: std::string when you must own the data (keep it beyond the buffer), std::span<char> for a mutable view, or a plain const std::string& when you specifically borrow from a string and want its guarantees. Rule of thumb: take string_view as a read-only parameter; return an owning type.
The security angleThe dangling-view bug is not a curiosity — it's a use-after-free (Part on UAF), and on this code path it reads decrypted prompt data, so a dangling view can leak one user's data into another's response: an information-disclosure vulnerability. Separately, passing a non-null-terminated string_view's pointer to a C function that scans until \0 causes an over-read past the buffer — potentially leaking adjacent memory. Both are caught by the same discipline as raw pointers: keep the owner alive for the view's whole life, and run AddressSanitizer to catch the ones you miss.
The practical guidelineIn APIs, accept std::string_view for any read-only string parameter — callers pass strings, literals, or buffers with no conversion or copy. Return std::string (or another owning type) when you're handing back data whose lifetime you can't guarantee. This one convention gives you allocation-free reads on the hot path while keeping lifetimes obviously correct.
Rememberstring_view is a non-owning {{pointer, length}}: parsing with it costs zero allocations and zero copies, inheriting the cache-locality win. The catch is it owns nothing — outlive its buffer and it dangles (a use-after-free), and it isn't null-terminated. Take it as a read-only parameter; return owning types.
Across Parts 1–5 we noted, at each stage, how an attacker would probe it. This finale threads those notes into one continuous kill chain from the wire to the core, then shows the three principles — defense in depth, fail closed, zero trust — that answer all of them at once.
Part 6 of 6

The attacker's whole playbook

One adversary, walked end to end through every stage of the system — and the unifying defense that turns "one bug equals breach" into "one bug equals contained."

6.1  The kill chain: one attacker, edge to core

PrerequisiteA kill chain is the ordered set of stages an attacker moves through: reconnaissance → intrusion → escalation → lateral movement → objective. Recall STRIDE (six categories of threat) and every "how an attacker sees it" note from Parts 1–5. So far we met those attacks one at a time. A real adversary chains them — and the only way to know your defenses actually hold is to walk the whole path as one story.

The problem with piecemeal defense

Each earlier part hardened one stage. But an attacker doesn't attack "a stage" — they look for the cheapest complete path from the outside to your data. If any single stage is a soft bypass, the strength of the others may not matter. So we walk the chain from the wire inward, and at each stop name the move and the control that stops it.

The attacker's path: inward through every stage, and the defense at eachOne attacker, moving left to right. Above each stage: the attack. Below: what stops it.MITM · downgrade· SSL-stripWIREcert verify,HSTS, TLS 1.3volumetricflood (DoS)EDGErate limit,keyed on IDHTTP requestsmugglingL7 PARSEone strictparserinjection,evasionINSPECTnormalize→scanfail closeduse-after-free(decrypted data)PROCESSRAII, smart ptrASan, fuzzlateral move,own istiod/etcdCLUSTER/CPmTLS, RBACNetworkPolicy↓ attackdefense in depth: every stage is independent, so beating one doesn't beat the rest — and each fails closed.
The same attacker, moving inward through six stages. Each stage has its own attack and its own independent defense.

Stage by stage

Stage 1 — the wire (Part 1)Goal: read or alter traffic before it's protected. Moves: MITM by presenting a forged certificate; downgrade to a weak protocol (POODLE); SSL-strip to keep the victim on plain http; or steal the terminating private key — the jackpot, since it decrypts everything. Stopped by: certificate-chain verification (a forged cert fails), TLS 1.3 + disabling old versions (no downgrade), HSTS (no strip), and guarding the private key as the system's crown jewel.
Stage 2 — the edge (Part 2)Goal: overwhelm the service, or slip past its budget. Moves: a volumetric flood (DoS); or defeat rate limiting with a distributed botnet (each IP under the cap), header spoofing (X-Forwarded-For to fake identity), or weaponizing the limit to throttle a victim. Stopped by: rate limiting keyed on authenticated identity not raw IP, trusting X-Forwarded-For only from your own edge, and authenticating before attributing.
Stage 3 — HTTP parsing (Part 2)Goal: smuggle a request the inspection never sees. Move: HTTP request smuggling — exploit a disagreement between two parsers about where one request ends and the next begins, hiding a second request inside the first. Stopped by: one strict, consistent HTTP parser that rejects ambiguous framing, so front and back agree on message boundaries.
Stage 4 — content inspection (Part 3 + security)Goal: get malicious content to the model, or exfiltrate data. Moves: prompt injection (direct in the message, or indirect planted in retrieved content); evasion via homoglyphs, zero-width characters, or encodings to dodge literal matching; splitting a payload across requests; algorithmic DoS aimed at the expensive matcher. Stopped by: normalize-first (evasion collapses), Aho-Corasick + Bloom (fast exact scan), an ML classifier for novel/semantic attacks, per-session context for splitting, and a hard time budget with fail-closed so a flood becomes rejection, never bypass.
Stage 5 — the process (Part 5 + C++ memory)Goal: read data you shouldn't, or run code. Moves: trigger a use-after-free or a dangling string_view on the buffer holding a decrypted prompt, leaking one user's plaintext into another's response; or a buffer overflow toward code execution. This is the deepest breach — it's inside the trusted process, on cleartext. Stopped by: RAII and smart pointers by default (no manual free to get wrong), AddressSanitizer in CI, and fuzzing the parsers.
Stage 6 — cluster & control plane (Part 4)Goal: total compromise. Moves: from one foothold, move laterally across the flat cluster network to other services; then own the control plane — istiod (reroute all traffic, mint mTLS certs), the API server (control everything), or etcd (read every Secret). Stopped by: NetworkPolicy to segment the network, mTLS so a rogue workload can't impersonate a service, tight RBAC on who can touch routing and objects, and etcd encryption at rest.
Why walking the whole chain mattersEach stage the attacker clears hands them more leverage for the next: reading the wire enables replay; slipping the edge gives them volume; beating inspection reaches the model; a memory bug reaches cleartext; the control plane reaches everything. The design goal is not "no bug ever" — it's a chain where no single failure is catastrophic, because the next independent layer still stands.
RememberAttackers chain stages — wire → edge → parse → inspect → process → cluster/control-plane — always seeking the cheapest complete path to your data. You defend by making every stage independently hard, so beating one never means beating all.

6.2  The three principles that answer the whole playbook

Every defense in the walk above is an instance of just three ideas. If you internalize these, you can reason about a threat you've never seen.

Every move maps to a layer — and a control that fails closedattacker's movethe control that stops iton the wireMITM, downgrade, SSL-stripcert-chain verify, HSTS, TLS 1.3at the edgevolumetric flood / DoSrate limit, keyed on identityHTTP parsingrequest smugglingone strict, consistent parsercontent inspectprompt injection, evasionnormalize → scan, ML, fail closedthe processuse-after-free on plaintextRAII, smart ptrs, ASan, fuzzingcluster networklateral movement (flat net)NetworkPolicy + mTLS (zero trust)control planeown istiod / API / etcdtight RBAC, etcd encryptionVerdict: no layer trusts another; every hop authenticates, and every check denies by default.
Each attacker move maps to a specific control — and every control denies by default when it fails.
Why these three compose into safetyIndividually each is partial; together they change the game. Defense in depth means a bug is rarely reachable alone. Fail-closed means a bug that is reachable degrades to a denial, not a breach. Zero trust means even a reachable, exploited bug can't spread. Together they convert the default posture of software — one bug equals compromise — into one bug equals contained. That conversion is the entire job.
The concrete control listWire: TLS 1.3, HSTS, cert pinning where feasible, HSM-protected keys. Edge: identity-keyed rate limits, WAF. Parse: one strict HTTP parser. Inspect: normalize → Bloom → Aho-Corasick → ML, fail closed, budgeted. Process: RAII, smart pointers, ASan, fuzzing, minimal privilege. Cluster: NetworkPolicy, mTLS everywhere, tight RBAC, etcd encryption, audit logging. Each is a line of config or a coding standard — not a heroic effort, just the discipline applied consistently.
RememberThe whole playbook is answered by three principles: defense in depth (beat one ≠ beat all), fail closed (errors deny), and zero trust (prove identity every hop, least privilege everywhere). Together they turn "one bug = breach" into "one bug = contained."

Course complete — the six parts, as one picture

  1. Part 1 · The map — the whole AI gateway on Kubernetes; control plane vs data plane; TLS/mTLS; Bloom filters (why O(1)).
  2. Part 2 · Reading the request — L4 vs L7; rate limiting built for real in Nginx (shared-memory leaky bucket) and Envoy (local token bucket vs global RLS).
  3. Part 3 · Inspecting content — Aho-Corasick (one automaton, one pass, O(n+matches)); the normalize → Bloom → scan → verdict pipeline, sub-millisecond and fail-closed.
  4. Part 4 · Kubernetes for real — the cluster (control plane vs nodes), Services (stable VIP over pods), VirtualServices (L7 rules via istiod + xDS).
  5. Part 5 · Why C++ is fast — the memory hierarchy and GC-free tail latency, honestly measured; string_view and its dangling trap.
  6. Part 6 · The attacker's playbook — the kill chain end to end, answered by defense in depth, fail-closed, and zero trust.

Every topic followed one path: prerequisite → problem → how it works (why, why, why) → evidence → alternatives & when not → where it lives → how it's attacked → config → remember. That structure is the real deliverable — apply it to the next unfamiliar system and you can teach yourself anything.