Kubernetes 叢集無損升級實踐 轉至後設資料結尾

vivo網際網路技術發表於2021-12-20

一、背景

活躍的社群和廣大的使用者群,使 Kubernetes 仍然保持3個月一個版本的高頻釋出節奏。高頻的版本釋出帶來了更多的新功能落地和 bug 及時修復,但是線上環境業務長期執行,任何變更出錯都可能帶來巨大的經濟損失,升級對企業來說相對吃力,緊跟社群更是幾乎不可能,因此高頻釋出和穩定生產之間的矛盾需要容器團隊去衡量和取捨。

vivo 網際網路團隊建設大規模 Kubernetes 叢集以來,部分叢集較長時間一直使用 v1.10 版本,但是由於業務容器化比例越來越高,對大規模叢集穩定性、應用釋出的多樣性等訴求日益攀升,叢集升級迫在眉睫。叢集升級後將解決如下問題:

  • 高版本叢集在大規模場景做了優化,升級可以解決一系列效能瓶頸問題。

  • 高版本叢集才能支援 OpenKruise 等 CNCF 專案,升級可以解決版本依賴問題。

  • 高版本叢集增加的新特效能夠提高叢集資源利用率,降低伺服器成本同時提高叢集效率。

  • 公司內部維護多個不同版本叢集,升級後減少叢集版本碎片化,進一步降低運維成本。

這篇文章將會從0到1的介紹 vivo 網際網路團隊支撐線上業務的叢集如何在不影響原有業務正常執行的情況下從 v1.10 版本升級到 v1.17 版本。之所以升級到 v1.17 而不是更高的 v1.18 以上版本, 是因為在 v1.18 版本引入的程式碼變動 [1] 會導致 extensions/v1beta1 等高階資源型別無法繼續執行(這部分程式碼在 v1.18 版本刪除)。

二、無損升級難點

容器叢集搭建通常有二進位制 systemd 部署和核心元件靜態 Pod 容器化部署兩種方式,叢集 API 服務多副本對外負載均衡。兩種部署方式在升級時沒有太大區別,二進位制部署更貼合早期叢集,因此本文將對二進位制方式部署的叢集升級做分享。

對二進位制方式部署的叢集,叢集元件升級主要是二進位制的替換、配置檔案的更新和服務的重啟;從生產環境 SLO 要求來看,升級過程務必不能因為叢集元件自身邏輯變化導致業務重啟。因此升級的難點集中在下面幾點:

首先,當前內部叢集執行版本較低,但是執行容器數量卻很多,其中部分仍然是單副本執行,為了不影響業務執行,需要儘可能避免容器重啟,這無疑是升級中最大的難點,而在 v1.10 版本和 v1.17 版本之間,kubelet 關於容器 Hash 值計算方式發生了變化,也就是說一旦升級必然會觸發 kubelet 重新啟動容器。

其次,社群推薦的方式是基於偏差策略 [2] 的升級以保證高可用叢集升級同時不會因為 API resources 版本差異導致 kube-apiserve 和 kubelet 等元件出現相容性錯誤,這就要求每次升級元件版本不能有2個 Final Release 以上的偏差,比如直接從 v1.11 升級至 v1.13是不推薦的。

再次,升級過程中由於新特性的引入,API 相容性可能引發舊版本叢集的配置不生效,為整個叢集埋下穩定性隱患。這便要求在升級前儘可能的熟悉升級版本間的 ChangeLog,排查出可能帶來潛在隱患的新特性。

三、無損升級方案

針對前述的難點,本節將逐個提出針對性解決方案,同時也會介紹升級後遇到的高版本 bug 和解決方法。希望關於升級前期相容性篩查和升級過程中排查的問題能夠給讀者帶來啟發。

3.1 升級方式

在軟體領域,主流的應用升級方式有兩種,分別是原地升級和替換升級。目前這兩種升級方式在業內網際網路大廠均有采用,具體方案選擇與叢集上業務有很大關係。

替換升級

1)Kubernetes 替換升級是先準備一個高版本叢集,對低版本叢集通過逐個節點排幹、刪除最後加入新叢集的方式將低版本叢集內節點逐步輪換升級到新版本。

2)替換升級的優點是原子性更強,逐步升級各個節點,升級過程不存在中間態,對業務安全更有保障;缺點是叢集升級工作量較大,排幹操作對pod重啟敏感度高的應用、有狀態應用、單副本應用等都不友好。

原地升級

1)Kubernetes 原地升級是對節點上服務如 kube-controller-manager、 kubelet 等元件按照一定順序批量更新,從節點角色維度批量管理元件版本。

2)原地升級的優點是自動化操作便捷,並且通過適當的修改能夠很好的保證容器的生命週期連續性;缺點是叢集升級中元件升級順序很重要,升級中存在中間態,並且一個元件重啟失敗可能影響後續其他元件升級,原子性差。

vivo 容器叢集上執行的部分業務對重啟容忍度較低,儘可能避免容器重啟是升級工作的第一要務。當解決好升級版本帶來的容器重啟後,結合業務容器化程度和業務型別不同,因地制宜的選擇升級方式即可。二進位制部署叢集建議選擇原地升級的方式,具有時間短,操作簡捷,單副本業務不會被升級影響的好處。

3.2 跨版本升級

由於Kubernetes 本身是基於 API 的微服務架構,Kuberntes 內部架構也是通過 API 的呼叫和對資源物件的 List-Watch 來協同資源狀態,因此社群開發者在設計 API 時遵循向上或向下相容的原則。這個相容性規則也是遵循社群的偏差策略 [2],即 API groups 棄用、啟用時,對於 Alpha 版本會立即生效,對於 Beta 版本將會繼續支援3個版本,超過對應版本將導致 API resource version 不相容。例如 kubernetes 在 v1.16 對 Deployment 等資源的 extensions/v1beta1 版本執行了棄用,在v1.18 版本從程式碼級別執行了刪除,當跨3個版本以上升級時會導致相關資源無法被識別,相應的增刪改查操作都無法執行。

如果按照官方建議的升級策略,從 v1.10 升級到 v1.17 需要經過至少 7 次升級,這對於業務場景複雜的生產環境來說運維複雜度高,業務風險大。

對於類似的 API breaking change 並不是每個版本都會存在,社群建議的偏差策略是最安全的升級策略,經過細緻的 Change Log 梳理和充分的跨版本測試,我們確認這幾個版本之間不能存在影響業務執行和叢集管理操作的 API 相容性問題,對於 API 型別的廢棄,可以通過配置 apiserver 中相應引數來啟動繼續使用,保證環境業務繼續正常執行。

3.3 避免容器重啟

在初步驗證升級方案時發現大量容器都被重建,重啟原因從升級後 kubelet 元件日誌看到是 "Container definition changed"。結合原始碼報錯位於 pkg/kubelet/kuberuntime_manager.go 檔案 computePodActions 方法,該方法用來計算 pod 的 spec 雜湊值是否發生變化,如果變化則返回 true,告知 kubelet syncPod 方法觸發 pod 內容器重建或者 pod 重建。

kubelet 容器 Hash 計算;

func (m *kubeGenericRuntimeManager) computePodActions(pod *v1.Pod, podStatus *kubecontainer.PodStatus) podActions {
    restart := shouldRestartOnFailure(pod)
    if _, _, changed := containerChanged(&container, containerStatus); changed {
        message = fmt.Sprintf("Container %s definition changed", container.Name)
        // 如果 container spec 發生變化,將會強制重啟 container(將 restart 標誌位設定為 true)
        restart = true
    }
    ...
    if restart {
       message = fmt.Sprintf("%s, will be restarted", message)
       // 需要重啟的 container 加入到重啟列表
       changes.ContainersToStart = append(changes.ContainersToStart, idx)
    }
}
 
