「服務發現」模式提供一個穩定的端點,讓服務的客戶端能存取到提供該服務的實例。為此,Kubernetes 依「服務的消費者與提供者位於叢集內或叢集外」提供了多種機制。

問題#

部署在 Kubernetes 上的應用很少獨自存在,通常必須與叢集內的其他服務、或叢集外的系統互動。互動可以由內部發起,也可以由外部刺激觸發:

  • 內部發起通常透過輪詢式消費者(polling consumer):應用在啟動後或稍後主動連上另一個系統,開始收送資料。典型例子包括:Pod 內的應用連上檔案伺服器開始消費檔案、連上訊息代理開始收發訊息、連上關聯式資料庫或鍵值儲存開始讀寫資料。這種情境不需要 Kubernetes 額外設定,「批次工作」與「週期性工作」模式常用這個技巧。
  • 外部刺激則是 Kubernetes 工作負載更常見的使用案例:長期執行的服務等待外部刺激,最常見的形式是來自叢集內其他 Pod 或外部系統的 HTTP 連線。

在後一種情況下,服務消費者需要一套機制來發現那些由排程器動態配置、且可能被彈性擴縮的 Pod。若得由我們自己追蹤、註冊與發現動態 Pod 的端點,將是極大的負擔——這正是 Kubernetes 透過多種機制實作服務發現模式的原因。

解法#

回顧「Kubernetes 之前的年代」,最常見的服務發現機制是客戶端發現(client-side discovery):當服務消費者要呼叫另一個可能擴展成多個實例的服務時,消費者身上帶著一個發現代理,能查詢註冊表取得服務實例清單,再挑一個來呼叫。典型做法是在消費端服務內嵌一個代理(如 ZooKeeper client、Consul client 或 Ribbon),或用共置的另一個行程(如 Prana)去註冊表查詢。

圖 12-1:客戶端服務發現

到了「Kubernetes 之後的年代」,分散式系統的許多非功能性職責——配置、健康檢查、修復、資源隔離——都往平台移動,服務發現與負載平衡也是如此。用服務導向架構(SOA)的說法:服務提供者實例在提供服務能力時仍須向服務註冊表註冊,服務消費者則須存取註冊表資訊才能連到服務。

在 Kubernetes 世界裡,這一切都在幕後發生:服務消費者呼叫一個固定的虛擬 Service 端點,由它動態發現以 Pod 實作的服務實例。 這是伺服器端服務發現。

圖 12-2:伺服器端服務發現——註冊與查詢都由 Kubernetes 承接

服務發現乍看是個簡單模式,實則可用多種機制實作,取決於服務消費者在叢集內或外、以及服務提供者在叢集內或外

叢集內部的服務發現#

假設我們有個網頁應用要跑在 Kubernetes 上。一旦建立含數個複本的 Deployment,排程器就把 Pod 放到合適節點,每個 Pod 啟動前會取得一個叢集 IP。但若另一個 Pod 中的客戶端服務想消費這個網頁應用的端點,沒有簡單的辦法事先知道提供者 Pod 的 IP

這正是 Kubernetes Service 資源要解決的問題:它為一組提供相同功能的 Pod 提供恆定而穩定的進入點

最簡單的建立方式是 kubectl expose——它會建立虛擬 IP(稱為 clusterIP),並從資源中擷取 Pod 選擇器與埠號來組出 Service 定義。但若要完全掌控定義,我們手動建立:

apiVersion: v1
kind: Service
metadata:
  name: random-generator
spec:
  selector:
    app: random-generator # 比對 Pod 標籤的選擇器
  ports:
    - port: 80 # 可透過此埠聯繫到本 Service
      targetPort: 8080 # Pod 實際監聽的埠
      protocol: TCP

這會建立一個名為 random-generator名稱很重要,稍後發現時要用)、type: ClusterIP(預設值)的 Service,在 80 埠接受 TCP 連線,並路由到所有 app: random-generator 相符 Pod 的 8080 埠。Pod 何時、如何被建立都無所謂——任何相符的 Pod 都會成為路由目標。

圖 12-3:叢集內部的服務發現

兩個要記住的重點:Service 一旦建立就取得一個 clusterIP,該 IP 只能從叢集內部存取(名稱由此而來);而且只要 Service 定義存在,這個 IP 就不會改變

那麼叢集內的其他應用如何得知這個動態配置的 clusterIP?有兩種方式。

透過環境變數發現#

Kubernetes 啟動 Pod 時,會把當下已存在的所有 Service 的細節填入其環境變數:

RANDOM_GENERATOR_SERVICE_HOST=10.109.72.32
RANDOM_GENERATOR_SERVICE_PORT=8080

