FIELD MANUAL · ALT 10,000 FT → 1 CM

How a request survives a billion-RPS gateway.
Nginx · Envoy · Kubernetes · mTLS.

A single HTTP request enters a datacenter and dies or gets answered in ~50 ms. This manual follows it down through every layer — load balancer, nginx event loop, rate limiter, bloom filter, kube-proxy, Envoy sidecar, mTLS handshake — and at every fork explains why this design and not the other one.

CLIENTTCP+TLS ANYCAST/L4ECMP · IPVS NGINXedge · L7 INGRESSk8s Service POD (shared netns) ENVOYsidecar APP:8080 ④ mTLS
FIG 0 — The whole journey. Sections below zoom into each hop; animated dashes show packet direction.
Section 00 · Read this first

The 80% in ten cards

If you internalize only this section, you can hold a credible gateway-architecture conversation. Every card links to the deep section where the "why" lives.

01 Nginx = event loop, not threads

One worker per CPU core, each running a single-threaded epoll loop over tens of thousands of sockets. No thread per connection → no context-switch tax, no stack-per-connection memory. C10K solved by waiting cheaply, not working faster. §02

02 Master/worker split

Master (root) reads config, binds ports, forks workers (unprivileged). Reload = fork new workers with new config, drain old ones. Zero-downtime by process lifecycle, not by locks. §02

03 Perf is measured, not assumed

Confirm with p99 latency under open-loop load (wrk2), USE method on the host (utilization/saturation/errors), and flame graphs. Throughput without tail latency is a vanity metric. §03

04 Rate limit = leaky bucket in shared memory

limit_req keeps per-key state in a shared-memory zone guarded by a spinlock; excess requests are delayed or 503'd. Token bucket allows bursts; leaky bucket smooths them — nginx picked smoothing. §04

05 Distributed rate limit = local + async global

Across pods you cannot lock a global counter per request (adds an RTT to every call). You run local buckets sized quota/N, and reconcile via Redis/gossip asynchronously. You trade exactness for latency — deliberately. §04

06 Auth at the edge, identity everywhere

Edge validates JWT signatures statelessly (no DB hit per request); auth_request delegates when logic is complex; service-to-service identity is mTLS certs, not passwords. §05

07 Bloom filter = cheap "definitely not bad"

10M-entry IP blocklist in ~12 MB of RAM, O(k) lookup, zero false negatives, ~0.1% false positives. Bloom answers "definitely clean / maybe dirty"; only the "maybe" pays for an exact check. §06

08 Kubernetes = reconciliation loops

Everything is a controller diffing desired state (etcd) vs actual state, forever. Services are virtual IPs programmed into iptables/IPVS by kube-proxy on every node — there is no LB "box" in the pod path. §07

09 Sidecar = network stack you don't rewrite

Envoy sits in the pod's netns; iptables redirects all app traffic through it. mTLS, retries, telemetry, rate limits become platform config, applied uniformly to every language. §08

10 mTLS = both sides prove identity

Server TLS proves the server; mTLS adds a client certificate + a signature over the handshake transcript. In a mesh, certs carry SPIFFE IDs, live ~24h, and rotate automatically. §09

HOW TO USE THIS FOR THE APPLICATION

For each card, be able to (a) state the mechanism in one sentence, (b) name the alternative that was rejected, (c) say the one number that justifies the choice (e.g., "a cross-AZ Redis hop is ~1 ms; that's why global rate limiting is async"). Section 10 drills exactly this format.

Section 01 · ALT 10,000 FT

Before nginx ever sees a byte

Type api.example.com and hit enter. At billion-requests-per-day scale, four systems have already made decisions before any L7 software runs. You must know them, because half of "nginx performance problems" are actually problems here.

1. DNS + GSLB: which region?

Authoritative DNS doesn't return one IP; a global load balancer (GSLB) returns the IP of the healthiest, nearest region, based on the resolver's location and region health. TTLs are short (30–60 s) so a region can be drained in minutes.

WHY NOT LONG TTLs?

A 24 h TTL means a dead region keeps receiving traffic for up to 24 h from caches you don't control. The cost of short TTLs is more DNS queries — trivial. The cost of long TTLs is a region-sized outage you can't route around. Asymmetric risk → short TTLs win.

2. Anycast + BGP: which datacenter edge?

The same IP prefix (say 203.0.113.0/24) is announced via BGP from 30 datacenters. Internet routers deliver each packet to the topologically nearest announcement. One IP, many physical locations — that's how a DDoS gets automatically diluted across the planet instead of concentrating on one site.

WHY NOT ONE IP PER REGION (UNICAST ONLY)?

Then attackers pick one region's IP and focus 400 Gbps on it. Anycast forces the attack to split along the attacker's own network topology. Also, client failover with unicast requires DNS re-resolution (seconds to minutes); with anycast, BGP reconverges in seconds and the client keeps the same IP.

3. L4 load balancer: which nginx machine?

Inside the datacenter, an L4 balancer (IPVS, Maglev-style consistent hashing, or router ECMP) spreads TCP flows across hundreds of nginx boxes. It looks only at the 5-tuple (src IP, src port, dst IP, dst port, protocol). It never parses HTTP.

WHY AN L4 TIER BELOW THE L7 TIER — WHY NOT JUST MORE NGINX?

Because the two layers scale on different resources. L4 forwarding is per-packet and nearly stateless — millions of packets/sec per core. L7 is per-request: TLS handshakes, header parsing, buffer allocation — thousands of requests/sec per core. If nginx boxes received traffic directly, adding/removing one would reshuffle client connections; the L4 tier with consistent hashing makes nginx boxes replaceable mid-flight. Cheap dumb layer protects expensive smart layer.

4. The kernel handshake, before your process wakes

SYN → SYN/ACK → ACK happens in the kernel. Completed connections sit in the accept queue (bounded by listen backlog and net.core.somaxconn) until nginx calls accept(). If that queue overflows, the kernel drops SYNs — clients see timeouts and no log line appears anywhere in nginx. First lesson of production debugging: the request you can't find in logs may have died in a kernel queue.

DNS / GSLBpicks region ANYCAST / BGPpicks datacenter L4 LB (IPVS/ECMP)picks machine, 5-tuple hash LINUX KERNEL SYN queue (half-open) accept queue (somaxconn) overflow ⇒ silent SYN drop accept() NGINX WORKERepoll loop — §02
FIG 1 — Three routing decisions and two kernel queues sit in front of your first line of nginx config.
Section 02 · ALT 1,000 FT

Nginx internals: how one core serves 50,000 connections

The problem nginx was built to kill: C10K

Apache's classic model: one process or thread per connection. Each thread costs a ~1 MB stack, kernel scheduling entries, and context switches (~1–5 µs each, plus trashed CPU caches). At 10,000 mostly-idle connections you burn gigabytes of RAM and most of your CPU on switching between threads that are all just waiting for bytes.

Igor Sysoev's observation (2002): a proxy's job is 99% waiting on the network and 1% computing. So don't dedicate an execution context to each wait. Instead, one process asks the kernel: "of my 50,000 sockets, which ones have something to do right now?" — and only touches those.

Process architecture: master + workers

MASTER (root)parse config · bind :80/:443 · fork · signals WORKER 0 → CPU0epoll loop · unprivileged WORKER 1 → CPU1epoll loop · unprivileged WORKER N → CPUNepoll loop · unprivileged fork() — inherit listen FDs shared memory: limit_req zones · cache keys · stats
FIG 2 — Master binds sockets as root, then drops to workers. Workers share zones via shm, nothing else.
WHY PROCESSES, NOT THREADS?

Isolation: a segfault in a worker (or a buggy 3rd-party module) kills one process, not the server. With threads, one bad pointer kills everything. No shared-heap locking: workers share almost nothing, so there are no mutexes on the hot path; the few shared things (rate-limit zones) live in explicit shm with tiny critical sections. Security: workers drop privileges; a compromised worker can't read the master's key material handling. The cost — cross-worker state needs shm — is small because a proxy is mostly stateless per request.

WHY ONE WORKER PER CORE, NOT 100 WORKERS?

