「單例服務」模式確保同一時間只有一個應用實例是活躍的,同時仍維持高可用。這個模式可以從應用內部實作,也可以完全委派給 Kubernetes。

問題#

Kubernetes 的主要能力之一,是讓應用能輕鬆而透明地擴縮:Pod 可以用 kubectl scale 這類單一指令命令式擴縮,也可以透過 ReplicaSet 等控制器定義宣告式擴縮,甚至依應用負載動態擴縮(見「彈性擴縮」)。執行同一服務的多個實例(此處指分散式應用中由 Pod 代表的元件,非 Kubernetes Service),通常能提升吞吐量與可用性——可用性提升,是因為當某個實例變得不健康時,請求分派者會把後續請求轉給其他健康實例。

某些情況下,同一時間只允許一個服務實例執行

  • 服務中有週期性執行的任務時,若同時有多個實例,每個實例都會在排定間隔觸發該任務,造成重複執行,而非預期的「只觸發一次」。
  • 服務會對特定資源(檔案系統或資料庫)進行輪詢,我們希望確保只有單一實例、甚至單一執行緒在做輪詢與處理。
  • 必須用單執行緒消費者依序消費訊息代理(message broker)的訊息,而該消費者本身就是單例服務。

在這些情境中,我們需要控制「同時有幾個服務實例是活躍的」(通常只要一個),而不管實際啟動並保持運行的實例有多少

解法#

執行同一 Pod 的多個複本會形成 active-active 拓撲,所有服務實例都是活躍的。我們要的是 active-passive(或 master-slave)拓撲:只有一個實例活躍,其餘皆為被動。根本上,這可以在兩個層級達成:應用外鎖定應用內鎖定

應用外鎖定(Out-of-Application Locking)#

顧名思義,這個機制仰賴應用之外的管理行程來確保只有單一實例在執行。應用實作本身對這項約束毫無所知,只是被當作單例實例來執行。這類似於一個 Java 類別由管理執行期(如 Spring Framework)只實例化一次——類別本身不知道自己是單例,也沒有任何防止多重實例化的程式碼建構。

圖 10-1:應用外鎖定機制,可透過複本數為 1 的 StatefulSet 或 ReplicaSet 控制器實現

在 Kubernetes 中的做法是:啟動一個複本數為 1 的 Pod。但光是這樣不足以讓單例 Pod 高可用——還必須用 ReplicaSet 這類控制器來支撐它,把單例 Pod 變成高可用的單例。這個拓撲嚴格說來不算 active-passive(並沒有被動實例),但效果相同:Kubernetes 確保永遠有一個 Pod 實例在執行;同時,拜控制器執行健康檢查、並在失敗時修復 Pod 之賜,這個單一 Pod 實例也是高可用的。

採用這個做法要特別盯住複本數,不能被意外調高——平台層級沒有任何機制能阻止複本數被更改

「永遠只有一個實例」其實不完全成立#

尤其在出事的時候。ReplicaSet 這類 Kubernetes 原語偏好可用性勝於一致性——這是為了打造高可用、可擴展的分散式系統所做的刻意抉擇。這意味著 ReplicaSet 對其複本套用的是「至少(at least)」而非「至多(at most)」語意:把 ReplicaSet 設為 replicas: 1 時,控制器確保至少有一個實例在跑,但偶爾可能不只一個。

最經典的邊角案例:跑著受控 Pod 的節點變得不健康、與叢集其餘部分失聯。此時 ReplicaSet 控制器會在健康節點上啟動另一個 Pod 實例(假設容量足夠),卻無法確保失聯節點上的 Pod 已被關閉。同樣地,變更複本數或把 Pod 搬到其他節點時,Pod 數也可能暫時超出期望值——這種暫時性增加是為了確保高可用、避免服務中斷,正是無狀態可擴展應用所需要的。