該 Pod 內的應用只要知道自己要消費的 Service 名稱,就能寫程式讀取這些環境變數。這是簡單機制,任何語言寫的應用都能用,也很容易在叢集外模擬以供開發測試。

主要問題是對 Service 建立時機的時序依賴:環境變數無法注入已在執行的 Pod,因此只有在 Service 建立之後才啟動的 Pod 才拿得到座標。這要求 Service 必須先於依賴它的 Pod 被定義——否則那些 Pod 必須重啟。

透過 DNS 查詢發現#

Kubernetes 執行一台 DNS 伺服器,所有 Pod 都自動被設定使用它。新 Service 建立時會自動取得新的 DNS 條目,所有 Pod 立刻可用。只要客戶端知道要存取的 Service 名稱,就能用完整網域名稱(FQDN)連到它:

random-generator.default.svc.cluster.local

其中 random-generator 是 Service 名稱、default 是命名空間、svc 表示這是 Service 資源、cluster.local 是叢集特定後綴。叢集後綴可以省略;從同一命名空間存取時,命名空間也可以省略。

DNS 發現機制沒有環境變數機制的時序缺陷:只要 Service 一被定義,DNS 伺服器就允許所有 Pod 查詢所有 Service。不過若埠號是非標準的、或消費者不知道,你可能仍需靠環境變數查詢埠號。

type: ClusterIP 的其他特性#

其他 Service 型別都建立在這些特性之上:

  • 多埠:單一 Service 定義可支援多組來源與目標埠。例如 Pod 同時支援 8080 的 HTTP 與 8443 的 HTTPS,不必定義兩個 Service,一個就能同時曝露 80 與 443。
  • Session 親和性:預設情況下,新請求進來時 Service 會隨機挑一個 Pod 連線。可用 sessionAffinity: ClientIP 改成「同一客戶端 IP 的所有請求都黏在同一個 Pod」。
  • 就緒探針:若 Pod 定義了就緒檢查且檢查失敗,即使標籤選擇器相符,該 Pod 仍會被移出 Service 端點清單
  • 虛擬 IPtype: ClusterIP 取得的穩定虛擬 IP 不對應任何網路介面,實際上並不存在。是每個節點上的 kube-proxy 接手這個新 Service,更新節點的 iptables 規則,攔截目的地為此虛擬 IP 的封包並換成選定的 Pod IP。
  • 指定 ClusterIP:建立時可用 .spec.clusterIP 指定要用的 IP,必須是預定義範圍內的有效位址。雖然不建議,但在處理「被設定成使用特定 IP 的遺留應用」、或想沿用既有 DNS 條目時可能派上用場。

iptables 規則只加入 Service 定義中指定的協定(如 TCP 或 UDP),不加 ICMP 規則。因此無法 ping 一個 Service 的 IP 位址(ping 用的是 ICMP),但透過 TCP 存取(例如發 HTTP 請求)當然沒問題。

type: ClusterIP 的 Service 只能從叢集內存取,透過比對選擇器來發現 Pod,是最常用的型別。

手動指定的服務發現#

當我們建立帶 selector 的 Service 時,Kubernetes 會在端點資源清單中追蹤相符且可服務的 Pod(可用 kubectl get endpoints random-generator 查看)。但我們也可以把連線導向外部 IP 與埠,做法是省略 selector 定義,並手動建立端點資源

apiVersion: v1
kind: Service
metadata:
  name: external-service
spec:
  type: ClusterIP
  ports:
    - protocol: TCP
      port: 80

接著定義一個同名的 endpoints 資源,內含目標 IP 與埠:

apiVersion: v1
kind: Endpoints
metadata:
  name: external-service # 名稱必須與存取這些 Endpoints 的 Service 相同
subsets:
  - addresses:
      - ip: 1.1.1.1
      - ip: 2.2.2.2
    ports:
      - port: 8080

這個 Service 同樣只能在叢集內存取,消費方式與前述相同(環境變數或 DNS 查詢)。差別在於端點清單是手動維護的,其中的值通常指向叢集之外的 IP。

圖 12-4:手動指定的服務發現

Endpoints 可以放 Pod 的 IP,但不能放其他 Service 的虛擬 IP

Service 有個好處:允許增刪 selector、在外部與內部提供者之間切換,而不必刪除資源定義(刪除會導致 Service IP 改變)。因此服務消費者可以繼續使用最初指向的同一個 Service IP,而實際的服務提供者實作則從地端遷移到 Kubernetes,完全不影響客戶端

同屬手動配置目的地這一類,還有一種 Service:

