Compute as a Service#

I don’t try to understand computers. I try to understand the programs. — Barbara Liskov

寫完程式碼後,你需要硬體來執行它。因此你去購買或租用硬體——這本質上就是 Compute as a Service (CaaS),其中「Compute」是指實際運行程式所需的運算能力。

本章探討這個看似簡單的概念——「給我硬體來跑我的東西」——如何對應到一個能隨著組織演化與成長而存活和擴展的系統。

馴服運算環境#

Google 的內部系統 Borg 是許多現代 CaaS 架構(如 Kubernetes、Mesos)的前身。透過追溯 Borg 的演進,我們可以理解 CaaS 如何回應不斷成長的組織需求。

自動化繁瑣工作#

想像 2000 年代初期的部署方式:用 SFTP 把程式碼傳到機器上、SSH 進去、編譯並執行。這在規模小的時候堪用,但隨著機器數量增長到數十甚至數百台,這套流程就會崩潰。

2002 年,Google 資深工程師 Jeff Dean 曾描述當時執行自動化資料處理任務的痛苦:需要手動取得 50 多台機器清單、逐一啟動程序、逐一監控進度,而且沒有自動遷移機制,還靠人工維護的「登記檔」來分配機器資源。

簡易自動化#

組織可以做一些簡單的事來減輕痛苦:

  • 部署自動化:用 shell script 或更穩健的程式語言,自動化將二進位檔部署到多台機器上並平行執行
  • 監控自動化:匯出監控指標(如「程序是否存活」、「已處理文件數量」),透過 Grafana 或 Prometheus 等工具偵測異常
  • 故障自動恢復:在機器上部署代理程式偵測異常並自動殺掉問題程序;用 wrapper script 自動重啟失敗的程序

雲端世界的等價物是設定 autohealing policy——在健康檢查失敗後自動銷毀並重建 VM 或容器。

自動化排程#

下一步是自動化機器分配。這需要一個知道所有可用機器清單的中央服務,能按需挑選未佔用的機器並自動部署二進位檔,取代手動維護的「登記檔」。

這個系統還能進一步結合故障偵測:

  1. 掃描機器日誌以識別健康問題(如大量磁碟讀取錯誤)
  2. 標記故障機器,通知人員維修
  3. 避免在故障機器上排程新工作
  4. 當正在執行的機器故障時,自動在新機器上重新排程工作

容器化與多租戶#

早期假設機器與程式是一對一的對應關係,但這在運算資源使用上極度低效:

  • 工作類型遠多於機器類型,許多工作必須共用同類型機器
  • 機器的部署週期長,但程式的資源需求會隨時間增長
  • 舊機器不能直接丟棄,必須管理異質叢集

自然的解決方案是讓每個程式指定資源需求(CPU、RAM、磁碟空間),再由排程器將副本 bin-pack 到可用的機器池中。

鄰居的狗在我的 RAM 裡叫#

多租戶引發了隔離問題:

  • 資源競爭:一個程式因 bug 或自然增長而超出宣告的資源,會影響同機器上的其他程式——CPU 會導致延遲抖動,RAM 會導致 OOM kill 或因磁碟 swap 造成嚴重延遲
  • 依賴衝突:不同程式可能需要不同版本的函式庫
  • 安全性:處理敏感資料的程式需要確保同機器上的其他程式無法存取

虛擬機器 (VM) 是經典的隔離方案,但其資源開銷和啟動時間使其不適合輕量級批次作業。這促使 Google 工程師在 2003 年設計 Borg 時選擇了 容器 (containers)——基於 cgroups(Google 工程師於 2007 年貢獻到 Linux 核心)和 chroot jails、bind mounts 或 union/overlay 檔案系統的輕量級隔離機制。開源實作包括 Docker 和 LMCTFY。

自動調整大小與自動擴展#

2006 年的 Borg 根據工程師手動設定的參數(副本數量、資源需求)來排程工作。但讓人類來決定這些數字本身就有問題——這不是人類日常接觸的數值,容易過時且導致效率低落甚至故障。

自然的解決方案是 自動化這些參數設定 (rightsizing),但實務上極其困難。Google 直到近期才達到超過一半的 Borg 叢集資源用量由自動化調整決定。儘管如此,這已涵蓋大多數的組態設定,讓多數工程師免於手動調整容器大小的繁瑣工作——體現了「簡單的事應該簡單,複雜的事應該可能」這一原則。

