kubernetes集羣中利用etcd和grpc實現golang服務間通信
注:文中涉及工作環境相關的網址和IP已經被替換
1. 項目背景
- 服務運行於docker容器中
- 使用kubernetes管理容器
- 服務有多個節點作爲一個集羣
- 使用rest接口設置服務緩存中的信息
- 需要將信息同步到集羣中其他節點
2. 項目方案
- 使用grpc做服務間通信
- 從etcd中讀取服務所有狀態爲running的節點信息,包括:podIp、status、hostIp、startedAt(啓動時間)
- 服務啓動時選取運行時間最長的節點,調用grpc接口請求緩存的信息同步到本容器的服務中
- 使用rest接口設置緩存的時候,遍歷所有節點(不包括自身),調用grpc接口將信息同步到其他節點
方案特點:
- 不需要藉助額外的配置管理工具(如:zookeeper)
- 不需要自行管理節點的配置信息(因爲kubernetes的etcd中已經有完整的節點信息)
- grpc開發、傳輸效率高,擴展性好
- grpc使用http2.0方便後續提供rest接口
1. etcd簡介
etcd 是用 golang 實現的一種 K-V 分佈式存儲系統,內部用raft協議做一致性校驗,對外提供http的訪問接口,最新版中提供了grpc的訪問接口。
etcd主要用於:
- 配置管理
- 服務註冊於發現
- 選主
- 應用調度
- 分佈式隊列
- 分佈式鎖
與etcd類似的還有zookeeper
這裏 有一篇文章簡單介紹了etcd和zookeeper的優缺點以及etcd的工作原理
2. kubernetes與etcd
前面介紹了etcd特別適合用於做集羣服務的配置管理,kubernets 是用於docker容器編排的,也是用golang實現的,所以自然而然就採用etcd作爲服務配置的存儲方式了。這裏 有一篇kubernets的架構介紹。
etcd在kubernetes中的最大作用是保存容器節點(pod)信息,包括:容器的服務名、狀態、IP、版本以及其他信息
通過類似如下的命令可以獲取到pod的信息
curl http://10.20.30.40:2379/v2/keys/registry/pods/default
etcd中保存的容器節點信息格式如下:
{
"action": "get",
"node": {
"key": "/registry/pods/default",
"dir": true,
"nodes": [
{
"key": "/registry/pods/default/hello-web-29a74e26ea3c2138e1727f35a111f4c6-dknwh",
"value": "{\"kind\":\"Pod\",\"apiVersion\":\"v1\",\"metadata\":{\"name\":\"hello-web-29a74e26ea3c2138e1727f35a111f4c6-dknwh\",\"generateName\":\"hello-web-29a74e26ea3c2138e1727f35a111f4c6-\",\"namespace\":\"default\",\"selfLink\":\"/api/v1/namespaces/default/pods/hello-web-29a74e26ea3c2138e1727f35a111f4c6-dknwh\",\"uid\":\"09c45029-3fa0-11e7-a46c-00163e327954\",\"creationTimestamp\":\"2017-05-23T10:10:24Z\",\"labels\":{\"app\":\"hello\",\"deployment\":\"bb6de7bfc7f357818a8c07faf3987d40\",\"tier\":\"frontend\"},\"annotations\":{\"kubernetes.io/created-by\":\"{\\\"kind\\\":\\\"SerializedReference\\\",\\\"apiVersion\\\":\\\"v1\\\",\\\"reference\\\":{\\\"kind\\\":\\\"ReplicationController\\\",\\\"namespace\\\":\\\"default\\\",\\\"name\\\":\\\"hello-web-29a74e26ea3c2138e1727f35a111f4c6\\\",\\\"uid\\\":\\\"e42ce61a-3f9f-11e7-a46c-00163e327954\\\",\\\"apiVersion\\\":\\\"v1\\\",\\\"resourceVersion\\\":\\\"4361319\\\"}}\\n\"},\"ownerReferences\":[{\"apiVersion\":\"v1\",\"kind\":\"ReplicationController\",\"name\":\"hello-web\",\"uid\":\"32559b88-3fa0-11e7-a46c-00163e327954\",\"controller\":true}]},\"spec\":{\"containers\":[{\"name\":\"hello-web\",\"image\":\"docker.helloword.com/hello-web:f022d25\",\"ports\":[{\"containerPort\":8087,\"protocol\":\"TCP\"}],\"env\":[{\"name\":\"SERVER\",\"valueFrom\":{\"configMapKeyRef\":{\"name\":\"cluster-config\",\"key\":\"external.ip\"}}},{\"name\":\"SERVER_PORT\",\"valueFrom\":{\"configMapKeyRef\":{\"name\":\"hello-config\",\"key\":\"hello.api.port\"}}}],\"resources\":{\"limits\":{\"cpu\":\"1\",\"memory\":\"1Gi\"},\"requests\":{\"cpu\":\"100m\",\"memory\":\"512Mi\"}},\"terminationMessagePath\":\"/dev/termination-log\",\"imagePullPolicy\":\"IfNotPresent\"}],\"restartPolicy\":\"Always\",\"terminationGracePeriodSeconds\":30,\"dnsPolicy\":\"ClusterFirst\",\"nodeName\":\"10.30.58.179\",\"securityContext\":{},\"imagePullSecrets\":[{\"name\":\"cn-registry\"}]},\"status\":{\"phase\":\"Running\",\"conditions\":[{\"type\":\"Initialized\",\"status\":\"True\",\"lastProbeTime\":null,\"lastTransitionTime\":\"2017-05-23T10:10:24Z\"},{\"type\":\"Ready\",\"status\":\"True\",\"lastProbeTime\":null,\"lastTransitionTime\":\"2017-05-23T10:10:29Z\"},{\"type\":\"PodScheduled\",\"status\":\"True\",\"lastProbeTime\":null,\"lastTransitionTime\":\"2017-05-23T10:10:24Z\"}],\"hostIP\":\"10.30.58.179\",\"podIP\":\"172.80.13.4\",\"startTime\":\"2017-05-23T10:10:24Z\",\"containerStatuses\":[{\"name\":\"hello-web\",\"state\":{\"running\":{\"startedAt\":\"2017-05-23T10:10:29Z\"}},\"lastState\":{},\"ready\":true,\"restartCount\":0,\"image\":\"docker.helloword.com/hello-web:f022d25\",\"imageID\":\"docker-pullable://docker.helloword.com/hello-web@sha256:f8e0460983b0d3f87733453b588469d8e225afbfc764da2ae55238cd524ef70a\",\"containerID\":\"docker://78cd912de942f744a36bd51907562c5e670fb300ddc85267e3ec72572fdb5617\"}]}}\n",
"modifiedIndex": 4361528,
"createdIndex": 4361320
}
]
}
}
其中value部分的json數據格式化後如下:
{
"kind": "Pod",
"apiVersion": "v1",
"metadata": {
"name": "hello-web-29a74e26ea3c2138e1727f35a111f4c6-dknwh",
"generateName": "hello-web-29a74e26ea3c2138e1727f35a111f4c6-",
"namespace": "default",
"selfLink": "/api/v1/namespaces/default/pods/hello-web-29a74e26ea3c2138e1727f35a111f4c6-dknwh",
"uid": "09c45029-3fa0-11e7-a46c-00163e327954",
"creationTimestamp": "2017-05-23T10:10:24Z",
"labels": {
"app": "hello",
"deployment": "bb6de7bfc7f357818a8c07faf3987d40",
"tier": "frontend"
},
"annotations": {
"kubernetes.io/created-by": "{\"kind\":\"SerializedReference\",\"apiVersion\":\"v1\",\"reference\":{\"kind\":\"ReplicationController\",\"namespace\":\"default\",\"name\":\"hello-web-29a74e26ea3c2138e1727f35a111f4c6\",\"uid\":\"e42ce61a-3f9f-11e7-a46c-00163e327954\",\"apiVersion\":\"v1\",\"resourceVersion\":\"4361319\"}}\n"
},
"ownerReferences": [
{
"apiVersion": "v1",
"kind": "ReplicationController",
"name": "hello-web",
"uid": "32559b88-3fa0-11e7-a46c-00163e327954",
"controller": true
}
]
},
"spec": {
"containers": [
{
"name": "hello-web",
"image": "docker.helloword.com/hello-web:f022d25",
"ports": [
{
"containerPort": 8087,
"protocol": "TCP"
}
],
"env": [
{
"name": "SERVER",
"valueFrom": {
"configMapKeyRef": {
"name": "cluster-config",
"key": "external.ip"
}
}
},
{
"name": "SERVER_PORT",
"valueFrom": {
"configMapKeyRef": {
"name": "hello-config",
"key": "hello.api.port"
}
}
}
],
"resources": {
"limits": {
"cpu": "1",
"memory": "1Gi"
},
"requests": {
"cpu": "100m",
"memory": "512Mi"
}
},
"terminationMessagePath": "/dev/termination-log",
"imagePullPolicy": "IfNotPresent"
}
],
"restartPolicy": "Always",
"terminationGracePeriodSeconds": 30,
"dnsPolicy": "ClusterFirst",
"nodeName": "10.30.58.179",
"securityContext": {},
"imagePullSecrets": [
{
"name": "cn-registry"
}
]
},
"status": {
"phase": "Running",
"conditions": [
{
"type": "Initialized",
"status": "True",
"lastProbeTime": null,
"lastTransitionTime": "2017-05-23T10:10:24Z"
},
{
"type": "Ready",
"status": "True",
"lastProbeTime": null,
"lastTransitionTime": "2017-05-23T10:10:29Z"
},
{
"type": "PodScheduled",
"status": "True",
"lastProbeTime": null,
"lastTransitionTime": "2017-05-23T10:10:24Z"
}
],
"hostIP": "10.30.58.179",
"podIP": "172.80.13.4",
"startTime": "2017-05-23T10:10:24Z",
"containerStatuses": [
{
"name": "hello-web",
"state": {
"running": {
"startedAt": "2017-05-23T10:10:29Z"
}
},
"lastState": {},
"ready": true,
"restartCount": 0,
"image": "docker.helloword.com/hello-web:f022d25",
"imageID": "docker-pullable://docker.helloword.com/hello-web@sha256:f8e0460983b0d3f87733453b588469d8e225afbfc764da2ae55238cd524ef70a",
"containerID": "docker://78cd912de942f744a36bd51907562c5e670fb300ddc85267e3ec72572fdb5617"
}
]
}
}
3. grpc簡介
grpc是google實現的一種基於protobuf的遠程服務調用框架,數據採用二進制傳輸,其傳輸協議是基於http2.0。
相比於其他各種rpc框架,grpc由於基於protobuf和http2.0,具有以下優點:
- 通用性好,支持各種語言
- 二進制傳輸,效率高
- 擴展性好,只需要修改protobuf文件並重新生成代碼
4. grpc開發環境搭建
4.1 protobuf環境
首先,去https://github.com/google/protobuf/releases/tag/v3.3.0 這個頁面下載對應的protobuf編譯器安裝文件並安裝好protoc
go get -u github.com/golang/protobuf
cd $GOPATH/src/github.com/golang/protobuf
# 如果有安裝makefile,直接執行make install,如果沒有則執行以下命令
go install ./proto ./jsonpb ./ptypes
go install ./protoc-gen-go
4.2 grpc環境
#安裝grpc依賴庫
go get -u google.golang.org/grpc
#安裝grpc-go插件,用於將proto文件編譯成grpc的golang代碼
go get -u github.com/grpc/grpc-go
cd $GOPATH/src
mv github.com/grpc/grpc-go google.golang.org/grpc/grpc-go
遇到go get無法下載的包,也可以通過 http://gopm.io/ 或者 http://golangtc.com/download/package 進行下載
5. 定義proto文件
syntax = "proto3"; //使用proto3版本
//用於java等語言的package配置
option java_multiple_files = true;
option java_package = "io.grpc.examples.hellorpc";
option java_outer_classname = "hellorpcProto";
//用於golang等語言的package配置
package hellorpc;
//定義服務接口,其中rpc關鍵字表示 rpc 接口,用於生成grpc接口代碼
service Sync {
rpc Get (SyncRequest) returns(SyncResponse) {}
rpc Set (SyncRequest) returns(SyncResponse) {}
rpc GetAll(SyncRequest)returns(SyncResponse) {}
}
//定義請求數據類型, repeated最終會轉換成golang中的數組/切片
message SyncRequest {
repeated SyncData data= 1;
}
//定義返回的數據類型
message SyncResponse {
repeated SyncData data= 1;
}
//定義實體數據類型,用type字段表示請求的數據類型,用data字段保存請求的數據或者返回的數據
//map<string, string>最終會轉換成golang中的map[string]string類型
message SyncData {
int32 type = 1;
map<string, string> data = 2;
}
編譯proto文件
protoc --go_out=plugins=grpc:./hellorpc hellorpc.proto
其中–go_out用於指定go的proto編譯插件以及插件參數
編譯成功後,會在 hellorpc目錄中生成 hellorpc.pb.go 文件,可以在其他go文件中通過 import “hello-api/hellorpc” 來使用文件中定義的接口
6. hellorpc.pb.go 文件分析
前面提到的 service Sync 部分會編譯成如下兩部分
type SyncClient interface {
Get(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
Set(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
GetAll(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
}
type SyncServer interface {
Get(context.Context, *SyncRequest) (*SyncResponse, error)
Set(context.Context, *SyncRequest) (*SyncResponse, error)
GetAll(context.Context, *SyncRequest) (*SyncResponse, error)
}
其中 SyncClient 的接口 在 hellorpc.pb.go 裏面已經實現好了接口,直接調用即可,但SyncServer定義的接口是需要我們自己實現
7. 服務端代碼實現(rtc_server.go)
//先定義server類型,並實現好SyncServer定義的接口
type server struct {}
const (
HELLO_SYNC_REST_CLUSTER_INFO = iota
)
func (s *server)Get(ctx context.Context, in *hellorpc.SyncRequest) (*hellorpc.SyncResponse, error){
var response = hellorpc.SyncResponse{Data: make([]*hellorpc.SyncData, 0, 10)}
for i := 0; i < len(in.Data); i++{
request := in.Data[i]
switch request.Type {
case hello_SYNC_REST_CLUSTER_INFO:
// get something from local cache and set to response
break
}
}
return &response, nil
}
func (s *server)Set(ctx context.Context, in *hellorpc.SyncRequest) (*hellorpc.SyncResponse, error){
var response = hellorpc.SyncResponse{Data: make([]*hellorpc.SyncData, 0, 10)}
for i := 0; i < len(in.Data); i++{
request := in.Data[i]
switch request.Type {
case HELLO_SYNC_REST_CLUSTER_INFO:
// set something to local cache, and set the result to response
break
}
}
return &response, nil
}
func (s *server)GetAll(ctx context.Context, in *hellorpc.SyncRequest) (*hellorpc.SyncResponse, error){
var response = hellorpc.SyncResponse{Data: make([]*hellorpc.SyncData, 0, 10)}
for i := 0; i < len(in.Data); i++{
request := in.Data[i]
switch request.Type {
case HELLO_SYNC_REST_CLUSTER_INFO:
// get all data from local cache, and set the result to response
break
}
}
return &response, nil
}
實現好接口後,我們需要將服務註冊到grpc,這裏我們實現一個名爲StartSyncServer的函數來做這些事情
func StartSyncServer(address string) error{
lis, err := net.Listen("tcp", address)
if err != nil {
beego.Debug("start sync server error: %v", err)
return err
}
s := grpc.NewServer()
hellorpc.RegisterSyncServer(s, &server{})
//由於s.Serve方法是會一直阻塞住,所以我們需要起一個go routine來執行,在其停止後輸出錯誤信息
go func(){
err := s.Serve(lis)
beego.Debug("sync server stopped with error: %v", err)
}()
return nil
}
將StartSyncServer函數添加到模塊的 init 函數中執行,我們服務端的代碼就基本完成了
8. 客戶端代碼實現(rtc_client.go)
//先定義好客戶端類型syncClient,這裏我們利用繼承的方式將hellorpc.SyncClient實現的方法繼承過來
type syncClient struct{
hellorpc.SyncClient
conn *grpc.ClientConn
address string
}
func OpenSyncClient(address string)(syncClient, error) {
s := syncClient{}
//grpc.WithInsecure用於關閉安全驗證,因爲我們是在docker內部環境裏使用,不暴露在外網,就沒有加安全認證了
conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithTimeout(5*time.Second))
if err != nil {
fmt.Println("----open client error %v, conn: %v", err, conn)
return s, err
}
s.conn = conn
s.address = address
s.client = hellorpc.NewSyncClient(conn)
return s, nil
}
func CloseSyncClient(s *syncClient) {
if s.conn != nil {
s.conn.Close()
s.conn = nil
s.client = nil
}
}
這樣我們只需要編寫 c, err := OpenSyncClient(address),既可通過 response, err := c.Get(context.Background(), request) 的方式調用hellorpc.SyncClient定義的方法
9. etcd客戶端代碼實現(部分功能)
根據etcd的返回值數據結構,我們定義一下兩種類型的數據
//用於保存etcd的返回的數據
type EtcdData struct{
Key string
Dir bool
Value interface{}
CreatedIndex int32
ModifiedIndex int32
Nodes []EtcdData
}
//用於保存pod相關的數據
type PodData struct {
Name string
PodIP string
HostIP string
Status string
UpdateTime string
Timestamp int64
}
func newEtcdData() EtcdData{
return EtcdData{Dir: false, Value: "", Key: "", Nodes: make([]EtcdData, 0, 100)}
}
接下來我們實現EtcdClient
//先定義好EtcdClient的數據結構
type EtcdClient struct{}
//用於解析etcd返回的數據
func parseEtcdData(dataIn map[string]interface{}, dataOut *EtcdData) error {
if key, ok := dataIn["key"]; ok {
dataOut.Key = key.(string)
}
if isDir, ok := dataIn["dir"]; ok {
dataOut.Dir = isDir.(bool)
}
if value, ok := dataIn["value"]; ok {
dataOut.Value = value
}
if createdIndex, ok := dataIn["createdIndex"]; ok {
dataOut.CreatedIndex = int32(createdIndex.(float64))
}
if modifiedIndex, ok := dataIn["modifiedIndex"]; ok {
dataOut.ModifiedIndex = int32(modifiedIndex.(float64))
}
if nodes, ok := dataIn["nodes"]; ok {
var subnodes = nodes.([]interface{})
for i := 0; i < len(subnodes); i++{
node := subnodes[i].(map[string]interface{})
var nodeData = newEtcdData()
parseEtcdData(node, &nodeData)
dataOut.Nodes = append(dataOut.Nodes,nodeData)
}
}
return nil
}
//實現Get方法用於獲取某個key的值
func (c *EtcdClient)Get(baseUrl, key string)(EtcdData, error){
var url = baseUrl + key
var res = newEtcdData()
var result = make(map[string]interface{})
resp, err := http.Get(url)
if err == nil{
out, err1 := ioutil.ReadAll(resp.Body)
if err1 == nil{
err2 := json.Unmarshal([]byte(out), &result)
if err2 != nil{
return res, err2
}
node := result["node"].(map[string]interface{})
err = parseEtcdData(node, &res)
}else{
return res, err1
}
}
return res, err
}
由於我們的服務是跑在docker裏,由kubernetes進行服務編排,所以我們需要解析kubernetes在etcd中保存的數據
//用於解析pod的狀態信息
func parsePodStatus(podStatus interface{}, podData *PodData){
pod_status := podStatus.(map[string]interface{})
if podIP, ok := pod_status["podIP"]; ok {
podData.PodIP = podIP.(string)
}
if hostIP, ok := pod_status["hostIP"]; ok {
podData.HostIP = hostIP.(string)
}
if status, ok := pod_status["phase"]; ok{
podData.Status = strings.ToLower(status.(string))
if containerStatuses, ok := pod_status["containerStatuses"]; ok{
for i := 0; i<len(containerStatuses.([]interface{})); i++{
contaner_status := containerStatuses.([]interface{})[i]
if state, ok := contaner_status.(map[string]interface{})["state"]; ok {
if stateCond, ok := state.(map[string]interface{})[podData.Status]; ok{
if startedAt, ok := stateCond.(map[string]interface{})["startedAt"]; ok{
layout := "2006-01-02T15:04:05Z"
podData.UpdateTime = startedAt.(string)
t, _ := time.Parse(layout, podData.UpdateTime)
podData.Timestamp = t.Unix()
}
}
}
}
}
}
}
//用於查找某個服務的pod信息
func (c *EtcdClient)FindPods(url, name string)([]PodData, error){
var output = make([]PodData, 0, 10)
res, err := c.Get(url)
if err != nil {
return output, err
}
//獲取本容器的內網IP,用於將pod列表中的本pod信息剔除
local_ip := getInternalIP()
for i := 0; i < len(res.Nodes); i++{
node := res.Nodes[i]
if strings.Contains(node.Key, name) {
nodeData := make(map[string]interface{})
err = json.Unmarshal([]byte(node.Value.(string)), &nodeData)
if err != nil {
beego.Info("find pods json decode error: ", err.Error())
continue
}
podData := PodData{}
if metaData, ok := nodeData["metadata"]; ok {
meta_data := metaData.(map[string]interface{})
if name, ok := meta_data["name"]; ok {
podData.Name = name.(string)
}
}
if podStatus, ok := nodeData["status"]; ok {
parsePodStatus(podStatus, &podData)
}
//剔除沒有podIP的節點
if podData.PodIP == ""{
continue
}
//剔除本pod
isLocalIP := local_ip[podData.PodIP]
if !isLocalIP {
output = append(output, podData)
}
}
}
beego.Info("find pods: ", output)
//按照pod的啓動時間升序排序,這樣方便獲取運行時間最長的pod
sort.Slice(output, func(i,j int)bool{
return (output[i].Timestamp < output[j].Timestamp)
})
return output, nil
}
//獲取本容器的內網IP
func getInternalIP() map[string]bool{
output := make(map[string]bool)
addrs, err := net.InterfaceAddrs()
if err != nil {
beego.Info("get local ip error: ", err.Error())
return output
}
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
output[ipnet.IP.String()] = true
}
}
}
beego.Info("local ip: ", output)
return output
}
10. EtcdClient結合SyncClient
- 拿到了服務所有容器的IP
- 遍歷所有pod的IP
- 使用IP+端口建立連接(OpenSyncClient)
- 執行grpc server端提供的服務接口,如:c.Get …
- 校驗/處理返回值(同步本地信息)
- 斷開連接(CloseSyncClient)
11. 總結
- 直接使用kubernetes的etcd,主要是因爲kubernetes的etcd已經有所有節點的信息,不需要另外再維護節點信息
- protobuf文件中的request、response數據結構中使用repeate以及SyncData採用map,主要用於批量請求、返回結果以及方便擴展
- 自定義數據結構保存etcd返回的數據,而不是直接使用json處理後的數據,主要是因爲各接口之間使用方便,更易於維護。
- 使用繼承的方式來擴展的接口,可以有效減少代碼量