apiVersion: v1
kind: Service
metadata:
  name: database-service
spec:
  type: ExternalName
  externalName: my.database.example.com
  ports:
    - port: 80

這個定義同樣沒有 selector,但 typeExternalName。從實作角度看這是重要差異:它只透過 DNS 映射到 externalName 所指的內容,等於用 DNS CNAME 為外部端點建立別名,而不是透過 IP 位址走代理。

從叢集外部進行服務發現#

前述機制都使用只能從叢集內存取的虛擬 IP。但 Kubernetes 叢集不會與世隔絕——除了從 Pod 連到外部資源,外部應用想連到 Pod 提供的端點同樣常見。

NodePort#

apiVersion: v1
kind: Service
metadata:
  name: random-generator
spec:
  type: NodePort # 在所有節點上開啟連接埠
  selector:
    app: random-generator
  ports:
    - port: 80
      targetPort: 8080
      # 指定固定埠(必須可用),或省略此欄位讓系統隨機配置
      nodePort: 30036
      protocol: TCP

這個定義除了做前述 Service 該做的事之外,還在所有節點上保留 30036 埠並把進來的連線轉發給該 Service。於是這個 Service 既能在內部透過虛擬 IP 存取,也能在外部透過每個節點的專屬埠存取。

圖 12-5:NodePort 服務發現

這個做法看似不錯,但有若干值得注意的特性與缺點:

  • 埠號:可以不指定 nodePort: 30036,讓 Kubernetes 從其範圍內挑一個空閒埠。
  • 防火牆規則:由於這個做法在所有節點上開埠,你可能得額外設定防火牆規則讓外部客戶端連得進來。
  • 節點選擇:外部客戶端可以連到叢集中任一節點。但若該節點不可用,挑選另一個健康節點是客戶端應用自己的責任。因此在節點前面放一個負載平衡器來挑健康節點並做故障移轉,通常是好主意。
  • Pod 選擇:客戶端經 node port 連入時,會被路由到隨機挑選的 Pod,可能在同一節點,也可能在其他節點。加上 externalTrafficPolicy: Local 可以避免這多一跳、強制 Kubernetes 只挑連線進入的那個節點上的 Pod。但設了這個選項後,Kubernetes 不允許連到其他節點上的 Pod,這可能造成問題——解法是確保每個節點上都有 Pod(例如用常駐服務),或確保客戶端知道哪些節點上有健康的 Pod。
  • 來源位址:使用 NodePort 時,客戶端位址會被 source NAT——封包中含有客戶端 IP 的來源位址,會被換成節點的內部位址。例如客戶端把封包送到節點 1,節點 1 把來源位址換成自己的位址、目的位址換成 Pod 位址,再轉發到 Pod 所在的節點 2;Pod 收到封包時看到的來源位址是節點 1,而非原始客戶端。要避免這點,同樣可設 externalTrafficPolicy: Local,只把流量轉給節點 1 上的 Pod。

LoadBalancer#

NodePort 的限制是:我們仍需要一個負載平衡器來讓客戶端挑到健康節點。type: LoadBalancer 解決了這點——它除了建立一般 Service、在每個節點開埠之外,還會用雲端供應商的負載平衡器把服務對外曝露

圖 12-6:負載平衡器服務發現——一台專有的負載平衡器作為進入 Kubernetes 叢集的閘道

因此這種 Service 只在雲端供應商支援 Kubernetes 並能佈建負載平衡器時才管用

apiVersion: v1
kind: Service
metadata:
  name: random-generator
spec:
  type: LoadBalancer
  clusterIP: 10.0.171.239 # clusterIP 與 loadBalancerIP 由 Kubernetes 在可用時指派
  loadBalancerIP: 78.11.24.19
  selector:
    app: random-generator
  ports:
    - port: 80
      targetPort: 8080
      protocol: TCP
status: # status 欄位由 Kubernetes 管理,會加上 Ingress IP
  loadBalancer:
    ingress:
      - ip: 146.148.47.155

有了這份定義,外部客戶端就能連到負載平衡器,由它挑選節點並定位 Pod。

負載平衡器的佈建與服務發現的具體做法因雲端供應商而異:有些允許你定義負載平衡器位址,有些不行;有些提供保留來源位址的機制,有些則會換成負載平衡器位址。請查閱你所選供應商的具體實作。

還有一種 Service:headless service,你不為它請求專屬 IP,做法是在 spec 中設 clusterIP: None。對 headless service 而言,後端 Pod 會被加入內部 DNS 伺服器,最適合用於為 StatefulSet 實作 Service(詳見「有狀態服務」)。

應用層的服務發現:Ingress#

