概要

存储性能很大程度决定了etcd集群的性能表现,因为etcd必须持久化日志,一次写请求必须确保多数节点将用于保证状态机的日志写入磁盘中。 在已经运行的 etcd 集群中,我们可以通过指标etcd_disk_wal_fysnc_duration_seconds来评估存储 I/O 性能, 该指标记录了 WAL 文件系统调用 fsync 的延迟分布,当 99% 样本的同步时间小于 10 毫秒就可以认为存储性能能够满足 etcd 的性能要求。 那么如何在部署实施前或没有监控系统的情况下,评估存储的性能是否满足需求呢? fio命令行工具可能是不错的选择,fio主要用于进行 I/O 性能测试,你可以使用下面的命令测试存储性能,以评估是否满足 etcd 的要求。

1
fio --rw=write --ioengine=sync --fdatasync=1 --directory=test-data --size=22m --bs=2300 --name=mytest

观察输出,验证是否 99%的文件同步执行时间小于 10ms,下图用例的测试结果不满足要求。

注意:

  1. 参数--size--bs的值需要根据使用场景进行调优
  2. 测试过程中,fio 产生的 IO 负载是唯一的 IO 活动,除了与 wal_sync_duration_seconds 相关的 IO 还会有其他写入存储的 IO 活动, 因此实际环境下 wal_sync_duration_seconds 要更大一些。
  3. fio 的版本需要大于 3.5,因为旧版本不会输出 fdatasycn 系统调用百分位数。

故事的细节

数据库系统通常都会使用预写式日志(write-ahead logging,WAL)来保证原子性和持久性,etcd 也是如此,关于 WAL 的介绍超出了本文讨论的范畴, 但是我们需要知道 etcd 集群的每个成员都会持久存储上保留 WAL 日志。etcd 首先将键值存储上的某些操作(例如,更新)写入 WAL 文件中, 然后才会应用这些操作。如果其中一个成员在快照期间崩溃并重启,它可以在本地通过 WAL 内容恢复自上次快照以来的事务。

因此,每当客户端添加或更新键值对数据时,etcd 会向 WAL 文件添加一条入库记录条目。再进一步处理之前,etcd 必须 100% 确保 WAL 条目已经被持久化。 要在 Linux 实现这一点,仅使用write系统调用是不够的,因为对物理存储的写入操作可能会发生延迟。比如, Linux 可能会将写入的 WAL 条目在内核内存缓存中保留一段时间(例如,页缓存)。如果要确保数据被写入持久化存储,你必须在 write 系统调用之后调用 fdatasync 系统调用,实际上 etcd 就是采用这种方式,下图是使用strace命令的输出,参数 8 是 WAL 文件的文件描述符。

1
2
3
21:23:09.894875 lseek(8, 0, SEEK_CUR)   = 12808 <0.000012>
21:23:09.894911 write(8, ".\0\0\0\0\0\0\202\10\2\20\361\223\255\266\6\32$\10\0\20\10\30\26\"\34\"\r\n\3fo"..., 2296) = 2296 <0.000130>
21:23:09.895041 fdatasync(8) = 0 <0.008314>

写入持久化存储相对是比较耗时的,如果 fdatasync 系统调用用时太长,会导致 etcd 的存储性能下降。etcd 官方文档建议存储性能, 写入 WAL 文件时 fdatasync 系统耗时的 99%的百分位数应该小于 10ms,还有其他与存储相关的指标,但这是本文的重点。

使用 fio 测试存储性能

如果你有一些存储并且想知道是不是满足 etcd 的存储需求,你可以使用非常流行的 IO 测试工具 fio。磁盘 的 IO 活动可能以很多种方式发生, 如同步或异步,不同的系统调用等,所以 fio 工具的使用也比较负载,它有很多参数,不同值的组合会产生完全不同的 IO 负载, 要获取 etcd 相关的存储性能数据,你必要要保证 fio 产生的写负载和 etcd 写入 WAL 文件产生的 IO 负载保持一致。

这意味着,至少 fio 产生 IO 负载是顺序写入到一个文件中的,其中每次写入由 write 系统调用和 fdatasync 调用组成。 要产生顺序写入,可以通过命令行参数--rw=write,要确保 fio 写入使用 write 系统调用而不是其他的系统调用(例如,pwrite), 通过命令行参数--ioengine=sync实现,最后为了保证每次 write 系统调用后调用 fdatasync,指定--fdatasync=1。 示例中剩余的两个参数--size--bs,依赖具体的使用场景,下一节将会介绍如何设置这两个参数。

为什么我们会使用 fio 以及我如何知道怎样进行配置的

