回到所有文章

CPU 才用三成,為什麼還被 throttle?拆開 CPU limit 的 100ms 配額

RD 回報延遲很高,儀表板上的 CPU 卻很閒。從 cgroup 的 100ms 配額,看 CPU limit 在 Node 上怎麼被執行,以及該怎麼處理。

RD 在群組裡回報:「API 的 latency 很高,P90 將近 190ms,P99 到了 370ms。可是我看 Grafana,CPU 才用三成,limit 還剩一大截。」你打開儀表板,這個服務的 CPU limit 是 2,平均只用了 0.64 顆 CPU。 CPU 明明很閒,到底發生什麼事了?

這個狀態是在本機 kind 叢集重現的,步驟與完整輸出放在實驗篇。延遲的絕對值受實驗環境影響,本文只看數量級與相對差異。

要回答這個問題,得先知道 Linux 怎麼執行 CPU limit。

1. 從 Pod spec 到 cgroup:CPU limit 怎麼限制 CPU?

在 Pod spec 寫下 limits.cpu: 2,很容易理解成「這個 container 最多用兩顆 CPU」。但 Node 上真正執行限制的,是 Linux 的 cgroup:kernel 用它把一組 process 放在一起,統一限制與統計資源。每個 container 都有自己的 cgroup 目錄,CPU limit 最後會變成其中一個檔案的內容。

kubelet 先把 limit 換算成兩個數字:period,也就是多久結算一次,預設 100ms;以及 quota,每個 period 內最多能用多少 CPU 時間。2 顆 CPU 就是每 100ms 最多 200ms。kubelet 的換算 這兩個數字透過 CRI 交給 container runtime,由 runtime 寫進 container 的 cgroup。CRI 的資源欄位

在 container 裡就讀得到結果(cgroup v2,單位是微秒):

$ kubectl exec <pod> -- cat /sys/fs/cgroup/cpu.max
200000 100000

所以 limit 2 的精確意思是:每 100ms 最多 200ms 的 CPU 時間,由這個 container 裡所有 thread 共用。 它不是一個平滑的速度上限,而是一份每 100ms 重新發放的預算。

2. 為什麼平均只用三成,也會被停下來?

執行這份預算的是 Linux 排程器的 CFS bandwidth control。thread 在 CPU 上跑多久,就從 quota 扣多久;quota 用完之後,這個 cgroup 裡的所有 thread 都會被暫停,直到下一個 period 開始、quota 重新補滿。這個暫停就是 throttle。CFS bandwidth control

「所有 thread 共用」是關鍵。假設一個工作要 8 個 thread 同時各算 20ms,總共需要 160ms 的 CPU 時間:

  • limit 2 時,每個 period 有 200ms,一次就夠,8 個 thread 平行跑,約 20ms 就做完。
  • limit 1 時,每個 period 只有 100ms。8 個 thread 一起跑,12.5ms 就把預算用完,接著全部停住;等下一個 period,再用 7.5ms 做完剩下的部分。
limit 2 與 limit 1 下,8 個 thread 做同一份 160ms 工作的時間軸:limit 1 在 12.5ms 用完預算,暫停到下一個 period
開啟完整圖表

就算這份工作一秒只做一次、平均只用 0.16 顆 CPU,limit 1 還是每次都會讓它停下來。實驗中,同一份工作在 limit 2 下約 34ms 做完,limit 1 下要 84ms 左右;四種 limit 的比較放在實驗篇。

回到開頭的服務。它每個請求約需 6ms 的 CPU,平均每秒 100 個請求,最多同時用 10 個 thread 處理(實驗的 Node 有 10 顆 CPU)。差別只在請求怎麼到達:

請求到達方式平均使用率被 throttle 的 periodp99 延遲
一個一個來約 0.6 顆0%13ms
每次 50 個一起來約 0.64 顆約 30%370ms

