【編者的話】2018年1月OpenAI官方博客稱,他們已將Kubernetes集群擴展到2500個節點。時隔三年,在2021年1月,OpenAI官方博客再度宣布Kubernetes集群擴展到7500個節點,目前不僅可以滿足GPT-3、CLIP 和DALL·E等大型模型的需求,而且也可以服務于快速的小規模迭代研究。下面文章來自于OpenAI官方博客,描述了走向這個7500節點規模過程中遇到的問題和解決辦法,以及對于未來走向的暢想。
我們的Kubernetes集群規模已經上升到7,500個節點,主要為諸如GPT-3、CLIP和DALL·E等大型訓練模型提供可擴展的基礎架構,而且還可用于小規模快速迭代研究,例如神經語言模型的標度律等。將單個Kubernetes集群擴展到如此規模很難完成,同時在這個過程中需要格外小心。但好處是借助這種簡單的基礎架構使得我們的機器學習研究團隊無需更改其代碼就可以快速擴容。
自上一篇有關擴展到2,500個節點的文章發表以來,我們一直在不斷擴展基礎架構以滿足研究人員的需求,在此過程中我們還學到了很多經驗。這篇文章對此作了總結,以便Kubernetes社區共同受益,最后介紹我們仍然要面對的問題以及解決辦法探討。
工作負載
在我們深入討論之前,介紹一下我們的工作負載是很重要的。我們運行Kubernetes軟硬件和您在公司的情況可能不太一樣。我們的問題和相應的解決方案可能是,也可能不是,也請您視情況而應用!
大型機器學習作業跨越許多節點,并且只有當可以訪問每個節點上的所有硬件資源時,才能最大化運行效率。如此一來,GPU就可以通過 NVLink直接進行交叉通信,或者GPU也可以通過GPUDirect直接與NIC通信。因此,對于我們的許多工作負載,一個節點上只放置一個Pod。任何NUMA、CPU或PCIE資源爭用都不是調度的因素,因此裝箱調度或碎片化不是一個常見的問題。我們現有的集群擁有完整的對分帶寬,因此也無需考慮任何機架或網絡拓撲。所有這些都表明,我們的Kubernetes擁有許多節點,但是調度的壓力相對較低。
不過,kube-scheduler上經常會出現峰值壓力。一個新的Job可能包含數百個一次性創建的Pod,但具有較低的使用率。
我們最大的Job上運行著 MPI 協議(消息傳遞接口協議),該Job內的所有Pod都加入了同一個MPI通信器。如果某個Pod宕機,則整個Job都將暫停,需要重新啟動。我們會定期保存檢查點,Job重啟時會從上一個檢查點恢復。因此,可以認為Pod是半狀態化的,終止的Pod可以被替換掉,而且Job還可以繼續,但是這種做法會干擾正常的Job,應盡量減少。
由于HTTPS通道流量很少,也不需要進行A/B測試、藍/綠或金絲雀部署,我們沒有完全依賴Kubernetes進行負載均衡。Pod之間通過SSH(而不是服務端點),利用IP地址直接通過MPI相互通信。我們的服務“發現”功能很有限,一般只需要在Job啟動的時候執行一次查找去找到MPI中的Pod。
我們的大多數Job都使用了某種形式的Blob存儲。通常,它們會直接從Blob存儲,以流的形式讀取數據及或檢查點的某些分片,或將其緩存到臨時的本地磁盤。在需要POSIX語義的時候,我們也使用了一些持久卷,但是Blob存儲更容易擴展,而且不需要緩慢的分離/附加操作。
最后要提醒,我們的工作大多是基于研究性質的,這意味著負載本身在不斷變化。盡管超算團隊努力提供了生產級別的計算基礎架構,但集群上運行的應用程序的生命周期很短,而且開發人員的迭代非常快。新的使用模式隨時可能出現,因此我們很難預料發展趨勢,并做出適當的折中。我們需要一個可持續發展的系統,以便在事情發生變化時迅速做出響應。
網絡
由于集群內的Node數和Pod數不斷增長,我們發現Flannel難以擴展到所需的吞吐量。于是,我們轉而使用原生Pod網絡技術來管理Azure VMSSes的IP配置和相關的CNI插件。這樣我們的Pod就能夠獲得宿主級別的網絡吞吐。
我們最大的集群上大約有20萬個IP地址正在使用中,在測試基于路由的Pod網絡時,我們發現可以有效利用的路由數量受到了嚴重限制。因此我們改用基于別名的IP尋址。
避免封裝增加了對底層SDN或路由引擎的要求,但它使我們的網絡設置保持簡單。無需任何額外的適配器就可以添加隧道。我們不需要擔心數據包分片,因為網絡的某些部分MTU較低。網絡策略和流量監控也很簡單;數據包的源和目的地不存在歧義。
我們在宿主上使用iptables來跟蹤每個命名空間和Pod上網絡資源的使用情況。這樣研究人員就可以可視化網絡的使用情況。具體來說,因為許多實驗的互聯網和Pod間通信都有獨特的模式,所以能夠調查何處可能出現瓶頸是非常必要的。
iptables的mangle規則可以給任何符合特定規則的數據包做標記。我們采用了以下規則來檢測流量屬于內部還是發向外網。FORWARD規則負責Pod間的流量,而INPUT和OUTPUT負責來自宿主的流量:
iptables -t mangle -A INPUT ! -s 10.0.0.0/8 -m comment --comment "iptables-exporter openai traffic=internet-in"
iptables -t mangle -A FORWARD ! -s 10.0.0.0/8 -m comment --comment "iptables-exporter openai traffic=internet-in"
iptables -t mangle -A OUTPUT ! -d 10.0.0.0/8 -m comment --comment "iptables-exporter openai traffic=internet-out"
iptables -t mangle -A FORWARD ! -d 10.0.0.0/8 -m comment --comment "iptables-exporter
做好標記后,iptables就會統計符合該規則的數據包的字節數。使用iptables命令就可以看到這些統計結果:
% iptables -t mangle -L -v
Chain FORWARD (policy ACCEPT 50M packets, 334G bytes)
pkts bytes target prot opt in out source destination
....
1253K 555M all -- any any anywhere !10.0.0.0/8 /* iptables-exporter openai traffic=internet-out */
1161K 7937M all -- any any !10.0.0.0/8 anywhere
我們使用了一個名為 iptables-exporter 的開源 Prometheus 導出程序,將這些跟蹤信息導出到監控系統中。這樣就可以直接跟蹤符合各種條件的數據包了。
我們的網絡模型的獨特之處在于,Node、Pod和服務網絡的CIDR范圍是完全暴露給研究者的。網絡采用了輪輻模型,使用原生節點和Pod的CIDR范圍進行路由。研究者連接到中央樞紐,從那里可以訪問到任何集群。但是兩個集群之間不能互相通信。這樣可以保證每個集群都是隔離的,不會出現跨集群依賴(否則會破壞故障隔離原則)。
我們使用一個“NAT”宿主對來自集群外部的流量進行CIDR范圍轉譯。這種結構可以讓研究人員自由地選擇使用何種網絡配置以及怎樣使用,以滿足實驗的需要。
API Servers
對于健康工作的集群來講, API Servers和etcd是Kubernetes的關鍵組件,所以我們特別關注這些組件。我們采用了kube-prometheus提供的Grafana儀表板,以及自己設計的儀表板。我們發現,針對API Servers上發生的HTTP 429(Too Many Requests)和5xx(Server Error)發送高級別報警非常有效。
雖然許多人在Kubernetes內部運行API Servers,但我們選擇了在集群外部運行。etcd和API Servers都運行在獨立的節點上。最大的集群運行了5個API Servers和5個etcd節點,并以分散負載減小宕機造成的影響。自從將Kubernetes Events分離到單獨的etcd集群上以后,就再也沒有出現過因etcd問題導致的故障。API Servers是無狀態的,因此只需要運行一個自我修復的實例組或scaleset就可以。我們沒有嘗試過針對etcd集群構建自我修復自動化,因為它極少出故障。
API Servers占用的內存相當多,而且內存占用會隨著集群中的節點數量增加而呈線性增長。對于我們擁有7500節點的集群,每個API Servers上的堆空間占用最多為70GB,還好這依然在硬件能夠承受的范圍內。
API Servers上比較大的壓力之一就是端點上的WATCH。有幾個服務的服務對象是集群中的所有成員,如kubelet、node-exporter等。每當集群中添加或刪除節點時,就會觸發WATCH。而且由于每個節點自身都會通過kube-proxy監視kubelet服務,這些服務的響應數量和所需帶寬就會呈N^2增長,大約每秒增加1 GB。Kubernetes 1.17中發布的EndpointSlices極大地緩解了這個壓力,它將負載降低了1000倍。
一般而言,我們會注意任何API Servers請求數量隨著集群大小而變化的情況。我們會盡量避免讓任何DaemonSet與API Servers交流。如果需要讓每個節點監控變化,那么引入中間緩存服務(如DatadogCluster Agent)或許是避免集群范圍瓶頸的好辦法。
隨著集群的增長,我們的自動伸縮越來越少了。但偶爾也會出現大幅自動伸縮的情況。新的節點加入集群會產生許多請求,而一次性增加幾百個節點會超過API Servers能夠承受的容量。平滑請求速度,甚至僅僅增加幾秒鐘,就可以有效地避免這個問題。
使用Prometheus和Grafana測量時序列度量
我們使用Prometheus收集時序列度量,利用Grafana繪制成圖表、顯示儀表板并生成警告。首先我們部署了kube-prometheus來收集各種度量和可視化的儀表板。隨著時間的推移,我們已經添加了許多我們自己的儀表板、指標和警報。
隨著節點越來越多,我們逐漸難以理解Prometheus收集到的度量。盡管kube-prometheus公開了許多非常有用的數據,但有些數據我們并不需要,而有些數據過于細致,很難收集、存儲和有效地查詢。因此我們使用Prometheus 規則“放棄”了一些度量。
長期以來,有一個問題一直困擾我們:Prometheus消耗的內存越來越多,最終由于內存耗盡而崩潰。即使給Prometheus提供大量的內存也無濟于事。更糟糕的是,每當出現崩潰,它就需要花費好幾個小時重新執行預寫式日志(write-ahead log)文件,之后才能正常使用。
最后我們研究了Prometheus的源代碼,發現內存耗盡是由于Grafana和Prometheus之間的交互導致的,Grafana會使用Prometheus上的/api/v1/series這個API,進行{le!=""}的查詢(含義是“獲取所有直方圖的度量”)。而/api/v1/series的實現在運行時間和空間上都沒有任何限制,如果查詢結果過多,就會消耗越來越多的內存和時間。即使請求者放棄請求并關閉連接,查詢也會繼續執行。對于我們的情況而言,無論多少內存都不夠,Prometheus最終總會崩潰。于是,我們給Prometheus打了補丁,將這個API包裹在一個Context中以實現超時,終于修復了該問題。
雖然Prometheus的崩潰次數大大減少了,但我們依然需要經常重啟,因此預寫式日志(簡稱WAL)的重新執行依然是一個問題。重新執行所有 WAL 通常需要花費好幾個小時,之后Prometheus才能啟動,并開始收集度量和查詢請求。在Robust Perception的幫助下,我們發現設置GOMAXPROCS=24可以極大地改善這個問題。因為Prometheus會在執行WAL期間嘗試使用所有CPU核心,對于核心數量極多的服務器而言,核心之間的競爭會導致性能大幅度下降。
我們正在探索新的選項,以增加我們的監測能力,如下面的“未解決的問題”一節所述。
健康檢查
面對如此龐大的集群,我們必須依賴自動化來檢測并移除任何有問題的節點。慢慢地,我們建立起了一系列健康檢查系統。
被動健康檢查
一些健康檢查是被動的,永遠在節點上運行。這些健康檢查會監視基本的系統資源,如網絡不通暢、磁盤失敗、磁盤寫滿或GPU錯誤等。GPU會呈現多種錯誤,但最常見的就是“Uncorrectable ECC error”(無法修復的ECC錯誤)。Nvidia的Data Center GPU Manager (DCGM)工具可以幫助查詢該錯誤,以及許多其他的“Xid”錯誤。跟蹤錯誤的方法之一就是使用dcgm-exporter工具將度量導出到Prometheus監視系統中。這樣就可以創建DCGM_FI_DEV_XID_ERRORS度量,其內容為最近發生過的錯誤代碼。此外,NVMLDevice Query API還可以提供有關GPU的健康情況和操作的更詳細信息。
檢測到錯誤之后,通常重啟就能修復GPU或系統,盡管有些情況下需要更換顯卡。
另一種健康檢查會跟蹤來自上游云服務提供商的維護事件。每個主流云服務提供商都會提供一種方法,獲知當前使用的VM是否即將維護,從而導致服務中斷。VM可能需要重啟,因為需要給監視程序打補丁,或者給物理服務器更換硬件。
這些被動健康檢查在所有節點的后臺不斷運行。如果運行狀況檢查開始失敗,將自動隔離該節點,這樣就不會在該節點上調度新的Pod。對于更嚴重的健康檢查失敗,我們還將嘗試終止Pod,請求所有當前運行的Pod立即退出。它仍然取決于Pod本身,通過Pod中斷預算進行配置,以決定它是否希望允許這種終止發生。最終,在所有Pod終止或7天過去(我們SLA的一部分)之后,我們將強制終止VM。
主動GPU測試
不幸的是,并非所有的GPU問題都能從DCGM中看到錯誤碼。我們自己構建了GPU測試庫,能夠捕獲額外的錯誤,確保硬件和驅動程序按照預期運行。這些測試無法在后臺運行,因為運行測試需要獨占GPU幾秒鐘或幾分鐘。
首先,我們會在節點啟動時運行測試,稱為“預運行”。所有加入集群的節點都會加上“preflight” 污染并打標簽。該污染可以防止普通Pod被調度到節點上。然后配置一個DaemonSet,在所有帶有該標簽的Pod上運行預運行測試。測試成功后,測試程序會移除污染,節點就可以正常使用了。
我們還會在節點的生命周期內定期執行測試。測試通過CronJob運行,因此可以在集群中的任何可用節點上執行。雖然這樣無法控制測試在哪個節點上運行,但我們發現,只要時間足夠長,它就能提供足夠的測試覆蓋,同時不會對服務造成太多干擾。
配額和資源利用
當我們擴大集群時,研究人員開始發現他們很難獲得分配給它們的所有能力。傳統的Job調度系統有很多不同的特性,可以在競爭團隊之間公平地運行工作,而Kubernetes沒有這些特性。隨著時間的推移,我們從這些Job調度系統中獲得了靈感,給Kubernetes添加了幾個原生功能。
Team taints
我們在每個集群都有一個服務“team-resource-manager”,它具有多種功能。它的數據源是一個ConfigMap,它為在給定集群中有能力的所有研究團隊指定元組(節點選擇器、要應用的團隊標簽、分配數量)。它與集群中的當前節點保持一致,從而設置適當數量的節點。
openai.com/team=teamname:NoSchedule.
“team-resource-manager”還具有一個admission webhook服務,例如,當提交每個作業時,會根據提交者的團隊成員申請相應的容忍度。使用taints允許我們靈活地約束Kubernetes Pod調度程序,例如允許對較低優先級的Pod有“any”容忍度,這允許團隊在不需要重量級協調的情況下借用彼此的能力。
CPU & GPU balloons
除了使用cluster-autoscaler來動態伸縮集群之外,我們還會刪除并重新添加集群內的不健康節點。實現方法是將集群的最小尺寸設置為零,最大尺寸設置為可用的容量。但是,如果cluster-autoscaler看到空閑節點,就會嘗試將集群收縮至必要限度大小。從許多角度來看(VM的啟動延遲、預分配的成本、對API服務器的影響)來看,這種空閑狀態的伸縮并不理想。
所以,我們同時為僅支持CPU的宿主和支持GPU的宿主引入了氣球部署。該部署包含一個ReplicaSet,其中設置了低優先級Pod的最大數量。這些Pod會占用一個節點內的資源,所以自動縮放器就不會認為該節點閑置。但是由于這些Pod優先級很低,因此調度器可以隨時將其驅逐,給真正的作業騰出空間。(我們選擇了使用部署而不是DaemonSet,避免DaemonSet在節點上被認為是閑置負載。)
需要注意的一點是,我們使用了Pod反親和性來保證Pod最終會均勻地分布到節點上。Kubernetes早期版本的調度器在處理Pod反親和性時的性能為O(N^2),不過這一點在1.8版本后就修正了。
有問題的調度
我們的實驗經常涉及到一個或多個StatefulSet,每個負責訓練作業的一部分。至于優化器,研究人員要求所有的StatefulSet都被調度,訓練作業才能完成(因為我們經常使用MPI來協調優化器的各個成員,而MPI對于組內成員數量的變化非常敏感)。
但是,Kubernetes默認并不一定會優先滿足某個StatefulSet的所有請求。例如,如果兩個實驗都要求100%的集群容量,那么Kubernetes不一定會調度某個實驗的所有Pod,而是可能會為每個實驗調度一半的Pod,導致死鎖狀態,每個實驗都無法完成。
我們嘗試了幾種方案,但都遇到了一些極端情況,會與正常Pod的調度產生沖突。Kubernetes 1.18為核心調度器引入了一個插件架構,因此添加功能變得非常容易了。我們最近剛剛發布了Coscheduling plugin,以解決這個問題。
未解決的問題
隨著我們的Kubernetes集群不斷擴大,還有許多問題有待解決。一些問題包括:
度量
在目前的規模下,Prometheus自帶的TSDB存儲引擎有許多問題,例如速度很慢、重啟時需要很長時間重新執行WAL(預寫入日志)等。查詢也很容易導致“查詢可能會加載過多數據”的錯誤。我們正在遷移到與Prometheus兼容的另一個存儲和查詢引擎上。
Pod網絡流量
隨著集群的擴大,每個Pod都會占用一定的互聯網帶寬。因此,每個人的互聯網帶寬加起來就無法忽略不計了,我們的研究人員有可能無意間給互聯網的其他部分帶來不可忽略的資源壓力,例如下載數。
總結
我們發現Kubernetes對于我們的研究需求來說是一個非常靈活的平臺。它有能力擴大規模,以滿足最苛刻的工作負載。不過,Kubernetes還有很多需要改進的地方,OpenAI的超級計算團隊將繼續探索Kubernetes如何擴展。