OpenKruise 源码剖析之原地升级

OpenKruise 是基于 CRD 的拓展,包含了很多应用工作负载和运维增强能力,本系列文章会从源码和底层原理上解读各个组件,以帮助大家更好地使用和理解 OpenKruise。让我们开始 OpenKruise 的源码之旅吧!

1. 背景

OpenKruise 是针对 Kubernetes 的增强能力套件,聚焦于云原生应用的部署、升级、运维、稳定性防护等领域。OpenKruise 提供的绝大部分能力都是基于 CRD 扩展来定义,它们不存在于任何外部依赖,可以运行在任意纯净的 Kubernetes 集群中。它包含了一系列增强版本的 Workloads(工作负载),比如 CloneSet、Advanced StatefulSet、Advanced DaemonSet、BroadcastJob 等, 它们不仅支持类似于 Kubernetes 原生 Workloads 的基础功能,还提供了如原地升级、可配置的扩缩容/发布策略、并发操作等。

其中原地升级是 OpenKruise 的核心功能, 它只需要使用新的镜像重建 Pod 中的特定容器,整个 Pod 以及其中的其他容器都不会被影响。因此它带来了更快的发布速度,以及避免了对其他 Scheduler、CNI、CSI 等组件的负面影响, 像 CloneSet、AdvancedStatefulSet、AdvancedDaemonSet、SidecarSet 的热更新机制,ContainerRestartRequest 等功能都依赖原地升级。理解原地升级之后再去研究其他组件就会事半功倍,所以本文首先带大家分析原地升级的源码,来一窥其底层原理。

有关原地升级的使用和介绍可以先阅读这篇文档,下面让我们开始解读源码。

2. 源码解读

2.1 Before Pod Update

2.1.1 reconcile 入口函数

我们以 CloneSet 为例,当 CloneSet 更新后,相应的 controller 感知到资源变化,此时代码会走到 cloneset_controller.godoReconcile 函数,该函数是处理 CloneSet 更新的主干入口。

1
func (r *ReconcileCloneSet) doReconcile(request reconcile.Request) (res reconcile.Result, retErr error)

2.1.2 syncCloneSet

经过一系列检查后,执行到 syncCloneSet, 该函数主要是处理 CloneSet 的 scale 和 update pod 的细节, 我们这里只关注 update 操作。

1
2
3
4
5
func (r *ReconcileCloneSet) syncCloneSet(
instance *appsv1alpha1.CloneSet, newStatus *appsv1alpha1.CloneSetStatus,
currentRevision, updateRevision *apps.ControllerRevision, revisions []*apps.ControllerRevision,
filteredPods []*v1.Pod, filteredPVCs []*v1.PersistentVolumeClaim,
) error

Kruise 专门为这两个操作声明了两个 interface,

1
2
3
4
5
6
7
8
9
10
11
12
13
// Interface for managing pods scaleing and updating.
type Interface interface {
Scale(
currentCS, updateCS *appsv1alpha1.CloneSet,
currentRevision, updateRevision string,
pods []*v1.Pod, pvcs []*v1.PersistentVolumeClaim,
) (bool, error)

Update(cs *appsv1alpha1.CloneSet,
currentRevision, updateRevision *apps.ControllerRevision, revisions []*apps.ControllerRevision,
pods []*v1.Pod, pvcs []*v1.PersistentVolumeClaim,
) error
}

cloneset_update.go 中实现了上述接口。

1
2
3
4
func (c *realControl) Update(cs *appsv1alpha1.CloneSet,
currentRevision, updateRevision *apps.ControllerRevision, revisions []*apps.ControllerRevision,
pods []*v1.Pod, pvcs []*v1.PersistentVolumeClaim,
) error {}

2.1.3 Pod 状态检查

在对 pod 执行真正的 Update 之前,Kruise 做了很多的校验,比如更新 pod 的 lifecycle 的 state,设置可以更新的最大数量,过滤掉不符合 update 条件的 pod等。下面代码的注释中给出了详细的分析。