平均幾乎一樣。一個一個來時,每個 period 需要的 CPU 遠低於 200ms,從來不會用完預算。50 個一起來時,這一群請求要 300ms 的 CPU,10 個 thread 同時跑,約 20ms 就把 200ms 的預算用完,整個服務停到 period 結束;剩下的請求,以及這段時間剛好到達的其他請求,都得等下一個 period。

會不會被 throttle,看的是 100ms 內要做多少事,不是平均用了多少。

3. 儀表板為什麼看不出來?

儀表板上的 CPU 使用率,通常是 container_cpu_usage_seconds_total 在一段時間內的平均,例如 1 分鐘或 5 分鐘。開頭的服務每秒只有兩群請求,每一群在幾十毫秒內用掉 300ms 的 CPU,其餘時間幾乎閒置,平均起來當然只有 0.64 顆。分鐘級的平均,看不見 100ms 內的擠兌。

要看的是 throttle 的指標。kernel 在每個 cgroup 的 cpu.stat 記錄了 throttle 的次數與時間,cAdvisor 把它們轉成監控指標。cpu.stat 欄位說明 最直接的是「被 throttle 的 period 佔多少比例」,依 container 分開看:

rate(container_cpu_cfs_throttled_periods_total[5m])
  / rate(container_cpu_cfs_periods_total[5m])

開頭的服務,這個比例約 30%。幾個相關的數字各有能說明與不能說明的事:

指標能說明不能說明
CPU 使用率一段時間內平均用了多少100ms 內的突發
throttle 的 period 比例忙碌的 period 裡,有多少用完了預算暫停了多久。分母只算有在跑的 period,閒置時不累積,所以 30% 不等於 30% 的時間被暫停
container_cpu_cfs_throttled_seconds_total暫停的程度變多或變少實際暫停的時間。它是各 CPU 暫停時間的加總,8 個 thread 同時停 40ms 會記成 320ms

這些指標能回答「有沒有被暫停、暫停得多不多」,但不能回答「使用者有沒有受影響」。判斷影響要回到延遲本身。在開頭的服務上,兩者是對得起來的:同樣的流量,不設 limit 時 p99 只有 74 到 86ms。

4. 只把 limit 調高,為什麼不一定有用?

既然 throttle 是 quota 用完造成的,最直接的做法是把 limit 調高。Node 有空閒時,這確實有用:limit 從 2 提高到 4,一群請求的 300ms 裝得進一個 period 的 400ms,throttle 幾乎消失。

但 limit 只是上限,不是保證。能不能真的用到,要看 Node 上還有沒有空閒的 CPU。Node 忙的時候,決定每個 Pod 分到多少的是 request:request 在執行時會變成 cgroup 的 cpu.weight,CPU 不夠分時,Pod 之間大致依 weight 的比例分配;CPU 有空閒時,weight 不起作用,誰都可以用。requests 如何套用在執行時

假設同一個 Node 上有一個鄰居 Pod:request 4、不設 limit,一直把 CPU 用滿。開頭的服務把 limit 調到 4 之後,Node 滿載時能分到多少 CPU,取決於它的 request:

Node 閒與 Node 忙時,limit 4 的服務在不同 request 下能用到的 CPU:Node 忙且 request 500m 時只分到約 0.9 顆
開啟完整圖表

request 維持 500m 時,服務只分得到不到 1 顆,limit 4 的上限根本碰不到。

實驗照這個情境重現,每種設定跑兩次(Node 限制在 8 顆 CPU,細節見實驗篇)。同樣的流量下,p99 延遲與被 throttle 的比例如下:

Node 閒與 Node 忙時的 p99 延遲:Node 忙時 limit 2 與 limit 4 都要好幾秒,throttle 卻幾乎是 0;request 調高後才改善
開啟完整圖表

Node 忙時,limit 2 和 limit 4 一樣慢,p99 都要好幾秒;throttle 卻幾乎是 0,因為服務根本用不到 quota。只看 throttle 的指標,會以為問題已經解決了。request 調高之後,服務在 Node 忙時才拿得回 CPU,但就算 request 和 limit 都是 4,也比不上 Node 閒的時候。

所以只調高 limit 有沒有用,取決於兩件事:新的 quota 裝不裝得下一次突發,以及突發的當下 Node 有沒有空閒。前者要跟著尖峰調,突發再大一倍,limit 4 又不夠了;後者不在這個 Pod 的控制之內。要在 Node 忙時也拿得到,就得連 request 一起調高,而 request 是排程時預留的容量,平時閒著也佔著 Node。

反過來看,weight 也是不設 limit 時保護鄰居的機制:CPU 不夠分時,每個 Pod 大致依 request 的比例拿到自己那份。

5. 回到開頭:該怎麼處理?

0.64 顆 CPU 是一段時間的平均,limit 2 管的卻是每 100ms 的 200ms。這個服務的請求成群到達,每一群需要 300ms 的 CPU,一個 period 的預算裝不下;10 個 thread 在 20ms 內就把 200ms 用完,剩下的請求和這段時間到達的其他請求,一起等下一個 period。平均很低、throttle 很多、p99 很高,三件事同時成立。

第 4 節說明了,調高 limit 有沒有用,要看突發有多大、Node 當下有沒有空閒;連 request 一起調高能在 Node 忙時拿回 CPU,代價是平時也替尖峰預留了容量。所以先弄清楚突發長什麼樣子,再決定怎麼處理。

先確認,再估算突發

  1. 確認延遲和 throttle 同時出現。 逐個 Pod 看 throttle 的 period 比例與延遲的時間點。只有 throttle 沒有延遲,或延遲升高時沒有 throttle,問題就不在這裡。
  2. 估算一次突發需要多少 CPU。 一群請求有多少個、每個請求用多少 CPU,和每個 period 的 quota 比。開頭的服務是 50 × 6ms = 300ms,而 quota 只有 200ms。
  3. 找出突發從哪裡來。 定時任務或批次同時觸發、上游一次 fan-out 大量請求、失敗後的集體重試,都會讓請求成群到達。

增加副本,還是提高 limit?

另一個常見的做法是增加副本。實驗在 Node 有空閒時,用同樣的流量比較了增加副本與提高 limit(和開頭不是同一次執行,只比較圖中的相對關係):

Node 有空閒時,增加副本與提高 limit 的 p99 延遲:兩個各 limit 1 的副本最慢,一個 limit 4 的 Pod 最接近不設 limit
開啟完整圖表

結果和直覺不太一樣。只把同樣的 limit 拆給兩個副本,反而更慢:每個 Pod 分到約 25 個請求、150ms 的 CPU,仍然超過自己 100ms 的預算,而一個 Pod 用不完的預算,另一個也借不到。limit 總和同樣是 4 時,一個 limit 4 的 Pod 也比兩個 limit 2 的副本好:一群請求的 300ms 在一個 period 內就裝得下;兩個副本則要看請求分得平不平均,而實驗中兩個副本分到的請求並不平均(可能的原因見實驗篇)。

選擇與代價

  • request 不變,提高或拿掉 limit。 平時的排程容量不變,突發時借用 Node 上的空閒 CPU,Node 閒時效果最好。代價是 Node 上要真的有空閒;Node 忙時分到多少依 request 決定,throttle 的指標也看不出問題(第 4 節)。limit 越高,擋住失控 Pod 的那道上限也越鬆。
  • 增加副本。 limit 的總量要跟著增加才有用,請求在副本之間分得不平均時,效果也會打折。HPA 依一段時間的平均使用率調整副本數,看不到 100ms 內的突發,所以副本數要依尖峰事先估好,不能指望自動擴容來救。
  • request 與 limit 一起調高。 Node 忙時也拿得回 CPU,但平時的浪費最大:request 是排程時預留的容量,閒著也佔著 Node。
  • 攤平流量。 從來源處理:替定時任務加上隨機延遲、讓上游排隊或限流、重試加上退避。這是唯一讓突發本身變小的做法。
  • 限制同時處理的數量。 例如減少服務處理請求的 thread 數,或用固定大小的 worker pool。throttle 指標會明顯下降,但每 100ms 能做的事沒有變多,只是把「暫停」換成了「排隊」。實驗中,把 thread 從 10 個降到 2 個,throttle 的比例從約 30% 降到約 4%,p99 卻變得更高,無關的小請求也慢了三倍多。它適合搭配優先順序,讓重要的請求先做,而不是拿來降低 throttle 指標。

