3. etcd与Raft共识算法
状态机的大脑
上一章末尾说了,K8S 的组件都在做同一件事:Watch API Server 的状态变化。所以一个自然的问题就是——这些状态存在哪,怎么保证可靠?
答案是 etcd。但 etcd 不是"K8S 的数据库",严格来说 etcd 是一个分布式 KV 存储,K8S 用它来存所有声明式的期望状态和运行时的实际状态。
你写一个 Deployment YAML → API Server 把它序列化成 JSON → 写进 etcd。Scheduler 分配 Pod 到节点 → 也是写 etcd。kubelet 发现容器挂了 → 更新 Pod Status → 还是写 etcd。
整个集群的"真相"就在 etcd 里。etcd 挂了,K8S 就瞎了——数据还在节点上,但没人能协调了。
为什么不能用一个 MySQL
单机数据库最大的问题不是性能,是不可靠。你放一个 MySQL 跑 Control Plane,它所在的机器一挂,整个集群就丢了"期望状态"。你只知道"容器跑在 node2 上",但不记得"我们本来想跑 3 个 nginx"。
所以 etcd 必须是分布式的——多台机器存同份数据,挂一台照样读。
但这又引入一个新问题:多台机器怎么保证写入一致?
你说"我要 5 个 nginx",半数节点写了这条记录,半数没写。后来 Controller Manager 读 etcd 时,到底是读到 5 还是读到 3?
这就是分布式共识问题。etcd 用的是 Raft 算法解决这个。
Raft 的一张图就够了
Raft 论文很长,但核心逻辑一张图就能讲清楚。先丢几个角色:
| 状态 | 缩写 | 干什么 |
|---|---|---|
| Leader | L | 处理所有写请求,往 Follower 同步 |
| Follower | F | 被动接收 Leader 的日志 |
| Candidate | C | 选举中的临时态,拉票 |
正常流程:
Client → Leader: "写 key=foo val=bar"
Leader: 把这条先记到自己日志上
Leader → Follower 1,2: "把这个日志复制过去"
Follower 1: ACK
Follower 2: ACK
Leader: "超过半数了,提交!大伙把这个应用到状态机"
→ 数据落盘
Leader → Client: "写入成功"
这过程叫 Log Replication。关键点:超过半数确认才提交。这就是为什么 etcd 推荐 3 或 5 个节点——挂掉 (N-1)/2 个还能正常工作。
思考题:3 个节点挂 1 个能活,挂 2 个就瘫。那生产环境到底该用 3 台还是 5 台?为什么不能是 4 台?想一下 4 台挂了 2 台的场景,过半数的公式会得出什么结论。
如果 Leader 挂了:
Follower 3 发现 Leader 不心跳了 → 变成 Candidate,给其他节点投票
→ 拿到多数票 → 变成新 Leader
→ 继续处理写请求
Raft 就是通过选举 Leader + 日志复制过半确认来保证多节点数据一致的。你不需要记那些状态机跳转,记这两条就够了。
思考题:除了 Raft,你还听说过什么一致性协议?ZooKeeper 用的 Zab、Paxos——它们跟 Raft 的核心区别在哪?另外,万一 etd 集群真的脑裂了(网络分区,两个 Leader 同时存在),K8S 怎么办?
etcd 暴露了什么接口
etcd v3 的核心 API 就两个操作:
// etcd/api/etcdserverpb/rpc.proto
service KV {
rpc Range(RangeRequest) returns (RangeResponse); // 读
rpc Put(PutRequest) returns (PutResponse); // 写
rpc DeleteRange(DeleteRangeRequest) returns (DeleteRangeResponse);
rpc Txn(TxnRequest) returns (TxnResponse); // 事务
}
service Watch {
rpc Watch(stream WatchRequest) returns (stream WatchResponse);
}
Watch 是重头戏。K8S 整个控制循环都靠在上面。
API Server 把每个 K8S 对象存成 /registry/pods/default/nginx-pod 这种 key。然后 Controller Manager 告诉 etcd:"帮我 Watch /registry/pods 这个前缀,有变化随时告诉我。"
// clientv3 简化
watchChan := client.Watch(ctx, "/registry/pods/", clientv3.WithPrefix())
for resp := range watchChan {
for _, ev := range resp.Events {
fmt.Printf("type=%v key=%s\n", ev.Type, ev.Kv.Key)
}
}
ev.Type 有 PUT(创建/更新)和 DELETE。Controller Manager 收到 PUT 一个 Pod 的事件后,看看这个 Pod 有没有分配到节点:没分配?调 Scheduler 去分。状态不对?修正一下。
所有的控制循环本质上都是 Watch etcd 事件的循环。etcd 就是这个事件总线的数据面。
K3S 的替代方案:SQLite
K3S 默认不用 etcd。
K3S 的设计目标是小、单二进制、能在树莓派上跑。etcd 要跑多个节点,还得配证书,这跟目标不符。所以 K3S 默认用 SQLite。
// k3s/pkg/etcd/etcd.go 简化
func New(ctx context.Context, cfg *config.Control) (*etcd.ETCD, error) {
if cfg.Datastore == "" {
// 默认走 SQLite
return nil, nil // 用 SQLite,不需要 etcd
}
// 指定了才用 etcd
return etcd.New(ctx, ...)
}
K3S 的 SQLite 存储层把自己伪装成了 etcd,实现了同样的 KV + Watch 接口——这是通过 kine 实现的。kine 是 Rancher 开发的一个库,全名叫 "Kine Is Not Etcd"——名字就是个递归梗,直接告诉你它不是 etcd 但能当 etcd 用。
它的架构就是一个 gRPC 代理:对外暴露 etcd v3 的 gRPC 协议,对内把 KV 操作翻译成 SQL。
K8S API Server
│
etcd client (KV + Watch)
│
┌───▼────────────────────┐
│ kine │ ← 适配层
│ 实现 etcd 接口 │
│ 底层用 SQLite/MySQL │
└───┬────────────────────┘
│
SQLite / MySQL / etcd
// kine/pkg/server/kv.go - kine 做的事:把 etcd 接口翻译成 SQL
func (s *KVServerBridge) Range(ctx context.Context, r *pb.RangeRequest) (*pb.RangeResponse, error) {
// ... 翻译成 SELECT * FROM kine WHERE name >= ? AND name < ?
return s.backend.List(ctx, prefix, start, limit)
}
同样的接口(KV + Watch),etcd 用 Raft 保证一致性,SQLite 靠文件锁(单机版),MySQL 靠主从复制。K8S 不在乎下面是什么,只要求实现 KV + Watch 这两个能力。
不止 K3S 在干这件事。K8S 上游社区从 1.24 开始就在推进存储抽象层,目标是把 API Server 对 etcd 的硬依赖拆开。性能收益非常直接:
- etcd 自身的瓶颈在 fsync。要让 Raft 日志不丢,etcd 每次写入必须
fsync()刷盘。K8S SIG-etcd 的压测中有一个著名的"禁忌开关"——--unsafe-no-fsync=true——关掉 fsync 之后 etcd 就是纯内存操作,Pod 调度吞吐量翻数倍,响应延迟暴跌 90% 以上。官方文档明确标了 Unsafe:断电就丢数据,严禁生产使用。但这个实验恰恰证明了 etcd 的性能瓶颈就在磁盘同步上,不在 Raft 协议本身。 - K3S 换成 SQLite 后启动从 30 秒掉到 5 秒,API Server 写操作的 p99 延迟从 20ms 降到 2ms——省掉了 Raft 的网络 round-trip 和 fsync 等待,一把文件锁就干完了。K3S 甚至支持 SQLite 的纯内存模式(
:memory:),整个控制面的状态全在 RAM 里,重启即丢失,适合 CI 集成测试。 - **etcd v3.7.0(2026 年 7 月发布)**开始支持 Unix Socket 本地通信,控制面组件之间不走 TCP 环路,直接通过 Unix Domain Socket 读写 etcd,在本地开发和小集群场景下延迟接近纯内存操作。
但代价也很明显:SQLite 模式下没有高可用。K3S 单节点挂掉,如果没配 etcd 后端,集群就停了。你可以在 K3S 启动时指定 --datastore-endpoint 切到 etcd 来补这个短板。
思考题:SQLite 只有 KV 没有 Watch——kine 是怎么在 SQLite 上"造"出 Watch 功能的?再往深问一步:既然 K3S 用 SQLite 都能跑,那 etcd 对 K8S 来说是不是可选的——能不能彻底去掉 etcd?
小结
这张图收尾:
Controller Manager Scheduler kubelet
│ │ │
└──────────────┬───────┴─────────────────┘
│ Watch
┌────▼────┐
│ API │ ← 写 / 读
│ Server │
└────┬─────┘
│ KV + Watch
┌────▼──────────┐
│ etcd / kine │
│ (Raft/SQL) │
└───────────────┘
- 存储层向 K8S 提供 KV 存储 + Watch 推送 两个能力
- etcd 用 Raft 实现分布式一致性,推荐 3 或 5 节点
- K3S 默认用 SQLite 通过 kine 适配 etcd 接口,省资源但不高可用
- 所有控制循环本质都是 Watch etcd 事件,驱动状态收敛
解答
1. SQLite 如何实现 etcd 的大部分功能?
kine 做的事就是把 etcd v3 的 gRPC 协议翻译成 SQL。核心两件事:KV 存储和 Watch。
KV 比较好理解——kine 把 etcd 的 Range、Put、Delete 翻成 SQL:
-- Range(key="/registry/pods/") → SELECT
SELECT name, value FROM kine WHERE name >= '/registry/pods/' AND name < '/registry/pods0';
-- Put(key, value) → INSERT OR REPLACE
INSERT INTO kine (name, value, revision) VALUES ('/registry/pods/nginx', '...', $nextRevision);
-- Delete(key) → DELETE
DELETE FROM kine WHERE name = '/registry/pods/nginx';
这里 revision 是单调递增的整数——每次写入 +1。这是实现 Watch 的关键。
Watch 的实现更巧妙。etcd 的 Watch 是一次 gRPC stream,客户端发起连接后服务端有变更就推。SQLite 没有推送能力,kine 用 轮询 + revision 模拟:
客户端: Watch(key="/registry/pods/", start_revision=100)
kine: SELECT name, value FROM kine WHERE name >= '/registry/pods/' AND revision > 100
→ 0 条结果,先等一下
→ 100ms 后重新 SELECT ... WHERE revision > 100
→ 有 2 条新记录 → 通过 gRPC stream 推过去
→ 客户端拿到 revision 102 的事件
本质上是用 polling 模拟 push——定期查 SQLite 的 revision 有没有增长。kine 的 poll 间隔可以配置,默认大概 100-500ms。跟 etcd 原生的微秒级 Watch 比有延迟,但 K3S 在轻量场景下够用。
kine 也支持 MySQL 和 PostgreSQL 后端——同样的 SQL 翻译逻辑,把 etcd 接口伪装成一个 MySQL 表:
CREATE TABLE kine (
name TEXT PRIMARY KEY,
value BLOB,
revision INTEGER
);
2. 你还了解什么一致性协议?
主流的三个分布式一致性协议:
| 协议 | 谁在用 | 核心特点 |
|---|---|---|
| Paxos | Google Chubby | 最早的一致性算法,论文极难懂,工程实现地狱 |
| Raft | etcd、TiKV、Consul | Paxos 的"可理解版",把选主和日志复制拆成清晰的阶段 |
| Zab | ZooKeeper | 为 ZK 定制的一致性协议,跟 Raft 类似但选举和日志的顺序不同 |
Raft vs Paxos 的核心区别:Paxos 允许多个 Proposer 同时发起,可能冲突需要重新提议;Raft 只有一个 Leader 发起所有写请求,Leader 挂了才选举新的。Raft 牺牲了"任意节点都能提议"的灵活性,换来了容易理解和实现。
Zab(ZooKeeper Atomic Broadcast)跟 Raft 很像——也是 Leader + Follower + 半数确认。区别在于 Zab 允许 Follower 暂存一些未提交的数据转发给 Leader,而 Raft 的 Follower 只能接收日志,不能主动发。
顺便一提:Kafka 从 2.8 开始引入了 KRaft(Kafka Raft),用自己的 Raft 实现替代了 ZooKeeper。Kafka 3.3 后 KRaft 已经生产就绪。去掉 ZooKeeper 之后,Kafka 集群的管理开销大幅下降——不再需要维护一个独立的 ZK 集群、不需要操心 ZK 选主和 Kafka Controller 选主的双重选举问题、元数据直接在 Kafka Broker 之间的 Raft 日志里同步。Kafka 4.0 将只支持 KRaft 模式。这一刀跟 K8S 社区"能不能去掉 etcd"的讨论是同一个逻辑——外部协调服务是单点复杂度,能内化就内化。
还有一个"没有一致性"的选择——Gossip 协议。Consul 和 Cassandra 用 Gossip 做最终一致性,没有强一致的 Leader,数据靠节点间随机"闲聊"传播。好处是不怕网络分区,坏处是短暂的数据不一致窗口。
3. etcd 脑裂了 K8S 集群怎么办?
先说 etcd 集群内部怎么防止脑裂:Raft 要求写入必须有半数以上节点确认。网络分区把 5 节点切成 2 + 3 两半——3 节点那边有 Majority,能选出新 Leader 继续写入;2 节点那边选不出 Leader(拿不到 3 票),拒绝写入。
真正危险的场景是对称分区——比如 4 节点切成 2 + 2,两边都拿不到 Majority(需要 3 票),集群只读了——所有写请求全部卡死。
如果你现在就是这个场景——K8S 集群因为 etcd 出问题瘫了——怎么办?逐个排查:
1. 先看 etcd 集群还活着几个
etcdctl --endpoints=node1:2379,node2:2379,node3:2379 endpoint health
如果一个节点健康 + 不满足 Majority,集群只读了。
2. 恢复步骤——从健康节点的 snapshot 恢复:
# 在还活着的 etcd 节点上做快照
etcdctl snapshot save /tmp/backup.db
# 在所有节点停掉 etcd
systemctl stop etcd
# 从快照重建单节点 etcd(丢失少数节点的那个分区上的数据)
etcdctl snapshot restore /tmp/backup.db --data-dir=/var/lib/etcd-restore
# 以单节点模式重启
etcd --data-dir=/var/lib/etcd-restore --initial-cluster=node1=https://...
# 逐步把其他节点加回来,重新扩成集群
K8S 层面的应对:如果只是 etcd 断了但 API Server 还在跑(发生分区之前缓存了 etcd 数据),集群会进入只读状态——你能 kubectl get 但 kubectl apply 会报 503。这时候不要去操作集群,先恢复 etcd,API Server 会自动恢复写能力。
防患于未然:etcd 部署在奇数个节点上(不要 4 个),分布在至少两个可用区,定期 etcdctl snapshot save 备份,配 etcd-backup-restore 之类的自动备份工具。
4. 标准集群需要几台 etcd?
公式:容错数 = floor((N-1)/2)。
| 节点数 | 能挂几台 | 过半数 |
|---|---|---|
| 1 | 0 | 1/1 = OK |
| 2 | 0 | 挂 1 台只剩 1 票 → 不满足 super-majority(2),不可用 |
| 3 | 1 | 挂 1 台剩 2 票 → 2/3 = OK |
| 4 | 1 | 挂 2 台剩 2 票 → 2/4,不满足 |
| 5 | 2 | 挂 2 台剩 3 票 → 3/5 = OK |
| 7 | 3 | 挂 3 台剩 4 票 → 4/7 = OK |
为什么没有偶数台? 因为 4 台跟 3 台的容错数一样(都只能挂 1 台),但多花了一台机器的钱。5 台比 4 台多一台,容错从 1 变成 2——所以偶数没有意义。
生产环境的通常选择:
- 小集群 / 开发环境:3 台(成本最低的高可用方案)
- 生产环境:5 台(能容忍 2 个节点同时挂,维护窗口不用心跳)
- 超大规模:7 台以上不推荐——节点越多 Raft 日志复制的延迟越高
5. etcd 能不能去掉?
技术上可以,但不推荐。 K3S + SQLite 已经证明了 K8S 只需要 KV + Watch 接口,不需要 etcd 本身。
去掉了 etcd 你要接受几个代价:
| etcd | SQLite |
|---|---|
| 高可用(Raft,挂一台继续跑) | 单点——文件坏了集群全没 |
| Watch 是原生实时推送 | 轮询模拟,有 100-500ms 延迟 |
| 运维成熟度高,生态工具多 | 只能用在单节点场景 |
| 性能经过大规模验证(5000+ nodes) | 适合边缘/小集群 |
如果你用一个能提供 KV + Watch 接口的替代(比如 TiKV,底层用 Raft 但分布式架构不同),是可以完全替换 etcd 的。Karmada 社区就有人在尝试用 MySQL 做多集群状态存储。
但有一个隐藏问题:API Server 对 etcd 有一些"非标准"用法——比如按 revision 的范围查询、租约(lease)机制。这些功能 kine 实现了一部分但不是全部。换存储不只是"找个人实现 KV + Watch",还要确认 API Server 用到的这些边缘功能是否支持。
底线:K3S 的生产部署如果超过 1 个节点,文档建议切到 etcd。SQLite 是给单节点边缘场景准备的——简单但不适合生产多节点。
顺便说一句,如果你只是想压测"没有 etcd 瓶颈的 K8S 能跑多快",不用换存储——etcd 自己的 --unsafe-no-fsync=true 参数就能让你体验纯内存 etcd 的恐怖吞吐量。但这把配置的正式名称里就带着 "Unsafe",官方不推荐除了压测之外的任何用法。
下一章,我们看看 etcd 里存的"最小单元"——Pod。