核心使用
key 管理
etcd 的 key 是任意字节串,实际使用中基本都按「斜杠分隔的层级」来命名。注意这只是命名习惯,etcd 并不真的建目录,前缀匹配靠的是字典序范围扫描。
写入与读取
put 支持几个常用附加选项:
export ETCDCTL_ENDPOINTS=http://127.0.0.1:2379
# 普通写入
etcdctl put /app/config/feature-a enabled
# 写入并返回旧值,做变更审计很有用
etcdctl put /app/config/feature-a disabled --prev-kv
# 绑定租约写入(租约下文细讲)
LEASE_ID=$(etcdctl lease grant 300 | awk '{print $2}')
etcdctl put /app/instance/web-1 '{"addr":"10.0.0.11:8080"}' --lease=$LEASE_ID
# 读取
etcdctl get /app/config/feature-a
etcdctl get /app/config/feature-a --print-value-only
etcdctl get /app/config/feature-a -w json
get 的返回里,kv 之外的 header.revision 是这次读看到的全局 revision,做 List-Watch 时要用它当基准。-w json 里的 key 和 value 是 base64 编码的,不是乱码,解码后才是原始字符串。
前缀范围查询
etcd 的范围查询是左闭右开区间 [key, range_end)。etcdctl 的 --prefix 会自动把前缀转成 range_end:
# 列出某个前缀下的所有 key
etcdctl get /app/ --prefix
# 只要 key,不要 value
etcdctl get /app/ --prefix --keys-only
# 按前缀分页,先取 10 条
etcdctl get /app/ --prefix --limit=10
# 从指定 key 开始往后取(不设上限),适合遍历大前缀
etcdctl get /app/ --from-key --limit=100
# 统计 key 数量
etcdctl get /app/ --prefix --count-only -w fields
分页遍历时,下一页的起点是上一页最后一个 key 后面拼一个 \x00 字节,etcdctl 不直接支持「从上一个 key 之后开始」,脚本里常用 --from-key 配合自己算范围。K8s 的 apiserver 对 etcd 的 list 就是这么分页的,避免一次拉太多把内存打满。
/app 和 /app/ 是两个不同的前缀。用 /app 做前缀会把 /application、/app2 一起扫出来。命名时统一带上结尾斜杠,能省掉很多边界问题。
历史版本
MVCC 保留了 key 的历史版本,只要没被 compact 掉,就能按 revision 读回旧值:
# 当前 revision
REV=$(etcdctl get /app/config/feature-a -w json | jq -r '.header.revision')
echo $REV
# 读 3 个 revision 之前的值
etcdctl get /app/config/feature-a --rev=$((REV - 3))
# 看某个 key 的元数据:create_revision / mod_revision / version
etcdctl get /app/config/feature-a -w json | jq '.kvs[0]'
这条能力在排查「配置是什么时候被改坏的」时特别好用:找到异常时间点对应的 revision,逐个版本读出来对比。一旦执行了 compact,早于压缩点的历史版本就读不到了,返回 required revision has been compacted。
租约 Lease
租约是 etcd 实现「自动过期」的机制。客户端申请一个带 TTL 的 lease,把 key 绑上去,然后定期续期;续期一停,TTL 到期后 etcd 自动删掉这些 key。服务注册、心跳、分布式锁的临时节点都靠它。
创建、绑定与查询
# 申请一个 60 秒的租约,输出里第二列是 lease ID
LEASE_ID=$(etcdctl lease grant 60 | awk '{print $2}')
echo $LEASE_ID
# 694d7c3f8b1c2a5e
# 把 key 绑到租约上
etcdctl put /services/web/10.0.0.11:8080 up --lease=$LEASE_ID
# 查租约剩余时间和绑定的 key
etcdctl lease timetolive $LEASE_ID
etcdctl lease timetolive $LEASE_ID --keys
# 列出所有租约
etcdctl lease list
# 主动撤销租约,绑定的 key 立即删除
etcdctl lease revoke $LEASE_ID
一个租约可以绑多个 key,适合「一个实例注册多个端点」的场景。绑定的 key 如果被重新 put 且不带 --lease,就会脱离租约,不再随租约过期。
续期与过期演示
开一个终端续期:
etcdctl lease keep-alive $LEASE_ID
# lease 694d7c3f8b1c2a5e keepalived with TTL(60s)
# lease 694d7c3f8b1c2a5e keepalived with TTL(60s)
keep-alive 默认每隔 TTL 的三分之一续一次,前台阻塞运行,按 Ctrl+C 或进程退出就停止续期。
再开一个终端观察过期:
# 申请 10 秒租约并写 key
LEASE_ID=$(etcdctl lease grant 10 | awk '{print $2}')
etcdctl put /demo/ephemeral alive --lease=$LEASE_ID
etcdctl get /demo/ephemeral
# 等 12 秒,不续期
sleep 12
etcdctl get /demo/ephemeral
# 无输出,key 已被自动删除
这个演示说明一个关键点:过期删除是 etcd 服务端行为,不依赖客户端来清理。客户端进程崩溃、网络断开,只要续期停了,key 自然消失,不会留下僵尸注册信息。
服务注册场景
把上面的机制串起来,就是一个最简服务注册中心:
# 服务启动时:申请租约,注册自身信息
LEASE_ID=$(etcdctl lease grant 30 | awk '{print $2}')
etcdctl put /services/api/10.0.0.11:8080 '{"healthy":true}' --lease=$LEASE_ID
# 进程存活期间:持续续期(生产上用客户端库的后台 KeepAlive)
etcdctl lease keep-alive $LEASE_ID
# 服务退出或崩溃:续期停止,30 秒后 key 自动删除
消费方 watch --prefix /services/api/,就能实时感知实例上下线。生产环境一般用 Go client 的 concurrency 包或各语言 SDK 封装好的注册组件,不要自己用 shell 循环续期。
K8s 的节点心跳早期是每个节点每 10 秒更新一次 Node 对象的 status,集群大了会给 apiserver 和 etcd 造成巨大写压力。后来引入 coordination.k8s.io/v1 的 Lease 对象,节点只更新一个很小的 Lease,本质上是把 etcd 租约的能力上移到了 API 层。
事务 txn
etcd 的事务是「带条件的原子操作集合」:先做一组 compare,全部成立就执行 success 分支,否则执行 failure 分支。事务内的所有写共享同一个 revision,要么全成功,要么全不生效。
交互式事务
etcdctl txn 支持交互式输入,适合手工验证:
etcdctl put /demo/count 1
etcdctl txn --interactive
按提示输入:
compares:
value("/demo/count") = "1"
success requests (get, put, del):
put /demo/count 2
failure requests (get, put, del):
get /demo/count
输出 SUCCESS 表示 compare 成立,count 被改成 2。再执行一次同样的 txn,因为当前值已经是 2,compare 不成立,走 failure 分支,只返回当前值,不会把 count 覆盖掉。
compare 支持的操作符有 =、!=、>、<,比较对象可以是:
| 比较对象 | 写法示例 | 用途 |
|---|---|---|
| value | value("/demo/count") = "1" | 值相等判断,最常用 |
| create | create("/demo/lock") = "0" | 判断 key 是否从未创建过 |
| mod | mod("/demo/count") > "10" | 按修改 revision 判断 |
| version | ver("/demo/count") = "3" | 按修改次数判断 |
| lease | lease("/demo/key") = "0" | 判断是否绑定了租约 |
CAS 与分布式锁
create revision = 0 是最经典的「抢锁」写法:
compares:
create("/demo/lock") = "0"
success requests (get, put, del):
put /demo/lock owner-a
failure requests (get, put, del):
get /demo/lock
第一个抢到的客户端 put 成功,后来者 compare 失败,只能读到当前持有者。注意这只是「抢锁」,还没解决锁释放和持有者崩溃的问题——生产上用 etcd 官方的 concurrency 包,它把租约、续期、等待队列都封装好了。
事务的边界
- 原子性只覆盖一次事务。事务内的多个 put 共享 revision,但事务之间没有隔离级别可调,etcd 的默认语义就是串行化。
- 单个事务最多 128 个操作,包含 compare 和 put/delete。批量导入要拆批,每批之间自己保证幂等。
- 嵌套事务要谨慎。v3 API 允许事务里再嵌套事务,但嵌套分支的执行顺序没有保证,也更容易写出超大提案;etcdctl 交互式模式不提供嵌套写法,业务里尽量用扁平事务。失败分支只能做补偿读或补偿写,没有回滚脚本。
- 事务不保证跨 key 的业务约束。etcd 只保证原子提交,唯一性、外键这些约束得靠 compare 自己实现。
etcd 的事务是「一次 Raft 提案」,里面塞几百个操作会让这条日志变得很大,复制和 apply 都变慢。大批量写入应该拆成多个小事务,并控制写入速率,避免 leader 被一次提案拖住。
watch 事件结构
etcdctl watch -w json 的输出是排查 watch 问题的一手材料。一次 PUT 事件大致长这样:
{
"Header": {
"cluster_id": 14841639068965178418,
"member_id": 10276657743932975437,
"revision": 42,
"raft_term": 2
},
"Events": [
{
"kv": {
"key": "L2RlbW8vaGVsbG8=",
"create_revision": 40,
"mod_revision": 42,
"version": 2,
"value": "d29ybGQ="
},
"prev_kv": {
"key": "L2RlbW8vaGVsbG8=",
"create_revision": 40,
"mod_revision": 41,
"version": 1,
"value": "aGVsbG8="
}
}
],
"CompactRevision": 0,
"Canceled": false,
"Created": false
}
字段含义:
| 字段 | 说明 |
|---|---|
Header.revision | 这批事件对应的全局 revision |
Events[].kv | 变更后的 key 和值,key/value 是 base64 |
Events[].prev_kv | 变更前的值,需要客户端开启 PrevKV 才有 |
Events[].type | PUT 或 DELETE,DELETE 事件的 kv 里只有 key 和 revision |
CompactRevision | 非 0 表示请求的 revision 已被压缩,watch 被取消 |
Canceled | 服务端主动取消时为 true,常见于慢消费者或权限变更 |
Created | 表示这是 watch 创建成功的确认事件 |
事件类型是 proto 枚举,PUT 对应 0 值,proto3 的 JSON 序列化会省略默认值。所以 PUT 事件的 JSON 里可能根本没有 type 字段,看到没写 type 就按 PUT 理解;DELETE 是非零值,会显式出现。
几个实战要点:
- 从哪个 revision 开始:
--rev=N是包含 N 的,也就是从 revision N 开始推。客户端记录最后处理的 revision 后,重连时传lastRevision + 1,避免把已经处理过的事件再收一遍。 - 事件可能成批:一次事务改了多个 key,会在一个
Events数组里返回多条,revision 相同。处理时要么整批应用,要么按自己的业务逻辑保证幂等。 - 删除事件没有 value:DELETE 事件的 kv 只有 key、mod_revision 等元数据,
prev_kv才带删除前的值。 - 慢消费者会被踢:客户端处理速度长期跟不上,etcd 会取消 watch 并记录慢 watcher 指标。不要在一个 watch 回调里做耗时 IO。
# 看事件类型和 revision,最直观的调试方式
etcdctl watch --prefix /demo/ -w json | jq '.Events[] | {type, key: .kv.key, rev: .kv.mod_revision}'
压缩与碎片整理
为什么必须 compact
etcd 的 MVCC 会保留每个 key 的历史版本。K8s 里 Pod 状态频繁更新,每次更新都是一个新 revision,旧版本一直占着 bbolt 文件的空间。如果不压缩,db 文件会持续增长,最终撞上配额触发 NOSPACE,整个集群变成只读。
compact 做的是逻辑删除:告诉 etcd「某个 revision 之前的历史版本可以扔了」。它不会立刻缩小磁盘文件,只是把这些版本标记为可回收。
# 压缩到当前 revision
REV=$(etcdctl endpoint status -w json | jq -r '.[0].Status.header.revision')
etcdctl compact $REV
# compacted revision 123456
# 压缩后再读旧版本会报错
etcdctl get /demo/hello --rev=$((REV - 100))
# etcdserver: mvcc: required revision has been compacted
defrag 回收磁盘
compact 只释放逻辑空间,bbolt 文件里的空洞要靠 defrag 重写文件来回收:
# 看当前 db 大小和实际使用量
etcdctl endpoint status -w json | jq '.[0].Status | {dbSize, dbSizeInUse}'
# 对单个成员做碎片整理,会阻塞该成员的读写
etcdctl defrag --command-timeout=600s
# 对集群所有成员依次整理(3.5 支持,内部仍是逐个执行)
etcdctl defrag --cluster --command-timeout=600s
defrag 的注意事项:
- 会阻塞读写。整理期间这个成员不处理请求,集群靠其他成员保持可用。生产上必须一台一台来,每台之间确认集群健康。
- 需要额外磁盘空间。bbolt 重写文件时临时占用接近 db 大小的空间,磁盘剩余不足会失败。
- 不要和 compact 同时做。先 compact 到当前 revision,再 defrag,顺序反了回收效果打折。
- leader 最后做。先整理 follower,观察集群稳定后再整理 leader,减少选主风险。
自动压缩
手工 compact 容易忘,生产上直接开自动压缩。etcd 3.5 支持两种模式:
# 按时间周期压缩,保留最近 1 小时的历史
--auto-compaction-mode=periodic --auto-compaction-retention=1h
# 按 revision 数压缩,保留最近 10000 个 revision
--auto-compaction-mode=revision --auto-compaction-retention=10000
周期模式适合写入量稳定的集群,revision 模式适合写入量波动大的场景。K8s 集群通常用周期模式,保留 1 小时左右足够 apiserver 的 watch 重连和 etcd 自身恢复。自动压缩只做 compact,不会做 defrag,磁盘回收仍然要靠定期任务。
K8s 集群的日常维护
K8s 的 etcd 维护有两条铁律:
- 不要指望 kube-apiserver 帮你压缩。历史上 apiserver 有定期压缩的参数,但新版本已经废弃或不再生效,生产集群必须自己配 etcd 的自动压缩,或者用定时任务执行 compact + defrag。
- defrag 要选业务低峰期。虽然每次只阻塞一个成员,但控制面写入频繁时,阻塞几秒也可能引起 apiserver 请求超时和 leader 切换。
kubeadm 集群加自动压缩,推荐在 ClusterConfiguration 里给 etcd 传参,升级时不会被静态 Pod 清单覆盖:
apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
etcd:
local:
extraArgs:
auto-compaction-mode: periodic
auto-compaction-retention: 1h
quota-backend-bytes: "8589934592"
改完之后 kubeadm upgrade 会重新生成静态 Pod。已经在跑的集群也可以直接改 /etc/kubernetes/manifests/etcd.yaml 的 command 段,kubelet 检测到文件变化会自动重启 etcd——但这是控制面操作,务必在维护窗口做,并确认另外两个成员健康。
etcdctl compact 的参数不能大于当前 revision,也不能小于已经压缩过的 revision。脚本里先查当前 revision 再压缩,别把固定数字写死。
小结
- key 管理核心是前缀 + revision:
--prefix做范围查询,--rev读历史版本,-w json拿元数据。 - Lease 把「过期删除」交给服务端:grant → put --lease → keep-alive,停续期就自动清理,是服务注册的标准做法。
- 事务是带 compare 的原子操作集,适合 CAS、抢锁、计数更新;单事务别超过 128 个操作。
- Watch 事件带 revision 和 prev_kv,是增量同步的基础;压缩会让过旧的 watch 失效,客户端要能处理
CompactRevision。 - compact 释放逻辑空间,defrag 回收磁盘;生产上开自动压缩,defrag 逐台做、放低峰,K8s 集群尤其要当回事。
会用之后,下一个问题就是「怎么把它部署成不怕挂的集群」,以及证书、备份、恢复这些真正决定能不能活过事故的东西。