百种弊病,皆从懒生

整理几个碰到的 etcd bug

2023.04.12

产品里用了一年多的 etcd, 碰到过一些 bug, 整理下,其中用些在最新版本里已经修复了, 会标注下.

添加 etcd 节点相关 bug

添加 etcd 节点的过程一般是先 member add, 然后启动新节点上的 etcd,这样的问题是在 member add 和新 etcd 启动 之间整个 etcd 集群处于 quorum - 1 的状态, 此过程增加了集群的不稳定性,如果新节点由于配置错误起不来,现存节点再挂一个 就可能导致整个集群不可用.

从 3.4 开始 etcd 引入了 learner 的概念, member add –learner, 可以将新节点添加成 leaner 角色,只同步 raft log, 不参与 quorum vote, 即现有集群的 quorum size 不会变化. 等新节点起来后再通过 member promote 将新节点提升为正常节点,参与 quorum vote.

在这个过程中碰到了两个 bug

etcd client auto sync 无法剔除 learner 节点

项目一开始用的client 版本是3.5.0, Config 中设置 AutoSyncInterval 后, 会定时刷新cluster 中现有节点信息(当前连接节点挂了可以自动做故障转移), 但如果某个新加的节点由于故障或bug停留在 learner 角色时, auto sync 没有识别,还是把该节点加进了可用 endpoint 列表,导致请求被发到 learner 节点(learner 无法处理任何请求). 需要升级到 https://github.com/etcd-io/etcd/commit/125f3c3f9a014febf8df073e78a03c4cdb490aa0 该 commit 之后的版本.

开启 auth 时, follower 节点无法处理 member promote 请求

在上文, 添加节点最后一步是把 learner 提升为member(voter), 设计上,这个请求可以发给集群中任意节点的, 但实践里发生了问题. 添加第二个节点时没问题,当添加第三个节点时, 看似随机会失败,

etcdctl member promote 命令得到超时错误:

{"level":"warn","ts":"2022-05-25T14:56:30.869+0800","logger":"etcd-client","caller":"v3/retry_interceptor.go:64","msg":"retrying of unary invoker failed","target":"etcd-endpoints://0xc0003fc000/10.252.90.217:22379","method":"/etcdserverpb.Cluster/MemberPromote","attempt":0,"error":"rpc error: code = Unavailable desc = etcdserver: request timed out"}
Error: etcdserver: request timed out

检查当前的 leader 节点,能看到上百条的相同报错日志, 看起来是重试导致的, 只是这里的报错比较诡异auth: user name is empty:

{"level":"warn","ts":"2022-05-25T14:56:30.816+0800","caller":"etcdhttp/utils.go:72","msg":"unexpected v2 response error","remote-addr":"10.252.90.217:39136","internal-server-error":"auth: user name is empty"}                                                                                                              

{"level":"warn","ts":"2022-05-25T14:56:30.816+0800","caller":"etcdhttp/peer.go:151","msg":"failed to promote a member","member-id":"c6b6f4cbd45937f5","error":"auth: user name is empty"}                                                                                                                                     

{"level":"warn","ts":"2022-05-25T14:56:30.850+0800","caller":"etcdhttp/utils.go:72","msg":"unexpected v2 response error","remote-addr":"10.252.90.217:39136","internal-server-error":"auth: user name is empty"}                                                                                                              

{"level":"warn","ts":"2022-05-25T14:56:30.851+0800","caller":"etcdhttp/peer.go:151","msg":"failed to promote a member","member-id":"c6b6f4cbd45937f5","error":"auth: user name is empty"} 

检查另一个 follower 节点:

{"level":"debug","ts":"2022-05-25T15:11:55.228+0800","caller":"v3rpc/interceptor.go:185","msg":"request stats","start time":"2022-05-25T15:11:50.336+0800","time spent":"4.89210609s","remote":"10.252.90.217:37732","response type":"/etcdserverpb.Cluster/MemberPromote","request count":-1,"request size":-1,"response count":-1,"response size":-1,"request content":""}

这是条slow log, follower 花了快5s 处理请求, 然后失败了.

路径看起来是 member promote 的请求被发到了 follower, 然后 follower 把请求转发到 leader, 结果 leader 验证 auth 信息失败,follower 不停尝试,然后失败了(集群开了 auth)

