2

OpenKruise 源码剖析之原地升级

 1 year ago
source link: https://cloudsjhan.github.io/2022/06/19/OpenKruise-%E6%BA%90%E7%A0%81%E8%A7%A3%E8%AF%BB%E4%B9%8B%E5%8E%9F%E5%9C%B0%E5%8D%87%E7%BA%A7/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

OpenKruise 源码剖析之原地升级

2022-06-19

| OpenKruise

|

|

| 3,249

|

14

从源码解读 OpenKruise 原地升级的原理

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

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

005UfcOkly8h3se6zfp1kj30ge0dpjsh.jpg


其中原地升级是 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 更新的主干入口。

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

2.1.2 syncCloneSet

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

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,

// 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 中实现了上述接口。

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(生命周期钩子) 的更多介绍,可以继续阅读 这篇文档

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

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,都是会回退为重建升级。

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

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 上摘下,避免升级过程中有流量损失。

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 中记录升级信息

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 端点列表中去除。

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 做升级了。

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 实现的细节而只显示所需的接口。

005UfcOkly8h3sdj2vcjzj30mv0fraaw.jpg

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

2.4.1 Kruise_daemon 入口

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

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

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

// 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
if utilfeature.DefaultFeatureGate.Enabled(features.InPlaceUpdateEnvFromMetadata)
  1. 计算 ExtractedEnvFromMetadataHash

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

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

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

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

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

// 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 升级的状态。

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. 源码流程图

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

005UfcOkly8h3se47ep4tj31po0u0gq1.jpg

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



About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK