Graceful shutdown
优雅停止(Graceful shutdown),在停止程序之前先完成资源清理工作。比如:
- 操作数据:清理、转移数据。数据库节点发生重启时需要考虑
- 反注册:程序退出之前通知网关或服务注册中心,服务下线后再停止服务,此时不会有任何流量受到服务停止的影响。
Prestop Hook
一般情况当Pod
停止后,k8s会把Pod
从service中摘除,同时程序内部对SIGTERM信号进行处理就可以满足优雅停止的需求。但如果Pod
通过注册中心向外暴露ip,并直接接受外部流量,则需要做一些额外的事情。此时就需要用到Prestop hook,目前kubernetes提供目前提供了 Exec
和 HTTP
两种方式,使用时需要通过 Pod 的 .spec.containers[].lifecycle.preStop
字段为 Pod 中的每个容器单独配置,比如:
apiVersion: v1
kind: Pod
metadata:
name: lifecycle-demo
spec:
containers:
- name: lifecycle-demo-container
image: nginx
lifecycle:
preStop:
exec:
command: ["/bin/sh","-c","nginx -s quit; while killall -0 nginx; do sleep 1; done"]
pod删除流程
为了方便理解Prestop Hook工作原理,下面说明一下Pod
的退出流程
- API-Server接受到请求后更新Pod中的DeletionTimestamp以及DeletionGracePeriodSeconds。Pod 进入 Terminating 状态
Pod会进行停止的相关处理
- 如果存在Prestop Hook,kubelet 会调用每个容器的 preStop hook,假如 preStop hook 的运行时间超出了 grace period,kubelet 会发送 SIGTERM 并再等 2 秒(可以通过调整参数
terminationGracePeriodSeconds
以适应每个pod的退出流程,默认30s) - kubelet 发送 TERM信号给每个container中的1号进程
- 如果存在Prestop Hook,kubelet 会调用每个容器的 preStop hook,假如 preStop hook 的运行时间超出了 grace period,kubelet 会发送 SIGTERM 并再等 2 秒(可以通过调整参数
- 在优雅退出的同时,k8s 会将 Pod 从对应的 Service 上摘除
- grace period 超出之后,kubelet 发送 SIGKILL 给Pod中的所有运行容器;同上清理pause状态的container
- Kubelet向API-Server发送请求,强制删除Pod(通过将grace period设置为0)
- API Server删除Pod在etcd中的数据
详情参考官方说明:https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/
问题
- 无法预测 Pod 会在多久之内完成优雅退出,导致某些特殊资源不能释放或者牵引
- 优雅退出的代码逻辑需要很久才能处理完成或者存在BUG(此问题可以要求业务进行改造,下面不再说明)
- 程序代码没有处理SIGTERM(此问题可以要求业务进行改造,下面不再说明)
解决方案
为了保证业务稳定和数据安全,同时减少人工接入,需要额外方式协助完成停止后的处理流程。
删除原因
为了找到具体的解决方案需要明确Pod的删除原因有哪些
- kubectl 命令删除
- kubernetes go client调用api删除
- Pod update
- kubelet驱逐
- OOM
kubectl 命令删除
其实都是通过api调用完成Pod
删除
调用api删除
通过api调用完成Pod
删除
Pod update
大致可以分为两类
Deployment
Deployment
通过ReplicasSet
完成对应的操作
func (dc *DeploymentController) scaleReplicaSet(rs *apps.ReplicaSet, newScale int32, deployment *apps.Deployment, scalingOperation string) (bool, *apps.ReplicaSet, error) {
sizeNeedsUpdate := *(rs.Spec.Replicas) != newScale
annotationsNeedUpdate := deploymentutil.ReplicasAnnotationsNeedUpdate(rs, *(deployment.Spec.Replicas), *(deployment.Spec.Replicas)+deploymentutil.MaxSurge(*deployment))
scaled := false
var err error
if sizeNeedsUpdate || annotationsNeedUpdate {
rsCopy := rs.DeepCopy()
*(rsCopy.Spec.Replicas) = newScale
deploymentutil.SetReplicasAnnotations(rsCopy, *(deployment.Spec.Replicas), *(deployment.Spec.Replicas)+deploymentutil.MaxSurge(*deployment))
rs, err = dc.client.AppsV1().ReplicaSets(rsCopy.Namespace).Update(context.TODO(), rsCopy, metav1.UpdateOptions{})
if err == nil && sizeNeedsUpdate {
scaled = true
dc.eventRecorder.Eventf(deployment, v1.EventTypeNormal, "ScalingReplicaSet", "Scaled %s replica set %s to %d", scalingOperation, rs.Name, newScale)
}
}
return scaled, rs, err
}
上面的代码中通过dc.client.AppsV1().ReplicaSets(rsCopy.Namespace).Update(context.TODO(), rsCopy, metav1.UpdateOptions{})
方式,调整ReplicaSets中的设置完成对Pod
数量的调整。
manageReplicas
是ReplicasSet
中核心的方法,它会计算 ReplicasSet
需要创建或者删除多少个 Pod
并调用 API-Server
的接口进行操作,下面是调用删除Pod
接口
func (r RealPodControl) DeletePod(namespace string, podID string, object runtime.Object) error {
accessor, err := meta.Accessor(object)
if err != nil {
return fmt.Errorf("object does not have ObjectMeta, %v", err)
}
klog.V(2).InfoS("Deleting pod", "controller", accessor.GetName(), "pod", klog.KRef(namespace, podID))
if err := r.KubeClient.CoreV1().Pods(namespace).Delete(context.TODO(), podID, metav1.DeleteOptions{}); err != nil {
if apierrors.IsNotFound(err) {
klog.V(4).Infof("pod %v/%v has already been deleted.", namespace, podID)
return err
}
r.Recorder.Eventf(object, v1.EventTypeWarning, FailedDeletePodReason, "Error deleting: %v", err)
return fmt.Errorf("unable to delete pods: %v", err)
}
r.Recorder.Eventf(object, v1.EventTypeNormal, SuccessfulDeletePodReason, "Deleted pod: %v", podID)
return nil
}
最终是通过api完成对Pod
的删除
DaemonSet
DaemoSet删除Pod有几种情况
- 升级
- 调整nodeSelector、容忍等导致某个节点不再部署
升级
Pod升级策略由Spec.Update.Strategy字段指定,目前支持OnDelete和RollingUpdate两种模式
OnDelete
需要用户手动删除旧Pod,然后DaemonSets Contro‖er会利用更新后的Spec.Template创建新Pod。通过api调用完成Pod
删除
RollingUpdate
删除旧Pod操作,函数syncNodes
中完成具体操作,syncNodes
删除逻辑如下
...
klog.V(4).Infof("Pods to delete for daemon set %s: %+v, deleting %d", ds.Name, podsToDelete, deleteDiff)
deleteWait := sync.WaitGroup{}
deleteWait.Add(deleteDiff)
for i := 0; i < deleteDiff; i++ {
go func(ix int) {
defer deleteWait.Done()
if err := dsc.podControl.DeletePod(ds.Namespace, podsToDelete[ix], ds); err != nil {
dsc.expectations.DeletionObserved(dsKey)
if !apierrors.IsNotFound(err) {
klog.V(2).Infof("Failed deletion, decremented expectations for set %q/%q", ds.Namespace, ds.Name)
errCh <- err
utilruntime.HandleError(err)
}
}
}(i)
}
deleteWait.Wait()
...
上面最终是通过调用podControl.DeletePod
完成的删除,是通过api调用完成Pod
删除
节点调整
func (dsc *DaemonSetsController) manage(ds *apps.DaemonSet, nodeList []*v1.Node, hash string) error {
// 1、获取已存在 daemon pod 与 node 的映射关系
nodeToDaemonPods, err := dsc.getNodesToDaemonPods(ds)
......
// 2、判断每一个 node 是否需要运行 daemon pod
var nodesNeedingDaemonPods, podsToDelete []string
for _, node := range nodeList {
nodesNeedingDaemonPodsOnNode, podsToDeleteOnNode, err := dsc.podsShouldBeOnNode(
node, nodeToDaemonPods, ds)
if err != nil {
continue
}
nodesNeedingDaemonPods = append(nodesNeedingDaemonPods, nodesNeedingDaemonPodsOnNode...)
podsToDelete = append(podsToDelete, podsToDeleteOnNode...)
}
// 3、判断是否启动了 ScheduleDaemonSetPods feature-gates 特性,若启用了则对不存在 node 上的
// daemon pod 进行删除
if utilfeature.DefaultFeatureGate.Enabled(features.ScheduleDaemonSetPods) {
podsToDelete = append(podsToDelete, getUnscheduledPodsWithoutNode(nodeList, nodeToDaemonPods)...)
}
// 4、为对应的 node 创建 daemon pod 以及删除多余的 pods
if err = dsc.syncNodes(ds, podsToDelete, nodesNeedingDaemonPods, hash); err != nil {
return err
}
return nil
}
syncNodes
中的删除逻辑已经在上面进行了说明,是通过api调用完成Pod
删除
kubelet驱逐
驱逐函数调用链m.evictPod()
=> m.killPodFunc() = killPodNow()的返回值
=> podWorkers.UpdatePod()
=> podWorkers.managePodLoop()
=> podWorkers.syncPodFn() = kubelet.syncPod()
。最终就是调用kubelet的syncPod()方法,把podPhase=Failed更新进去syncPod
里面调用了statusManager.SetPodStatus(pod, apiPodStatus)
,通过statusManager将Pod
信息同步到API-Server
,并没有调用接口删除Pod
,代码如下:
func (kl *Kubelet) syncPod(o syncPodOptions) error {
// pull out the required options
pod := o.pod
mirrorPod := o.mirrorPod
podStatus := o.podStatus
updateType := o.updateType
// if we want to kill a pod, do it now!
if updateType == kubetypes.SyncPodKill {
killPodOptions := o.killPodOptions
if killPodOptions == nil || killPodOptions.PodStatusFunc == nil {
return fmt.Errorf("kill pod options are required if update type is kill")
}
apiPodStatus := killPodOptions.PodStatusFunc(pod, podStatus)
kl.statusManager.SetPodStatus(pod, apiPodStatus)
// we kill the pod with the specified grace period since this is a termination
if err := kl.killPod(pod, nil, podStatus, killPodOptions.PodTerminationGracePeriodSecondsOverride); err != nil {
kl.recorder.Eventf(pod, v1.EventTypeWarning, events.FailedToKillPod, "error killing pod: %v", err)
// there was an error killing the pod, so we return that error directly
utilruntime.HandleError(err)
return err
}
return nil
}
statusManager
通过syncPod
完成状态同步
func (m *manager) syncPod(uid types.UID, status versionedPodStatus) {
// 1、判断是否需要同步状态
if !m.needsUpdate(uid, status) {
klog.V(1).Infof("Status for pod %q is up-to-date; skipping", uid)
return
}
// 2、获取 pod 的 oldStatus
pod, err := m.kubeClient.CoreV1().Pods(status.podNamespace).Get(status.podName, metav1.GetOptions{})
if errors.IsNotFound(err) {
return
}
if err != nil {
return
}
translatedUID := m.podManager.TranslatePodUID(pod.UID)
// 3、检查 pod UID 是否已经改变
if len(translatedUID) > 0 && translatedUID != kubetypes.ResolvedPodUID(uid) {
return
}
// 4、同步 pod 最新的 status 至 apiserver
oldStatus := pod.Status.DeepCopy()
newPod, patchBytes, err := statusutil.PatchPodStatus(m.kubeClient, pod.Namespace, pod.Name, *oldStatus, mergePodStatus(*oldStatus, status.status))
if err != nil {
return
}
pod = newPod
...
kubelet的驱逐带来了很多不确定性,其实可以通过 自定义调度功能
来替代,生产环境应该避免kubelet的主动驱逐
OOM
oom killer其实是内核自我保护的机制,当宿主机资源不足时,内核为了维护自身的稳定性,会杀死某些进程(其实就是Containaer),此时不会有任何api调用同时也不会调用PreStop。为了捕获到这种情况,只能在controller中增加对Pod的watch,针对Pod的DELETE且没有进行外部资源是否的进行补救处理
方案一
在不考虑kubelet驱逐的情况下,通过ValidatingAdmissionWebhook截取Pod Delete请求,并附加额外操作就能满足在资源没有释放完全之前不删除Pod
Webhook处理流程
具体说明可以参考官网
时序图
Pod Delete请求与资源释放的时序图
流程如下:
- 通过API删除
Pod
API-Server
接收到请求后,调用外部WebHook
进行校验WebHook
需要先识别出Pod
是否需要释放资源。同时需要检查资源是否进行了释放,如果资源已释放,则同意删除;如果需要首先创建一个CRD
实例,同时拒绝请求,Pod
将不会被删除(创建CRD
目的主要是针对用户手动删除的这种情况,其他删除都是由各种资源的controller触发的,为了满足状态需要会不断触发删除请求)- controller发现新的
CRD
资源创建以后清理Pod
外部资源(注册中心、数据等) - 如果清理未完成,整个流程会因为 controller 的控制循环回到第 4 步
- 清理完成后由controller删除对应的
Pod
影响
- Pod delete: 清理工作未完成时,Pod无法删除
- Pod update: 清理工作未完成时,不能进行Pod特殊资源(需要重启Pod才能完成的设置,比如镜像)的更新
方案二
使用sidecar解决注册和反注册的优缺点分析
类别 | 优点 | 缺点 |
---|---|---|
注册 | 1. 实现简单 2. 问题单一,不会影响全局 3. 不会出现性能瓶颈 | 1. sidecar需要配置信息(注册中心地址) 2. 需要识别出每个Pod中哪些需要注册,哪些不需要注册 3. 需要识别出应用的健康状态 4. 需要解决sidecar升级问题 5. 造成资源浪费 6. sidecar异常会导致整个Pod异常 |
反注册 | 同上 | 1. 没有解决时长问题,需要为每个Pod单独配置优雅停止时间 2. 需要通过健康检查的方式处理OOM的情况 3. 无法处理驱逐情况的资源释放 4. 需要解决sidecar升级问题 5. 造成资源浪费 6. sidecar异常会导致整个Pod异常 |
说明:
- 方案一适用于比较复杂的情况,比如需要处理数据、同步状态而且不知道需要多长才能完成
- 方案二适用于简单的情况
结构图
说明:
- sidecar需要通过container的状态结合service中的endpoint判断服务的健康状态判断服务是否满足注册和反注册的条件
- sidecar需要捕获term信号,收到信后进行反注册(Pod正常删除)
- sidecar需要配置Prestop,在Prestop中调用sidecar的反注册函数(用于处理驱逐情况,kubelet驱逐过程中会主动调用container的Prestop,参考killPod),收到信后进行反注册
有疑问加站长微信联系(非本文作者)