Etcd原理与运维

基于K8s之上的声明式API编程可以说Etcd起到了至关重要的作用。一方面,API Server使用了etcd的特性从而提供了订阅,选举等功能。另一方面,k8s集群的所有配置信息也都集中存放于etcd中,etcd完好的情况下,可以随意的变动node节点,k8s最终会保障服务都按照预期来编排和对外提供服务。

从etcd自生的实现来讲,其使用了Raft协议来保障数据的一致性,底层使用bbolt k-v存储,同时使用WAL和snapshot等都是分布式系统研究的好素材。

Raft协议

说到分布式系统的一致性,都会想到raft协议。那raft协议之所以这么声明大昭,其到底有哪些精妙之处呢?我觉得其实很多协议(包括通讯协议)都是对生活中一些道理的抽象,我后续会举一个例子来讲述raft的选举。

Raft协议主要包含两块:

  • 选举过程
    主要涉及采用何种流程来确立集群中的江湖地位;

  • 日志复制
    主要涉及在确立好各个角色江湖地位的情况下,如何保障日志存储的一致性的问题。

选举过程

无论是哪一种排别,都要先讲讲江湖地位。这里一种有三种角色,分别为:

  • leader
    对内,定期发送心跳报文,通告天下,维护自己的江湖地位;
    对外,负责处理所有客户端的请求,当接收到客户端的写入请求时,会在本地追加一条相应的日志,然后将其封装成消息发送到集群中其他的Follower节点。

  • candidate
    由Follower节点转换而来的,当Follower节点长时间没有收到Leader节点发送的心跳消息时,则该节点的选举计时器就会过期,同时会将自身状态转换成Candidate,发起新一轮选举。

  • follower
    简单地响应来自Leader或者Candidate的请求; 也不处理Client的请求,而是将请求重定向给集群的Leader节点。

学院派

总结起来就是:3/2/1, 即:”三个角色 / 两个定时器 / 一个任期”。


  1. 集群初始化时,所有节点都处于Follower状态, 当Follower一段时间(选举计时器超时)收不到Leader的心跳消息,就认为Leader出现故障导致其任期(Term)过期, Follower会转成Candidate;
  2. Candidate等选举计时器超时后,会先投自己一票,并向集群中其他节点发起选举请求,并带上自己的Term ID以及当前日志的Max Index;
  3. 如果其他节点没有投出Term ID内的一票,就会比较Candidate的Max Index是否比自己小,如果比自己大或者相等,就会投票给Candidate;同时,会将自己的选举计时器重置;
  4. Candidate在获取到超过半数节点的选票后,升级为Leader;并按照心跳计时器向集群中其他节点发送心跳报文,并同步log entity等; follower收到心跳报文后,会重置选举计时器

注意: 在第三步中,除了要确定term任期内未投过票之外,还要确定candidate的log index的原因是保障不会让日志记录缺失的成员成为leader,相当于冒泡排序法,找出log最完整的成员做leader。

另外,为了保障不频繁出现重新选举,对两个定时器的设置需要满足:

广播时间 < 选举超时时间 < 平均故障间隔时间

武林派

  1. 话说当年日月圣教由任我行教主执掌,但是出于对武学的痴迷,仍教主天天闭关修炼,不理教内事务。于是教内腐败严重,没有教主的打压,各种小势力乘机崛起,其中,最牛叉的当数东方不败;
  2. 东方不败乘任教主闭关期间,怂恿半数的收下归顺自己,同时凭借自己的武功力压群雄,让教内所有人都不得不承认任我行的时期已过去,现在是东方不败的时代了;
  3. 作为新的继任者,东方不败一方面整治教内腐败,打压各种小势力;另一方面积极处理对外事务,很快教内又恢复了生机;
  4. 有一天任我行闭关归来,发现所有教众都已经诚服与东方不败;同时,功力也不如东方不败,于是也只好认栽!

这个故事歪曲了《笑傲江湖》的原剧本,谁叫任我行姓任呢,书中他必须要重出江湖呀!但是Raft协议在这里却告诉了我们什么是现实,现实就是你必须低头,你的Term过了,长江后浪推前浪。

日志复制

Leader除了向Follower发送心跳消息,还会处理客户端的请求,并将客户端的更新操作以消息(Append Entries消息)的形式发送到集群中所有的Follower。当Follower记录收到的这些消息之后,会向Leader返回相应的响应消息。 Leader在收到半数以上的 Follower的响应消息后,会对客户端的请求进行应答。

Leader会维护nextIndex[]matchIndex[],这两个数组中记录的都是日志索引值

  • nextIndex[]
    记录了需要发送给每个Follower的下一条日志的索引值;

  • matchIndex[]
    表示记录了己经复制给每个Follower的最大的日志索引值。

调整过程

  1. 当一个新leader被选举出来时,它不知道每一个follower当前log的状态,因此会将每个的nextIndex值都设置为其日志的最大index(然后再逐步调整),同时将其matchIndex设置为0;
  2. Leader会向其他节点发送AppendEntries,若节点己经拥有了Leader的全部log(要求index和term都对应),它会返回追加成功的响应并等待后续的日志 ;若节点没有index对应的log,就会返回追加日志失败的响应;收到响应后,Leader节点会将nextIndex前移。

我的理解是,在AppendEntries中只是带了index和term信息,而不带内容;在第二步确定了nextIndex的位置之后,leader才真正将缺失的log内容追加给follower。

Etcd

主要介绍一些调试和性能方面可能有影响的知识点,以及运维常用的命令。

知识点

可能影响到etcd性能的点。

存储

etcd在 BoltDB 中存储的 Key 是 reversion, Value 是 etcd 自定义的键值对组合。

1
2
3
4
rev={1 0) , key=key1, value="value1"
rev={1 1), key=key2, value="value2"
rev={2 0) , key=key1, value="updatel"
rev={2 1) , key=key2 , value= "update2"

reversion主要由两部分组成,第一部分是 main reversion,每次事务递增一;第二部分是sub reversion,同一个事务中的每次操作都会递增1,两者结合就可以保证Key唯一且递增。

从backend store保存的数据格式我们可以看出,如果要从BoltDB中查询键值对,必须通过reversion进行查找。但客户端只知道具体的键值对中的Key值,并不清楚每个键值对对应的reversion信息,所以在v3版本存储的内存索引(kvIndex)中保存的就是Key与reversion之前的映射关系。

