Cloud Run CPU 節流如何中斷背景 Goroutine
Cloud Run 只在處理請求期間為您的容器提供 CPU,因此定時器、心跳和快取刷新會靜默地停滯。本文將說明原因,並提供四種修正方法。
一個在 VM 上運作良好的 Go 服務,在遷移到 Cloud Run 的當天開始遺失其租約鎖 (lease locks)。該租約心跳,一個每三十秒觸發一次的普通 time.Ticker,會一次靜默數分鐘之久。程式碼沒有任何變更。原因是 Cloud Run 的一項預設設定,大多數指南只會用一行字帶過:預設情況下,您的容器只有在處理請求時才會獲得 CPU。在請求之間,CPU 會被節流到幾乎為零,而任何您預期在背景持續運行的 goroutine 也會隨之停止運行。
如果您在 Cloud Run 上有背景迴圈,例如計時器 (tickers)、快取刷新器 (cache refreshers)、心跳 (heartbeats)、佇列消費者 (queue consumers),這個行為最有可能導致它們出錯,而且這種出錯方式永遠不會在本地環境出現。這篇文章會解釋其機制,用 Go 程式碼展示此故障,並逐步介紹四種修復方法以及每種方法的權衡取捨。
Cloud Run 如何分配 CPU
Cloud Run 有兩種 CPU 分配模式,而預設模式是會讓人感到意外的那一種。
預設模式是「僅在處理請求時分配 CPU」(有時稱為以請求為基礎的計費或 CPU 節流)。當您的容器正在處理至少一個請求時,它會獲得完整的 CPU 資源。當最後一個處理中的請求完成時,Cloud Run 會將 CPU 節流至極小的一部分,接近於零。容器仍然存活,其記憶體也完好無損,但幾乎不會排程任何 CPU 週期。您只需為處理請求期間使用的 CPU 付費,這也是此模式較便宜的原因。
另一種模式是「CPU 始終分配」(以執行個體為基礎的計費)。無論是否有請求正在處理,容器在其整個生命週期內都會保有完整的 CPU 資源。您需要為執行個體存在的整個期間付費,作為交換,背景工作會正常執行。
在本地端和一般的 VM 上,您的程序始終擁有 CPU,因此背景 goroutine 可以正常運作。預設的 Cloud Run 模式在不變動您任何一行程式碼的情況下,移除了這個假設,這也正是失敗原因令人困惑的地方。程式是正確的。其底層的排程環境改變了。
在請求之間,實際上會出什麼問題
任何假設背景進程會穩定執行的東西都有風險。常見的案例有:
- 一個以固定間隔觸發來刷新記憶體內快取的
time.Ticker。由於在請求之間,負責刷新的 goroutine 因 CPU 資源不足而無法執行,導致快取變得過時。 - 一個「每 N 秒」更新一次的租約或鎖的心跳。如果負責更新的 goroutine 沒有被排程到,租約就會過期,並被另一個 worker 搶走。
- 一個在記憶體中批次處理並透過計時器刷新的指標或日誌刷新器。刷新動作會被延遲,直到下一個請求恰好喚醒該實例為止。
- 一個循環等待訊息的背景佇列或 Pub/Sub 拉取消費者。在沒有傳入請求的期間,它不會處理任何東西。
關於 time.Ticker 的一個重要細節是,計時器本身並不是問題。tick 是根據現實世界時間(wall-clock time)傳遞的,其值甚至可以暫存在 channel 的緩衝區中。問題在於,等待該 channel 的 goroutine 需要 CPU 才能被喚醒並執行工作,而調節(throttling)機制正好剝奪了這些 CPU 資源。因此,tick 會準時「觸發」,但處理程式會延遲執行、或以叢發方式執行,或只有在請求恰好讓實例恢復完整 CPU 資源時才會執行。
心跳的案例是最棘手的,因為它是無聲的,而且會破壞共享狀態。你不只是遲到而已。你會失去一個你以為還持有的鎖。
一個會讓自身租約過期的心跳
出錯的程式碼大致如下。一個工作者取得租約,然後啟動一個 goroutine 來定時更新租約,同時它會執行一項長時間的工作。
func (w *Worker) run(ctx context.Context) error {
if err := w.store.AcquireLease(ctx, w.id, 90*time.Second); err != nil {
return err
}
go w.heartbeat(ctx) // renew the lease in the background
return w.processLongJob(ctx)
}
func (w *Worker) heartbeat(ctx context.Context) {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
// Needs CPU to run. Under request-based throttling,
// this may not be scheduled until the next request.
_ = w.store.RenewLease(ctx, w.id, 90*time.Second)
}
}
}
在虛擬機器 (VM) 上,這樣沒有問題。在採用預設配置的 Cloud Run 上,如果 processLongJob 本身正在等待 I/O 且沒有新的請求傳入,執行個體會被節流,heartbeat goroutine 不會被排程,九十秒後租約就會到期。另一個執行個體看到已到期的租約,並接手了相同的工作。現在有兩個 worker 在執行相同的任務,這正是租約本來要防止的確切失敗情況。
根本的混淆之處在於,程式碼看起來是並行且正確的,在一個會提供 CPU 給 goroutine 的環境下,它確實如此。Cloud Run 的預設設定在請求之間並不保證這一點。有兩種出路:改變環境,讓背景 goroutine 獲得 CPU;或者改變設計,讓你完全不依賴背景 goroutine。接下來的四個章節將同時涵蓋這兩種方法。
選項 1:為最少一個執行個體始終分配 CPU
最直接的修復方法就是停止節流。將執行個體設定為始終持有 CPU,並讓最少一個執行個體保持暖機狀態。
gcloud run services update my-service \
--no-cpu-throttling \
--min-instances=1
--no-cpu-throttling 會將服務切換為以執行個體為基礎的 CPU,如此一來 goroutine 就能在請求之間執行。--min-instances=1 則讓至少一個執行個體保持運作,這樣背景迴圈就永遠有容器可以執行。這兩者缺一不可。在一個已縮減至零的執行個體上,即使 CPU 是始終配置的,也仍然沒有迴圈在執行,因為該執行個體已不復存在。
其代價是成本。你現在需要支付執行個體運作整個期間的 CPU 和記憶體費用,而不僅僅是在處理請求時;而設定最小執行個體數代表即使在零流量的情況下,你仍需支付一天二十四小時的費用。對於一個確實需要常駐背景迴圈的服務而言,這是實實在在的代價,而且這個代價通常不大。對於一個只是出於習慣而加入背景 goroutine 的服務而言,它會持續付費,來維持一個其實不需要常駐的東西運作。在採用此方法之前,請先自問,這項工作是否真的完全需要在請求之外執行。如果確實需要,這就是一個簡潔明瞭的解決方案。
選項 2:將背景工作移至請求路徑中
如果背景工作的存在只是為了為下一個請求保持某些內容的最新狀態,你可能完全不需要背景迴圈。在請求抵達時執行工作,並設定防護,使其在每個間隔內最多執行一次。
type cache struct {
mu sync.Mutex
data []Item
refreshed time.Time
}
func (c *cache) get(ctx context.Context, refresh func(context.Context) ([]Item, error)) ([]Item, error) {
c.mu.Lock()
defer c.mu.Unlock()
if time.Since(c.refreshed) < 60*time.Second {
return c.data, nil // still fresh, no work
}
data, err := refresh(ctx)
if err != nil {
if c.data != nil {
return c.data, nil // serve stale on refresh failure
}
return nil, err
}
c.data, c.refreshed = data, time.Now()
return c.data, nil
}
現在,刷新是由流量觸發的,而這正好是保證有 CPU 資源的時候。計時器變成了一個過時檢查,而非排程器。這適用於快取、小型的週期性重新計算,以及任何其唯一消費者是請求本身的事物。
它不適用於必須按排程發生,而不管流量如何的工作。如果一小時內沒有請求到達,就一小時內不會有任何東西刷新。對於快取而言,這是可以接受的,因為間隔後的第一個請求會付出一次性的刷新成本。對於必須在特定時間觸發的任務而言,這是不可接受的,而你會需要接下來的兩種選項之一。
選項 3:將定期工作分割為 Cloud Run Job 和 Cloud Scheduler
對於任何真正類似 cron 的工作,也就是按排程執行、完成一個工作單元後就結束,正確的模型並不是在一個處理請求的服務中執行一個長時間運行的迴圈。而是由 Cloud Scheduler 觸發的 Cloud Run Job。
Cloud Run Job 會執行一個容器直到完成,而不是處理請求,並且在整個執行期間始終擁有 CPU。Cloud Scheduler 會根據 cron 運算式來叫用它。您的服務可以保持精簡和無狀態,而批次工作則存在於自己的位置,並擁有自己的逾時和重試政策。
# Deploy the periodic work as a Job (runs to completion, always has CPU)
gcloud run jobs create refresh-job \
--image=REGION-docker.pkg.dev/PROJECT/repo/refresh:latest \
--task-timeout=600 --max-retries=1
# Fire it every 10 minutes with Cloud Scheduler
gcloud scheduler jobs create http refresh-schedule \
--schedule="*/10 * * * *" \
--uri="https://REGION-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/PROJECT/jobs/refresh-job:run" \
--http-method=POST \
--oauth-service-account-email=[email protected]
這能將請求-回應模型與批次工作乾淨地分開。工作之所以能取得 CPU,是因為它完全不受基於請求的節流限制。它在閒置時能縮減至零,且在每次執行之間不產生任何費用。排程器提供您真正的 cron 語意、重試機制,以及可供檢視的執行歷史記錄。
其權衡之處在於粒度與耦合。Cloud Scheduler 的最小間隔為一分鐘,因此對於必須每隔幾秒鐘就執行一次的任務來說,這並不是合適的工具。而且,工作是在一個獨立的程序中執行,因此它所需的任何狀態都必須存放在雙方都能存取的資料庫或快取中,而不是在服務的記憶體裡。對於大多數真正的定期維護工作,例如重新整理具體化視觀表、讓舊記錄過期、傳送摘要等,此模型正符合這類工作的形式。
選項 4:使用 Pub/Sub 推送的事件驅動
如果背景迴圈真的在處理一個佇列,請將其反轉。不要使用在迴圈中拉取訊息的 goroutine,而是讓 Pub/Sub 將每則訊息推送到您服務上的 HTTP 端點。每次傳遞都是一個正常的請求,因此它會獲得 CPU,而且沒有常駐迴圈會被餓死。
// Pub/Sub push delivers one message per HTTP POST.
// This runs as a request, so it always has CPU.
func handlePush(w http.ResponseWriter, r *http.Request) {
var env struct {
Message struct {
Data []byte `json:"data"`
} `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&env); err != nil {
http.Error(w, "bad envelope", http.StatusBadRequest)
return
}
if err := processMessage(r.Context(), env.Message.Data); err != nil {
http.Error(w, "retry", http.StatusInternalServerError) // Pub/Sub redelivers
return
}
w.WriteHeader(http.StatusNoContent) // ack
}
傳回 2xx 表示確認,或傳回 5xx 讓 Pub/Sub 稍後重新傳送。訂閱的重試和無效信件政策會處理失敗,因此您可以刪除一整類手動編寫的重試迴圈。服務可以縮減至零,並在下一則訊息送達時喚醒。
代價是推送傳送是透過 HTTP 逐一傳送訊息,這與批次處理的提取取用者不同。如果您需要高輸送量批次處理或依序提取處理,那麼在具有永久配置 CPU 的元件上使用提取模型會更適合。但對於一次回應一個事件,推送會將背景工作轉變為一般的請求處理,這正是 Cloud Run 預設所建構的模型。
哪個選項適合,以及何時適合使用 CPU 恆定開啟
關鍵問題不在於「我該如何讓我的 goroutine 保持運作」。而是「這真的需要一個常駐的背景迴圈,還是可以改用請求驅動或事件驅動的方式」。大多數在 Cloud Run 上中斷的背景迴圈,從來就不需要是常駐的。它們只是一種方便執行工作的方式,而這些工作更適合以請求、排程作業或事件處理常式來完成。
| 工作模式 | 最佳選擇 |
|---|---|
| 請求本身消耗的快取 | 選項 2,依請求重新整理 |
| Cron 形式的維護,分鐘或更粗的粒度 | 選項 3,作業搭配排程器 |
| 回應佇列中的事件 | 選項 4,Pub/Sub 推送 |
| 真正必須持續運行的迴圈 | 選項 1,CPU 恆定開啟搭配最小執行個體 |
當工作確實無法以請求或事件來表達,且必須在服務程序中持續運行時,才應考慮使用恆定分配的 CPU (選項 1)。例如,次秒級的內部時脈、即時的記憶體內彙總、必須保持連線開啟的串流提取消費者、WebSocket 扇出。這些工作本質上在請求之間就需要 CPU,因此為一個暖機的執行個體付費是正確的決定。這是一個會產生帳單的決策,而不是因為計時器行為不當就該切換的預設選項。
對於其他所有情況,節流並不是一個需要繞過的錯誤。它是一個信號,表示該工作應置於請求服務路徑之外。租約心跳是一個很好的最終範例。即使使用 CPU 恆定開啟,最安全的版本也完全不依賴心跳的觸發。將租約改為基於到期時間,並在到期時使用 compare-and-swap,如此一來,即使錯過了續約,過期的租約仍可被回收。將正確性推入資料中,這樣即使程序短暫失去 CPU,也不會損壞共享狀態。Cloud Run 的預設設定比 VM 更早地強制執行這種紀律,而由此產生的系統往往因此更加穩固。