一、認識etcd
1.1 etcd 概念
從哪里說起呢?官網第一個頁面,有那么一句話:
"A distributed, reliable key-value store for the most critical data of a distributed system"
即 etcd 是一個分布式、可靠 key-value 存儲的分布式系統。當然,它不僅僅用於存儲,還提供共享配置及服務發現。
1.2 etcd vs Zookeeper
提供配置共享和服務發現的系統比較多,其中最為大家熟知的是 Zookeeper,而 etcd 可以算得上是后起之秀了。在項目實現、一致性協議易理解性、運維、安全等多個維度上,etcd 相比 zookeeper 都占據優勢。
本文選取 Zookeeper 作為典型代表與 etcd 進行比較,而不考慮 Consul 項目作為比較對象,原因為 Consul 的可靠性和穩定性還需要時間來驗證(項目發起方自身服務並未使用Consul,自己都不用)。
- 一致性協議: etcd 使用 Raft 協議,Zookeeper 使用 ZAB(類PAXOS協議),前者容易理解,方便工程實現;
- 運維方面:etcd 方便運維,Zookeeper 難以運維;
- 數據存儲:etcd 多版本並發控制(MVCC)數據模型 , 支持查詢先前版本的鍵值對
- 項目活躍度:etcd 社區與開發活躍,Zookeeper 感覺已經快死了;
- API:etcd 提供 HTTP+JSON, gRPC 接口,跨平台跨語言,Zookeeper 需要使用其客戶端;
- 訪問安全方面:etcd 支持 HTTPS 訪問,Zookeeper 在這方面缺失;
...
1.3 etcd 應用場景
etcd 比較多的應用場景是用於服務發現,服務發現 (Service Discovery) 要解決的是分布式系統中最常見的問題之一,即在同一個分布式集群中的進程或服務如何才能找到對方並建立連接。和 Zookeeper 類似,etcd 有很多使用場景,包括:
- 配置管理
- 服務注冊發現
- 選主
- 應用調度
- 分布式隊列
- 分布式鎖
1.4 etcd 工作原理
1.4.1 如何保證一致性
etcd 使用 raft 協議來維護集群內各個節點狀態的一致性。簡單說,etcd 集群是一個分布式系統,由多個節點相互通信構成整體對外服務,每個節點都存儲了完整的數據,並且通過 Raft 協議保證每個節點維護的數據是一致的。
每個 etcd 節點都維護了一個狀態機,並且,任意時刻至多存在一個有效的主節點。主節點處理所有來自客戶端寫操作,通過 Raft 協議保證寫操作對狀態機的改動會可靠的同步到其他節點。
1.4.2 數據模型
etcd 的設計目標是用來存放非頻繁更新的數據,提供可靠的 Watch插件,它暴露了鍵值對的歷史版本,以支持低成本的快照、監控歷史事件。這些設計目標要求它使用一個持久化的、多版本的、支持並發的數據數據模型。
當 etcd 鍵值對的新版本保存后,先前的版本依然存在。從效果上來說,鍵值對是不可變的,etcd 不會對其進行 in-place 的更新操作,而總是生成一個新的數據結構。為了防止歷史版本無限增加,etcd 的存儲支持壓縮(Compact)以及刪除老舊版本。
邏輯視圖
從邏輯角度看,etcd 的存儲是一個扁平的二進制鍵空間,鍵空間有一個針對鍵(字節字符串)的詞典序索引,因此范圍查詢的成本較低。
鍵空間維護了多個修訂版本(Revisions),每一個原子變動操作(一個事務可由多個子操作組成)都會產生一個新的修訂版本。在集群的整個生命周期中,修訂版都是單調遞增的。修訂版同樣支持索引,因此基於修訂版的范圍掃描也是高效的。壓縮操作需要指定一個修訂版本號,小於它的修訂版會被移除。
一個鍵的一次生命周期(從創建到刪除)叫做 “代 (Generation)”,每個鍵可以有多個代。創建一個鍵時會增加鍵的版本(version),如果在當前修訂版中鍵不存在則版本設置為1。刪除一個鍵會創建一個墓碑(Tombstone),將版本設置為0,結束當前代。每次對鍵的值進行修改都會增加其版本號 — 在同一代中版本號是單調遞增的。
當壓縮時,任何在壓縮修訂版之前結束的代,都會被移除。值在修訂版之前的修改記錄(僅僅保留最后一個)都會被移除。
物理視圖
etcd 將數據存放在一個持久化的 B+ 樹中,處於效率的考慮,每個修訂版僅僅存儲相對前一個修訂版的數據狀態變化(Delta)。單個修訂版中可能包含了 B+ 樹中的多個鍵。
鍵值對的鍵,是三元組(major,sub,type):
- major:存儲鍵值的修訂版
- sub:用於區分相同修訂版中的不同鍵
- type:用於特殊值的可選后綴,例如 t 表示值包含墓碑
鍵值對的值,包含從上一個修訂版的 Delta。B+ 樹 —— 鍵的詞法字節序排列,基於修訂版的范圍掃描速度快,可以方便的從一個修改版到另外一個的值變更情況查找。
etcd 同時在內存中維護了一個 B 樹索引,用於加速針對鍵的范圍掃描。索引的鍵是物理存儲的鍵面向用戶的映射,索引的值則是指向 B+ 樹修該點的指針。
1.5 etcd 讀寫性能
按照官網給出的數據, 在 2CPU,1.8G 內存,SSD 磁盤這樣的配置下,單節點的寫性能可以達到 16K QPS, 而先寫后讀也能達到12K QPS。這個性能還是相當可觀。
1.6 etcd 術語
二、安裝和運行
構建
需要Go 1.9以上版本:
cd $GOPATH/src mkdir go.etcd.io && cd go.etcd.io git clone https://github.com/etcd-io/etcd.git cd etcd ./build
使用 build 腳本構建會在當前項目的 bin 目錄生產 etcd 和 etcdctl 可執行程序。etcd 就是 etcd server 了,etcdctl 主要為 etcd server 提供了命令行操作。
靜態集群
如果Etcd集群成員是已知的,具有固定的IP地址,則可以靜態的初始化一個集群。
每個節點都可以使用如下環境變量:
ETCD_INITIAL_CLUSTER="radon=http://10.0.2.1:2380,neon=http://10.0.3.1:2380" ETCD_INITIAL_CLUSTER_STATE=new
或者如下命令行參數
--initial-cluster radon=http://10.0.2.1:2380,neon=http://10.0.3.1:2380
--initial-cluster-state new
來指定集群成員。
初始化集群
完整的命令行示例:
etcd --name radon --initial-advertise-peer-urls http://10.0.2.1:2380
--listen-peer-urls http://10.0.2.1:2380
--listen-client-urls http://10.0.2.1:2379,http://127.0.0.1:2379
--advertise-client-urls http://10.0.2.1:2380
# 所有以-initial-cluster開頭的選項,在第一次運行(Bootstrap)后都被忽略
--initial-cluster-token etcd.gmem.cc
--initial-cluster radon=http://10.0.2.1:2380,neon=http://10.0.3.1:2380
--initial-cluster-state new
使用TLS
Etcd支持基於TLS加密的集群內部、客戶端-集群通信。每個集群節點都應該擁有被共享CA簽名的證書:
# 密鑰對、證書簽名請求
openssl genrsa -out radon.key 2048
export SAN_CFG=$(printf "\n[SAN]\nsubjectAltName=IP:127.0.0.1,IP:10.0.2.1,DNS:radon.gmem.cc")
openssl req -new -sha256 -key radon.key -out radon.csr \
-subj "/C=CN/ST=BeiJing/O=Gmem Studio/CN=Server Radon" \
-reqexts SAN -config <(cat /etc/ssl/openssl.cnf <(echo $SAN_CFG))
# 執行簽名
openssl x509 -req -sha256 -in radon.csr -out radon.crt -CA ../ca.crt -CAkey ../ca.key -CAcreateserial -days 3650 \
-extensions SAN -extfile <(echo "${SAN_CFG}")
初始化集群命令需要修改為:
etcd --name radon --initial-advertise-peer-urls https://10.0.2.1:2380
--listen-peer-urls https://10.0.2.1:2380
--listen-client-urls https://10.0.2.1:2379,https://127.0.0.1:2379
--advertise-client-urls https://10.0.2.1:2380
# 所有以-initial-cluster開頭的選項,在第一次運行(Bootstrap)后都被忽略
--initial-cluster-token etcd.gmem.cc
--initial-cluster radon=https://10.0.2.1:2380,neon=https://10.0.3.1:2380 # 指定集群成員列表
--initial-cluster-state new # 初始化新集群時使用
--initial-cluster-state existing # 加入已有集群時使用
# 客戶端TLS相關參數
--client-cert-auth
--trusted-ca-file=/usr/share/ca-certificates/GmemCA.crt
--cert-file=/opt/etcd/cert/radon.crt
--key-file=/opt/etcd/cert/radon.key
# 集群內部TLS相關參數
--peer-client-cert-auth
--peer-trusted-ca-file=/usr/share/ca-certificates/GmemCA.crt
--peer-cert-file=/opt/etcd/cert/radon.crt
--peer-key-file=/opt/etcd/cert/radon.key
三、與 etcd 交互
etcd 提供了 etcdctl 命令行工具 和 HTTP API 兩種交互方法。etcdctl命令行工具用 go 語言編寫,也是對 HTTP API 的封裝,日常使用起來也更容易。所以這里我們主要使用 etcdctl 命令行工具演示。
put
應用程序通過 put 將 key 和 value 存儲到 etcd 集群中。每個存儲的密鑰都通過 Raft 協議復制到所有 etcd 集群成員,以實現一致性和可靠性。
這里是設置鍵的值的命令 foo 到 bar:
$ etcdctl put foo bar
OK
get
應用程序可以從一個 etcd 集群中讀取 key 的值。
假設 etcd 集群已經存儲了以下密鑰:
foo = bar
foo1 = bar1
foo2 = bar2
foo3 = bar3
a = 123
b = 456
z = 789
- 讀取鍵為
foo的命令:
$ etcdctl get foo
foo // key
bar // value
- 上面同時返回了 key 和 value,怎么只讀取 key 對應的值呢:
$ etcdctl get foo --print-value-only
bar
- 以十六進制格式讀取鍵為
foo的命令:
$ etcdctl get foo --hex
\x66\x6f\x6f
\x62\x61\x72
- 查詢可以讀取單個key,也可以讀取一系列key:
$ etcdctl get foo foo3
foo
bar
foo1
bar1
foo2
bar2
請注意,foo3由於范圍超過了半開放時間間隔[foo, foo3),因此不包括在內foo3。
- 按前綴讀取:
$ etcdctl get --prefix foo
foo
bar
foo1
bar1
foo2
bar2
foo3
bar3
- 按結果數量限制讀取
$ etcdctl get --limit=2 --prefix foo
foo
bar
foo1
bar1
- 讀取大於或等於指定鍵的字節值的鍵:
$ etcdctl get --from-key b
b
456
z
789
應用程序可能希望通過訪問早期版本的 key 來回滾到舊配置。由於對 etcd 集群鍵值存儲區的每次修改都會增加一個 etcd 集群的全局修訂版本,因此應用程序可以通過提供舊的 etcd 修訂版來讀取被取代的鍵。
假設一個 etcd 集群已經有以下 key:
foo = bar # revision = 2
foo1 = bar1 # revision = 3
foo = bar_new # revision = 4
foo1 = bar1_new # revision = 5
以下是訪問以前版本 key 的示例:
$ etcdctl get --prefix foo # 訪問最新版本的key
foo
bar_new
foo1
bar1_new
$ etcdctl get --prefix --rev=4 foo # 訪問第4個版本的key
foo
bar_new
foo1
bar1
$ etcdctl get --prefix --rev=3 foo # 訪問第3個版本的key
foo
bar
foo1
bar1
$ etcdctl get --prefix --rev=2 foo # 訪問第3個版本的key
foo
bar
$ etcdctl get --prefix --rev=1 foo # 訪問第1個版本的key
del
應用程序可以從一個 etcd 集群中刪除一個 key 或一系列 key。
假設一個 etcd 集群已經有以下key:
foo = bar
foo1 = bar1
foo3 = bar3
zoo = val
zoo1 = val1
zoo2 = val2
a = 123
b = 456
z = 789
- 刪除 key 為
foo的命令:
$ etcdctl del foo
1
- 刪除鍵值對的命令:
$ etcdctl del --prev-kv zoo
1 zoo val
- 刪除從
foo到foo9的命令:
$ etcdctl del foo foo9
2
- 刪除具有前綴的鍵的命令:
$ etcdctl del --prefix zoo
2
- 刪除大於或等於鍵的字節值的鍵的命令:
$ etcdctl del --from-key b
2
watch
應用程序可以使用watch觀察一個鍵或一系列鍵來監視任何更新。
打開第一個終端,監聽 foo的變化,我們輸入如下命令:
$ etcdctl watch foo
再打開另外一個終端來對 foo 進行操作:
$ etcdctl put foo 123
OK
$ etcdctl put foo 456
OK
$ ./etcdctl del foo
1
第一個終端結果如下:
$ etcdctl watch foo
PUT
foo
123
PUT
foo
456
DELETE
foo
除了以上基本操作,watch 也可以像 get、del 操作那樣使用 prefix、rev、 hex等參數,這里就不一一列舉了。
lock
Distributed locks: 分布式鎖,一個人操作的時候,另外一個人只能看,不能操作
lock 可以通過指定的名字加鎖。注意,只有當正常退出且釋放鎖后,lock命令的退出碼是0,否則這個鎖會一直被占用直到過期(默認60秒)
在第一個終端輸入如下命令:
$ etcdctl lock mutex1
mutex1/326963a02758b52d
在第二個終端輸入同樣的命令:
$ etcdctl lock mutex1
從上可以發現第二個終端發生了阻塞,並未返回像 mutex1/326963a02758b52d 的字樣。此時我們需要結束第一個終端的 lock ,可以使用 Ctrl+C 正常退出lock命令。第一個終端 lock 退出后,第二個終端的顯示如下:
$ etcdctl lock mutex1
mutex1/694d6ee9ac069436
txn
txn 從標准輸入中讀取多個請求,將它們看做一個原子性的事務執行。事務是由條件列表,條件判斷成功時的執行列表(條件列表中全部條件為真表示成功)和條件判斷失敗時的執行列表(條件列表中有一個為假即為失敗)組成的。
$ etcdctl put user frank
OK
$ ./etcdctl txn -i
compares:
value("user") = "frank"
success requests (get, put, del):
put result ok
failure requests (get, put, del):
put result failed
SUCCESS
OK
$ etcdctl get result
result
ok
解釋如下:
- 先使用 etcdctl put user frank 設置 user 為 frank
- etcdctl txn -i 開啟事務(-i表示交互模式)
- 第2步輸入命令后回車,終端顯示出 compares:
- 輸入 value("user") = "frank",此命令是比較 user 的值與 frank 是否相等
- 第 4 步完成后輸入回車,終端會換行顯示,此時可以繼續輸入判斷條件(前面說過事務由條件列表組成),再次輸入回車表示判斷條件輸入完畢
- 第 5 步連續輸入兩個回車后,終端顯示出 success requests (get, put, delete):,表示下面輸入判斷條件為真時要執行的命令
- 與輸入判斷條件相同,連續兩個回車表示成功時的執行列表輸入完成
- 終端顯示 failure requests (get, put, delete):后輸入條件判斷失敗時的執行列表
- 為了看起來簡潔,此實例中條件列表和執行列表只寫了一行命令,實際可以輸入多行
- 總結上面的事務,要做的事情就是 user 為 frank 時設置 result 為 ok,否則設置 result 為 failed
- 事務執行完成后查看 result 值為 ok
compact
正如我們所提到的,etcd保持修改,以便應用程序可以讀取以前版本的 key。但是,為了避免累積無限的歷史,重要的是要壓縮過去的修訂版本。壓縮后,etcd刪除歷史版本,釋放資源供將來使用。在壓縮版本之前所有被修改的數據都將不可用。
$ etcdctl compact 5
compacted revision 5
$ etcdctl get --rev=4 foo
Error: etcdserver: mvcc: required revision has been compacted
lease 與 TTL
etcd 也能為 key 設置超時時間,但與 redis 不同,etcd 需要先創建 lease,然后 put 命令加上參數 –lease= 來設置。lease 又由生存時間(TTL)管理,每個租約都有一個在授予時間由應用程序指定的最小生存時間(TTL)值。
以下是授予租約的命令:
$ etcdctl lease grant 30
lease 694d6ee9ac06945d granted with TTL(30s)
$ etcdctl put --lease=694d6ee9ac06945d foo bar
OK
以下是撤銷同一租約的命令:
$ etcdctl lease revoke 694d6ee9ac06945d
lease 694d6ee9ac06945d revoked
$ etcdctl get foo
應用程序可以通過刷新其TTL來保持租約活着,因此不會過期。
假設我們完成了以下一系列操作:
$ etcdctl lease grant 10
lease 32695410dcc0ca06 granted with TTL(10s)
以下是保持同一租約有效的命令:
$ etcdctl lease keep-alive 32695410dcc0ca06
lease 32695410dcc0ca06 keepalived with TTL(10)
lease 32695410dcc0ca06 keepalived with TTL(10)
lease 32695410dcc0ca06 keepalived with TTL(10)
...
應用程序可能想要了解租賃信息,以便它們可以續訂或檢查租賃是否仍然存在或已過期。應用程序也可能想知道特定租約所附的 key。
假設我們完成了以下一系列操作:
$ etcdctl lease grant 200
lease 694d6ee9ac06946a granted with TTL(200s)
$ etcdctl put demo1 val1 --lease=694d6ee9ac06946a
OK
$ etcdctl put demo2 val2 --lease=694d6ee9ac06946a
OK
以下是獲取有關租賃信息的命令:
$ etcdctl lease timetolive 694d6ee9ac06946a
lease 694d6ee9ac06946a granted with TTL(200s), remaining(178s)
以下是獲取哪些 key 使用了租賃信息的命令:
$ etcdctl lease timetolive --keys 694d6ee9ac06946a
lease 694d6ee9ac06946a granted with TTL(200s), remaining(129s), attached keys([demo1 demo2])
四、服務發現實戰
如果有一個讓系統可以動態調整集群大小的需求,那么首先就要支持服務發現。就是說當一個新的節點啟動時,可以將自己的信息注冊到 master,讓 master 把它加入到集群里,關閉之后也可以把自己從集群中刪除。這個情況,其實就是一個 membership protocol,用來維護集群成員的信息。
整個代碼的邏輯很簡單,worker 啟動時向 etcd 注冊自己的信息,並設置一個帶 TTL 的租約,每隔一段時間更新這個 TTL,如果該 worker 掛掉了,這個 TTL 就會 expire 並刪除相應的 key。發現服務監聽 workers/ 這個 etcd directory,根據檢測到的不同 action 來增加,更新,或刪除 worker。
首先我們要建立一個 etcd client:
func NewMaster(endpoints []string) *Master { // etcd 配置 cfg := client.Config{ Endpoints: endpoints, DialTimeout: 5 * time.Second, } // 創建 etcd 客戶端 etcdClient, err := client.New(cfg) if err != nil { log.Fatal("Error: cannot connect to etcd: ", err) } // 創建 master master := &Master{ members: make(map[string]*Member), API: etcdClient, } return master }
這里我們先建立一個 etcd client,然后把它的 key API 放進 master 里面,這樣我們以后只需要通過這個 API 來跟 etcd 進行交互。Endpoints 是指 etcd 服務器們的地址,如 ”http://127.0.0.1:2379“ 等。go master.WatchWorkers() 這一行啟動一個 Goroutine 來監控節點的情況。下面是 WatchWorkers 的代碼:
func (master *Master) WatchWorkers() { // 創建 watcher channel watcherCh := master.API.Watch(context.TODO(), "workers", client.WithPrefix()) // 從 chanel 取數據 for wresp := range watcherCh { for _, ev := range wresp.Events { key := string(ev.Kv.Key) if ev.Type.String() == "PUT" { // put 方法 info := NodeToWorkerInfo(ev.Kv.Value) if _, ok := master.members[key]; ok { log.Println("Update worker ", info.Name) master.UpdateWorker(key,info) } else { log.Println("Add worker ", info.Name) master.AddWorker(key, info) } } else if ev.Type.String() == "DELETE" { // del 方法 log.Println("Delete worker ", key) delete(master.members, key) } } } }
worker 這邊也跟 master 類似,保存一個 etcd KeysAPI,通過它與 etcd 交互,然后用 heartbeat 來保持自己的狀態,在 heartbeat 定時創建租約,如果租用失效,master 將會收到 delete 事件。代碼如下:
func NewWorker(name, IP string, endpoints []string) *Worker { // etcd 配置 cfg := client.Config { Endpoints: endpoints, DialTimeout: 5 * time.Second, } // 創建 etcd 客戶端 etcdClient, err := client.New(cfg) if err != nil { log.Fatal("Error: cannot connect to etcd: ", err) } // 創建 worker worker := &Worker { Name: name, IP: IP, API: etcdClient, } return worker } func (worker *Worker) HeartBeat() { for { // worker info info := &WorkerInfo{ Name: worker.Name, IP: worker.IP, CPU: runtime.NumCPU(), } key := "workers/" + worker.Name value, _ := json.Marshal(info) // 創建 lease leaseResp, err := worker.API.Lease.Grant(context.TODO(), 10) if err != nil { log.Fatalf("設置租約時間失敗:%s\n", err.Error()) } // 創建 watcher channel _, err = worker.API.Put(context.TODO(), key, string(value), client.WithLease(leaseResp.ID)) if err != nil { log.Println("Error update workerInfo:", err) } time.Sleep(time.Second * 3) } }
啟動的時候需要有多個 worker 節點(至少一個)和一個 master 節點,所以我們在啟動程序的時候,可以傳遞一個 “role” 參數。代碼如下:
var role = flag.String("role", "", "master | worker") flag.Parse() endpoints := []string{"http://127.0.0.1:2379"} if *role == "master" { master := discovery.NewMaster(endpoints) master.WatchWorkers() } else if *role == "worker" { worker := discovery.NewWorker("localhost", "127.0.0.1", endpoints) worker.HeartBeat() } else { ... }
項目地址: https://github.com/chapin666/etcd-service-discovery
五、總結
- etcd 默認只保存 1000 個歷史事件,所以不適合有大量更新操作的場景,這樣會導致數據的丟失。
- etcd 典型的應用場景是配置管理和服務發現,這些場景都是讀多寫少的。
- 相比於 zookeeper,etcd 使用起來要簡單很多。不過要實現真正的服務發現功能,etcd 還需要和其他工具(比如 registrator、confd 等)一起使用來實現服務的自動注冊和更新。
文章來源:https://zhuanlan.zhihu.com/p/96428375?from_voters_page=true
