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

本文首發在OPPO互聯網公眾號,歡迎點擊轉載 //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(//openkruise.io/)是阿里開源的一個項目,提供了一套在Kubernetes核心控制器之外的擴展 workload 管理和實現。其中Advanced StatefulSet,基於原生 StatefulSet 之上的增強版本,默認行為與原生完全一致,在此之外提供了原地升級、並行發布(最大不可用)、發布暫停等功能。

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

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

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

[root@xxx ~]# 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。