背景
长期以来,弹性云线上服务一直饱受缓存不一致的困扰。
缓存不一致的发生一般伴随着kube-apiserver的升级或重启。且当缓存不一致问题发生时,用户侧能够较为明显的感知,问题严重时会引发线上故障。而常见的故障有:
- 平台数据不一致:Pod状态一会正常,一会不正常,并且来回跳动
- 服务管理事件丢失:服务变更时,服务管理未正常工作,如服务树未挂载、流量未接入等等
在问题未定位之前,弹性云制定了诸多问题感知与及时止损策略:
- 问题感知:
- 人工:kube-apiserver升级或重启时,人工通知关联方也重启平台服务
- 智能:配置监控与报警策略,当一段时间内未收到k8s对象的变更事件时,发送告警信息
- 及时止损:
- 重启:缓存不一致问题发生时,重启服务,并从kube-apiserver全量拉取最新的数据
- 自愈:部分场景下,即使服务重启也不能完全恢复,添加自愈策略,主动感知并处理异常情况
问题感知与止损策略并没有真正意义上解决问题,而仅仅是在确定性场景下尝试恢复服务,并且伴随着更多异常场景的发现,策略也需同步调整。
问题定位
感知与止损是一种类似亡羊补牢的修复手段,显然,我们更希望的是一个彻底解决问题的方案。那么,我们先从引起缓存不一致的根因开始排查。
我们选择notifier来排查该问题,notifier是一个集群管理服务的控制器集合,其功能主要包含:
- 服务树挂载
- DNS注册
- LVS摘接流等
选择notifier的原因,在于其功能较为简单:notifier使用了client-go的informer,并对核心资源事件注册处理函数;此外也没有复杂的业务流程来干扰问题排查。
问题复现
我们在线下环境中进行测试,发现kube-apiserver服务重启后,问题能够稳定复现,这给我们排查问题带来了极大的便利。因此问题复现步骤如下:
- 启动notifier服务
- 重启kube-apiserver服务
状态分析
当问题发生时,我们首先对服务状态做一些基本检查:
1 | # #服务存活状态 |
对比问题发生前的服务状态信息,我们发现一个严重的问题,notifier与kube-apiserver (服务地址:https://localhost:6443) 建立的连接消失了。
因此,notifier与kube-apiserver的数据失去了同步,其后notifier也感知不到业务的变更事件,并最终丧失了对服务的管理能力。
日志分析
现在我们分析notifier的运行日志,重点关注kube-apiserver重启时,notifier打印的日志,其中关键日志信息如下:
1 | E1127 14:08:19.728515 1041482 reflector.go:251] notifier/monitor/endpointInformer.go:140: Failed to watch *v1.Endpoints: Get "https://127.0.0.1:6443/api/v1/endpoints?resourceVersion=276025109&timeoutSeconds=395&watch=true": http2: no cached connection was available |
上面展示了关键的异常信息 http2: no cached connection was available
,而其关联的操作正是EndpointInformer的ListAndWatch操作。
这里我们已经掌握了关键线索,下一步,我们将结合代码分析定位根因。
代码分析
Informer的工作机制介绍不是本文重点,我们仅关注下面的代码片段:
1 | // Run starts a watch and handles watch events. Will restart the watch if it is closed. |
Informer的Reflector组件运行在一个独立的goroutine中,并循环调用ListAndWatch接收kube-apiserver的通知事件。
我们结合日志分析可得出结论:当kube-apiserver服务重启后,notifier服务的所有ListAndWatch操作都返回了 http2: no cached connection was available
错误。
因此,我们将关注的重点转移至该错误信息上。
通过代码检索,我们定位了该错误的定位及返回位置:
1 | // file: vendor/golang.org/x/net/http2/transport.go:L301 |
上述代码返回 ErrNoCachedConn
的条件为:
- 参数dialOnMiss值为false
- p.conns连接池内没有可用连接
理论上,在发送http请求时,如果连接池为空,则会先建立一个连接,然后发送请求;并且连接池能够自动剔除状态异常的连接。那么本文关注的问题有时如何发生的呢?
现在我们关注 getClientConn
方法的调用链,主要有二:
栈一:
1 | 0 0x0000000000a590b8 in notifier/vendor/golang.org/x/net/http2.(*clientConnPool).getClientConn |
栈二:
1 | 0 0x0000000000a590b8 in notifier/vendor/golang.org/x/net/http2.(*clientConnPool).getClientConn |
分别跟踪两个调用栈后,我们可以很快排除栈一的因素:
1 | // file: net/http/transport.go:L502~620 |
栈一调用 getClientConn
返回了 ErrNoCachedConn
错误,并在 noDialH2RoundTripper.RoundTrip
函数中被替换为 http.ErrSkipAltProtocol
错误,返回 roundTrip
函数后继续执行余下流程,并进入栈二的流程。
因此我们重点关注栈二的流程:
1 | // file: net/http/transport.go:L502~620 |
区别于栈一,栈二不再对返回错误做一个转换,而是直接返回了 ErrNoCachedConn
错误,并且 roundTrip
的错误处理流程中也特殊处理了本类错误。如果检测 http2isnoCachedConnError
返回true,则连接池会移除该异常连接。
一切都那么的合乎情理,那么问题是如何发生的呢?这里问题就发生在 http2isnoCachedConnError
:
1 | // file: net/http/h2_bundle.go:L6922~6928 |
如果 err
对象实现了匿名接口 (仅定义了一个函数 IsHTTP2NoCachedConnError
),那么返回true,否则返回false。
那么,getClientConn
返回的错误类型实现了该接口吗?很显然:没有。
1 | // file: vendor/golang.org/x/net/http2/transport.go:L301 |
至此,问题发生的原因已基本定位清楚。
解决方案
既然问题是由于 getClientConn
返回的错误类型 ErrNoCachedConn
没有实现 IsHTTP2NoCachedConnError
函数引起,那么其修复策略自然是:修改返回错误类型,并实现该接口函数。
注意,由于该部分代码是我们引用的外部代码库的内容,我们检查最新的 golang.org/x/net
代码发现,问题早在2018年1月份就已被修复。。。具体参见:golang.org/x/net修复方案。
1 | // noCachedConnError is the concrete type of ErrNoCachedConn, which |
而我们线上使用的版本仍然为:1c05540f6。
因此,我们的修复策略变得更为简单,升级vendor中的依赖库版本即可。
目前,线上notifier服务已升级依赖版本,全量上线所有机房。并且也已验证kube-apiserver重启,不会导致notifier服务异常。