云监控业务主要部署在腾讯云TKE上,共部署了40多个地域,80多个TKE集群,1700多个Node节点,1万多个Pod。由于TKE集群需要业务维护Node节点,出于成本的考虑,云监控逐渐把TKE集群迁移至EKS集群,中间经历了自监控的升级与优化,对于自监控建设有一定的参考意义,通过文章记录下来。
(备注:本文所描述的自监控指metric类监控,不涉及log与tracing)
1、迁移引发的问题 - 自监控不可用了
云监控的自监控主要是业务程序使用Prometheus SDK通过export的方式进行上报,同时在每个TKE集群会部署一套采集用的Agent(Agent是基于telegraf进行二次开发)进行主动抓取。
自监控Agent是通过DaemonSet方式部署的,DaemonSet方式能确保每个Node节点会部署一个采集Agent,该Agent只会抓取所在节点上Pod暴露的指标,远程写入云监控中台存储。因为云监控中台存储支持类influxQL的查询语法,因此可以用Grafana配置InfluxDB数据源进行面板展示。
DaemonSet采集方式
然而EKS集群是TKE Serverless集群,没有任何的计算节点,不支持DaemonSet,因此原有的自监控将无法使用。
TKE与EKS对比
2、自监控升级
既然DaemonSet不能使用,那么Agent能否使用Deployment方式部署呢?目前看是不行的,因为DaemonSet方式能确保Agent只抓取所在Node节点上Pod暴露的指标,如果换成Deployment,一个采集Agent还好,在Agent超过一个的情况下会重复抓取,Counter类型的数据会是实际的倍数,而一个采集Agent在集群Pod数量比较大的情况下又有性能瓶颈。
这里还可以使用Sidecar的方式进行采集,即一个业务Pod搭配一个采集Agent,因为Sidecar共享容器网络,因此Agent能抓取到Pod暴露的指标数据。但这种方式比较耗费资源(Agent与业务Pod数量是1:1),在降本增效的背景下,也不是最佳的做法。
Sidecar采集方式
如果对Agent进行一定的改造,支持Deployment部署,同时能做到采集的均衡,不重不漏,是不是就能解决这里的问题了呢?因此Agent需要做到
- 灵活的部署方式。既支持DaemonSet方式部署,也支持普通的Deployment方式部署,且可以根据集群的规模人工调整自监控Agent数量及调度,不影响监控数据的采集(不重不漏)
- 服务发现能力。集群中新增或销毁Pod,自监控Agent应该能自动感知到, 并更新自己的采集列表
2.1 一致性哈希
支持动态调整自监控Agent数量,且被采集Pod需要做到不重不漏,很自然想到了一致性哈希的做法。
可以认为自监控Agent是哈希环里的真实节点,被采集的Pod会被均匀分到这个哈希环上,通过对被采集Pod的PodName算哈希最终确定这个Pod被哪个Agent采集
代码语言:javascript复制AgentN=Hash(PodName)
使用一致性hash的好处是即使有多个采集Agent,Agent间不需要通讯和同步状态,而由一种约定好的哈希算法决定这个Pod被哪个Agent采集。
一致性哈希
2.1.1 引入虚拟节点
为了避免因为采集Agent数量过少而导致的哈希不均衡,表现某些Agent采集多某些Agent采集少,因此需要引入虚拟节点。目前笔者线上服务一个采集Agent分配的虚拟节点是500,目前看还比较均衡。这里的虚拟节点不是越大越好,因为调整采集Agent数量时会涉及虚拟节点的增加或移除,可以根据具体情况进行配置。
以上这些做完后,自监控Agent已能通过Deployment方式部署多个Pod并能抓取到数据且不重不漏。但如果集群增加或销毁了Pod,采集Agent如何感知到Pod生命周期变化,并更新自己的采集列表呢?这时候就需要用到服务发现
3、服务发现
3.1 Watch API与informer
K8S集群的服务发现比较通用的方法是使用watch API,除了watch API,K8S还有informer机制也支持服务发现。informer相比watch API在事件可靠性和性能上会更好,更推荐使用这种方式。笔者发现,使用informer相比watch API有10%以上的性能提升。以笔者的自监控升级为例,使用watch API平均的CPU使用率大概是40%,改成Informer方式后CPU使用率大概是20%~30%,进一步节省了资源
inforner下的CPU使用率
3.2 获取采集路径
通过上面的服务发现Agent已经能感知到Pod新增/销毁了,那Agent是如何知道业务Pod的采集地址,并且怎么做到只采集业务Pod的呢?这里使用到了annotation标签
annotation标签
业务接入自监控需要在业务yaml的annonation上打上以上标签,Agent根据这里的标签拼接Pod IP后可以获取采集路径,同时prometheus.io/scrape: "true"也作为采集开关。以上面截图标签为例,最终的采集路径是http://Pod IP:8080/metrics
3.3 区分Agent与业务Pod
因为Agent的采集是基于一致性哈希实现的,Agent是一致性哈希里的真实节点,业务Pod是哈希环上的普通节点,因此使用informer机制实现Pod Watch能力后,需要区分新增/销毁的Pod是采集Agent还是业务Pod,因为两者的处理逻辑不一样。
笔者是这样做的,采集Agent起来后获取自身的Pod IP,然后通过K8S API获取default namespace下的所有的Pod(采集Agent部署在default namespace下),遍历这里Pod IP,如果IP等于当前Pod IP的,那么认为是采集Agent,进而拿到Agent的GenerateName,后续只要Pod的GenerateName与Agent的GenerateName相等,则肯定是Agent,走Agent处理逻辑,否则是业务Pod,走业务Pod处理逻辑
代码语言:javascript复制func (p *Prometheus) InitConsistentHashing(ctx context.Context, client *kubernetes.Clientset) error {
pods, err := client.CoreV1().Pods(metav1.NamespaceAll).List(ctx, metav1.ListOptions{})
if err != nil {
p.Log.Errorf("init consistent hash, client list all pods failed, errMsg:%s", err)
return err
}
var scrapePods []corev1.Pod
for _, pod := range pods.Items {
if pod.Annotations["prometheus.io/scrape"] == "true" && pod.GetDeletionTimestamp() == nil {
scrapePods = append(scrapePods, pod)
}
}
// 根据ip获取当前pod的generateName,进而找到其他agent
match := false
for _, pod := range scrapePods {
podIP := pod.Status.PodIP
for _, ip := range p.IPAddrs {
if ip == podIP {
p.Log.Infof("ip:%s, podIP:%s, ipAddrs:% v", ip, podIP, p.IPAddrs)
p.ConsHashing.Namespace = pod.Namespace
p.ConsHashing.PodName = pod.Name // 记录当前pod的名称
p.ConsHashing.GenerateName = pod.GenerateName
p.Log.Infof("current podName:%s, namespace:%s, generateName:%s", p.ConsHashing.PodName,
p.ConsHashing.Namespace, p.ConsHashing.GenerateName)
match = true
break
}
}
if match {
break
}
}
if match {
for _, pod := range scrapePods {
if p.isAgentPod(&pod) {
p.ConsHashing.Add(pod.Name)
p.Log.Infof("init consistent hash, add node:%s", pod.Name)
}
}
for _, pod := range scrapePods {
if p.isTargetPod(&pod) {
p.addTargetURL(&pod)
}
}
p.Log.Infof("init consistent hash succ, nodes:%v, targetURL count:%d",
p.ConsHashing.Members(), len(p.KubernetesPods))
return nil
}
return fmt.Errorf("init consistent hash failed")
}
3.3 Pod自动发现
自监控Agent需要感知Pod新增/销毁等生命周期,进而更新采集列表,这里使用的是informer机制,informer在内部定义了Add、Update、Delete回调事件,只需要在事件回调函数中添加相应的处理逻辑即可。
不管是哪种事件,都需要区分事件关联的Pod是Agent Pod还是业务Pod,如果是Agent Pod需要更新一致性哈希真实节点,所有的Agent会对PodName算哈希,更新自己的采集列表。如果是业务Pod,只需要对这个PodName算哈希,对应的Agent更新自己的采集列表。
尽管informer的事件比较可靠,笔者还是在Agent上加入了定时检查自身采集任务的逻辑,避免采集任务的遗漏。
代码语言:javascript复制podinformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(newObj interface{}) {
key, err := cache.MetaNamespaceKeyFunc(newObj)
if err != nil {
p.Log.Errorf("getting key from cache %sn", err.Error())
}
namespace, name, err := cache.SplitMetaNamespaceKey(key)
if err != nil {
p.Log.Errorf("splitting key into namespace and name %sn", err.Error())
}
pod, _ := clientset.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
if pod.Annotations["prometheus.io/scrape"] == "true" &&
podReady(pod.Status.ContainerStatuses) &&
podHasMatchingLabelSelector(pod, p.podLabelSelector) &&
podHasMatchingFieldSelector(pod, p.podFieldSelector) {
p.registerPod(pod, clientset)
}
},
UpdateFunc: func(oldObj, newObj interface{}) {
newKey, err := cache.MetaNamespaceKeyFunc(newObj)
if err != nil {
p.Log.Errorf("getting key from cache %sn", err.Error())
}
newNamespace, newName, err := cache.SplitMetaNamespaceKey(newKey)
if err != nil {
p.Log.Errorf("splitting key into namespace and name %sn", err.Error())
}
newPod, _ := clientset.CoreV1().Pods(newNamespace).Get(ctx, newName, metav1.GetOptions{})
if newPod.Annotations["prometheus.io/scrape"] == "true" &&
podReady(newPod.Status.ContainerStatuses) &&
podHasMatchingLabelSelector(newPod, p.podLabelSelector) &&
podHasMatchingFieldSelector(newPod, p.podFieldSelector) {
if newPod.GetDeletionTimestamp() == nil {
p.registerPod(newPod, clientset)
}
}
oldKey, err := cache.MetaNamespaceKeyFunc(oldObj)
if err != nil {
p.Log.Errorf("getting key from cache %sn", err.Error())
}
oldNamespace, oldName, err := cache.SplitMetaNamespaceKey(oldKey)
if err != nil {
p.Log.Errorf("splitting key into namespace and name %sn", err.Error())
}
oldPod, _ := clientset.CoreV1().Pods(oldNamespace).Get(ctx, oldName, metav1.GetOptions{})
if oldPod.Annotations["prometheus.io/scrape"] == "true" &&
podReady(oldPod.Status.ContainerStatuses) &&
podHasMatchingLabelSelector(oldPod, p.podLabelSelector) &&
podHasMatchingFieldSelector(oldPod, p.podFieldSelector) {
if oldPod.GetDeletionTimestamp() != nil {
p.unregisterPod(oldPod, clientset)
}
}
},
DeleteFunc: func(oldObj interface{}) {
key, err := cache.MetaNamespaceKeyFunc(oldObj)
if err != nil {
p.Log.Errorf("getting key from cache %s", err.Error())
}
namespace, name, err := cache.SplitMetaNamespaceKey(key)
if err != nil {
p.Log.Errorf("splitting key into namespace and name %sn", err.Error())
}
pod, _ := clientset.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
if pod.Annotations["prometheus.io/scrape"] == "true" &&
podReady(pod.Status.ContainerStatuses) &&
podHasMatchingLabelSelector(pod, p.podLabelSelector) &&
podHasMatchingFieldSelector(pod, p.podFieldSelector) {
if pod.GetDeletionTimestamp() != nil {
p.unregisterPod(pod, clientset)
}
}
},
})
4、升级后的收益
自监控的升级,很好支持了业务从TKE集群到EKS集群的迁移,确保了原有的自监控不中断。同时在成本上也有很大的收益,对40多个地域80多个TKE集群升级后,在资源上共节省了300多核300多G内存,相当于云监控一个中等规模集群。
据笔者了解,不少业务自监控也是基于Prometheus抓取方式实现,在指标数据特别大的情况下Prometheus容易出现超时导致的抓取失败。如果使用以上这种方式,因为Agent数量可以随意调整,因此可以支持比较大规模K8S集群的metric采集(瓶颈在写入)。并且这种方式扩展性也比较好,笔者在另外一个私有化项目里也使用了基于二次开发的telegraf采集K8S集群自监控数据写入到influxDB,再配合grafana做展示。
5、后续优化
自监控升级后已在现网运行约几个月时间,现在回想起来这里还有优化的空间,比如现在是通过Deployment方式部署的,如果Agent重启,Agent的PodName会改变,相当于一致性哈希环里剔除了真实节点的同时又加入了一个新的真实节点,这样会使得采集任务的重新分配,导致这个期间漏采或重复采集。
因此这里可以改为StatefulSet的方式部署,StatefulSet能确保Agent即使销毁了PodName依然不变,这样能保证整个一致性哈希环里的真实节点是稳定的