Cloud Run 服務與任務的比較:一個 Go 二進位檔,兩個進入點
Cloud Run 服務與工作可以共用同一個 Go 映像檔與建置。本文說明如何將單一二進位檔拆分為服務模式與批次工作,以及何時不該這麼做。
Cloud Run 提供兩種執行容器的方式。一種是「服務」(Service),它會持續運行並回應 HTTP 請求。另一種是「任務」(Job),它會啟動、執行工作,然後結束。大多數的指南都將它們視為使用不同映像檔的獨立產品,但其實不必如此。我們透過單一的 Go 二進位檔、單一的映像檔以及單一的建置標籤,來執行一個網頁伺服器及其批次任務。這個二進位檔會檢視其第一個參數,來決定要以哪種模式運作。本篇文章將展示其分派程式碼、部署的樣貌,以及在何種情況下,將其拆分為兩個映像檔反而是正確的選擇。
一個二進位檔如何選擇其進入點
整個訣竅在於,一個容器映像檔只有一個 ENTRYPOINT,但 Cloud Run 允許每個服務 (Service) 或作業 (Job) 將自己的引數傳遞給該進入點。因此,二進位檔會讀取第一個引數並進行分支。
func main() {
if len(os.Args) < 2 {
log.Fatal("usage: blog-engine <serve|ingest|index>")
}
switch os.Args[1] {
case "serve":
runServe(os.Args[2:]) // Cloud Run Service: long-lived HTTP
case "ingest":
runIngest(os.Args[2:]) // Cloud Run Job: parse, translate, load
case "index":
runIndex(os.Args[2:]) // Cloud Run Job: create DB indexes, then exit
default:
log.Fatalf("unknown command %q", os.Args[1])
}
}
Dockerfile 將進入點設定為該二進位檔,僅此而已。它不會寫死一個模式。
ENTRYPOINT ["/blog-engine"]
服務 (Service) 在建立時以 serve 作為其引數。每個作業 (Job) 在建立時都有自己的動詞 (verb)。相同的映像檔摘要,不同的第一個權杖 (token)。
gcloud run deploy blog-engine \
--image "$IMAGE" --args serve
gcloud run jobs create blog-engine-ingest \
--image "$IMAGE" --args ingest
一次建置會產生一個產物。服務 (Service) 和每個作業 (Job) 都會拉取完全相同的位元組,因此在提供頁面的程式碼與將該頁面寫入資料庫的程式碼之間,不會有版本差異。
serve 模式是一個 Cloud Run 服務
serve 是一個請求-回應的程序,它本身不應該會自行結束。Cloud Run 會保持其暖機狀態、將 HTTP 路由至此,並根據流量擴展執行個體的數量。因為部落格需要為搜尋引擎爬蟲提供快速的第一個位元組,所以此服務以 min-instances 1 執行,這樣總會有一個執行個體準備就緒,在寧靜早晨的第一個請求也不會有冷啟動。
服務必須正確處理的一個平台細節是關閉程序。當 Cloud Run 縮減執行個體或推出新修訂版本時,它會發送 SIGTERM,並在 SIGKILL 之前給予程序一個短暫的寬限期。忽略 SIGTERM 的伺服器將會丟棄進行中的請求。所以 serve 會在收到信號時阻擋並耗盡。
func runServe(args []string) {
srv := &http.Server{Addr: ":" + port(), Handler: router()}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("serve: %v", err)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()
<-ctx.Done() // block here for the life of the instance
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutCtx) // let open requests finish
}
其架構很簡單:啟動監聽器、等待終止信號,然後給予開啟的連線一個有限的時間視窗來完成。關於此服務的一切都假設程序的生命週期會比任何單一請求都長。
ingest 和 index 模式是 Cloud Run Jobs
Job 是相反的合約。它不附加到連接埠,也不接收任何流量。它會執行到完成並結束,其結束代碼就是結果。結束代碼為零表示任務成功。任何非零的結束代碼都會將任務標記為失敗,這會觸發重試。
func runIngest(args []string) {
ctx := context.Background()
if err := ingest.Run(ctx); err != nil {
log.Fatalf("ingest: %v", err) // non-zero exit, the Job task fails
}
// clean return, exit 0, the Job task succeeds
}
對於部落格,ingest 會剖析 Markdown 文章,將變更的區段傳送至翻譯模型,並將結果寫入 Firestore。index 會建立一次資料庫索引然後結束。兩者都不屬於 Web 請求。它們可以執行數分鐘,由排程或手動觸發,其成功與否是「是」或「否」,而不是 HTTP 主體。
Jobs 也帶有 Service 所沒有的自身執行設定。這些是對於批次步驟很重要的設定。
| 設定 | 控制項目 | Ingest 值 |
|---|---|---|
task-timeout |
任務被終止前的最長執行時間 | 3600s |
max-retries |
每個失敗任務的重試次數 | 0 |
parallelism |
同時執行的任務數 | 1 |
部落格設定 max-retries 0,因為執行到一半的翻譯不應被靜默地重複。失敗應該停止並被檢查,而不是循環。另一個工作負載可能需要重試和高平行處理,例如處理獨立檔案的扇出 (fan-out)。重點是這些調整選項存在於 Job 上,而作為 Service 執行的相同二進位檔永遠不會看到它們。
為何使用一個二進位檔而非兩個儲存庫
之所以將它們融合在一起,是因為網站伺服器與批次作業並非毫無關聯。在進入點之下,它們幾乎共用所有東西。伺服程式碼從 store 套件讀取文章,而擷取程式碼也用同一個套件寫入文章。兩者都使用相同的資料模型、相同的分類載入器、相同的 Firestore 連線程式碼、相同的設定檔解析。
若將其拆分成兩個儲存庫或兩個映像檔,你就得維護兩份儲存層的複本,或是一個有自己版本控制的共用函式庫。兩邊會漸行漸遠。伺服器開始預期一個擷取端尚未寫入的欄位,或是擷取端寫入了一個伺服器已更新並停止讀取的形狀。這類錯誤在資料以不同版本流經兩半部之前是看不見的。
使用單一二進位檔時,Post 結構只有一個定義的地方,查詢形狀也只有一個存在的地方,且一次編譯要嘛對兩種模式都成功,要嘛都失敗。如果擷取程式碼開始寫入新欄位,伺服程式碼會在同一次建置中,根據相同的定義進行編譯。寫入者與讀取者之間的版本偏差變得不可能發生,因為它們是同一個程式。
這樣做的成本坦白說很小。映像檔會帶著它當前未執行模式的程式碼。伺服執行個體會有擷取程式碼閒置在二進位檔中未被使用,而擷取作業則會編譯進 HTTP 伺服器但從不監聽。對於一個 Go 二進位檔來說,這只是多了幾 MB,並不是真正的限制。你也需要在 main 中加入分派邏輯,也就是上面的 switch 陳述式。這就是全部的代價。
部署共用映像檔且不清除密鑰
共用一個映像檔會改變重新部署的樣貌。只有一次建置。當它完成時,Service 和每個 Job 都會指向新的映像檔,而它們的其他部分則維持不變。
gcloud run services update blog-engine --image "$IMAGE"
gcloud run jobs update blog-engine-ingest --image "$IMAGE"
這是個慘痛教訓換來的規則:在重新部署時,只動 --image,其他什麼都別動。環境變數和密鑰在建立時就已設定一次。如果重新部署時也傳遞了 --set-secrets 或 --set-env-vars,這些旗標會取代整個現有的設定,而不是與其合併。在重新部署指令中漏掉一個密鑰,它就會從執行中的服務消失。第一次在生產環境中發生這種情況時,伺服器會以空的資料庫 URL 啟動,然後每個頁面都回傳 500 錯誤。所以,create 指令是用來設定 env 和 secrets 的地方,而 update 指令只攜帶映像檔。
將 Job 當作一個列表來管理是值得的,而不是用單一寫死的名稱,因為批次工作負載很少會長時間只停留在一個 Job。
JOBS=("blog-engine-ingest")
for job in "${JOBS[@]}"; do
gcloud run jobs update "$job" --image "$IMAGE"
done
當出現第二個 Job 時,例如每日夜間的 sitemap 重建,它會被加入陣列中,然後迴圈會用相同的映像檔來更新它。如果忘了加入,那個 Job 就會持續執行舊的映像檔而不會有任何警告,因為過時的 Job 不會失敗,它只會默默地做錯事。陣列讓完整的 Job 集合成為一個你可以看見並擴充的東西。
何時該使用 Job 而非背景 goroutine
一個誘人的捷徑是將所有東西都保留在 Service 中。您已經有一個長時間執行的程序,那麼為何不在回應請求後,於 goroutine 中啟動繁重的工作呢?對於任何需要實際 CPU 或數分鐘執行時間 (wall time) 的工作,這在 Cloud Run 上是錯誤的直覺。
Service 執行個體是圍繞著請求來計費和擴展的。在啟動它的請求返回後,當執行個體縮減時,goroutine 中的背景工作可能會被中斷,並且它會與請求處理爭奪該執行個體所配置的 CPU。該工作沒有任務逾時、沒有重試機制,也沒有明確的成功或失敗信號。它對平台而言是不可見的。
對於任何在請求之外執行的工作,Job 是其正確的歸宿。這個決定幾乎是機械式的:
- 回應 HTTP 請求、維持 session、提供頁面:Service。
- 排程任務、資料遷移、批次匯入、夜間重建:Job。
- 在請求之外需要實際 CPU 或長時間執行 (wall time):Job,而非掛在 Service 下的 goroutine。
部落格的擷取作業就是一個教科書般的案例。它按排程執行,可能需要數分鐘,需要一個確切的逾時和一個明確的成功與否結果,並且絕不能竊取頁面渲染的運算週期。這就是一個 Job,即使 Service 已經在執行相同的二進位檔,且技術上可以完成這項工作。
權衡取捨,簡而言之
一個具有兩個進入點的二進位檔,為您帶來單一的建置、單一的版本,且寫入資料的程式碼與讀取資料的程式碼之間不會有偏差。對於一個伺服器和批次作業共用資料模型的系統來說,這些是巨大的優勢。您為此付出的代價是一個稍大的映像檔,以及 main 中一個小小的分派切換。對於大多數在線上和離線路徑之間共用程式碼的服務而言,這是一筆划算的交易。
當兩邊不再共用時,這就不再是筆划算的交易了。如果您的「作業」引入了伺服器不需要的龐大依賴樹(例如機器學習執行環境或大型資料工具包),將其納入伺服器映像檔會使每個服務實例及其冷啟動因從未執行的程式碼而變得臃腫。如果這兩條路徑由不同團隊擁有,並以不同的節奏部署,強迫它們使用同一個建置標籤會將想要獨立發展的版本耦合在一起。而如果它們真的什麼都不共用——沒有模型、沒有儲存、沒有設定——那麼這個單一的二進位檔就只是兩個不相關的程式共用一個進入點,而使用兩個映像檔會更清晰。
測試的標準是進入點之下的共用程式碼。當「服務」和「作業」透過相同的套件讀寫相同的類型時,請將它們放在一個二進位檔中,並讓第一個參數選擇模式。當它們在依賴性、所有權或資料上出現分歧時,就分割映像檔,並讓每一方按照自己的方式建置。