Kubernetes 集羣無損升級實踐

一、背景

活躍的社區和廣大的用戶羣,使 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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章