有关 lifecycle(生命周期钩子) 的更多介绍,可以继续阅读 这篇文档

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
for i, pod := range pods {
// 判断该 pod 是否暂停升级
if coreControl.IsPodUpdatePaused(pod) {
continue
}

var waitUpdate, canUpdate bool
if diffRes.updateNum > 0 {
waitUpdate = !clonesetutils.EqualToRevisionHash("", pod, updateRevision.Name)
} else {
waitUpdate = clonesetutils.EqualToRevisionHash("", pod, updateRevision.Name)
}
if waitUpdate {
switch lifecycle.GetPodLifecycleState(pod) {
// 准备删除的 Pod 就不升级了
case appspub.LifecycleStatePreparingDelete:
klog.V(3).Infof("CloneSet %s/%s find pod %s in state %s, so skip to update it",
cs.Namespace, cs.Name, pod.Name, lifecycle.GetPodLifecycleState(pod))
// 已经更新完成的 Pod 无须升级
case appspub.LifecycleStateUpdated:
klog.V(3).Infof("CloneSet %s/%s find pod %s in state %s but not in updated revision",
cs.Namespace, cs.Name, pod.Name, appspub.LifecycleStateUpdated)
canUpdate = true
default:
if gracePeriod, _ := appspub.GetInPlaceUpdateGrace(pod); gracePeriod != "" {
// 原地升级中提供了 graceful period 选项,作为优雅原地升级的策略。用户如果配置了 gracePeriodSeconds 这个字段,控制器在原地升级的过程中会先把 Pod status 改为 not-ready,然后等一段时间(gracePeriodSeconds),最后再去修改 Pod spec 中的镜像版本。 这样,就为 endpoints-controller 这些控制器留出了充足的时间来将 Pod 从 endpoints 端点列表中去除。
klog.V(3).Infof("CloneSet %s/%s find pod %s still in grace period %s, so skip to update it",
cs.Namespace, cs.Name, pod.Name, gracePeriod)
} else {
canUpdate = true
}
}
}
if canUpdate {
waitUpdateIndexes = append(waitUpdateIndexes, i)
}
}
......
// PUB 是 OPenKruise 的可用性防护组件,是原生 PDB 的升级版,在升级的场景里也需要检查一下是否符合 PUB 的要求
allowed, _, err := pubcontrol.PodUnavailableBudgetValidatePod(c.Client, pod, pubcontrol.NewPubControl(pub, c.controllerFinder, c.Client), pubcontrol.UpdateOperation, false)

这里有关于 PUB 的详细介绍,感兴趣的可以继续深入了解。

2.2 Check About Pod Inplace Update

2.2.1 选择 UpdateStrategy = Inplace

进入到 updatePod 函数,开始升级 Pod。要想使用原地升级机制,必须在 CloneSet 的 Spec 中指定 UpdateStrategy 的 Type 为 InPlaceIfPossible 或者 InPlaceOnly

1
2
3
4
if cs.Spec.UpdateStrategy.Type == appsv1alpha1.InPlaceIfPossibleCloneSetUpdateStrategyType ||
cs.Spec.UpdateStrategy.Type == appsv1alpha1.InPlaceOnlyCloneSetUpdateStrategyType {
...
}

2.2.2 CanUpdateInPlace 检查是否满足原地升级的条件

当 Kruise workload 的升级类型名为 InplaceOnly 的时候,表示强制使用原地升级,如果不满足原地升级条件,就会报错; 如果是 InPlaceIfPossible,它意味着 Kruise 会尽量对 Pod 采取原地升级,如果不能则退化到重建升级。

只有满足以下的改动条件会被允许执行原地升级:

  1. 更新 workload 中的 spec.template.metadata.*,比如 labels/annotations,Kruise 只会将 metadata 中的改动更新到存量 Pod 上。
  2. 更新 workload 中的 spec.template.spec.containers[x].image,Kruise 会原地升级 Pod 中这些容器的镜像,而不会重建整个 Pod。
  3. 从 Kruise v1.0 版本开始(包括 v1.0 alpha/beta),更新 spec.template.metadata.labels/annotations 并且 container 中有配置 env from 这些改动的 labels/anntations,Kruise 会原地升级这些容器来生效新的 env 值。

否则,其他字段的改动,比如 spec.template.spec.containers[x].env 或 spec.template.spec.containers[x].resources,都是会回退为重建升级。

完成这项检查的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func defaultCalculateInPlaceUpdateSpec(oldRevision, newRevision *apps.ControllerRevision, opts *UpdateOptions) *UpdateSpec {
...
patches, err := jsonpatch.CreatePatch(oldRevision.Data.Raw, newRevision.Data.Raw)
if err != nil {
return nil
}

oldTemp, err := GetTemplateFromRevision(oldRevision)
if err != nil {
return nil
}
newTemp, err := GetTemplateFromRevision(newRevision)
if err != nil {
return nil
}
...
}

defaultCalculateInPlaceUpdateSpec 会计算出新旧两个版本的差异,如果 diff 中只包含第2步骤中的改动,就执行原地升级。

2.2.3 更新 Pod Readiness-gate

符合原地升级条件的 pod 都会在 condition 中增加 InPlaceUpdateReady 的 ConditionType,开始原地升级的时候,将该值置为 false,如果 Pod 上层有 Service 的话,就会自动将准备升级的 pod 从 Endpoint 上摘下,避免升级过程中有流量损失。

1
2
3
4
5
6
7
8
if containsReadinessGate(pod) {
newCondition := v1.PodCondition{
Type: appspub.InPlaceUpdateReady,
LastTransitionTime: metav1.NewTime(Clock.Now()),
Status: v1.ConditionFalse,
Reason: "StartInPlaceUpdate",
}
}

2.3 Begin Inplace update

2.3.1 Pod annotation 中记录升级信息

1
2
3
4
5
6
7
inPlaceUpdateState := appspub.InPlaceUpdateState{
Revision: spec.Revision,
UpdateTimestamp: metav1.NewTime(Clock.Now()),
UpdateEnvFromMetadata: spec.UpdateEnvFromMetadata,
}
inPlaceUpdateStateJSON, _ := json.Marshal(inPlaceUpdateState)
clone.Annotations[appspub.InPlaceUpdateStateKey] = string(inPlaceUpdateStateJSON)

2.3.2 根据配置设置 GracefulPeriod

原地升级中提供了 graceful period 选项,作为优雅原地升级的策略。用户如果配置了 gracePeriodSeconds 这个字段,控制器在原地升级的过程中会先把 Pod status 改为 not-ready,然后等一段时间(gracePeriodSeconds),最后再去修改 Pod spec 中的镜像版本。 这样,就为 endpoints-controller 这些控制器留出了充足的时间来将 Pod 从 endpoints 端点列表中去除。

1
2
3
4
5
6
7
8
9
if spec.GraceSeconds <= 0 {
if clone, err = opts.PatchSpecToPod(clone, spec, &inPlaceUpdateState); err != nil {
return err
}
appspub.RemoveInPlaceUpdateGrace(clone)
} else {
// Put the info into annotation
// 此处设置了 GracePeriod 后,效果会在 上面的 reconcile controller 中体现
}

2.3.3 Update Pod

在这里就调用 UpdatePod 方法开始真正对 Pod 做升级了。

1
newPod, updateErr := c.podAdapter.UpdatePod(clone)

以上原地升级相关的逻辑都是由 kruise_manager 组件负责的,但是当执行 UpdatePod 后,Kruise_manager 就只负责正常的 workload 状态更新,Container 的更新由原地升级的核心组件 Kruise-demaon 接管。

2.4 kruise_daemon

当 Kubelet 收到一个 Pod 创建之后,通过 CRI(Container Runtime Interface) , CNI 以及类似的公共接口(例如 CSI)来调用底层真正的接口实现者去完成操作。对于容器运行时来说,是通过 CRI 接口调用底层真正的 Runtime 运行时来完成对容器的创建和启动镜像拉取这些操作。其中 CRI 是 Kubernetes1.5 之后加入的一个新功能,由协议缓冲区和 gRPC API 组成,提供了一个明确定义的抽象层,它的目的是对于 Kubelet 能屏蔽底下 Runtime 实现的细节而只显示所需的接口。

Kruise_daemon 就是一个全新的组件,作为 DaemonSet 部署到每个节点上,可以连接到节点上的 CRI API,来拓展 Kubernetes 容器进行时的操作, 它也可以调用 CRI 这一层来实现 Container Runtime 层面的能力,比如它可以拉镜像,可以重启容器。

2.4.1 Kruise_daemon 入口

kruise_daemon 的入口函数在 kruise/cmd/daemon/main.go 中,

1
2
3
4
d, err := daemon.NewDaemon(cfg, *bindAddr)
if err != nil {
klog.Fatalf("Failed to new daemon: %v", err)
}

进入到 NewDaemon 函数中看一眼,发现这个 daemon 服务本质上也是几个 controller,用来监听相应的 Pod 变化并执行操作。

1
2
3
4
5
6
7
8
9
10
11
// kruise/pkg/daemon/daemon.go
// 在这里也能看到很多组件都在这个函数中注册了 controller,因为这些组件都依赖原地升级的能力,比如 ImagePull, ContaienrRestartRequest 等。

if utilfeature.DefaultFeatureGate.Enabled(features.DaemonWatchingPod) {
// DaemonWatchingPod enables kruise-daemon to list watch pods that belong to the same node.
containerMetaController, err := containermeta.NewController(opts)
if err != nil {
return nil, fmt.Errorf("failed to new containermeta controller: %v", err)
}
runnables = append(runnables, containerMetaController)
}

