Uber的20萬容器實踐:如何避免容器化環(huán)境中的 CPU 節(jié)流
https://mp.weixin.qq.com/s/MPE92VARsJsNx6ewKYweOg

本文譯自 Avoiding CPU Throttling in a Containerized Environment[1]。作者:Joakim Recht和Yury Vostrikov
在 Uber,所有有狀態(tài)的工作負載都運行在一個跨大型主機的通用容器化平臺上。有狀態(tài)的工作負載包括MySQL?、Apache Cassandra?、ElasticSearch?、Apache Kafka?、Apache HDFS?、Redis?、Docstore、Schemaless等,在很多情況下,這些工作負載位于同一臺物理主機上。
憑借 65,000 個物理主機、240 萬個內核和 200,000 個容器,提高利用率以降低成本是一項重要且持續(xù)的工作。但最近,由于 CPU限流,導致利用率提升這件事沒有那么順利了。
事實證明,問題在于 Linux 內核如何為進程運行分配時間。在這篇文章中,我們將描述從 CPU 配額切換到cpusets(也稱為 CPU pinning),如何使我們能夠以 P50 延遲的輕微增加換取 P99 延遲的顯著下降。由于資源需求的變化較小,這反過來又使我們能夠將整個集群范圍內的核心分配減少 11%。
Cgroups、配額和 Cpusets
CPU 配額和 cpusets 是Linux內核的調度器功能。Linux內核通過cgroups實現(xiàn)資源隔離,所有容器平臺均以此為基礎。通常,一個容器映射到一個 cgroup,它控制著在容器中運行的任何進程的資源。
有兩種類型的 cgroup(Linux 術語中的控制器)用于執(zhí)行 CPU 隔離:CPU和cpuset 。它們都控制允許一組進程使用多少 CPU,但有兩種不同的方式:分別通過 CPU 時間配額和 CPU pinning。
CPU 配額
CPU控制器使用配額來實現(xiàn)隔離。對于一個CPU 集,你指定要允許的 CPU 比例(核心)。使用以下公式將其轉換為給定時間段(通常為 100 毫秒)的配額:
配額 = core_count * 周期(quota = core_count * period)

在上面的例子中,有一個需要 2 個內核的容器,這相當于每周期需要 200 毫秒的 CPU 時間。
CPU 配額和節(jié)流
由于容器內的多處理/線程,這種方法被證明是有問題的。這會使容器過快地用完配額,導致它在剩余時間段內受到限制。如下圖所示:

對于提供低延遲請求的容器來說,這是個問題。突然間,由于節(jié)流,通常需要幾毫秒才能完成的請求可能需要超過 100 毫秒。
簡單的解決方法是為進程分配更多的 CPU 時間。雖然這很有效,但在規(guī)模上也很昂貴。另一種解決方案是根本不使用隔離。然而,這對于同一地點的工作負載來說是一個非常糟糕的主意,因為一個進程可能會完全耗盡其他進程。
使用 Cpusets避免節(jié)流
cpuset 控制器使用 CPU pinning 而不是配額——它基本上限制了一個容器可以在哪些內核上運行。這意味著有可能將所有容器分布在不同的核上,以便每個核只服務于一個容器。這樣就實現(xiàn)了完全隔離,不再需要配額或節(jié)流,換句話說,可以用延遲的一致性和更繁瑣的核管理,來與處理突發(fā)和簡單配置進行妥協(xié)。上面的例子看起來像這樣:

兩個容器在兩組不同的內核上運行。它們被允許在這些核心上盡可能地使用,但不能使用未分配的核心。
這樣做的結果是 P99 的延遲變得更加穩(wěn)定。下面是一個在啟用 cpuset 時對生產數(shù)據庫集群(每一行是一個容器)進行節(jié)流的例子。正如預期的那樣,所有節(jié)流都消失了:

節(jié)流現(xiàn)象消失了,因為容器能夠自由使用所有分配的內核。更有趣的是,由于容器能夠以穩(wěn)定的速率處理請求,P99 的延遲也得到了改善。在這種情況下,由于消除了嚴重的節(jié)流,延遲下降了50%左右。

