Etcd-Authentication

起因

今早上我们的某个程序升级了,负责升级的哥们儿没有检查QPS就去睡觉了(当然检查了也没用,除了尽早发现bug然后把大家喊起来),然后我们的搜索出了问题(好久),直到上班才发现,然后重启解决(Thanks Dat)。

问题是啥?

升级后,新的instance没有请求,依赖这个服务的所有service都连着旧的地址(在mesos上重新部署之后地址和端口都变了),但这些instance都是通过etcd作服务发现的,把自己注册到etcd上,而且etcd上也的确看到了新的地址,但是为什么那些server还抓着旧地址不放呢?(讲道理我写的lib没问题啊)而且重启之后就能拿到新的地址了,也有请求到升级之后的instance上去了。

经过

Dat在群里贴了这样一段,取自etcd官方文档

At first, a client must create a gRPC connection only to authenticate its user ID and password. An etcd server will respond with an authentication reply. The reponse will be an authentication token on success or an error on failure. The client can use its authentication token to present its credentials to etcd when making API requests.

The client connection used to request the authentication token is typically thrown away; it cannot carry the new token’s credentials. This is because gRPC doesn’t provide a way for adding per RPC credential after creation of the connection (calling grpc.Dial()). Therefore, a client cannot assign a token to its connection that is obtained through the connection. The client needs a new connection for using the token.

起初我是怀疑这个操作的,所以自己就做了一个实验:

  • export ETCDCTL_API=3
  • bin/etcdctl user add root:root
  • bin/etcdctl user add test:test
  • bin/etcdctl role add test
  • bin/etcdctl role grant-permission --prefix=true test readwrite /test
  • bin/etcdctl user grant-role test test

一个窗口put key,一个窗口watch change:

put and watch

没毛病啊!即使打开了认证,已经建立的watch连接还是能看到变化啊,所以问题出在哪儿?

所以我们又在test环境测试了一下,试图重现线上的问题。这个经过不再罗嗦,但结果是能够复现这个问题:

  • 关掉etcd的认证
  • 重启svc A以及依赖svc Asvc B
  • 此时svc B能够通过etcd发现新的svc A的地址,一切正常
  • 打开etcd的认证(一切正常)
  • 再次重启svc A以更换新的地址和端口
  • 此时svc B拿不到新的svc A的地址,仍然在retry旧的地址
  • 爆炸

而且通过重启svc B是能够解决问题的。

所以我又写了一个小程序去debug。这次是独立测试我们的lib,也就是grpc-lb这个库。发现watch操作和keep lease都是正常的,除了,GET

lib的更新是这样的:

  • watch change
  • if found, get new contents(server lists)

但是在etcd authentication disable的时候,NewClient的操作其实是“不成功的”,之所以“不成功”,是因为虽然返回了一个etcd clientv3,但是这个client并不带认证,因为server端此时没有开启认证,并且client会打印日志:

{"level":"warn","ts":"2019-05-16T22:46:04.481+0800","caller":"clientv3/retry_interceptor.go:60","msg":"retrying of unary invoker failed","target":"endpoint://client-dbc9cada-657f-4bf2-800e-1c0728b09277/localhost:2379","attempt":0,"error":"rpc error: code = FailedPrecondition desc = etcdserver: authentication is not enabled"}

当打开了认证的时候,GET操作就会返回空,并有WARN日志:

{"level":"warn","ts":"","caller":"clientv3/retry_interceptor.go:60","msg":"retrying of unary invoker failed","target":"endpoint://client-a6b927f0-39d2-4ef1-80ba-af9f7dfca5f3/localhost:2379","attempt":0,"error":"rpc error: code = InvalidArgument desc = etcdserver: user name is empty"}
etcdserver: user name is empty

结果

通过上面的分析,可以得出结论;

  • 打开etcd的认证,不会影响已经建立的client的watchkeep lease
  • 如果一个client是建立在etcd关闭认证的情况下,当etcd重新打开认证的时候,client的GET操作会失败

解决方案也很简单,当发现错误为etcdserver: user name is empty时,重新创建一个新的client,并重新GET。(这个时候会有两个client,旧的是为了watch,新的是为了get)

通过深入etcd的源码发现,当client带着一个用户名和密码去跟server请求JWT的时候,server会首先检查一下自己是否开了认证,如果没开,直接返回ErrGRPCAuthNotEnabled,而client也不会得到server“应该”返回的SimpleToken,所以当etcd server重新开启认证时,这个旧的client的GET RPC 就会得到ErrGRPCUserEmpty这样的error。

讲道理etcd团队在设计这个API的时候,可能没有考虑(或者我们的操作就是错的,不应该在运行的etcd cluster上随便开关认证)在创建的时候发送认证信息的已经建立好的client再次连接重新开启认证的etcd server的情况。

由于watchkeep lease都是stream,所以RPC一旦建立,除了重启etcd,就不会有认证问题。如果etcd重启了,那么虽然gRPC会重连,但是曾经拿到的token已经变了,我们用旧的token去验证自己,永远不会成功。

BTW,我们也需要考虑etcd重启的情况,所以万能的解决方案是,重连。一旦失败,重连整个client,重新获得token,重新建立gRPC连接,因为不管是开启认证,还是重启etcd,subConn都已经失效了。

重启大法好

P.S. etcd 的版本为 3.3.13@98d308426

GO_BUILD_FLAGS="-v" ./build
./bin/etcd --version
etcd Version: 3.3.13
Git SHA: 98d308426
Go Version: go1.12
Go OS/Arch: linux/amd64
ETCDCTL_API=3 ./bin/etcdctl version
etcdctl version: 3.3.13
API version: 3.3
comments powered by Disqus