不論選哪一種,都用延遲確認效果,而不是只看 throttle 的比例。

延伸閱讀

這幾個主題不影響主線的結論,需要時再點開。

網路上「沒用滿也被 throttle」的案例

網路上討論 CPU throttling 的文章,有不少提到「使用率遠低於 limit,卻被大量 throttle」。其中一部分案例,其實是 Linux 4.18 到 5.3 之間的 kernel bug:kernel 以 slice 為單位(預設 5ms)把 quota 先分給各顆 CPU,4.18 起各 CPU 上沒用完的 slice 會過期作廢,thread 多、每個 thread 用量又小的程式,因此在沒用完 quota 的情況下被 throttle。這個問題由 commit de53fd7aedb1 修正,併入 Linux 5.4。修正說明 讀這些文章的數據時,要先看當時的 kernel 版本;引發問題的修改也曾被 backport 到更舊的 kernel,發行版的實際情況需要另外確認。

修掉 bug 之後剩下的,就是本文的機制本身。能調整它的設定不多,本文都沒有實測:

設定作用現況
kernel cpu.max.burst(5.14 起)允許累積沒用完的 quota,在突發時多用一點Kubernetes 沒有提供設定方式,相關 issue 自 2021 年起仍未解決
kubelet cpuCFSQuota: false整台 Node 不執行 CPU limit影響該 Node 上所有 Pod
kubelet cpuCFSQuotaPeriod調整 period 長度需要 CustomCPUCFSQuotaPeriod feature gate,仍是 Alpha
CPU Manager static policyGuaranteed 且 request 為整數的 container 獨占 CPU 核心,並且不設 quotaDisableCPUQuotaWithExclusiveCPUs 自 1.33 起為 Beta、預設開啟;條件見 kubelet 設定 CPU 資源的程式碼
throttle 指標的兩個細節

container 的 cpu.stat 只記錄這個 container 自己的 limit 造成的暫停。如果暫停來自上層,例如 Pod 層的上限,會記在上層的 cgroup;Linux 6.6 以後,可以從同目錄下的 cpu.stat.local 看到 container 實際受到的暫停。Pod 層的上限是各 container limit 的總和,只要有一個 container 沒有 limit,Pod 層就不設上限。

另外,cAdvisor 的數值是定期收集的,每個樣本附帶收集的時間戳。自己拿兩次抓取的數字算 rate 時,分母要用時間戳的差,而不是兩次抓取相隔的時間,實驗篇有實際的例子。

應用程式看到幾顆 CPU

thread 越多,預算越快用完,而很多 runtime 會依自己「看到」的 CPU 數量決定 thread 數。這個數字不一定跟著 limit 走,也會隨版本改變。實驗服務是用 Go 寫的,Go 在 1.25 改變了這個預設值,而且行為由 go.mod 的版本行決定;這些細節和 JVM 的情況整理在實驗篇第 4 節。調整之前,先確認服務實際用了幾個 thread。


本文的 Kubernetes 實作核對 v1.34.0,並比對 v1.35.0–v1.37.0 的 kubelet weight 換算沒有改變。實驗在作者 Mac 上 OrbStack 的一次性 kind 叢集執行(Kubernetes v1.34.11、10 CPU、kernel 7.0),延遲只看數量級;kind 的 Node 本身是 container,cgroup 的最上層和真實主機不同。成群到達是刻意製造的流量形狀,用來重現開頭的狀態;第 4 節持續用滿 Node 的鄰居,也是刻意做出的極端情況。

繼續閱讀其他筆記