單例可以有韌性、可以復原,但依定義就不是高可用;單例通常偏好一致性勝於可用性。Kubernetes 中同樣偏好一致性、能提供嚴格單例保證的資源是 StatefulSet。若 ReplicaSet 無法提供你應用所需的保證,而你有嚴格的單例要求,StatefulSet 可能就是答案——它為有狀態應用而生,提供包括更強單例保證在內的許多功能,但也帶來更高的複雜度(詳見「有狀態服務」)。

讓外界連上單例 Pod#

Kubernetes 上的單例應用通常對外開啟連線,連往訊息代理、關聯式資料庫、檔案伺服器或其他系統。但偶爾你的單例 Pod 也需要接受傳入連線,Kubernetes 上的做法是透過 Service 資源。

一般的 Service(type: ClusterIP)會建立一個虛擬 IP,並在所有選擇器相符的 Pod 實例間做負載平衡。但由 StatefulSet 管理的單例 Pod 只有一個 Pod、且具備穩定的網路身分,這時建立 headless Service 更合適(同時設定 type: ClusterIPclusterIP: None)。之所以稱為 headless,是因為這種 Service 沒有虛擬 IP,kube-proxy 不處理它,平台也不做任何代理。

即便如此它仍然有用:帶選擇器的 headless Service 會在 API Server 中建立端點記錄,並為相符的 Pod 產生 DNS A 記錄。於是對該 Service 做 DNS 查詢時,回傳的不是虛擬 IP,而是後端 Pod 的實際 IP——這讓你能透過 Service 的 DNS 記錄直接存取單例 Pod,不必經過虛擬 IP。例如建立一個名為 my-singleton 的 headless Service 後,就能用 my-singleton.default.svc.cluster.local 直接取得該 Pod 的 IP。

一句話總結:非嚴格單例用「複本數為 1 的 ReplicaSet + 一般 Service」就夠了;嚴格單例、且要更高效的服務發現,則首選「StatefulSet + headless Service」。

應用內鎖定(In-Application Locking)#

在分散式環境中,控制服務實例數的另一種方式是分散式鎖。每當一個服務實例(或實例內的某個元件)被啟用,它會嘗試取得鎖:成功者成為活躍;後續取鎖失敗的實例則等待,並持續嘗試,以便在當前活躍者釋放鎖時接手。

圖 10-2:應用內鎖定機制

許多既有的分散式框架用這個機制達成高可用與韌性。例如訊息代理 Apache ActiveMQ 可以跑在高可用的 active-passive 拓撲中,由資料來源提供共享鎖:第一個啟動的 broker 實例取得鎖並成為活躍,後續啟動的實例則成為被動、等待鎖被釋放。這個策略確保有單一活躍的 broker 實例,同時具備故障韌性。

可以把這個策略類比為物件導向世界裡的經典單例:一個存放在靜態類別變數中的物件實例。此時類別本身知道自己是單例,並以「不允許在同一行程內建立多個實例」的方式撰寫。放到分散式系統,這意味著容器化應用本身必須被寫成「不論啟動多少 Pod 實例,同一時間都不允許超過一個活躍實例」。

要在分散式環境達成這點,首先需要一套分散式鎖實作,例如 Apache ZooKeeper、HashiCorp Consul、Redis 或 Etcd。

以 ZooKeeper 與 Etcd 實作分散式鎖

ZooKeeper 的典型實作使用臨時節點(ephemeral node):只要客戶端 session 存在它就存在,session 一結束就被刪除。第一個啟動的服務實例在 ZooKeeper 伺服器發起 session 並建立臨時節點,成為活躍;同叢集的其他服務實例則成為被動,等待臨時節點被釋放。這就是 ZooKeeper 實作確保「整個叢集只有一個活躍服務實例」、達成 active/passive 故障移轉行為的方式。

