Kubernetes源码探疑:Pod IP泄露排查及解决 | U刻
技术分享/

Kubernetes源码探疑:Pod IP泄露排查及解决

  • Kubernetes源码探疑:Pod IP泄露排查及解决

    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容器。因此,我们需要查清楚两个问题:

    1. 为什么会出现第二个Sandbox容器?

    2. 为什么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的生命周期中,理想情况下,需要经历以下重要事件:

    1. Pod被成功调度到某个工作节点,节点上的Kubelet通过Watch APIServer感知到创建Pod事件,开始创建Pod流程;

    2. kubelet为Pod创建Sandbox容器,用于创建Pod网络名称空间和调用CNI插件初始化Pod网络,Sandbox容器启动后,会触发第一次kubelet PLEG(Pod Life Event Generator)事件。

    3. 主容器创建并启动,触发第二次PLEG事件。

    4. 主容器date命令运行结束,容器终止,触发第三次PLEG事件。

    5. kubelet杀死Pod中残余的Sandbox容器。

    6. 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前沿技术。