基於Kubernetes和OpenKruise的可變基礎設施實踐

xinkun 發表於 2020-11-27

本文首發在OPPO網際網路公眾號,歡迎點選轉載 https://mp.weixin.qq.com/s/hRvZz_bZfchmP0tkF6M2OA

對於可變基礎設施的思考

kubernetes中的可變與不可變基礎設施

在雲原生逐漸盛行的現在,不可變基礎設施的理念已經逐漸深入人心。不可變基礎設施最早是由Chad Fowler於2013年提出的,其核心思想為任何基礎設施的例項一旦建立之後變成為只讀狀態,如需要修改和升級,則使用新的例項進行替換。這一理念的指導下,實現了執行例項的一致,因此在提升釋出效率、彈性伸縮、升級回滾方面體現出了無與倫比的優勢。

kubernetes是不可變基礎設施理念的一個極佳實踐平臺。Pod作為k8s的最小單元,承擔了應用例項這一角色。通過ReplicaSet從而對Pod的副本數進行控制,從而實現Pod的彈性伸縮。而進行更新時,Deployment通過控制兩個ReplicaSet的副本數此消彼長,從而進行例項的整體替換,實現升級和回滾操作。

我們進一步思考,我們是否需要將Pod作為一個完全不可變的基礎設施例項呢?其實在kubernetes本身,已經提供了一個替換image的功能,來實現Pod不變的情況下,通過更換image欄位,實現Container的替換。這樣的優勢在於無需重新建立Pod,即可實現升級,直接的優勢在於免去了重新排程等的時間,使得容器可以快速啟動。

從這個思路延伸開來,那麼我們其實可以將Pod和Container分為兩層來看。將Container作為不可變的基礎設施,確保應用例項的完整替換;而將Pod看為可變的基礎設施,可以進行動態的改變,亦即可變層。

關於升級變化的分析

對於應用的升級變化種類,我們來進行一下分類討論,將其分為以下幾類:

升級變化型別 說明
規格的變化 cpu、記憶體等資源使用量的修改
配置的變化 環境變數、配置檔案等的修改
映象的變化 程式碼修改後映象更新
健康檢查的變化 readinessProbe、livenessProbe配置的修改
其他變化 排程域、標籤修改等其他修改

針對不同的變化型別,我們做過一次抽樣調查統計,可以看到下圖的一個統計結果。

image-20201116110018301

在一次升級變化中如果含有多個變化,則統計為多次。

可以看到支援映象的替換可以覆蓋一半左右的的升級變化,但是仍然有相當多的情況下導致不得不重新建立Pod。這點來說,不是特別友好。所以我們做了一個設計,將對於Pod的變化分為了三種Dynamic,Rebuild,Static三種。

修改型別 修改型別說明 修改舉例 對應變化型別
Dynamic 動態修改 Pod不變,容器無需重建 修改了健康檢查埠 健康檢查的變化
Rebuild 原地更新 Pod不變,容器需要重新建立 更新了映象、配置檔案或者環境變數 映象的變化,配置的變化
Static 靜態修改 Pod需要重新建立 修改了容器規格 規格的變化

這樣動態修改和原地更新的方式可以覆蓋90%以上的升級變化。在Pod不變的情況下帶來的收益也是顯而易見的。

  1. 減少了排程、網路建立等的時間。
  2. 由於同一個應用的映象大部分層都是複用的,大大縮短了映象拉取的時間。
  3. 資源鎖定,防止在叢集資源緊缺時由於出讓資源重新建立進入排程後,導致資源被其他業務搶佔而無法執行。
  4. IP不變,對於很多有狀態的服務十分友好。

Kubernetes與OpenKruise的定製

kubernetes的定製

那麼如何來實現Dynamic和Rebuild更新呢?這裡需要對kubernetes進行一下定製。

動態修改定製

liveness和readiness的動態修改支援相對來說較為簡單,主要修改點在與prober_manager中增加了UpdatePod函式,用以判斷當liveness或者readiness的配置改變時,停止原先的worker,重新啟動新的worker。而後將UpdatePod嵌入到kubelet的HandlePodUpdates的流程中即可。

func (m *manager) UpdatePod(pod *v1.Pod) {
	m.workerLock.Lock()
	defer m.workerLock.Unlock()

	key := probeKey{podUID: pod.UID}
	for _, c := range pod.Spec.Containers {
		key.containerName = c.Name
		{
			key.probeType = readiness
			worker, ok := m.workers[key]
			if ok {
				if c.ReadinessProbe == nil {
					//readiness置空了,原worker停止
					worker.stop()
				} else if !reflect.DeepEqual(*worker.spec, *c.ReadinessProbe) {
					//readiness配置改變了,原worker停止
					worker.stop()
				}
			}
			if c.ReadinessProbe != nil {
				if !ok || (ok && !reflect.DeepEqual(*worker.spec, *c.ReadinessProbe)) {
					//readiness配置改變了,啟動新的worker
					w := newWorker(m, readiness, pod, c)
					m.workers[key] = w
					go w.run()
				}
			}
		}
		{
			//liveness與readiness相似
			......
		}
	}
}
原地更新定製

kubernetes原生支援了image的修改,對於env和volume的修改是未做支援的。因此我們對env和volume也支援了修改功能,以便其可以進行環境變數和配置檔案的替換。這裡利用了一個小技巧,就是我們在增加了一個ExcludedHash,用於計算Container內,包含env,volume在內的各項配置。

func HashContainerExcluded(container *v1.Container) uint64 {
	copyContainer := container.DeepCopy()
	copyContainer.Resources = v1.ResourceRequirements{}
	copyContainer.LivenessProbe = &v1.Probe{}
	copyContainer.ReadinessProbe = &v1.Probe{}
	hash := fnv.New32a()
	hashutil.DeepHashObject(hash, copyContainer)
	return uint64(hash.Sum32())
}

這樣當env,volume或者image發生變化時,就可以直接感知到。在SyncPod時,用於在計算computePodActions時,發現容器的相關配置發生了變化,則將該容器進行Rebuild。

func (m *kubeGenericRuntimeManager) computePodActions(pod *v1.Pod, podStatus *kubecontainer.PodStatus) podActions {
	......
	for idx, container := range pod.Spec.Containers {
		......
		if expectedHash, actualHash, changed := containerExcludedChanged(&container, containerStatus); changed {
		    // 當env,volume或者image更換時,則重建該容器。
			reason = fmt.Sprintf("Container spec exclude resources hash changed (%d vs %d).", actualHash, expectedHash)			
			restart = true
		}
		......
		message := reason
		if restart {
			//將該容器加入到重建的列表中
			message = fmt.Sprintf("%s. Container will be killed and recreated.", message)
			changes.ContainersToStart = append(changes.ContainersToStart, idx)
		}
......
	return changes
}
Pod的生命週期

在Pod從排程完成到建立Running中,會有一個ContaienrCreating的狀態用以標識容器在建立中。而原生中當image替換時,先前的一個容器銷燬,後一個容器建立過程中,Pod狀態會一直處於Running,容易有錯誤流量匯入,使用者也無法識別此時容器的狀態。

因此我們為原地更新,在ContainerStatus裡增加了ContaienrRebuilding的狀態,同時在容器建立成功前Pod的Ready Condition置為False,以便表達容器整在重建中,應用在此期間不可用。利用此標識,可以在此期間方便識別Pod狀態、隔斷流量。

OpenKruise的定製

OpenKruise(https://openkruise.io/)是阿里開源的一個專案,提供了一套在Kubernetes核心控制器之外的擴充套件 workload 管理和實現。其中Advanced StatefulSet,基於原生 StatefulSet 之上的增強版本,預設行為與原生完全一致,在此之外提供了原地升級、並行釋出(最大不可用)、釋出暫停等功能。

Advanced StatefulSet中的原地升級即與本文中的Redbuild一致,但是原生只支援替換映象。因此我們在OpenKruise的基礎上進行了定製,使其不僅可以支援image的原地更新,也可以支援當env、volume的原地更新以及livenessProbe、readinessProbe的動態更新。這個主要在shouldDoInPlaceUpdate函式中進行一下判斷即可。這裡就不再做程式碼展示了。

還在生產執行中還發現了一個基礎庫的小bug,我們也順帶向社群做了提交修復。https://github.com/openkruise/kruise/pull/154。

另外,還有個小坑,就是在pod裡為了標識不同的版本,加入了controller-revision-hash值。

[[email protected] ~]# kubectl get pod -n predictor  -o yaml predictor-0 
apiVersion: v1
kind: Pod
metadata:
  labels:
    controller-revision-hash: predictor-85f9455f6
...

一般來說,該值應該只使用hash值作為value就可以了,但是OpenKruise中採用了{sts-name}+{hash}的方式,這帶來的一個小問題就是sts-name就要因為label value的長度受到限制了。

寫在最後

定製後的OpenKruise和kubernetes已經大規模在各個叢集上上線,廣泛應用在多個業務的後端執行服務中。經統計,通過原地更新覆蓋了87%左右的升級部署需求,基本達到預期。

特別鳴謝阿里貢獻的開源專案OpenKruise。