原文:https://segmentfault.com/a/1190000018731395?utm_source=tag-newest
------------------------------------------------
服務注冊與服務發現是在分布式服務架構中常常會涉及到的東西,業界常用的服務注冊與服務發現工具有 ZooKeeper、etcd、Consul 和 Eureka。Consul 的主要功能有服務發現、健康檢查、KV存儲、安全服務溝通和多數據中心。Consul 與其他幾個工具的區別可以在這里查看 Consul vs. Other Software。
為什么需要有服務注冊與服務發現?
假設在分布式系統中有兩個服務 Service-A (下文以“S-A”代稱)和 Service-B(下文以“S-B”代稱),當 S-A 想調用 S-B 時,我們首先想到的時直接在 S-A 中請求 S-B 所在服務器的 IP 地址和監聽的端口,這在服務規模很小的情況下是沒有任何問題的,但是在服務規模很大每個服務不止部署一個實例的情況下是存在一些問題的,比如 S-B 部署了三個實例 S-B-1、S-B-2 和 S-B-3,這時候 S-A 想調用 S-B 該請求哪一個服務實例的 IP 呢?還是將3個服務實例的 IP 都寫在 S-A 的代碼里,每次調用 S-B 時選擇其中一個 IP?這樣做顯得很不靈活,這時我們想到了 Nginx
剛好就能很好的解決這個問題,引入 Nginx
后現在的架構變成了如下圖這樣:
引入 Nginx 后就解決了 S-B 部署多個實例的問題,還做了 S-B 實例間的負載均衡。但現在的架構又面臨了新的問題,分布式系統往往要保證高可用以及能做到動態伸縮,在引入 Nginx 的架構中,假如當 S-B-1 服務實例不可用時,Nginx 仍然會向 S-B-1 分配請求,這樣服務就不可用,我們想要的是 S-B-1 掛掉后 Nginx 就不再向其分配請求,以及當我們新部署了 S-B-4 和 S-B-5 后,Nginx 也能將請求分配到 S-B-4 和 S-B-5,Nginx 要做到這樣就要在每次有服務實例變動時去更新配置文件再重啟 Nginx。這樣看似乎用了 Nginx 也很不舒服以及還需要人工去觀察哪些服務有沒有掛掉,Nginx 要是有對服務的健康檢查以及能夠動態變更服務配置就是我們想要的工具,這就是服務注冊與服務發現工具的用處。下面是引入服務注冊與服務發現工具后的架構圖:
在這個架構中:
- 首先 S-B 的實例啟動后將自身的服務信息(主要是服務所在的 IP 地址和端口號)注冊到注冊工具中。不同注冊工具服務的注冊方式各不相同,后文會講 Consul 的具體注冊方式。
- 服務將服務信息注冊到注冊工具后,注冊工具就可以對服務做健康檢查,以此來確定哪些服務實例可用哪些不可用。
- S-A 啟動后就可以通過服務注冊和服務發現工具獲取到所有健康的 S-B 實例的 IP 和端口,並將這些信息放入自己的內存中,S-A 就可用通過這些信息來調用 S-B。
- S-A 可以通過監聽(Watch)注冊工具來更新存入內存中的 S-B 的服務信息。比如 S-B-1 掛了,健康檢查機制就會將其標為不可用,這樣的信息變動就被 S-A 監聽到了,S-A 就更新自己內存中 S-B-1 的服務信息。
所以務注冊與服務發現工具除了服務本身的服務注冊和發現功能外至少還需要有健康檢查和狀態變更通知的功能。
Consul
Consul 作為一種分布式服務工具,為了避免單點故障常常以集群的方式進行部署,在 Consul 集群的節點中分為 Server 和 Client 兩種節點(所有的節點也被稱為Agent),Server 節點保存數據,Client 節點負責健康檢查及轉發數據請求到 Server;Server 節點有一個 Leader 節點和多個 Follower 節點,Leader 節點會將數據同步到 Follower 節點,在 Leader 節點掛掉的時候會啟動選舉機制產生一個新的 Leader。
Client 節點很輕量且無狀態,它以 RPC 的方式向 Server 節點做讀寫請求的轉發,此外也可以直接向 Server 節點發送讀寫請求。下面是 Consul 的架構圖:
Consule 的安裝和具體使用及其他詳細內容可瀏覽官方文檔。
下面是我用 Docker 的方式搭建了一個有3個 Server 節點和1個 Client 節點的 Consul 集群。
# 這是第一個 Consul 容器,其啟動后的 IP 為172.17.0.5 docker run -d --name=c1 -p 8500:8500 -e CONSUL_BIND_INTERFACE=eth0 consul agent --server=true --bootstrap-expect=3 --client=0.0.0.0 -ui docker run -d --name=c2 -e CONSUL_BIND_INTERFACE=eth0 consul agent --server=true --client=0.0.0.0 --join 172.17.0.5 docker run -d --name=c3 -e CONSUL_BIND_INTERFACE=eth0 consul agent --server=true --client=0.0.0.0 --join 172.17.0.5 #下面是啟動 Client 節點 docker run -d --name=c4 -e CONSUL_BIND_INTERFACE=eth0 consul agent --server=false --client=0.0.0.0 --join 172.17.0.5
啟動容器時指定的環境變量 CONSUL_BIND_INTERFACE
其實就是相當於指定了 Consul 啟動時 --bind
變量的參數,比如可以把啟動 c1 容器的命令換成下面這樣,也是一樣的效果。
docker run -d --name=c1 -p 8500:8500 -e consul agent --server=true --bootstrap-expect=3 --client=0.0.0.0 --bind='{{ GetInterfaceIP "eth0" }}' -ui
操作 Consul 有 Commands 和 HTTP API 兩種方式,進入任意一個容器執行 consul members
都可以有如下的輸出,說明 Consul 集群就已經搭建成功了。
Node Address Status Type Build Protocol DC Segment 2dcf0c824cf0 172.17.0.7:8301 alive server 1.4.4 2 dc1 <all> 64746cffa116 172.17.0.6:8301 alive server 1.4.4 2 dc1 <all> 77af7d94a8ca 172.17.0.5:8301 alive server 1.4.4 2 dc1 <all> 6c71148f0307 172.17.0.8:8301 alive client 1.4.4 2 dc1 <default>
代碼實踐
假設現在有一個用 Node.js 寫的服務 node-server 需要通過 gRPC 的方式調用一個用 Go 寫的服務 go-server。
下面是用 Protobuf 定義的服務和數據類型文件 hello.proto
。
syntax = "proto3"; package hello; option go_package = "hello"; // The greeter service definition. service Greeter { // Sends a greeting rpc SayHello (stream HelloRequest) returns (stream HelloReply) {} } // The request message containing the user's name. message HelloRequest { string name = 1; } // The response message containing the greetings message HelloReply { string message = 1; }
用命令通過 Protobuf 的定義生成 Go 語言的代碼:protoc --go_out=plugins=grpc:./hello ./*.proto
會在 hello 目錄下得到 hello.pb.go 文件,然后在 hello.go 文件中實現我們定義的 RPC 服務。
// hello.go package hello import "context" type GreeterServerImpl struct {} func (g *GreeterServerImpl) SayHello(c context.Context, h *HelloRequest) (*HelloReply, error) { result := &HelloReply{ Message: "hello" + h.GetName(), } return result, nil }
下面是入口文件 main.go
,主要是將我們定義的服務注冊到 gRPC 中,並建了一個 /ping
接口用於之后 Consul 的健康檢查。
package main import ( "go-server/hello" "google.golang.org/grpc" "net" "net/http" ) func main() { lis1, _ := net.Listen("tcp", ":8888") lis2, _ := net.Listen("tcp", ":8889") grpcServer := grpc.NewServer() hello.RegisterGreeterServer(grpcServer, &hello.GreeterServerImpl{}) go grpcServer.Serve(lis1) go grpcServer.Serve(lis2) http.HandleFunc("/ping", func(res http.ResponseWriter, req *http.Request){ res.Write([]byte("pong")) }) http.ListenAndServe(":8080", nil) }
至此 go-server 端的代碼就全部編寫完了,可以看出代碼里面沒有任何涉及到 Consul 的地方,用 Consul 做服務注冊是可以做到對項目代碼沒有任何侵入性的。下面要做的是將 go-server 注冊到 Consul 中。將服務注冊到 Consul 可以通過直接調用 Consul 提供的 REST API 進行注冊,還有一種對項目沒有侵入的配置文件進行注冊。Consul 服務配置文件的詳細內容可以在此查看。下面是我們通過配置文件進行服務注冊的配置文件 services.json
:
{
"services": [ { "id": "hello1", "name": "hello", "tags": [ "primary" ], "address": "172.17.0.9", "port": 8888, "checks": [ { "http": "http://172.17.0.9:8080/ping", "tls_skip_verify": false, "method": "GET", "interval": "10s", "timeout": "1s" } ] },{ "id": "hello2", "name": "hello", "tags": [ "second" ], "address": "172.17.0.9", "port": 8889, "checks": [ { "http": "http://172.17.0.9:8080/ping", "tls_skip_verify": false, "method": "GET", "interval": "10s", "timeout": "1s" } ] } ] }
配置文件中的 172.17.0.9
代表的是 go-server 所在服務器的 IP 地址,port
就是服務監聽的不同端口,check
部分定義的就是健康檢查,Consul 會每隔 10秒鍾請求一下 /ping
接口以此來判斷服務是否健康。將這個配置文件復制到 c4 容器的 /consul/config 目錄,然后執行consul reload
命令后配置文件中的 hello 服務就注冊到 Consul 中去了。通過在宿主機執行curl http://localhost:8500/v1/catalog/services\?pretty
就能看到我們注冊的 hello 服務。
下面是 node-server 服務的代碼:
const grpc = require('grpc'); const axios = require('axios'); const protoLoader = require('@grpc/proto-loader'); const packageDefinition = protoLoader.loadSync( './hello.proto', { keepCase: true, longs: String, enums: String, defaults: true, oneofs: true }); const hello_proto = grpc.loadPackageDefinition(packageDefinition).hello; function getRandNum (min, max) { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1)) + min; }; const urls = [] async function getUrl() { if (urls.length) return urls[getRandNum(0, urls.length-1)]; const { data } = await axios.get('http://172.17.0.5:8500/v1/health/service/hello'); for (const item of data) { for (const check of item.Checks) { if (check.ServiceName === 'hello' && check.Status === 'passing') { urls.push(`${item.Service.Address}:${item.Service.Port}`) } } } return urls[getRandNum(0, urls.length - 1)]; } async function main() { const url = await getUrl(); const client = new hello_proto.Greeter(url, grpc.credentials.createInsecure()); client.sayHello({name: 'jack'}, function (err, response) { console.log('Greeting:', response.message); }); } main()
代碼中 172.17.0.5 地址為 c1 容器的 IP 地址,node-server 項目中直接通過 Consul 提供的 API 獲得了 hello 服務的地址,拿到服務后我們需要過濾出健康的服務的地址,再隨機從所有獲得的地址中選擇一個進行調用。代碼中沒有做對 Consul 的監聽,監聽的實現可以通過不斷的輪詢上面的那個 API 過濾出健康服務的地址去更新 urls
數組來做到。現在啟動 node-server 就可以調用到 go-server 服務。
服務注冊與發現給服務帶來了動態伸縮的能力,也給架構增加了一定的復雜度。Consul 除了服務發現與注冊外,在配置中心、分布式鎖方面也有着很多的應用。
服務注冊與服務發現是在分布式服務架構中常常會涉及到的東西,業界常用的服務注冊與服務發現工具有 ZooKeeper、etcd、Consul 和 Eureka。Consul 的主要功能有服務發現、健康檢查、KV存儲、安全服務溝通和多數據中心。Consul 與其他幾個工具的區別可以在這里查看 Consul vs. Other Software。
為什么需要有服務注冊與服務發現?
假設在分布式系統中有兩個服務 Service-A (下文以“S-A”代稱)和 Service-B(下文以“S-B”代稱),當 S-A 想調用 S-B 時,我們首先想到的時直接在 S-A 中請求 S-B 所在服務器的 IP 地址和監聽的端口,這在服務規模很小的情況下是沒有任何問題的,但是在服務規模很大每個服務不止部署一個實例的情況下是存在一些問題的,比如 S-B 部署了三個實例 S-B-1、S-B-2 和 S-B-3,這時候 S-A 想調用 S-B 該請求哪一個服務實例的 IP 呢?還是將3個服務實例的 IP 都寫在 S-A 的代碼里,每次調用 S-B 時選擇其中一個 IP?這樣做顯得很不靈活,這時我們想到了 Nginx
剛好就能很好的解決這個問題,引入 Nginx
后現在的架構變成了如下圖這樣:
引入 Nginx 后就解決了 S-B 部署多個實例的問題,還做了 S-B 實例間的負載均衡。但現在的架構又面臨了新的問題,分布式系統往往要保證高可用以及能做到動態伸縮,在引入 Nginx 的架構中,假如當 S-B-1 服務實例不可用時,Nginx 仍然會向 S-B-1 分配請求,這樣服務就不可用,我們想要的是 S-B-1 掛掉后 Nginx 就不再向其分配請求,以及當我們新部署了 S-B-4 和 S-B-5 后,Nginx 也能將請求分配到 S-B-4 和 S-B-5,Nginx 要做到這樣就要在每次有服務實例變動時去更新配置文件再重啟 Nginx。這樣看似乎用了 Nginx 也很不舒服以及還需要人工去觀察哪些服務有沒有掛掉,Nginx 要是有對服務的健康檢查以及能夠動態變更服務配置就是我們想要的工具,這就是服務注冊與服務發現工具的用處。下面是引入服務注冊與服務發現工具后的架構圖:
在這個架構中:
- 首先 S-B 的實例啟動后將自身的服務信息(主要是服務所在的 IP 地址和端口號)注冊到注冊工具中。不同注冊工具服務的注冊方式各不相同,后文會講 Consul 的具體注冊方式。
- 服務將服務信息注冊到注冊工具后,注冊工具就可以對服務做健康檢查,以此來確定哪些服務實例可用哪些不可用。
- S-A 啟動后就可以通過服務注冊和服務發現工具獲取到所有健康的 S-B 實例的 IP 和端口,並將這些信息放入自己的內存中,S-A 就可用通過這些信息來調用 S-B。
- S-A 可以通過監聽(Watch)注冊工具來更新存入內存中的 S-B 的服務信息。比如 S-B-1 掛了,健康檢查機制就會將其標為不可用,這樣的信息變動就被 S-A 監聽到了,S-A 就更新自己內存中 S-B-1 的服務信息。
所以務注冊與服務發現工具除了服務本身的服務注冊和發現功能外至少還需要有健康檢查和狀態變更通知的功能。
Consul
Consul 作為一種分布式服務工具,為了避免單點故障常常以集群的方式進行部署,在 Consul 集群的節點中分為 Server 和 Client 兩種節點(所有的節點也被稱為Agent),Server 節點保存數據,Client 節點負責健康檢查及轉發數據請求到 Server;Server 節點有一個 Leader 節點和多個 Follower 節點,Leader 節點會將數據同步到 Follower 節點,在 Leader 節點掛掉的時候會啟動選舉機制產生一個新的 Leader。
Client 節點很輕量且無狀態,它以 RPC 的方式向 Server 節點做讀寫請求的轉發,此外也可以直接向 Server 節點發送讀寫請求。下面是 Consul 的架構圖:
Consule 的安裝和具體使用及其他詳細內容可瀏覽官方文檔。
下面是我用 Docker 的方式搭建了一個有3個 Server 節點和1個 Client 節點的 Consul 集群。
# 這是第一個 Consul 容器,其啟動后的 IP 為172.17.0.5 docker run -d --name=c1 -p 8500:8500 -e CONSUL_BIND_INTERFACE=eth0 consul agent --server=true --bootstrap-expect=3 --client=0.0.0.0 -ui docker run -d --name=c2 -e CONSUL_BIND_INTERFACE=eth0 consul agent --server=true --client=0.0.0.0 --join 172.17.0.5 docker run -d --name=c3 -e CONSUL_BIND_INTERFACE=eth0 consul agent --server=true --client=0.0.0.0 --join 172.17.0.5 #下面是啟動 Client 節點 docker run -d --name=c4 -e CONSUL_BIND_INTERFACE=eth0 consul agent --server=false --client=0.0.0.0 --join 172.17.0.5
啟動容器時指定的環境變量 CONSUL_BIND_INTERFACE
其實就是相當於指定了 Consul 啟動時 --bind
變量的參數,比如可以把啟動 c1 容器的命令換成下面這樣,也是一樣的效果。
docker run -d --name=c1 -p 8500:8500 -e consul agent --server=true --bootstrap-expect=3 --client=0.0.0.0 --bind='{{ GetInterfaceIP "eth0" }}' -ui
操作 Consul 有 Commands 和 HTTP API 兩種方式,進入任意一個容器執行 consul members
都可以有如下的輸出,說明 Consul 集群就已經搭建成功了。
Node Address Status Type Build Protocol DC Segment 2dcf0c824cf0 172.17.0.7:8301 alive server 1.4.4 2 dc1 <all> 64746cffa116 172.17.0.6:8301 alive server 1.4.4 2 dc1 <all> 77af7d94a8ca 172.17.0.5:8301 alive server 1.4.4 2 dc1 <all> 6c71148f0307 172.17.0.8:8301 alive client 1.4.4 2 dc1 <default>
代碼實踐
假設現在有一個用 Node.js 寫的服務 node-server 需要通過 gRPC 的方式調用一個用 Go 寫的服務 go-server。
下面是用 Protobuf 定義的服務和數據類型文件 hello.proto
。
syntax = "proto3"; package hello; option go_package = "hello"; // The greeter service definition. service Greeter { // Sends a greeting rpc SayHello (stream HelloRequest) returns (stream HelloReply) {} } // The request message containing the user's name. message HelloRequest { string name = 1; } // The response message containing the greetings message HelloReply { string message = 1; }
用命令通過 Protobuf 的定義生成 Go 語言的代碼:protoc --go_out=plugins=grpc:./hello ./*.proto
會在 hello 目錄下得到 hello.pb.go 文件,然后在 hello.go 文件中實現我們定義的 RPC 服務。
// hello.go package hello import "context" type GreeterServerImpl struct {} func (g *GreeterServerImpl) SayHello(c context.Context, h *HelloRequest) (*HelloReply, error) { result := &HelloReply{ Message: "hello" + h.GetName(), } return result, nil }
下面是入口文件 main.go
,主要是將我們定義的服務注冊到 gRPC 中,並建了一個 /ping
接口用於之后 Consul 的健康檢查。
package main import ( "go-server/hello" "google.golang.org/grpc" "net" "net/http" ) func main() { lis1, _ := net.Listen("tcp", ":8888") lis2, _ := net.Listen("tcp", ":8889") grpcServer := grpc.NewServer() hello.RegisterGreeterServer(grpcServer, &hello.GreeterServerImpl{}) go grpcServer.Serve(lis1) go grpcServer.Serve(lis2) http.HandleFunc("/ping", func(res http.ResponseWriter, req *http.Request){ res.Write([]byte("pong")) }) http.ListenAndServe(":8080", nil) }
至此 go-server 端的代碼就全部編寫完了,可以看出代碼里面沒有任何涉及到 Consul 的地方,用 Consul 做服務注冊是可以做到對項目代碼沒有任何侵入性的。下面要做的是將 go-server 注冊到 Consul 中。將服務注冊到 Consul 可以通過直接調用 Consul 提供的 REST API 進行注冊,還有一種對項目沒有侵入的配置文件進行注冊。Consul 服務配置文件的詳細內容可以在此查看。下面是我們通過配置文件進行服務注冊的配置文件 services.json
:
{
"services": [ { "id": "hello1", "name": "hello", "tags": [ "primary" ], "address": "172.17.0.9", "port": 8888, "checks": [ { "http": "http://172.17.0.9:8080/ping", "tls_skip_verify": false, "method": "GET", "interval": "10s", "timeout": "1s" } ] },{ "id": "hello2", "name": "hello", "tags": [ "second" ], "address": "172.17.0.9", "port": 8889, "checks": [ { "http": "http://172.17.0.9:8080/ping", "tls_skip_verify": false, "method": "GET", "interval": "10s", "timeout": "1s" } ] } ] }
配置文件中的 172.17.0.9
代表的是 go-server 所在服務器的 IP 地址,port
就是服務監聽的不同端口,check
部分定義的就是健康檢查,Consul 會每隔 10秒鍾請求一下 /ping
接口以此來判斷服務是否健康。將這個配置文件復制到 c4 容器的 /consul/config 目錄,然后執行consul reload
命令后配置文件中的 hello 服務就注冊到 Consul 中去了。通過在宿主機執行curl http://localhost:8500/v1/catalog/services\?pretty
就能看到我們注冊的 hello 服務。
下面是 node-server 服務的代碼:
const grpc = require('grpc'); const axios = require('axios'); const protoLoader = require('@grpc/proto-loader'); const packageDefinition = protoLoader.loadSync( './hello.proto', { keepCase: true, longs: String, enums: String, defaults: true, oneofs: true }); const hello_proto = grpc.loadPackageDefinition(packageDefinition).hello; function getRandNum (min, max) { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1)) + min; }; const urls = [] async function getUrl() { if (urls.length) return urls[getRandNum(0, urls.length-1)]; const { data } = await axios.get('http://172.17.0.5:8500/v1/health/service/hello'); for (const item of data) { for (const check of item.Checks) { if (check.ServiceName === 'hello' && check.Status === 'passing') { urls.push(`${item.Service.Address}:${item.Service.Port}`) } } } return urls[getRandNum(0, urls.length - 1)]; } async function main() { const url = await getUrl(); const client = new hello_proto.Greeter(url, grpc.credentials.createInsecure()); client.sayHello({name: 'jack'}, function (err, response) { console.log('Greeting:', response.message); }); } main()
代碼中 172.17.0.5 地址為 c1 容器的 IP 地址,node-server 項目中直接通過 Consul 提供的 API 獲得了 hello 服務的地址,拿到服務后我們需要過濾出健康的服務的地址,再隨機從所有獲得的地址中選擇一個進行調用。代碼中沒有做對 Consul 的監聽,監聽的實現可以通過不斷的輪詢上面的那個 API 過濾出健康服務的地址去更新 urls
數組來做到。現在啟動 node-server 就可以調用到 go-server 服務。
服務注冊與發現給服務帶來了動態伸縮的能力,也給架構增加了一定的復雜度。Consul 除了服務發現與注冊外,在配置中心、分布式鎖方面也有着很多的應用。