UK8S 是 UCloud 推出的 Kubernetes 容器云产品,完全兼容原生 API,为用户提供一站式云上 Kubernetes 服务。我们团队自研了 CNI(Container Network Interface)网络插件,深度集成 VPC,使 UK8S 容器应用拥有与云主机间等同的网络性能(目前最高可达 10Gb/s, 100 万 pps),并打通容器和物理云/托管云的网络。过程中,我们解决了开源 kubelet 创建多余 Sandbox 容器导致 Pod IP 莫名消失的问题,确保 CNI 插件正常运行,并准备将修复后的 kubelet 源码提交给社区。
深度集成 VPC 的网络方案
按照我们的设想,开发者可以在 UK8S 上部署、管理、扩展容器化应用,无需关心 Kubernetes 集群自身的搭建及维护等运维类工作。UK8S 完全兼容原生的 Kubernetes API,以 UCloud 公有云资源为基础,通过自研的插件整合打通了 ULB、UDisk、EIP 等公有云网络和存储产品,为用户提供一站式云上 Kubernetes 服务。
其中 VPC 既保障网络隔离,又提供灵活的 IP 地址定义等,是用户对网络的必备需求之一。UK8S 研发团队经过考察后认为,UCloud 基础网络平台具有原生、强大的底层网络控制能力,令我们能抛开 Overlay 方案,把 VPC 的能力上移到容器这一层,通过 VPC 的能力去实现控制和转发。UK8S 每创建一个 Pod 都为其申请一个 VPC IP 并通过 VethPair 配置到 Pod 上,再配置策略路由。原理如下图所示。
此方案具有以下优势:
无 Overlay,网络性能高。50 台 Node 下的测试数据表明,容器与容器之间的网络性能,相对于云主机与云主机之间,只有轻微差异(小包场景下,pps 会有 3~5% 损耗),而且 Pod 网络性能各项指标 (吞吐量,包量,延迟等) 不会随着节点规模增大而削减。而 Flannel UDP,VXLan 模式和 Calico IPIP 的模式存在明显的性能消耗。 Pod 能直通公有云和物理云。对于使用公有云和物理云的用户而言,业务上 K8S 少了一层障碍,多了一份便利。而 Flannel 的 host gw 模式下,容器无法访问公有云和物理云主机。 而 CNI 的工作流程如下所示。
创建 Pod 网络过程:
删除 Pod 网络过程:
Pod IP 消失问题的排查与解决
为了测试 CNI 插件的稳定性,测试同学在 UK8S 上部署了一个 CronJob,每分钟运行一个 Job 任务,一天要运行 1440 个任务。该 CronJob 定义如下:
apiVersion: batch/v1beta1 kind: CronJob metadata: name: hello spec: schedule: "*/1 * * * *" jobTemplate: spec: template: spec: containers: - name: hello image: busybox args: - /bin/sh - -c - date; echo Hello from the Kubernetes cluster restartPolicy: OnFailure
每运行一次 Job 都要创建一个 Pod,每创建一个 Pod,CNI 插件需要申请一次 VPC IP,当 Pod 被销毁时,CNI 插件需要释放该 VPC IP。因此理论上,通过该 CronJob 每天需要进行 1440 次申请 VPC IP 和释放 VPC IP 操作。
然而,经过数天的测试统计,发现通过该 CronJob,集群每天申请 IP 次数高达 2500 以上,而释放的的 IP 次数也达到了 1800。申请和释放次数都超过了 1440,而且申请次数超过了释放次数,意味着,部分分配给 Pod 的 VPC IP 被无效占用而消失了。
CNI:待删除的 IP 去哪儿了?
仔细分析 CNI 插件的运行日志,很快发现,CNI 在执行拆除 SandBox 网络动作(CNI_COMMAND=DEL)中,存在不少无法找到 Pod IP 的情况。由于 UK8S 自研的 CNI 查找 Pod IP 依赖正确的 Pod 网络名称空间路径 (格式:/proc/10001/net/ns),而 kubelet 传给 CNI 的 NETNS 环境变量参数为空字符串,因此,CNI 无法获取待释放的 VPC IP,这是造成 IP 泄露的直接原因,如下图所示。
问题转移到 kubelet,为什么 kubelet 会传入一个空的 CNI_NETNS 环境变量参数给 CNI 插件?
随后跟踪 kubelet 的运行日志,发现不少 Job Pod 创建和销毁的时候,生成了一个额外的 Sandbox 容器。Sandbox 容器是 k8s pod 中的 Infra 容器,它是 Pod 中第一个创建出来的容器,用于创建 Pod 的网络名称空间和初始化 Pod 网络,例如调用 CNI 分配 Pod IP,下发策略路由等。它执行一个名为 pause 的进程,这个进程绝大部分时间处于 Sleep 状态,对系统资源消耗极低。奇怪的是,当任务容器 busybox 运行结束后,kubelet 为 Pod 又创建了一个新的 Sandbox 容器,创建过程中自然又进行了一次 CNI ADD 调用,再次申请了一次 VPC IP。
回到 UK8S CNI,我们再次分析重现案例日志。这一次有了更进一步的发现,所有 kubelet 传递给 NETNS 参数为空字符串的情形都发生在 kubelet 试图销毁 Pod 中第二个 Sandbox 的过程中。反之,kubelet 试图销毁第二个 Sandbox 时,给 CNI 传入的 NETNS 参数也全部为空字符串。
到这里,思路似乎清晰了不少,所有泄露的 VPC IP 都是来自第二个 Sandbox 容器。因此,我们需要查清楚两个问题:
为什么会出现第二个 Sandbox 容器?
为什么 kubelet 在销毁第二个 Sandbox 容器时,给 CNI 传入了不正确的 NETNS 参数?
第二个 Sandbox:我为何而生?
在了解的第二个 Sandbox 的前世今生之前,需要先交待一下 kubelet 运行的基本原理和流程。
kubelet 是 kubernetes 集群中 Node 节点的工作进程。当一个 Pod 被 kube-sheduler 成功调度到 Node 节点上后,kubelet 负责将这个 Pod 创建出来,并把它所定义的各个容器启动起来。kubelet 也是按照控制器模式工作的,它的工作核心是一个控制循环,源码中称之为 syncLoop,这个循环关注并处理以下事件:
Pod 更新事件,源自 API Server; Pod 生命周期 (PLEG) 变化,源自 Pod 本身容器状态变化,例如容器的创建,开始运行,和结束运行; kubelet 本身设置的周期同步(Sync)任务; Pod 存活探测(LivenessProbe)失败事件; 定时的清理事件(HouseKeeping)。 在上文描述的 CronJob 任务中,每次运行 Job 任务都会创建一个 Pod。这个 Pod 的生命周期中,理想情况下,需要经历以下重要事件:
Pod 被成功调度到某个工作节点,节点上的 Kubelet 通过 Watch APIServer 感知到创建 Pod 事件,开始创建 Pod 流程;
kubelet 为 Pod 创建 Sandbox 容器,用于创建 Pod 网络名称空间和调用 CNI 插件初始化 Pod 网络,Sandbox 容器启动后,会触发第一次 kubelet PLEG(Pod Life Event Generator) 事件。
主容器创建并启动,触发第二次 PLEG 事件。
主容器 date 命令运行结束,容器终止,触发第三次 PLEG 事件。
kubelet 杀死 Pod 中残余的 Sandbox 容器。
Sandbox 容器被杀死,触发第四次 PLEG 事件。
其中 3 和 4 由于时间间隔短暂,可能被归并到同一次 PLEG 事件(kubelet 每隔 1s 进行一次 PLEG 事件更新)。
然而,在我们观察到的所有 VPC IP 泄露的情况中,过程 6 之后“意外地”创建了 Pod 的第二个 Sandbox 容器,如下图右下角所示。在我们对 Kubernetes 的认知中,这不应该发生。
对 kubelet 源码 (1.13.1) 抽丝剥茧
前文提到,syncLoop 循环会监听 PLEG 事件变化并处理之。而 PLEG 事件,则来源 kubelet 内部的一个 pleg relist 定时任务。kubelet 每隔一秒钟执行一次 relist 操作,及时获取容器的创建,启动,容器,删除事件。
relist 的主要责任是通过 CRI 来获取 Pod 中所有容器的实时状态,这里的容器被区分成两大类:Sandbox 容器和非 Sandbox 容器,kubelet 通过给容器打不同的 label 来识别之。CRI 是一个统一的容器操作 gRPC 接口,kubelet 对容器的操作,都要通过 CRI 请求来完成,而 Docker,Rkt 等容器项目则负责实现各自的 CRI 实现,Docker 的实现即为 dockershim,dockershim 负责将收到的 CRI 请求提取出来,翻译成 Docker API 发给 Docker Daemon。
relist 通过 CRI 请求更新到 Pod 中 Sandbox 容器和非 Sandbox 容器最新状态,然后将状态信息写入 kubelet 的缓存 podCache 中,如果有容器状态发生变化,则通过 pleg channel 通知到 syncLoop 循环。对于单个 pod,podCache 分配了两个数组,分别用于保存 Sandbox 容器和非 Sandbox 容器的最新状态。
syncLoop 收到 pleg channel 传来事件后,进入相应的 sync 同步处理流程。对于 PLEG 事件来说,对应的处理函数是 HandlePodSyncs。这个函数开启一个新的 pod worker goroutine,获取 pod 最新的 podCache 信息,然后进入真正的同步操作:syncPod 函数。
syncPod 将 podCache 中的 pod 最新状态信息 (podStatus) 转化成 Kubernetes API PodStatus 结构。这里值得一提的是,syncPod 会通过 podCache 里各个容器的状态,来计算出 Pod 的状态 (getPhase 函数),比如 Running,Failed 或者 Completed。然后进入 Pod 容器运行时同步操作:SyncPod 函数,即将当前的各个容器状态与 Pod API 定义的 SPEC 期望状态做同步。下面源码流程图可以总结上述流程。
SyncPod:我做错了什么?
SyncPod 首先计算 Pod 中所有容器的当前状态与该 Pod API 期望状态做对比同步。这一对比同步分为两个部分:
检查 podCache 中的 Sandbox 容器的状态是否满足此条件:Pod 中有且只有一个 Sandbox 容器,并且该容器处于运行状态,拥有 IP。如不满足,则认为该 Pod 需要重建 Sandbox 容器。如果需要重建 Sandbox 容器,Pod 内所有容器都需要销毁并重建。 检查 podCache 中非 Sandbox 容器的运行状态,保证这些容器处于 Pod API Spec 期望状态。例如,如果发现有容器主进程退出且返回码不为 0,则根据 Pod API Spec 中的 RestartPolicy 来决定是否重建该容器。 回顾前面提到的关键线索:所有的 VPC IP 泄露事件,都源于一个意料之外的 Sandbox 容器,被泄露的 IP 即为此 Sandbox 容器的 IP。刚才提到,SyncPod 函数中会对 Pod 是否需要重建 Sandbox 容器进行判定,这个意外的第二个 Sandbox 容器是否和这次判定有关呢?凭 kubelet 的运行日志无法证实该猜测,必须修改源码增加日志输出。重新编译 kubelet 后,发现第二个 Sandbox 容器确实来自 SyncPod 函数中的判定结果。进一步确认的是,该 SyncPod 调用是由第一个 Sandbox 容器被 kubelet 所杀而导致的 PLEG 触发的。
那为什么 SyncPod 在第一个 Sandbox 容器被销毁后认为 Pod 需要重建 Sandbox 容器呢?进入判定函数 podSandboxChanged 仔细分析。
podSandboxChanged 获取了 podCache 中 Sandbox 容器结构体实例,发现第一个 Sandbox 已经被销毁,处于 NOT READY 状态,于是认为 pod 中已无可用的 Sandbox 容器,需要重建之,源码如下图所示。
注意本文前面我们定位的 CronJob yaml 配置,Job 模板里的 restartPolicy 被设置成了 OnFailure。SyncPod 完成 Sandbox 容器状态检查判定后,认为该 Pod 需要重建 Sandbox 容器,再次检查 Pod 的 restartPolicy 为 OnFailure 后,决定重建 Sandbox 容器,对应源码如下。
可以看出 kubelet 在第一个 Sandbox 容器死亡后触发的 SyncPod 操作中,只是简单地发现唯一的 Sandbox 容器处于 NOT READY 状态,便认为 Pod 需要重建 Sandbox,忽视了 Job 的主容器已经成功结束的事实。
事实上,在前面 syncPod 函数中通过 podCache 计算 API PodStatus Phase 的过程中,kubelet 已经知道该 Pod 处于 Completed 状态并存入 apiPodStatus 变量中作为参数传递给 SyncPod 函数。如下图所示。
Job 已经进入 Completed 状态,此时不应该重建 Sandbox 容器。而 SyncPod 函数在判定 Sandbox 是否需要重建时,并没有参考调用者 syncPod 传入的 apiPodStatus 参数,甚至这个参数是被忽视的。
第二个 Sandbox 容器的来源已经水落石出,解决办法也非常简单,即 kubelet 不为已经 Completed 的 Pod 创建 Sandbox,具体代码如下所示。
重新编译 kubelet 并更新后,VPC IP 泄露的问题得到解决。
下图可以总结上面描述的第二个 Sandbox 容器诞生的原因。
事情离真相大白还有一段距离。还有一个问题需要回答:
为什么 kubelet 在删除第二个 Sandbox 容器的时候,调用 CNI 拆除容器网络时,传入了不正确的 NETNS 环境变量参数?
失去的 NETNS
还记得前面介绍 kubelet 工作核心循环 syncLoop 的时候,里面提到的定期清理事件 (HouseKeeping) 吗?HouseKeeping 是一个每隔 2s 运行一次的定时任务,负责扫描清理孤儿 Pod,删除其残余的 Volume 目录并停止该 Pod 所属的 Pod worker goroutine。HouseKeeping 发现 Job Pod 进入 Completed 状态后,会查找该 Pod 是否还有正在运行的残余容器,如有则请理之。由于第二个 Sandbox 容器依然在运行,因此 HouseKeeping 会将其清理,其中的一个步骤是清理该 Pod 所属的 cgroup,杀死该 group 中的所有进程,这样第二个 Sandbox 容器里的 pause 进程被杀,容器退出。
已经死亡的第二个 Sandbox 容器会被 kubelet 里的垃圾回收循环接管,它将被彻底停止销毁。然而由于之前的 Housekeeping 操作已经销毁了该容器的 cgroup, 网络名称空间不复存在,因此在调用 CNI 插件拆除 Sandbox 网络时,kubelet 无法获得正确的 NETNS 参数传给 CNI,只能传入空字符串。
到此,问题的原因已经确认。
问题解决
一切水落石出后,我们开始着手解决问题。为了能确保找到所删除的 Pod 对应的 VPC IP,CNI 需要在 ADD 操作成功后,将 PodName,Sandbox 容器 ID,NameSpace,VPC IP 等对应关联信息进行额外存储。这样当进入 DEL 操作后,只需要通过 kubelet 传入的 PodName,Sandbox 容器 ID 和 NameSpace 即可找到 VPC IP,然后通过 UCloud 公有云相关 API 删除之,无需依赖 NETNS 操作。
考虑到问题的根因是出现在 kubelet 源码中的 SyncPod 函数,UK8S 团队也已修复 kubelet 相关源码并准备提交 patch 给 Kubernetes 社区。
写在最后
Kubernetes 依然是一个高速迭代中的开源项目,生产环境中会不可用避免遇见一些异常现象。UK8S 研发团队在学习理解 Kubernetes 各个组件运行原理的同时,积极根据现网异常现象深入源码逐步探索出问题根因,进一步保障 UK8S 服务的稳定性和可靠性,提升产品体验。
2019 年内 UK8S 还将支持节点弹性伸缩 (Cluster AutoScaler)、物理机资源、GPU 资源、混合云和 ServiceMesh 等一系列特性,敬请期待。
欢迎扫描下方二维码,加入 UCloud K8S 技术交流群,和我们共同探讨 Kubernetes 前沿技术。
如显示群人数已加满,可添加群主微信 zhaoqi628543,备注 K8S 即可邀请入群。