跳到主要内容

核心使用

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 为什么用 Lease 对象

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 支持的操作符有 =、!=、>、<,比较对象可以是:

比较对象写法示例用途
valuevalue("/demo/count") = "1"值相等判断,最常用
createcreate("/demo/lock") = "0"判断 key 是否从未创建过
modmod("/demo/count") > "10"按修改 revision 判断
versionver("/demo/count") = "3"按修改次数判断
leaselease("/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 自己实现。
别用 txn 做长事务

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[].typePUT 或 DELETE,DELETE 事件的 kv 里只有 key 和 revision
CompactRevision非 0 表示请求的 revision 已被压缩,watch 被取消
Canceled服务端主动取消时为 true,常见于慢消费者或权限变更
Created表示这是 watch 创建成功的确认事件
PUT 的 type 可能省略

事件类型是 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 维护有两条铁律:

  1. 不要指望 kube-apiserver 帮你压缩。历史上 apiserver 有定期压缩的参数,但新版本已经废弃或不再生效,生产集群必须自己配 etcd 的自动压缩,或者用定时任务执行 compact + defrag。
  2. 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——但这是控制面操作,务必在维护窗口做,并确认另外两个成员健康。

压缩点不要超过当前 revision

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 集群尤其要当回事。

会用之后,下一个问题就是「怎么把它部署成不怕挂的集群」,以及证书、备份、恢复这些真正决定能不能活过事故的东西。