Kubernetes 服务部署最佳实践(一)

作者: 陈鹏

引言

业务容器化后,如何将其部署在 K8S 上?如果仅仅是将它跑起来,很简单,但如果是上生产,我们有许多地方是需要结合业务场景和部署环境进行方案选型和配置调优的。比如,如何设置容器的 Request 与 Limit、如何让部署的服务做到高可用、如何配置健康检查、如何进行弹性伸缩、如何更好的进行资源调度、如何选择持久化存储、如何对外暴露服务等。

对于这一系列高频问题,这里将会出一个 Kubernetes 服务部署最佳实践的系列的文章来为大家一一作答,本文将先围绕如何合理利用资源的主题来进行探讨。

Request 与 Limit 怎么设置才好

如何为容器配置 Request 与 Limit? 这是一个即常见又棘手的问题,这个根据服务类型,需求与场景的不同而不同,没有固定的答案,这里结合生产经验总结了一些最佳实践,可以作为参考。

所有容器都应该设置 request

request 的值并不是指给容器实际分配的资源大小,它仅仅是给调度器看的,调度器会 “观察” 每个节点可以用于分配的资源有多少,也知道每个节点已经被分配了多少资源。被分配资源的大小就是节点上所有 Pod 中定义的容器 request 之和,它可以计算出节点剩余多少资源可以被分配(可分配资源减去已分配的 request 之和)。如果发现节点剩余可分配资源大小比当前要被调度的 Pod 的 reuqest 还小,那么就不会考虑调度到这个节点,反之,才可能调度。所以,如果不配置 request,那么调度器就不能知道节点大概被分配了多少资源出去,调度器得不到准确信息,也就无法做出合理的调度决策,很容易造成调度不合理,有些节点可能很闲,而有些节点可能很忙,甚至 NotReady。

所以,建议是给所有容器都设置 request,让调度器感知节点有多少资源被分配了,以便做出合理的调度决策,让集群节点的资源能够被合理的分配使用,避免陷入资源分配不均导致一些意外发生。

老是忘记设置怎么办

有时候我们会忘记给部分容器设置 request 与 limit,其实我们可以使用 LimitRange 来设置 namespace 的默认 request 与 limit 值,同时它也可以用来限制最小和最大的 request 与 limit。
示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: v1
kind: LimitRange
metadata:
name: mem-limit-range
namespace: test
spec:
limits:
- default:
memory: 512Mi
cpu: 500m
defaultRequest:
memory: 256Mi
cpu: 100m
type: Container

重要的线上应用改如何设置

节点资源不足时,会触发自动驱逐,将一些低优先级的 Pod 删除掉以释放资源让节点自愈。没有设置 request,limit 的 Pod 优先级最低,容易被驱逐;request 不等于 limit 的其次; request 等于 limit 的 Pod 优先级较高,不容易被驱逐。所以如果是重要的线上应用,不希望在节点故障时被驱逐导致线上业务受影响,就建议将 request 和 limit 设成一致。

怎样设置才能提高资源利用率

如果给给你的应用设置较高的 request 值,而实际占用资源长期远小于它的 request 值,导致节点整体的资源利用率较低。当然这对时延非常敏感的业务除外,因为敏感的业务本身不期望节点利用率过高,影响网络包收发速度。所以对一些非核心,并且资源不长期占用的应用,可以适当减少 request 以提高资源利用率。

如果你的服务支持水平扩容,单副本的 request 值一般可以设置到不大于 1 核,CPU 密集型应用除外。比如 coredns,设置到 0.1 核就可以,即 100m。

尽量避免使用过大的 request 与 limit

如果你的服务使用单副本或者少量副本,给很大的 request 与 limit,让它分配到足够多的资源来支撑业务,那么某个副本故障对业务带来的影响可能就比较大,并且由于 request 较大,当集群内资源分配比较碎片化,如果这个 Pod 所在节点挂了,其它节点又没有一个有足够的剩余可分配资源能够满足这个 Pod 的 request 时,这个 Pod 就无法实现漂移,也就不能自愈,加重对业务的影响。

相反,建议尽量减小 request 与 limit,通过增加副本的方式来对你的服务支撑能力进行水平扩容,让你的系统更加灵活可靠。

避免测试 namespace 消耗过多资源影响生产业务

若生产集群有用于测试的 namespace,如果不加以限制,可能导致集群负载过高,从而影响生产业务。可以使用 ResourceQuota 来限制测试 namespace 的 request 与 limit 的总大小。
示例:

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: ResourceQuota
metadata:
name: quota-test
namespace: test
spec:
hard:
requests.cpu: "1"
requests.memory: 1Gi
limits.cpu: "2"
limits.memory: 2Gi

如何让资源得到更合理的分配

设置 Request 能够解决让 Pod 调度到有足够资源的节点上,但无法做到更细致的控制。如何进一步让资源得到合理的使用?我们可以结合亲和性、污点与容忍等高级调度技巧,让 Pod 能够被合理调度到合适的节点上,让资源得到充分的利用。

使用亲和性

  • 对节点有特殊要求的服务可以用节点亲和性 (Node Affinity) 部署,以便调度到符合要求的节点,比如让 MySQL 调度到高 IO 的机型以提升数据读写效率。
  • 可以将需要离得比较近的有关联的服务用 Pod 亲和性 (Pod Affinity) 部署,比如让 Web 服务跟它的 Redis 缓存服务都部署在同一可用区,实现低延时。
  • 也可使用 Pod 反亲和 (Pod AntiAffinity) 将 Pod 进行打散调度,避免单点故障或者流量过于集中导致的一些问题。

使用污点与容忍

使用污点 (Taint) 与容忍 (Toleration) 可优化集群资源调度:

  • 通过给节点打污点来给某些应用预留资源,避免其它 Pod 调度上来。
  • 需要使用这些资源的 Pod 加上容忍,结合节点亲和性让它调度到预留节点,即可使用预留的资源。

弹性伸缩

如何支持流量突发型业务

通常业务都会有高峰和低谷,为了更合理的利用资源,我们为服务定义 HPA,实现根据 Pod 的资源实际使用情况来对服务进行自动扩缩容,在业务高峰时自动扩容 Pod 数量来支撑服务,在业务低谷时,自动缩容 Pod 释放资源,以供其它服务使用(比如在夜间,线上业务低峰,自动缩容释放资源以供大数据之类的离线任务运行) 。

使用 HPA 前提是让 K8S 得知道你服务的实际资源占用情况(指标数据),需要安装 resource metrics (metrics.k8s.io) 或 custom metrics (custom.metrics.k8s.io) 的实现,好让 hpa controller 查询这些 API 来获取到服务的资源占用情况。早期 HPA 用 resource metrics 获取指标数据,后来推出 custom metrics,可以实现更灵活的指标来控制扩缩容。官方有个叫 metrics-server 的实现,通常社区使用的更多的是基于 prometheus 的 实现 prometheus-adapter,而云厂商托管的 K8S 集群通常集成了自己的实现,比如 TKE,实现了 CPU、内存、硬盘、网络等维度的指标,可以在网页控制台可视化创建 HPA,但最终都会转成 K8S 的 yaml,示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: autoscaling/v2beta2
kind: HorizontalPodAutoscaler
metadata:
name: nginx
spec:
scaleTargetRef:
apiVersion: apps/v1beta2
kind: Deployment
name: nginx
minReplicas: 1
maxReplicas: 10
metrics:
- type: Pods
pods:
metric:
name: k8s_pod_rate_cpu_core_used_request
target:
averageValue: "100"
type: AverageValue

如何节约成本

HPA 能实现 Pod 水平扩缩容,但如果节点资源不够用了,Pod 扩容出来还是会 Pending。如果我们提前准备好大量节点,做好资源冗余,提前准备好大量节点,通常不会有 Pod Pending 的问题,但也意味着需要付出更高的成本。通常云厂商托管的 K8S 集群都会实现 cluster-autoscaler,即根据资源使用情况,动态增删节点,让计算资源能够被最大化的弹性使用,按量付费,以节约成本。在 TKE 上的实现叫做伸缩组,以及一个包含伸缩功能组但更高级的特性:节点池(正在灰度)

无法水平扩容的服务怎么办

对于无法适配水平伸缩的单体应用,或者不确定最佳 request 与 limit 超卖比的应用,可以尝用 VPA 来进行垂直伸缩,即自动更新 request 与 limit,然后重启 pod。不过这个特性容易导致你的服务出现短暂的不可用,不建议在生产环境中大规模使用。

参考资料

TKE 集群组建最佳实践

作者: 陈鹏

Kubernetes 版本

K8S 版本迭代比较快,新版本通常包含许多 bug 修复和新功能,旧版本逐渐淘汰,建议创建集群时选择当前 TKE 支持的最新版本,后续出新版本后也是可以支持 Master 和 节点的版本升级的。

网络模式: GlobalRouter vs VPC-CNI

GlobalRouter 模式架构:

  • 基于 CNI 和 网桥实现的容器网络能力,容器路由直接通过 VPC 底层实现
  • 容器与节点在同一网络平面,但网段不与 VPC 网段重叠,容器网段地址充裕

VPC-CNI 模式架构:

  • 基于 CNI 和 VPC 弹性网卡实现的容器网络能力,容器路由通过弹性网卡,性能相比 Global Router 约提高 10%
  • 容器与节点在同一网络平面,网段在 VPC 网段内
  • 支持 Pod 固定 IP

对比:

支持三种使用方式:

  • 创建集群时指定 GlobalRouter 模式
  • 创建集群时指定 VPC-CNI 模式,后续所有 Pod 都必须使用 VPC-CNI 模式创建
  • 创建集群时指定 GlobalRouter 模式,在需要使用 VPC-CNI 模式时为集群启用 VPC-CNI 的支持,即两种模式混用

选型建议:

  • 绝大多数情况下应该选择 GlobalRouter,容器网段地址充裕,扩展性强,能适应规模较大的业务
  • 如果后期部分业务需要用到 VPC-CNI 模式,可以在 GlobalRouter 集群再开启 VPC-CNI 支持,也就是 GlobalRouter 与 VPC-CNI 混用,仅对部分业务使用 VPC-CNI 模式
  • 如果完全了解并接受 VPC-CNI 的各种限制,并且需要集群内所有 Pod 都用 VPC-CNI 模式,可以创建集群时选择 VPC-CNI 网络插件

参考官方文档 《如何选择容器服务网络模式》: https://cloud.tencent.com/document/product/457/41636

运行时: Docker vs Containerd

Docker 作为运行时的架构:

  • kubelet 内置的 dockershim 模块帮傲娇的 docker 适配了 CRI 接口,然后 kubelet 自己调自己的 dockershim (通过 socket 文件),然后 dockershim 再调 dockerd 接口 (Docker HTTP API),接着 dockerd 还要再调 docker-containerd (gRPC) 来实现容器的创建与销毁等。
  • 为什么调用链这么长? K8S 一开始支持的就只是 Docker,后来引入了 CRI,将运行时抽象以支持多种运行时,而 Docker 跟 K8S 在一些方面有一定的竞争,不甘做小弟,也就没在 dockerd 层面实现 CRI 接口,所以 kubelet 为了让 dockerd 支持 CRI,就自己为 dockerd 实现了 CRI。docker 本身内部组件也模块化了,再加上一层 CRI 适配,调用链肯定就长了。

Containerd 作为运行时的架构:

  • containerd 1.1 之后,支持 CRI Plugin,即 containerd 自身这里就可以适配 CRI 接口。
  • 相比 Docker 方案,调用链少了 dockershim 和 dockerd。

对比:

  • containerd 方案由于绕过了 dockerd,调用链更短,组件更少,占用节点资源更少,绕过了 dockerd 本身的一些 bug,但 containerd 自身也还存在一些 bug (已修复一些,灰度中)。
  • docker 方案历史比较悠久,相对更成熟,支持 docker api,功能丰富,符合大多数人的使用习惯。

选型建议:

  • Docker 方案 相比 containerd 更成熟,如果对稳定性要求很高,建议 docker 方案
  • 以下场景只能使用 docker:
    • Docker in docker (通常在 CI 场景)
    • 节点上使用 docker 命令
    • 调用 docker API
  • 没有以上场景建议使用 containerd

参考官方文档 《如何选择 Containerd 和 Docker》: https://cloud.tencent.com/document/product/457/35747

Service 转发模式: iptables vs ipvs

先看看 Service 的转发原理:

  • 节点上的 kube-proxy 组件 watch apiserver,获取 Service 与 Endpoint,转化成 iptables 或 ipvs 规则并写到节点上
  • 集群内的 client 去访问 Service (Cluster IP),会被 iptable/ipvs 规则负载均衡到 Service 对应的后端 pod

对比:

  • ipvs 模式性能更高,但也存在一些已知未解决的 bug
  • iptables 模式更成熟稳定

选型建议:

  • 对稳定性要求极高且 service 数量小于 2000,选 iptables
  • 其余场景首选 ipvs

集群类型: 托管集群 vs 独立集群

托管集群:

  • Master 组件用户不可见,由腾讯云托管
  • 很多新功能也是会率先支持托管的集群
  • Master 的计算资源会根据集群规模自动扩容
  • 用户不需要为 Master 付费

独立集群:

  • Master 组件用户可以完全掌控
  • 用户需要为 Master 付费购买机器

选型建议:

  • 一般推荐托管集群
  • 如果希望能能够对 Master 完全掌控,可以使用独立集群 (比如对 Master 进行个性化定制实现高级功能)

节点操作系统

TKE 主要支持 Ubuntu 和 CentOS 两类发行版,带 “TKE-Optimized” 后缀用的是 TKE 定制优化版的内核,其它的是 linux 社区官方开源内核:

TKE-Optimized 的优势:

  • 基于内核社区长期支持的 4.14.105 版本定制
  • 针对容器和云场景进行优化
  • 计算、存储和网络子系统均经过性能优化
  • 对内核缺陷修复支持较好
  • 完全开源: https://github.com/Tencent/TencentOS-kernel

选型建议:

  • 推荐 “TKE-Optimized”,稳定性和技术支持都比较好
  • 如果需要更高版本内核,选非 “TKE-Optimized” 版本的操作系统

节点池

此特性当前正在灰度中,可申请开白名单使用。主要可用于批量管理节点:

  • 节点 Label 与 Taint
  • 节点组件启动参数
  • 节点自定义启动脚本
  • 操作系统与运行时 (暂未支持)

产品文档:https://cloud.tencent.com/document/product/457/43719

适用场景:

  • 异构节点分组管理,减少管理成本
  • 让集群更好支持复杂的调度规则 (Label, Taint)
  • 频繁扩缩容节点,减少操作成本
  • 节点日常维护(版本升级)

用法举例:

部分IO密集型业务需要高IO机型,为其创建一个节点池,配置机型并统一设置节点 Label 与 Taint,然后将 IO 密集型业务配置亲和性,选中 Label,使其调度到高 IO 机型的节点 (Taint 可以避免其它业务 Pod 调度上来)。

随着时间的推移,业务量快速上升,该 IO 密集型业务也需要更多的计算资源,在业务高峰时段,HPA 功能自动为该业务扩容了 Pod,而节点计算资源不够用,这时节点池的自动伸缩功能自动扩容了节点,扛住了流量高峰。

启动脚本

添加节点时通过自定义数据配置节点启动脚本 (可用于修改组件启动参数、内核参数等):

组件自定义参数

此特性当前也正在灰度中,可申请开白名单使用。

创建集群时可自定义 Master 组件部分启动参数:

添加节点时可自定义 kubelet 部分启动参数:

揭秘 Kubernetes attach/detach controller 逻辑漏洞致使 pod 启动失败

作者: 蔡靖

前言

本文主要通过深入学习k8s attach/detach controller源码,了解现网案例发现的attach/detach controller bug发生的原委,并给出解决方案。

看完本文你也将学习到:

  • attach/detach controller的主要数据结构有哪些,保存什么数据,数据从哪来,到哪去等等;
  • k8s attach/detach volume的详细流程,如何判断volume是否需要attach/detach,attach/detach controller和kubelet(volume manager)如何协同工作等等。

现网案例现象

我们首先了解下现网案例的问题和现象;然后去深入理解ad controller维护的数据结构;之后根据数据结构与ad controller的代码逻辑,再来详细分析现网案例出现的原因和解决方案。从而深入理解整个ad controller。

问题描述

  • 一个statefulsets(sts)引用了多个pvc cbs,我们更新sts时,删除旧pod,创建新pod,此时如果删除旧pod时cbs detach失败,且创建的新pod调度到和旧pod相同的节点,就可能会让这些pod一直处于ContainerCreating

现象

  • kubectl describe pod

enter image description here

  • kubelet log

enter image description here

  • kubectl get node xxx -oyamlvolumesAttachedvolumesInUse
1
2
3
4
5
6
volumesAttached:
- devicePath: /dev/disk/by-id/virtio-disk-6w87j3wv
name: kubernetes.io/qcloud-cbs/disk-6w87j3wv
volumesInUse:
- kubernetes.io/qcloud-cbs/disk-6w87j3wv
- kubernetes.io/qcloud-cbs/disk-7bfqsft5

k8s存储简述

k8s中attach/detach controller负责存储插件的attach/detach。本文结合现网出现的一个案例来分析ad controller的源码逻辑,该案例是因k8s的ad controller bug导致的pod创建失败。

k8s中涉及存储的组件主要有:attach/detach controller、pv controller、volume manager、volume plugins、scheduler。每个组件分工明确:

  • attach/detach controller:负责对volume进行attach/detach
  • pv controller:负责处理pv/pvc对象,包括pv的provision/delete(cbs intree的provisioner设计成了external provisioner,独立的cbs-provisioner来负责cbs pv的provision/delete)
  • volume manager:主要负责对volume进行mount/unmount
  • volume plugins:包含k8s原生的和各厂商的的存储插件
    • 原生的包括:emptydir、hostpath、flexvolume、csi等
    • 各厂商的包括:aws-ebs、azure、我们的cbs等
  • scheduler:涉及到volume的调度。比如对ebs、csi等的单node最大可attach磁盘数量的predicate策略

