容器云平台API Server卡顿问题排查
58云计算平台是58集团架构线基于Kubernetes + Docker技术为集团内部服务开发的一套业务实例管理平台,它具有简单,轻量的特点及高效利用物理资源,更快的部署和统一规范的标准化运行环境,通过云平台,使得服务标准化,上线流程规范化,资源利用合理化。然而云平台的建设过程不是一帆风顺,也不乏出现一些问题挑战,本文就针对云平台现实中遇到的一个问题和大家分享。
1、关于问题
1.1 问题概述
近期,很多业务同事反馈使用云平台上线存在容器部署慢,平台反应慢的问题。通过详细的问题排查定位后,最终问题得以解决。
1.2 Kubernetes基本知识
私有云平台通过Kubernetes对容器进行编排。Kubernetes整体架构如下图所示:
其中几个主要的模块的功能简要描述如下:
- etcd:用于Kubernetes的后端存储。
- Pod:Kubernetes最基本的操作单元,包含一个或多个紧密相关的容器。
- Replication Controller:副本控制器,用来保证Deployment或者RC中副本的数量。
- Scheduler:Kubernetes的调度器,Scheduler监听API Server,当需要创建新的Pod时Scheduler负责选择该Pod与哪个Node进行绑定。
- Kubelet:每个Node节点上都会有一个Kubelet负责Master下发到该节点的具体任务,管理该节点上的Pod和容器。
- API Server:对于整个Kubernetes集群而言,API Server是通过暴露Kubernetes API的方式提供给内部组件或者外部程序调用去完成对Kubernetes的操作。各个组件之间也是通过API Server作为桥梁进行间接通信,这种方式做到各个组件间充分解耦。
业务同事操作管理平台发出创建集群请求到集群创建完成的整个流程如下:
- 业务同学操作管理平台进行升级操作,管理平台通过http方式向API Server发出请求。
- API Server处理和解析请求参数,将待创建的Pod信息通过API Server存储到etcd。
- Scheduler通过API Server的watch机制,查看到新的Pod,尝试为Pod绑定Node。
- 经过预选筛除不合适节点及从待选节点中根据一定规则选出最适合的节点。
- 对选中的节点及Pod进行binding操作,将相关的结果通过API Server存储到etcd。
- 对应Node的Kubelet进程调用容器运行时创建容器。
2. 定位问题
2.1 问题排查
从1.2可以看到,API Server在创建Pod过程中起到非常关键的中间桥梁作用,解析外部请求及读写etcd。因此决定首先从API Server进程所在宿主机的各项性能指标及日志方面进行排查,看是否有所发现。
目前线上环境有3台主机运行API Server,以达到流量负载均衡的目的,异常时间段网卡eth2入流量如下图所示:
由3台API Server主机的监控数据,发现服务器A的网卡入流量远高于另外两台,说明绝大部分请求发送到了服务器A。
通过对比三台服务器API Server 的CPU利用率,发现服务器A的API Server进程CPU使用率一直保持在2000%(20核)上下波动,而另外两台服务器的API Server的CPU利用率没有超过100%(1核)。进一步证实了A的API Server进程处理了绝大多数的请求。
查看A服务器的API Server的相关log,发现正在大量输出如下的日志:
这个日志显示有大量请求通过API Server到etcd查询Pod的状态。
对于Kubernetes后端的存储目前采用5个etcd节点组成etcd集群。登陆其中一个节点(E1),发现对E1节点执行etcd操作命令,比如命令:“etcdctl ls /registry/pods/default”,命令执行也会经常超时。如果你想和更多Kubernetes技术专家交流,可以加我微信liyingjiese,备注『加群』。群里每周都有全球各大公司的最佳实践以及行业最新动态。
同时对比5台etcd节点的流量,发现有一个节点网卡入流量远高于其他四个节点,该节点(E1)的etcd进程的CPU利用率在100%左右,明显高于剩余的4个节点CPU利用率。查看节点E1的etcd进程日志,经常看到如下报错:
可以推断节点E1的负载非常高,节点间同步心跳都已经超时,无法正常的响应外部的请求了。
2.2 问题分析
经过上述排查,主要集中在这两个问题上:
2.2.1负载均衡策略失效
首先可以看到对Kubernetes集群的操作请求大部分都落在某个API Server上,导致其中一个API Server负载很高,那么有可能负载均衡策略有些问题。那就先看看当前负载均衡策略是如何的。
当前我们租赁的是腾讯的机房,负载均衡策略采用的是TGW(Tencent Gateway)系统所自带支持的负载均衡策略。腾讯云上有关介绍如下:
TGW负载均衡策略保证请求的分摊转发,也会自动对resource server(RS)进行存活检测,每分钟会有心跳包去对接入TGW的IP Port进行探测。
关于TGW相关配置具体如下:
- 做域名解析:我们对需要访问到API Server的物理机都做了本地DNS,将一个固定域名(D)解析到一个特定的VIP(V),而该VIP就是TGW对外提供的虚拟IP。
- 配置TGW服务的RS列表:将三台API Server节点对应的物理IP加入到RS列表。
正常情况下,所有需要访问API Server的请求都先本地域名解析到虚拟IP V,将请求的数据包都发送到V,V相当于是TGW对外的接入点,再通过TGW内部负载均衡策略将请求数据包进行目的网络地址转换(DNAT),分发到不同的RS上。
经排查,TGW的监控检测模块定期向所有的RS发送心跳包,但是TGW监控检测模块只能收到A服务器的回包,因此TGW认为只有A节点是存活状态,所有的请求数据包最终就由TGW转发到A服务器上了,这就是负载均衡策略失效的根本原因。
这里还有一个现象是为什么etcd集群中只有一个节点的负载很高呢?
五个节点的etcd集群中只有一个节点负载很高,其他正常,通过查看A服务器的API Server的log,可以看到的大量的读请求都固定发送到了同一个etcd节点。
对于这个现象,可以看下API Server访问后端存储的源码,目前线上Kubernetes基于v1.7.12的源码编译运行,API Server访问etcd是在内部初始化一个etcd client端,然后通过etcd client端发送请求到etcd server端。etcd client端有v2和v3两个版本。线上API Server使用的是v2版本客户端。主要代码如下:
//初始化etcd工作 func New(cfg Config) (Client, error) { c := &httpClusterClient{//返回一个http类型的client clientFactory: newHTTPClientFactory(cfg.transport(), cfg.checkRedirect(), cfg.HeaderTimeoutPerRequest), rand: rand.New(rand.NewSource(int64(time.Now().Nanosecond()))),//传入一个当前时间的随机种子 selectionMode: cfg.SelectionMode, } if err := c.SetEndpoints(cfg.Endpoints); err != nil { return nil, err } return c, nil } //对etcd列表进行打乱 func (c *httpClusterClient) SetEndpoints(eps []string) error { ... neps, err := c.parseEndpoints(eps) c.Lock() defer c.Unlock() c.endpoints = shuffleEndpoints(c.rand, neps)//打乱etcd列表 c.pinned = 0 ... return nil } func shuffleEndpoints(r *rand.Rand, eps []url.URL) []url.URL { p := r.Perm(len(eps))//rank库的Perm方法可以返回[0,n)之间的随机乱序数组 neps := make([]url.URL, len(eps)) for i, k := range p { neps[i] = eps[k] } return neps }
可以看到在初始化etcd客户端时候会传入一个当前时间的随机种子去打乱所有Endpoints(etcd节点)的顺序。
对于etcd的操作都是通过API Server内部的etcd客户端发送http请求到etcd Server端,最主要是调用如下方法:
func (c *httpClusterClient) Do(ctx context.Context, act httpAction) (*http.Response, []byte, error) { ... for i := pinned; i < leps+pinned; i++ { k := i % leps hc := c.clientFactory(eps[k]) resp, body, err = hc.Do(ctx, action) ... if resp.StatusCode/100 == 5 { switch resp.StatusCode { case http.StatusInternalServerError, http.StatusServiceUnavailable: cerr.Errors = ... default: cerr.Errors = ... } ... continue } if k != pinned { c.Lock() c.pinned = k c.Unlock() } return resp, body, nil } return nil, nil, cerr }
该方法表明每次请求时候,会从pinned节点开始尝试发送请求,如果发送请求异常,则按照初始化时候打乱顺序的下一个节点(pinned++)开始尝试发送数据。如此看来,如果API Server使用了某个endpoint发送数据,除非用坏了这个节点,否则会一直使用该节点(pinned)发送数据。这就说明了,没有异常情况下,一个API Server就对应往一个固定的etcd发送请求。
对于etcd集群,如果是写请求的话,follower节点会把请求先转发给leader节点处理,然后leader再转发给follower同步。那么5个节点CPU负载不会这么不均衡,但是根据2.1排查API Server日志看到这里是大量的读请求,相对于写请求,读请求是所有follower节点都能对外提供的。也就是大量请求由于负载均衡策略失效都转发到A服务器,A再把查询请求都打到其中一个固定的etcd,导致该节点忙于处理etcd查询请求,负载就会飙高。
总的来说,TGW做负载均衡时候,由于心跳检测模块和其中两个Resource Server间连接不通,导致误将所有请求都转发到其中一个API Server,而一个特定的API Server使用v2版本etcd客户端就只会往一个固定的etcd服务端发请求,这样整个负载均衡策略就失效了。
2.2.2 etcd存取数据缓慢
namespace未做划分:
从2.1中查看API Server 的日志可以看出,很多get请求Pod对象信息,比如:“Get /api/v1/namespaces/default/pods?...” 这些都是从default namespace下获取Pod信息,这就说明线上并没有对Pod的namespace做划分。
Kubernetes是通过namespace对容器资源进行隔离,默认情况下,如果未指定namespace的话,创建的容器都被划分到default namespace下,因为这个原因也给后面往etcd中存储容器元数据信息也留下了坑。所有的Kuberentes的元数据都存储在etcd的/registry目录下,整体如下图所示:
Kubernetes中Pod的信息存储在/registry/pods/#{命名空间}/#{具体实例名}的目录结构中,正因为如果不指定namespace的话,就会存储到default的namespace中,也就是/registry/pods/default目录下保存了线上全部Pod对象信息。
也就是说大量get请求Pod对象信息,由于未做namespace划分,每次都会去访问default子目录,每次请求相当于都要做全局搜索,随着集群的增多,Pod不断的存入到该子目录中,搜索性能也会变得越来越差。
查询结果未加入缓存:
从2.1中查看API Server 的日志看到很多Get/List操作,那么可以仔细看看相关方法的执行流程,下面是List方法执行过程中调用的中间函数:
f unc (c *Cacher) GetToList(ctx context.Context, key string, resourceVersion string, pred SelectionPredicate, listObj runtime.Object) error { if resourceVersion == "" { return c.storage.GetToList(ctx, key, resourceVersion, pred, listObj)//直接查询etcd } listRV, err := ParseListResourceVersion(resourceVersion) ... obj, exists, readResourceVersion, err := c.watchCache.WaitUntilFreshAndGet(listRV, key, trace)//从缓存中获取 ... return nil }
可以看到,GetToList方法中传入的有个resourceVersion 参数,如果设置了就会从缓存中获取,如果不设置就会去etcd中查询。这个也是一个关键点,有关resourceVersion 的相关使用如下:
- 不设置:通过API Server从etcd读取。
- 设置成0:从API Server的cache读取,减轻API Server和etcd压力。例如Kubelet经常通过此方法Get Node对象,Kubernetes Infomer第一次启动时List也通过此方法获得对象。
- 大于0:读取对象指定版本。
线上管理平台通过http接口去查询Pod信息时候是没有设置resourceVersion,所以每次通过Get/List方法获取资源时候都会查询etcd,如此一来经常大量高频率的查询etcd会导致其压力较大,开启缓存策略不仅可以减轻访问etcd压力而且还可以加快查询速度。
总结以上两点:所有的请求都发往一个固定的API Server,导致该API Server节点负载较高,同时该API Server又会将查询请求固定的发给某个etcd节点,然而请求结果并没有在API Server端做缓存,每次都会直接查询etcd,在从etcd中获取Pod信息又是从default这个大的子目录中全局搜索,每次请求都比较费时,这样导致某一个固定的etcd一直处理大量的费时的请求,最终将该etcd资源耗尽,负载过高,因而查询结果不能及时返回给API Server,导致创建Pod时候拿不到相关的信息,Pod创建工作无法进行,所以最终表象是集群部署长时间卡顿。
3、解决方案
切换负载均衡方案:临时切换为DNS轮询方式,保证每个API Server节点的流量均衡。同时跟进TGW对于某些网段的RS和TGW服务不能探测心跳及后续改进。
将Kubernetes中Pod按多个namespace划分,目前线上所有的Pod都划分到默认的default的namespace下,每次读取Pod信息都是从etcd检索整个namespace,比较损耗etcd性能,目前已经将Pod的namespace进行细分,加快了读取Pod信息速度同时减少了etcd性能损耗。
etcd v3版本客户端会对Endpoints定期打乱,后续我们会升级到v3版本,这样同一个API Server的请求就不会一直落到某一个etcd上,这样即使负载均衡策略失效也能做到对etcd请求的分摊。
查询Kubernetes资源信息时带入resourceVersion开启缓存机制,减轻对etcd的访问压力。