2.4.2 计算 PlainHash 或者 ExtractedEnvFromMetadataHash

2.4.2.1 如果只是 image update 的话,只需要更新 image 字段,kubelet 来执行 preStop 和 container restart。这是因为 Kubelet 在创建每个容器时,会为容器计算一个 hash 值,当上层修改了容器的 image 之后,Kubelet 就认为容器的 hash 值发生了变化。当 Kubelet 发现 Pod spec 中容器的 hash 值和实际的,如 container 对应的 hash 值不一致时,就会把旧的容器停掉,用新的镜像再重建新的容器,从而实现容器的原地升级的能力。

2.4.2.2 从 Kruise v1.0 版本开始(包括 v1.0 alpha/beta),更新 spec.template.metadata.labels/annotations 并且 container 中有配置 env from 这些改动的 labels/anntations,Kruise 会原地升级这些容器来生效新的 env 值。也就是修改环境变量 kruise 也支持原地重启,这部分工作就是由 kruise daemon 来完成的,核心的代码在 kruise/pkg/daemon/containermeta/container_meta_controller.go 中。

  1. 开启 InPlaceUpdateEnvFromMetadata feature gate
1
if utilfeature.DefaultFeatureGate.Enabled(features.InPlaceUpdateEnvFromMetadata)
  1. 计算 ExtractedEnvFromMetadataHash

    1
    containerMeta.Hashes.ExtractedEnvFromMetadataHash, err = envHasher.GetCurrentHash(containerSpec, envGetter)
  2. 将 container ID 传到 restarter 的处理队列中

    1
    c.restarter.queue.AddRateLimited(status.ID)

restarter controller 专门用来处理需要原地重启的 container 队列,核心逻辑在 sync 函数中,上一步中加到队列的 containerID 就会在这里被处理。

1
func (c *restartController) sync(containerID kubeletcontainer.ContainerID) error
  1. 执行 killCOntainer
1
func (m *genericRuntimeManager) KillContainer(pod *v1.Pod, containerID kubeletcontainer.ContainerID, containerName string, message string, gracePeriodOverride *int64) error

KillContainer 中有两个重要的操作,首先调用对旧的 container 执行 preStop (如果有的话),然后调用容器运行时接口 StopContainer将容器停止。

1
2
3
4
5
6
7
// Run the pre-stop lifecycle hooks if applicable and if there is enough time to run it
if containerSpec.Lifecycle != nil && containerSpec.Lifecycle.PreStop != nil && gracePeriod > 0 {
gracePeriod = gracePeriod - m.executePreStopHook(pod, containerID, containerSpec, gracePeriod)
}
...
err := m.runtimeService.StopContainer(containerID.ID, gracePeriod)
...

2.4.3 kubelet image pull & create container

在上一步中容器被停止后,kubelet 会开始新建容器,然后更新容器的状态。

2.4.4 kruise manager 同步 pod 状态

与此同时,相关的 controller 也在同步 Pod/workloads 升级的状态。

1
2
3
if err = r.statusUpdater.UpdateCloneSetStatus(instance, &newStatus, filteredPods); err != nil {
return reconcile.Result{}, err
}

2.4.5 kubelet 标记 Pod Ready

完成更新后,由 kubelet 标记 Pod Ready,kruise manager 将相应的 worklod 同步为更新完成。

至此,整个原地升级的流程就完成了。不难发现,原地升级除了用到原生 kubernetes 提供的 informer controller 机制外,最核心的也是最有亮点的地方就是巧妙地实现了一个类似 kubelet 的 plugin - kruise daemon, 为我们提供了一种全新的 Kubernetes 容器运行时 operations 的拓展思路。

3. 源码流程图

以上的代码可以总结简化为下面这张图

4. 总结

原地升级是 OpenKruise 的核心能力,是其他组件和功能实现的基石。了解其底层实现原理和源码能够扩宽自己的技术视野,加深对 kubernetes 的理解,给我们提供了一种新的拓展 kubelet 的新思路。同时,当我们在使用 Openkruise 遇到问题的时候,了解源码也有助于问题的排查。


-------------The End-------------

本文标题:OpenKruise 源码剖析之原地升级

文章作者:cloud sjhan

发布时间:2022年06月19日 - 11:06

最后更新:2022年07月02日 - 11:07

原始链接:https://cloudsjhan.github.io/2022/06/19/OpenKruise-源码解读之原地升级/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

cloud sjhan wechat
subscribe to my blog by scanning my public wechat account
坚持原创技术分享,您的支持将鼓励我继续创作!
0%
;