揭秘:如何为 Kubernetes 实现原地升级

2023年 7月 9日 27.0k 0

揭秘:如何为 Kubernetes 实现原地升级-1

作者 | 王思宇(酒祝)  阿里云技术专家

概念介绍

原地升级一词中,“升级”不难理解,是将应用实例的版本由旧版替换为新版。那么如何结合 Kubernetes 环境来理解“原地”呢?

我们先来看看 K8s 原生 workload 的发布方式。这里假设我们需要部署一个应用,包括 foo、bar 两个容器在 Pod 中。其中,foo 容器第一次部署时用的镜像版本是 v1,我们需要将其升级为 v2 版本镜像,该怎么做呢?

  • 如果这个应用使用 Deployment 部署,那么升级过程中 Deployment 会触发新版本 ReplicaSet 创建 Pod,并删除旧版本 Pod。如下图所示:

揭秘:如何为 Kubernetes 实现原地升级-2

在本次升级过程中,原 Pod 对象被删除,一个新 Pod 对象被创建。新 Pod 被调度到另一个 Node 上,分配到一个新的 IP,并把 foo、bar 两个容器在这个 Node 上重新拉取镜像、启动容器。

  • 如果这个应该使用 StatefulSet 部署,那么升级过程中 StatefulSet 会先删除旧 Pod 对象,等删除完成后用同样的名字在创建一个新的 Pod 对象。如下图所示:

揭秘:如何为 Kubernetes 实现原地升级-3

值得注意的是,尽管新旧两个 Pod 名字都叫 pod-0,但其实是两个完全不同的 Pod 对象(uid也变了)。StatefulSet 等到原先的 pod-0 对象完全从 Kubernetes 集群中被删除后,才会提交创建一个新的 pod-0 对象。而这个新的 Pod 也会被重新调度、分配IP、拉镜像、启动容器。

  • 而所谓原地升级模式,就是在应用升级过程中避免将整个 Pod 对象删除、新建,而是基于原有的 Pod 对象升级其中某一个或多个容器的镜像版本:

揭秘:如何为 Kubernetes 实现原地升级-4

在原地升级的过程中,我们仅仅更新了原 Pod 对象中 foo 容器的 image 字段来触发 foo 容器升级到新版本。而不管是 Pod 对象,还是 Node、IP 都没有发生变化,甚至 foo 容器升级的过程中 bar 容器还一直处于运行状态。

总结:这种只更新 Pod 中某一个或多个容器版本、而不影响整个 Pod 对象、其余容器的升级方式,被我们称为 Kubernetes 中的原地升级。

收益分析

那么,我们为什么要在 Kubernetes 中引入这种原地升级的理念和设计呢?