func containerChanged(container *v1.Container, containerStatus *kubecontainer.ContainerStatus) (uint64, uint64, bool) {
   // 計算 container spec 的 Hash 值
   expectedHash := kubecontainer.HashContainer(container)
   return expectedHash, containerStatus.Hash, containerStatus.Hash != expectedHash
}

相對於 v1.10 版本,v1.17 版本在計算容器 Hash 時使用的是 container 結構 json 序列化後的資料,而不是 v1.10 版本使用 container struct 的結構資料。而且高版本 kubelet 中對容器的結構也增加了新的屬性,通過 go-spew 庫計算出結果自然不一致,進一步向上傳遞返回值使得 syncPod 方法觸發容器重建。

那是否可以通過修改 go-spew 對 container struct 的資料結構剔除新增的欄位呢? 答案是肯定的,但是卻不是優雅的方式,因為這樣對核心程式碼邏輯侵入較為嚴重,以後每個版本的升級都需要定製程式碼,並且新增的欄位越來越多,維護複雜度也會越來越高。換個角度,如果在升級過渡期間將屬於舊版本叢集 kubelet 建立的 Pod 跳過該檢查,則可以避免容器重啟。

和圈內同事交流後發現類似思路在社群已有實現,本地建立一個記錄舊叢集版本資訊和啟動時間的配置檔案,kubelet 程式碼中維護一個 cache 讀取配置檔案,在每個 syncPod 週期中,當 kubelet 發現自身 version 高於 cache 中記錄的 oldVersion, 並且容器啟動時間早於當前 kubelet 啟動時間,則會跳過容器 Hash 值計算。升級後的叢集內執行定時任務探測 Pod 的 containerSpec 是否與高版本計算方式計算得到 Hash 結果全部一致,如果是則可以刪除掉本地配置檔案,syncPod 邏輯恢復到與社群完全一致。

具體方案參考這種實現的好處是對原生 kubelet 程式碼侵入小,沒有改變核心程式碼邏輯,而且未來如果還需要升級高版本也可以複用該程式碼。如果叢集內所有 Pod 都是當前版本 kubelet 建立,則會恢復到社群自身的邏輯。

3.4 Pod 非預期驅逐問題

Kubernetes 雖然迭代了十幾個版本,但是每個迭代社群活躍度仍然很高,保持著每個版本大約30個關於擴充性增強和穩定性提升的新特性。選擇升級很大一方面原因是引入很多社群開發的新特性來豐富叢集的功能與提升叢集穩定性。新特性開發也是遵循偏差策略,跨大版本升級很可能導致在部分配置未載入的情況下啟用新特性,這就給叢集帶來穩定性風險,因此需要梳理影響 Pod 生命週期的一些特性,尤其關注控制器相關的功能。

這裡注意到在 v1.13 版本引入的 TaintBasedEvictions 特性用於更細粒度的管理 Pod 的驅逐條件。在 v1.13基於條件版本之前,驅逐是基於 NodeController 的統一時間驅逐,節點 NotReady 超過預設5分鐘後,節點上的 Pod 才會被驅逐;在 v1.16 預設開啟 TaintBasedEvictions 後,節點 NotReady 的驅逐將會根據每個 Pod 自身配置的 TolerationSeconds 來差異化的處理。

舊版本叢集建立的 Pod 預設沒有設定 TolerationSeconds,一旦升級完畢 TaintBasedEvictions 被開啟,節點變成 NotReady 後 5 秒就會驅逐節點上的 Pod。對於短暫的網路波動、kubelet 重啟等情況都會影響叢集中業務的穩定性。

TaintBasedEvictions 對應的控制器是按照 pod 定義中的 tolerationSeconds 決定 Pod 的驅逐時間,也就是說只要正確設定 Pod 中的 tolerationSeconds 就可以避免出現 Pod 的非預期驅逐。

在v1.16 版本社群預設開啟的 DefaultTolerationSeconds 准入控制器基於 k8s-apiserver 輸入引數 default-not-ready-toleration-seconds 和 default-unreachable-toleration-seconds 為 Pod 設定預設的容忍度,以容忍 notready:NoExecute 和 unreachable:NoExecute 汙點。

新建 Pod 在請求傳送後會經過 DefaultTolerationSeconds 准入控制器給 pod 加上預設的 tolerations。但是這個邏輯如何對叢集中已經建立的 Pod 生效呢?檢視該准入控制器發現除了支援 create 操作,update 操作也會更新 pod 定義觸發 DefaultTolerationSeconds 外掛去設定 tolerations。因此我們通過給叢集中已經執行的 Pod 打 label 就可以達成目的。

tolerations:
- effect: NoExecute
  key: node.kubernetes.io/not-ready
  operator: Exists
  tolerationSeconds: 300
- effect: NoExecute
  key: node.kubernetes.io/unreachable
  operator: Exists
  tolerationSeconds: 300

3.5 Pod MatchNodeSelector

為了判斷升級時 Pod 是否發生非預期的驅逐以及是否存在 Pod 內容器批量重啟,有指令碼去實時同步節點上非Running狀態的Pod和發生重啟的容器。

在升級過程中,突然多出來數十個 pod 被標記為 MatchNodeSelector 狀態,檢視該節點上業務容器確實停止。kubelet 日誌中看到如下錯誤日誌;

predicate.go:132] Predicate failed on Pod: nginx-7dd9db975d-j578s_default(e3b79017-0b15-11ec-9cd4-000c29c4fa15), for reason: Predicate MatchNodeSelector failed
kubelet_pods.go:1125] Killing unwanted pod "nginx-7dd9db975d-j578s"

經分析,Pod 變成 MatchNodeSelector 狀態是因為 kubelet 重啟時對節點上 Pod 做准入檢查時無法找到節點滿足要求的節點標籤,pod 狀態就會被設定為 Failed 狀態,而 Reason 被設定為 MatchNodeSelector。在 kubectl 命令獲取時,printer 做了相應轉換直接顯示了Reason,因此我們看到 Pod 狀態是 MatchNodeSelector。通過給節點加上標籤,可以讓 Pod 重新排程回來,然後刪除掉 MatchNodeSelector 狀態的 Pod 即可。

建議在升級前寫指令碼檢查節點上 pod 定義中使用的 NodeSelector 屬性節點是否都有對應的 Label。

3.6 無法訪問 kube-apiserver

預發環境升級後的叢集執行在 v1.17 版本後,突然有節點變成 NotReady 狀態告警,分析後通過重啟 kubelet 節點恢復正常。繼續分析出錯原因發現 kubelet 日誌中出現了大量 use of closed network connection 報錯。在社群搜尋相關 issue 發現有類似的問題,其中有開發者描述了問題的起因和解決辦法,並且在 v1.18 已經合入了程式碼。

問題的起因是 kubelet 預設連線是 HTTP/2.0 長連線,在構建 client 到 server的連線時使用的 golang net/http2 包存在 bug,在 http 連線池中仍然能獲取到 broken 的連線,也就導致 kubelet 無法正常與 kube-apiserver 通訊。

golang社群通過增加 http2 連線健康檢查規避這個問題,但是這個 fix 仍然存在 bug ,社群在 golang v1.15.11 版本徹底修復。我們內部通過 backport 到 v1.17 分支,並使用 golang 1.15.15 版本編譯二進位制解決了此問題。

3.7 TCP 連線數問題

在預釋出環境測試執行期間,偶然發現叢集每個節點 kubelet 都有近10個長連線與 kube-apiserver 通訊,這與我們認知的 kubelet 會複用連線與 kube-apiserver 通訊明顯不符,檢視 v1.10 版本環境也確實只有1個長連線。這種 TCP 連線數增加情況無疑會對 LB 造成了壓力,隨著節點增多,一旦 LB 被拖垮,kubelet 無法上報心跳,節點會變成 NotReady,緊接著將會有大量 Pod 被驅逐,後果是災難性的。因此除去對 LB 本身引數調優外,還需要定位清楚kubelet 到 kube-apiserver 連線數增加的原因。

在本地搭建的 v1.17.1 版本 kubeadm 叢集 kubelet 到 kube-apiserver 也僅有1個長連線,說明這個問題是在 v1.17.1 到升級目標版本之間引入的,排查後(問題)發現增加了判斷邏輯導致 kubelet 獲取 client 時不再從 cache 中獲取快取的長連線。transport 的主要功能其實就是快取了長連線,用於大量 http 請求場景下的連線複用,減少傳送請求時 TCP(TLS) 連線建立的時間損耗。在該 PR 中對 transport 自定義 RoundTripper 的介面,一旦 tlsConfig 物件中有 Dial 或者 Proxy 屬性,則不使用 cache 中的連線而新建連線。

// client-go 從 cache 獲取複用連線邏輯
func tlsConfigKey(c *Config) (tlsCacheKey, bool, error) {
    ...
 
    if c.TLS.GetCert != nil || c.Dial != nil || c.Proxy != nil {
        // cannot determine equality for functions
        return tlsCacheKey{}, false, nil
    }
...
}
 
 
func (c *tlsTransportCache) get(config *Config) (http.RoundTripper, error) {
    key, canCache, err := tlsConfigKey(config)
    ...
 
    if canCache {
        // Ensure we only create a single transport for the given TLS options
        c.mu.Lock()
        defer c.mu.Unlock()
 
        // See if we already have a custom transport for this config
        if t, ok := c.transports[key]; ok {
            return t, nil
        }
    }
...
}
 
// kubelet 元件構建 client 邏輯
func buildKubeletClientConfig(ctx context.Context, s *options.KubeletServer, nodeName types.NodeName) (*restclient.Config, func(), error) {
    ...
    kubeClientConfigOverrides(s, clientConfig)
    closeAllConns, err := updateDialer(clientConfig)
    ...
    return clientConfig, closeAllConns, nil
}
 