enter image description here

控制器模式是k8s非常重要的概念,一般一个controller会去管理一个或多个API对象,以让对象从实际状态/当前状态趋近于期望状态。

所以attach/detach controller的作用其实就是去attach期望被attach的volume,detach期望被detach的volume。

后续attach/detach controller简称ad controller。

ad controller数据结构

对于ad controller来说,理解了其内部的数据结构,再去理解逻辑就事半功倍。ad controller在内存中维护2个数据结构:

  1. actualStateOfWorld —— 表征实际状态(后面简称asw)
  2. desiredStateOfWorld —— 表征期望状态(后面简称dsw)

很明显,对于声明式API来说,是需要随时比对实际状态和期望状态的,所以ad controller中就用了2个数据结构来分别表征实际状态和期望状态。

actualStateOfWorld

actualStateOfWorld 包含2个map:

  • attachedVolumes: 包含了那些ad controller认为被成功attach到nodes上的volumes
  • nodesToUpdateStatusFor: 包含要更新node.Status.VolumesAttached 的nodes
attachedVolumes
如何填充数据?

1、在启动ad controller时,会populate asw,此时会list集群内所有node对象,然后用这些node对象的node.Status.VolumesAttached 去填充attachedVolumes

2、之后只要有需要attach的volume被成功attach了,就会调用MarkVolumeAsAttachedGenerateAttachVolumeFunc 中)来填充到attachedVolumes中

如何删除数据?

1、只有在volume被detach成功后,才会把相关的volume从attachedVolumes中删掉。(GenerateDetachVolumeFunc 中调用MarkVolumeDetached)

nodesToUpdateStatusFor
如何填充数据?

1、detach volume失败后,将volume add back到nodesToUpdateStatusFor

​ - GenerateDetachVolumeFunc 中调用AddVolumeToReportAsAttached

如何删除数据?

1、在detach volume之前会先调用RemoveVolumeFromReportAsAttachednodesToUpdateStatusFor中先删除该volume相关信息

desiredStateOfWorld

desiredStateOfWorld 中维护了一个map:

nodesManaged:包含被ad controller管理的nodes,以及期望attach到这些node上的volumes。

nodesManaged
如何填充数据?

1、在启动ad controller时,会populate asw,list集群内所有node对象,然后把由ad controller管理的node填充到nodesManaged

2、ad controller的nodeInformer watch到node有更新也会把node填充到nodesManaged

3、另外在populate dsw和podInformer watch到pod有变化(add, update)时,往nodesManaged 中填充volume和pod的信息

4、desiredStateOfWorldPopulator 中也会周期性地去找出需要被add的pod,此时也会把相应的volume和pod填充到nodesManaged

如何删除数据?

1、当删除node时,ad controller中的nodeInformer watch到变化会从dsw的nodesManaged 中删除相应的node

2、当ad controller中的podInformer watch到pod的删除时,会从nodesManaged 中删除相应的volume和pod

3、desiredStateOfWorldPopulator 中也会周期性地去找出需要被删除的pod,此时也会从nodesManaged 中删除相应的volume和pod

ad controller流程简述

ad controller的逻辑比较简单:

1、首先,list集群内所有的node和pod,来populate actualStateOfWorld (attachedVolumes )和desiredStateOfWorld (nodesManaged)

2、然后,单独开个goroutine运行reconciler,通过触发attach, detach操作周期性地去reconcile asw(实际状态)和dws(期望状态)

  • 触发attach,detach操作也就是,detach该被detach的volume,attach该被attach的volume

3、之后,又单独开个goroutine运行DesiredStateOfWorldPopulator ,定期去验证dsw中的pods是否依然存在,如果不存在就从dsw中删除

现网案例

接下来结合上面所说的现网案例,来详细看看reconciler的逻辑。

案例初步分析

  • 从pod的事件可以看出来:ad controller认为cbs attach成功了,然后kubelet没有mount成功。
  • 但是从kubelet日志却发现Volume not attached according to node status ,也就是说kubelet认为cbs没有按照node的状态去挂载。这个从node info也可以得到证实:volumesAttached 中的确没有这个cbs盘(disk-7bfqsft5)。
  • node info中还有个现象:volumesInUse 中还有这个cbs。说明没有unmount成功

很明显,cbs要能被pod成功使用,需要ad controller和volume manager的协同工作。所以这个问题的定位首先要明确:

  1. volume manager为什么认为volume没有按照node状态挂载,ad controller却认为volume attch成功了?
  2. volumesAttachedvolumesInUse 在ad controller和kubelet之间充当什么角色?