與前述機制不同,Ingress 不是一種 Service 型別,而是獨立的 Kubernetes 資源。它坐在 Service 前面,扮演智慧路由器與叢集進入點的角色,通常透過對外可及的 URL 提供基於 HTTP 的 Service 存取、負載平衡、SSL 終結與基於名稱的虛擬主機(也有其他特化的 Ingress 實作)。

Ingress 要能運作,叢集中必須有一個或多個 Ingress controller 在執行。

最簡單的 Ingress 曝露單一 Service:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: random-generator
spec:
  backend:
    serviceName: random-generator
    servicePort: 8080

依 Kubernetes 所在的基礎設施與 Ingress controller 實作,這份定義會配置一個對外可及的 IP,並在 80 埠曝露該 Service。但這和 type: LoadBalancer 差別不大——後者每份 Service 定義都需要一個外部 IP。

以下是依 HTTP URI 路徑把單一 IP 路由到多個 Service 的簡單扇出(fan-out)設定:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: random-generator
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  rules: # 給 Ingress controller 依請求路徑分派請求的專屬規則
    - http:
        paths:
          - path: / # 把所有請求導到 random-generator 服務……
            backend:
              serviceName: random-generator
              servicePort: 8080
          - path: /cluster-status # ……除了 /cluster-status,它導往另一個服務
            backend:
              serviceName: cluster-status
              servicePort: 80

每種 Ingress controller 實作都不同,除了常規的 Ingress 定義外,controller 可能需要透過**註解(annotation)**傳入額外組態。

圖 12-7:應用層的服務發現

Ingress 是 Kubernetes 上最強大、同時也最複雜的服務發現機制。當你要在同一個 IP 底下曝露多個服務、且所有服務都使用相同的 L7(通常是 HTTP)協定時,它最有用。

延伸:OpenShift Routes

Red Hat OpenShift 是流行的 Kubernetes 企業版發行版。除了完全相容 Kubernetes 之外,它還提供一些額外功能,其中之一是 Route,與 Ingress 非常相似——相似到差異都不太容易看出來。首先,Route 早於 Kubernetes 引入 Ingress 物件,因此可視為 Ingress 的某種前身。

技術上兩者仍有差異:

  • Route 會被 OpenShift 內建整合的 HAProxy 負載平衡器自動接手,因此不需要另外安裝 Ingress controller(不過你也可以替換掉 OpenShift 內建的負載平衡器)。
  • 對通往 Service 的那一段,可使用 re-encryption 或 pass-through 等額外的 TLS 終結模式。
  • 可使用多個帶權重的後端來分流流量。
  • 支援萬用字元網域。

話說回來,你在 OpenShift 上同樣可以使用 Ingress,兩者可自由選擇。

討論#

叢集內部發現動態 Pod 一律透過 Service 資源達成,只是不同選項導向不同實作。Service 抽象是一種高階、雲原生的方式,用來配置虛擬 IP、iptables、DNS 記錄、環境變數這些低階細節。叢集外部的服務發現則建立在 Service 抽象之上,聚焦於把服務曝露給外界:NodePort 提供了曝露服務的基本功能,但高可用的配置需要與平台基礎設施供應商整合

下表把本章各種機制由簡入繁整理如下:

名稱組態客戶端類型摘要
ClusterIPtype: ClusterIP + .spec.selector內部最常用的內部發現機制
Manual IPtype: ClusterIP + kind: Endpoints內部外部 IP 的發現
Manual FQDNtype: ExternalName + .spec.externalName內部外部 FQDN 的發現
Headless Servicetype: ClusterIP + .spec.clusterIP: None內部不含虛擬 IP、基於 DNS 的發現
NodePorttype: NodePort外部非 HTTP 流量的首選
LoadBalancertype: LoadBalancer外部需要雲端基礎設施支援
Ingresskind: Ingress外部基於 L7/HTTP 的智慧路由機制

旅程並未止步於此。Knative 專案在 Kubernetes 之上引入了新的原語,協助應用開發者處理進階的服務供給、建置與訊息傳遞。

就服務發現而言,Knative serving 子專案特別值得注意:它引入了一個與本章 Service 同 kind(但屬於不同 API group)的新 Service 資源,除了支援應用版本(revision)之外,也支援負載平衡器背後極具彈性的服務擴縮。

更多資訊#

  • Service Discovery Example
  • Kubernetes Services
  • DNS for Services and Pods
  • Debug Services
  • Using Source IP
  • Create an External Load Balancer
  • Kubernetes NodePort versus LoadBalancer versus Ingress?
  • Ingress
  • Kubernetes Ingress versus OpenShift Route