說說 Kubernetes 是怎么實現服務發現的
我們來說說 Kubernetes 的服務發現。那么首先這個大前提是同主機通信以及跨主機通信都是 ok 的,即同一 Kubernetes 集群中各個 Pod 都是互通的。這點是由更底層的方案實現,包括 docker0/CNI 網橋、Flannel vxlan/host-gw 模式等,在此篇就不展開講了。
在各 Pod 都互通的前提下,我們可以通過訪問 podIP 來調用 Pod 上的資源,那么離服務發現還有多少距離呢?首先 Pod 的 IP 不是固定的,另一方面我們訪問一組 Pod 實例的時候往往會有負載均衡的需求,那么 Service 對象就是用來解決此類問題的。
集群內通信
Endpoints
Service 首先解決的是集群內通信的需求,首先我們編寫一個普通的 deployment:
- apiVersion: apps/v1
- kind: Deployment
- metadata:
- name: hostnames
- spec:
- selector:
- matchLabels:
- app: hostnames
- replicas: 3
- template:
- metadata:
- labels:
- app: hostnames
- spec:
- containers:
- - name: hostnames
- image: mirrorgooglecontainers/serve_hostname
- ports:
- - containerPort: 9376
- protocol: TCP
這個應用干的事兒就是訪問它是返回自己的 hostname,并且每個 Pod 都帶上了 APP 為 hostnames 的標簽。
那么我們為這些 pod 編寫一個普通的 Service:
- apiVersion: v1
- kind: Service
- metadata:
- name: hostnames
- spec:
- selector:
- app: hostnames
- ports:
- - name: default
- protocol: TCP
- port: 80
- targetPort: 9376
可以看到 Service 通過 selector 選擇 了帶相應的標簽 Pod,而這些被選中的 Pod,成為 Endpoints,我們可以試一下:
- ~/cloud/k8s kubectl get ep hostnames
- NAME ENDPOINTS
- hostnames 172.28.21.66:9376,172.28.29.52:9376,172.28.70.13:9376
當某一個 Pod 出現問題,不處于 running 狀態或者 readinessProbe 未通過時,Endpoints 列表會將其摘除。
ClusterIP
以上我們有了 Service 和 Endpoints,而默認創建 Service 的類型是 ClusterIP 類型,我們查看一下之前創建的 Service:
- ~ kubectl get svc hostnames
- NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
- hostnames ClusterIP 10.212.8.127 <none> 80/TCP 8m2s
我們看到 ClusterIP 是 10.212.8.127,那么我們此時可以在 Kubernetes 集群內通過這個地址訪問到 Endpoints 列表里的任意 Pod:
- sh-4.2# curl 10.212.8.127
- hostnames-8548b869d7-9qk6b
- sh-4.2# curl 10.212.8.127
- hostnames-8548b869d7-wzksp
- sh-4.2# curl 10.212.8.127
- hostnames-8548b869d7-bvlw8
訪問了三次 ClusterIP 地址,返回了三個不同的 hostname,我們意識到 ClusterIP 模式的 Service 自動對請求做了 round robin 形式的負載均衡。
對于此時 ClusterIP 模式 Serivice 來說,它有一個 A 記錄是 service-name.namespace-name.svc.cluster.local,指向 ClusterIP 地址:
- sh-4.2# nslookup hostnames.coops-dev.svc.cluster.local
- Server: 10.212.0.2
- Address: 10.212.0.2#53
- Name: hostnames.coops-dev.svc.cluster.local
- Address: 10.212.8.127
理所當然我們通過此 A 記錄去訪問得到的效果一樣:
- sh-4.2# curl hostnames.coops-dev.svc.cluster.local
- hostnames-8548b869d7-wzksp
那對 Pod 來說它的 A 記錄是啥呢,我們可以看一下:
- sh-4.2# nslookup 172.28.21.66
- 66.21.28.172.in-addr.arpa name = 172-28-21-66.hostnames.coops-dev.svc.cluster.local.
Headless service
Service 的 CluserIP 默認是 Kubernetes 自動分配的,當然也可以自己設置,當我們將 CluserIP 設置成 None 的時候,它就變成了 Headless service。
Headless service 一般配合 StatefulSet 使用。StatefulSet 是一種有狀態應用的容器編排方式,其核心思想是給予 Pod 指定的編號名稱,從而讓 Pod 有一個不變的唯一網絡標識碼。那這么說來,使用 CluserIP 負載均衡訪問 Pod 的方式顯然是行不通了,因為我們渴望通過某個標識直接訪問到 Pod 本身,而不是一個虛擬 vip。
這個時候我們其實可以借助 DNS,每個 Pod 都會有一條 A 記錄 pod-name.service-name.namespace-name.svc.cluster.local 指向 podIP,我們可以通過這條 A 記錄直接訪問到 Pod。
我們編寫相應的 StatefulSet 和 Service 來看一下:
- ---
- apiVersion: apps/v1
- kind: StatefulSet
- metadata:
- name: hostnames
- spec:
- serviceName: "hostnames"
- selector:
- matchLabels:
- app: hostnames
- replicas: 3
- template:
- metadata:
- labels:
- app: hostnames
- spec:
- containers:
- - name: hostnames
- image: mirrorgooglecontainers/serve_hostname
- ports:
- - containerPort: 9376
- protocol: TCP
- ---
- apiVersion: v1
- kind: Service
- metadata:
- name: hostnames
- spec:
- selector:
- app: hostnames
- clusterIP: None
- ports:
- - name: default
- protocol: TCP
- port: 80
- targetPort: 9376
如上,StatefulSet 和 deployment 并沒有什么不同,多了一個字段 spec.serviceName,這個字段的作用就是告訴 StatefulSet controller,在邏輯處理時使用 hostnames 這個 Service 來保證 Pod 的唯一可解析性。
當你執行 apply 之后,一會你就可以看到生成了對應的 Pod:
- ~ kubectl get pods -w -l app=hostnames
- NAME READY STATUS RESTARTS AGE
- hostnames-0 1/1 Running 0 9m54s
- hostnames-1 1/1 Running 0 9m28s
- hostnames-2 1/1 Running 0 9m24s
如意料之中,這里對 Pod 名稱進行了遞增編號,并不重復,同時這些 Pod 的創建過程也是按照編號依次串行進行的。我們知道,使用 deployment 部署的 Pod 名稱會加上 replicaSet 名稱和隨機數,重啟后是不斷變化的。而這邊使用 StatefulSet 部署的 Pod,雖然 podIP 仍然會變化,但名稱是一直不會變的,基于此我們得以通過固定的 DNS A 記錄來訪問到每個 Pod。
那么此時,我們來看一下 Pod 的 A 記錄:
- sh-4.2# nslookup hostnames-0.hostnames
- Server: 10.212.0.2
- Address: 10.212.0.2#53
- Name: hostnames-0.hostnames.coops-dev.svc.cluster.local
- Address: 172.28.3.57
- sh-4.2# nslookup hostnames-1.hostnames
- Server: 10.212.0.2
- Address: 10.212.0.2#53
- Name: hostnames-1.hostnames.coops-dev.svc.cluster.local
- Address: 172.28.29.31
- sh-4.2# nslookup hostnames-2.hostnames
- Server: 10.212.0.2
- Address: 10.212.0.2#53
- Name: hostnames-2.hostnames.coops-dev.svc.cluster.local
- Address: 172.28.23.31
和之前的推論一致,我們可以通過 pod-name.service-name.namespace-name.svc.cluster.local 這條 A 記錄訪問到 podIP,在同一個 namespace 中,我們可以簡化為 pod-name.service-name。
而這個時候,Service 的 A 記錄是什么呢:
- sh-4.2# nslookup hostnames
- Server: 10.212.0.2
- Address: 10.212.0.2#53
- Name: hostnames.coops-dev.svc.cluster.local
- Address: 172.28.29.31
- Name: hostnames.coops-dev.svc.cluster.local
- Address: 172.28.3.57
- Name: hostnames.coops-dev.svc.cluster.local
- Address: 172.28.23.31
原來是 Endpoints 列表里的一組 podIP,也就是說此時你依然可以通過service-name.namespace-name.svc.cluster.local這條 A 記錄來負載均衡地訪問到后端 Pod。
iptables
或多或少我們知道 Kubernetes 里面的 Service 是基于 kube-proxy 和 iptables 工作的。Service 創建之后可以被 kube-proxy 感知到,那么它會為此在宿主機上創建對應的 iptables 規則。
以 CluserIP 模式的 Service 為例,首先它會創建一條 KUBE-SERVICES 規則作為入口:
- -A KUBE-SERVICES -d 10.212.8.127/32 -p tcp -m comment --comment "default/hostnames: cluster IP" -m tcp --dport 80 -j KUBE-SVC-NWV5X2332I4OT4T3
這條記錄的意思是:所有目的地址是 10.212.8.127 這條 CluserIP 的,都將跳轉到 KUBE-SVC iptables 鏈處理。
那么我們來看 KUBE-SVC 鏈都是什么:
- -A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -m statistic --mode random --probability 0.33332999982 -j KUBE-SEP-WNBA2IHDGP2BOBGZ
- -A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-X3P2623AGDH6CDF3
- -A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -j KUBE-SEP-57KPRZ3JQVENLNBR
這組規則其實是用于負載均衡的,我們看到了--probability 依次是 1/3、1/2、1,由于 iptables 規則是自上而下匹配的,所以設置這些值能保證每條鏈匹配到的幾率一樣。處理完負載均衡的邏輯后,又分別將請求轉發到了另外三條規則,我們來看一下:
- -A KUBE-SEP-57KPRZ3JQVENLNBR -s 172.28.21.66/32 -m comment --comment "default/hostnames:" -j MARK --set-xmark 0x00004000/0x00004000
- -A KUBE-SEP-57KPRZ3JQVENLNBR -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 172.28.21.66:9376
- -A KUBE-SEP-WNBA2IHDGP2BOBGZ -s 172.28.29.52/32 -m comment --comment "default/hostnames:" -j MARK --set-xmark 0x00004000/0x00004000
- -A KUBE-SEP-WNBA2IHDGP2BOBGZ -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 172.28.29.52:9376
- -A KUBE-SEP-X3P2623AGDH6CDF3 -s 172.28.70.13/32 -m comment --comment "default/hostnames:" -j MARK --set-xmark 0x00004000/0x00004000
- -A KUBE-SEP-X3P2623AGDH6CDF3 -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 172.28.70.13:9376
可以看到 KUBE-SEP 鏈就是三條 DNAT 規則,并在 DNAT 之前設置了一個 0x00004000 的標志。DNAT 規則就是在 PREROUTING,即路由作用之前,將請求的目的地址和端口改為 --to-destination 指定的 podIP 和端口。這樣一來,我們起先訪問 10.212.8.127 這個 CluserIP 的請求,就會被負載均衡到各個 Pod 上。
那么 Pod 重啟了,podIP 變了怎么辦?自然是 kube-proxy 負責監聽 Pod 變化以及更新維護 iptables 規則了。
而對于 Headless service 來說,我們直接通過固定的 A 記錄訪問到了 Pod,自然不需要這些 iptables 規則了。
iptables 理解起來比較簡單,但實際上性能并不好。可以想象,當我們的 Pod 非常多時,成千上萬的 iptables 規則將被創建出來,并不斷刷新,會占用宿主機大量的 CPU 資源。一個行之有效的方案是基于 IPVS 模式的 Service,IPVS 不需要為每個 Pod 都設置 iptables 規則,而是將這些規則都放到了內核態,極大降低了維護這些規則的成本。
集群間通信
外界訪問 Service
以上我們講了請求怎么在 Kubernetes 集群內互通,主要基于 kube-dns 生成的 DNS 記錄以及 kube-proxy 維護的 iptables 規則。而這些信息都是作用在集群內的,那么自然我們從集群外訪問不到一個具體的 Service 或者 Pod 了。
Service 除了默認的 CluserIP 模式外,還提供了很多其他的模式,比如 nodePort 模式,就是用于解決該問題的。
- apiVersion: v1
- kind: Service
- metadata:
- name: hostnames
- spec:
- selector:
- app: hostnames
- type: NodePort
- ports:
- - nodePort: 8477
- protocol: TCP
- port: 80
- targetPort: 9376
我們編寫了一個 NodePort 模式的 Service,并且設置 NodePort 為 8477,那么意味著我們可以通過任意一臺宿主機的 8477 端口訪問到 hostnames 這個 Service。
- sh-4.2# curl 10.1.6.25:8477
- hostnames-8548b869d7-j5lj9
- sh-4.2# curl 10.1.6.25:8477
- hostnames-8548b869d7-66vnv
- sh-4.2# curl 10.1.6.25:8477
- hostnames-8548b869d7-szz4f
我們隨便找了一臺 Node 地址去訪問,得到了相同的返回配方。
那么這個時候它的 iptables 規則是怎么作用的呢:
- -A KUBE-NODEPORTS -p tcp -m comment --comment "default/hostnames: nodePort" -m tcp --dport 8477 -j KUBE-SVC-67RL4FN6JRUPOJYM
kube-proxy 在每臺宿主機上都生成了如上的 iptables 規則,通過 --dport 指定了端口,訪問該端口的請求都會跳轉到 KUBE-SVC 鏈上,KUBE-SVC 鏈和之前 CluserIP Service 的配方一樣,接下來就和訪問 CluserIP Service 沒什么區別了。
不過還需要注意的是,在請求離開當前宿主機發往其他 Node 時會對其做一次 SNAT 操作:
- -A KUBE-POSTROUTING -m comment --comment "kubernetes service traffic requiring SNAT" -m mark --mark 0x4000/0x4000 -j MASQUERADE
可以看到這條 postrouting 規則給即將離開主機的請求進行了一次 SNAT,判斷條件為帶有 0x4000 標志,這就是之前 DNAT 帶的標志,從而判斷請求是從 Service 轉發出來的,而不是普通請求。
需要做 SNAT 的原因很簡單,首先這是一個外部的未經 Kubernetes 處理的請求,如果它訪問 node1,node1 的負載均衡將其轉發給 node2 上的某個 Pod,這沒什么問題,而這個 Pod 處理完后直接返回給外部 client,那么外部 client 就很疑惑,明明自己訪問的是 node1,給自己返回的確是 node2,這時往往會報錯。
SNAT 的作用與 DNAT 相反,就是在請求從 node1 離開發往 node2 時,將源地址改為 node1 的地址,那么當 node2 上的 Pod 返回時,會返回給 node1,然后再讓 node1 返回給 client。
- client
- | ^
- | |
- v |
- node 2 <--- node 1
- | ^ SNAT
- | | --->
- v |
- endpoints
Service 還有另外 2 種通過外界訪問的方式。適用于公有云的 LoadBalancer 模式的 service,公有云 Kubernetes 會調用 CloudProvider 在公有云上為你創建一個負載均衡服務,并且把被代理的 Pod 的 IP 地址配置給負載均衡服務做后端。另外一種是 ExternalName 模式,可以通過在 spec.externalName 來指定你想要的外部訪問域名,例如 hostnames.example.com,那么你訪問該域名和訪問 service-name.namespace-name.svc.cluser.local 效果是一樣的,這時候你應該知道,其實 kube-dns 為你添加了一條 CNAME 記錄。
Ingress
Service 有一種類型叫作 LoadBalancer,不過如果每個 Service 對外都配置一個負載均衡服務,成本很高而且浪費。一般來說我們希望有一個全局的負載均衡器,通過訪問不同 url,轉發到不同 Service 上,而這就是 Ingress 的功能,Ingress 可以看做是 Service 的 Service。
Ingress 其實是對反向代理的一種抽象,相信大家已經感覺到,這玩意兒和 Nginx 十分相似,實際上 Ingress 是抽象層,而其實現層其中之一就支持 Nginx。
我們可以部署一個 nginx ingress controller:
- $ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/mandatory.yaml
mandatory.yaml是官方維護的 ingress controller,我們看一下:
- kind: ConfigMap
- apiVersion: v1
- metadata:
- name: nginx-configuration
- namespace: ingress-nginx
- labels:
- app.kubernetes.io/name: ingress-nginx
- app.kubernetes.io/part-of: ingress-nginx
- ---
- apiVersion: extensions/v1beta1
- kind: Deployment
- metadata:
- name: nginx-ingress-controller
- namespace: ingress-nginx
- labels:
- app.kubernetes.io/name: ingress-nginx
- app.kubernetes.io/part-of: ingress-nginx
- spec:
- replicas: 1
- selector:
- matchLabels:
- app.kubernetes.io/name: ingress-nginx
- app.kubernetes.io/part-of: ingress-nginx
- template:
- metadata:
- labels:
- app.kubernetes.io/name: ingress-nginx
- app.kubernetes.io/part-of: ingress-nginx
- annotations:
- ...
- spec:
- serviceAccountName: nginx-ingress-serviceaccount
- containers:
- - name: nginx-ingress-controller
- image: quay.io/kubernetes-ingress-controller/nginx-ingress-controller:0.20.0
- args:
- - /nginx-ingress-controller
- - --configmap=$(POD_NAMESPACE)/nginx-configuration
- - --publish-service=$(POD_NAMESPACE)/ingress-nginx
- - --annotations-prefix=nginx.ingress.kubernetes.io
- securityContext:
- capabilities:
- drop:
- - ALL
- add:
- - NET_BIND_SERVICE
- # www-data -> 33
- runAsUser: 33
- env:
- - name: POD_NAME
- valueFrom:
- fieldRef:
- fieldPath: metadata.name
- - name: POD_NAMESPACE
- - name: http
- valueFrom:
- fieldRef:
- fieldPath: metadata.namespace
- ports:
- - name: http
- containerPort: 80
- - name: https
- containerPort: 443
總的來說,我們定義了一個基于 nginx-ingress-controller 鏡像的 Pod,而這個 Pod 自身,是一個監聽 Ingress 對象及其代理后端 Service 變化的控制器。
當一個 Ingress 對象被創建時,nginx-ingress-controller 就會根據 Ingress 對象里的內容,生成一份 Nginx 配置文件(nginx.conf),并依此啟動一個 Nginx 服務。
當 Ingress 對象被更新時,nginx-ingress-controller 就會更新這個配置文件。nginx-ingress-controller 還通過 Nginx Lua 方案實現了 nginx upstream 的動態配置。
為了讓外界可以訪問到這個 Nginx,我們還得給它創建一個 Service 來把 Nginx 暴露出去:
- $ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/provider/baremetal/service-nodeport.yaml
這里面的內容描述了一個 NodePort 類型的 Service:
- apiVersion: v1
- kind: Service
- metadata:
- name: ingress-nginx
- namespace: ingress-nginx
- labels:
- app.kubernetes.io/name: ingress-nginx
- app.kubernetes.io/part-of: ingress-nginx
- spec:
- type: NodePort
- ports:
- - name: http
- port: 80
- targetPort: 80
- protocol: TCP
- - name: https
- port: 443
- targetPort: 443
- protocol: TCP
- selector:
- app.kubernetes.io/name: ingress-nginx
- app.kubernetes.io/part-of: ingress-nginx
可以看到這個 Service 僅僅是把 Nginx Pod 的 80/443 端口暴露出去,完了你就可以通過宿主機 IP 和 NodePort 端口訪問到 Nginx 了。
接下來我們來看 Ingress 對象一般是如何編寫的,我們可以參考一個例子。
- apiVersion: extensions/v1beta1
- kind: Ingress
- metadata:
- name: cafe-ingress
- spec:
- tls:
- - hosts:
- - cafe.example.com
- secretName: cafe-secret
- rules:
- - host: cafe.example.com
- http:
- paths:
- - path: /tea
- backend:
- serviceName: tea-svc
- servicePort: 80
- - path: /coffee
- backend:
- serviceName: coffee-svc
- servicePort: 80
這個 Ingress 表明我們整體的域名是 cafe.example.com,希望通過 cafe.example.com/tea 訪問 tea-svc 這個 Service,通過 cafe.example.com/coffee 訪問 coffee-svc 這個 Service。這里我們通過關鍵字段 spec.rules 來編寫轉發規則。
我們可以查看到 Ingress 對象的詳細信息:
- $ kubectl get ingress
- NAME HOSTS ADDRESS PORTS AGE
- cafe-ingress cafe.example.com 80, 443 2h
- $ kubectl describe ingress cafe-ingress
- Name: cafe-ingress
- Namespace: default
- Address:
- Default backend: default-http-backend:80 (<none>)
- TLS:
- cafe-secret terminates cafe.example.com
- Rules:
- Host Path Backends
- ---- ---- --------
- cafe.example.com
- /tea tea-svc:80 (<none>)
- /coffee coffee-svc:80 (<none>)
- Annotations:
- Events:
- Type Reason Age From Message
- ---- ------ ---- ---- -------
- Normal CREATE 4m nginx-ingress-controller Ingress default/cafe-ingress
我們之前講了我們通過 NodePort 的方式將 nginx-ingress 暴露出去了,而這時候我們 Ingress 配置又希望通過 cafe.example.com 來訪問到后端 Pod,那么首先 cafe.example.com 這個域名得指到任意一臺宿主機 Ip:nodePort上,請求到達 nginx-ingress 之后再轉發到各個后端 Service 上。當然,暴露 nginx-ingress 的方式有很多種,除了 NodePort 外還包括 LoadBalancer、hostNetwork 方式等等。
我們最后來試一下請求:
- $ curl cafe.example.com/coffee
- Server name: coffee-7dbb5795f6-vglbv
- $ curl cafe.example.com/tea
- Server name: tea-7d57856c44-lwbnp
可以看到 Nginx Ingress controller 已經為我們成功將請求轉發到了對應的后端 Service。而當請求沒有匹配到任何一條 ingress rule 的時候,理所當然我們會得到一個 404。
至此,Kubernetes 的容器網絡是怎么實現服務發現的已經講完了,而服務發現正是微服務架構中最核心的問題,解決了這個問題,那么使用 Kubernetes 來實現微服務架構也就實現了一大半。