这里只对分析volume manager做简要分析。

  • 根据Volume not attached according to node status 在代码中找到对应的位置,发现在GenerateVerifyControllerAttachedVolumeFunc 中。仔细看代码逻辑,会发现
    • volume manager的reconciler会先确认该被unmount的volume被unmount掉
    • 然后确认该被mount的volume被mount
      • 此时会先从volume manager的dsw缓存中获取要被mount的volumes(volumesToMountpodsToMount
      • 然后遍历,验证每个volumeToMount是否已经attach了
        • 这个volumeToMount是由podManager中的podInformer加入到相应内存中,然后desiredStateOfWorldPopulator周期性同步到dsw中的
      • 验证逻辑中,在GenerateVerifyControllerAttachedVolumeFunc中会去遍历本节点的node.Status.VolumesAttached,如果没有找到就报错(Volume not attached according to node status
  • 所以可以看出来,volume manager就是根据volume是否存在于node.Status.VolumesAttached 中来判断volume有无被attach成功
  • 那谁去填充node.Status.VolumesAttached ?ad controller的数据结构nodesToUpdateStatusFor 就是用来存储要更新到node.Status.VolumesAttached 上的数据的。
  • 所以,如果ad controller那边没有更新node.Status.VolumesAttached,而又新建了pod,desiredStateOfWorldPopulator 从podManager中的内存把新建pod引用的volume同步到了volumesToMount中,在验证volume是否attach时,就会报错(Volume not attached according to node status)
    • 当然,之后由于kublet的syncLoop里面会调用WaitForAttachAndMount 去等待volumeattach和mount成功,由于前面一直无法成功,等待超时,才会有会面timeout expired 的报错

所以接下来主要需要看为什么ad controller那边没有更新node.Status.VolumesAttached

ad controller的reconciler详解

接下来详细分析下ad controller的逻辑,看看为什么会没有更新node.Status.VolumesAttached,但从事件看ad controller却又认为volume已经挂载成功。

流程简述中表述可见,ad controller主要逻辑是在reconciler中。

  • reconciler定时去运行reconciliationLoopFunc,周期为100ms。

  • reconciliationLoopFunc的主要逻辑在reconcile()中:

    1. 首先,确保该被detach的volume被detach掉

      • 遍历asw中的attachedVolumes,对于每个volume,判断其是否存在于dsw中
        • 根据nodeName去dsw.nodesManaged中判断node是否存在
        • 存在的话,再根据volumeName判断volume是否存在
      • 如果volume存在于asw,且不存在于dsw,则意味着需要进行detach
      • 之后,根据node.Status.VolumesInUse来判断volume是否已经unmount完成,unmount完成或者等待6min timeout时间到后,会继续detach逻辑
      • 在执行detach volume之前,会先调用RemoveVolumeFromReportAsAttached从asw的nodesToUpdateStatusFor中去删除要detach的volume
      • 然后patch node,也就等于从node.status.VolumesAttached删除这个volume
      • 之后进行detach,detach失败主要分2种
        • 如果真正执行了volumePlugin的具体实现DetachVolume失败,会把volume add back到nodesToUpdateStatusFor(之后在attach逻辑结束后,会再次patch node)
        • 如果是operator_excutor判断还没到backoff周期,就会返回backoffError,直接跳过DetachVolume
      • backoff周期起始为500ms,之后指数递增至2min2s。已经detach失败了的volume,在每个周期期间进入detach逻辑都会直接返回backoffError
    2. 之后,确保该被attach的volume被attach成功

      • 遍历dsw的nodesManaged,判断volume是否已经被attach到该node,如果已经被attach到该node,则跳过attach操作

      • 去asw.attachedVolumes中判断是否存在,若不存在就认为没有attach到node

        • 若存在,再判断node,node也匹配就返回attachedConfirmed
 - 而`attachedConfirmed`是由asw中`AddVolumeNode`去设置的,`MarkVolumeAsAttached`设置为true。(true即代表该volume已经被attach到该node了)
     - 之后判断是否禁止多挂载,再由operator_excutor去执行attach

3. 最后,`UpdateNodeStatuses`去更新node status

案例详细分析

  • 前提
    • volume detach失败
    • sts+cbs(pvc),pod recreate前后调度到相同的node
  • 涉及k8s组件
    • ad controller
    • kubelet(volume namager)
  • ad controller和kubelet(volume namager)通过字段node.status.VolumesAttached交互。
    • ad controller为node.status.VolumesAttached新增或删除volume,新增表明已挂载,删除表明已删除
    • kubelet(volume manager)需要验证新建pod中的(pvc的)volume是否挂载成功,存在于node.status.VolumesAttached中,则表明验证volume已挂载成功;不存在,则表明还未挂载成功。
  • 以下是整个过程:
  1. 首先,删除pod时,由于某种原因cbs detach失败,失败后就会backoff重试。
    1. 由于detach失败,该volume也不会从asw的attachedVolumes中删除
  2. 由于detach时,
    1. 先从node.status.VolumesAttached中删除volume,之后才去执行detach
    2. detach时返回backoffError不会把该volumeadd back node.status.VolumesAttached
  3. 之后,我们在backoff周期中(假如就为第一个周期的500ms中间)再次创建sts,pod被调度到之前的node
  4. 而pod一旦被创建,就会被添加到dsw的nodesManaged(nodeName和volumeName都没变)
  5. reconcile()中的第2步,会去判断volume是否被attach,此时发现该volume同时存在于asw和dws中,并且由于detach失败,也会在检测时发现还是attach,从而设置attachedConfirmed为true
  6. ad controller就认为该volume被attach成功了
  7. reconcile()中第1步的detach逻辑进行判断时,发现要detach的volume已经存在于dsw.nodesManaged了(由于nodeName和volumeName都没变),这样volume同时存在于asw和dsw中了,实际状态和期望状态一致,被认为就不需要进行detach了。
  8. 这样,该volume之后就再也不会被add back到node.status.VolumesAttached。所以就出现了现象中的node info中没有该volume,而ad controller又认为该volume被attach成功了
  9. 由于kubelet(volume manager)与controller manager是异步的,而它们之间交互是依据node.status.VolumesAttached ,所以volume manager在验证volume是否attach成功,发现node.status.VolumesAttached中没有这个voume,也就认为没有attach成功,所以就有了现象中的报错Volume not attached according to node status
  10. 之后kubelet的syncPod在等待pod所有的volume attach和mount成功时,就超时了(现象中的另一个报错timeout expired wating...)。
  11. 所以pod一直处于ContainerCreating

小结

  • 所以,该案例出现的原因是:
    • sts+cbs,pod recreate时间被调度到相同的node
    • 由于detach失败,backoff期间创建sts/pod,致使ad controller中的dsw和asw数据一致(此时该volume由于没有被detach成功而确实处于attach状态),从而导致ad controller认为不再需要去detach该volume。
    • 又由于detach时,是先从node.status.VolumesAttached中删除该volume,再去执行真正的DetachVolume。backoff期间直接返回backoffError,跳过DetachVolume,不会add back
    • 之后,ad controller因volume已经处于attach状态,认为不再需要被attach,就不会再向node.status.VolumesAttached中添加该volume
    • 最后,kubelet与ad controller交互就通过node.status.VolumesAttached,所以kubelet认为没有attach成功,新创建的pod就一直处于ContianerCreating
  • 据此,我们可以发现关键点在于node.status.VolumesAttached和以下两个逻辑:
    1. detach时backoffError,不会add back
    2. detach是先删除,失败再add back
  • 所以只要想办法能在任何情况下add back就不会有问题了。根据以上两个逻辑就对应有以下2种解决方案,推荐使用方案2
    1. backoffError时,也add back
      • pr #72914
        • 但这种方式有个缺点:patch node的请求数增加了10+次/(s * volume)
    2. 一进入detach逻辑就判断是否backoffError(处于backoff周期中),是就跳过之后所有detach逻辑,不删除就不需要add back了。
      • pr #88572
        • 这个方案能避免方案1的问题,且会进一步减少请求apiserver的次数,且改动也不多

总结

  • AD Controller负责存储的Attach、Detach。通过比较asw和dsw来判断是否需要attach/detach。最终attach和detach结果会体现在node.status.VolumesAttached
  • 以上现网案例出现的现象,是k8s ad controller的bug导致,目前社区并未修复。
    • 现象出现的原因主要是:
      • 先删除旧pod过程中detach失败,而在detach失败的backoff周期中创建新pod,此时由于ad controller逻辑bug,导致volume被从node.status.VolumesAttached中删除,从而导致创建新pod时,kubelet检查时认为该volume没有attach成功,致使pod就一直处于ContianerCreating
    • 而现象的解决方案,推荐使用pr #88572。目前TKE已经有该方案的稳定运行版本,在灰度中。

揭秘!containerd 镜像文件丢失问题,竟是镜像生成惹得祸

作者: 李志宇

containerd 镜像丢失文件问题说明

近期有客户反映某些容器镜像出现了文件丢失的奇怪现象,经过模拟复现汇总出丢失情况如下:

某些特定的镜像会稳定丢失文件;

“丢失”在某些发行版稳定复现,但在 ubuntu 上不会出现;

v1.2 版本的 containerd 会文件丢失,而 v1.3 不会。

通过阅读源码和文档,最终解决了这个 containerd 镜像丢失问题,并写下了这篇文章,希望和大家分享下解决问题的经历和镜像生成的原理。为了方便某些心急的同学,本文接下来将首先揭晓该问题的答案~

根因和解决方案

由于内核 overlay 模块 Bug,当 containerd 从镜像仓库下载镜像的“压缩包”生成镜像的“层”时,overlay 错误地把trusted.overlay.opaque=y这个 xattrs 从下层传递到了上层。如果某个目录设置了这个属性,overlay 则会认为这个目录是不透明的,以至于在进行联合挂载时该目录将会把下面的目录覆盖掉,进而导致镜像文件丢失的问题。

这个问题的解决方案可以有两种,一种简单粗暴,直接升级内核中 overlay 模块即可。

另外一种可以考虑把 containerd 从 v1.2 版本升级到 v1.3,原因在于 containerd v1.3 中会主动设置上述 opaque 属性,该版本 containerd 不会触发 overlayfs 的 bug。当然,这种方式是规避而非彻底解决 Bug。

snapshotter 生成镜像原理分析

虽然根本原因看起来比较简单,但分析的过程还是比较曲折的。在分享下这个问题的排查过程和收获之前,为了方便大家理解,本小节将集中讲解问题排查过程涉及到的 containerd 和 overlayfs 的知识,比较了解或者不感兴趣的同学可以直接跳过。

与 docker daemon 一开始的设计不同,为了减少耦合性,containerd 通过插件的方式由多个模块组成。结合下图可以看出,其中与镜像相关的模块包含以下几种:

enter image description here

  • metadata 是 containerd 通过 bbolt 实现的 kv 存储模块,用来保存镜像、容器或者层等元信息。比如命令行 ctr 列出所有 snapshot 或 kubelet 获取所有 pod 都是通过 metadata 模块查询的数据。
  • content 是负责保存 blob 的模块,其保存的关于镜像的内容一般分为三种:

    1. 镜像的 manifest(一个普通的 json,其中指定了镜像的 config 和镜像的 layers 数组)
    2. 镜像的 config(同样是个 json,其中指定镜像的元信息,比如启动命令、环境变量等)
    3. 镜像的 layer(tar 包,解压、处理后会生成镜像的层)
  • snapshots 是快照模块总称,可以设置使用不同的快照模块,常见的模块有 overlayfs、aufs 或 native。在 unpack 时 snapshots 会把生成镜像层并保存到文件系统;当运行容器时,可以调用 snapshots 模块给容器提供 rootfs 。

容器镜像规范主要有 docker 和 oci v1、v2 三种,考虑到这三种规范在原理上大同小异,可以参考以下示例,将 manifest 当作是每个镜像只有一份的元信息,用于指向镜像的 config 和每层 layer。其中,config 即为镜像配置,把镜像作为容器运行时需要;layer 即为镜像的每一层。

1
2
3
4
type manifest struct {
c config
layers []layer
}

镜像下载流程与图 1 中数字标注出来的顺序一致,每个步骤作用总结如下:

首先在 metadata 模块中添加一个 image,这样我们在执行 list image 时可看到这个 image。

其次是需要下载镜像,因为镜像是有 manifest、config、layers 等多个部分组成,所以先下载镜像的 manifest 并保存到 content 模块,再解析 manifest 获取 config 的地址和 layers 的地址。接下来分别把 config 和每个 layer 下载并保存到 content 模块,这里需要强调镜像的 layer 本来应该是目录,当创建容器时联合挂载到 root 下,但是为了方便网络传输和存储,这里会用 tar + 压缩的方式保存。这里保存到 content 也是不解压的。

③、④、⑤的作用关联性比较强,此处放在一起解释。snapshot 模块去 content 模块读取 manifest,找到镜像的所有层,再去 content 模块把这些层自“下”而“上”读取出来,逐一解压并加工,最后放到 snapshot 模块的目录下,像图 1 中的 1001/fs、1002/fs 这些都是镜像的层。(当创建容器时,需要把这些层联合挂载生成容器的 rootfs,可以理解成1001/fs + 1002/fs + … => 1008/work)。

整个流程的函数调用关系如下图 2,喜欢阅读源码的同学可以照着这个去看下。
enter image description here

为了方便理解,接下来用 layer 表示 snapshot 中的层,把刚下载未经过加工的“层”称之为镜像层的 tar 包或者是 tar 包。

下载镜像保存入 content 的流程比较简单,直接跳过就好。而通过镜像的 tar 包生成 snapshot 中的 layer 这个过程比较巧妙,甚至 bug 也是出现在这里,接下来进行重点描述。

首先通过 content 拿到了镜像的 manifest,这样我们得知镜像是有哪些层组成的。最下面一层镜像比较简单,直接解压到 snapshot 提供的目录就可以了,比如 10/fs。假设接下来要在 11/fs 生成第二层(此时 11/fs 还是空的),snapshot 会使用mount -t overlay overlay -o lowerdir=10/fs,upperdir=11/fs,workdir=11/work tmp把已经生成好的 layer 10 和还未生成的 layer 11 挂载到一个 tmp 目录上,其中写入层是 11/fs 也就是我们想要生成的 layer。去 content 中拿到 layer 11 对应的 tar 包,遍历这个 tar 包,根据 tar 包中不同的文件对挂载点 tmp 进行写入或者删除文件的操作(因为是联合挂载,所以对于挂载点的操作都会变成对写入层的操作)。把 tar 包转化成 layer 的具体逻辑和下面经过简化的源码一致,可以看到如果 tar 包中存在 whiteout 文件或者当前的层比如 11/fs 和之前的层有冲突比如 10/fs,会把底层目录删掉。在把 tar 包的文件写入到目录后,会根据 tar 包中记录的 PAXRecords 给文件添加 xattr,PAXRecords 可以看做是 tar 中每个文件都带有的 kv 数组,可以用来映射文件系统中文件属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 这里的tmp就是overlay的挂载点
applyNaive(tar, tmp) {
for tar.hashNext() {
tar_file := tar.Next() // tar包中的文件
real_file := path.Join(root, file.base) // 现实世界的文件
// 按照规则删除文件
if isWhiteout(info) {
whiteRM(real_file)
}
if !(file.IsDir() && IsDir(real_file)) {
rm(real_file)
}
// 把tar包的文件写入到layer中
createFileOrDir(tar_file, real_file)
for k, v := range tar_file.PAXRecords {
setxattr(real_file, k, v)
}
}
}

需要删除的这些情况总结如下:

如果存在同名目录,两者进行 merge

如果存在同名但不都是目录,需要删除掉下层目录(上文件下目录、上目录下文件、上文件下文件)

如果存在 .wh. 文件,需要移除底层应该被覆盖掉的目录,比如目录下存在 .wh..wh.opaque 文件,就需要删除 lowerdir 中的对应目录。

enter image description here

当然这里的删除也没那么简单,还记得当前的操作都是通过挂载点来删除底层的文件么?在 overlay 中,如果通过挂载点删除 lower 层的内容,不会把文件真的从 lower 的文件目录中干掉,而是会在 upper 层中添加 whiteout,添加 whiteout 的其中一种方式就是设置上层目录的 xattr trusted.overlay.opaque=y。

当 tar 包遍历结束以后,对 tmp 做个 umount,得到的 11/fs 就是我们想要的 layer,当我们想要生成 12/fs 这个 layer 时,只需要把 10/fs,11/fs 作为 lowerdir,把 12/fs 作为 upperdir 联合挂载就可以。也就是说,之后镜像的每一个 layer 生成都是需要把之前的 layer 挂载,下面图说明了整个流程。

enter image description here

可以考虑下为什么要这么大费周章?关键有两点。

一是镜像中的删除下层文件是要遵循 image-spec 中对于 whiteout 文件的定义(image-spec),这个文件只会在 tar 包中作为标识,并不会产生真正的影响。而起到真正作用的是在 applyNaive 碰到了 whiteout 文件,会调用联合文件系统对底层目录进行删除,当然这个删除对于 overlay 就是标记 opaque。

二是因为存在文件和目录相互覆盖的现象,每一个 tar 包中的文件都需要和之前所有 tar包 中的内容进行比对,如果不借用联合文件系统的“超能力”,我们就只能拿着 tar 中的每一个文件对之前的层遍历。

问题排查过程

了解了镜像相关的知识,我们来看看这个问题的排查过程。首先我们观察用户的容器,经过简化和打码目录结构如下,其中目录 modules 就是事故多发地。

1
2
3
4
5
6
/data
└── prom
├── bin
└── modules
├── file
└── lib/

再观察下用户的镜像的各个层。我们把镜像的层按照从下往上用递增的 ID 来标注,对这个目录有修改的有 5099、5101、5102、5103、5104 这几层。把容器运行起来后,看到的 modules 目录和 5104 提供的一样。并没有把 5103 等“下面”的镜像合并起来,相当于 5104 把下面的目录都覆盖掉了(当然,51045103 文件是有区别的)。

5104 下层目录为何被覆盖?

看到这里,首先想到是不是创建容器的 rootfs 时参数出现了问题,导致少 mount 了一些层?于是模拟手动挂载mount -t overlay overlay -o lowerdir=5104:5103 point把最上两层挂载,结果 5104 依然把 5103 覆盖了。这里推断可能是存在 overlay 的 .wh. 文件,于是尝试在这两层中搜 .wh. 文件,无果。于是去查 overlayfs 的文档:

A directory is made opaque by setting the xattr “trusted.overlay.opaque”
to “y”. Where the upper filesystem contains an opaque directory, any
directory in the lower filesystem with the same name is ignored.

设置了属性 trusted.overlay.opaque=y 的目录会变成“不透明”的,当上层文件系统被设置为“不透明”时,下层中同名的目录会被忽略。overlay 如果想要在上层把下层覆盖掉,就需要设置这个属性。

通过命令getfattr -n “trusted.overlay.opaque” dir查看发现,5104 下面的 /data/asr_offline/modules 果然带有这个属性,这一现象也进而导致了下层目录被“覆盖”。

1
2
3
[root@]$ getfattr -n "trusted.overlay.opaque" 5104/fs/data/asr_offline/modules
# file: 5102/fs/data/asr_offline/modules
trusted.overlay.opaque="y"

一波多折,层层追究
那么问题来了,为什么只有特定的发行版会出现这个现象?我们尝试在 ubuntu 拉下镜像,发现“同源”目录居然没有设置 opaque!由于镜像的层通过把源文件解压和解包生成的,我们决定在确保不同操作系统中的“镜像源文件”的 md5 相同之后,在各个操作系统上把镜像源文件通过tar -zxf进行解包并重新手动挂载,发现 5104 均不会把 5103 覆盖。

根据以上现象推断,可能是某些发行版下的 containerd 从 content 读取 tar 包并解压制作 snapshot 的 layer 时出现问题,错误地把 snapshot 的目录设置上了这个属性。

为验证该推断,决定进行源代码梳理,由此发现了其中的疑点(相关代码如下)——生成 layers 时遍历 tar 包会读取每个文件的 PAXRecords 并且把这个设置在文件的 xattr 上( tar 包给每个文件都准备了 PAXRecords,和 Pod 的 labels 等价)。

1
2
3
4
5
6
7
8
9
10
func applyNaive() {
// ...
for k, v := range tar_file.PAXRecords {
setxattr(real_file, k, v)
}
}

func setxattr(path, key, value string) error {
return unix.Lsetxattr(path, key, []byte(value), 0)
}

因为之前实验过 v1.3 的 containerd 不会出现这个问题,所以对照了下两者的代码,发现两者从 tar 包中抽取 PAXRecords 设置 xattr 的逻辑两者是不一样的。v1.3 的代码如下:

1
2
3
4
5
6
7
func setxattr(path, key, value string) error {
// Do not set trusted attributes
if strings.HasPrefix(key, "trusted.") {
return errors.Wrap(unix.ENOTSUP, "admin attributes from archive not supported")
}
return unix.Lsetxattr(path, key, []byte(value), 0)
}

也就是说 v1.3.0 中不会设置以trusted.开头的 xattr!如果 tar 包中某目录带有trusted.overlay.opaque=y这个 PAX,低版本的 containerd 可能就会把这些属性设置到 snapshot 的目录上,而高版本的却不会。那么,当用户在打包时,如果把 opaque 也打到 tar 包中,解压得到的 layer 对应目录也就会带有这个属性。5104 这个目录可能就是这个原因才变成 opaque 的。

为了验证这个观点,我写了一段简单的程序来扫描与 layer 对应的 content 来寻找这个属性,结果发现 510251035104 几个层都没有这个属性。这时我也开始怀疑这个观点了,毕竟如果只是 tar 包中有特别的标识,应该不会在不同的操作系统表现不同。

抱着最后一丝希望扫描了 50995101,果然也并没有这个属性。但在扫描的过程中,注意到 5101 的 tar 包里存在 /data/asr_offline/modules/.wh..wh.opq 这个文件。记得当时看代码 applyNaive 时如果遇到了 .wh..wh.opq 对应的操作应该是在挂载点删除 /data/asr_offline/modules,而在 overlay 中删除 lower 目录会给 upper 同名目录加上trusted.overlay.opaque=y。也就是说,在生成 layer 5101 时(需要提前挂载好 51005099),遍历 tar 包遇到了这个 wh 文件,应该先在挂载点删除 modules,也就是会在 5101 对应目录加上 opaque=y。

再次以验证源代码成果的心态,去 snapshot 的 5101/fs 下查看目录 modules 的 opaque,果然和想象的一样。这些文件应该都是在 lower层,所以对应的 overlayfs 的操作应该是在 upper 也就是 5101 层的 /data/asr_offline/modules 目录设置trusted.overlay.opaque=y。去查看 5101 的这个目录,果然带有这个属性,好奇心驱使着我继续查看了 510251035104 这几层的目录,发现居然都有这个属性。

也就是这些 layer 每个都会把下面的覆盖掉?这好像不符合常理。于是,去表现正常的 ubuntu 中查看,发现只有 5101 有这个属性。经过反复确认 510251035104 的 tar 包中的确没有目录 modules 的 whiteout 文件,也就是说镜像原本的意图就是让 5101 把下面的层覆盖掉,再把 5101510251035104 这几层的 modules 目录 merge 起来。整个生成镜像的流程里,只有“借用”overlay 生成 snapshot 的 layer 会涉及到操作系统。

云开雾散,大胆猜探

我们不妨大胆猜测一下,会不会像下图这样,在生成 layer 5102 时,因为内核或 overlay 的 bug 把 modules 也添加了不透明的属性?

enter image description here

为了对这个特性做单独的测试,写了个简单的脚本。运行脚本之后,果然发现在这个发行版中,如果 overlay 的低层目录有这个属性并且在 upper 层中创建了同样的目录,会把这个 opaque“传播”到 upper 层的目录中。如果像 containerd 那样递推生成镜像,肯定从有 whiteout 层开始上面的每一层都会具有这个属性,也就导致了最终容器在某些特定的目录只能看到最上面一层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
`#!/bin/bash

mkdir 1 2 work p
mkdir 1/func
touch 1/func/min

mount -t overlay overlay p -o lowerdir=1,upperdir=2,workdir=work
rm -rf p/func
mkdir -p p/func
touch p/func/max
umount p
getfattr -n "trusted.overlay.opaque" 2/func

mkdir 3
mount -t overlay overlay p -o lowerdir=2:1,upperdir=3,workdir=work
touch p/func/sqrt
umount p
getfattr -n "trusted.overlay.opaque" 3/func`

最终总结

在几个内核大佬的帮助下,确认了是内核 overlayfs 模块的 bug。在 lower 层调用 copy_up 时并没有检测 xattr,从而导致 opaque 这个 xattr 传播到了 upper 层。做联合挂载时,如果上层的文件得到了这个属性,自然会把下层文件覆盖掉,也就出现了镜像中丢失文件的现象。反思整个排查过程,其实很难在一开始就把问题定位到内核的某个模块上,好在可以另辟蹊径通过测试和阅读源码逐步逼近“真相”,成功寻得解决方案。

大规模使用ConfigMap卷的负载分析及缓解方案

作者: 李波

简介

有客户反馈在大集群(几千节点)中大量使用ConfigMap卷时,会给集群带来很大负载和压力,这里我们分析下原因以及缓解方案。

Kubelet如何管理ConfigMap

我们先来看下Kubelet是如何管理ConfigMap的。

Kubelet在启动的时候,会创建ConfigMapManager(以及SecretManager),用来管理本机运行的Pod用到的ConfigMap(及Secret,下面只讨论ConfigMap)对象,功能包括获取及更新这些对象的内容,以及为其他组件比如VolumeManager提供获取这些对象内容的服务。

那Kubelet是如何获取和更新ConfigMap呢? k8s提供了三种检测资源更新的策略(ResourceChangeDetectionStrategy)

WatchChangeDetectionStrategy(Watch)

这是1.12+的默认策略。

看名字,这个策略使用K8s经典的ListWatch模式。在Pod创建时,对每个引用到的ConfigMap,都会先从ApiServer缓存(指定ResourceVersion=”0”)获取,然后对后续变化进行Watch。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// pkg/kubelet/util/manager/watch_based_manager.go
func (c *objectCache) newReflector(namespace, name string) *objectCacheItem {
fieldSelector := fields.Set{"metadata.name": name}.AsSelector().String()
listFunc := func(options metav1.ListOptions) (runtime.Object, error) {
options.FieldSelector = fieldSelector
return c.listObject(namespace, options)
}
watchFunc := func(options metav1.ListOptions) (watch.Interface, error) {
options.FieldSelector = fieldSelector
return c.watchObject(namespace, options)
}
store := c.newStore()
reflector := cache.NewNamedReflector(
fmt.Sprintf("object-%q/%q", namespace, name),
&cache.ListWatch{ListFunc: listFunc, WatchFunc: watchFunc},
c.newObject(),
store,
0,
)
...
...

重点强调下,是对每一个ConfigMap都会创建一个Watch。如果大量使用CongiMap,并且集群规模很大,假设平均每个节点有100个ConfigMap,集群有2000个节点,就会创建20w个watch。经过测试(测试结果如下图,20w个watch),单纯大量的watch会对ApiServer造成一定的内存压力,对Etcd则基本没有压力。

ListWatch压力测试

'ListWatch压力测试结果'

测试采用单节点的ApiServer(16核32G)和单节点的Etcd,并停止所有(共5个)节点kubelet服务以及删除所有非kube-system的负载,并把5个节点作为客户端,每个有间隔的发起4w个ListWatch。
从上图的测试结果,可以看到在20w ListWatch创建期间,ApiServer的内存增长到20G左右,CPU使用率在25%左右(创建完成后,使用率降回原来水平),连接数增持长并稳定到965个左右,而Etcd的内存,CPU核连接数无明显变化。粗略计算,每个watch占用100KB左右的内存。

TTLCacheChangeDetectionStrategy(Cache)

这是1.101.11版本的默认策略,且不可通过参数或者配置文件修改。

看名字,这是带TTL的缓存方式。第一次获取时,从ApiServer获取最新内容,超过TTL后,如果读取ConfigMap,会从ApiServer缓存获取(Get请求指定ResouceVersion=0)进行刷新,以减小对ApiServer和Etcd的压力。

1
2
3
4
5
6
7
8
9
10
11
12
// pkg/kubelet/util/manager/cache_based_manager.go
func (s *objectStore) Get(namespace, name string) (runtime.Object, error) {
...
if data.err != nil || !fresh {
klog.V(1).Infof("data is null or object is not fresh: err=%v, fresh=%v", fresh)
opts := metav1.GetOptions{}
if data.object != nil && data.err == nil {
util.FromApiserverCache(&opts) //opts.ResourceVersion = "0"
}

object, err := s.getObject(namespace, name, opts)
...

TTL时间首先会从节点的Annotation["node.alpha.kubernetes.io/ttl"]获取,如果节点没有设置,那么会使用默认值1分钟。

node.alpha.kubernetes.io/ttl由kube-controller-manager中的TTLController根据集群节点数自动设置,具体规则如下(例如100个节点及以下规模的集群,ttl是0s;随着集群规模变大,节点数大于100小于500时,节点ttl变为15s;当集群规模超过100又减小,少于90个节点时,节点的ttl又变回0s):

1
2
3
4
5
6
7
8
9
// pkg/controller/ttl/ttl_controller.go
ttlBoundaries = []ttlBoundary{
{sizeMin: 0, sizeMax: 100, ttlSeconds: 0},
{sizeMin: 90, sizeMax: 500, ttlSeconds: 15},
{sizeMin: 450, sizeMax: 1000, ttlSeconds: 30},
{sizeMin: 900, sizeMax: 2000, ttlSeconds: 60},
{sizeMin: 1800, sizeMax: 10000, ttlSeconds: 300},
{sizeMin: 9000, sizeMax: math.MaxInt32, ttlSeconds: 600},
}

GetChangeDetectionStrategy(Get)

这是最简单直接粗暴的方式,每次获取ConfigMap时,都访问ApiServer从Etcd读取最新版本。

1
2
3
4
// pkg/kubelet/configmap/configmap_manager.go
func (s *simpleConfigMapManager) GetConfigMap(namespace, name string) (*v1.ConfigMap, error) {
return s.kubeClient.CoreV1().ConfigMaps(namespace).Get(name, metav1.GetOptions{})
}

ConfigMap卷的自动更新机制

Kubelet在Pod创建成功后,会把Pod放到podWorker的工作队列,并指定延迟1分钟(--sync-frequency,默认1m)才能出队列被获取。
Kubelet在sync逻辑中,会在延迟过后取到Pod进行同步,包括同步Volume状态。VolumeManager在同步Volume时会看它的类型是否需要重新挂载(RequiresRemount() bool),ConfigMapSecretdownwardAPIProjected四种VolumePlugin,这个方法都返回true,需要重新挂载。

因此每隔1分钟多,Kubelet都会访问ConfigMapManager,去获取本机Pod使用的ConfigMap的最新内容。这个操作对于Watch类型的策略,没有影响,不会对ApiServer及Etcd带来额外的压力;对于ttl很小的Cache及Get类型的策略,会给ApiServer及Etcd带来压力。

大集群方案

从上面的分析看,一般小规模的集群或者ConfigMap(及Secret)用量不大的集群,可以使用默认的Watch策略。如果集群规模比较大,并且大量使用ConfigMap,默认的Watch策略会对ApiServer带来内存压力。在实际生产集群,ApiServer除了处理这些watch,还会执行很多其他任务,相互之间共享抢占系统资源,会加重和放大对ApiServer的负载,影响服务。

同时,实际上我们很多应用并不需要通过修改ConfigMap动态更新配置的功能,一方面在大集群时会带来不必要的压力,另一方面,如1.18的这个KEP所考虑的,实时更新ConfigMap或者Secret,如果内容出现错误,会导致应用异常,在配置发生变化时,更推荐采用滚动更新的方式来更新应用。

在大集群时,我们可以怎么使用和管理ConfigMap,来减轻对集群的负载压力呢?

1.18版本

社区也注意到了这个问题(刚才提到的KEP),增加了一个新的特性ImmutableEphemeralVolumes,允许用户设置ConfigMap(及Secrets)为不可变(immutable: true),这样Kubelet就不会去Watch这些ConfigMap的变化了。

1
2
3
4
5
6
7
...
if utilfeature.DefaultFeatureGate.Enabled(features.ImmutableEphemeralVolumes) && c.isImmutable(object) {
if item.stop() {
klog.V(4).Infof("Stopped watching for changes of %q/%q - object is immutable", namespace, name)
}
}
...

开启ImmutableEphemeralVolumes

ImmutableEphemeralVolumes是alpha特性,需要设置kubelet参数开启它:

1
--feature-gates=ImmutableEphemeralVolumes=true

ConfigMap设置为不可变

ConfigMap设置immutable为true

1
2
3
4
5
6
7
apiVersion: v1
kind: ConfigMap
metadata:
name: immutable-cm
data:
name: tencent
immutable: true

之前版本

在1.18之前的版本,我们可以使用Cache策略来代替Watch

  1. 关闭TTLController: kube-controller-manager启动参数增加 --controllers=-ttl,*,重启。

  2. 配置所有节点Kubelet使用Cache策略:

    • 创建/etc/kubernetes/kubelet.conf,内容如下:
1
2
3
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
configMapAndSecretChangeDetectionStrategy: Cache
  • kubelet增加参数: --config=/etc/kubernetes/kubelet.conf,重启
    1. 设置所有节点的ttl为期望值,比如1000天: kubectl annotate node <node> node.alpha.kubernetes.io/ttl=86400000 --overwrite
      。设置1000天并不是1000天内真的不更新。在Kubelet新建Pod时,它所引用的ConfigMap的cache都会被重置和更新。

打造云原生大型分布式监控系统(三): Thanos 部署与实践

作者: 陈鹏

视频

附上本系列完整视频

概述

上一篇 Thanos 架构详解 我们深入理解了 thanos 的架构设计与实现原理,现在我们来聊聊实战,分享一下如何部署和使用 Thanos。

部署方式

本文聚焦 Thanos 的云原生部署方式,充分利用 Kubernetes 的资源调度与动态扩容能力。从官方 这里 可以看到,当前 thanos 在 Kubernetes 上部署有以下三种:

  • prometheus-operator: 集群中安装了 prometheus-operator 后,就可以通过创建 CRD 对象来部署 Thanos 了。
  • 社区贡献的一些 helm charts: 很多个版本,目标都是能够使用 helm 来一键部署 thanos。
  • kube-thanos: Thanos 官方的开源项目,包含部署 thanos 到 kubernetes 的 jsonnet 模板与 yaml 示例。

本文将使用基于 kube-thanos 提供的 yaml 示例 (examples/all/manifests) 来部署,原因是 prometheus-operator 与社区的 helm chart 方式部署多了一层封装,屏蔽了许多细节,并且它们的实现都还不太成熟;直接使用 kubernetes 的 yaml 资源文件部署更直观,也更容易做自定义,而且我相信使用 thanos 的用户通常都是高玩了,也有必要对 thanos 理解透彻,日后才好根据实际场景做架构和配置的调整,直接使用 yaml 部署能够让我们看清细节。

方案选型

Sidecar or Receiver

看了上一篇文章的同学应该知道,目前官方的架构图用的 Sidecar 方案,Receiver 是一个暂时还没有完全发布的组件。通常来说,Sidecar 方案相对成熟一些,最新的数据存储和计算 (比如聚合函数) 比较 “分布式”,更加高效也更容易扩展。

Receiver 方案是让 Prometheus 通过 remote wirte API 将数据 push 到 Receiver 集中存储 (同样会清理过期数据):

那么该选哪种方案呢?我的建议是:

  1. 如果你的 Query 跟 Sidecar 离的比较远,比如 Sidecar 分布在多个数据中心,Query 向所有 Sidecar 查数据,速度会很慢,这种情况可以考虑用 Receiver,将数据集中吐到 Receiver,然后 Receiver 与 Query 部署在一起,Query 直接向 Receiver 查最新数据,提升查询性能。
  2. 如果你的使用场景只允许 Prometheus 将数据 push 到远程,可以考虑使用 Receiver。比如 IoT 设备没有持久化存储,只能将数据 push 到远程。

此外的场景应该都尽量使用 Sidecar 方案。

评估是否需要 Ruler

Ruler 是一个可选组件,原则上推荐尽量使用 Prometheus 自带的 rule 功能 (生成新指标+告警),这个功能需要一些 Prometheus 最新数据,直接使用 Prometheus 本机 rule 功能和数据,性能开销相比 Thanos Ruler 这种分布式方案小得多,并且几乎不会出错,Thanos Ruler 由于是分布式,所以更容易出错一些。

如果某些有关联的数据分散在多个不同 Prometheus 上,比如对某个大规模服务采集做了分片,每个 Prometheus 仅采集一部分 endpoint 的数据,对于 record 类型的 rule (生成的新指标),还是可以使用 Prometheus 自带的 rule 功能,在查询时再聚合一下就可以(如果可以接受的话);对于 alert 类型的 rule,就需要用 Thanos Ruler 来做了,因为有关联的数据分散在多个 Prometheus 上,用单机数据去做 alert 计算是不准确的,就可能会造成误告警或不告警。

评估是否需要 Store Gateway 与 Compact

Store 也是一个可选组件,也是 Thanos 的一大亮点的关键:数据长期保存。

评估是否需要 Store 组件实际就是评估一下自己是否有数据长期存储的需求,比如查看一两个月前的监控数据。如果有,那么 Thanos 可以将数据上传到对象存储保存。Thanos 支持以下对象存储:

  • Google Cloud Storage
  • AWS/S3
  • Azure Storage Account
  • OpenStack Swift
  • Tencent COS
  • AliYun OSS

在国内,最方便还是使用腾讯云 COS 或者阿里云 OSS 这样的公有云对象存储服务。如果你的服务没有跑在公有云上,也可以通过跟云服务厂商拉专线的方式来走内网使用对象存储,这样速度通常也是可以满足需求的;如果实在用不了公有云的对象存储服务,也可以自己安装 minio 来搭建兼容 AWS 的 S3 对象存储服务。

搞定了对象存储,还需要给 Thanos 多个组件配置对象存储相关的信息,以便能够上传与读取监控数据。除 Query 以外的所有 Thanos 组件 (Sidecar、Receiver、Ruler、Store Gateway、Compact) 都需要配置对象存储信息,使用 --objstore.config 直接配置内容或 --objstore.config-file 引用对象存储配置文件,不同对象存储配置方式不一样,参考官方文档: https://thanos.io/storage.md

通常使用了对象存储来长期保存数据不止要安装 Store Gateway,还需要安装 Compact 来对对象存储里的数据进行压缩与降采样,这样可以提升查询大时间范围监控数据的性能。注意:Compact 并不会减少对象存储的使用空间,而是会增加,增加更长采样间隔的监控数据,这样当查询大时间范围的数据时,就自动拉取更长时间间隔采样的数据以减少查询数据的总量,从而加快查询速度 (大时间范围的数据不需要那么精细),当放大查看时 (选择其中一小段时间),又自动选择拉取更短采样间隔的数据,从而也能显示出小时间范围的监控细节。

部署实践

这里以 Thanos 最新版本为例,选择 Sidecar 方案,介绍各个组件的 k8s yaml 定义方式并解释一些重要细节 (根据自身需求,参考上一节的方案选型,自行评估需要安装哪些组件)。

准备对象存储配置

如果我们要使用对象存储来长期保存数据,那么就要准备下对象存储的配置信息 (thanos-objectstorage-secret.yaml),比如使用腾讯云 COS 来存储:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
apiVersion: v1
kind: Secret
metadata:
name: thanos-objectstorage
namespace: thanos
type: Opaque
stringData:
objectstorage.yaml: |
type: COS
config:
bucket: "thanos"
region: "ap-singapore"
app_id: "12*******5"
secret_key: "tsY***************************Edm"
secret_id: "AKI******************************gEY"

或者使用阿里云 OSS 存储:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: v1
kind: Secret
metadata:
name: thanos-objectstorage
namespace: thanos
type: Opaque
stringData:
objectstorage.yaml: |
type: ALIYUNOSS
config:
endpoint: "oss-cn-hangzhou-internal.aliyuncs.com"
bucket: "thanos"
access_key_id: "LTA******************KBu"
access_key_secret: "oki************************2HQ"

注: 对敏感信息打码了

给 Prometheus 加上 Sidecar

如果选用 Sidecar 方案,就需要给 Prometheus 加上 Thanos Sidecar,准备 prometheus.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
kind: Service
apiVersion: v1
metadata:
name: prometheus-headless
namespace: thanos
labels:
app.kubernetes.io/name: prometheus
spec:
type: ClusterIP
clusterIP: None
selector:
app.kubernetes.io/name: prometheus
ports:
- name: web
protocol: TCP
port: 9090
targetPort: web
- name: grpc
port: 10901
targetPort: grpc
---

apiVersion: v1
kind: ServiceAccount
metadata:
name: prometheus
namespace: thanos

---

apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRole
metadata:
name: prometheus
namespace: thanos
rules:
- apiGroups: [""]
resources:
- nodes
- nodes/proxy
- nodes/metrics
- services
- endpoints
- pods
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get"]
- nonResourceURLs: ["/metrics"]
verbs: ["get"]

---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
name: prometheus
subjects:
- kind: ServiceAccount
name: prometheus
namespace: thanos
roleRef:
kind: ClusterRole
name: prometheus
apiGroup: rbac.authorization.k8s.io
---

apiVersion: apps/v1
kind: StatefulSet
metadata:
name: prometheus
namespace: thanos
labels:
app.kubernetes.io/name: thanos-query
spec:
serviceName: prometheus-headless
podManagementPolicy: Parallel
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: prometheus
template:
metadata:
labels:
app.kubernetes.io/name: prometheus
spec:
serviceAccountName: prometheus
securityContext:
fsGroup: 2000
runAsNonRoot: true
runAsUser: 1000
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- prometheus
topologyKey: kubernetes.io/hostname
containers:
- name: prometheus
image: quay.io/prometheus/prometheus:v2.15.2
args:
- --config.file=/etc/prometheus/config_out/prometheus.yaml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=10d
- --web.route-prefix=/
- --web.enable-lifecycle
- --storage.tsdb.no-lockfile
- --storage.tsdb.min-block-duration=2h
- --storage.tsdb.max-block-duration=2h
- --log.level=debug
ports:
- containerPort: 9090
name: web
protocol: TCP
livenessProbe:
failureThreshold: 6
httpGet:
path: /-/healthy
port: web
scheme: HTTP
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 3
readinessProbe:
failureThreshold: 120
httpGet:
path: /-/ready
port: web
scheme: HTTP
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 3
volumeMounts:
- mountPath: /etc/prometheus/config_out
name: prometheus-config-out
readOnly: true
- mountPath: /prometheus
name: prometheus-storage
- mountPath: /etc/prometheus/rules
name: prometheus-rules
- name: thanos
image: quay.io/thanos/thanos:v0.11.0
args:
- sidecar
- --log.level=debug
- --tsdb.path=/prometheus
- --prometheus.url=http://127.0.0.1:9090
- --objstore.config-file=/etc/thanos/objectstorage.yaml
- --reloader.config-file=/etc/prometheus/config/prometheus.yaml.tmpl
- --reloader.config-envsubst-file=/etc/prometheus/config_out/prometheus.yaml
- --reloader.rule-dir=/etc/prometheus/rules/
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
ports:
- name: http-sidecar
containerPort: 10902
- name: grpc
containerPort: 10901
livenessProbe:
httpGet:
port: 10902
path: /-/healthy
readinessProbe:
httpGet:
port: 10902
path: /-/ready
volumeMounts:
- name: prometheus-config-tmpl
mountPath: /etc/prometheus/config
- name: prometheus-config-out
mountPath: /etc/prometheus/config_out
- name: prometheus-rules
mountPath: /etc/prometheus/rules
- name: prometheus-storage
mountPath: /prometheus
- name: thanos-objectstorage
subPath: objectstorage.yaml
mountPath: /etc/thanos/objectstorage.yaml
volumes:
- name: prometheus-config-tmpl
configMap:
defaultMode: 420
name: prometheus-config-tmpl
- name: prometheus-config-out
emptyDir: {}
- name: prometheus-rules
configMap:
name: prometheus-rules
- name: thanos-objectstorage
secret:
secretName: thanos-objectstorage
volumeClaimTemplates:
- metadata:
name: prometheus-storage
labels:
app.kubernetes.io/name: prometheus
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 200Gi
volumeMode: Filesystem
  • Prometheus 使用 StatefulSet 方式部署,挂载数据盘以便存储最新监控数据。
  • 由于 Prometheus 副本之间没有启动顺序的依赖,所以 podManagementPolicy 指定为 Parallel,加快启动速度。
  • 为 Prometheus 绑定足够的 RBAC 权限,以便后续配置使用 k8s 的服务发现 (kubernetes_sd_configs) 时能够正常工作。
  • 为 Prometheus 创建 headless 类型 service,为后续 Thanos Query 通过 DNS SRV 记录来动态发现 Sidecar 的 gRPC 端点做准备 (使用 headless service 才能让 DNS SRV 正确返回所有端点)。
  • 使用两个 Prometheus 副本,用于实现高可用。
  • 使用硬反亲和,避免 Prometheus 部署在同一节点,既可以分散压力也可以避免单点故障。
  • Prometheus 使用 --storage.tsdb.retention.time 指定数据保留时长,默认15天,可以根据数据增长速度和数据盘大小做适当调整(数据增长取决于采集的指标和目标端点的数量和采集频率)。
  • Sidecar 使用 --objstore.config-file 引用我们刚刚创建并挂载的对象存储配置文件,用于上传数据到对象存储。
  • 通常会给 Prometheus 附带一个 quay.io/coreos/prometheus-config-reloader 来监听配置变更并动态加载,但 thanos sidecar 也为我们提供了这个功能,所以可以直接用 thanos sidecar 来实现此功能,也支持配置文件根据模板动态生成:--reloader.config-file 指定 Prometheus 配置文件模板,--reloader.config-envsubst-file 指定生成配置文件的存放路径,假设是 /etc/prometheus/config_out/prometheus.yaml ,那么 /etc/prometheus/config_out 这个路径使用 emptyDir 让 Prometheus 与 Sidecar 实现配置文件共享挂载,Prometheus 再通过 --config.file 指定生成出来的配置文件,当配置有更新时,挂载的配置文件也会同步更新,Sidecar 也会通知 Prometheus 重新加载配置。另外,Sidecar 与 Prometheus 也挂载同一份 rules 配置文件,配置更新后 Sidecar 仅通知 Prometheus 加载配置,不支持模板,因为 rules 配置不需要模板来动态生成。

然后再给 Prometheus 准备配置 (prometheus-config.yaml):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-config-tmpl
namespace: thanos
data:
prometheus.yaml.tmpl: |-
global:
scrape_interval: 5s
evaluation_interval: 5s
external_labels:
cluster: prometheus-ha
prometheus_replica: $(POD_NAME)
rule_files:
- /etc/prometheus/rules/*rules.yaml
scrape_configs:
- job_name: cadvisor
metrics_path: /metrics/cadvisor
scrape_interval: 10s
scrape_timeout: 10s
scheme: https
tls_config:
insecure_skip_verify: true
bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token
kubernetes_sd_configs:
- role: node
relabel_configs:
- action: labelmap
regex: __meta_kubernetes_node_label_(.+)
---

apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-rules
labels:
name: prometheus-rules
namespace: thanos
data:
alert-rules.yaml: |-
groups:
- name: k8s.rules
rules:
- expr: |
sum(rate(container_cpu_usage_seconds_total{job="cadvisor", image!="", container!=""}[5m])) by (namespace)
record: namespace:container_cpu_usage_seconds_total:sum_rate
- expr: |
sum(container_memory_usage_bytes{job="cadvisor", image!="", container!=""}) by (namespace)
record: namespace:container_memory_usage_bytes:sum
- expr: |
sum by (namespace, pod, container) (
rate(container_cpu_usage_seconds_total{job="cadvisor", image!="", container!=""}[5m])
)
record: namespace_pod_container:container_cpu_usage_seconds_total:sum_rate
  • 本文重点不在 prometheus 的配置文件,所以这里仅以采集 kubelet 所暴露的 cadvisor 容器指标的简单配置为例。
  • Prometheus 实例采集的所有指标数据里都会额外加上 external_labels 里指定的 label,通常用 cluster 区分当前 Prometheus 所在集群的名称,我们再加了个 prometheus_replica,用于区分相同 Prometheus 副本(这些副本所采集的数据除了 prometheus_replica 的值不一样,其它几乎一致,这个值会被 Thanos Sidecar 替换成 Pod 副本的名称,用于 Thanos 实现 Prometheus 高可用)

安装 Query

准备 thanos-query.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
apiVersion: v1
kind: Service
metadata:
name: thanos-query
namespace: thanos
labels:
app.kubernetes.io/name: thanos-query
spec:
ports:
- name: grpc
port: 10901
targetPort: grpc
- name: http
port: 9090
targetPort: http
selector:
app.kubernetes.io/name: thanos-query
---

apiVersion: apps/v1
kind: Deployment
metadata:
name: thanos-query
namespace: thanos
labels:
app.kubernetes.io/name: thanos-query
spec:
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: thanos-query
template:
metadata:
labels:
app.kubernetes.io/name: thanos-query
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- podAffinityTerm:
labelSelector:
matchExpressions:
- key: app.kubernetes.io/name
operator: In
values:
- thanos-query
topologyKey: kubernetes.io/hostname
weight: 100
containers:
- args:
- query
- --log.level=debug
- --query.auto-downsampling
- --grpc-address=0.0.0.0:10901
- --http-address=0.0.0.0:9090
- --query.partial-response
- --query.replica-label=prometheus_replica
- --query.replica-label=rule_replica
- --store=dnssrv+_grpc._tcp.prometheus-headless.thanos.svc.cluster.local
- --store=dnssrv+_grpc._tcp.thanos-rule.thanos.svc.cluster.local
- --store=dnssrv+_grpc._tcp.thanos-store.thanos.svc.cluster.local
image: thanosio/thanos:v0.11.0
livenessProbe:
failureThreshold: 4
httpGet:
path: /-/healthy
port: 9090
scheme: HTTP
periodSeconds: 30
name: thanos-query
ports:
- containerPort: 10901
name: grpc
- containerPort: 9090
name: http
readinessProbe:
failureThreshold: 20
httpGet:
path: /-/ready
port: 9090
scheme: HTTP
periodSeconds: 5
terminationMessagePolicy: FallbackToLogsOnError
terminationGracePeriodSeconds: 120
  • 因为 Query 是无状态的,使用 Deployment 部署,也不需要 headless service,直接创建普通的 service。
  • 使用软反亲和,尽量不让 Query 调度到同一节点。
  • 部署多个副本,实现 Query 的高可用。
  • --query.partial-response 启用 Partial Response,这样可以在部分后端 Store API 返回错误或超时的情况下也能看到正确的监控数据(如果后端 Store API 做了高可用,挂掉一个副本,Query 访问挂掉的副本超时,但由于还有没挂掉的副本,还是能正确返回结果;如果挂掉的某个后端本身就不存在我们需要的数据,挂掉也不影响结果的正确性;总之如果各个组件都做了高可用,想获得错误的结果都难,所以我们有信心启用 Partial Response 这个功能)。
  • --query.auto-downsampling 查询时自动降采样,提升查询效率。
  • --query.replica-label 指定我们刚刚给 Prometheus 配置的 prometheus_replica 这个 external label,Query 向 Sidecar 拉取 Prometheus 数据时会识别这个 label 并自动去重,这样即使挂掉一个副本,只要至少有一个副本正常也不会影响查询结果,也就是可以实现 Prometheus 的高可用。同理,再指定一个 rule_replica 用于给 Ruler 做高可用。
  • --store 指定实现了 Store API 的地址(Sidecar, Ruler, Store Gateway, Receiver),通常不建议写静态地址,而是使用服务发现机制自动发现 Store API 地址,如果是部署在同一个集群,可以用 DNS SRV 记录来做服务发现,比如 dnssrv+_grpc._tcp.prometheus-headless.thanos.svc.cluster.local,也就是我们刚刚为包含 Sidecar 的 Prometheus 创建的 headless service (使用 headless service 才能正确实现服务发现),并且指定了名为 grpc 的 tcp 端口,同理,其它组件也可以按照这样加到 --store 参数里;如果是其它有些组件部署在集群外,无法通过集群 dns 解析 DNS SRV 记录,可以使用配置文件来做服务发现,也就是指定 --store.sd-files 参数,将其它 Store API 地址写在配置文件里 (挂载 ConfigMap),需要增加地址时直接更新 ConfigMap (不需要重启 Query)。

安装 Store Gateway

准备 thanos-store.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
apiVersion: v1
kind: Service
metadata:
name: thanos-store
namespace: thanos
labels:
app.kubernetes.io/name: thanos-store
spec:
clusterIP: None
ports:
- name: grpc
port: 10901
targetPort: 10901
- name: http
port: 10902
targetPort: 10902
selector:
app.kubernetes.io/name: thanos-store
---

apiVersion: apps/v1
kind: StatefulSet
metadata:
name: thanos-store
namespace: thanos
labels:
app.kubernetes.io/name: thanos-store
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: thanos-store
serviceName: thanos-store
podManagementPolicy: Parallel
template:
metadata:
labels:
app.kubernetes.io/name: thanos-store
spec:
containers:
- args:
- store
- --log.level=debug
- --data-dir=/var/thanos/store
- --grpc-address=0.0.0.0:10901
- --http-address=0.0.0.0:10902
- --objstore.config-file=/etc/thanos/objectstorage.yaml
- --experimental.enable-index-header
image: thanosio/thanos:v0.11.0
livenessProbe:
failureThreshold: 8
httpGet:
path: /-/healthy
port: 10902
scheme: HTTP
periodSeconds: 30
name: thanos-store
ports:
- containerPort: 10901
name: grpc
- containerPort: 10902
name: http
readinessProbe:
failureThreshold: 20
httpGet:
path: /-/ready
port: 10902
scheme: HTTP
periodSeconds: 5
terminationMessagePolicy: FallbackToLogsOnError
volumeMounts:
- mountPath: /var/thanos/store
name: data
readOnly: false
- name: thanos-objectstorage
subPath: objectstorage.yaml
mountPath: /etc/thanos/objectstorage.yaml
terminationGracePeriodSeconds: 120
volumes:
- name: thanos-objectstorage
secret:
secretName: thanos-objectstorage
volumeClaimTemplates:
- metadata:
labels:
app.kubernetes.io/name: thanos-store
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
  • Store Gateway 实际也可以做到一定程度的无状态,它会需要一点磁盘空间来对对象存储做索引以加速查询,但数据不那么重要,是可以删除的,删除后会自动去拉对象存储查数据重新建立索引。这里我们避免每次重启都重新建立索引,所以用 StatefulSet 部署 Store Gateway,挂载一块小容量的磁盘(索引占用不到多大空间)。
  • 同样创建 headless service,用于 Query 对 Store Gateway 进行服务发现。
  • 部署两个副本,实现 Store Gateway 的高可用。
  • Store Gateway 也需要对象存储的配置,用于读取对象存储的数据,所以要挂载对象存储的配置文件。

安装 Ruler

准备 Ruler 部署配置 thanos-ruler.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
apiVersion: v1
kind: Service
metadata:
labels:
app.kubernetes.io/name: thanos-rule
name: thanos-rule
namespace: thanos
spec:
clusterIP: None
ports:
- name: grpc
port: 10901
targetPort: grpc
- name: http
port: 10902
targetPort: http
selector:
app.kubernetes.io/name: thanos-rule
---

apiVersion: apps/v1
kind: StatefulSet
metadata:
labels:
app.kubernetes.io/name: thanos-rule
name: thanos-rule
namespace: thanos
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: thanos-rule
serviceName: thanos-rule
podManagementPolicy: Parallel
template:
metadata:
labels:
app.kubernetes.io/name: thanos-rule
spec:
containers:
- args:
- rule
- --grpc-address=0.0.0.0:10901
- --http-address=0.0.0.0:10902
- --rule-file=/etc/thanos/rules/*rules.yaml
- --objstore.config-file=/etc/thanos/objectstorage.yaml
- --data-dir=/var/thanos/rule
- --label=rule_replica="$(NAME)"
- --alert.label-drop="rule_replica"
- --query=dnssrv+_http._tcp.thanos-query.thanos.svc.cluster.local
env:
- name: NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
image: thanosio/thanos:v0.11.0
livenessProbe:
failureThreshold: 24
httpGet:
path: /-/healthy
port: 10902
scheme: HTTP
periodSeconds: 5
name: thanos-rule
ports:
- containerPort: 10901
name: grpc
- containerPort: 10902
name: http
readinessProbe:
failureThreshold: 18
httpGet:
path: /-/ready
port: 10902
scheme: HTTP
initialDelaySeconds: 10
periodSeconds: 5
terminationMessagePolicy: FallbackToLogsOnError
volumeMounts:
- mountPath: /var/thanos/rule
name: data
readOnly: false
- name: thanos-objectstorage
subPath: objectstorage.yaml
mountPath: /etc/thanos/objectstorage.yaml
- name: thanos-rules
mountPath: /etc/thanos/rules
volumes:
- name: thanos-objectstorage
secret:
secretName: thanos-objectstorage
- name: thanos-rules
configMap:
name: thanos-rules
volumeClaimTemplates:
- metadata:
labels:
app.kubernetes.io/name: thanos-rule
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 100Gi
  • Ruler 是有状态服务,使用 Statefulset 部署,挂载磁盘以便存储根据 rule 配置计算出的新数据。
  • 同样创建 headless service,用于 Query 对 Ruler 进行服务发现。
  • 部署两个副本,且使用 --label=rule_replica= 给所有数据添加 rule_replica 的 label (与 Query 配置的 replica_label 相呼应),用于实现 Ruler 高可用。同时指定 --alert.label-droprule_replica,在触发告警发送通知给 AlertManager 时,去掉这个 label,以便让 AlertManager 自动去重 (避免重复告警)。
  • 使用 --query 指定 Query 地址,这里还是用 DNS SRV 来做服务发现,但效果跟配 dns+thanos-query.thanos.svc.cluster.local:9090 是一样的,最终都是通过 Query 的 ClusterIP (VIP) 访问,因为它是无状态的,可以直接由 K8S 来给我们做负载均衡。
  • Ruler 也需要对象存储的配置,用于上传计算出的数据到对象存储,所以要挂载对象存储的配置文件。
  • --rule-file 指定挂载的 rule 配置,Ruler 根据配置来生成数据和触发告警。

再准备 Ruler 配置文件 thanos-ruler-config.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apiVersion: v1
kind: ConfigMap
metadata:
name: thanos-rules
labels:
name: thanos-rules
namespace: thanos
data:
record.rules.yaml: |-
groups:
- name: k8s.rules
rules:
- expr: |
sum(rate(container_cpu_usage_seconds_total{job="cadvisor", image!="", container!=""}[5m])) by (namespace)
record: namespace:container_cpu_usage_seconds_total:sum_rate
- expr: |
sum(container_memory_usage_bytes{job="cadvisor", image!="", container!=""}) by (namespace)
record: namespace:container_memory_usage_bytes:sum
- expr: |
sum by (namespace, pod, container) (
rate(container_cpu_usage_seconds_total{job="cadvisor", image!="", container!=""}[5m])
)
record: namespace_pod_container:container_cpu_usage_seconds_total:sum_rate

安装 Compact

准备 Compact 部署配置 thanos-compact.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
apiVersion: v1
kind: Service
metadata:
labels:
app.kubernetes.io/name: thanos-compact
name: thanos-compact
namespace: thanos
spec:
ports:
- name: http
port: 10902
targetPort: http
selector:
app.kubernetes.io/name: thanos-compact
---

apiVersion: apps/v1
kind: StatefulSet
metadata:
labels:
app.kubernetes.io/name: thanos-compact
name: thanos-compact
namespace: thanos
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: thanos-compact
serviceName: thanos-compact
template:
metadata:
labels:
app.kubernetes.io/name: thanos-compact
spec:
containers:
- args:
- compact
- --wait
- --objstore.config-file=/etc/thanos/objectstorage.yaml
- --data-dir=/var/thanos/compact
- --debug.accept-malformed-index
- --log.level=debug
- --retention.resolution-raw=90d
- --retention.resolution-5m=180d
- --retention.resolution-1h=360d
image: thanosio/thanos:v0.11.0
livenessProbe:
failureThreshold: 4
httpGet:
path: /-/healthy
port: 10902
scheme: HTTP
periodSeconds: 30
name: thanos-compact
ports:
- containerPort: 10902
name: http
readinessProbe:
failureThreshold: 20
httpGet:
path: /-/ready
port: 10902
scheme: HTTP
periodSeconds: 5
terminationMessagePolicy: FallbackToLogsOnError
volumeMounts:
- mountPath: /var/thanos/compact
name: data
readOnly: false
- name: thanos-objectstorage
subPath: objectstorage.yaml
mountPath: /etc/thanos/objectstorage.yaml
terminationGracePeriodSeconds: 120
volumes:
- name: thanos-objectstorage
secret:
secretName: thanos-objectstorage
volumeClaimTemplates:
- metadata:
labels:
app.kubernetes.io/name: thanos-compact
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 100Gi
  • Compact 只能部署单个副本,因为如果多个副本都去对对象存储的数据做压缩和降采样的话,会造成冲突。
  • 使用 StatefulSet 部署,方便自动创建和挂载磁盘。磁盘用于存放临时数据,因为 Compact 需要一些磁盘空间来存放数据处理过程中产生的中间数据。
  • --wait 让 Compact 一直运行,轮询新数据来做压缩和降采样。
  • Compact 也需要对象存储的配置,用于读取对象存储数据以及上传压缩和降采样后的数据到对象存储。
  • 创建一个普通 service,主要用于被 Prometheus 使用 kubernetes 的 endpoints 服务发现来采集指标(其它组件的 service 也一样有这个用途)。
  • --retention.resolution-raw 指定原始数据存放时长,--retention.resolution-5m 指定降采样到数据点 5 分钟间隔的数据存放时长,--retention.resolution-1h 指定降采样到数据点 1 小时间隔的数据存放时长,它们的数据精细程度递减,占用的存储空间也是递减,通常建议它们的存放时间递增配置 (一般只有比较新的数据才会放大看,久远的数据通常只会使用大时间范围查询来看个大致,所以建议将精细程度低的数据存放更长时间)

安装 Receiver

该组件处于试验阶段,慎用。准备 Receiver 部署配置 thanos-receiver.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
apiVersion: v1
kind: ConfigMap
metadata:
name: thanos-receive-hashrings
namespace: thanos
data:
thanos-receive-hashrings.json: |
[
{
"hashring": "soft-tenants",
"endpoints":
[
"thanos-receive-0.thanos-receive.kube-system.svc.cluster.local:10901",
"thanos-receive-1.thanos-receive.kube-system.svc.cluster.local:10901",
"thanos-receive-2.thanos-receive.kube-system.svc.cluster.local:10901"
]
}
]
---

apiVersion: v1
kind: Service
metadata:
name: thanos-receive
namespace: thanos
labels:
kubernetes.io/name: thanos-receive
spec:
ports:
- name: http
port: 10902
protocol: TCP
targetPort: 10902
- name: remote-write
port: 19291
protocol: TCP
targetPort: 19291
- name: grpc
port: 10901
protocol: TCP
targetPort: 10901
selector:
kubernetes.io/name: thanos-receive
clusterIP: None
---

apiVersion: apps/v1
kind: StatefulSet
metadata:
labels:
kubernetes.io/name: thanos-receive
name: thanos-receive
namespace: thanos
spec:
replicas: 3
selector:
matchLabels:
kubernetes.io/name: thanos-receive
serviceName: thanos-receive
template:
metadata:
labels:
kubernetes.io/name: thanos-receive
spec:
containers:
- args:
- receive
- --grpc-address=0.0.0.0:10901
- --http-address=0.0.0.0:10902
- --remote-write.address=0.0.0.0:19291
- --objstore.config-file=/etc/thanos/objectstorage.yaml
- --tsdb.path=/var/thanos/receive
- --tsdb.retention=12h
- --label=receive_replica="$(NAME)"
- --label=receive="true"
- --receive.hashrings-file=/etc/thanos/thanos-receive-hashrings.json
- --receive.local-endpoint=$(NAME).thanos-receive.thanos.svc.cluster.local:10901
env:
- name: NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
image: thanosio/thanos:v0.11.0
livenessProbe:
failureThreshold: 4
httpGet:
path: /-/healthy
port: 10902
scheme: HTTP
periodSeconds: 30
name: thanos-receive
ports:
- containerPort: 10901
name: grpc
- containerPort: 10902
name: http
- containerPort: 19291
name: remote-write
readinessProbe:
httpGet:
path: /-/ready
port: 10902
scheme: HTTP
initialDelaySeconds: 10
periodSeconds: 30
resources:
limits:
cpu: "4"
memory: 8Gi
requests:
cpu: "2"
memory: 4Gi
volumeMounts:
- mountPath: /var/thanos/receive
name: data
readOnly: false
- mountPath: /etc/thanos/thanos-receive-hashrings.json
name: thanos-receive-hashrings
subPath: thanos-receive-hashrings.json
- mountPath: /etc/thanos/objectstorage.yaml
name: thanos-objectstorage
subPath: objectstorage.yaml
terminationGracePeriodSeconds: 120
volumes:
- configMap:
defaultMode: 420
name: thanos-receive-hashrings
name: thanos-receive-hashrings
- name: thanos-objectstorage
secret:
secretName: thanos-objectstorage
volumeClaimTemplates:
- metadata:
labels:
app.kubernetes.io/name: thanos-receive
name: data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 200Gi
  • 部署 3 个副本, 配置 hashring, --label=receive_replica 为数据添加 receive_replica 这个 label (Query 的 --query.replica-label 也要加上这个) 来实现 Receiver 的高可用。
  • Query 要指定 Receiver 后端地址: --store=dnssrv+_grpc._tcp.thanos-receive.thanos.svc.cluster.local
  • request, limit 根据自身规模情况自行做适当调整。
  • --tsdb.retention 根据自身需求调整最新数据的保留时间。
  • 如果改命名空间,记得把 Receiver 的 --receive.local-endpoint 参数也改下,不然会疯狂报错直至 OOMKilled。

因为使用了 Receiver 来统一接收 Prometheus 的数据,所以 Prometheus 也不需要 Sidecar 了,但需要给 Prometheus 配置文件里加下 remote_write,让 Prometheus 将数据 push 给 Receiver:

1
2
remote_write:
- url: http://thanos-receive.thanos.svc.cluster.local:19291/api/v1/receive

指定 Query 为数据源

查询监控数据时需要指定 Prometheus 数据源地址,由于我们使用了 Thanos 来做分布式,而 Thanos 关键查询入口就是 Query,所以我们需要将数据源地址指定为 Query 的地址,假如使用 Grafana 查询,进入 Configuration-Data Sources-Add data source,选择 Prometheus,指定 thanos query 的地址: http://thanos-query.thanos.svc.cluster.local:9090

总结

本文教了大家如何选型 Thanos 部署方案并详细讲解了各个组件的安装方法,如果仔细阅读完本系列文章,我相信你已经有能力搭建并运维一套大型监控系统了。

打造云原生大型分布式监控系统(二): Thanos 架构详解

作者: 陈鹏

概述

之前在 大规模场景下 Prometheus 的优化手段 中,我们想尽 “千方百计” 才好不容易把 Prometheus 优化到适配大规模场景,部署和后期维护麻烦且复杂不说,还有很多不完美的地方,并且还无法满足一些更高级的诉求,比如查看时间久远的监控数据,对于一些时间久远不常用的 “冷数据”,最理想的方式就是存到廉价的对象存储中,等需要查询的时候能够自动加载出来。

Thanos (没错,就是灭霸) 可以帮我们简化分布式 Prometheus 的部署与管理,并提供了一些的高级特性:全局视图长期存储高可用。下面我们来详细讲解一下。

Thanos 架构

这是官方给出的架构图:

这张图中包含了 Thanos 的几个核心组件,但并不包括所有组件,为了便于理解,我们先不细讲,简单介绍下图中这几个组件的作用:

  • Thanos Query: 实现了 Prometheus API,将来自下游组件提供的数据进行聚合最终返回给查询数据的 client (如 grafana),类似数据库中间件。
  • Thanos Sidecar: 连接 Prometheus,将其数据提供给 Thanos Query 查询,并且/或者将其上传到对象存储,以供长期存储。
  • Thanos Store Gateway: 将对象存储的数据暴露给 Thanos Query 去查询。
  • Thanos Ruler: 对监控数据进行评估和告警,还可以计算出新的监控数据,将这些新数据提供给 Thanos Query 查询并且/或者上传到对象存储,以供长期存储。
  • Thanos Compact: 将对象存储中的数据进行压缩和降低采样率,加速大时间区间监控数据查询的速度。

架构设计剖析

如何理解 Thanos 的架构设计的?我们可以自己先 YY 一下,要是自己来设计一个分布式 Prometheus 管理应用,会怎么做?

Query 与 Sidecar

首先,监控数据的查询肯定不能直接查 Prometheus 了,因为会存在许多个 Prometheus 实例,每个 Prometheus 实例只能感知它自己所采集的数据。我们可以比较容易联想到数据库中间件,每个数据库都只存了一部分数据,中间件能感知到所有数据库,数据查询都经过数据库中间件来查,这个中间件收到查询请求再去查下游各个数据库中的数据,最后将这些数据聚合汇总返回给查询的客户端,这样就实现了将分布式存储的数据集中查询。

实际上,Thanos 也是使用了类似的设计思想,Thanos Query 就是这个 “中间件” 的关键入口。它实现了 Prometheus 的 HTTP API,能够 “看懂” PromQL。这样,查询 Prometheus 监控数据的 client 就不直接查询 Prometheus 本身了,而是去查询 Thanos Query,Thanos Query 再去下游多个存储了数据的地方查数据,最后将这些数据聚合去重后返回给 client,也就实现了分布式 Prometheus 的数据查询。

那么 Thanos Query 又如何去查下游分散的数据呢?Thanos 为此抽象了一套叫 Store API 的内部 gRPC 接口,其它一些组件通过这个接口来暴露数据给 Thanos Query,它自身也就可以做到完全无状态部署,实现高可用与动态扩展。

这些分散的数据可能来自哪些地方呢?首先,Prometheus 会将采集的数据存到本机磁盘上,如果我们直接用这些分散在各个磁盘上的数据,可以给每个 Prometheus 附带部署一个 Sidecar,这个 Sidecar 实现 Thanos Store API,当 Thanos Query 对其发起查询时,Sidecar 就读取跟它绑定部署的 Prometheus 实例上的监控数据返回给 Thanos Query。

由于 Thanos Query 可以对数据进行聚合与去重,所以可以很轻松实现高可用:相同的 Prometheus 部署多个副本(都附带 Sidecar),然后 Thanos Query 去所有 Sidecar 查数据,即便有一个 Prometheus 实例挂掉过一段时间,数据聚合与去重后仍然能得到完整数据。

这种高可用做法还弥补了我们上篇文章中用负载均衡去实现 Prometheus 高可用方法的缺陷:如果其中一个 Prometheus 实例挂了一段时间然后又恢复了,它的数据就不完整,当负载均衡转发到它上面去查数据时,返回的结果就可能会有部分缺失。

不过因为磁盘空间有限,所以 Prometheus 存储监控数据的能力也是有限的,通常会给 Prometheus 设置一个数据过期时间 (默认15天) 或者最大数据量大小,不断清理旧数据以保证磁盘不被撑爆。因此,我们无法看到时间比较久远的监控数据,有时候这也给我们的问题排查和数据统计造成一些困难。

对于需要长期存储的数据,并且使用频率不那么高,最理想的方式是存进对象存储,各大云厂商都有对象存储服务,特点是不限制容量,价格非常便宜。

Thanos 有几个组件都支持将数据上传到各种对象存储以供长期保存 (Prometheus TSDB 数据格式),比如我们刚刚说的 Sidecar:

Store Gateway

那么这些被上传到了对象存储里的监控数据该如何查询呢?理论上 Thanos Query 也可以直接去对象存储查,但会让 Thanos Query 的逻辑变的很重。我们刚才也看到了,Thanos 抽象出了 Store API,只要实现了该接口的组件都可以作为 Thanos Query 查询的数据源,Thanos Store Gateway 这个组件也实现了 Store API,向 Thanos Query 暴露对象存储的数据。Thanos Store Gateway 内部还做了一些加速数据获取的优化逻辑,一是缓存了 TSDB 索引,二是优化了对象存储的请求 (用尽可能少的请求量拿到所有需要的数据)。

这样就实现了监控数据的长期储存,由于对象存储容量无限,所以理论上我们可以存任意时长的数据,监控历史数据也就变得可追溯查询,便于问题排查与统计分析。

Ruler

有一个问题,Prometheus 不仅仅只支持将采集的数据进行存储和查询的功能,还可以配置一些 rules:

  1. 根据配置不断计算出新指标数据并存储,后续查询时直接使用计算好的新指标,这样可以减轻查询时的计算压力,加快查询速度。
  2. 不断计算和评估是否达到告警阀值,当达到阀值时就通知 AlertManager 来触发告警。

由于我们将 Prometheus 进行分布式部署,每个 Prometheus 实例本地并没有完整数据,有些有关联的数据可能存在多个 Prometheus 实例中,单机 Prometheus 看不到数据的全局视图,这种情况我们就不能依赖 Prometheus 来做这些工作,Thanos Ruler 应运而生,它通过查询 Thanos Query 获取全局数据,然后根据 rules 配置计算新指标并存储,同时也通过 Store API 将数据暴露给 Thanos Query,同样还可以将数据上传到对象存储以供长期保存 (这里上传到对象存储中的数据一样也是通过 Thanos Store Gateway 暴露给 Thanos Query)。

看起来 Thanos Query 跟 Thanos Ruler 之间会相互查询,不过这个不冲突,Thanos Ruler 为 Thanos Query 提供计算出的新指标数据,而 Thanos Query 为 Thanos Ruler 提供计算新指标所需要的全局原始指标数据。

至此,Thanos 的核心能力基本实现了,完全兼容 Prometheus 的情况下提供数据查询的全局视图,高可用以及数据的长期保存。

看下还可以怎么进一步做下优化呢?

Compact

由于我们有数据长期存储的能力,也就可以实现查询较大时间范围的监控数据,当时间范围很大时,查询的数据量也会很大,这会导致查询速度非常慢。通常在查看较大时间范围的监控数据时,我们并不需要那么详细的数据,只需要看到大致就行。Thanos Compact 这个组件应运而生,它读取对象存储的数据,对其进行压缩以及降采样再上传到对象存储,这样在查询大时间范围数据时就可以只读取压缩和降采样后的数据,极大地减少了查询的数据量,从而加速查询。

再看架构图

上面我们剖析了官方架构图中各个组件的设计,现在再来回味一下这张图:

理解是否更加深刻了?

另外还有 Thanos Bucket 和 Thanos Checker 两个辅助性的工具组件没画出来,它们不是核心组件,这里也就不再赘述。

Sidecar 模式与 Receiver 模式

前面我们理解了官方的架构图,但其中还缺失一个核心组件 Thanos Receiver,因为它是一个还未完全发布的组件。这是它的设计文档: https://thanos.io/proposals/201812_thanos-remote-receive.md/

这个组件可以完全消除 Sidecar,所以 Thanos 实际有两种架构图,只是因为没有完全发布,官方的架构图只给的 Sidecar 模式。

Receiver 是做什么的呢?为什么需要 Receiver?它跟 Sidecar 有什么区别?

它们都可以将数据上传到对象存储以供长期保存,区别在于最新数据的存储。

由于数据上传不可能实时,Sidecar 模式将最新的监控数据存到 Prometheus 本机,Query 通过调所有 Sidecar 的 Store API 来获取最新数据,这就成一个问题:如果 Sidecar 数量非常多或者 Sidecar 跟 Query 离的比较远,每次查询 Query 都调所有 Sidecar 会消耗很多资源,并且速度很慢,而我们查看监控大多数情况都是看的最新数据。

为了解决这个问题,Thanos Receiver 组件被提出,它适配了 Prometheus 的 remote write API,也就是所有 Prometheus 实例可以实时将数据 push 到 Thanos Receiver,最新数据也得以集中起来,然后 Thanos Query 也不用去所有 Sidecar 查最新数据了,直接查 Thanos Receiver 即可。另外,Thanos Receiver 也将数据上传到对象存储以供长期保存,当然,对象存储中的数据同样由 Thanos Store Gateway 暴露给 Thanos Query。

有同学可能会问:如果规模很大,Receiver 压力会不会很大,成为性能瓶颈?当然设计这个组件时肯定会考虑这个问题,Receiver 实现了一致性哈希,支持集群部署,所以即使规模很大也不会成为性能瓶颈。

总结

本文详细讲解了 Thanos 的架构设计,各个组件的作用以及为什么要这么设计。如果仔细看完,我相信你已经 get 到了 Thanos 的精髓,不过我们还没开始讲如何部署与实践,实际上在腾讯云容器服务的多个产品的内部监控已经在使用 Thanos 了,比如 TKE (公有云 k8s)、TKEStack (私有云 k8s)、EKS (Serverless k8s)。 下一篇我们将介绍 Thanos 的部署与最佳实践,敬请期待。

打造云原生大型分布式监控系统(一): 大规模场景下 Prometheus 的优化手段

作者: 陈鹏

概述

Prometheus 几乎已成为监控领域的事实标准,它自带高效的时序数据库存储,可以让单台 Prometheus 能够高效的处理大量的数据,还有友好并且强大的 PromQL 语法,可以用来灵活的查询各种监控数据以及配置告警规则,同时它的 pull 模型指标采集方式被广泛采纳,非常多的应用都实现了 Prometheus 的 metrics 接口以暴露自身各项数据指标让 Prometheus 去采集,很多没有适配的应用也会有第三方 exporter 帮它去适配 Prometheus,所以监控系统我们通常首选用 Prometheus,本系列文章也将基于 Prometheus 来打造云原生环境下的大型分布式监控系统。

大规模场景下 Prometheus 的痛点

Prometheus 本身只支持单机部署,没有自带支持集群部署,也就不支持高可用以及水平扩容,在大规模场景下,最让人关心的问题是它的存储空间也受限于单机磁盘容量,磁盘容量决定了单个 Prometheus 所能存储的数据量,数据量大小又取决于被采集服务的指标数量、服务数量、采集速率以及数据过期时间。在数据量大的情况下,我们可能就需要做很多取舍,比如丢弃不重要的指标、降低采集速率、设置较短的数据过期时间(默认只保留15天的数据,看不到比较久远的监控数据)。

这些痛点实际也是可以通过一些优化手段来改善的,下面我们来细讲一下。

从服务维度拆分 Prometheus

Prometheus 主张根据功能或服务维度进行拆分,即如果要采集的服务比较多,一个 Prometheus 实例就配置成仅采集和存储某一个或某一部分服务的指标,这样根据要采集的服务将 Prometheus 拆分成多个实例分别去采集,也能一定程度上达到水平扩容的目的。

通常这样的扩容方式已经能满足大部分场景的需求了,毕竟单机 Prometheus 就能采集和处理很多数据了,很少有 Prometheus 撑不住单个服务的场景。不过在超大规模集群下,有些单个服务的体量也很大,就需要进一步拆分了,我们下面来继续讲下如何再拆分。

对超大规模的服务做分片

想象一下,如果集群节点数量达到上千甚至几千的规模,对于一些节点级服务暴露的指标,比如 kubelet 内置的 cadvisor 暴露的容器相关的指标,又或者部署的 DeamonSet node-exporter 暴露的节点相关的指标,在集群规模大的情况下,它们这种单个服务背后的指标数据体量就非常大;还有一些用户量超大的业务,单个服务的 pod 副本数就可能过千,这种服务背后的指标数据也非常大,当然这是最罕见的场景,对于绝大多数的人来说这种场景都只敢 YY 一下,实际很少有单个服务就达到这么大规模的业务。

针对上面这些大规模场景,一个 Prometheus 实例可能连这单个服务的采集任务都扛不住。Prometheus 需要向这个服务所有后端实例发请求采集数据,由于后端实例数量规模太大,采集并发量就会很高,一方面对节点的带宽、CPU、磁盘 IO 都有一定的压力,另一方面 Prometheus 使用的磁盘空间有限,采集的数据量过大很容易就将磁盘塞满了,通常要做一些取舍才能将数据量控制在一定范围,但这种取舍也会降低数据完整和精确程度,不推荐这样做。

那么如何优化呢?我们可以给这种大规模类型的服务做一下分片(Sharding),将其拆分成多个 group,让一个 Prometheus 实例仅采集这个服务背后的某一个 group 的数据,这样就可以将这个大体量服务的监控数据拆分到多个 Prometheus 实例上。

如何将一个服务拆成多个 group 呢?下面介绍两种方案,以对 kubelet cadvisor 数据做分片为例。

第一,我们可以不用 Kubernetes 的服务发现,自行实现一下 sharding 算法,比如针对节点级的服务,可以将某个节点 shard 到某个 group 里,然后再将其注册到 Prometheus 所支持的服务发现注册中心,推荐 consul,最后在 Prometheus 配置文件加上 consul_sd_config 的配置,指定每个 Prometheus 实例要采集的 group。

1
2
3
4
5
- job_name: 'cadvisor-1'
consul_sd_configs:
- server: 10.0.0.3:8500
services:
- cadvisor-1 # This is the 2nd slave

在未来,你甚至可以直接利用 Kubernetes 的 EndpointSlice 特性来做服务发现和分片处理,在超大规模服务场景下就可以不需要其它的服务发现和分片机制。不过暂时此特性还不够成熟,没有默认启用,不推荐用(当前 Kubernentes 最新版本为 1.18)。

第二,用 Kubernetes 的 node 服务发现,再利用 Prometheus relabel 配置的 hashmod 来对 node 做分片,每个 Prometheus 实例仅抓其中一个分片中的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- job_name: 'cadvisor-1'
metrics_path: /metrics/cadvisor
scheme: https

# 请求 kubelet metrics 接口也需要认证和授权,通常会用 webhook 方式让 apiserver 代理进行 RBAC 校验,所以还是用 ServiceAccount 的 token
bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token

kubernetes_sd_configs:
- role: node

# 通常不校验 kubelet 的 server 证书,避免报 x509: certificate signed by unknown authority
tls_config:
insecure_skip_verify: true

relabel_configs:
- source_labels: [__address__]
modulus: 4 # 将节点分片成 4 个 group
target_label: __tmp_hash
action: hashmod
- source_labels: [__tmp_hash]
regex: ^1$ # 只抓第 2 个 group 中节点的数据(序号 0 为第 1 个 group)
action: keep

拆分引入的新问题

前面我们通过不通层面对 Prometheus 进行了拆分部署,一方面使得 Prometheus 能够实现水平扩容,另一方面也加剧了监控数据落盘的分散程度,使用 Grafana 查询监控数据时我们也需要添加许多数据源,而且不同数据源之间的数据还不能聚合查询,监控页面也看不到全局的视图,造成查询混乱的局面。

要解决这个问题,我们可以从下面的两方面入手,任选其中一种方案。

集中数据存储

我们可以让 Prometheus 不负责存储,仅采集数据并通过 remote write 方式写入远程存储的 adapter,远程存储使用 OpenTSDB 或 InfluxDB 这些支持集群部署的时序数据库,Prometheus 配置:

1
2
remote_write:
- url: http://10.0.0.2:8888/write

然后 Grafana 添加我们使用的时序数据库作为数据源来查询监控数据来展示,架构图:

这种方式相当于更换了存储引擎,由其它支持存储水平扩容的时序数据库来存储庞大的数据量,这样我们就可以将数据集中到一起。OpenTSDB 支持 HBase, BigTable 作为存储后端,InfluxDB 企业版支持集群部署和水平扩容(开源版不支持)。不过这样的话,我们就无法使用友好且强大的 PromQL 来查询监控数据了,必须使用我们存储数据的时序数据库所支持的语法来查询。

Prometheus 联邦

除了上面更换存储引擎的方式,还可以将 Prometheus 进行联邦部署。

简单来说,就是将多个 Prometheus 实例采集的数据再用另一个 Prometheus 采集汇总到一起,这样也意味着需要消耗更多的资源。通常我们只把需要聚合的数据或者需要在一个地方展示的数据用这种方式采集汇总到一起,比如 Kubernetes 节点数过多,cadvisor 的数据分散在多个 Prometheus 实例上,我们就可以用这种方式将 cadvisor 暴露的容器指标汇总起来,以便于在一个地方就能查询到集群中任意一个容器的监控数据或者某个服务背后所有容器的监控数据的聚合汇总以及配置告警;又或者多个服务有关联,比如通常应用只暴露了它应用相关的指标,但它的资源使用情况(比如 cpu 和 内存) 由 cadvisor 来感知和暴露,这两部分指标由不同的 Prometheus 实例所采集,这时我们也可以用这种方式将数据汇总,在一个地方展示和配置告警。

更多说明和配置示例请参考官方文档: https://prometheus.io/docs/prometheus/latest/federation/

Prometheus 高可用

虽然上面我们通过一些列操作将 Prometheus 进行了分布式改造,但并没有解决 Prometheus 本身的高可用问题,即如果其中一个实例挂了,数据的查询和完整性都将受到影响。

我们可以将所有 Prometheus 实例都使用两个相同副本,分别挂载数据盘,它们都采集相同的服务,所以它们的数据是一致的,查询它们之中任意一个都可以,所以可以在它们前面再挂一层负载均衡,所有查询都经过这个负载均衡分流到其中一台 Prometheus,如果其中一台挂掉就从负载列表里踢掉不再转发。

这里的负载均衡可以根据实际环境选择合适的方案,可以用 Nginx 或 HAProxy,在 Kubernetes 环境,通常使用 Kubernentes 的 Service,由 kube-proxy 生成的 iptables/ipvs 规则转发,如果使用 Istio,还可以用 VirtualService,由 envoy sidecar 去转发。

这样就实现了 Prometheus 的高可用,简单起见,上面的图仅展示单个 Prometheus 的高可用,当你可以将其拓展,代入应用到上面其它的优化手段中,实现整体的高可用。

总结

通过本文一系列对 Prometheus 的优化手段,我们在一定程度上解决了单机 Prometheus 在大规模场景下的痛点,但操作和运维复杂度比较高,并且不能够很好的支持数据的长期存储(long term storage)。对于一些时间比较久远的监控数据,我们通常查看的频率很低,但也希望能够低成本的保留足够长的时间,数据如果全部落盘到磁盘成本是很高的,并且容量有限,即便利用水平扩容可以增加存储容量,但同时也增大了资源成本,不可能无限扩容,所以需要设置一个数据过期策略,也就会丢失时间比较久远的监控数据。

对于这种不常用的冷数据,最理想的方式就是存到廉价的对象存储中,等需要查询的时候能够自动加载出来。Thanos 可以帮我们解决这些问题,它完全兼容 Prometheus API,提供统一查询聚合分布式部署的 Prometheus 数据的能力,同时也支持数据长期存储到各种对象存储(无限存储能力)以及降低采样率来加速大时间范围的数据查询。

下一篇我们将会介绍 Thanos 的架构详解,敬请期待。

Kubernetes 疑难杂症排查分享:神秘的溢出与丢包

作者: 陈鹏

上一篇 Kubernetes 疑难杂症排查分享: 诡异的 No route to host 不小心又爆火,这次继续带来干货,看之前请提前泡好茶,避免口干。

问题描述

有用户反馈大量图片加载不出来。

图片下载走的 k8s ingress,这个 ingress 路径对应后端 service 是一个代理静态图片文件的 nginx deployment,这个 deployment 只有一个副本,静态文件存储在 nfs 上,nginx 通过挂载 nfs 来读取静态文件来提供图片下载服务,所以调用链是:client –> k8s ingress –> nginx –> nfs。

猜测

猜测: ingress 图片下载路径对应的后端服务出问题了。

验证:在 k8s 集群直接 curl nginx 的 pod ip,发现不通,果然是后端服务的问题!

抓包

继续抓包测试观察,登上 nginx pod 所在节点,进入容器的 netns 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 拿到 pod 中 nginx 的容器 id
$ kubectl describe pod tcpbench-6484d4b457-847gl | grep -A10 "^Containers:" | grep -Eo 'docker://.*$' | head -n 1 | sed 's/docker:\/\/\(.*\)$/\1/'
49b4135534dae77ce5151c6c7db4d528f05b69b0c6f8b9dd037ec4e7043c113e

# 通过容器 id 拿到 nginx 进程 pid
$ docker inspect -f {{.State.Pid}} 49b4135534dae77ce5151c6c7db4d528f05b69b0c6f8b9dd037ec4e7043c113e
3985

# 进入 nginx 进程所在的 netns
$ nsenter -n -t 3985

# 查看容器 netns 中的网卡信息,确认下
$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
3: eth0@if11: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 56:04:c7:28:b0:3c brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 172.26.0.8/26 scope global eth0
valid_lft forever preferred_lft forever

使用 tcpdump 指定端口 24568 抓容器 netns 中 eth0 网卡的包:

1
tcpdump -i eth0 -nnnn -ttt port 24568

在其它节点准备使用 nc 指定源端口为 24568 向容器发包:

1
nc -u 24568 172.16.1.21 80

观察抓包结果:

1
2
3
4
5
6
7
00:00:00.000000 IP 10.0.0.3.24568 > 172.16.1.21.80: Flags [S], seq 416500297, win 29200, options [mss 1424,sackOK,TS val 3000206334 ecr 0,nop,wscale 9], length 0
00:00:01.032218 IP 10.0.0.3.24568 > 172.16.1.21.80: Flags [S], seq 416500297, win 29200, options [mss 1424,sackOK,TS val 3000207366 ecr 0,nop,wscale 9], length 0
00:00:02.011962 IP 10.0.0.3.24568 > 172.16.1.21.80: Flags [S], seq 416500297, win 29200, options [mss 1424,sackOK,TS val 3000209378 ecr 0,nop,wscale 9], length 0
00:00:04.127943 IP 10.0.0.3.24568 > 172.16.1.21.80: Flags [S], seq 416500297, win 29200, options [mss 1424,sackOK,TS val 3000213506 ecr 0,nop,wscale 9], length 0
00:00:08.192056 IP 10.0.0.3.24568 > 172.16.1.21.80: Flags [S], seq 416500297, win 29200, options [mss 1424,sackOK,TS val 3000221698 ecr 0,nop,wscale 9], length 0
00:00:16.127983 IP 10.0.0.3.24568 > 172.16.1.21.80: Flags [S], seq 416500297, win 29200, options [mss 1424,sackOK,TS val 3000237826 ecr 0,nop,wscale 9], length 0
00:00:33.791988 IP 10.0.0.3.24568 > 172.16.1.21.80: Flags [S], seq 416500297, win 29200, options [mss 1424,sackOK,TS val 3000271618 ecr 0,nop,wscale 9], length 0

SYN 包到容器内网卡了,但容器没回 ACK,像是报文到达容器内的网卡后就被丢了。看样子跟防火墙应该也没什么关系,也检查了容器 netns 内的 iptables 规则,是空的,没问题。

排除是 iptables 规则问题,在容器 netns 中使用 netstat -s 检查下是否有丢包统计:

1
2
3
$ netstat -s | grep -E 'overflow|drop'
12178939 times the listen queue of a socket overflowed
12247395 SYNs to LISTEN sockets dropped

果然有丢包,为了理解这里的丢包统计,我深入研究了一下,下面插播一些相关知识。

syn queue 与 accept queue

Linux 进程监听端口时,内核会给它对应的 socket 分配两个队列:

  • syn queue: 半连接队列。server 收到 SYN 后,连接会先进入 SYN_RCVD 状态,并放入 syn queue,此队列的包对应还没有完全建立好的连接(TCP 三次握手还没完成)。
  • accept queue: 全连接队列。当 TCP 三次握手完成之后,连接会进入 ESTABELISHED 状态并从 syn queue 移到 accept queue,等待被进程调用 accept() 系统调用 “拿走”。

注意:这两个队列的连接都还没有真正被应用层接收到,当进程调用 accept() 后,连接才会被应用层处理,具体到我们这个问题的场景就是 nginx 处理 HTTP 请求。

为了更好理解,可以看下这张 TCP 连接建立过程的示意图:

listen 与 accept

不管使用什么语言和框架,在写 server 端应用时,它们的底层在监听端口时最终都会调用 listen() 系统调用,处理新请求时都会先调用 accept() 系统调用来获取新的连接,然后再处理请求,只是有各自不同的封装而已,以 go 语言为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 调用 listen 监听端口
l, err := net.Listen("tcp", ":80")
if err != nil {
panic(err)
}
for {
// 不断调用 accept 获取新连接,如果 accept queue 为空就一直阻塞
conn, err := l.Accept()
if err != nil {
log.Println("accept error:", err)
continue
}
// 每来一个新连接意味着一个新请求,启动协程处理请求
go handle(conn)
}

Linux 的 backlog

内核既然给监听端口的 socket 分配了 syn queue 与 accept queue 两个队列,那它们有大小限制吗?可以无限往里面塞数据吗?当然不行! 资源是有限的,尤其是在内核态,所以需要限制一下这两个队列的大小。那么它们的大小是如何确定的呢?我们先来看下 listen 这个系统调用:

1
int listen(int sockfd, int backlog)

可以看到,能够传入一个整数类型的 backlog 参数,我们再通过 man listen 看下解释:

The behavior of the backlog argument on TCP sockets changed with Linux 2.2. Now it specifies the queue length for completely established sockets waiting to be accepted, instead of the number of incomplete connection requests. The maximum length of the queue for incomplete sockets can be set using /proc/sys/net/ipv4/tcp_max_syn_backlog. When syncookies are enabled there is no logical maximum length and this setting is ignored. See tcp(7) for more information.

If the backlog argument is greater than the value in /proc/sys/net/core/somaxconn, then it is silently truncated to that value; the default value in this file is 128. In kernels before 2.4.25, this limit was a hard coded value, SOMAXCONN, with the value 128.

继续深挖了一下源码,结合这里的解释提炼一下:

  • listen 的 backlog 参数同时指定了 socket 的 syn queue 与 accept queue 大小。
  • accept queue 最大不能超过 net.core.somaxconn 的值,即:

    1
    max accept queue size = min(backlog, net.core.somaxconn)
  • 如果启用了 syncookies (net.ipv4.tcp_syncookies=1),当 syn queue 满了,server 还是可以继续接收 SYN 包并回复 SYN+ACK 给 client,只是不会存入 syn queue 了。因为会利用一套巧妙的 syncookies 算法机制生成隐藏信息写入响应的 SYN+ACK 包中,等 client 回 ACK 时,server 再利用 syncookies 算法校验报文,校验通过后三次握手就顺利完成了。所以如果启用了 syncookies,syn queue 的逻辑大小是没有限制的,

  • syncookies 通常都是启用了的,所以一般不用担心 syn queue 满了导致丢包。syncookies 是为了防止 SYN Flood 攻击 (一种常见的 DDoS 方式),攻击原理就是 client 不断发 SYN 包但不回最后的 ACK,填满 server 的 syn queue 从而无法建立新连接,导致 server 拒绝服务。
  • 如果 syncookies 没有启用,syn queue 的大小就有限制,除了跟 accept queue 一样受 net.core.somaxconn 大小限制之外,还会受到 net.ipv4.tcp_max_syn_backlog 的限制,即:
    1
    max syn queue size = min(backlog, net.core.somaxconn, net.ipv4.tcp_max_syn_backlog)

4.3 及其之前版本的内核,syn queue 的大小计算方式跟现在新版内核这里还不一样,详细请参考 commit ef547f2ac16b

队列溢出

毫无疑问,在队列大小有限制的情况下,如果队列满了,再有新连接过来肯定就有问题。

翻下 linux 源码,看下处理 SYN 包的部分,在 net/ipv4/tcp_input.ctcp_conn_request 函数:

1
2
3
4
5
6
7
8
9
10
11
if ((net->ipv4.sysctl_tcp_syncookies == 2 ||
inet_csk_reqsk_queue_is_full(sk)) && !isn) {
want_cookie = tcp_syn_flood_action(sk, rsk_ops->slab_name);
if (!want_cookie)
goto drop;
}

if (sk_acceptq_is_full(sk)) {
NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
goto drop;
}

goto drop 最终会走到 tcp_listendrop 函数,实际上就是将 ListenDrops 计数器 +1:

1
2
3
4
5
static inline void tcp_listendrop(const struct sock *sk)
{
atomic_inc(&((struct sock *)sk)->sk_drops);
__NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENDROPS);
}

大致可以看出来,对于 SYN 包:

  • 如果 syn queue 满了并且没有开启 syncookies 就丢包,并将 ListenDrops 计数器 +1。
  • 如果 accept queue 满了也会丢包,并将 ListenOverflowsListenDrops 计数器 +1。

而我们前面排查问题通过 netstat -s 看到的丢包统计,其实就是对应的 ListenOverflowsListenDrops 这两个计数器。

除了用 netstat -s,还可以使用 nstat -az 直接看系统内各个计数器的值:

1
2
3
$ nstat -az | grep -E 'TcpExtListenOverflows|TcpExtListenDrops'
TcpExtListenOverflows 12178939 0.0
TcpExtListenDrops 12247395 0.0

另外,对于低版本内核,当 accept queue 满了,并不会完全丢弃 SYN 包,而是对 SYN 限速。把内核源码切到 3.10 版本,看 net/ipv4/tcp_ipv4.ctcp_v4_conn_request 函数:

1
2
3
4
5
6
7
8
9
/* Accept backlog is full. If we have already queued enough
* of warm entries in syn queue, drop request. It is better than
* clogging syn queue with openreqs with exponentially increasing
* timeout.
*/
if (sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1) {
NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
goto drop;
}

其中 inet_csk_reqsk_queue_young(sk) > 1 的条件实际就是用于限速,仿佛在对 client 说: 哥们,你慢点!我的 accept queue 都满了,即便咱们握手成功,连接也可能放不进去呀。

回到问题上来

总结之前观察到两个现象:

  • 容器内抓包发现收到 client 的 SYN,但 nginx 没回包。
  • 通过 netstat -s 发现有溢出和丢包的统计 (ListenOverflowsListenDrops)。

根据之前的分析,我们可以推测是 syn queue 或 accept queue 满了。

先检查下 syncookies 配置:

1
2
$ cat /proc/sys/net/ipv4/tcp_syncookies
1

确认启用了 syncookies,所以 syn queue 大小没有限制,不会因为 syn queue 满而丢包,并且即便没开启 syncookies,syn queue 有大小限制,队列满了也不会使 ListenOverflows 计数器 +1。

从计数器结果来看,ListenOverflowsListenDrops 的值差别不大,所以推测很有可能是 accept queue 满了,因为当 accept queue 满了会丢 SYN 包,并且同时将 ListenOverflowsListenDrops 计数器分别 +1。

如何验证 accept queue 满了呢?可以在容器的 netns 中执行 ss -lnt 看下:

1
2
3
$ ss -lnt
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 129 128 *:80 *:*

通过这条命令我们可以看到当前 netns 中监听 tcp 80 端口的 socket,Send-Q 为 128,Recv-Q 为 129。

什么意思呢?通过调研得知:

  • 对于 LISTEN 状态,Send-Q 表示 accept queue 的最大限制大小,Recv-Q 表示其实际大小。
  • 对于 ESTABELISHED 状态,Send-QRecv-Q 分别表示发送和接收数据包的 buffer。

所以,看这里输出结果可以得知 accept queue 满了,当 Recv-Q 的值比 Send-Q 大 1 时表明 accept queue 溢出了,如果再收到 SYN 包就会丢弃掉。

导致 accept queue 满的原因一般都是因为进程调用 accept() 太慢了,导致大量连接不能被及时 “拿走”。

那么什么情况下进程调用 accept() 会很慢呢?猜测可能是进程连接负载高,处理不过来。

而负载高不仅可能是 CPU 繁忙导致,还可能是 IO 慢导致,当文件 IO 慢时就会有很多 IO WAIT,在 IO WAIT 时虽然 CPU 不怎么干活,但也会占据 CPU 时间片,影响 CPU 干其它活。

最终进一步定位发现是 nginx pod 挂载的 nfs 服务对应的 nfs server 负载较高,导致 IO 延时较大,从而使 nginx 调用 accept() 变慢,accept queue 溢出,使得大量代理静态图片文件的请求被丢弃,也就导致很多图片加载不出来。

虽然根因不是 k8s 导致的问题,但也从中挖出一些在高并发场景下值得优化的点,请继续往下看。

somaxconn 的默认值很小

我们再看下之前 ss -lnt 的输出:

1
2
3
$ ss -lnt
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 129 128 *:80 *:*

仔细一看,Send-Q 表示 accept queue 最大的大小,才 128 ?也太小了吧!

根据前面的介绍我们知道,accept queue 的最大大小会受 net.core.somaxconn 内核参数的限制,我们看下 pod 所在节点上这个内核参数的大小:

1
2
$ cat /proc/sys/net/core/somaxconn
32768

是 32768,挺大的,为什么这里 accept queue 最大大小就只有 128 了呢?

net.core.somaxconn 这个内核参数是 namespace 隔离了的,我们在容器 netns 中再确认了下:

1
2
$ cat /proc/sys/net/core/somaxconn
128

为什么只有 128?看下 stackoverflow 这里 的讨论:

The "net/core" subsys is registered per network namespace. And the initial value for somaxconn is set to 128.

原来新建的 netns 中 somaxconn 默认就为 128,在 include/linux/socket.h 中可以看到这个常量的定义:

1
2
/* Maximum queue length specifiable by listen.  */
#define SOMAXCONN 128

很多人在使用 k8s 时都没太在意这个参数,为什么大家平常在较高并发下也没发现有问题呢?

因为通常进程 accept() 都是很快的,所以一般 accept queue 基本都没什么积压的数据,也就不会溢出导致丢包了。

对于并发量很高的应用,还是建议将 somaxconn 调高。虽然可以进入容器 netns 后使用 sysctl -w net.core.somaxconn=1024echo 1024 > /proc/sys/net/core/somaxconn 临时调整,但调整的意义不大,因为容器内的进程一般在启动的时候才会调用 listen(),然后 accept queue 的大小就被决定了,并且不再改变。

下面介绍几种调整方式:

方式一: 使用 k8s sysctls 特性直接给 pod 指定内核参数

示例 yaml:

1
2
3
4
5
6
7
8
9
apiVersion: v1
kind: Pod
metadata:
name: sysctl-example
spec:
securityContext:
sysctls:
- name: net.core.somaxconn
value: "8096"

有些参数是 unsafe 类型的,不同环境不一样,我的环境里是可以直接设置 pod 的 net.core.somaxconn 这个 sysctl 的。如果你的环境不行,请参考官方文档 Using sysctls in a Kubernetes Cluster 启用 unsafe 类型的 sysctl。

注:此特性在 k8s v1.12 beta,默认开启。

方式二: 使用 initContainers 设置内核参数

示例 yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
apiVersion: v1
kind: Pod
metadata:
name: sysctl-example-init
spec:
initContainers:
- image: busybox
command:
- sh
- -c
- echo 1024 > /proc/sys/net/core/somaxconn
imagePullPolicy: Always
name: setsysctl
securityContext:
privileged: true
Containers:
...

注: init container 需要 privileged 权限。

方式三: 安装 tuning CNI 插件统一设置 sysctl

tuning plugin 地址: https://github.com/containernetworking/plugins/tree/master/plugins/meta/tuning

CNI 配置示例:

1
2
3
4
5
6
7
{
"name": "mytuning",
"type": "tuning",
"sysctl": {
"net.core.somaxconn": "1024"
}
}

nginx 的 backlog

我们使用方式一尝试给 nginx pod 的 somaxconn 调高到 8096 后观察:

1
2
3
$ ss -lnt
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 512 511 *:80 *:*

WTF? 还是溢出了,而且调高了 somaxconn 之后虽然 accept queue 的最大大小 (Send-Q) 变大了,但跟 8096 还差很远呀!

在经过一番研究,发现 nginx 在 listen() 时并没有读取 somaxconn 作为 backlog 默认值传入,它有自己的默认值,也支持在配置里改。通过 ngx_http_core_module 的官方文档我们可以看到它在 linux 下的默认值就是 511:

1
2
backlog=number
sets the backlog parameter in the listen() call that limits the maximum length for the queue of pending connections. By default, backlog is set to -1 on FreeBSD, DragonFly BSD, and macOS, and to 511 on other platforms.

配置示例:

1
listen  80  default  backlog=1024;

所以,在容器中使用 nginx 来支撑高并发的业务时,记得要同时调整下 net.core.somaxconn 内核参数和 nginx.conf 中的 backlog 配置。

参考资料

Kubernetes 疑难杂症排查分享: 诡异的 No route to host

作者: 陈鹏

之前发过一篇干货满满的爆火文章 Kubernetes 网络疑难杂症排查分享,包含多个疑难杂症的排查案例分享,信息量巨大。这次我又带来了续集,只讲一个案例,但信息量也不小,Are you ready ?

问题反馈

有用户反馈 Deployment 滚动更新的时候,业务日志偶尔会报 “No route to host” 的错误。

分析

之前没遇到滚动更新会报 “No route to host” 的问题,我们先看下滚动更新导致连接异常有哪些常见的报错:

  • Connection reset by peer: 连接被重置。通常是连接建立过,但 server 端发现 client 发的包不对劲就返回 RST,应用层就报错连接被重置。比如在 server 滚动更新过程中,client 给 server 发的请求还没完全结束,或者本身是一个类似 grpc 的多路复用长连接,当 server 对应的旧 Pod 删除(没有做优雅结束,停止时没有关闭连接),新 Pod 很快创建启动并且刚好有跟之前旧 Pod 一样的 IP,这时 kube-proxy 也没感知到这个 IP 其实已经被删除然后又被重建了,针对这个 IP 的规则就不会更新,旧的连接依然发往这个 IP,但旧 Pod 已经不在了,后面继续发包时依然转发给这个 Pod IP,最终会被转发到这个有相同 IP 的新 Pod 上,而新 Pod 收到此包时检查报文发现不对劲,就返回 RST 给 client 告知将连接重置。针对这种情况,建议应用自身处理好优雅结束:Pod 进入 Terminating 状态后会发送 SIGTERM 信号给业务进程,业务进程的代码需处理这个信号,在进程退出前关闭所有连接。
  • Connection refused: 连接被拒绝。通常是连接还没建立,client 正在发 SYN 包请求建立连接,但到了 server 之后发现端口没监听,内核就返回 RST 包,然后应用层就报错连接被拒绝。比如在 server 滚动更新过程中,旧的 Pod 中的进程很快就停止了(网卡还未完全销毁),但 client 所在节点的 iptables/ipvs 规则还没更新,包就可能会被转发到了这个停止的 Pod (由于 k8s 的 controller 模式,从 Pod 删除到 service 的 endpoint 更新,再到 kube-proxy watch 到更新并更新 节点上的 iptables/ipvs 规则,这个过程是异步的,中间存在一点时间差,所以有可能存在 Pod 中的进程已经没有监听,但 iptables/ipvs 规则还没更新的情况)。针对这种情况,建议给容器加一个 preStop,在真正销毁 Pod 之前等待一段时间,留时间给 kube-proxy 更新转发规则,更新完之后就不会再有新连接往这个旧 Pod 转发了,preStop 示例:

    1
    2
    3
    4
    5
    6
    7
    lifecycle:
    preStop:
    exec:
    command:
    - /bin/bash
    - -c
    - sleep 30

    另外,还可能是新的 Pod 启动比较慢,虽然状态已经 Ready,但实际上可能端口还没监听,新的请求被转发到这个还没完全启动的 Pod 就会报错连接被拒绝。针对这种情况,建议给容器加就绪检查 (readinessProbe),让容器真正启动完之后才将其状态置为 Ready,然后 kube-proxy 才会更新转发规则,这样就能保证新的请求只被转发到完全启动的 Pod,readinessProbe 示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    readinessProbe:
    httpGet:
    path: /healthz
    port: 80
    httpHeaders:
    - name: X-Custom-Header
    value: Awesome
    initialDelaySeconds: 15
    timeoutSeconds: 1
  • Connection timed out: 连接超时。通常是连接还没建立,client 发 SYN 请求建立连接一直等到超时时间都没有收到 ACK,然后就报错连接超时。这个可能场景跟前面 Connection refused 可能的场景类似,不同点在于端口有监听,但进程无法正常响应了: 转发规则还没更新,旧 Pod 的进程正在停止过程中,虽然端口有监听,但已经不响应了;或者转发规则更新了,新 Pod 端口也监听了,但还没有真正就绪,还没有能力处理新请求。针对这些情况的建议跟前面一样:加 preStop 和 readinessProbe。

下面我们来继续分析下滚动更新时发生 No route to host 的可能情况。

这个报错很明显,IP 无法路由,通常是将报文发到了一个已经彻底销毁的 Pod (网卡已经不在)。不可能发到一个网卡还没创建好的 Pod,因为即便不加存活检查,也是要等到 Pod 网络初始化完后才可能 Ready,然后 kube-proxy 才会更新转发规则。

什么情况下会转发到一个已经彻底销毁的 Pod? 借鉴前面几种滚动更新的报错分析,我们推测应该是 Pod 很快销毁了但转发规则还没更新,从而新的请求被转发了这个已经销毁的 Pod,最终报文到达这个 Pod 所在 PodCIDR 的 Node 上时,Node 发现本机已经没有这个 IP 的容器,然后 Node 就返回 ICMP 包告知 client 这个 IP 不可达,client 收到 ICMP 后,应用层就会报错 “No route to host”。

所以根据我们的分析,关键点在于 Pod 销毁太快,转发规则还没来得及更新,导致后来的请求被转发到已销毁的 Pod。针对这种情况,我们可以给容器加一个 preStop,留时间给 kube-proxy 更新转发规则来解决,参考 《Kubernetes实践指南》中的部分章节: https://k8s.imroc.io/best-practice/high-availability-deployment-of-applications#smooth-update-using-prestophook-and-readinessprobe

问题没有解决

我们自己没有复现用户的 “No route to host” 的问题,可能是复现条件比较苛刻,最后将我们上面理论上的分析结论作为解决方案给到了用户。

但用户尝试加了 preStop 之后,问题依然存在,服务滚动更新时偶尔还是会出现 “No route to host”。

深入分析

为了弄清楚根本原因,我们请求用户协助搭建了一个可以复现问题的测试环境,最终这个问题在测试环境中可以稳定复现。

仔细观察,实际是部署两个服务:ServiceA 和 ServiceB。使用 ab 压测工具去压测 ServiceA (短连接),然后 ServiceA 会通过 RPC 调用 ServiceB (短连接),滚动更新的是 ServiceB,报错发生在 ServiceA 调用 ServiceB 这条链路。

在 ServiceB 滚动更新期间,新的 Pod Ready 了之后会被添加到 IPVS 规则的 RS 列表,但旧的 Pod 不会立即被踢掉,而是将新的 Pod 权重置为1,旧的置为 0,通过在 client 所在节点查看 IPVS 规则可以看出来:

1
2
3
4
5
6
root@VM-0-3-ubuntu:~# ipvsadm -ln -t 172.16.255.241:80
Prot LocalAddress:Port Scheduler Flags
-> RemoteAddress:Port Forward Weight ActiveConn InActConn
TCP 172.16.255.241:80 rr
-> 172.16.8.106:80 Masq 0 5 14048
-> 172.16.8.107:80 Masq 1 2 243

为什么不立即踢掉旧的 Pod 呢?因为要支持优雅结束,让存量的连接处理完,等存量连接全部结束了再踢掉它(ActiveConn+InactiveConn=0),这个逻辑可以通过这里的代码确认:https://github.com/kubernetes/kubernetes/blob/v1.17.0/pkg/proxy/ipvs/graceful_termination.go#L170

然后再通过 ipvsadm -lnc | grep 172.16.8.106 发现旧 Pod 上的连接大多是 TIME_WAIT 状态,这个也容易理解:因为 ServiceA 作为 client 发起短连接请求调用 ServiceB,调用完成就会关闭连接,TCP 三次挥手后进入 TIME_WAIT 状态,等待 2*MSL (2 分钟) 的时长再清理连接。

经过上面的分析,看起来都是符合预期的,那为什么还会出现 “No route to host” 呢?难道权重被置为 0 之后还有新连接往这个旧 Pod 转发?我们来抓包看下:

1
2
3
4
5
6
7
root@VM-0-3-ubuntu:~# tcpdump -i eth0 host 172.16.8.106 -n -tttt
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes
2019-12-13 11:49:47.319093 IP 10.0.0.3.36708 > 172.16.8.106.80: Flags [S], seq 3988339656, win 29200, options [mss 1460,sackOK,TS val 3751111666 ecr 0,nop,wscale 9], length 0
2019-12-13 11:49:47.319133 IP 10.0.0.3.36706 > 172.16.8.106.80: Flags [S], seq 109196945, win 29200, options [mss 1460,sackOK,TS val 3751111666 ecr 0,nop,wscale 9], length 0
2019-12-13 11:49:47.319144 IP 10.0.0.3.36704 > 172.16.8.106.80: Flags [S], seq 1838682063, win 29200, options [mss 1460,sackOK,TS val 3751111666 ecr 0,nop,wscale 9], length 0
2019-12-13 11:49:47.319153 IP 10.0.0.3.36702 > 172.16.8.106.80: Flags [S], seq 1591982963, win 29200, options [mss 1460,sackOK,TS val 3751111666 ecr 0,nop,wscale 9], length 0

果然是!即使权重为 0,仍然会尝试发 SYN 包跟这个旧 Pod 建立连接,但永远无法收到 ACK,因为旧 Pod 已经销毁了。为什么会这样呢?难道是 IPVS 内核模块的调度算法有问题?尝试去看了下 linux 内核源码,并没有发现哪个调度策略的实现函数会将新连接调度到权重为 0 的 rs 上。

这就奇怪了,可能不是调度算法的问题?继续尝试看更多的代码,主要是 net/netfilter/ipvs/ip_vs_core.c 中的 ip_vs_in 函数,也就是 IPVS 模块处理报文的主要入口,发现它会先在本地连接转发表看这个包是否已经有对应的连接了(匹配五元组),如果有就说明它不是新连接也就不会调度,直接发给这个连接对应的之前已经调度过的 rs (也不会判断权重);如果没匹配到说明这个包是新的连接,就会走到调度这里 (rr, wrr 等调度策略),这个逻辑看起来也没问题。

那为什么会转发到权重为 0 的 rs ?难道是匹配连接这里出问题了?新的连接匹配到了旧的连接?我开始做实验验证这个猜想,修改一下这里的逻辑:检查匹配到的连接对应的 rs 如果权重为 0,则重新调度。然后重新编译和加载 IPVS 内核模块,再重新压测一下,发现问题解决了!没有报 “No route to host” 了。

虽然通过改内核源码解决了,但我知道这不是一个好的解决方案,它会导致 IPVS 不支持连接的优雅结束,因为不再转发包给权重为 0 的 rs,存量的连接就会立即中断。

继续陷入深思……

这个实验只是证明了猜想:新连接匹配到了旧连接。那为什么会这样呢?难道新连接报文的五元组跟旧连接的相同了?

经过一番思考,发现这个是有可能的。因为 ServiceA 作为 client 请求 ServiceB,不同请求的源 IP 始终是相同的,关键点在于源端口是否可能相同。由于 ServiceA 向 ServiceB 发起大量短连接,ServiceA 所在节点就会有大量 TIME_WAIT 状态的连接,需要等 2 分钟 (2*MSL) 才会清理,而由于连接量太大,每次发起的连接都会占用一个源端口,当源端口不够用了,就会重用 TIME_WAIT 状态连接的源端口,这个时候当报文进入 IPVS 模块,检测到它的五元组跟本地连接转发表中的某个连接一致(TIME_WAIT 状态),就以为它是一个存量连接,然后直接将报文转发给这个连接之前对应的 rs 上,然而这个 rs 对应的 Pod 早已销毁,所以抓包看到的现象是将 SYN 发给了旧 Pod,并且无法收到 ACK,伴随着返回 ICMP 告知这个 IP 不可达,也被应用解释为 “No route to host”。

后来无意间又发现一个还在 open 状态的 issue,虽然还没提到 “No route to host” 关键字,但讨论的跟我们这个其实是同一个问题。我也参与了讨论,有兴趣的同学可以看下:https://github.com/kubernetes/kubernetes/issues/81775

总结

这个问题通常发生的场景就是类似于我们测试环境这种:ServiceA 对外提供服务,当外部发起请求,ServiceA 会通过 rpc 或 http 调用 ServiceB,如果外部请求量变大,ServiceA 调用 ServiceB 的量也会跟着变大,大到一定程度,ServiceA 所在节点源端口不够用,复用 TIME_WAIT 状态连接的源端口,导致五元组跟 IPVS 里连接转发表中的 TIME_WAIT 连接相同,IPVS 就认为这是一个存量连接的报文,就不判断权重直接转发给之前的 rs,导致转发到已销毁的 Pod,从而发生 “No route to host”。

如何规避?集群规模小可以使用 iptables 模式,如果需要使用 ipvs 模式,可以增加 ServiceA 的副本,并且配置反亲和性 (podAntiAffinity),让 ServiceA 的 Pod 部署到不同节点,分摊流量,避免流量集中到某一个节点,导致调用 ServiceB 时源端口复用。

如何彻底解决?暂时还没有一个完美的方案。

Issue 85517 讨论让 kube-proxy 支持自定义配置几种连接状态的超时时间,但这对 TIME_WAIT 状态无效。

Issue 81308 讨论 IVPS 的优雅结束是否不考虑不活跃的连接 (包括 TIME_WAIT 状态的连接),也就是只考虑活跃连接,当活跃连接数为 0 之后立即踢掉 rs。这个确实可以更快的踢掉 rs,但无法让优雅结束做到那么优雅了,并且有人测试了,即便是不考虑不活跃连接,当请求量很大,还是不能很快踢掉 rs,因为源端口复用还是会导致不断有新的连接占用旧的连接,在较新的内核版本,SYN_RECV 状态也被视为活跃连接,所以活跃连接数还是不会很快降到 0。

这个问题的终极解决方案该走向何方,我们拭目以待,感兴趣的同学可以持续关注 issue 81775 并参与讨论。想学习更多 K8S 知识,可以关注本人的开源书《Kubernetes实践指南》: https://k8s.imroc.io