这其实源自一个我们遇到了一个客户问题,一个客户环境中,安装了 v1.20 版本的 Kubernetes,etcd 集群使用的本地磁盘,本地磁盘是由 ceph 存储集群提供的,使用过程中,集群 apiserver 会出现间断性重启,kubernetes 系统组件重启次数过高,etcd 集群也是, etcd 节点过多的重启导致 etcd 的 leader 切换次数过高,并且 etcd 的日志出现大量的 Warning 级别的日志。

1
2021-06-29 15:04:13.396304 W | etcdserver: read-only range request "key:\"/registry/cluster.flyingshark.com/clusters/\" range_end:\"/registry/cluster.flyingshark.com/clusters0\" count_only:true " with result "range_response_count:0 size:8" took too long (1.568548001s) to execute

集群完全处于不可用状态,客户环境是一套私有云,k8s 集群都通过虚拟机部署,我们需要知道究竟是物理磁盘性能还是虚拟化导致的这个问题, 以及给出解决方案,是需要更换存储还是能够通过参数配置解决。etcd 集群有两个关于存储性能指标,不过要在部署实施前发现问题, 指标数据显然不是一个好的参考数据。

首先,我们弄清楚几个问题:etcd 写入 WAL 文件所有产生的 IO 负载是怎样的?使用了那些系统调用?写入内容大小是多少?其次, 在有了这些问题的答案之后,如是使用 fio 模拟 etcd 的 IO 活动?因为 fio 功能丰富且十分灵活,我们通过lsofstrace这两个工具成功弄清楚了这些问题。 lsof 可以用来显示进程所使用的文件描述符(FD)以及关联的文件。strace 可以测试一个处于运行状态的进程和新运行的进程, 打印该进程使用的系统调用,重要的是也能测试该进程的子进程,因为 etcd 就是这样一个进程。

测试集群中 etcd 使用静态 pod 的方式运行,并且在宿主机命名空间,我们使用 lsof 来获取 etcd 进程当前 wal 文件的文件描述符(FD), 这个 FD 在后续观测系统调用中将作为一些函数的参数传递。

接下来我们使用 strace 对 etcd 集群观察系统调用,下图结果显示 WAL 文件写入大小几乎都在 100 字节这个范围, 客户环境中在 2200-2400 范围,这就是为什么示例我们设置参数--bs=2300(bs 参数设置 fio 每次写操作的大小)的原因。这个数值可能取决于 etcd 版本,部署规模, 配置等因素,这个参数影响 fdatasync 的持续时间。

同时我们我们也确定了 etcd 使用了 fdatasync 将保证将缓冲区写入磁盘。etcd 中的代码也能进一步验证这一点,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func (w *WAL) sync() error {
if w.encoder != nil {
if err := w.encoder.flush(); err != nil {
return err
}
}

if w.unsafeNoSync {
return nil
}

start := time.Now()
// 使用fdatasync同步缓冲区
err := fileutil.Fdatasync(w.tail().File)

took := time.Since(start)
if took > warnSyncDuration {
w.lg.Warn(
"slow fdatasync",
zap.Duration("took", took),
zap.Duration("expected-duration", warnSyncDuration),
)
}
walFsyncSec.Observe(took.Seconds())

return err
}

// Fdatasync is similar to fsync(), but does not flush modified metadata
// unless that metadata is needed in order to allow a subsequent data retrieval
// to be correctly handled.
func Fdatasync(f *os.File) error {
return syscall.Fdatasync(int(f.Fd()))
}

到目前为止,我们确定了 etcd 写入日志使用的是 Append 的方式即顺序写,并且使用 fdatasync 确保数据持久化到磁盘, 每次写入的日志大小大约为 2300 字节,接下来就可以使用强大的 fio 来模拟 etcd 的 I/O 活动,来测试存储是否满足需求。

建议

etcd 是 kubernetes 集群的核心,而 etcd 的性能主要取决于存储性能和网络性能, 提升 etcd 集群性能最有效的方式是使用高性能的存储硬件,官方推荐使用 ssd 存储, 如果没有办法使用高性能硬件,也可以通过以下几种方式保证 etcd 集群的稳定性:

  1. 在单独节点上运行 etcd,特别注意的是不能将 etcd 运行在工作节点上,如果资源允许,将 etcd 集群与控制面单独运行。
  2. 如果 etcd 已经运行单独节点还是出现问题,你可以为 etcd 分配单独的存储卷,这样可以防止节点上的 IO 活动影响 etcd, 特别是在云环境。
  3. 如果你的 etcd 运行在单独服务器上,则可以安装新的的存储设备为 etcd 专用。
  4. 为 etcd 容器设置更高的优先级ionice -c2 -n0 -p "pgrep -x etcd"
  5. 适当增大 etcd 的 –heartbeat-interval–election-timeout 启动参数来适当提高高吞吐网络下 etcd 的集群鲁棒性。

参考文章

  1. etcd 架构设计
  2. raft 论文中文版