客户端在查找指定键值对时,会先通过内存中维护的 B树索引(该B树索引中维护了原始Key值到keyIndex的映射关系)查找到对应的keyIndex实例,然后通过keyIndex查找到对应的revision信息(keyIndex内维护了多个版本的revision信息〉,最后通过revision映射到磁盘中的 BoltDB查找并返回真正的键值对数据。

快照

随着节点的运行,会处理客户端和集群中其他节点发来的大量请求,相应的WAL日志量会不断增加,会产生大量的WAL日志文件,这就会导致资源浪费 。当节点宕机之后,如果要恢复其状态,则需要从头读取全部的WAL日志文件,这显然是非常耗时的。为了解决这些问题,etcd会定期创建快照并将其保存到本地磁盘中,在恢复节点状态时会先加载快照文件,使用该快照数据将节点恢复到对应的状态,之后从快照数据之后的相应位置开始读取WAL日志文件,最终将节点恢复到正确的状态。

etcd的members目录下可以看到snap和wal两个目录,wal下主要存放Write ahead log的数据;snap下存放的就是从wal做的snap。另外,在snap下还有一个db文件,它就是bbolt的数据库文件。

--snapshot-count参数控制快照的频率,默认是10000,即每10000次变更会触发一次快照操作。如果内存使用率高并且磁盘使用率高,可以尝试调低这个参数。

debug

启动参数中添加--debug即可打开debug模式,etcd会在http://x.x.x.x:2379/debug路径下输出debug信息。由于debug信息很多,会导致性能下降。

/debug/pprof为go语言runtime的endpoint,可以用于分析CPU、heap、mutex和goroutine利用率。

调优

  • 将etcd所使用的磁盘与系统盘分开
  • data目录和wal目录分别挂载不同的磁盘
  • 有条件推荐使用SSD固态硬盘
  • 使用ionice调高etcd进程的IO优先级(这个针对etcd数据目录在系统盘的情况)
    1
    ionice -c2 -n0 -p `pgrep etcd`

运维

包含一些运维常用的命令和方法,以备随时拷贝。

添加新节点

增加一个新的节点分为两步:

  1. 通过 etcdctl或对应的API注册新节点
  2. 使用恰当的参数启动新节点

假设我们要新加的节点取名为infra3, peerURLs是http://10.0.1.13:2380

1
2
3
4
5
6
$ etcdctl member add infra3 http://10.0.1.13:2380
added member 9bf1b35fc7761a23 to cluster

ETCD_NAME="infra3"
ETCD_INITIAL_CLUSTER="infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380,infra3=http://10.0.1.13:2380"
ETCD_INITIAL_CLUSTER_STATE=existing

etcdctl 在注册完新节点后,会返回一段提示,包含3个环境变量。然后在第二部启动新节点的时候,带上这3个环境变量即可。

1
2
3
4
$ export ETCD_NAME="infra3"
$ export ETCD_INITIAL_CLUSTER="infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380,infra3=http://10.0.1.13:2380"
$ export ETCD_INITIAL_CLUSTER_STATE=existing
$ etcd -listen-client-urls http://10.0.1.13:2379 -advertise-client-urls http://10.0.1.13:2379 -listen-peer-urls http://10.0.1.13:2380 -initial-advertise-peer-urls http://10.0.1.13:2380 -data-dir %data_dir%

这样,新节点就会运行起来并且加入到已有的集群中了。

值得注意的是,如果原先的集群只有1个节点,在新节点成功启动之前,新集群并不能正确的形成。因为原先的单节点集群无法完成leader的选举。直到新节点启动完,和原先的节点建立连接以后,新集群才能正确形成。

故障恢复

备份数据

1
2
etcdctl backup --data-dir /var/lib/etcd -backup-dir /tmp/etcd_backup
tar -zcxf backup.etcd.tar.gz /tmp/etcd_backu

使用--force-new-cluster参数启动Etcd服务。这个参数会重置集群ID和集群的所有成员信息,其中节点的监听地址会被重置为localhost:2379, 表示集群中只有一个节点。

1
2
tar -zxvf backup.etcd.tar.gz -C /var/lib/etcd
etcd --data-dir=/var/lib/etcd --force-new-cluster ...

快照恢复

节点数据快照

1
2
3
4
5
6
7
8
9
10
ETCDCTL_API=3 etcdctl --endpoints $ENDPOINT snapshot save snapshotdb
# exit 0

# verify the snapshot
ETCDCTL_API=3 etcdctl --write-out=table snapshot status snapshotdb
+----------+----------+------------+------------+
| HASH | REVISION | TOTAL KEYS | TOTAL SIZE |
+----------+----------+------------+------------+
| fe01cf57 | 10 | 7 | 2.1 MB |
+----------+----------+------------+------------+

需要在每一个节点上都做restore

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ etcdctl snapshot restore snapshot.db \
--name m1 \
--initial-cluster m1=http:/host1:2380,m2=http://host2:2380,m3=http://host3:2380 \
--initial-cluster-token etcd-cluster-1 \
--initial-advertise-peer-urls http://host1:2380
$ etcdctl snapshot restore snapshot.db \
--name m2 \
--initial-cluster m1=http:/host1:2380,m2=http://host2:2380,m3=http://host3:2380 \
--initial-cluster-token etcd-cluster-1 \
--initial-advertise-peer-urls http://host2:2380
$ etcdctl snapshot restore snapshot.db \
--name m3 \
--initial-cluster m1=http:/host1:2380,m2=http://host2:2380,m3=http://host3:2380 \
--initial-cluster-token etcd-cluster-1 \
--initial-advertise-peer-urls http://host3:2380

历史记录压缩

如果将etcd用作服务发现,每次服务注册和更新都可以看做一条新数据,日积月累,这些数据的量会导致etcd占用内存越来越大,直到etcd到达空间配额限制的时候,etcd的写入将会被静止,影响线上服务,定期删除历史记录就是避免这种情况。

数据压缩并不是清理现有数据,只是对数据的历史版本进行清理,清理后数据的历史版本将不能访问,但不会影响现有最新数据的访问。

  • –auto-compaction-retention
    Auto compaction retention for mvcc key value store in hour. 0 means disable auto compaction.
    default: 0

  • –auto-compaction-mode
    Interpret ‘auto-compaction-retention’ one of: ‘periodic’, ‘revision’. ‘periodic’ for duration based retention, defaulting to hours if no time unit is provided (e.g. ‘5m’). ‘revision’ for revision number based retention.
    default: periodic

v3.3之上的版本有这样一个规则

Periodic compactor keeps recording latest revisions for every compaction period when given period is less than 1-hour, or for every 1-hour when given compaction period is greater than 1-hour (e.g. 1-hour when –auto-compaction-mode=periodic –auto-compaction-retention=24h)

也就是说,如果配置的值小于1小时,那么就严格按照这个时间来执行压缩;如果配置的值大于1小时,会每小时执行压缩,但是采样还是按照保留的版本窗口依然按照用户指定的时间周期来定。

1
$ etcd --auto-compaction-retention=1  # 只保留一个小时的历史数据

k8s api-server支持定期执行压缩操作,其参数里面有这样的配置:

–etcd-compaction-interval duration Default: 5m0s
The interval of compaction requests. If 0, the compaction request from apiserver is disabled.

碎片整理

进行compaction操作之后,旧的revision被压缩,会产生内部的碎片,内部碎片是指空闲状态的,能被etcd使用但是仍然消耗存储空间的磁盘空间。去碎片化实际上是将存储空间还给文件系统。

1
2
3
4
5
6
7
etcdctl defrag

# 带参数,整理集群所有节点
$ etcdctl defrag --cluster
Finished defragmenting etcd member[http://127.0.0.1:2379]
Finished defragmenting etcd member[http://127.0.0.1:22379]
Finished defragmenting etcd member[http://127.0.0.1:32379]

如果etcd没有运行,可以直接整理目录中db的碎片

1
$ etcdctl defrag --data-dir <path-to-etcd-data-dir>

Note:碎片整理和压缩都会阻塞对etcd的读写操作。

存取空间限制

  • Request size limit

    etcd is designed to handle small key value pairs typical for metadata. Larger requests will work, but may increase the latency of other requests. By default, the maximum size of any request is 1.5 MiB. This limit is configurable through --max-request-bytes flag for etcd server.

    --max-request-bytes限制请求的大小,默认值是1572864,即1.5M。在某些场景可能会出现请求过大导致无法写入的情况,可以调大到10485760即10M。

  • Storage size limit

    The default storage size limit is 2GB, configurable with --quota-backend-bytes flag. 8GB is a suggested maximum size for normal environments and etcd warns at startup if the configured value exceeds it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 设置非常小的 16MB 配额
$ etcd --quota-backend-bytes=$((16*1024*1024))

# 消耗空间
$ while [ 1 ]; do dd if=/dev/urandom bs=1024 count=1024 | etcdctl put key || break; done
...
Error: rpc error: code = 8 desc = etcdserver: mvcc: database space exceeded

# 确认配额空间被超过
$ etcdctl --write-out=table endpoint status
+----------------+------------------+-----------+---------+-----------+-----------+------------+
| ENDPOINT | ID | VERSION | DB SIZE | IS LEADER | RAFT TERM | RAFT INDEX |
+----------------+------------------+-----------+---------+-----------+-----------+------------+
| 127.0.0.1:2379 | bf9071f4639c75cc | 2.3.0+git | 18 MB | true | 2 | 3332 |
+----------------+------------------+-----------+---------+-----------+-----------+------------+

# 确认警告已发起
$ etcdctl alarm list
memberID:13803658152347727308 alarm:NOSPACE

如果遇到空间不足,可以这样操作:

1
2
3
4
5
6
7
8
9
10
# 获取当前版本号
$ rev=$(ETCDCTL_API=3 etcdctl --endpoints=:2379 endpoint status --write-out="json" | egrep -o '"revision":[0-9]*' | egrep -o '[0-9]*'
# 压缩所有旧版本
$ ETCDCTL_API=3 etcdctl compact $rev
# 去碎片化
$ ETCDCTL_API=3 etcdctl defrag
# 取消警报
$ ETCDCTL_API=3 etcdctl alarm disarm
# 测试通过
$ ETCDCTL_API=3 etcdctl put key0 1234
0%