小結#

隨著組織成長,以下三個軸都會增長:

  • 需要管理的不同應用程式數量
  • 每個應用程式需要執行的副本數量
  • 最大應用程式的規模

為了有效管理規模,需要自動化來處理所有成長軸。自動化本身也會隨時間變得更複雜——例如 GPU/TPU 排程是 Borg 過去十年的重大變更。在較小規模下可以手動完成的操作,隨著規模增長必須自動化,否則組織將被負擔壓垮。

為受管運算環境撰寫軟體#

從手動管理機器清單轉移到自動化排程和自動調整,大幅簡化了叢集管理,但也深刻改變了我們撰寫和思考軟體的方式。

為失敗而設計架構#

假設需要處理一百萬份文件,單機需要約 12 天。分散到 200 台機器可以在 100 分鐘內完成。但在 Borg 的世界裡,排程器可能隨時殺掉其中一個 worker 並遷移到另一台機器。

這就是著名的 「寵物 vs. 牲口」(pets vs. cattle) 比喻:

  • 寵物 (pets):伺服器壞了,人類急忙趕來診斷、修復。很難替換。
  • 牲口 (cattle):伺服器命名為 replica001 到 replica100,某個失敗了,自動化會移除它並自動配置新的。

「牲口」的核心特徵是可以完全自動地建立新的工作實例,不需要手動設定。這使得系統具備自我修復能力——失敗時自動化接管並用健康的實例替換。

如果你的伺服器是寵物,維護負擔會隨叢集規模線性甚至超線性增長。如果是牲口,系統能在失敗後自動恢復到穩定狀態。

但光是讓容器成為牲口還不夠。工作架構也需要調整——將一百萬份文件分成 1,000 個 chunk,每個 chunk 包含 1,000 份文件。Worker 完成一個 chunk 後回報結果並取得下一個。這樣即使某個 worker 失敗,最多只損失一個 chunk 的工作量。

對於處理使用者流量的服務,理想情況下容器被重新排程時不應對使用者產生錯誤。Borg 排程器在計畫重新排程容器時,會提前發送通知,讓容器拒絕新請求同時完成進行中的請求。

批次作業 vs. 服務作業#

Google 將工作負載分為兩大類:

特性批次作業 (Batch)服務作業 (Serving)
目標處理完成特定任務無限期運行,處理請求
關注點吞吐量單一請求延遲
生命週期短暫(分鐘到小時)長期運行
啟動時間通常較短可能較長
範例日誌分析、ML 模型訓練搜尋查詢服務

批次作業透過 MapReduce(後來被 Flume 取代)等框架,將工作切分為小塊並動態分配給 worker,以適應失敗。

服務作業天然適合失敗容忍——工作自然被切分為小片(個別使用者請求),透過負載平衡動態分配給伺服器。但有些服務架構不天然適合此模式:

  • Leader 伺服器:在記憶體或本地檔案系統中維護系統狀態,機器故障時新實例無法重建狀態
  • 資料分片 (sharding):資料分散在 100 台伺服器上,一台故障就暫時無法服務部分資料
  • 以主機名稱識別的伺服器:該主機失去網路連線時,其他系統無法聯繫它

狀態管理#

狀態是將工作當作牲口對待時的主要問題來源。當你替換一個牲口工作時,所有的行程內狀態以及本地儲存都會遺失。因此:

  • 行程內狀態應被視為暫時的
  • 真正的儲存需要放在外部持久儲存系統中

持久狀態本身也可以透過牲口模式管理——透過 狀態複製 (state replication)。類似 RAID 陣列的概念,多個副本持有同一份資料並同步,確保每份資料被複製足夠的次數(通常 3 到 5 份)。Google 為此開發了多種專用儲存方案(GFS、Bigtable、Spanner)。

本地儲存仍然有合理的用途:

  • 快取 (caching):暫時持有的資料以改善延遲。關鍵教訓是——按快取配置延遲目標,但按總負載配置核心應用程式,這樣快取層遺失時不會導致服務中斷
  • 預熱 (warm-up):啟動時從外部儲存拉取資料到本地以改善請求延遲
  • 寫入批次 (batching writes):適用於監控資料等可容忍部分資料遺失的場景

連線到服務#

如果系統中任何地方硬編碼了程式執行的主機名稱,你的程式副本就不是牲口。解決方案是引入一層間接層:

  1. 其他應用程式透過某個持久識別碼(而非主機名稱)來參照你的應用程式
  2. 排程器將應用程式放置在特定機器時,將對應關係寫入解析系統
  3. 客戶端在啟動時查詢地址並建立連線,在背景監控變化

這就是 服務發現 (service discovery),大多數解決方案也包含某種形式的 負載平衡 (load balancing)

由於伺服器可能在回應前被終止,客戶端需要能夠重試請求。對於變更操作,需要設計 API 使其具備冪等性 (idempotency)——發出請求兩次的結果與發出一次相同。一個實用工具是客戶端指定的識別碼:如果具有相同識別碼的請求已被記錄,伺服器假設是重複請求。

另一個需要冪等性的情境是排程器暫時與某台機器失去聯繫,在其他機器上重新排程工作,但隨後原機器恢復——此時會有兩個程式都認為自己是「replica072」。

一次性程式碼#

軟體工程師的日常也包括執行一次性分析、探索性原型、自訂資料處理管線等。工程師的工作站對小規模任務夠用(例如處理 1 GB 日誌),但對於大規模任務(例如 1 TB 日誌),能在分散式環境中利用數百個核心在幾分鐘內完成分析是巨大的效率提升。

工程師執行一次性任務所消耗的運算資源成本,幾乎不可能超過工程師撰寫程式碼所花費的時間成本。運算資源類似於辦公室的麥克筆——設立申請流程節省的費用,遠不如因流程造成的工程機會損失。

但運算資源與麥克筆不同的是,很容易不小心佔用太多——例如有人無意中讓程式佔用了上千台機器。解決方案是為個別工程師設定資源配額 (quota)。Google 的替代方案是利用低優先級批次工作基本上免費運行的特性,為工程師提供幾乎無限的低優先級批次配額。

CaaS 的時間與規模考量#

容器作為抽象層#

容器最初的動機是隔離與多租戶,但它同時扮演了抽象化運算環境的重要角色。容器在部署的軟體和實際機器之間提供了抽象邊界——當機器隨時間變化時,只有容器軟體需要調整,而應用程式軟體可以保持不變。

兩個具體範例:

  1. 檔案系統抽象:允許整合非公司內部撰寫的軟體(開源軟體、被收購的產品),無需修改叢集的基礎檔案系統佈局或修改軟體本身。也有助於依賴管理——軟體可以預先宣告並打包所需的依賴。

  2. 網路埠管理:Google 最初未將網路埠納入容器抽象,導致二進位檔需要自行搜尋未使用的埠(PickUnusedPortOrDie 函式在 Google C++ 程式碼庫中有超過 20,000 個用法)。Docker 使用 Linux namespaces 提供虛擬私有 NIC,Kubernetes 更進一步要求將容器(pods)視為「真實」的 IP 位址。

容器與隱式依賴#

如同所有抽象,Hyrum’s Law 適用於容器抽象。使用者數量龐大,且使用者在使用檔案系統等功能時並不覺得自己在使用 API。

一個經典案例是 Borg 在 2011 年遭遇的 PID 空間耗盡問題。Linux 的 PID_MAX 預設為 32,000,理論上可以提高,但根據 Hyrum’s Law,程式已經隱式依賴 PID 不超過五位數——例如日誌儲存程式因六位數 PID 導致記錄名稱超過最大長度而崩潰。

解決這個問題變成了漫長的多階段專案:

  1. 為單一容器可用的 PID 數量設定臨時上限
  2. 將 PID 空間分為執行緒和程序(因為很少使用者依賴執行緒的 32,000 上限)
  3. 引入 PID namespaces 給每個容器自己的完整 PID 空間——但因為大量系統假設 {hostname, timestamp, pid} 三元組唯一識別一個程序,這個努力在八年後仍在進行中

重點不在於是否應該使用 PID namespaces。當 Borg 的容器被建構時,PID namespaces 還不存在。這凸顯了設計一個隨時間可維護的容器系統的挑戰,也說明了使用由更廣泛社群開發的容器系統的價值——這類問題已經被其他人遇過,經驗教訓已被整合。