在 Kubernetes 世界裡,與其只為了鎖定功能而管理一整套 ZooKeeper 叢集,更好的選項是使用透過 Kubernetes API 曝露、跑在 master 節點上的 Etcd 能力。Etcd 是分散式鍵值儲存,使用 Raft 協定維持其複製狀態;最重要的是,它提供了實作領導者選舉所需的建構單元,而且已有若干客戶端函式庫實作了這項功能。

例如 Apache Camel 的 Kubernetes 連接器就提供了領導者選舉與單例能力。這個連接器更進一步:它不直接存取 Etcd API,而是使用 Kubernetes API、把 ConfigMap 當作分散式鎖,仰賴 Kubernetes 對編輯資源的**樂觀鎖定(optimistic locking)**保證——同一時間只有一個 Pod 能更新某個 ConfigMap。Camel 的實作用這個保證確保只有一個 Camel route 實例是活躍的,其他實例必須等待並取得鎖後才能啟用。

不論用 ZooKeeper、Etcd 或任何其他分散式鎖實作,做法都類似:只有一個應用實例成為領導者並啟用自己,其他實例保持被動並等待鎖。這確保即使多個 Pod 複本都啟動、健康且運行中,也只有一個服務是活躍的、以單例身分執行商業功能,其餘實例則等著在主節點失效或關閉時取得鎖。

Pod Disruption Budget#

單例服務與領導者選舉試圖限制「同時執行的實例上限」,而 Kubernetes 的 PodDisruptionBudget 提供了互補、方向相反的功能——限制同時因維護而下線的實例數量

其核心是確保:任一時間點都有一定數量或比例的 Pod 不會被自願地從節點逐出。

這裡的「自願(voluntary)」指的是可以被延後的逐出,例如為維護或升級而排空節點(kubectl drain)、或叢集縮容所觸發的逐出;而非「節點變得不健康」這類無法預測或控制的情況。

apiVersion: policy/v1beta1
kind: PodDisruptionBudget
metadata:
  name: random-generator-pdb
spec:
  selector: # 用來計算可用 Pod 的選擇器
    matchLabels:
      app: random-generator
  minAvailable: 2 # 至少必須有兩個 Pod 可用

除了 .spec.minAvailable,也可以改用 .spec.maxUnavailable,指定該組 Pod 在逐出後可以有多少個不可用。

這項功能對基於法定人數(quorum)的應用很有用——它們需要隨時保持最低數量的複本以維持 quorum;也適用於承載關鍵流量、實例數絕不可低於某個比例的應用。

討論#

若你的使用案例需要強單例保證,就不能依賴 ReplicaSet 的應用外鎖定機制。Kubernetes ReplicaSet 的設計目的是保全 Pod 的可用性,而非確保 Pod 的「至多一個」語意。因此存在許多失敗情境(例如跑著單例 Pod 的節點與叢集其餘部分分區、或以新實例取代被刪除的 Pod 實例),會導致短時間內有兩份 Pod 並存。

若這不可接受,請改用 StatefulSet,或研究應用內鎖定選項——後者讓你對領導者選舉流程有更多控制與更強保證,同時也能防止有人改動複本數而意外擴展 Pod

另一種情境是:容器化應用中只有一部分該是單例。例如某個應用提供的 HTTP 端點可以安全地擴展到多個實例,但其中的輪詢元件必須是單例。此時採用應用外鎖定會導致整個服務都無法擴展,於是我們只有兩條路:

  • 把單例元件拆到自己的部署單元以維持單例——理論上不錯,但未必實際,也未必值得那份額外開銷;
  • 使用應用內鎖定,只鎖住必須為單例的那個元件——這讓我們能透明地擴展整個應用,HTTP 端點照樣擴展,其他部分則以 active-passive 單例運作。

更多資訊#

  • Singleton Service Example
  • Simple Leader Election with Kubernetes and Docker
  • Leader Election in Go Client
  • Configuring a Pod Disruption Budget
  • Creating Clustered Singleton Services on Kubernetes
  • Apache Camel Kubernetes Connector