前两篇反复出现同一句话:"所有人都只看 apiserver,apiserver 背后是 etcd"。这一篇把 etcd 单独拎出来,不再讲 K8s 的对象模型,只讲 etcd 自己:一条 key 的每一次修改是怎么被记住的(resourceVersion 的真正来源)、Watch 断线重连之后为什么不会丢事件、以及 etcd 自己这一层的"主节点"是怎么选出来的——跟第一篇 kube-scheduler 依赖的选举,是完全不同的另一套机制(Raft)。
这一篇跟前两篇不一样:本机真实装了 etcd 3.7.1(跟 K8s v1.37.0 官方推荐的 3.7.0 是同一个大版本),真实搭了一个 3 节点集群,做了真实的写入、历史读取、Watch、以及主节点故障切换实验。
本机装了 etcd 3.7.1(brew 安装),起 3 个进程组成一个真实集群。
$ etcd --name n1 --data-dir ./n1 \ --listen-client-urls http://127.0.0.1:12379 --advertise-client-urls http://127.0.0.1:12379 \ --listen-peer-urls http://127.0.0.1:12380 --initial-advertise-peer-urls http://127.0.0.1:12380 \ --initial-cluster n1=http://127.0.0.1:12380,n2=http://127.0.0.1:22380,n3=http://127.0.0.1:32380 & $ etcd --name n2 ... & # 同样的模式,端口换成 22379/22380 $ etcd --name n3 ... & # 端口换成 32379/32380 $ etcdctl --endpoints=http://127.0.0.1:12379,http://127.0.0.1:22379,http://127.0.0.1:32379 \ endpoint status --write-out=tableENDPOINT RAFT TERM IS LEADER http://127.0.0.1:12379 2 false http://127.0.0.1:22379 2 true ← n2 是真实选出来的第一个 leader http://127.0.0.1:32379 2 false
跟前几篇搭多节点 MongoDB、多进程 K8s 组件的手法一样——本机随便起几个进程,不需要容器,组一个真实集群。n2 被真实选成了 leader,RAFT TERM 是 2(集群刚初始化那一轮选举本身也算一个 term)。
对同一个 key 连续写 3 次,看 etcd 到底记录了哪些版本信息。
$ etcdctl put /registry/pods/default/web-1 'v1' # -> revision: 2 $ etcdctl put /registry/pods/default/web-1 'v2' # -> revision: 3 $ etcdctl put /registry/pods/default/web-1 'v3' # -> revision: 4 $ etcdctl get /registry/pods/default/web-1 -w json{ "kvs": [{ "create_revision": 2, // 第一次写入时的全局 revision,以后不再变 "mod_revision": 4, // 最近一次写入时的全局 revision "version": 3, // 这个 key 一共被写过几次 "value": "djM=" // v3 }] } # 时间旅行:回到 revision=2、revision=3 那一刻,这个 key 是什么值 $ etcdctl get /registry/pods/default/web-1 --rev=2 v1 $ etcdctl get /registry/pods/default/web-1 --rev=3 v2
revision 是整个集群共用的一个全局递增计数器,不是每个 key 各自维护——三次写入依次拿到 2、3、4。这个计数器,就是 K8s 里 resourceVersion 的真身:第一篇里 Reflector 记住的那个 resourceVersion,本质就是 etcd 的这个全局 revision。
server/storage/mvcc/revision.go · etcd-io/etcd @ v3.7.1, L35 type Revision struct { // Main is the main revision of a set of changes that happen atomically. Main int64 // Sub is the sub revision of a change in a set of changes that happen // atomically. Each change has different increasing sub revision in that set. Sub int64 }
server/storage/mvcc/key_index.go · etcd-io/etcd @ v3.7.1, L80 func (ki *keyIndex) put(lg *zap.Logger, main int64, sub int64) { rev := Revision{Main: main, Sub: sub} ... g := &ki.generations[len(ki.generations)-1] if len(g.revs) == 0 { // create a new key g.created = rev } g.revs = append(g.revs, rev) g.ver++ ki.modified = rev }
g.created = rev 只在这个 key 第一次被写入时执行一次——这就是 create_revision 为什么永远是 2,不会变;g.ver++ 每次写入都加一,就是 version;ki.modified = rev 每次都覆盖成最新的,就是 mod_revision。三个字段,三行代码,一一对应。
从一个"过去"的 revision(2)开始 watch,这时候集群当前 revision 已经是 4 了——看 etcd 怎么处理这个"时间差"。
$ etcdctl watch /registry/pods/default/web-1 --rev=2 -w json & $ etcdctl put /registry/pods/default/web-1 'v4'// 第一条消息:一次性把 revision 2、3、4 全部回放过来 {"Header":{"revision":4}, "Events":[ {"kv":{"mod_revision":2,"version":1,"value":"djE="}}, {"kv":{"mod_revision":3,"version":2,"value":"djI="}}, {"kv":{"mod_revision":4,"version":3,"value":"djM="}} ]} // 第二条消息:真正实时的那次写入,单独一条 {"Header":{"revision":5}, "Events":[ {"kv":{"mod_revision":5,"version":4,"value":"djQ="}} ]}
请求的起始 revision(2)比集群当前 revision(4)旧,etcd 没有直接从"现在"开始推送,而是先把 2 到 4 之间错过的全部变化,在一条消息里一次性回放完,再切换成真正的实时推送——这正是第一篇里"Reflector 记住 resourceVersion、断线重连不会丢事件"这句话在 etcd 这一层的真实实现。
server/storage/mvcc/watchable_store.go · etcd-io/etcd @ v3.7.1, L141 synced := startRev > s.store.currentRev || startRev == 0 if synced { ... s.synced.add(wa) } else { slowWatcherGauge.Inc() s.unsynced.add(wa) }
server/storage/mvcc/watchable_store.go · etcd-io/etcd @ v3.7.1, L341 // syncWatchers syncs unsynced watchers by: // 1. choose a set of watchers from the unsynced watcher group // 2. iterate over the set to get the minimum revision and remove compacted watchers // 3. use minimum revision to get all key-value pairs and send those events to watchers // 4. remove synced watchers in set from unsynced group and move to synced group
startRev > s.store.currentRev 是关键判断——请求的起点比当前还新(或者干脆没指定,startRev==0),直接进 synced 组只收实时消息;请求的起点是"过去",就进 unsynced 组,由一个每 100ms 跑一次的后台循环把它欠的历史事件一次性补上,再挪进 synced 组——跟上面真实抓到的"先回放、后实时"两条消息完全对上。
杀掉当前 leader(n2)进程,只剩 2 个节点——够不够选出新 leader,数据丢不丢。
$ kill $(pgrep -f "etcd --name n2") # 干掉当前 leader $ sleep 8 $ etcdctl --endpoints=http://127.0.0.1:12379,http://127.0.0.1:32379 endpoint status --write-out=tableENDPOINT RAFT TERM IS LEADER http://127.0.0.1:12379 3 false http://127.0.0.1:32379 3 true ← n3 真实当选新 leader,term 从 2 变成 3 # 之前写的数据还在吗?新 leader 还能写吗? $ etcdctl --endpoints=http://127.0.0.1:32379 get /registry/pods/default/web-1 v4 ← 没丢 $ etcdctl --endpoints=http://127.0.0.1:32379 put /registry/pods/default/web-1 'v5-after-failover' revision: 6 ← 还能正常写,全局 revision 接着往后走
2 个节点占 3 个投票节点里的多数,集群没有停摆,几秒内真实选出了新 leader(n3),RAFT TERM 从 2 变成 3。之前写入的 v4 完好无损,新 leader 接手后 revision 继续从 6 往后编号,不会因为换了个节点当家就从头计数。
raft.go · etcd-io/raft @ v3.7.0, L850 func (r *raft) tickElection() { r.electionElapsed++ if r.promotable() && r.pastElectionTimeout() { r.electionElapsed = 0 if err := r.Step(&pb.Message{From: new(r.id), Type: pb.MsgHup.Enum()}); err != nil { r.logger.Debugf("error occurred during election: %v", err) } } }
raft.go · etcd-io/raft @ v3.7.0, L902 func (r *raft) becomeCandidate() { ... r.step = stepCandidate r.reset(r.Term + 1) ... r.state = StateCandidate r.logger.Infof("%x became candidate at term %d", r.id, r.Term) }
每个节点自己维护一个选举计时器,超时(pastElectionTimeout(),带随机抖动避免多个节点同时超时打平票)就给自己发一条 MsgHup,触发 becomeCandidate()——r.reset(r.Term + 1) 这一行,就是 term 永远只会精确加一的原因。这跟第六篇 MongoDB 副本集选举的 term 机制,是同一个思路在两套完全独立实现里的体现。
把上面全部真实实验按发生顺序串成一条演示。
判断逻辑由 Python 脚本按上面三段真实源码逐条转写:create_revision/mod_revision/version 的推导跟真实 keyIndex.put 一致,还有一套只用 min/max/len 重新推导的独立实现交叉核对过;watch 的 synced/unsynced 判断直接复刻 startRev > currentRev 这行真实代码;Raft term 的模型断言"每次选举只能精确加一",在 0/1/2/5/100 这几个起始 term 上都验证过。