統一的運算服務#

最初 Google 的 WorkQueue 只用於批次作業,服務作業則各自運行在專屬機器池中。2003 年 Borg 專案啟動,目標是將這些分散的池整合為一個大型池——涵蓋批次與服務作業,成為每個資料中心唯一的池。

這帶來兩個顯著的效率提升:

  1. 管理統一化:如果每個團隊各自管理自己的機器池,管理實務會隨時間分歧,使全公司範圍的變更越來越複雜。統一的管理基礎設施避免了這種線性擴展因子。

  2. 批次與服務作業的互補性:服務作業需要過度配置以應對流量高峰或部分基礎設施故障,導致機器利用率偏低。但這些閒置資源可以給批次作業使用——當服務作業需要資源時,從批次作業回收(CPU 凍結、RAM 則殺掉程序)。由於批次作業關注的是聚合吞吐量且個別副本是牲口,它們樂於吸收這些閒置容量。在 Google 的案例中,批次作業大部分時間實際上是免費運行的。

服務作業的多租戶需求#

將受管運算解決方案擴展到服務任務時,有額外的需求:

  • 重新排程需要節流:不能像批次作業那樣一次殺掉 50% 的副本,服務作業需要漸進式更新
  • 需要優雅關閉:批次作業通常可以直接殺掉,但服務作業需要提前幾秒的警告,讓它完成進行中的請求

提交的組態#

Borg 排程器透過 RPC 接收服務或批次作業的組態。依賴文件和口耳相傳的知識不如將組態提交到程式碼庫——因為文件和部落知識會隨時間劣化。

隨著時間推移,一個邏輯服務的運行時表現會跨越多個軸成長:

  • 分散到多個資料中心
  • 分支為 staging 和 development 環境
  • 增加附屬服務(如 memcached)

使用標準化組態語言來表達這些複雜設定,可以大幅簡化管理,並支援標準操作(如「更新到新版本,但任何時刻不超過 5% 的容量下線」)。標準化組態也是部署自動化的前提。

選擇運算服務#

如今很少有組織會從頭建構自己的運算架構。現代運算方案涵蓋開源(Kubernetes、Mesos、OpenWhisk、Knative)和公有雲託管服務(GCE、Amazon EC2、AKS、GKE、AWS Lambda、Cloud Functions 等),提供不同的抽象層級。

鎖定效應#

選擇運算服務時需注意高度鎖定因子 (lock-in)

  • 程式碼會被撰寫成利用系統的所有特性(Hyrum’s Law)
  • 如果架構允許將 VM/容器當寵物,團隊就會這樣做
  • 每個運算服務選擇最終會被龐大的工具生態系圍繞——日誌、監控、除錯、告警、視覺化、組態語言等

一個具體的鎖定案例:Borg 最初用 /usr/bin/bash -c 執行使用者命令,後來想改用更輕量的 ash 以節省記憶體。但有些團隊已經用自訂程式碼替換了 Bash 二進位檔來節省記憶體,當 Borg 改用 ash(未被自訂程式碼覆寫)時,這些團隊的記憶體用量反而增加,觸發了告警並導致變更回滾。

集中化 vs. 客製化#

從管理開銷和資源效率的角度,最佳做法是採用單一 CaaS 解決方案管理整個叢集——這基本上就是 Google 對 Borg 的做法。

但成長中的組織會有越來越多樣的需求。例如:

  • Google Compute Engine (2012):每個 VM 是一個 Borg 容器,但 Cloud 的使用者不把 VM 當牲口。解決方案需要 Cloud 團隊支援 VM 即時遷移 (live migration),Borg 也需調整以避免隨意殺掉含有 VM 的容器。
  • Google Search:搜尋容器在本地磁碟上建立巨大索引,需要數小時填充資料。Borg 原本假設任何磁碟故障都需要重新排程,但這與磁碟高故障率結合導致嚴重的可用性問題。

這些客製化導致 Borg 的 API 面越來越大且不可預測。2012 年後 Borg 團隊投入大量時間清理 API,引入功能白名單以限制擴散,但清理工作至今仍在進行。

Kubernetes 受益於 Borg 清理的經驗但不受龐大既有用戶群的牽制,因此從一開始在許多方面(如 labels 的處理)就更現代化。不過 Kubernetes 現在也因廣泛採用而面臨類似問題。

抽象層級:Serverless#

運算環境的演進可以看作不斷提升抽象的過程——從裸機寵物,到 VM(IaaS 如 GCE 或 EC2),到容器牲口,再到更高層的 Serverless

Serverless 的核心概念:假設多個團隊使用相同的框架,與其讓每個團隊運行自己的伺服器容器,不如讓框架伺服器本身也成為多租戶——動態載入/卸載不同團隊的程式碼,動態將請求導向已載入相關程式碼的伺服器。團隊不再運行自己的伺服器,故稱「serverless」。

Serverless 的優點:

  • 更靈活的資源擴展,特別是在低流量端——可以縮減到零實例,成本隨流量彈性變動
  • 基礎設施供應商承擔更大比例的管理開銷
  • 對小型組織或團隊而言,比自行建設容器叢集簡單且便宜得多

Serverless 的限制:

  • 要求程式碼真正無狀態——所有我們討論過的本地狀態管理方式(快取、預熱等)都不適用
  • 多數組織有無法由純無狀態工作負載處理的需求
  • 意味著對環境的控制權降低
  • 許多 serverless 框架建構在其他運算層之上(AppEngine 跑在 Borg 上、Knative 跑在 Kubernetes 上、Lambda 跑在 EC2 上)

Google 的選擇: 不大力投入 serverless 解決方案。Borg 已足夠先進,提供大部分 serverless 的好處。Google 運行許多不適合「真正無狀態」模式的應用程式(GCE、BigQuery、Spanner、需要長時間填充快取的搜尋服務等),因此為所有工作負載維持統一架構的好處超過為部分工作負載建立額外 serverless 堆疊的潛在收益。

但對於較小的組織或團隊,serverless 方案(如 AWS Lambda、Cloud Run)通常更有吸引力。如果叢集無法在多個團隊之間真正共享,其成本只有在真正共享時才能良好攤銷。

選擇具有「逃脫路徑」的方案(如從 KNative 到 Kubernetes)會更有利,因為它提供自然的路徑通往統一的運算架構。

公有雲 vs. 私有雲#

使用公有雲本質上是將管理開銷外包給雲端供應商。好處包括:

  • 專注於核心業務,不需建立基礎設施專業知識
  • 更容易擴展——從簽訂租約到 CLI 獲取 VM,到自動擴展
  • 對年輕的組織或產品特別有價值,因為預測資源需求極具挑戰

主要顧慮是鎖定風險——供應商可能漲價或倒閉。部分緩解策略:

  • 使用開源架構的公有雲方案(如 Kubernetes),確保遷移路徑存在
  • 在低層公有雲上運行高層開源方案(如在 EC2 上跑 KNative),可以帶走客製化和工具
  • 多雲 (multi-cloud):使用兩個以上雲端供應商的同一開源方案(如 GKE 和 AKS),降低對特定供應商的依賴
  • 混合雲 (hybrid cloud):部分工作負載在私有基礎設施上,部分在公有雲——可用公有雲處理溢出流量

結論#

Google 從建構、改進和運行運算基礎設施的經驗中學到了良好設計的通用運算基礎設施的價值。為整個組織提供單一基礎設施(例如每個區域一個或少數幾個共享的 Kubernetes 叢集)在管理和資源成本上帶來顯著的效率提升,並允許在該基礎設施之上開發共享工具。

容器是此架構的關鍵工具——允許在實體(或虛擬)機器上共享不同任務以提高資源效率,同時提供應用程式與作業系統之間的抽象層以確保隨時間的韌性。

善用容器架構需要將應用程式設計為「牲口」模式:工程化你的應用程式使其由可輕鬆自動替換的節點組成,允許擴展到數千個實例。與此模式相容的軟體撰寫需要不同的思維模式——例如將所有本地儲存(包括磁碟)視為暫時的,避免硬編碼主機名稱。

TL;DRs#

  • 規模需要通用基礎設施:在生產環境中運行工作負載需要通用的基礎設施。
  • 穩定的抽象與環境:運算解決方案可以為軟體提供標準化、穩定的抽象和環境。
  • 軟體需要適應:軟體需要調整以適應分散式的受管運算環境。
  • 審慎選擇:組織的運算解決方案應該經過深思熟慮,提供適當的抽象層級。