JVM 預(yù)熱是一個(gè)非常頭疼而又難解決的問(wèn)題。本文討論了在運(yùn)行在 Kubernetes 集群中的 Java 服務(wù)如何解決 JVM 預(yù)熱問(wèn)題的一些方法和經(jīng)驗(yàn)。
JVM 預(yù)熱是一個(gè)非常頭疼而又難解決的問(wèn)題。基于 JVM 的應(yīng)用程序在達(dá)到最高性能之前,需要一些時(shí)間來(lái)“預(yù)熱”。當(dāng)應(yīng)用程序啟動(dòng)時(shí),通常會(huì)從較低的性能開(kāi)始。這歸因于像即時(shí)(JIT)編譯這些事兒,它會(huì)通過(guò)收集使用配置文件信息來(lái)優(yōu)化常用代碼。最終這樣的負(fù)面影響是,與平均水平相比,預(yù)熱期間接收的 request 將具有非常高的響應(yīng)時(shí)間。在容器化、高吞吐量、頻繁部署和自動(dòng)伸縮的環(huán)境中,這個(gè)問(wèn)題可能會(huì)加劇。
在這篇文章中,我們將討論在運(yùn)行在 Kubernetes 集群中的 Java 服務(wù)如何解決 JVM 預(yù)熱問(wèn)題的經(jīng)驗(yàn)。
起因
幾年前,我們逐步從整體中分離出服務(wù),開(kāi)始在 Kubernetes 上進(jìn)行遷移到基于微服務(wù)的體系結(jié)構(gòu)。大多數(shù)新服務(wù)都是在 Java 中開(kāi)發(fā)的。當(dāng)我們?cè)谟《仁袌?chǎng)上運(yùn)行一個(gè)這樣的服務(wù)時(shí),我們第一次遇到了這個(gè)問(wèn)題。我們通過(guò)負(fù)載測(cè)試進(jìn)行了通常的容量規(guī)劃過(guò)程,并確定 N 個(gè) Pod 足以處理超過(guò)預(yù)期的峰值流量。
盡管該服務(wù)在輕松處理高峰流量,但我們?cè)诓渴疬^(guò)程中發(fā)現(xiàn)了問(wèn)題。我們的每個(gè) Pod 在高峰時(shí)間處理的 RPM 都超過(guò) 10k,而我們使用的是 Kubernetes 滾動(dòng)更新機(jī)制。在部署過(guò)程中,服務(wù)的響應(yīng)時(shí)間會(huì)激增幾分鐘,然后再穩(wěn)定到通常的穩(wěn)定狀態(tài)。在我們的儀表板中,會(huì)看到類(lèi)似的圖表:

與此同時(shí),我們開(kāi)始收到來(lái)自部署時(shí)間段內(nèi)的大量投訴,幾乎都關(guān)于高響應(yīng)時(shí)間和超時(shí)錯(cuò)誤。
第一步:花錢(qián)解決問(wèn)題
我們很快意識(shí)到這個(gè)問(wèn)題與 JVM 預(yù)熱階段有關(guān),但當(dāng)時(shí)有其他的重要事情,因此我們沒(méi)有太多時(shí)間進(jìn)行調(diào)查,直接嘗試了最簡(jiǎn)單的解決方案——增加 Pod 數(shù)量,以減少每個(gè) Pod 的吞吐量。我們將 Pod 數(shù)量增加了近三倍,以便每個(gè) Pod 在峰值處理約 4k RPM 的吞吐量。我們還調(diào)整了部署策略,以確保一次最多滾動(dòng)更新 25%(使用?maxSurge?和?maxUnavailable?參數(shù))。這樣就解決了問(wèn)題,盡管我們的運(yùn)行容量是穩(wěn)定狀態(tài)所需容量的 3 倍,但我們能夠在我們的服務(wù)中或任何相關(guān)服務(wù)中沒(méi)有問(wèn)題地進(jìn)行部署。
隨著后面幾個(gè)月里更多的遷移服務(wù),我們開(kāi)始在其他服務(wù)中常常看到這個(gè)問(wèn)題。因此我們決定花一些時(shí)間來(lái)調(diào)查這個(gè)問(wèn)題并找到更好的解決方案。
第二步:預(yù)熱腳本
在仔細(xì)閱讀了各種文章后,我們決定嘗試一下預(yù)熱腳本。我們的想法是運(yùn)行一個(gè)預(yù)熱腳本,向服務(wù)發(fā)送幾分鐘的綜合請(qǐng)求,來(lái)完成 JVM 預(yù)熱,然后再允許實(shí)際流量通過(guò)。
為了創(chuàng)建預(yù)熱腳本,我們從生產(chǎn)流量中抓取了實(shí)際的 URL。然后,我們創(chuàng)建了一個(gè) Python 腳本,使用這些 URL 發(fā)送并行請(qǐng)求。我們相應(yīng)地配置了 readiness 探針的?initialDelaySeconds,以確保預(yù)熱腳本在 Pod 為?ready?并開(kāi)始接受流量之前完成。
令人吃驚的是,盡管結(jié)果有一些改進(jìn),但并不顯著。我們?nèi)匀唤?jīng)常觀察到高響應(yīng)時(shí)間和錯(cuò)誤。此外,預(yù)熱腳本還帶來(lái)了新的問(wèn)題。之前,Pod 可以在 40-50 秒內(nèi)準(zhǔn)備就緒,但用了腳本,它們大約需要 3 分鐘,這在部署期間成為了一個(gè)問(wèn)題,更別說(shuō)在自動(dòng)伸縮期間。我們?cè)陬A(yù)熱機(jī)制上做了一些調(diào)整,比如允許預(yù)熱腳本和實(shí)際流量有一個(gè)短暫的重疊期,但也沒(méi)有看到顯著的改進(jìn)。最后,我們認(rèn)為預(yù)熱腳本的收益太小了,決定放棄。
第三步:?jiǎn)l(fā)式發(fā)現(xiàn)
由于預(yù)熱腳本想法失敗了,我們決定嘗試一些啟發(fā)式技術(shù)-
GC(G1、CMS 和 并行)和各種 GC 參數(shù)
堆內(nèi)存
CPU 分配
經(jīng)過(guò)幾輪實(shí)驗(yàn),我們終于取得了突破。測(cè)試的服務(wù)配置了 Kubernetes 資源 limits:
我們將 CPU request 和 limit 增加到 2000m,并部署服務(wù)以查看影響,可以看到響應(yīng)時(shí)間和錯(cuò)誤有了巨大的改進(jìn),比預(yù)熱腳本好得多。