// 為 clientConfig 設定 Dial屬性,因此 kubelet 構建 clinet 時會新建 transport
func updateDialer(clientConfig *restclient.Config) (func(), error) {
    if clientConfig.Transport != nil || clientConfig.Dial != nil {
        return nil, fmt.Errorf("there is already a transport or dialer configured")
    }
    d := connrotation.NewDialer((&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext)
    clientConfig.Dial = d.DialContext
    return d.CloseAll, nil

在這裡構建 closeAllConns 物件來關閉已經處於 Dead 但是尚未 Close 的連線,但是上一個問題通過升級 golang 版本解決了這個問題,因此我們在原生程式碼分支回退了該修改中的部分程式碼解決了 TCP 連線數增加的問題。

最近追蹤社群發現已經合併了解決方案 ,通過重構 client-go 的介面實現對自定義 RESTClient 的 TCP 連線複用。

四、無損升級操作

跨版本升級最大的風險是升級前後物件定義不一致,可能導致升級後的元件無法解析儲存在 ETCD 資料庫中的物件;也可能是升級存在中間態,kubelet 還未升級而控制平面元件升級,存在上報狀態異常,最壞的情況是節點上 Pod 被驅逐。這些都是升級前需要考慮並通過測試驗證的。

經過反覆測試,上述問題在 v1.10 到 v1.17 之間除了部分廢棄的 API Resources 通過增加 kube-apiserver 配置方式其他情況暫時不存在。為了保證升級時及時能處理未覆蓋到的特殊情況,強烈建議升級前備份 ETCD 資料庫,並在升級期間停止控制器和排程器,避免非預期的控制邏輯發生(實際上這裡應該是停止 controller manager 中的部分控制器,不過需要修改程式碼編譯臨時 controller manager ,增加了升級流程步驟和管理複雜度,因此直接停掉了全域性控制器)。

除卻以上程式碼變動和升級流程注意事項,在替換二進位制升級前,就剩下比對新老版本服務的配置項的區別以保證服務成功啟動執行。對比後發現,kubelet 元件啟動時不再支援 --allow-privileged 引數,需要刪除。值得說明的是,刪除不代表高版本不再支援節點上執行特權容器,在 v1.15 以後通過 Pod Security Policy 資源物件來定義一組 pod 訪問的安全特徵,更細粒度的做安全管控。

基於上面討論的無損升級程式碼側的修改編譯二進位制,再對叢集元件配置檔案中各個配置項修改後,就可以著手線上升級。整個升級步驟為:

  • 備份叢集(二進位制,配置檔案,ETCD資料庫等);

  • 灰度升級部分節點,驗證二進位制和配置檔案正確性

  • 提前分發升級的二進位制檔案;

  • 停止控制器、排程器和告警;

  • 更新控制平面服務配置檔案,升級元件;

  • 更新計算節點服務配置檔案,升級元件;

  • 為節點打 Label 觸發 pod 增加 tolerations 屬性;

  • 開啟控制器和排程器,啟用告警;

  • 叢集業務點檢,確認叢集正常。

升級過程中建議節點併發數不要太高,因為大量節點 kubelet 同時重啟上報資訊,對 kube-apiserver 前面使用的 LB 帶來衝擊,特別情況下可能節點心跳上報失敗,節點狀態會在 NotReady 與 Ready 狀態間跳動。

五、總結

叢集升級是困擾容器團隊比較長時間的事,在經過一系列調研和反覆測試,解決了上面提到的數個關鍵問題後,成功將叢集從 v1.10 升級到 v1.17 版本,1000 個節點的叢集分批執行升級操作,大概花費 10 分鐘,後續在完成平臺介面改造後將會再次升級到更高版本。

叢集版本升級提高了叢集的穩定性、增加了叢集的擴充套件性,同時還豐富了叢集的能力,升級後的叢集也能夠更好的相容 CNCF 專案。

如開篇所述,按照偏差策略頻繁對大規模叢集升級可能不太現實,因此跨版本升級雖然風險較大,但是也是業界廣泛採用的方式。在 2021 年中國 KubeCon 大會上,阿里巴巴也有關於零停機跨版本升級 Kubernetes 叢集的分享,主要是關於應用遷移、流量切換等升級關鍵點的介紹,升級的準備工作和升級過程相對複雜。相對於阿里巴巴的叢集跨版本替換升級方案,原地升級的方式需要在原始碼上做少量修改,但是升級過程會更簡單,運維自動化程度更高。

由於叢集版本具有很大的可選擇性,本文所述的升級並不一定廣泛適用,筆者更希望給讀者提供生產叢集在跨版本升級時的思路和風險點。升級過程短暫,但是升級前的準備和調研工作是費時費力的,需要對不同版本 Kubernetes 特性和原始碼深入探索,同時對 Kubernetes 的 API 相容性策略和釋出策略擁有完整認知,這樣便能在升級前做出充分的測試,也能更從容面對升級過程中突發情況。

六、參考連結

[1]https://github.com

[2] https://kubernetes.io/version-skew-policy

[3] 具體方案參考:https://github.comstart

[4] 類似的問題: https://github.com/kubernetes

[5] https://github.com/golang/34978

[6] https://github.com/kubernetes/100376

[7] https://github.com/kubernetes/95427

[8] https://github.com/kubernetes/105490

作者:vivo網際網路伺服器團隊-Shu Yingya

相關文章