如何優雅重啟 kubernetes 的 Pod

2023-10-20 15:00:51

最近在升級服務網格 Istio,升級後有個必要的流程就是需要重啟資料面的所有的 Pod,也就是業務的 Pod,這樣才能將這些 Pod 的 sidecar 更新為新版本。

方案 1

因為我們不同環境的 Pod 數不少,不可能手動一個個重啟;之前也做過類似的操作:

kubectl delete --all pods --namespace=dev

這樣可以一鍵將 dev 這個名稱空間下的 Pod 刪掉,kubernetes 之後會自動將這些 Pod 重啟,保證和應用的可用性。

但這有個大問題是對 kubernetes 的排程壓力較大,一般一個 namespace 下少說也是幾百個 Pod,全部需要重新排程啟動對 kubernetes 的負載會很高,稍有不慎就會有嚴重的後果。

所以當時我的第一版方案是遍歷所有的 deployment,刪除一個 Pod 後休眠 5 分鐘再刪下一個,虛擬碼如下:

deployments, err := clientSet.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{})  
if err != nil {  
    return err  
}
for _, deployment := range deployments.Items {
	podList, err := clientSet.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{  
	    LabelSelector: fmt.Sprintf("app=%s", deployment.Name),  
	})
	err = clientSet.CoreV1().Pods(pod.Namespace).Delete(ctx, pod.Name, metav1.DeleteOptions{})  
	if err != nil {  
	    return err  
	}  
	log.Printf("    Pod %s rebuild success.\n", pod.Name)
	time.Sleep(time.Minute * 5)	
}

存在的問題

這個方案確實是簡單粗暴,但在測試的時候就發現了問題。

當某些業務只有一個 Pod 的時候,直接刪掉之後這個業務就掛了,沒有多餘的副本可以提供服務了。

這肯定是不能接受的。

甚至還有刪除之後沒有重啟成功的:

  • 長期沒有重啟導致映象快取沒有了,甚至映象已經被刪除了,這種根本就沒法啟動成功。
  • 也有一些 Pod 有 Init-Container 會在啟動的時候做一些事情,如果失敗了也是沒法啟動成功的。
    總之就是有多種情況導致一個 Pod 無法正常啟動,這線上上就會直接導致生產問題,所以方案一肯定是不能用的。

方案二

為此我就準備了方案二:

  • 先將副本數+1,這是會新增一個 Pod,也會使用最新的 sidecar 映象。
  • 等待新建的 Pod 重啟成功。
  • 重啟成功後刪除原有的 Pod。
  • 再將副本數還原為之前的數量。

這樣可以將原有的 Pod 平滑的重啟,同時如果新的 Pod 啟動失敗也不會繼續重啟其他 Deployment 的 Pod,老的 Pod 也是一直保留的,對服務本身沒有任何影響。

存在的問題

看起來是沒有什麼問題的,就是實現起來比較麻煩,流程很繁瑣,這裡我貼了部分核心程式碼:

func RebuildDeploymentV2(ctx context.Context, clientSet kubernetes.Interface, ns string) error {
	deployments, err := clientSet.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{})
	if err != nil {
		return err
	}

	for _, deployment := range deployments.Items {

		// Print each Deployment
		log.Printf("Ready deployment: %s\n", deployment.Name)

		originPodList, err := clientSet.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{
			LabelSelector: fmt.Sprintf("app=%s", deployment.Name),
		})
		if err != nil {
			return err
		}

		// Check if there are any Pods
		if len(originPodList.Items) == 0 {
			log.Printf("	No pod in %s\n", deployment.Name)
			continue
		}

		// Skip Pods that have already been upgraded
		updateSkip := false
		for _, container := range pod.Spec.Containers {
			if container.Name == "istio-proxy" && container.Image == "proxyv2:1.x.x" {
				log.Printf("  Pod: %s Container: %s has already upgrade, skip\n", pod.Name, container.Name)
				updateSkip = true
			}
		}
		if updateSkip {
			continue
		}

		// Scale the Deployment, create a new pod.
		scale, err := clientSet.AppsV1().Deployments(ns).GetScale(ctx, deployment.Name, metav1.GetOptions{})
		if err != nil {
			return err
		}
		scale.Spec.Replicas = scale.Spec.Replicas + 1
		_, err = clientSet.AppsV1().Deployments(ns).UpdateScale(ctx, deployment.Name, scale, metav1.UpdateOptions{})
		if err != nil {
			return err
		}

		// Wait for pods to be scaled
		for {
			podList, err := clientSet.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{
				LabelSelector: fmt.Sprintf("app=%s", deployment.Name),
			})
			if err != nil {
				log.Fatal(err)
			}
			if len(podList.Items) != int(scale.Spec.Replicas) {
				time.Sleep(time.Second * 10)
			} else {
				break
			}
		}

		// Wait for pods to be running
		for {
			podList, err := clientSet.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{
				LabelSelector: fmt.Sprintf("app=%s", deployment.Name),
			})
			if err != nil {
				log.Fatal(err)
			}
			isPending := false
			for _, item := range podList.Items {
				if item.Status.Phase != v1.PodRunning {
					log.Printf("Deployment: %s Pod: %s Not Running Status: %s\n", deployment.Name, item.Name, item.Status.Phase)
					isPending = true
				}
			}
			if isPending == true {
				time.Sleep(time.Second * 10)
			} else {
				break
			}
		}

		// Remove origin pod
		for _, pod := range originPodList.Items {
			err = clientSet.CoreV1().Pods(ns).Delete(context.Background(), pod.Name, metav1.DeleteOptions{})
			if err != nil {
				return err
			}
			log.Printf("	Remove origin %s success.\n", pod.Name)
		}

		// Recover scale
		newScale, err := clientSet.AppsV1().Deployments(ns).GetScale(ctx, deployment.Name, metav1.GetOptions{})
		if err != nil {
			return err
		}
		newScale.Spec.Replicas = newScale.Spec.Replicas - 1
		newScale.ResourceVersion = ""
		newScale.UID = ""
		_, err = clientSet.AppsV1().Deployments(ns).UpdateScale(ctx, deployment.Name, newScale, metav1.UpdateOptions{})
		if err != nil {
			return err
		}
		log.Printf("	Depoloyment %s rebuild success.\n", deployment.Name)
		log.Println()

	}

	return nil
}

看的出來程式碼是比較多的。

最終方案

有沒有更簡單的方法呢,當我把上述的方案和領導溝通後他人都傻了,這也太複雜了:kubectl 不是有一個直接捲動重啟的命令嗎。

❯ k rollout -h
Manage the rollout of one or many resources.

Available Commands:
  history       View rollout history
  pause         Mark the provided resource as paused
  restart       Restart a resource
  resume        Resume a paused resource
  status        Show the status of the rollout
  undo          Undo a previous rollout

kubectl rollout restart deployment/abc
使用這個命令可以將 abc 這個 deployment 進行卷動更新,這個更新操作發生在 kubernetes 的伺服器端,執行的步驟和方案二差不多,只是 kubernetes 實現的比我的更加嚴謹。

後來我在檢視 Istio 的官方升級指南中也是提到了這個命令:

所以還是得好好看官方檔案

整合 kubectl

既然有現成的了,那就將這個命令整合到我的指令碼裡即可,再遍歷 namespace 下的 deployment 的時候迴圈呼叫就可以了。

但這個 rollout 命令在 kubernetesclient-goSDK 中是沒有這個 API 的。

所以我只有參考 kubectl 的原始碼,將這部分功能複製過來;不過好在可以直接依賴 kubect 到我的專案裡。

require (  
    k8s.io/api v0.28.2  
    k8s.io/apimachinery v0.28.2  
    k8s.io/cli-runtime v0.28.2  
    k8s.io/client-go v0.28.2  
    k8s.io/klog/v2 v2.100.1  
    k8s.io/kubectl v0.28.2  
)

原始碼裡使用到的 RestartOptions 結構體是公共存取的,所以我就參考它原始碼魔改了一下:

func TestRollOutRestart(t *testing.T) {  
    kubeConfigFlags := defaultConfigFlags()  
    streams, _, _, _ := genericiooptions.NewTestIOStreams()  
    ns := "dev"  
    kubeConfigFlags.Namespace = &ns  
    matchVersionKubeConfigFlags := cmdutil.NewMatchVersionFlags(kubeConfigFlags)  
    f := cmdutil.NewFactory(matchVersionKubeConfigFlags)  
    deploymentName := "deployment/abc"  
    r := &rollout.RestartOptions{  
       PrintFlags: genericclioptions.NewPrintFlags("restarted").WithTypeSetter(scheme.Scheme),  
       Resources:  []string{deploymentName},  
       IOStreams:  streams,  
    }  
    err := r.Complete(f, nil, []string{deploymentName})  
    if err != nil {  
       log.Fatal(err)  
    }  
    err = r.RunRestart()  
    if err != nil {  
       log.Fatal(err)  
    }  
}

最終在幾次 debug 後終於可以執行了,只需要將這部分邏輯移動到迴圈裡,加上 sleep 便可以有規律的重啟 Pod 了。

參考連結: