etcd is the coordination store beneath Kubernetes and countless platforms: a small replicated key-value service whose entire design is consensus made deployable. Two decisions carry it: the Raft consensus module is a deterministic, event-driven state machine with no goroutines of its own (the host pumps it), and the applied keyspace is an MVCC log of revisions (v3) rather than a flat map β which is what made watches and transactions correct. This deep-dive teaches the gap between a consensus paper and a service: leases, linearizable reads, and why every read needs an explicit consistency decision.
π§ Explain like I'm 10
A club notebook that most members must agree on. etcd is a club with one precious notebook (the keyspace). Any important note (a Put) is only written after MOST members agree on the next line (a quorum in the Raft vote) β so the notebook never contradicts itself, even if some members nap or storm off. Every line gets a number (a revision), so you can ask "read me the notebook as of line 500" (a watch) or "read me only notes newer than line 500." Even "this note expired at noon" goes through the notebook (leases), so a member who missed the meeting can never secretly disagree later. And reading has two speeds: quick, straight from your copy (maybe slightly old), or confirmed-fresh β the leader first checks the club still agrees, then answers (ReadIndex).
Why the grown-ups did it this way: with most-members agreement, even power cuts and split rooms can't produce two disagreeing notebooks β the difference between "probably fine" and "provably agreed," which is exactly what Kubernetes needs under it.
1. Architecture overview
flowchart TD
C["client β gRPC: Range / Txn / Watch / Lease"]
subgraph server["EtcdServer β server/etcdserver/server.go"]
ADAPT["raftNode adapter (server.go raft.go 81)<br/>pumps Ready(): persist β send β apply"]
RAFT["raft library β etcd-io/raft<br/>deterministic FSM, no goroutines of its own"]
RUN["run loop (751) β applyEntries (1180)<br/>β applier pipeline"]
READ["reads: serializable (local)<br/>| linearizable β ReadIndex (read/read.go 109)"]
end
subgraph store["storage"]
WALF["WAL β storage/wal/wal.go<br/>Save 1000, ReadAll 477"]
MVCC["MVCC keyspace β storage/mvcc/kvstore.go<br/>every mutation at a revision; currentRev 72"]
WATCH["watchable store β synced push (notify 470),<br/>unsynced catch-up"]
LEASE["leases β lease/lessor.go Grant 281<br/>expiry applied as normal entries"]
end
BE[("backend β boltdb B+tree")]
C --> ADAPT
ADAPT <--> RAFT
ADAPT --> WALF
RUN --> MVCC --> BE
MVCC --> WATCH
RUN --> LEASE
C --> READ
READ -.-> MVCC
Diagram source β enable JavaScript to render the diagram.- The spine β one
Putβ is: gRPC handler wraps it as a proposal β raftStepappends to theraftLogβ WALSavepersists the entry (wal.go:1000) β leader replicates; on quorum commit, the server'srunloop (server.go:751)applyEntries(1180) β the applier runs a backend transaction on the MVCC store, bumpingcurrentRev(kvstore.go:72) β watchers are notified (watchable_store.go:470) β the client's proposal future resolves.
Walkthrough: one Put, proposal to watch notification
The spine as a story β step through it on the site or read straight down. Count the places where durability is bought before visibility.
sequenceDiagram
autonumber
participant C as Client (gRPC Put)
participant R as raft library (Step 1089)
participant W as WAL (wal.go Save 1000)
participant F as follower peers
participant A as apply loop (run 751)
participant M as MVCC store + watchers
C->>R: proposal: Put key value
R->>R: leader appends entry to the raft log
R->>W: Save β fsync before anything else
R->>F: MsgApp replicate the entry
F-->>R: MsgAppResp β quorum reached
R->>R: commit index advances
A->>M: applyEntries (1180) β backend txn, currentRev++
M->>M: notify synced watchers (470)
A-->>C: proposal future resolves β done
Diagram source β enable JavaScript to render the diagram.Wrap the wish as a proposal
The gRPC handler turns
Put(key, value) into a raft proposal and funnels it into Step
(etcd-io/raft/raft.go:1089) β a single message-driven function. The
raft state machine itself owns no goroutines: it only computes what
should happen next.
Persist before you promise
The leader appends the entry
to its log and the host's raftNode adapter writes it to the WAL
(wal.go:Save:1000, length-crc framed records) β persistence ordering
is the host's explicit responsibility in the Ready contract.
Ask the club
The entry ships to followers in MsgApp;
peers persist it and answer MsgAppResp. When a quorum has it, the
commit index advances β the line is now official.
Apply, don't improvise
The server's single consumer loop
(run, 751) picks up committed entries and applyEntries (1180) hands
them to the applier, which runs a backend transaction on the MVCC
store β deterministic, or it's a design bug.
Stamp and shout
The mutation lands at a fresh revision
(currentRev, kvstore.go:72); synced watchers are notified by push
(watchable_store.go:470) β watchers that joined at an older revision
are caught up from history first by syncWatchers.
The future resolves
The client's Put returns only now:
persisted on a quorum, applied, visible to watchers. Reads that need
guaranteed freshness go through ReadIndex (read/read.go:109) β the
leader confirms its commit index with one heartbeat round, then serves
from local state.
A write that survives the worst night
As long as a majority of members (and their disks) live, this Put survives crashes, partitions, and restarted peers β and every watcher sees it at a numbered point in history.
2. Core data structures β and why these
The raft state machine (etcd-io/raft/raft.go:343)
struct raft holds the entire node state: role, term, votes, the
log, progress trackers for peers. All transitions happen inside
Step(msg) (1089) β a single message-driven function with a big
dispatch (e.g. MsgVote handling at 1212) β and tickElection/
tickHeartbeat (850/862) convert the host's clock into logical time.
Why goroutine-free: a consensus library that owns no concurrency is
deterministic β the same message sequence always yields the same
state, which is why etcd's raft ships with a simulated-network test
harness that found bugs no integration test could. (The same property
makes Raft's cousin design in Kafka's KRaft β see that deep-dive β
event-sourced and testable.)
The raft log with unstable tail (log.go:25, log_unstable.go:37)
raftLog composes a stable (storage-backed) section and an
unstable in-memory tail; append (133) only mutates the tail until
the host persists it. Why the split: the library must never pretend
durability it doesn't own β the boundary between "in memory" and
"saved" is an explicit, inspectable type.
The MVCC revision keyspace (storage/mvcc/kvstore.go:53)
v3 stores every mutation at key
(main_rev, sub)=(key, value, tombstone) β history is the store;
"current value" is the latest rev β€ your read rev. currentRev
(kvstore.go:72) is the clock of the database. Why: watchers replay
exact history, transactions compare revisions atomically, and
compaction truncates the past by rev β the same MVCC pattern as
PostgreSQL (see PATTERNS.md), implemented on a tiny B+tree (boltdb).
Watchable store: synced and unsynced watcher sets
(storage/mvcc/watchable_store.go:56)
Watchers are partitioned by whether their start revision is already
visible in the backend; synced watchers get events pushed
(notify:470), unsynced ones are caught up by syncWatchers scanning
history β converging to the synced set. Why: a watcher joining at an
arbitrary past revision is a batch problem, but steady-state
notification must be a push problem; the store physically separates
the two regimes.
WAL (storage/wal/wal.go:73) and leases (lease/lessor.go:145)
The WAL frames raft entries/snapshots in length-crc records
(Save:1000, SaveSnapshot:1044, recovered by ReadAll:477).
Leases (Grant:281) attach TTLs to keys through the same raft log β
expiry runs only on the leader, applied as normal entries. Why: even
timeouts flow through consensus, so a partitioned minority can never
resurrect expired keys differently than the majority.
3. Concurrency and failure model
- One goroutine per concern, one raft:
EtcdServer.runis the single consumer of committed entries; clients' proposals funnel through channels intoStep. Concurrency lives at the service edges (gRPC handlers, watcher fan-out), not inside consensus. - The Ready() contract: raft never does I/O; it exposes a
Readybundle (entries to persist, messages to send, entries to apply) and the host (server/etcdserver/raft.go:81 raftNode) executes it β persistence ordering is the host's responsibility, formalized. - Failure/partition semantics: a minority partition keeps serving
serializable (possibly stale) reads but fails writes and
linearizable reads; on heal, the follower's divergent log tail is
truncated by term/commit rules. Learners and
MoveLeader(server.go:1238) smooth leadership changes. - Crash recovery = WAL replay + snapshot + backend consistency check (apply/ has an explicit corruption detector). Anything not through the log does not exist β same WAL discipline as PostgreSQL and LevelDB in this atlas.
flowchart LR
subgraph svc["service edges β where concurrency lives"]
GRPC["gRPC handlers<br/>(many concurrent callers)"]
WF["watcher fan-out"]
end
subgraph core2["the serialized core β one consumer"]
RUNL["EtcdServer.run (751)<br/>single consumer of committed entries"]
STEPF["proposals funnel through channels<br/>into raft Step (1089)"]
end
subgraph raftside["the deterministic library"]
RAFTS["struct raft (343) β no channels, no locks,<br/>same messages in β same state out"]
READY["Ready() bundle: entries to persist,<br/>messages to send, entries to apply β<br/>host executes all I/O"]
end
PART["minority partition: serves serializable (stale)<br/>reads; writes and linearizable reads fail;<br/>divergent tails truncated on heal"]
GRPC --> STEPF --> RAFTS --> READY --> RUNL
RUNL --> WF
PART -.-> RUNL
Diagram source β enable JavaScript to render the diagram.4. API and extension design
- The API is gRPC with explicit consistency choices:
Range(v3_server.go:106) decides serializable vs linearizable per request;Txncomposes compare/success/failure batches;Watchstreams revisions;Leasegrants TTLs. Small surface, sharp semantics β every method answers "what consistency do I get?". - The read path is the extension story: linearizable reads use
ReadIndex (
etcdserver/read/read.goβ thelinearizableReadLooparound line 109): the leader records its commit index, confirms via one round of heartbeats, then serves from local state β correct reads without a log entry per read. A textbook optimization, made operational (timeouts, waiting for apply indexwaitAppliedIndex, backoff on leader change). - Honest wart: every write is a full cluster round trip β etcd is deliberately tiny (metadata, not data); teams that put queues or blobs in it meet the design's ceiling (default quotas, ~10k writes/s class), which is the intended guardrail, not a flaw.
5. The defining trade-offs
Trade-off 1: consensus as a deterministic library over a threaded service
- Tension: consensus must be exactly right; threads make reproducibility hard.
- Choice: raft library owns state and logic only; the host pumps
messages/ticks and executes I/O described by
Ready. - Evidence:
raft struct(raft.go:343) β no channels, no locks;Step(1089) as the single entry; the adapterserver/etcdserver/raft.go:81. - Given up: some performance tricks available to consensus that owns its scheduling; extra host complexity to honor the persist-before-send ordering.
- Verdict: determinism made the library exhaustively testable (simulated nets) and reusable (Kafka's KRaft descends from this design). For any correctness-core subsystem: separate the state machine from its I/O.
Trade-off 2: MVCC revision store over flat key-value
- Tension: watchers and transactions need history; a map is cheaper.
- Choice: every mutation gets a global revision; current state is a query; compaction bounds history.
- Evidence:
store/currentRev(kvstore.go:53/72);watchableStore.notify(470) + unsynced catch-up regime. - Given up: write amplification (boltdb rewrites), compaction as an operational task, value size discipline.
- Verdict: v2 (flat, in-memory, TTL keys) was replaced by v3 precisely because watch correctness and transactions required history β a system-level admission that "just a map" was the wrong core model.
Trade-off 3: explicit read consistency (serializable default opt-in to linearizable) over one-size reads
- Tension: reads dominate (config polling); not all reads need quorum freshness.
- Choice: local serializable reads by default;
serializable=falsetriggers ReadIndex confirmation. - Evidence:
Range(v3_server.go:106) branching; thelinearizableReadLoop(read/read.go:109) machinery. - Given up: default surprises (stale reads after partition heal unless clients opt in); two read code paths to maintain and reason about.
- Verdict: consistency-as-a-parameter is honest distributed design β hide it and clients get either waste (quorum everything) or silent staleness. Every distributed API should make this choice explicit.
6. Reading path
raft/raft.go:343βstruct raft: inventory the state (term, role, votes, log, progresses); this one struct is the paper's Figure 2 made literal.raft.go:tickElection(850) /tickHeartbeat(862) β time as injected ticks; thencampaign(1025) βbecomeLeader(933), andbecomeFollower(891): all role changes in one file.raft.go:Step(1089) β walk one message of each kind:MsgVote(1212),MsgApp,MsgAppRespβ notice commit index advance and message sending are return effects, not actions.raft/log.go:25+log_unstable.go:37βappend(133): where "durable" is an explicit boundary.server/etcdserver/raft.go:81(raftNode) β the bridge: how the host loops overReady(), persisting to WAL before sending, then applying β the ordering contract in code.server/etcdserver/server.go:run(751) βapplyEntries(1180) βapply/uber_applier.goβ proposals become backend transactions; errors here are design bugs (apply must be deterministic).server/storage/wal/wal.goβSave(1000),SaveSnapshot(1044),ReadAll(477): the restart story.server/storage/mvcc/kvstore.goβ revision allocation,TxnBegin, compaction entry points; keepcurrentRev(72) in mind as the database clock.server/storage/mvcc/watchable_store.goβsyncWatchersvsnotify(470): the two watcher regimes; start a watch at an old revision with etcdctl while reading.server/lease/lessor.go:Grant(281) β expiry as consensus entries; thenserver/etcdserver/read/read.go(~109) β ReadIndex: linearizable reads without log writes.- Finish with
server/etcdserver/v3_server.go:Range(106) β one method, both consistency modes, the API honest about each.
You have now traced one Put through consensus, persistence, apply, watch, and read-consistency β the complete distance from a Raft paper to a production service.
7. What to steal
- Make correctness-critical cores deterministic and I/O-free: pump them from the edges (the Ready pattern) and you can simulate what you can never reproduce in production.
- Give data a global revision (MVCC) if watches or transactions matter β flat stores converge to this design eventually, painfully.
- Make read consistency an explicit API choice, never an ambient accident; ReadIndex shows the middle path between quorum-everything and stale-by-default.
- Route even timeouts through consensus (leases): a partitioned minority expiring keys on its own clock is a split-brain you designed yourself.
- Split regimes, not just functions (synced/unsynced watchers): batch catch-up and push steady-state are different problems β make the split structural.
8. Difficulty and prerequisites
- Difficulty: demanding β the raft library is readable by design, but consensus vocabulary is assumed.
- Budget: ~7 hours for the reading path; steps 1β6 are the core.
- Prerequisites: read the Raft paper first (extended version, https://raft.github.io); Go concurrency; what a WAL buys (see the LevelDB deep-dive's WAL section).
9. Further reading
- Ongaro & Ousterhout, "In Search of an Understandable Consensus Algorithm" (Raft) β then re-read step 3 against the paper.
- The etcd docs' consistency and hardware tuning pages (https://etcd.io/docs/) β operational framing of these trade-offs.
etcd-io/raft'sREADME.mdand the interop/test harness (rafthttpsimulated tests) β determinism as a testing strategy.