验证过程是手工搭建一个2节点的 etcd 集群, 第三个加成 learner, promote时手工指定 endpoint 为 leader 或 follower, 可以发现当指定 leader 时总能成功, 指定 follower 时就能重现上面的情况.

由此可知我们添加第三个节点时promote 失败的概率是50% (实际情况是添加第三个节点时,会将前两个节点的地址都放在 endpoint list 里, 所以请求会被发到随机一个节点上)

绕过这个 bug 的方式也比较简单, 只把请求发到 leader 节点就好了.

进一步追踪下 etcd 的代码的话, 能发现问题出在这 https://github.com/etcd-io/etcd/blob/c3bc4116dcd1272c6a1318feadf5ac9739409a12/server/etcdserver/cluster_util.go#L292

follower 转发 promote 请求到 leader 时没有把auth 信息带上, 所以出了问题, 给官方提了个 issue: https://github.com/etcd-io/etcd/issues/14070 , 目前还没有修复,预计会在 3.6 里修.

调用 client.Maintenance 模块下函数会破坏 token renew 逻辑

官方在 3.5.5 里修复了 https://github.com/etcd-io/etcd/pull/12992

重现方式是比较诡异的, 大概步骤是业务代码必须先调用 client.Maintenance 下函数(我的情况是调用了 Maintenance.Status 函数来获取 leader 信息), 然后什么操作都不做,等待 auth token 过期, 再发起任意请求, client 会连续重试100次然后失败, error 是 “invalid auth token”.

etcdclient.New 的时候内部会生成一个 grpc.ClientConn, 通常请求是通过这个 conn 来发送的(内部会对多个 endpoint 做 loadbalance), 但 Maintenance 模块下函数比较特殊, 请求必须发送给指定的 endpoint, Maintenance 下函数都会接收一个 endpoint 作为参数, 在内部建单独的 conn, 同时也有单独的 getToken 操作, 每次调用都会 auth 一遍.

这里就产生了问题, 每次建立连接的时候 (dial), 都会覆盖 Client.authTokenBundle, 但最早的 authTokenBundle instance 会在第一次 dail 的时候就传给 grpc conn, 通常这样是没有问题的, 但由于 Maintenance 下函数会再次 dail, 就把最早的 authTokenBundle 覆盖了, 后续 token renew 的时候都是更新这个新 authTokenBundle, 最早传给 grpc conn 的那个没有被更新, 造成了问题.

官方的修复方法也比较简单,就是在 dail 的时候不要覆盖 Client.authTokenBundle, 保证指向的都是同一个对象.

因为用的 etcd 还是3.5.4, 没法简单升级,代码里做了个 workaround, 每次要调用 Maintenance 下函数时,重新建立一个 etcd client,用完就关闭.

当使用 –config-file 选项配置etcd 时 auto-compaction-mode 缺少默认值

这个有点坑,如果 etcd 的启动参数全部用命令行配置, auto-compaction-mode 会有默认值(periodic), 但如果通过配置文件配置, 就不会有默认值, 导致 auto compaction 没有生效, etcd 的 db size 一直扩大. 当然,依赖软件的默认值就不是 best practice, 这锅自己也要背.

https://github.com/etcd-io/etcd/issues/14894

开启 auth 后, watch 请求有时会失败.

一般的 etcd 请求中如果碰到 token 过期,会在 v3/retry_interceptor.go 里做 reauth, 但 watch 请求在创建的时候会复用当前 grpc stream 里 bundle 的token, 如果业务系统 IDLE 了很久没有发送 etcd request. 本地持有的 auth token 就可能已经过期了, 此时去做 watch 就会失败.

详情见 https://github.com/etcd-io/etcd/issues/12385

本来在 https://github.com/etcd-io/etcd/pull/14322 修复了一次, 后来又 rollback 了, 原因是修复方法比较粗暴,只是在 client 端对比了一下 error msg 的内容来决定要不要重试, 因为Watch 请求的 resp 中 的 CancelReason 只是个字符串,不是错误码.

后续官方打算在 WatchResp 添加错误码字段来标识要不要重试. 当前的 workaround 是每次要发起 Watch 请求时都重新创建一个 etcd client, 保证 token 是有效的.

修复进展可以关注这个 issue https://github.com/etcd-io/etcd/issues/15058

comments powered by Disqus