首先,这种原地升级的模式极大地提升了应用发布的效率,根据非完全统计数据,在阿里环境下原地升级至少比完全重建升级提升了 80% 以上的发布速度。这其实很容易理解,原地升级为发布效率带来了以下优化点:

  • 节省了调度的耗时,Pod 的位置、资源都不发生变化;
  • 节省了分配网络的耗时,Pod 还使用原有的 IP;
  • 节省了分配、挂载远程盘的耗时,Pod 还使用原有的 PV(且都是已经在 Node 上挂载好的);
  • 节省了大部分拉取镜像的耗时,因为 Node 上已经存在了应用的旧镜像,当拉取新版本镜像时只需要下载很少的几层 layer。
  • 其次,当我们升级 Pod 中一些 sidecar 容器(如采集日志、监控等)时,其实并不希望干扰到业务容器的运行。但面对这种场景,Deployment 或 StatefulSet 的升级都会将整个 Pod 重建,势必会对业务造成一定的影响。而容器级别的原地升级变动的范围非常可控,只会将需要升级的容器做重建,其余容器包括网络、挂载盘都不会受到影响。

    最后,原地升级也为我们带来了集群的稳定性和确定性。当一个 Kubernetes 集群中大量应用触发重建 Pod 升级时,可能造成大规模的 Pod 飘移,以及对 Node 上一些低优先级的任务 Pod 造成反复的抢占迁移。这些大规模的 Pod 重建,本身会对 apiserver、scheduler、网络/磁盘分配等中心组件造成较大的压力,而这些组件的延迟也会给 Pod 重建带来恶性循环。而采用原地升级后,整个升级过程只会涉及到 controller 对 Pod 对象的更新操作和 kubelet 重建对应的容器。

    技术背景

    在阿里巴巴内部,绝大部分电商应用在云原生环境都统一用原地升级的方式做发布,而这套支持原地升级的控制器就位于 OpenKruise 开源项目中。

    也就是说,阿里内部的云原生应用都是统一使用 OpenKruise 中的扩展 workload 做部署管理的,而并没有采用原生 Deployment/StatefulSet 等。

    那么 OpenKruise 是如何实现原地升级能力的呢?在介绍原地升级实现原理之前,我们先来看一些原地升级功能所依赖的原生 Kubernetes 功能:

    背景 1:Kubelet 针对 Pod 容器的版本管理

    每个 Node 上的 Kubelet,会针对本机上所有 Pod.spec.containers 中的每个 container 计算一个 hash 值,并记录到实际创建的容器中。

    如果我们修改了 Pod 中某个 container 的 image 字段,kubelet 会发现 container 的 hash 发生了变化、与机器上过去创建的容器 hash 不一致,而后 kubelet 就会把旧容器停掉,然后根据最新 Pod spec 中的 container 来创建新的容器。

    这个功能,其实就是针对单个 Pod 的原地升级的核心原理。

    背景 2:Pod 更新限制

    在原生 kube-apiserver 中,对 Pod 对象的更新请求有严格的 validation 校验逻辑:

    // validate updateable fields:
    // 1.  spec.containers[*].image
    // 2.  spec.initContainers[*].image
    // 3.  spec.activeDeadlineSeconds

    简单来说,对于一个已经创建出来的 Pod,在 Pod Spec 中只允许修改 containers/initContainers 中的 image 字段,以及 activeDeadlineSeconds 字段。对 Pod Spec 中所有其他字段的更新,都会被 kube-apiserver 拒绝。

    背景 3:containerStatuses 上报

    kubelet 会在 pod.status 中上报 containerStatuses,对应 Pod 中所有容器的实际运行状态:

    apiVersion: v1
    kind: Pod
    spec:
      containers:
      - name: nginx
        image: nginx:latest
    status:
      containerStatuses:
      - name: nginx
        image: nginx:mainline
        imageID: docker-pullable://nginx@sha256:2f68b99bc0d6d25d0c56876b924ec20418544ff28e1fb89a4c27679a40da811b

    绝大多数情况下,spec.containers[x].image 与 status.containerStatuses[x].image 两个镜像是一致的。

    但是也有上述这种情况,kubelet 上报的与 spec 中的 image 不一致(spec 中是 nginx:latest,但 status 中上报的是 nginx:mainline)。

    这是因为,kubelet 所上报的 image 其实是从 CRI 接口中拿到的容器对应的镜像名。而如果 Node 机器上存在多个镜像对应了一个 imageID,那么上报的可能是其中任意一个:

    $ docker images | grep nginx
    nginx            latest              2622e6cca7eb        2 days ago          132MB
    nginx            mainline            2622e6cca7eb        2 days ago

    因此,一个 Pod 中 spec 和 status 的 image 字段不一致,并不意味着宿主机上这个容器运行的镜像版本和期望的不一致。

    背景 4:ReadinessGate 控制 Pod 是否 Ready

    在 Kubernetes 1.12 版本之前,一个 Pod 是否处于 Ready 状态只是由 kubelet 根据容器状态来判定:如果 Pod 中容器全部 ready,那么 Pod 就处于 Ready 状态。

    但事实上,很多时候上层 operator 或用户都需要能控制 Pod 是否 Ready 的能力。因此,Kubernetes 1.12 版本之后提供了一个 readinessGates 功能来满足这个场景。如下:

    apiVersion: v1
    kind: Pod
    spec:
      readinessGates:
      - conditionType: MyDemo
    status:
      conditions:
      - type: MyDemo
        status: "True"
      - type: ContainersReady
        status: "True"
      - type: Ready
        status: "True"

    目前 kubelet 判定一个 Pod 是否 Ready 的两个前提条件:

  • Pod 中容器全部 Ready(其实对应了 ContainersReady condition 为 True);
  • 如果 pod.spec.readinessGates 中定义了一个或多个 conditionType,那么需要这些 conditionType 在 pod.status.conditions 中都有对应的 status: “true” 的状态。
  • 只有满足上述两个前提,kubelet 才会上报 Ready condition 为 True。

    实现原理

    了解了上面的四个背景之后,接下来分析一下 OpenKruise 是如何在 Kubernetes 中实现原地升级的原理。

    1. 单个 Pod 如何原地升级?

    由“背景 1”可知,其实我们对一个存量 Pod 的 spec.containers[x] 中字段做修改,kubelet 会感知到这个 container 的 hash 发生了变化,随即就会停掉对应的旧容器,并用新的 container 来拉镜像、创建和启动新容器。

    由“背景 2”可知,当前我们对一个存量 Pod 的 spec.containers[x] 中的修改,仅限于 image 字段。

    因此,得出第一个实现原理:**对于一个现有的 Pod 对象,我们能且只能修改其中的 spec.containers[x].image 字段,来触发 Pod 中对应容器升级到一个新的 image。

    2. 如何判断 Pod 原地升级成功?

    接下来的问题是,当我们修改了 Pod 中的 spec.containers[x].image 字段后,如何判断 kubelet 已经将容器重建成功了呢?

    由“背景 3”可知,比较 spec 和 status 中的 image 字段是不靠谱的,因为很有可能 status 中上报的是 Node 上存在的另一个镜像名(相同 imageID)。

    因此,得出第二个实现原理:判断 Pod 原地升级是否成功,相对来说比较靠谱的办法,是在原地升级前先将 status.containerStatuses[x].imageID 记录下来。在更新了 spec 镜像之后,如果观察到 Pod 的 status.containerStatuses[x].imageID 变化了,我们就认为原地升级已经重建了容器。

    但这样一来,我们对原地升级的 image 也有了一个要求:不能用 image 名字(tag)不同、但实际对应同一个 imageID 的镜像来做原地升级,否则可能一直都被判断为没有升级成功(因为 status 中 imageID 不会变化)。

    当然,后续我们还可以继续优化。OpenKruise 即将开源镜像预热的能力,会通过 DaemonSet 在每个 Node 上部署一个 NodeImage Pod。通过 NodeImage 上报我们可以得知 pod spec 中的 image 所对应的 imageID,然后和 pod status 中的 imageID 比较即可准确判断原地升级是否成功。

    3. 如何确保原地升级过程中流量无损?

    在 Kubernetes 中,一个 Pod 是否 Ready 就代表了它是否可以提供服务。因此,像 Service 这类的流量入口都会通过判断 Pod Ready 来选择是否能将这个 Pod 加入 endpoints 端点中。

    由“背景 4”可知,从 Kubernetes 1.12+ 之后,operator/controller 这些组件也可以通过设置 readinessGates 和更新 pod.status.conditions 中的自定义 type 状态,来控制 Pod 是否可用。

    因此,得出第三个实现原理:可以在 pod.spec.readinessGates 中定义一个叫 InPlaceUpdateReady 的 conditionType。

    在原地升级时:

  • 先将 pod.status.conditions 中的 InPlaceUpdateReady condition 设为 “False”,这样就会触发 kubelet 将 Pod 上报为 NotReady,从而使流量组件(如 endpoint controller)将这个 Pod 从服务端点摘除;
  • 再更新 pod spec 中的 image 触发原地升级。
  • 原地升级结束后,再将 InPlaceUpdateReady condition 设为 “True”,使 Pod 重新回到 Ready 状态。

    另外在原地升级的两个步骤中,第一步将 Pod 改为 NotReady 后,流量组件异步 watch 到变化并摘除端点可能是需要一定时间的。因此我们也提供优雅原地升级的能力,即通过 gracePeriodSeconds 配置在修改 NotReady 状态和真正更新 image 触发原地升级两个步骤之间的静默期时间。

    4. 组合发布策略

    原地升级和 Pod 重建升级一样,可以配合各种发布策略来执行:

    • partition:如果配置 partition 做灰度,那么只会将 replicas-partition 数量的 Pod 做原地升级;
    • maxUnavailable:如果配置 maxUnavailable,那么只会将满足 unavailable 数量的 Pod 做原地升级;
    • maxSurge:如果配置 maxSurge 做弹性,那么当先扩出来 maxSurge 数量的 Pod 之后,存量的 Pod 仍然使用原地升级;
    • priority/scatter:如果配置了发布优先级/打散策略,会按照策略顺序对 Pod 做原地升级。

    总结

    如上文所述,OpenKruise 结合 Kubernetes 原生提供的 kubelet 容器版本管理、readinessGates 等功能,实现了针对 Pod 的原地升级能力。

    而原地升级也为应用发布带来大幅的效率、稳定性提升。值得关注的是,随着集群、应用规模的增大,这种提升的收益越加明显。正是这种原地升级能力,在近两年帮助了阿里巴巴超大规模的应用容器平稳迁移到了基于 Kubernetes 的云原生环境,而原生 Deployment/StatefulSet 是完全无法在这种体量的环境下铺开使用的。(欢迎加入钉钉交流群:23330762)

    “阿里巴巴云原生关注微服务、Serverless、容器、Service Mesh 等技术领域、聚焦云原生流行技术趋势、云原生大规模的落地实践,做最懂云原生开发者的公众号。”

    相关文章

    KubeSphere 部署向量数据库 Milvus 实战指南
    探索 Kubernetes 持久化存储之 Longhorn 初窥门径
    征服 Docker 镜像访问限制!KubeSphere v3.4.1 成功部署全攻略
    那些年在 Terraform 上吃到的糖和踩过的坑
    无需 Kubernetes 测试 Kubernetes 网络实现
    Kubernetes v1.31 中的移除和主要变更

    发布评论