backend / protocols / nginx / 02_architecture.md

Nginx Architecture

6 interview angles 5 min read source

Nginx Architecture

Nginx is fast because of how it handles concurrency, not because of any single optimization. The model: one master process, a few worker processes, each worker handling thousands of connections via a non-blocking event loop.

Master / worker model

   nginx master process       ← reads config, manages workers, listens for signals

        ├── worker 1   (single-threaded, event loop, ~10k connections)
        ├── worker 2
        ├── worker 3
        └── worker 4   ← typically 1 per CPU core

The master:

  • Reads and validates config.
  • Binds the listening sockets.
  • Spawns workers; restarts them if they crash.
  • Handles signals (HUP for reload, USR2 for binary upgrade).
  • Owns privileges (root for binding port 80) — workers drop to an unprivileged user.

Workers do all the actual request handling. They never share memory with each other (no locking needed); they share the listening sockets and accept incoming connections as they’re available.

Common config:

worker_processes auto;          # one per CPU core
worker_connections 1024;        # per worker

events {
    use epoll;                  # Linux event mechanism (kqueue on BSD)
    multi_accept on;            # accept all pending connections in one go
}

worker_processes × worker_connections = upper bound on simultaneous connections (each connection costs 1 fd in worker; reverse-proxied requests cost 2 — client + upstream).

Event loop, not threads

Apache (prefork) used one process per request — fine for a few hundred concurrent connections, breaks at thousands (memory + context-switch overhead).

Nginx uses a single-threaded event loop per worker:

loop:
  wait for any of N sockets to be ready (epoll_wait)
  for each ready socket:
    do the small bit of work that's possible without blocking
    (read what's available, write what fits, parse headers, etc.)

A worker juggles ~10k connections concurrently because most are idle most of the time (waiting for the network or upstream). When something is ready to do, the worker does it; when nothing is, the worker sleeps in epoll_wait.

This is the same model as Python’s asyncio, Node.js, Go’s netpoller — all built on the same OS primitives (epoll/kqueue/io_uring).

Why workers are single-threaded

Single-threaded per worker = no locks, no thread-safety overhead, no race conditions. The cost: a single worker pegged at 100% CPU on one core can’t use other cores.

Solution: many workers. With worker_processes 8 on an 8-core machine, you have 8 independent event loops, each on its own core, sharing nothing but the listening sockets.

When workers fight for the accept lock (some kernels), enable accept_mutex off (the default in modern nginx) — kernels handle wake-up balancing fine.

Why nginx scales

Memory per connection: a few KB (just the request state — no per-connection thread stack). 10k connections per worker × 4 KB ≈ 40 MB.

Context switches: minimal — a worker handles many connections in one stack frame.

Compare to Apache prefork: one process per request, ~10 MB RSS each, hard cap around 1000 concurrent.

For Python interviewers: nginx is to Apache what asyncio is to threading.

What nginx is good at

  • Static filessendfile() syscall puts file → socket without copying through user space. Saturates a 10 Gbps NIC easily.
  • Reverse proxy — small CPU per request, parallel upstream fan-out.
  • TLS termination — even without specific HW acceleration, modern CPUs with AES-NI handle it cheaply at the edge.
  • Slow-client buffering — reads slow uploads to its memory, then forwards the whole request to the upstream in one shot, freeing the upstream worker.

What nginx is not good at

  • CPU-heavy logic — anything serious goes to upstream workers. Lua/Perl/JS modules exist but for small glue, not business logic.
  • Long-running connections under high churn — fine for steady WebSockets, but constant connect/disconnect spikes need tuning.
  • Dynamic config without reload — config is read at start/HUP. For dynamic upstreams use OpenResty + Lua, or switch to Envoy/Traefik (built for dynamic config).

Reload vs restart

nginx -s reload     # SIGHUP — re-reads config, spawns new workers, drains old ones
nginx -s quit       # SIGQUIT — graceful shutdown, finish in-flight, then exit
nginx -s stop       # SIGTERM — fast shutdown
nginx -s reopen     # SIGUSR1 — reopen log files (use after log rotation)

Reload is online: existing connections finish on the old workers, new connections go to new workers. Zero-downtime config changes.

For binary upgrades (new nginx version), kill -USR2 <master_pid> runs old + new master in parallel; both share the listening sockets via SO_REUSEPORT or socket inheritance. No dropped connections.

Connection lifecycle, simplified

1. epoll_wait: client socket ready
2. accept the connection
3. read request bytes (may take several loops if client is slow)
4. parse request
5. apply config (find matching server / location)
6. either:
   - serve static file via sendfile()
   - or open upstream socket, forward request
7. wait for upstream response (epoll_wait again)
8. stream/buffer response back to client
9. close or keep-alive for next request

All while the same worker juggles 9999 other connections in similar states.

Common interview confusions

  • “More workers = more performance.” — only up to CPU count (or thereabouts). Beyond that, workers fight each other for the same cores; performance plateaus or drops.
  • “Nginx is multi-threaded.” — workers are typically single-threaded. Newer nginx supports thread pools for blocking syscalls (file I/O on slow disks), but request handling stays event-driven.
  • “Reload drops connections.” — opposite. SIGHUP keeps existing connections on old workers and routes new ones to new workers; old workers exit when drained.
  • “Workers share state.” — they don’t (apart from a small shared-memory zone for things like limit_req counters and the cache index). Each worker is independent.

Interview angle

  • “Why is nginx faster than Apache for serving thousands of concurrent connections?” — event-driven non-blocking workers vs Apache’s process/thread per request. Nginx handles 10k connections per worker in a few KB each; Apache prefork uses MB per connection.
  • “How many worker processes should you configure?” — one per CPU core (worker_processes auto). More doesn’t help; CPUs are the bottleneck.
  • “What happens during nginx -s reload?” — master re-reads config, spawns new workers, sends SIGQUIT to old workers; they finish in-flight requests then exit. Zero-downtime.
  • “What do workers share?” — listening sockets (so any worker can accept), and small shared-memory zones (cache index, rate-limit counters). No request state.
  • “Why is the worker single-threaded?” — no locking, no thread-safety overhead, simpler code, no context switching within a worker. Use multiple workers for multi-core.
  • “What’s sendfile and why does it matter?” — kernel-level “copy this file to this socket” without the bytes ever entering user-space memory. Why nginx serves static files at near-line-rate.