Workers are never blocked (everything is non-blocking), so a worker can saturate a whole core by itself. More workers than cores just re-introduces the context switching you were escaping. The exception: heavy disk I/O can block (page-cache misses aren't pollable), which is why nginx added aio threads — a small thread pool only for blocking file reads, so the event loop never stalls.

The event loop, mechanically

WORKER LOOP (simplified from src/event/ngx_event.c)
for (;;) {
    timer = closest_timeout();            // e.g. 37ms until some client times out
    n = epoll_wait(ep, events, 512, timer); // SLEEP until sockets are ready
    for (i = 0; i < n; i++) {
        c = events[i].data.ptr;             // connection object
        c->handler(c);                      // run its state machine ONE step:
    }                                       //   read_request_line → read_headers →
    expire_timers();                        //   … → connect_upstream → send_response
}

Every connection is a state machine, and the handler pointer is the state. Read 300 bytes of a header, hit EAGAIN (no more bytes yet)? Save position, set handler to "continue reading headers", return to the loop. The worker instantly serves other sockets. When the kernel later signals readability, the machine resumes exactly where it left off. Concurrency without threads: it's cooperative multitasking where the kernel's readiness events are the scheduler.

WHY epoll, NOT select/poll?

select/poll are stateless: every call you hand the kernel the full list of N fds, kernel scans all N, you scan all N results → O(N) per wakeup even if one socket is active. At 50k connections that's 50k checks to find 3 events. epoll is stateful: register each fd once (epoll_ctl), the kernel keeps an interest list and pushes ready fds onto a ready list as interrupts arrive; epoll_wait returns only the ready ones → O(ready). Same idea as kqueue (BSD) and IOCP (Windows). This one syscall design difference is most of "how nginx scales".

WHY NOT ASYNC/AWAIT OR GREEN THREADS (GO-STYLE)?

Goroutines/coroutines are the same readiness-driven model with nicer syntax — the runtime parks a goroutine on EAGAIN and its netpoller is epoll underneath. Nginx predates those runtimes and chooses explicit state machines for zero scheduler overhead and total memory control (~a few KB per idle connection, no growable stacks). Trade-off honestly stated: nginx module code is much harder to write. For a proxy written once and run everywhere, that trade is right; for your business logic, it usually isn't — which is why your app is in Go/Java and your edge is nginx.

One request through the worker, 1 cm resolution

  1. accept. Listen fd is readable → accept4(..., SOCK_NONBLOCK). With reuseport, each worker has its own listen socket and the kernel hash-shards incoming connections — this removed the old "thundering herd" (all workers waking for one connection) and the accept-mutex workaround.
  2. TLS. SSL_do_handshake() over multiple loop iterations. Session resumption (tickets/TLS1.3 PSK) skips a full asymmetric handshake — at scale this is a double-digit % CPU difference, since one RSA/ECDSA signature costs ~100µs–1ms of pure CPU.
  3. parse. Request line and headers parsed by a hand-written state machine that consumes byte-by-byte and can pause mid-token. No regex, no allocation per header — pointers into the read buffer.
  4. 11 phases. The parsed request walks nginx's phase pipeline: POST_READ → SERVER_REWRITE → FIND_CONFIG → REWRITE → PREACCESS (limit_req/limit_conn live here) → ACCESS (allow/deny, auth_request) → PRECONTENT → CONTENT (proxy_pass/static) → LOG. Modules register into phases — this is why config order sometimes doesn't matter (phase order wins) and why rate limiting runs before auth: reject cheap before verifying expensive.
  5. upstream. Non-blocking connect() to the backend — ideally reused from a keepalive pool (saves a 3-way handshake, and with TLS an entire handshake, per request). Request forwarded; response streamed back through buffers.
  6. respond. For files: sendfile() — kernel copies page cache → socket, zero trips through userspace. For proxied bodies: buffer chain. tcp_nopush coalesces headers+body into full packets.
  7. log + reset. Access log line buffered; connection either closes or re-arms as keep-alive with a tiny memory footprint, request-scoped memory freed in one shot.

Memory pools — why nginx doesn't call malloc per header

Each request gets a pool: bump-pointer allocation from a slab; nothing is individually freed; when the request ends, the whole pool is destroyed at once. Consequences: allocation is ~a pointer increment, zero fragmentation over months of uptime, and no per-object leak class (you can't leak what dies with the pool). The trade-off — you can't free early — doesn't matter for request-scoped lifetimes measured in milliseconds.

Buffering: the impedance-matching job

A backend can emit a 2 MB response in 20 ms; a mobile client may need 20 s to receive it. If nginx streamed 1:1, the backend worker/connection is held hostage for 20 s (this is the classic slow client attack surface too). With proxy_buffering on, nginx slurps the response into memory (proxy_buffers) or temp file, frees the backend in 20 ms, and babysits the slow client itself — which costs it almost nothing (one epoll entry). Turn buffering off only for streaming/SSE/LLM token streams where time-to-first-byte matters more than backend efficiency. Notice this is exactly the IA-gateway tension: response inspection wants buffering; streaming UX wants none — hence chunk-wise scanning designs.

Section 03 · Utilization & proof

Making nginx fast — and proving it's fast

The tuning set that actually matters (with the why)

/etc/nginx/nginx.conf — annotated production baseline
worker_processes auto;              # one per core. More = context switching, not speed
worker_rlimit_nofile 200000;        # each conn = 1 fd client + often 1 fd upstream

events {
    worker_connections 65535;       # per worker. Real cap = fds & memory, not CPU
    multi_accept on;                # drain accept queue in bursts
}

http {
    sendfile on;                    # zero-copy file→socket, skips userspace
    tcp_nopush on;                  # full packets: headers+body coalesced
    keepalive_timeout 65s;          # client side: amortize TCP+TLS handshakes
    keepalive_requests 1000;

    ssl_session_tickets on;         # resume TLS w/o full asymmetric crypto
    ssl_protocols TLSv1.2 TLSv1.3;

    upstream app {
        server 10.0.1.10:8080;
        server 10.0.1.11:8080;
        keepalive 128;              # pooled upstream conns — the single biggest
    }                               #   proxy win: no handshake per request
    server {
        location / {
            proxy_pass http://app;
            proxy_http_version 1.1;         # required for upstream keepalive
            proxy_set_header Connection ""; # don't forward "close"
        }
    }
}
WHY IS worker_connections 65535 NOT "65k RPS"?

Connections ≠ requests. 65k concurrent sockets per worker is a memory/fd budget (~10 KB each idle). RPS depends on work per request (TLS? gzip? body size?). A 32-core box does ~1M idle keepalive connections easily but maybe 300k small-response RPS and only ~30–60k new full TLS handshakes/sec — handshake CPU is usually the real edge bottleneck, which is why session resumption and connection reuse dominate tuning.

WHY gzip_static / caching BEFORE more hardware?

The fastest request is one you don't process: proxy_cache for idempotent GETs, pre-compressed assets, 304s via ETag. A cache hit is ~50µs of nginx time vs multi-ms of backend time. Capacity math: 30% cache hit ratio ≈ 43% more effective backend capacity, for free.

Confirming performance: the methodology, not a number

1. Load correctly — open-loop. Closed-loop tools (ab, default wrk) wait for a response before sending the next request, so when your server slows, the tool politely slows too and hides the collapse. This is coordinated omission. Use wrk2 / Vegeta at a fixed arrival rate like real users:

wrk2 -t16 -c1000 -d120s -R 80000 --latency https://edge/api/ping
# -R 80000: 80k RPS constant, regardless of how the server suffers
  p50 1.9ms · p99 12ms · p99.9 41ms   ← the tail IS the product

2. Find the knee. Step the rate: 20k → 40k → 60k… Plot p99 vs offered load. Healthy systems are flat then hockey-stick. Your usable capacity is ~70–80% of the knee, not the knee — queues need headroom for bursts (queueing theory: at ρ→1, wait time →∞).

3. Diagnose with USE per resource. For CPU, memory, NIC, disk ask: Utilization? Saturation (queue)? Errors?

# CPU: user vs sys split — high %sys means kernel/network, not your config
mpstat -P ALL 1
# accept-queue overflow = invisible drops (the silent killer from §01)
ss -lnt        # Recv-Q vs backlog on :443
netstat -s | grep -i "listen"   # "times the listen queue overflowed"
# where is CPU actually going? flame graph:
perf record -F 99 -a -g -- sleep 30 && perf script | stackcollapse-perf.pl | flamegraph.pl > f.svg

4. Watch nginx's own truth. stub_status (active/reading/writing/waiting) plus log-derived histograms of $request_time and $upstream_response_time. The difference between those two numbers is nginx+client time — the fastest way to answer "is it us or the backend?".

WHY p99 AND NOT AVERAGE?

At 1B requests/day, p99 = 10 million bad experiences daily. Worse: a single page fans out to ~50 backend calls; P(page hits at least one p99) = 1−0.99⁵⁰ ≈ 39%. Tail latency compounds through fan-out; averages hide it completely. Big-tech SLOs are stated in percentiles for this exact arithmetic.

Section 04 · Admission control

Rate limiting: one box, then one thousand pods

Choose the algorithm by what "1000 req/s" should mean

AlgorithmMechanismBursts?Reject whenUse when
Fixed windowcounter resets every second2× at boundary (999 at t=0.99s + 999 at t=1.01s)counter > limitalmost never — boundary bug
Sliding windowweighted blend of this+last windowsmoothedestimate > limitgood default, cheap, approximate
Token buckettokens drip in at rate r, burst = bucket size byes, up to b instantlybucket emptyAPIs where short bursts are legitimate (Envoy, AWS)
Leaky bucketqueue drains at fixed rateno — output is perfectly smoothqueue fullprotecting a backend that hates spikes (nginx limit_req)
WHY DID NGINX PICK LEAKY, ENVOY PICK TOKEN?

Different customer. Nginx's limiter historically protects your servers — smoothing arrival to a fixed drain rate is exactly backend protection. Envoy/API platforms enforce customer quotas — a paying client who was idle for 10 s reasonably expects to send 20 requests at once; token bucket encodes "average r with grace b". Neither is "better"; they encode different contracts. Interview gold: state the contract first, then the algorithm.

Inside limit_req at 1 cm

nginx.conf
limit_req_zone $binary_remote_addr zone=perip:100m rate=10r/s;
# 100 MB shared memory ≈ state for ~800k distinct client keys
server {
  location /api/ {
      limit_req zone=perip burst=20 nodelay;
      limit_req_status 429;
  }
}

Now do it across 200 gateway pods

Kubernetes scales your gateway horizontally, and your per-pod limiter silently becomes 200 × limit. A client spraying requests across pods (which the L4 balancer does for them) gets 200× quota. Three honest designs:

A · LOCAL ONLY pod: r/N pod: r/N +0 ms · no dependency unfair if traffic skewed wrong when N autoscales B · SYNC GLOBAL (Redis/RLS) pod pod Redis INCR exact +0.5–2 ms EVERY request · SPOF C · LOCAL + ASYNC SYNC ✓ local bucket local bucket aggregator +0 ms hot path · fair over ~100 ms briefly inexact — acceptable
FIG 3 — Distributed limiting: decide on hot-path latency first, exactness second.
WHY NOT SYNC-GLOBAL (B), IT'S "CORRECT"?

Do the math you'd be asked to do live: 200 pods × 5k RPS = 1M Redis ops/s just for limiting; every request eats a network RTT (~0.5 ms same-AZ, ~1–2 ms cross-AZ) — that can exceed your entire gateway processing budget; and Redis down now means either fail-open (no limiting during an attack — the exact moment you need it) or fail-closed (self-inflicted outage). So production systems (Envoy's global RLS included) put a local decision first and reconcile globally out-of-band: each pod spends a locally cached allowance, periodically reports usage, receives an adjusted share. Precision error is bounded by (sync interval × rate); with 100 ms sync it's invisible to humans and fatal to abusers. Rate limiting is capacity protection, not billing — approximate is the correct spec. (Billing/quota-with-money is the one case where you accept design B.)

LAYERED LIMITS IN A REAL GATEWAY

L4 SYN-rate per IP (kernel/XDP) → nginx limit_conn (concurrent) → limit_req per IP (anti-abuse) → per-API-key token bucket (business quota, headers X-RateLimit-Remaining) → per-tenant concurrency at the app. Each layer rejects as early — hence as cheaply — as possible. Rejecting at L4 costs ~1 µs; rejecting after auth costs ~1 ms; rejecting at the DB costs an outage.

Section 05 · Identity at the edge

Auth: verify at the door, carry identity inward

The core principle: stateless verification on the hot path

1B requests/day ≈ 12k RPS average, 50k+ peak. If every request needed a session-store lookup ("is token abc123 valid?"), the session store becomes your global bottleneck and blast radius. So modern edges verify signed tokens (JWT): the proof of validity travels inside the request.

JWT anatomy: base64url(header).base64url(claims).signature. Claims carry sub (who), exp (until when), iss/aud (from whom, for what), scopes. The signature is computed by the identity provider's private key; the gateway verifies with the public key fetched once from the IdP's JWKS URL and cached.

Hot-path cost: one ECDSA/RS256 verify ≈ 20–80 µs CPU, zero I/O, zero shared state. Verification scales linearly with cores — the IdP is only touched at login and at key rotation.

WHY NOT OPAQUE SESSION TOKENS EVERYWHERE?

Opaque tokens give instant revocation (delete the row) but cost a lookup per request. JWTs cost nothing per request but can't be un-signed — revocation is "wait for exp". Production compromise: short-lived access JWTs (5–15 min) + long-lived refresh tokens that ARE checked against a store. Compromise window = minutes; hot path = stateless. For the rare instant-kill (fired employee, leaked key) keep a small in-memory denylist of jti/subjects — a bloom filter (§06) makes that check ~free.

WHY VERIFY AT THE GATEWAY AND NOT ONLY IN EACH SERVICE?

Both, actually — but for different things. Gateway verification rejects garbage before it consumes internal capacity (unauthenticated junk never touches a microservice) and centralizes IdP config. Services still verify the token (or the mesh's mTLS identity) because "the network is trusted" dies the day one internal box is popped — that's the zero-trust argument, and it's why §08–09 exist.

Three nginx auth mechanics you should be able to sketch

1 · native JWT (nginx-plus / njs / lua) — verify inline
location /api/ {
    auth_jwt "api" token=$http_authorization;
    auth_jwt_key_request /jwks;          # cached public keys
    proxy_set_header X-User $jwt_claim_sub;   # identity flows inward as a header…
    proxy_pass http://app;                    # …signed variant: re-mint an internal token
}

2 · auth_request — subrequest to a policy service (complex logic)
location /api/ {
    auth_request /_authz;                # internal subrequest; 2xx=allow 401/403=deny
    auth_request_set $uid $upstream_http_x_user;
    proxy_pass http://app;
}
location = /_authz {
    internal;
    proxy_pass http://authz-svc/check;   # gets original URI+headers, returns verdict
    proxy_pass_request_body off;         # verdict needs headers, not the 10MB body
    proxy_cache authz_cache;             # cache allow-verdicts a few seconds
}

3 · mTLS client certs — machines, not humans (§09 for the crypto)
ssl_verify_client on;
ssl_client_certificate /etc/nginx/ca.pem;
proxy_set_header X-Client-DN $ssl_client_s_dn;

auth_request is the pattern Envoy generalizes as ext_authz — same shape: the data plane asks a sidecar/service "yes or no?" per request, with caching to keep the added latency at ~0 for repeat callers. Know the failure question: what does the gateway do when the authz service is down? Fail-closed for admin APIs, fail-open-with-alarm is sometimes accepted for read-only public data — say the words "explicit fail-policy per route" and you sound like you've operated this.

Section 06 · Hostile traffic

Security at scale: bloom filters and throwing away the bad 30%

At a real edge, a third or more of traffic is junk: scanners, credential stuffers, scrapers, botnets. The design goal is cost asymmetry — make each malicious request cost you nanoseconds and the attacker a real resource.

Know the layer, know the defense

AttackWhat it exhaustsDefense & why it works
Volumetric flood (UDP/amp, 400 Gbps)your uplink bandwidthAnycast dilution + upstream scrubbing. Software can't help — the pipe is full before nginx exists.
SYN floodkernel half-open tableSYN cookies: encode the connection state into the SYN/ACK sequence number → server stores nothing until the client proves liveness by echoing it. Statelessness beats a memory-exhaustion attack by refusing to have memory.
Slowloris / slow-readconnection slotsNearly free against nginx (idle epoll entry ≈ KBs, vs a whole Apache thread) + client_header_timeout, limit_conn. Architecture as defense.
HTTP flood (looks legit)backend CPU/DBRate limits (§04), caching, fingerprinting + reputation (below), progressive challenges.
Credential stuffingyour users' accountsPer-IP and per-username limits (attackers rotate IPs, not target lists), breached-password checks, IP reputation.

Bloom filters: the datastructure of "is this one of the 10 million bad ones?"

You have a 10M-entry IP/token reputation blocklist, updated every minute, and 300k lookups/sec across workers. A hash set of strings costs ~600 MB+ and cache-miss-heavy probes; a remote lookup costs an RTT. A Bloom filter costs ~12 MB and answers in ~100 ns — with one honest caveat.

INSERT ip=198.51.100.7 h1(x)=2 · h2(x)=9 · h3(x)=17 → set those bits 0 0 1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 1 0 QUERY any of the k bits = 0 → DEFINITELY NOT IN SET (no false negatives, ever) all k bits = 1 → PROBABLY in set — bits may be set by other keys → false positive, rate p sizing: m ≈ −n·ln p ⁄ (ln 2)² · k ≈ (m/n)·ln 2 — worked example directly below
FIG 4 — Three hash functions set/check three bits. Asymmetric certainty is the whole trick.

Worked sizing (do this on a whiteboard): n = 10M entries, target false-positive p = 0.1%. Bits per entry ≈ 1.44·log₂(1/p) = 1.44·9.97 ≈ 14.4 bits. Total m ≈ 144 Mbit ≈ 18 MB; optimal hash count k ≈ 0.693·14.4 ≈ 10. (Relax to p = 1% and it's 9.6 bits/entry ≈ 12 MB, k = 7.) Compare: exact hash set of IPv6 strings ≈ 50–100 bytes/entry ≈ 0.5–1 GB. Per lookup: k memory reads, no branches, no allocation.

THE DEPLOYMENT PATTERN

Bloom filter in every worker/pod (18 MB is nothing; rebuilt and pushed each minute as a static blob — no coordination). Fast path: bit check says "definitely clean" for ≥99% of honest traffic → zero extra cost. "Maybe dirty" (real hits + 0.1% innocents) → confirm against the exact store (local RocksDB or Redis) before acting. Net effect: the expensive exact check runs on ~0.1% of clean traffic instead of 100%.

WHY NOT JUST A HASH SET / WHY NOT CUCKOO?

Hash set: 30–50× more memory, and at 10 pods × 200 workers that's the difference between "fits in L2-ish working set" and "evicts everything else". Cuckoo filters do support deletion and can beat Bloom below p≈3%, and are the right answer if your blocklist needs frequent removals — say that. Bloom's counter-answer for this use case: the set is rebuilt wholesale every minute anyway, so deletion is free via replacement, and Bloom's code is 40 lines you can audit. Also name the sibling: count-min sketch for "which keys are hot right now" (heavy-hitter detection feeding the rate limiter) — same family, frequency instead of membership.

Deciding who's suspicious in the first place

  1. Fingerprint beyond the IP. TLS ClientHello ordering (JA3/JA4 hash) exposes the actual client library — a "Chrome" User-Agent with a Python-requests JA3 is lying. HTTP/2 SETTINGS order and header order fingerprints similarly. Attackers rotate IPs cheaply; rotating a TLS stack is expensive. Always key detection on what's expensive to fake.
  2. Behavioral stats per key. Request-rate percentiles, path entropy (scanners touch many 404s), inter-arrival regularity (bots are metronomes, humans are bursty), impossible geography (same session, two continents, 3 min apart). Count-min sketches + EWMA keep this O(1) per request.
  3. Score, don't binary-block. Reputation score per fingerprint → tiered response: full service / rate-limited / challenged / tarpitted / dropped.
WHY GRADUATED RESPONSE, NOT INSTANT BAN?

Three reasons. False positives are customers: a university NAT or carrier CGNAT puts 10k humans behind one IP — banning it is a self-DoS. Feedback denial: a hard 403 tells the botnet operator exactly when detection triggered, so they binary-search your threshold; a silent 20% slowdown or tarpit (accept, drip bytes) wastes their fleet and blinds their tuning. Evidence: challenged-but-passing traffic labels your training data. Blocking is the last rung, not the first.

PIPELINE ORDER — CHEAPEST CHECK FIRST, ALWAYS

XDP/kernel drop of known-bad prefixes (~ns) → SYN cookies (~ns) → bloom reputation check (~100 ns) → limit_req (~µs) → JWT verify (~50 µs) → WAF/body rules (~100 µs–ms) → ML anomaly scoring (ms, async where possible) → backend. Each stage sheds traffic so the next, pricier stage sees less. That single ordering sentence is the architecture.

Section 07 · ALT 100 FT

Kubernetes: the machine that runs your gateway fleet

The one idea underneath everything: reconciliation

Kubernetes is not a scheduler with an API bolted on. It is a database of desired state (etcd) plus a swarm of controllers, each running the same loop forever: observe actual state → diff against desired → act to shrink the diff. You never command "start a container"; you declare "3 replicas should exist" and controllers conspire until reality matches. This is why K8s self-heals: a dead pod is just a diff, and diffs get fixed.

WHY DECLARATIVE + LEVEL-TRIGGERED, NOT IMPERATIVE COMMANDS?

Imperative ("run X on node 7") requires the commander to see every failure and re-issue — miss one event and state drifts forever. Level-triggered reconciliation only needs the current state to be readable: crash a controller, restart it, it re-lists, re-diffs, converges. Edge-triggered systems must never miss an event; level-triggered systems merely need to look again. At datacenter scale, "you will miss events" is a law of physics, so K8s chose the model that survives it.

CONTROL PLANE kube-apiserverauthn·authz·admission·watch etcd (raft)only stateful piece schedulerfilter → score → bind controller-mgrdeploy/rs/node loops everything talks ONLY via the apiserver; nothing talks to etcd directly EVERY WORKER NODE kubeletwatches "my pods" → CRI kube-proxyServices → iptables/IPVS containerd + runcnamespaces + cgroups CNI pluginpod IPs · routes pods (each = shared netns, one IP) envoy | apptalk over localhost envoy | apptalk over localhost watch
FIG 5 — Control plane decides; every node independently makes its slice of the decision real.

What each piece really does (and the why behind its shape)

Services and kube-proxy: the load balancer that isn't there

A Service (ClusterIP 10.96.7.20:80) is a virtual IP — no process listens on it, no box hosts it. kube-proxy on every node watches Services/EndpointSlices and programs the kernel: "packets to 10.96.7.20:80 → DNAT to one of [pod IPs]". The load balancing happens inside the sender's own kernel at connection time.

conceptually, what kube-proxy writes (iptables mode)
-A KUBE-SERVICES -d 10.96.7.20/32 -p tcp --dport 80 -j KUBE-SVC-XYZ
-A KUBE-SVC-XYZ -m statistic --mode random --probability 0.333 -j KUBE-SEP-A   # → 10.244.1.5
-A KUBE-SVC-XYZ -m statistic --mode random --probability 0.500 -j KUBE-SEP-B   # → 10.244.2.9
-A KUBE-SVC-XYZ -j KUBE-SEP-C                                                  # → 10.244.3.4
WHY iptables vs IPVS vs eBPF — AND WHEN IT MATTERS

iptables rules are a linear chain: with 10k services the first packet of a connection walks O(n) rules, and every update rewrites the whole table (minutes at scale). IPVS uses an in-kernel hash table: O(1) lookup, incremental updates, real algorithms (least-conn, sh). eBPF (Cilium) goes further: replaces both kube-proxy and much of iptables with programmable kernel maps, socket-level LB (connect-time, so no per-packet NAT at all). Under ~1k services, all three feel identical — say that too; picking IPVS "because scale" for a 50-service cluster is cargo cult.

WHY IS SERVICE LB CONNECTION-LEVEL, AND WHY DOES THAT BREAK gRPC?

DNAT is decided when the TCP connection opens; every request on that connection goes to the same pod. gRPC/HTTP2 multiplexes thousands of requests over one long-lived connection → one pod gets hammered, others idle. Fixes: L7-aware balancing — which is precisely Envoy's job (§08). This one fact explains half of "why do I need a mesh if K8s already load-balances?"

Traffic from the internet inward

NodePort (every node opens 3xxxx port) → LoadBalancer (cloud LB → NodePorts) → Ingress / Gateway API: one L7 proxy deployment (nginx-ingress or Envoy) receives everything and routes by host/path to Services. The ingress controller is just the reconciliation pattern again: watch Ingress objects → render nginx.conf / Envoy xDS → hot-reload. Everything you learned in §02–06 applies verbatim to that pod fleet; §04's distributed rate limiting exists because the ingress is a fleet.

Section 08 · The sidecar

Envoy: the network stack you deploy instead of writing

Why the sidecar pattern exists at all

Every service needs the same networking features: mTLS, retries with budgets, timeouts, circuit breaking, telemetry, rate limits. Option A: a library per language (Java, Go, Python, Node…) — you now maintain N implementations, and upgrading means redeploying every service on every team's schedule. Option B: put one proxy next to each service instance, and force all traffic through it. Features become platform config; the app thinks it's talking plaintext on localhost.

WHY A SIDECAR PER POD, NOT ONE PROXY PER NODE?

Per-pod gives per-service identity (the cert in §09 belongs to this service account, not "whatever runs on node 7"), per-service config/blast-radius, and clean cgroup accounting of proxy cost. Per-node halves memory overhead but mixes identities and couples tenants. The industry is actually oscillating: Istio ambient mode splits the difference (per-node L4 "ztunnel" for mTLS + optional per-namespace L7 "waypoint" proxies) precisely because per-pod Envoys at 10k pods cost real RAM and upgrade pain. Knowing both directions of this trade — and that it's still moving — is a staff-level answer.

How traffic gets captured: the iptables trick

The app is unmodified — so who reroutes its packets? An init container (injected by the mutating admission webhook from §07, or a CNI plugin) runs once with NET_ADMIN and writes iptables rules inside the pod's own netns:

inside the pod netns (conceptual)
iptables -t nat -A OUTPUT   -p tcp -j REDIRECT --to-port 15001  # app's outbound → envoy
iptables -t nat -A PREROUTING -p tcp -j REDIRECT --to-port 15006 # inbound → envoy first
# envoy's own uid 1337 exempted, or loops forever

Envoy learns the packet's original destination via SO_ORIGINAL_DST and routes accordingly. The app dialed payments:8080; Envoy intercepted, upgraded to mTLS, applied policy, load-balanced to a specific pod, and told nobody.

downstream ENVOY WORKER THREAD (event loop — same model as nginx §02) Listener:15006 · TLS HTTP filter chainjwt_authn → ext_authz → ratelimit → … Routermatch host/path Clusterendpoints · LB · outlier upstream
FIG 6 — Envoy's four nouns. Config for each arrives live over xDS; nginx's equivalent is a file + reload.

The four nouns + the API that feeds them

WHY ENVOY FOR THE MESH AND NOT NGINX?

Not speed — both are C/C++ event loops of comparable throughput. The decisive feature is xDS: config as a live gRPC stream. A mesh reprograms routes/endpoints/certs thousands of times an hour as pods churn; Envoy applies deltas in-memory with zero connection loss, while nginx's model is render-file-and-reload (fork new workers) — fine hourly at the edge, unusable at mesh churn rates. Plus first-class gRPC/HTTP2 semantics and per-route stats. Conversely at the internet edge, nginx's maturity, ecosystem, and static-file performance keep it the default. Right tool per tier; many shops run nginx at the edge and Envoy inside — knowing why both is the answer.

Istio in three sentences

istiod (control plane) watches K8s (Services, pods, its own CRDs like VirtualService/DestinationRule), compiles them into per-proxy Envoy config, and streams it over xDS. The webhook injects the sidecar + init container into every pod at admission. istiod is also the CA: it signs each sidecar's workload certificate — which is the bridge into §09.

Section 09 · ALT 1 CM

mTLS: both ends prove who they are — down to the bytes

First, plain TLS 1.3 in one breath

ClientHello (supported ciphers + client's ephemeral ECDHE public key) → ServerHello (chosen cipher + server's ephemeral key) → both sides compute the same shared secret (ECDHE) → server sends its certificate + CertificateVerify (a signature over the handshake transcript made with the private key matching the cert) → Finished. Client checks: cert chains to a trusted CA, name matches, signature valid. One round trip; everything after ServerHello is already encrypted.

WHY EPHEMERAL DH + A SIGNATURE, NOT "ENCRYPT WITH THE SERVER'S RSA KEY"?

Old-style RSA key exchange meant: record traffic today, steal the server key next year, decrypt everything retroactively. Ephemeral ECDHE keys die after the handshake → forward secrecy: the long-term key only ever signs, never encrypts, so its theft compromises future impersonation but not past traffic. TLS 1.3 removed static-RSA exchange entirely for this reason.

What the "m" adds

CLIENT ENVOY SERVER ENVOY ClientHello + keyshare ServerHello + keyshare · CertificateRequest · server cert + verify client Certificate (SPIFFE ID in SAN) + CertificateVerify + Finished server checks: chains to mesh CA? signature over transcript valid? identity allowed by policy? encrypted app data — both identities cryptographically pinned
FIG 7 — Three additions: CertificateRequest, client Certificate, client CertificateVerify.

The CertificateVerify is the part people miss: presenting a cert proves nothing (certs are public). The client signs the entire handshake transcript with its private key — proving possession of the key and binding the proof to this handshake, so it can't be replayed into another session.

WHY mTLS FOR SERVICES BUT NOT FOR HUMANS?

Browsers/humans can't manage client certs (issuance, rotation, revocation across devices is misery), so humans get passwords+MFA+OIDC. Machines are the opposite: they can't remember passwords safely (secrets in env vars leak) but a platform can mint, mount, and rotate certs for them automatically. Auth mechanism follows key-management capability — that's the deep rule.

Build a working mTLS pair by hand (know the plumbing before automating it)

1 · your own CA
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
  -keyout ca.key -out ca.crt -days 365 -nodes -subj "/CN=demo-mesh-ca"

2 · server cert (SAN is what's verified — CN matching is dead)
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -keyout sv.key -out sv.csr \
  -nodes -subj "/CN=payments"
openssl x509 -req -in sv.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 30 \
  -out sv.crt -extfile <(printf "subjectAltName=DNS:payments.default.svc")

3 · client cert — identity as a URI SAN, SPIFFE-style
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -keyout cl.key -out cl.csr \
  -nodes -subj "/CN=orders"
openssl x509 -req -in cl.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 30 \
  -out cl.crt -extfile <(printf "subjectAltName=URI:spiffe://demo/ns/default/sa/orders")

4 · nginx as the mTLS server
server {
    listen 443 ssl;
    ssl_certificate     sv.crt;   ssl_certificate_key sv.key;
    ssl_client_certificate ca.crt;
    ssl_verify_client on;                      # the one line that makes it mutual
    location / { proxy_set_header X-Peer $ssl_client_s_dn; proxy_pass http://127.0.0.1:8080; }
}

5 · prove it
curl https://payments/  --cacert ca.crt                      # → 400: no client cert
curl https://payments/  --cacert ca.crt --cert cl.crt --key cl.key   # → 200

The same thing, mesh-automated

istio: two objects = encrypted + authorized fleet
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata: {name: default, namespace: prod}
spec:
  mtls: {mode: STRICT}          # plaintext to any pod in prod is now refused
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata: {name: payments-callers, namespace: prod}
spec:
  selector: {matchLabels: {app: payments}}
  rules:
  - from:
    - source:
        principals: ["cluster.local/ns/prod/sa/orders"]   # cert SAN, not IP!
    to:
    - operation: {methods: ["POST"], paths: ["/charge"]}
Section 10 · Interview drill

The why-not table: every decision, its rival, its number

Practice format: choice → rejected alternative → the quantitative reason. If you can deliver each row in ~20 seconds, you own the material.

We choseInstead ofBecause (the number)
epoll event loopthread per connectionidle conn ≈ KBs + 0 switches vs ~1 MB stack + µs-scale context switches × 50k conns
worker processes = coreshundreds of workersnon-blocking workers already saturate a core; extras only add switching
processes over threadsthreaded single processcrash blast radius = 1/N of connections; zero hot-path locks
epollselect/pollO(ready) vs O(all): 3 events from 50k fds = 3 touched, not 50,000
request memory poolsmalloc/free per objectbump-pointer alloc ≈ ns; whole-pool free; zero fragmentation over months
response buffering on1:1 streamingbackend freed in ms instead of held for a 20 s mobile client
open-loop load tests (wrk2)closed-loop (ab)closed-loop hides collapse via coordinated omission; tails understate by 10–100×
p99 SLOsaverages50-call fan-out: 1−0.99⁵⁰ ≈ 39% of pages touch the tail
leaky bucket at edgetoken bucketcontract = protect backend from spikes; token bucket is for customer burst quotas
local + async-sync rate limitsRedis INCR per requestsaves 0.5–2 ms/request and 1M ops/s; error bound = sync interval × rate
JWT at the edgesession lookup per request~50 µs CPU, zero I/O vs a store RTT on 100% of traffic; revocation via 5–15 min expiry
bloom filter reputationexact hash set10M keys: ~12–18 MB @ p≤1% vs ~1 GB; false positives pay one exact check, negatives impossible
graduated responseinstant IP bansCGNAT = 10k humans per IP; silent degradation also denies attacker feedback
declarative reconciliationimperative orchestrationlevel-triggered survives missed events; restart controller → re-list → converge
IPVS/eBPF at scaleiptables chainsO(1) hash vs O(n) linear walk; incremental vs full-table rewrites at 10k services
Envoy in the meshnginx everywherexDS live deltas at pod-churn rate vs file-render + worker re-fork per change
per-pod sidecarper-node proxyidentity = this service account, not "whoever shares the node" (ambient mode revisits this)
24 h certs, auto-rotatedlong certs + CRL/OCSPrevocation becomes a clock; distributing CRLs to 10k proxies is the harder system
identity in cert SANsIP allowlistspod IPs recycle in seconds; SPIFFE ID survives rescheduling
ephemeral ECDHEstatic RSA exchangeforward secrecy: stolen key ≠ decrypted history

Five questions to rehearse out loud

  1. "Walk me through a request from browser to pod." Use FIG 0 order: DNS/GSLB → anycast/BGP → L4 ECMP → kernel queues → nginx phases → ingress Service → kube-proxy DNAT → Envoy inbound (mTLS) → localhost app. Name one failure mode per hop.
  2. "Design rate limiting for a multi-region API." Start with the contract (protection vs quota), then layered limits, then local-buckets-plus-async-reconciliation, then the fail-open/fail-closed call.
  3. "Nginx is at 90% CPU — go." user vs sys split → TLS handshake rate (resumption? keepalive?) → flame graph → accept-queue overflow check → only then scale out.
  4. "Why is one pod hot behind a Service?" gRPC over connection-level DNAT → L7 balancing in the sidecar (least-request) fixes it.
  5. "How would you inspect AI-bound traffic in this stack?" An Envoy HTTP filter at the egress/gateway tier: buffer-or-stream trade (§02 buffering tension), cheap pattern pass before expensive ML scoring (§06 pipeline order), fail-policy per route (§05). The whole manual composes into this answer.
Section 11 · The gateway in the middle

TLS interception: seeing inside traffic that was designed to be unseeable

Everything so far was a reverse proxy: you own the destination, so you own the certificate, so decryption is your right. A secure-access / IA gateway is the opposite animal — a forward proxy: your employees and agents talking to other people's servers (openai.com, drive.google.com, a random paste site). TLS was explicitly engineered to stop a middlebox from reading that. So how does an inspection gateway do it legitimately?

Break-and-inspect, mechanically

CLIENTtrusts corp root CA GATEWAY (sub-CA) terminates TLS A · inspects plaintext · originates TLS B mints cert "CN=api.openai.com" signed by corp CA, on the fly REAL SERVERpublic WebPKI cert TLS session A TLS session B client ↔ gateway keys gateway ↔ server keys · gateway validates WebPKI chain
FIG 8 — There is no "one TLS session being tapped". There are two full sessions and a plaintext gap in the middle — the gap is where every inspection feature lives.
  1. Trust is pre-installed. Enterprise device management (MDM) pushes the corporate root CA into every managed device's trust store. This is the entire legal/technical basis: interception only works on devices that opted into the CA. Unmanaged devices get a cert warning — which is the correct outcome.
  2. Client connects. The gateway learns the intended destination from the SNI field of the ClientHello (or from explicit-proxy CONNECT api.openai.com:443). It mints — and caches — a leaf certificate for that exact name, signed by the corp CA, and completes TLS session A as if it were the server.
  3. Gateway connects outward. Session B to the real server, where the gateway behaves as a strict client: full WebPKI chain validation, hostname check, minimum TLS version. If the gateway skips this, it has silently downgraded every user's security to nothing — historically the #1 audit finding against inspection middleboxes.
  4. Between A and B is plaintext. Request headers, bodies, prompts, file uploads. This is where §12's pipeline runs.
WHY DOES CERT PINNING BREAK, AND WHAT'S THE HONEST ANSWER?

Apps that pin the exact server key (many banking/first-party mobile apps) reject the minted cert — correctly, from their point of view: the gateway is the attack they were built to detect. There is no clever fix; the operational answer is a bypass list: pinned domains are tunneled un-inspected (still logged at SNI/metadata level). Every real deployment is therefore "inspect most, bypass some, log all" — say it that way, because claiming 100% inspection is how you fail the follow-up question.

WHY DOES mTLS-TO-THE-DESTINATION BREAK TOO?

If the destination demands a client certificate (§09), the client signs the handshake transcript of session A — but the gateway can't replay that signature into session B (different transcript, by design; that anti-replay property from §09 now works against you). The gateway would need the client's private key, which defeats the point of client keys. So destination-mTLS traffic must be bypassed, or the gateway must hold delegated credentials for the client. Recognizing that §09's CertificateVerify design is exactly what blocks §11's interception is the kind of cross-connection interviewers reward.

The protocol arms race: where visibility is eroding

MechanismWhat it hidesGateway consequence
TLS 1.3server certificate now encrypted in-handshakecan no longer classify by observed server cert; SNI became the only plaintext selector
ECH (Encrypted ClientHello)encrypts SNI itselfthe last passive selector dies → policy must move to explicit proxy / endpoint agent, or block ECH at egress
Session tickets / TLS1.3 PSK resumptionresumed handshakes skip certificates entirelyan inspection tier must share ticket keys across the whole fleet (any node may see the resumption), and rotate them — stale ticket keys are also a forward-secrecy hole
QUIC / HTTP3runs over UDP; transport headers encrypted; no kernel TCP state to piggyback onmost middleboxes can't parse it → common tactic: block/deprioritize UDP 443 at egress so browsers fall back to TCP+TLS, where the inspection stack works. Honest framing: that's a deliberate, temporary capability gap, not a solution
WebSockets / streaminglong-lived bidirectional frames after one HTTP upgradeper-request inspection model breaks; need per-frame/stateful scanning (leads into §12 streaming design)
THE STRATEGIC POINT

Network-only inspection is a shrinking window; endpoint-only agents miss unmanaged paths and are OS-fragile. Real architectures are a pincer: endpoint client (sees plaintext before encryption, attests device) + cloud gateway (uniform policy, sees everything routed through it) + identity plane (§13) tying both together. Any single-layer answer is the wrong answer.

Section 12 · Inside the plaintext gap

The content inspection pipeline: Aho-Corasick first, ML second

You now have plaintext (from §11's gap, or natively at a reverse proxy). Budget: a few milliseconds per request, at hundreds of thousands of requests per second, scanning for thousands of things at once — DLP patterns, credentials, malware signatures, prompt-injection markers, tenant-specific keywords. The architecture that survives this budget is always the same shape: a cheap deterministic stage that touches every byte, gating an expensive probabilistic stage that touches few. Same law as §06's pipeline ordering, applied inside one request.

Stage 1: multi-pattern matching — why Aho-Corasick

Naive approach: run 5,000 patterns as 5,000 regex/string searches → cost is O(patterns × text). At 5k patterns over a 100 KB body that's dead on arrival. Aho-Corasick (1975, and still what powers Snort/DLP engines) matches all patterns in one pass over the text, O(text + matches) — cost is independent of pattern count.

root h e r+s i+s s h e green = a pattern ends here (output) red dashed = failure links: "longest suffix that is also a prefix" patterns: he · she · his · hers scanning "she": after s-h-e we are at she✓ — failure link says we're ALSO inside "he"✓ — both reported, no rescan
FIG 9 — All patterns compiled into one trie; failure links let the scanner recover from a mismatch without ever moving backwards in the input.
WHY NOT ONE GIANT REGEX, OR 5,000 SMALL ONES?

5,000 separate scans = 5,000 passes over every byte. One combined regex compiled to a DFA is essentially Aho-Corasick generalized — but naive regex engines (backtracking, PCRE-style) have exponential worst cases, and a hostile payload that triggers catastrophic backtracking is a ReDoS attack on the inspector itself — your security layer becomes the DoS vector. Deterministic-automaton matching has a hard per-byte cost ceiling regardless of input. Rule: on a hostile-input hot path, only algorithms with adversary-independent worst cases.

Stage 2: the ML pass — and the gate between the stages

The streaming problem: scanning what you refuse to buffer

§02 set up this tension: inspection wants the full body; LLM token streams and uploads want zero added latency. The resolution:

Chunk-wise scanning with carried state. Aho-Corasick's state is just "current node" — carry it across chunks and patterns spanning a chunk boundary match for free (no overlap-window hacks needed; that's a hidden superpower of automaton matching). ML stage runs on a sliding window with overlap (e.g., score every 2 KB with 256-byte overlap) since classifiers do need context windows.

Verdict timing is a policy, not an afterthought: (a) hold-and-release per chunk — adds one scoring latency to time-to-first-byte, then pipelines; (b) stream-and-kill — forward optimistically, terminate mid-stream on a late positive; some bytes escape but the session, key, and user are burned; (c) async-audit — full speed, detection feeds response after the fact. Map: (a) uploads/DLP where escaped bytes = incident; (b) interactive AI responses; (c) low-risk categories. Naming which routes get which mode is the staff-level move.

WHY NOT "JUST BUFFER EVERYTHING, LATENCY IS ONLY MILLISECONDS"?

Three compounding costs. Memory: 100k concurrent streams × 1 MB buffers = 100 GB hot RAM per tier. UX: an LLM answer streams over ~10+ seconds; full buffering turns perceived latency from 300 ms-to-first-token into the entire duration. Attack surface: unbounded buffering is a memory-exhaustion primitive — attackers open streams and drip bytes (§06's slowloris, now aimed at your inspector). Every buffer in a hostile-facing system needs a byte cap and a time cap, and the design must define what happens at the cap.

Section 13 · The new client

Agentic traffic and continuous access: when the "user" is software

Every model so far quietly assumed a human behind the request: bursty arrival patterns (§06), interactive login (§05), sessions that end when someone sleeps. AI agents break each assumption: they run 24/7, fan one instruction into hundreds of API calls, hold tokens far longer than any human session, and chain identities ("user asked agent, agent calls tool, tool calls API — who is this request?"). A gateway that can't answer that question can't police it.

Telling agents from humans (and from bots)

SignalHumanSanctioned agentWhy it's hard to fake
Inter-arrival timingbursty, pauses, circadianmetronomic or instant fan-outfaking human cadence costs the agent its speed — its whole value
TLS/HTTP fingerprint (§06 JA3/JA4)browser stacksSDK/runtime stacksswapping TLS stacks is expensive; UA strings are free lies
Session shapelogin → work → expirelong-lived refresh loops, retries with exact backoff curvesretry jitter algorithms are library signatures
Call graphpage-shaped bundlestool-call shaped: list→read→write chains, MCP/function-call endpointsthe destination set itself (model APIs, tool servers) is the tell
Declared identityuser tokenshould be a distinct workload/agent identitythis is policy, not detection — the point of the next part
WHY IS "SHADOW AI" A DISCOVERY PROBLEM BEFORE A BLOCKING PROBLEM?

Blocking known AI domains on day one just teaches employees to use unknown ones — you convert visible risk into invisible risk. The correct sequence mirrors §09's PERMISSIVE→STRICT: observe (classify destinations via §11 SNI/inspection, inventory who talks to what) → grade (sanctioned / tolerated / forbidden per destination × data-sensitivity from §12) → enforce gradually with sanctioned alternatives in place. Same migration shape, third time it's appeared — that's not coincidence, it's how you change any live system safely.

Identity chains: on-behalf-of, not shared secrets

USERtoken: scope=mail.* AGENTOBO token: user+agent, scope=mail.read TOOL / MCPexchanged again: narrower, minutes TTL RESOURCE APIsees full actor chain each hop: token EXCHANGED at the IdP (RFC 8693), scopes can only narrow, act-as chain recorded in claims
FIG 10 — Delegation done right: every hop swaps its token for a narrower one; nothing downstream ever holds the user's full power.

CAE: killing the token you already issued

§05's compromise was "short expiry ≈ revocation". Agents strain it: they refresh forever, so a compromised agent self-renews indefinitely; and even a 15-minute window is long for a machine doing thousands of ops/minute. Continuous Access Evaluation upgrades the model from timer-based to event-based:

  1. Resource providers and gateways subscribe to IdP revocation events: user disabled, password reset, refresh revoked, device fell out of compliance, impossible-travel / risk signal fired.
  2. On event, the provider drops affected sessions now — mid-token-lifetime — and answers subsequent calls with a claims challenge (401 + insufficient_claims) forcing re-authentication against current policy.
  3. Tokens carry a claim advertising CAE capability, so providers know this client can handle mid-life invalidation instead of retry-looping.

Extended universally ("Universal CAE"), the same event channel reaches every session type — including long-lived agent and streaming sessions from §12 — so one HR action or one anomaly verdict propagates to everything that principal holds, in near-real-time, without shrinking token TTLs to absurdity.

WHY EVENTS + MODERATE TTLs, NOT JUST 60-SECOND TOKENS?

Ultra-short TTLs turn the IdP into a per-minute dependency of every service on earth — its availability now gates all traffic (§04's sync-Redis mistake, replayed with identity), and token-issuance load scales with fleet size × 1/TTL. Events invert it: quiet by default, targeted invalidation on signal, IdP load proportional to incidents not traffic. It's the same asymmetry as §06 (cheap default path, expensive path only on suspicion) — by now you can predict the architecture from the cost structure alone.

CLOSING SYNTHESIS — ONE PARAGRAPH TO RULE THE INTERVIEW

"An IA gateway is the composition of everything above: an event-loop data plane (§02/§08) terminating or brokering TLS (§09/§11), shedding hostile load through layered, mostly-local admission control (§04/§06), running a cheap-then-expensive inspection pipeline over streaming bodies (§12), keyed on chained, event-revocable identities rather than IPs or timers (§05/§13) — deployed as a reconciled fleet (§07) whose every design choice is the same trade, made repeatedly: bounded cost on the hot path, expensive certainty only where signals justify it."

Section 14 · ALT 1 CM, data plane

Down to the metal: where the next 10× lives after config tuning

§03 tuned nginx. This section is what you do when the config is already perfect and the graphs say the machine itself is the ceiling. The unifying theme: at millions of packets per second, the enemy is per-packet fixed costs — syscall crossings, interrupts, memory copies, cache misses — not algorithmic work.

Know your unit costs (memorize this table)

OperationApprox costAt 1M ops/sec that's
L1 cache hit~1 nsnegligible
Main-memory (cache miss)~100 ns10% of a core
Syscall crossing (post-Spectre mitigations)~0.5–2 µs0.5–2 full cores
Context switch~1–5 µs + cache pollution1–5+ cores
memcpy 4 KB~200–400 ns0.2–0.4 cores
Same-AZ network RTT~0.5 ms—(latency, not CPU)
ECDSA sign (TLS handshake)~100 µs–1 ms CPUwhy §03 obsessed over resumption

Every technique below is just an attack on one row of this table. Say which row, and the technique explains itself.

Fewer crossings: batching and io_uring

WHY DIDN'T READINESS MODELS DIE THE DAY io_uring SHIPPED?

Because the syscall row of the cost table only dominates for small-op, high-rate workloads. A proxy pushing 100 KB responses spends its time in memcpy and TLS, not crossings — switching to io_uring moves single-digit percents. A workload doing millions of tiny ops/sec (or heavy mixed disk+net I/O) can gain 2×. Also: a decade of battle-tested epoll code, and io_uring's fast-evolving surface has had real security findings — kernel attack surface is a cost too. Adopt where the row you're attacking is actually your biggest row. That sentence is the whole engineering discipline of this section.

Fewer copies: zero-copy end to end

Fewer kernels: XDP, eBPF, DPDK

NIC XDP (eBPF at driver)DROP/PASS/TX before skb exists kernel TCP/IP stacksk_buff · netfilter · sockets DPDK userspace driverpoll-mode · kernel never sees pkt nginx / envoyepoll sockets (§02) custom L4 LB appown TCP/flow logic §06's "drop known-bad at ~ns" lives here default path — fine for L7 L4 LBs, DDoS scrubbers, 100Gbps+
FIG 11 — Three altitudes for packet processing. The trick is putting each function at the lowest layer that can express it.
WHY XDP FOR THE BLOCKLIST BUT NOT FOR THE PROXY?

XDP programs are verified eBPF: bounded loops, limited memory (maps), no blocking — perfect for "hash the source IP, check a map, DROP" at tens of millions of pps per core, useless for TLS or HTTP parsing. DPDK gives you everything — and takes everything: you now own a userspace TCP stack, poll-mode cores pinned at 100% forever, and you've left behind the kernel's ecosystem (tcpdump, netfilter, sockets). That's justified for a Maglev/Katran-class L4 balancer shared by a whole datacenter; it's malpractice for an app team. Notice the layering resolves §06's pipeline: XDP drop → kernel SYN cookies → nginx limits → app. Each function at the cheapest layer that can express it.

Memory locality: NUMA, RSS, and false sharing

Section 15 · When it breaks

Resilience: designing for the day the graphs go vertical

Fast systems die differently than slow ones: they die by positive feedback loops. A small slowdown triggers retries, retries add load, load adds slowdown. Every pattern in this section exists to break one specific loop. Learn them as loop-breakers and you'll never mix them up again.

Anatomy of a cascading failure (the canonical incident)

DB p99 doubles(one bad query plan) app threads pile upconcurrency = rate×latency timeouts fireclients retry ×3 load now 3–4×healthy pods drown too feedback: extra load makes the DB slower still → repeat until 0% success at 400% load
FIG 12 — Little's Law (concurrency = arrival rate × latency) is the physics; retries are the accelerant.

End state of an unprotected system: every server busy doing work that will time out before it's used — 100% utilization, 0% goodput, and it stays there even after the DB recovers because the retry backlog sustains the overload. This is a metastable failure; recovery requires shedding load, not adding capacity.

The loop-breakers, each matched to its loop

WHY IS "JUST AUTOSCALE" NOT THE ANSWER TO OVERLOAD?

Timing and direction. Scale-out takes minutes (image pull, warm-up, cache fill — §07); cascades develop in seconds. Worse, cold pods start at their slowest (empty caches, JIT, unopened connection pools), so the autoscaler adds capacity that initially lowers fleet-average performance — and if scaling triggered on latency, that's another positive feedback loop (slow → scale → slower → scale more). Autoscaling handles diurnal traffic shape; shedding+breakers handle transients. You need both; they operate on different timescales.

WHY DEGRADE INSTEAD OF FAIL — AND WHY MUST DEGRADATION BE DESIGNED, NOT IMPROVISED?

Serve-stale beats serve-error: cached prices 90 s old, recommendations replaced by bestsellers, search without personalization — users barely notice, revenue survives. But each fallback is itself code with its own failure modes, and it runs precisely when the system is sickest — untested fallbacks are where second outages live (the fallback path stampedes the very dependency that's down, classically). So: fallbacks are declared per route ("this endpoint may serve stale up to X"), and exercised continuously (chaos drills / fallback-always cells), or they don't exist.

Deployment resilience: assume the bug ships

Most incidents are self-inflicted by change. The gateway stack is also the safety mechanism: canary via §08 traffic-split (1% → compare error/latency vs baseline automatically → promote or auto-rollback); cell-based isolation — partition the fleet into independent cells, each with its own gateways/limiters/state, deploy cell-by-cell so blast radius is 1/N of users and a poisoned config (§02's reload makes config a deploy too!) can't take the world down; feature flags for instant behavioral rollback without a rollout. Config and rulesets (§12's patterns, §06's blocklists) need the same canary treatment as code — a bad regex pushed globally is the outage.

Section 16 · Seeing it run

Observability: measuring a billion requests without a second billion-request problem

Instrumentation at scale has one central tension: the telemetry system's load scales with the traffic it watches. Every design below is a different answer to "how do we know everything while storing almost nothing?"

Metrics: the cardinality trap

The trap, numerically. A time series is a metric name × its label combination. http_requests{route, code, pod} with 200 routes × 8 codes × 500 pods = 800k series — fine. Add user_id as a label and it's 800k × active users = billions of series: the TSDB dies of RAM, not disk. Rule: labels are for dimensions you'd group by on a dashboard (bounded, low-cardinality); identities go in logs and traces, never in metrics. Half of all real-world monitoring outages are someone adding one unbounded label.

Histograms, not averages, not client-computed percentiles. You cannot average percentiles across pods (p99 of pod A + p99 of pod B ≠ fleet p99 — percentiles don't compose). So each pod exports bucket counters (histogram); bucket counts do sum across the fleet, and the query side derives any percentile from the merged distribution. That's why Prometheus histograms/OTel exponential histograms exist, and it's a beautiful example of choosing the exportable representation so the math composes.

Pull + local aggregation. §14's per-core counters merge per-pod; scrape carries pre-aggregated snapshots every 15–60 s. Cost is O(series), independent of RPS — the whole point.

Tracing: the request's own story

WHY TRACES AT ALL, IF METRICS ALREADY SHOW THE LATENCY?

Metrics tell you that p99 doubled; only a trace shows where those specific 200 ms went — which hop, queue, retry, or lock. Metrics aggregate away causality by construction. Conversely traces can't alert (sampled, delayed). Logs carry the arbitrary detail both discard. The three aren't rivals; they're the same events at three compression levels: metrics = aggregate, traces = causal skeleton, logs = full text. Pick per question, join by trace ID.

Logs at the edge: the firehose discipline

Closing the loop: SLOs and error budgets

THE OPERATING CONTRACT

Define per journey, not per server: "99.9% of checkout requests succeed in <500 ms, measured at the edge over 30 days." That yields an error budget (0.1% ≈ 43 min/month of full badness) — the explicit exchange rate between reliability and velocity: budget healthy → ship fast; budget burning → freeze features, spend on §15 hardening. Alert on burn rate (how fast the budget is depleting: page at 14×/1 h AND 6×/6 h-style multiwindow rules), not on raw CPU or single latency spikes — burn-rate alerts are the only kind that are simultaneously fast, precise, and quiet. This paragraph — SLO → budget → burn-rate paging → §15's shedding tied to the same criticality classes — is the difference between "we have Grafana" and "we operate a service."

Section 17 · Design lab

Four staff-level prompts, worked end to end

Format for each: restate → numbers first → architecture → the why-nots you volunteer before being asked → the follow-up the interviewer is holding in reserve. Practice delivering each in 12–15 minutes.

LAB 1 — "Design a shared TTL cache for gateway verdicts across 200 pods"

Restate + requirements you must extract: What's cached? (authz verdicts §05, reputation lookups §06, policy decisions — small values, ~100 B–1 KB.) Staleness tolerance? (seconds, because TTL is the contract.) Correctness on miss = safe (recompute), so this is an optimization cache, not a source of truth — say that sentence early; it licenses every shortcut that follows.

Numbers first: 200 pods × 5k RPS = 1M lookups/s. Hot keyset maybe 10M keys × 1 KB = 10 GB total. Per-pod RAM budget say 1–2 GB → each pod can hold the hot 10–20% locally.

Architecture — two tiers, both with TTL:

  • L1: in-process per pod. Sharded hash map (shard by key-hash to kill lock contention — §14 false-sharing logic), LRU or TinyLFU eviction, TTL checked lazily on read (§04's limit_req trick: no background reaper needed; expired entries evict on touch or via LRU pressure). Lookup ~100 ns. Serves ~80–90% of hits with zero network.
  • L2: shared Redis/memcached tier, consistent-hashed. Catches the long tail and makes a new pod warm-ish. Lookup ~0.5 ms (§14 cost table). Consistent hashing so scaling the cache tier moves 1/N of keys, not all (same reason as §01's L4 Maglev hashing).
  • Read path: L1 → L2 → compute → write back to both, each with TTL. Write path for verdict changes: don't chase invalidation first — TTL is the consistency model; bound staleness = L1 TTL. If the interviewer needs faster kill (revoked token! §13 CAE): add a pub/sub invalidation channel, but frame it as best-effort acceleration on top of TTL correctness, never instead of it.

The two failure modes that decide the interview:

  • Stampede (dog-pile): a hot key expires; 1M req/s all miss simultaneously and stampede the backend — you've built a self-DoS scheduled by your own TTL. Fixes, in order: singleflight per pod (one in-flight recompute per key, others wait on it); TTL jitter (±10% so fleet expiries desynchronize); serve-stale-while-revalidate (return the expired value, refresh async — §15's degrade-don't-fail); optionally probabilistic early refresh (XFetch: refresh with probability rising as expiry nears).
  • Negative caching: cache "not found / denied" too (short TTL), or a miss-storm on a nonexistent key (attacker-controlled! §06) bypasses the cache entirely. Unbounded attacker-chosen keys also demand an L1 size cap + eviction — §04's bounded-shm lesson again.

Why-nots to volunteer: Why not L2-only? Adds 0.5 ms × 1M/s network to every lookup — §04's sync-Redis mistake. Why not strongly-consistent replicated cache (e.g., via Raft)? Quorum write per cache fill at 1M/s is absurd for data whose contract is already "stale up to TTL" — consistency model must match the weakest guarantee the caller needs (§07 etcd discussion, inverted). Why not gossip full replication to all pods? 10 GB × 200 pods of RAM and O(pods²) churn traffic; fine for tiny hot sets (that's what §06's bloom blob push is), wrong for 10M keys.

Reserve follow-up: "Redis tier dies — what happens?" Answer: L1 keeps serving its ~85%; misses recompute against the backend, which must be protected by §15 shedding/breakers sized for cache-cold load; and this scenario is why L1 exists per pod rather than trusting L2 alone. Fail-open on optimization caches, fail-closed never applies — because it was never a source of truth (callback to your opening sentence).

LAB 2 — "Design the egress gateway that inspects streaming AI traffic for a 100k-employee company"

This is a composition test. Skeleton: endpoint client + PoP gateways (§11 pincer) → TLS break-and-inspect with corp CA, bypass list for pinned/mTLS apps (§11) → identity attached at connect: user, device posture, agent-vs-human (§13) → two-stage pipeline: Aho-Corasick DLP/patterns on every byte, ML scoring gated to a few % (§12) → per-route verdict modes: hold-and-release for uploads, stream-and-kill for LLM responses, async-audit for low-risk (§12) → destination catalog: sanctioned/tolerated/forbidden, built by observation first (§13 shadow-AI sequencing).

Numbers to anchor: 100k users × ~2k requests/day ≈ 200M req/day ≈ 2.3k RPS average, ~10k peak — small! Volume isn't the challenge; session length and bytes are: streaming sessions minutes long, uploads in GBs. So size by concurrent streams (~50–100k) and enforce §12's byte+time buffer caps, not by RPS.

Why-nots to volunteer: endpoint-only (misses unmanaged paths, N-OS fragility) vs network-only (ECH/QUIC erosion, §11 table) → pincer. Block-AI-domains-day-one → drives usage underground (§13). 100%-to-ML → budget failure (§12 gate). Fail-open vs fail-closed stated per route class: closed for DLP-critical uploads, open-with-audit for general browsing — an outage that blocks all web for 100k people is also an incident (§05 fail-policy).

Reserve follow-up: "An agent framework starts exfiltrating via a sanctioned AI API — prompt-injected." Answer chains §13 (agent has narrow OBO token, so blast radius is pre-capped) + §12 (stream-and-kill on the DLP hit) + §13 CAE (one event revokes every session that principal holds) + §16 (the trace ties the tool-call chain together for forensics).

LAB 3 — "Flash sale in 20 minutes; the edge fell over in the last one. Go."

This is an operations test — answer in timeline order.

  • Before (now): pre-scale past the autoscaler (§15: scaling is minutes, spikes are seconds — pre-warm pods, connection pools, caches); raise somaxconn/backlog and verify no accept-queue overflows from last time (§01 — check netstat -s listen overflow counters, the silent killer); confirm TLS session resumption + upstream keepalive (§03 — handshake CPU is the classic sale-day cliff); set limit_req per-IP with nodelay burst (§04); pre-position the waiting-room/queue page and label criticality classes so shedding has an order (§15); freeze deploys, canary any config (§15).
  • During: watch three numbers: edge p99 vs upstream p99 split (§03 — whose fault?), accept-queue depth, error-budget burn rate (§16). At the knee: shed lowest class first with fast 429+Retry-After, serve-stale product pages (§15), never let retries stack — retry budget already capped (§15).
  • After: the load test that would have caught it: open-loop wrk2 at 2× expected peak, measuring tails (§03 coordinated omission).

Reserve follow-up: "Why did adding 3× pods mid-incident not help last time?" Cold caches + cache-stampede on shared tiers + retry backlog sustaining metastable overload (§15, LAB 1 stampede) — capacity added into a feedback loop feeds the loop.

LAB 4 — "Distribute a 10M-entry, minutely-updated blocklist to 10,000 proxies worldwide"

Recognize the shape: this is config distribution, not a database — read-only at the edge, single writer, staleness tolerance ~1 min. That classification eliminates 90% of wrong answers immediately.

Architecture: compiler service builds the artifact (bloom filter blob §06, ~18 MB, plus exact-set delta files), versions it (content-hash), signs it (§09 logic: verify authenticity at the edge — a poisoned blocklist is an attack vector), pushes to blob storage + CDN fan-out; proxies poll-or-subscribe for the version pointer, download via CDN, atomically swap the in-memory pointer (build-new-then-swap, never mutate-in-place — same generational trick as §02's config reload). Deltas minute-to-minute, full snapshot hourly for new/recovering nodes.

Why-nots to volunteer: Why not each proxy queries a central DB per lookup? 10k × 100k lookups/s and an RTT on the hot path — §04/§06 already settled this. Why not pub/sub pushing raw entries? Ordering/loss handling per proxy re-derives state badly; versioned immutable artifacts make every proxy's state exactly "version N" — trivially observable (§16: export the version as a metric; alert on fleet version skew). Why not strong consistency? A blocklist 60 s stale is the spec, and CDN fan-out to 10k nodes is only cheap because it's eventually consistent.

Reserve follow-up: "An entry must die NOW, not in a minute" — the CAE pattern again (§13): baseline artifact cadence + a tiny urgent channel (pub/sub of individual revocations applied to an overlay set), quiet by default, targeted on signal. Third appearance of that asymmetry; name it as a pattern and the room nods.

HOW TO REHEARSE

For each lab: 2 min requirements+numbers aloud, 6 min whiteboard the boxes from memory, 4 min deliver the why-nots unprompted, then answer the reserve follow-up. If any why-not surprises you, its home section is cited inline — reread just that. Two full passes of this beats ten passive rereads.

Section 18 · Exam mode

Active recall: answer aloud before you tap

Reading feels like knowing; retrieval is knowing. Answer each question out loud in full sentences, then open it. Wrong or fuzzy → the cited section is your reread list. Target: all green in two sittings.

Round 1 · Mechanisms — "how does it actually work?"

Q01Why can one nginx worker handle 50,000 connections while Apache's classic model couldn't?

Apache dedicated a thread/process (~1 MB stack + context switches) to each connection, mostly to wait. Nginx registers all sockets with epoll and one worker touches only sockets the kernel says are ready — O(ready) per wakeup, a few KB per idle connection, no switching. Waiting was made nearly free. (§02)

Q02What exactly happens inside nginx when you send SIGHUP, and why is it zero-downtime?

Master parses the new config; if valid, forks a fresh generation of workers with it, then signals old workers to stop accepting and drain in-flight requests. Two generations overlap briefly; listening sockets stay open throughout, so no connection is refused or reset. Config change is a process lifecycle, not a mutation. (§02)

Q03A request never appears in nginx logs, and the client saw a timeout. Name the layer and the two commands you'd check.

Kernel accept-queue overflow — the connection died before nginx ever accept()ed it. Check ss -lnt (Recv-Q vs backlog on the listener) and netstat -s | grep -i listen (overflow counters). (§01, §03)

Q04How does nginx's limit_req compute a leaky bucket without any background timer?

Lazily: per key it stores last_seen and excess; on each arrival it drains retroactively — excess = max(0, excess − rate·Δt) + 1 — and rejects if excess exceeds burst. State only updates when the key shows up; storage is a shared-memory red-black tree with LRU eviction, so it's O(1) per request and bounded under key-spraying attacks. (§04)

Q05In mTLS, presenting the client certificate proves nothing by itself. What actually proves identity?

CertificateVerify: a signature over the entire handshake transcript with the private key matching the cert — proving key possession and binding the proof to this exact handshake so it can't be replayed. (§09)

Q06Trace a packet from "app calls payments:8080" to the receiving container, in an Istio mesh.

App connects → pod-local iptables OUTPUT rule redirects to Envoy :15001 → Envoy reads original dst via SO_ORIGINAL_DST, picks an endpoint from EDS, opens mTLS using its SDS-delivered cert → across the CNI network to the target pod → target's PREROUTING rule redirects into its Envoy :15006 → mTLS terminated, AuthorizationPolicy checked against the peer's SPIFFE SAN → forwarded over localhost to the app. (§08, §09)

Q07Why does a Bloom filter never produce false negatives, and where does the false-positive rate come from?

Insertion sets k bits and bits are never cleared, so a member's bits are always all set → any zero bit is definitive absence. False positives arise when other keys' insertions happen to have set all k of a non-member's bits; the rate is tuned by bits-per-entry (~14.4 bits/entry for 0.1%). (§06)

Q08Why can Aho-Corasick scan a chunked/streaming body without any overlap-window trick?

Its entire scan state is one automaton node, and the text pointer never moves backwards. Carry the node across chunk boundaries and a pattern spanning two chunks completes naturally. (Stage-2 ML classifiers do need overlapping windows — they require context, not just state.) (§12)

Round 2 · Numbers — "justify it quantitatively"

Q09Defend p99 SLOs over averages using the fan-out argument, with the arithmetic.

A page fanning out to 50 backend calls hits at least one p99-slow call with probability 1 − 0.99⁵⁰ ≈ 39%. Tail latency compounds through fan-out; averages hide exactly the experiences users get most upset about. (§03)

Q10Quantify why per-request global rate limiting through Redis is rejected at 200 pods × 5k RPS.

1M Redis ops/s just for limiting, plus 0.5–2 ms RTT added to every request — often exceeding the gateway's whole processing budget — plus a new SPOF whose failure forces fail-open (no limits during attack) or fail-closed (self-outage). Local buckets + async reconciliation bound the error to sync-interval × rate instead. (§04)

Q11Size a Bloom filter for 10M entries at 1% false positives, and compare to a hash set.

~9.6 bits/entry (1.44·log₂(1/0.01)) ≈ 96 Mbit ≈ 12 MB, k ≈ 7 hashes. An exact hash set of address strings runs ~50–100 B/entry ≈ 0.5–1 GB — a 40–80× difference that decides whether it fits hot in every worker. (§06)

Q12Three retry attempts per layer across three layers — what's the worst-case amplification, and what caps it?

Up to 3×3×3 = 27× load at the bottom layer during an outage. A fleet-level retry budget (e.g., retries ≤ 20% of active requests, Envoy-style) caps the multiplier globally, which per-caller attempt counts cannot. (§15)

Q13Your logging bill: 1B requests/day at ~400 bytes/line. Do the math and state the standard mitigation.

~400 GB/day raw, ~800 GB replicated, before indexing. Mitigation: full logs for errors, sample successes at 1–10%, rely on metrics (exact counters, cheap) for the totals — safe because histograms already count every request. (§16)

Q14Why is a syscall a "cost row" worth designing around? Give the number and one system built on avoiding it.

~0.5–2 µs per crossing post-Spectre — at 1M ops/s that's 0.5–2 entire cores of pure overhead. io_uring amortizes or eliminates crossings via shared submission/completion rings; epoll's 512-events-per-wakeup batching is the same idea in older clothes. (§14)

Round 3 · Traps — "the misconception the interviewer is fishing for"

Q15"K8s Services load-balance, so why is one pod hot?" — what's the trap?

kube-proxy DNAT balances at connection time; gRPC/HTTP2 multiplexes everything over one long-lived connection, so one pod receives it all. Fix is L7 (per-request) balancing — the sidecar's least-request policy. (§07, §08)

Q16Why is wiring a database health check into a liveness probe an outage generator?

Liveness failure restarts containers. A DB blip then mass-restarts every pod — cold caches, connection stampedes, amplified outage. Dependency checks belong in readiness (remove from endpoints, don't kill). (§07)

Q17"We'll intercept 100% of TLS" — give the two categories that make this claim false.

Cert-pinned apps (they correctly reject the minted cert — bypass list required) and destination-mTLS (the client's CertificateVerify can't be re-signed into the gateway's second session without the client's private key). Honest posture: inspect most, bypass some, log all. (§11)

Q18Why doesn't autoscaling save you from a cascading failure?

Timescales and feedback: scale-out takes minutes, cascades take seconds; new pods arrive cold (caches, JIT, pools) so they initially lower fleet performance; and latency-triggered scaling in an overload is itself a positive feedback loop. Shedding and breakers handle transients; autoscaling handles diurnal shape. (§15)

Q19Why can't you average p99s from 500 pods into a fleet p99, and what representation fixes it?

Percentiles don't compose — each pod's p99 is a point on a different distribution. Histogram bucket counts do sum; export buckets per pod, merge fleet-wide, derive any percentile from the merged distribution. (§16)

Q20Why is a backtracking regex engine on an inspection hot path a security bug in itself?

Catastrophic backtracking gives attackers an input-controlled exponential worst case — ReDoS: the inspector becomes the DoS vector. Hostile-input paths demand automaton matching with an adversary-independent per-byte ceiling. (§12)

Q21Spot the flaw: "our fallback for the recommendations service is to query the product DB directly."

The fallback fires exactly when the system is sickest and redirects full traffic onto a dependency not sized for it — the fallback stampedes and causes the second outage. Fallbacks must be cheaper than the primary (static bestsellers, stale cache) and continuously exercised. (§15)

Q22Why do 24-hour auto-rotated certs beat 5-year certs + CRL/OCSP in a mesh — and what's the CAE analogue?

Distributing revocation to 10k proxies reliably is the hard system; short lifetime makes revocation the default outcome of a clock. CAE is the same inversion for tokens: moderate TTLs plus targeted revocation events, so IdP load scales with incidents, not traffic. Quiet default path, expensive path on signal — the manual's master pattern. (§09, §13)

SCORING

20+ fluent: book the interview. 15–19: reread only the cited sections you missed, retest tomorrow. Under 15: run §17's labs aloud first — retrieval through design problems rebuilds the connections faster than rereading prose.

Section 19 · Executed, not described

Bloom filter & Aho-Corasick: real runs, real traces, real surprises

Everything below was actually executed — the bit positions, false positives, state transitions, and timings are genuine program output, including two accidents that teach more than the plan did. Full runnable source at the end of each half.

Part A · Bloom filter, five experiments

A1 — A 32-bit filter you can verify by eye

Three "malicious" IPs inserted into m=32 bits with k=3 hashes. Watch which bits each key claims:

RUN — insert phase
insert 203.0.113.9    -> bits [2, 5, 25]
insert 198.51.100.7   -> bits [4, 13, 16]
insert 192.0.2.44     -> bits [7, 8, 18]
bit array set positions: [2, 4, 5, 7, 8, 13, 16, 18, 25]  (9/32 set)

Now three queries — one true positive, two true negatives:

RUN — query phase
query 203.0.113.9    -> bits [2, 5, 25]  -> MAYBE (all set)          # true positive: its own bits
query 10.0.0.1       -> bits [1, 8, 11]  -> DEFINITELY NOT          # bit 1 is zero → proof of absence
query 172.16.5.5     -> bits [6, 11, 11] -> DEFINITELY NOT          # bit 6 is zero

Note 172.16.5.5: two of its three hashes landed on the same bit (11, 11). Legal and harmless — k hashes are independent draws, collisions among them just mean fewer distinct bits checked. It matters again in A1's false-positive hunt below.

A2 — Hunting a real false positive

Scan 10.0.0.0/24 — 256 addresses never inserted — and ask the filter about each:

RUN — 42 candidates scanned before 3 FPs found
FALSE POSITIVE: 10.0.0.0    bits [5, 16, 25]  # bit 5 from IP#1, 16 from IP#2, 25 from IP#1
FALSE POSITIVE: 10.0.0.24   bits [4, 5, 25]   # assembled from three different insertions
FALSE POSITIVE: 10.0.0.41   bits [2, 2, 5]    # self-collision! only needs TWO borrowed bits

This is the mechanism of a false positive laid bare: nobody inserted 10.0.0.0, but its three bits were each set by different legitimate insertions, and the filter cannot tell "all set by one key" from "all set by strangers." 10.0.0.41 shows the sharper edge: its internal hash collision means only two foreign bits had to be occupied — self-colliding keys have a higher personal FP probability. This 32-bit toy runs at ~28% fill and shows ~7% FP; that's why real sizing (next) targets ≤1%.

WHY IS A FALSE POSITIVE ACCEPTABLE HERE BUT A FALSE NEGATIVE WOULDN'T BE?

Deployment asymmetry (§06): "maybe dirty" triggers an exact confirmation check — an innocent 10.0.0.24 pays one extra lookup, ~0.5 ms, once. A false negative would wave known-bad traffic through with no second chance. The filter's math guarantees the harmless error direction and forbids the harmful one — that's why it fits security pre-filtering so well.

A3 — Theory vs measurement at 200k entries

RUN — sized by the formulas from §06
formula: m = 1,917,012 bits (234 KiB), k = 7, 9.59 bits/entry   # target p = 1%
measured FP rate: 1.064%   (theory ~1%)      # over 50,000 known non-members
measured FN rate: 0.000%   (theory: exactly 0, always)   # over 50,000 known members

The formulas are not approximations you hope hold — they land within measurement noise. 234 KiB for 200k keys; linearly, ~12 MB for 10M, confirming §06's claim from live code.

A4 — Saturation: what happens when you lie to the filter about n

RUN — same filter (sized for 200k), kept inserting
after 200,000 inserts: bits set  ~50%    FP rate   1.06%   # as designed
after 400,000 inserts: bits set  76.7%   FP rate  15.80%
after 600,000 inserts: bits set  88.8%   FP rate  43.42%   # nearly a coin flip

Fill fraction follows 1 − e^(−kn/m): plug n=400k in and you predict 76.8% — measured 76.7%. FP rate is (fill)^k, and it explodes: 2× overfill → 15× the error, 3× → 40×. Operational lesson: a Bloom filter fails silently and gradually — no exception, no OOM, just quietly rising junk verdicts. So production filters export their fill ratio as a metric (§16) and alert well before 60%, and the §06 rebuild-every-minute pipeline re-sizes from the current n each build.

A5 — Deletion: the counting variant, and its cost

RUN — counters instead of bits
query evil.example after insert : True
query evil.example after delete : False
query bad.example  (untouched)  : True   # shared positions survived: counters went 2→1, not 1→0

Plain Bloom can't delete — clearing a bit might destroy another key's evidence (exactly the sharing A2 exposed). Counting Bloom stores a small counter per position: delete = decrement, and shared positions survive. Price: 4-bit counters = 4× memory, and counter overflow must be handled (saturate, don't wrap). This is the honest bridge to §06's cuckoo-filter aside: if deletion is frequent, cuckoo beats counting-Bloom on space; if the set is rebuilt wholesale each minute, plain Bloom stays cheapest. Choose by the update pattern, not the lookup pattern.

SRCFull Bloom source (60 lines, runnable as-is)
import hashlib, math

def h(key, seed, m):
    d = hashlib.sha256(f"{seed}:{key}".encode()).digest()
    return int.from_bytes(d[:8], 'big') % m

class Bloom:
    def __init__(self, m, k):
        self.m, self.k, self.bits = m, k, bytearray((m+7)//8)
    def _idx(self, key):
        return [h(key, i, self.m) for i in range(self.k)]
    def add(self, key):
        for i in self._idx(key):
            self.bits[i//8] |= 1 << (i % 8)
    def query(self, key):                     # all() short-circuits on first zero bit
        return all(self.bits[i//8] >> (i%8) & 1 for i in self._idx(key))

def size_for(n, p):                           # §06 formulas
    m = math.ceil(-n * math.log(p) / (math.log(2)**2))
    k = max(1, round(m/n * math.log(2)))
    return m, k

class CountingBloom(Bloom):                   # deletable variant (A5)
    def __init__(self, m, k):
        self.m, self.k, self.cnt = m, k, [0]*m
    def add(self, key):
        for i in self._idx(key): self.cnt[i] += 1
    def remove(self, key):
        for i in self._idx(key): self.cnt[i] -= 1
    def query(self, key):
        return all(self.cnt[i] > 0 for i in self._idx(key))
# production notes: use 2 fast hashes + Kirsch-Mitzenmacher (h1+i*h2), not k sha256s;
# store bits in one contiguous array for cache locality (§14); export fill%% as a metric (§16)

Part B · Aho-Corasick, five experiments

B1 — The automaton for {he, she, his, hers}, fully dumped

RUN — every state: its path, goto edges, fail link, outputs
state  path    goto edges          fail   outputs
    0  [    ]  h->1, s->3            0    []
    1  [h   ]  e->2, i->6            0    []
    2  [he  ]  r->8                  0    ['he']
    3  [s   ]  h->4                  0    []
    4  [sh  ]  e->5                  1    []          # suffix "h" of "sh" = prefix of he/his
    5  [she ]                        2    ['she', 'he']  # inherits he via fail chain!
    6  [hi  ]  s->7                  0    []
    7  [his ]                        3    ['his']
    8  [her ]  s->9                  0    []
    9  [hers]                        3    ['hers']

Two things to point at. State 4's fail link = 1: after reading "sh", the longest suffix that is also some pattern's prefix is "h", so a mismatch resumes from the "h" state instead of root — no rescanning. State 5's output list contains he even though its path spells "she": outputs are merged down the fail chain at build time, which is why finishing "she" reports the overlapping "he" for free.

B2 — Positive trace: scanning "ushers"

RUN — one state integer, six characters, three matches
read 'u'  state  0 -> 0
read 's'  state  0 -> 3
read 'h'  state  3 -> 4
read 'e'  state  4 -> 5   MATCH: she@1  he@2     # two overlapping patterns, one step
read 'r'  state  5 -> 8   # "she"+r has suffix "her" → jumped INTO the hers branch via fail
read 's'  state  8 -> 9   MATCH: hers@2
matches: [(1,'she'), (2,'he'), (2,'hers')]

The 5→8 transition is the algorithm's soul: state 5 has no 'r' edge, so it falls to state 2 ("he"), which does — landing in "her". Three overlapping matches found in exactly six character-steps; a naive scanner would have re-read characters multiple times.

B3 — The "negative" trace that wasn't: an honest accident

This text was chosen as a true negative — "hazard shiv herd", full of near-misses. Watch what the run actually said:

RUN — near-misses dying via fail links… and one surprise
read 'h'  state 0 -> 1        # "hazard": h looks promising…
read 'a'  state 1 -> 0        # …'a' kills it, fail to root, no rescan
...
read 's'  state 0 -> 3        # "shiv": s-h builds "sh" (state 4)…
read 'h'  state 3 -> 4
read 'i'  state 4 -> 6        # no 'i' edge at 4 → fail to 1 ("h") → h+i = "hi"! pivoted mid-word
read 'v'  state 6 -> 0        # 'v' ends the "his" hope
...
read 'e'  state 1 -> 2   MATCH: he@12   # inside "herd" (!)
read 'r'  state 2 -> 8
read 'd'  state 8 -> 0
matches: [(12, 'he')]

Two lessons, one intended and one earned. Intended: the 4→6 pivot — "shi" seamlessly re-interpreted as "…hi" without moving the text pointer backwards. Earned: "herd" contains "he" — substring matchers do not know about word boundaries. In DLP terms: the pattern ssn fires inside "expressnotes"; a credit-card digit pattern fires inside a phone number. Production stage-1 therefore emits candidates, and a cheap post-filter (boundary check, checksum like Luhn for card numbers, context window) validates before the verdict — yet another instance of the manual's cheap-gate-then-verify pattern (§06, §12). A "false positive" here is not a bug in Aho-Corasick; it's the designed division of labor.

B4 — Streaming across the worst possible chunk boundary

Same "ushers" input, deliberately split so that both matches span chunk edges:

RUN — chunks "us" | "he" | "rs", carrying one integer of state
chunk 'us': state out 3, matches so far []
chunk 'he': state out 5, matches so far [(1,'she'), (2,'he')]   # "she" completed ACROSS the cut
chunk 'rs': state out 9, matches so far [(1,'she'), (2,'he'), (2,'hers')]
final == whole-text scan: identical

This is §12's claim, demonstrated: the automaton's entire memory is one state integer, so a gateway scanning a streamed LLM response or chunked upload stores 4 bytes per connection of scanner state — no buffering, no overlap windows, no missed boundary-spanning secrets. (The ML stage still needs windowed context; only the automaton stage gets this superpower.)

B5 — The scaling benchmark, with an honest handicap

RUN — 1 MB text; naive = one C-speed text.find() per pattern; AC = pure Python
patterns    naive k-scans        AC single pass
     100        0.078 s             0.266 s      # AC loses! interpreter tax
   1,000        0.787 s             0.342 s      # crossover
   5,000        3.931 s             0.517 s      # 7.6× — and growing linearly apart
(match counts agree at every size)

Read the shape, not the absolute numbers. Naive scales linearly with pattern count (0.078 → 3.93 s, a clean 50× for 50× patterns). AC is nearly flat (0.27 → 0.52 s — growth is only automaton cache pressure). And this is pure-Python AC giving compiled C a ~30× head start per operation; at parity (Hyperscan-class engines, SIMD, §12) the crossover moves from ~500 patterns to ~a handful. At §12's real scale — thousands of patterns, every byte of every request — the naive approach isn't slower, it's architecturally impossible: 5,000 passes over each request body versus one.

SRCFull Aho-Corasick source (45 lines, runnable as-is)
from collections import deque

class AC:
    def __init__(self, patterns):
        self.goto, self.fail, self.out = [{}], [0], [[]]
        for p in patterns:                        # 1) trie of all patterns
            s = 0
            for ch in p:
                if ch not in self.goto[s]:
                    self.goto.append({}); self.fail.append(0); self.out.append([])
                    self.goto[s][ch] = len(self.goto) - 1
                s = self.goto[s][ch]
            self.out[s].append(p)
        q = deque()                               # 2) fail links by BFS (depth order matters:
        for ch, s in self.goto[0].items():        #    a node's fail is always shallower)
            q.append(s)                           #    depth-1 nodes fail to root
        while q:
            r = q.popleft()
            for ch, s in self.goto[r].items():
                q.append(s)
                f = self.fail[r]                  # follow parent's fail chain until an
                while f and ch not in self.goto[f]:   # ancestor has a ch-edge
                    f = self.fail[f]
                self.fail[s] = self.goto[f].get(ch, 0)
                if self.fail[s] == s: self.fail[s] = 0
                self.out[s] += self.out[self.fail[s]] # 3) inherit outputs (why she⇒he)

    def step(self, state, ch):                    # amortized O(1): fails only give back
        while state and ch not in self.goto[state]:   # depth that gotos paid for
            state = self.fail[state]
        return self.goto[state].get(ch, 0)

    def scan(self, text, state=0, base=0):        # state in/out ⇒ streaming (B4)
        hits = []
        for i, ch in enumerate(text):
            state = self.step(state, ch)
            for p in self.out[state]:
                hits.append((base + i - len(p) + 1, p))
        return state, hits
# production notes: dense 256-wide goto arrays or double-array tries beat dicts (§14
# memory-bandwidth point); precompute the "next" DFA table to remove the fail loop
# from the hot path entirely; case-fold/normalize input BEFORE scanning or attackers
# trivially bypass with mixed case (normalization is itself attack surface — §12)
WHAT THE TWO ALGORITHMS SHARE — SAY THIS IN THE ROOM

Both convert an expensive question over a huge set ("is this key among 10M?", "do any of 5,000 patterns occur?") into a constant-cost-per-byte pass with a one-sided error or a candidate stream, and both delegate exactness to a rarer, pricier second stage. Bloom's zero-false-negative guarantee and AC's never-move-backwards guarantee are the same kind of promise: a hard worst-case bound an adversary cannot inflate (§12's ReDoS contrast). That property — adversary-independent hot-path cost — is the admission ticket for any algorithm running at a gateway's front door.

Section 20 · Executed, not described — II

Consistent hashing & count-min sketch: the other two gateway workhorses

The manual leaned on both without opening them: consistent hashing carries §01's L4 tier and Lab 1's cache tier; count-min sketch powers §06's "which keys are hot right now." Same rules as §19 — every number below is real program output (200k keys / 1M-request zipfian stream).

Part A · Consistent hashing, three experiments

A1 — Why mod-N is a fleet-wide cache wipe

RUN — hash(key) mod N, growing 10 → 11 nodes
keys moved: 181,965 / 200,000 = 91.0%    (theory: 1 − 1/11 = 90.9%)

Adding one node relocated 91% of keys. Meaning, concretely: scale your cache tier up by 10% and you just invalidated 91% of it — a self-inflicted stampede (§17 Lab 1) at the exact moment you added capacity because load was high. The failure isn't performance, it's coupling: with mod-N, every key's placement depends on N itself, so any change of N changes almost everything. Consistent hashing's entire purpose is to make placement depend only on the key and the surviving nodes.

A2 — The ring, and what virtual nodes actually buy

RUN — ring: remap when adding node #11, and load balance across 10 nodes
 vnodes   moved on add   max/mean load   load stddev
      1        1.11%          2.24×         76.3%
     10       11.91%          1.54×         29.7%
    100       10.19%          1.07×          4.8%
   1000        9.01%          1.05×          3.5%
(theory for moved-on-add: 1/11 = 9.09%)

Two lessons hiding in one table:

WHY DOES "ONLY 1/N MOVES" MATTER SO MUCH AT A GATEWAY?

Three places in this manual quietly depend on it: the L4 tier (§01) keeps existing TCP flows pinned to their nginx box while machines join/leave — reshuffling flows means resets for users; Lab 1's L2 cache keeps 90% of its hit rate through a scale event instead of 9%; and §04's per-key rate-limit state stays on the pod that owns the key. Placement stability under membership change is the shared requirement, and it's the requirement mod-N structurally cannot meet.

A3 — Maglev: trading a little churn for perfect balance

RUN — Maglev lookup table, M = 6007 slots, 10 backends
balance: min 600, max 601 slots (ideal 600)  →  max/mean 1.000×
remove node9: keys moved 10.94%
  unavoidable (node9's own keys):     10.00%
  collateral churn (didn't have to):   0.93%

Each backend generates a permutation of table slots (from two hashes: offset + skip) and backends take turns claiming their next preferred free slot until the table fills. Result measured above: balance is essentially perfect by construction (600 vs 601 slots), and lookup is a single array index — table[hash % M], no binary search, exactly what a §14-grade per-packet path wants.

The price is printed too: removing a node moved 10.94% of keys when only 10.00% had to move — 0.93% of keys were collateral churn, remapped despite their backend surviving. The ring never does that; Maglev accepts it deliberately.

WHY DOES GOOGLE'S L4 LB ACCEPT CHURN THE RING AVOIDS?

Because of what each system holds. A cache misplacement costs a miss — you want minimal movement, take the ring. An L4 balancer misplacement costs a live connection reset — but Maglev pairs the table with per-flow connection tracking, so established flows survive remaps and only new flows follow the new table; meanwhile per-packet lookup cost and perfect balance dominate the bill at millions of pps. Under ECMP, imbalance = one hot nginx box capping the whole tier (§03's knee, hit early). So: ring for stateful placement, Maglev-style tables for stateless per-packet dispatch with flow tracking as the safety net. Same family, different loss function — name the loss function and the choice makes itself.

Part B · Count-min sketch, four experiments

Setup: 1M requests from 100k client keys, zipf-distributed (few whales, long tail) — the honest shape of gateway traffic. Sketch: 4 rows × 2000 counters = 31 KiB, vs ~3.8 MB for the exact Counter. Structure: on add, increment one counter per row (row's own hash); on read, take the min across rows.

B1 — Heavy hitters: estimates vs truth

RUN — top clients, estimated from 31 KiB
   key      true   estimate   overcount
   ip0   134,634    134,725         +91
   ip1    63,421     63,557        +136
   ip2    40,329     40,397         +68
   ip3    29,403     29,532        +129
   ...      (all 8 shown in source run: overcounts +62…+170)

For the keys you actually care about — the whales — error is ~0.1–0.6%: collision noise from tail keys adds a few dozen counts to six-figure truths. The sketch is nearly exact precisely where precision matters, and that's structural: overcount magnitude is bounded by other keys' mass landing in your cells, which is the same for everyone — so it's relatively negligible for big counts and relatively brutal for small ones (B3).

B2 — The one-sided guarantee, measured across all 100k keys

RUN — error direction and bound
underestimates: 0            (theory: impossible — counters only increment, min() of
                              overestimates is still an overestimate)
mean overcount: 100    max: 859
within the ε·N = 1,359 bound: 100.00% of keys   (guarantee said ≥ 98.2%: 1 − e^−d)

ε = e/w gives the additive error bound; d rows drive the failure probability down exponentially (each row is an independent chance for a clean min). Both knobs measured landing inside their promises. Recognize the family resemblance: this is the Bloom filter's one-sided error, generalized from membership to frequency. Bloom never says "absent" wrongly; CMS never says "smaller" wrongly.

B3 — Where the error concentrates: the inflated minnow

RUN — worst-inflated key in the whole universe
key ip64982: true count 1  →  estimated 860

A key seen once, estimated at 860: all four of its row-cells happen to be shared with hot keys, and min() can only pick the least-polluted cell, not an unpolluted one. Absolute error (+859) is still inside ε·N — the guarantee never promised small relative error for small keys. Operational consequence: never use raw CMS estimates to punish small counts (e.g., "this IP made ~860 requests, throttle it" — it made one). CMS answers "is this key HUGE?"; for small-count decisions you need exact counters on the shortlist.

B4 — The actual job: detection quality

RUN — flag every key estimated above 0.5% of the stream (5,000)
actual heavy hitters: 20    flagged: 20    missed: 0    false flags: 0

Perfect recall is guaranteed (no underestimates ⇒ no whale can hide under the threshold); perfect precision this run is luck-adjacent (a tail key inflated past 5,000 would be a false flag — and B3's 860 shows the mechanism idling). So the production pattern is the manual's master pattern yet again: CMS as the cheap always-on stage streaming candidates → exact counters kept only for the few flagged keys → §04's limiter or §06's scorer acts on exact numbers. 31 KiB buys you the right to be exact about only twenty keys instead of a hundred thousand.

SRCBoth sources (ring+Maglev ~45 lines, CMS ~20 lines)
# --- consistent hash ring ---
class Ring:
    def __init__(self, nodes, vnodes):
        self.ring = sorted((H(f"{n}#v{v}"), n) for n in nodes for v in range(vnodes))
        self.pts  = [p for p, _ in self.ring]
    def owner(self, key_hash):
        i = bisect.bisect(self.pts, key_hash) % len(self.ring)   # clockwise successor
        return self.ring[i][1]

# --- minimal Maglev table ---
def maglev(nodes, M=6007):                       # M prime, M >> len(nodes)
    perm = {n: (H(n+"#off") % M, H(n+"#skip") % (M-1) + 1) for n in nodes}
    table = [None]*M; nxt = {n: 0 for n in nodes}; filled = 0
    while filled < M:
        for n in nodes:                          # round-robin turns = balance
            off, skip = perm[n]
            while True:                          # next free slot in MY permutation
                c = (off + nxt[n]*skip) % M; nxt[n] += 1
                if table[c] is None:
                    table[c] = n; filled += 1; break
            if filled == M: break
    return table                                 # dispatch = table[hash(5tuple) % M]

# --- count-min sketch ---
class CMS:
    def __init__(self, w, d):
        self.w, self.d = w, d
        self.t = [[0]*w for _ in range(d)]
    def add(self, key, c=1):
        for r in range(self.d):
            self.t[r][H2(key, r, self.w)] += c
    def est(self, key):                          # min across rows = least-polluted cell
        return min(self.t[r][H2(key, r, self.w)] for r in range(self.d))
# production notes: decay for "hot NOW" — halve all counters every T seconds, or run
# two alternating sketches (current+previous) and reset the older (§06's rebuild trick);
# use conservative update (only bump cells equal to the current min) to shrink overcounts;
# per-worker sketches merge by cell-wise addition — composable like §16's histograms
THE FOUR ALGORITHMS, ONE SHELF

Bloom: "have I seen this key?" — one-sided on membership. CMS: "how often?" — one-sided on frequency. Aho-Corasick: "which patterns occur?" — hard per-byte ceiling. Consistent hashing: "who owns this key when the fleet changes?" — bounded disruption. Four constant-ish-cost answers to the four questions a gateway asks about every single request, each trading a precisely characterized error for orders of magnitude in space or stability — and every one of them fronting an exact-but-expensive second stage. That shelf, plus knowing each one's loss function, is the algorithmic core of the whole manual.