第一個(gè) Deployment(大約下午 1 點(diǎn))使用 2 個(gè) CPU 配置,第二個(gè) Deployment (大約下午 1:25)使用原來(lái) 1 個(gè) CPU 配置
為了進(jìn)一步測(cè)試,我們將配置升級(jí)到 3000m CPU,令我們驚訝的是,問(wèn)題完全消失了。正如下面看到的,響應(yīng)時(shí)間沒(méi)有峰值。

具有 3 個(gè) CPU 配置的 Deployment
很快,我們就發(fā)現(xiàn)問(wèn)題出在 CPU 節(jié)流上。在預(yù)熱階段,JVM 需要比平均穩(wěn)定狀態(tài)下更多的 CPU 時(shí)間,但 Kubernetes 資源處理機(jī)制(CGroup)根據(jù)配置的 limits,從而限制了 CPU。
有一個(gè)簡(jiǎn)單的方法可以驗(yàn)證這一點(diǎn)。Kubernetes 公開(kāi)了一個(gè)每個(gè) Pod 的指標(biāo),
container_cpu_cfs_throttled_seconds_total?表示這個(gè) Pod 從開(kāi)始到現(xiàn)在限制了多少秒 CPU。如果我們用 1000m 配置觀察這個(gè)指標(biāo),應(yīng)該會(huì)在開(kāi)始時(shí)看到很多節(jié)流,然后在幾分鐘后穩(wěn)定下來(lái)。我們使用該配置進(jìn)行了部署,這是 Prometheus 中所有 Pod 的
container_cpu_cfs_throttled_seconds_total?圖:

正如預(yù)期,在容器啟動(dòng)的前 5 到 7 分鐘有很多節(jié)流,大部分在 500 秒到 1000 秒之間,然后穩(wěn)定下來(lái),這證實(shí)了我們的假設(shè)。
當(dāng)我們使用 3000m CPU 配置進(jìn)行部署時(shí),觀察到下圖:

CPU 節(jié)流幾乎可以忽略不計(jì)(幾乎所有 Pod 都不到 4 秒)。
第四步:改進(jìn)
盡管我們發(fā)現(xiàn)了這個(gè)問(wèn)題的根本,但就成本而言,該解決方案并不太理想。因?yàn)橛羞@個(gè)問(wèn)題的大多數(shù)服務(wù)都已經(jīng)有類(lèi)似的資源配置,并且在 Pod 數(shù)量上超額配置,以避免部署失敗,但是沒(méi)有一個(gè)團(tuán)隊(duì)有將 CPU 的 request、limits 增加三倍并相應(yīng)減少 Pod 數(shù)量的想法。這種解決方案實(shí)際上可能比運(yùn)行更多的 Pod 更糟糕,因?yàn)?Kubernetes 會(huì)根據(jù) request 調(diào)度 Pod,找到具有 3 個(gè)空閑 CPU 容量的節(jié)點(diǎn)比找到具有 1 個(gè)空閑 CPU 的節(jié)點(diǎn)要困難得多。它可能導(dǎo)致集群自動(dòng)伸縮器頻繁觸發(fā),從而向集群添加更多節(jié)點(diǎn)。
我們又回到了原點(diǎn) 但是這次有了一些新的重要信息?,F(xiàn)在問(wèn)題是這樣的:
在最初的預(yù)熱階段(持續(xù)幾分鐘),JVM 需要比配置的 limits(1000m)更多的 CPU(大約 3000m)。預(yù)熱后,即使 CPU limits 為 1000m,JVM 也可以充分發(fā)揮其潛力。Kubernetes 會(huì)使用 request 而不是 limits 來(lái)調(diào)度 Pod。我們清楚地了解問(wèn)題后,答案就出現(xiàn)了——Kubernetes Burstable QoS。
Kubernetes 根據(jù)配置的資源 request 和 limits 將 QoS 類(lèi)分配給 Pod。

到目前為止,我們一直在通過(guò)指定具有相等值的 request 和 limits(最初是 1000m,然后是 3000m)來(lái)使用 Guaranteed QoS。盡管 Guaranteed QoS 有它的好處,但我們不需要在整個(gè) Pod 生命周期中獨(dú)占 3 個(gè) CPU,我們只需要在最初的幾分鐘內(nèi)使用它。Burstable QoS 允許我們指定小于 limits 的 request,例如:

由于 Kubernetes 使用 request 中指定的值來(lái)調(diào)度 Pod,它會(huì)找到具有 1000m CPU 容量的節(jié)點(diǎn)來(lái)調(diào)度這個(gè) Pod。但是由于 3000m 的 limits 要高得多,如果應(yīng)用程序在任何時(shí)候都需要超過(guò) 1000m 的 CPU,并且該節(jié)點(diǎn)上有空閑的 CPU 容量,那么就不會(huì)在 CPU 上限制應(yīng)用程序。如果可用,它最多可以使用 3000m。
這非常符合我們的問(wèn)題。在預(yù)熱階段,當(dāng) JVM 需要更多的 CPU 時(shí),它可以獲取需要的 CPU。JVM 被優(yōu)化后,可以在 request 范圍內(nèi)全速運(yùn)行。這允許我們使用集群中的冗余的資源(足夠可用時(shí))來(lái)解決預(yù)熱問(wèn)題,而不需要任何額外的成本。
最后,進(jìn)行假設(shè)測(cè)試。我們更改了資源配置并部署了應(yīng)用程序,成功了!我們做了更多的測(cè)試以驗(yàn)證結(jié)果一致。此外,我們監(jiān)控了
container_cpu_cfs_throttled_seconds_total?指標(biāo),以下是其中一個(gè) Deployment 的圖表:

正如我們所看到的,這張圖與 3000m CPU 的 Guaranteed QoS 設(shè)置非常相似。節(jié)流幾乎可以忽略不計(jì),它證實(shí)了具有 Burstable QoS 的解決方案是有效的。
為了使 Burstable QoS 解決方案正常工作,節(jié)點(diǎn)上需要有可用的冗余資源。這可以通過(guò)兩種方式驗(yàn)證:
就 CPU 而言,節(jié)點(diǎn)資源未完全耗盡;
工作負(fù)載未使用 request 的 100% CPU。
結(jié)論
盡管花了一些時(shí)間,最終找到了一個(gè)成本效益高的解決方案。Kubernetes 資源限制是一個(gè)重要的概念。我們?cè)谒谢?Java 的服務(wù)中實(shí)現(xiàn)了該解決方案,部署和自動(dòng)擴(kuò)展都運(yùn)行良好,沒(méi)有任何問(wèn)題。
要點(diǎn):
在為應(yīng)用程序設(shè)置資源限制時(shí)要仔細(xì)考慮?;ㄐr(shí)間了解應(yīng)用程序的工作負(fù)載并相應(yīng)地設(shè)置 request 和 limits。了解設(shè)置資源限制和各種 QoS 類(lèi)的含義。
通過(guò)
monitoring/alertingcontainer_cpu_cfs_throttled_seconds_total?來(lái)關(guān)注 CPU 節(jié)流。如果觀察到過(guò)多的節(jié)流,可以調(diào)整資源限制。
使用 Burstable QoS 時(shí),確保在 request 中指定了穩(wěn)定狀態(tài)所需的容量。