在這一點上值得注意的是,使用 cpusets 也有負面影響。特別是,P50 延遲通常會增加一點,因為它不再可能突入未分配的核心。結果 P50 和 P99 的延遲變得更接近,這通常是可取的。這點將在本文末尾進行更多討論。
分配 CPU
為了使用 cpusets,容器必須綁定到核心。正確分配內核需要一些關于現(xiàn)代 CPU 架構如何工作的背景知識,因為錯誤的分配會導致性能顯著下降。
CPU 通常圍繞以下結構構建:
一臺物理機可以有多個 CPU 插槽
每個插座都有獨立的L3緩存
每個 CPU 有多個核心
每個核心都有獨立的 L2/L1 緩存
每個核心都可以有超線程
超線程通常被視為核心,但分配 2 個超線程而不是 1 個可能只會將性能提高 1.3 倍
所有這些都意味著選擇正確的內核實際上很重要。最后一個問題是編號不是連續(xù)的,有時甚至不是確定性的——例如,拓撲可能如下所示:

在這種情況下,一個容器被安排在物理套接字和不同的內核上,這會導致性能下降——我們已經看到由于錯誤的套接字分配,P99 延遲降低了多達 500%。為了處理這個問題,調度器必須從內核收集確切的硬件拓撲,并使用它來分配內核。原始信息在 /proc/cpuinfo 中找到:

利用這些信息,我們可以分配物理上相互接近的核心:

缺點和局限性
雖然 cpusets 解決了大部分延遲的問題,但也存在一些限制和權衡:
無法分配小數(shù)核心。這對于數(shù)據庫進程來說不是問題,因為它們往往很大,因此向上或向下舍入不是問題。但是,這確實意味著容器的數(shù)量不能大于內核的數(shù)量,這對于某些工作負載來說是有問題的。
系統(tǒng)范圍的進程仍然可以竊取時間。例如,通過 systemd、kernel workers 等在宿主機上運行的服務,仍然需要在某個地方運行。理論上也可以將它們分配給一組有限的內核,但這可能很棘手,因為它們需要的時間與系統(tǒng)負載成正比。一種解決方法是在容器子集上使用實時進程調度——后文會介紹這一點。
需要進行碎片整理。隨著時間的推移,可用內核將變得碎片化,并且需要移動進程以創(chuàng)建連續(xù)的可用內核塊。這可以在線完成,但是從一個物理套接字移動到另一個將意味著內存訪問突然變得遠程。這也可以緩解,另一篇文章會介紹[2]。
沒有突發(fā)限制。有時你可能希望使用主機上未分配的資源來加速正在運行的容器。在這篇文章中,我們討論了獨占的 cpusets,但可以將同一個核心分配給多個容器(即 cgroups),也可以將 cpusets 與配額結合使用,這允許突破限制。
結論
切換到有狀態(tài)工作負載的 cpusets 是 Uber 的一項重大改進。它使我們能夠實現(xiàn)更穩(wěn)定的數(shù)據庫級別的延遲,并且通過減少過度配置以處理由于節(jié)流導致的峰值,節(jié)省了大約 11% 的內核。由于沒有突發(fā)限制,相同大小的容器現(xiàn)在在主機之間的表現(xiàn)是一樣的,這也導致了更穩(wěn)定的性能。
Uber 的有狀態(tài)部署平臺是內部開發(fā)的,但Kubernetes ? 也通過使用靜態(tài)策略來支持cpusets[3] 。
有關Uber如何測試配額和 cpusets 的細節(jié),見附錄[4]。
引用鏈接
[1]
原文鏈接: https://eng.uber.com/avoiding-cpu-throttling-in-a-containerized-environment/
[2]
文章鏈接: https://www.kernel.org/doc/html/latest/vm/numa.html
[3]鏈接: https://kubernetes.io/docs/tasks/administer-cluster/cpu-management-policies/#static-policy
[4]附錄: https://gist.github.com/ubermunck/2f116b7817812ae6255d19a4e10242f4
相關閱讀:案例分享 | Yelp 如何在 Kubernetes 上運行 Kafka(第 1 部分 - 架構)
案例分享 | Yelp 如何在 Kubernetes 上運行 Kafka(第 2 部分 - 遷移)
Kubernetes中暴露服務的新方法