所謂分布式系統,指的是一組通過發送消息實現協作、從而共同達成同一目標的資源集合。正如知名計算機科學家Leslie Lamport所指出之定義:“所謂分布式系統,其中任意一臺計算設備——即使使用者并未直接使用甚至對其存在毫不知情——出現故障,亦會影響到其它設備的正常運作。”
而這條定義也恰恰概括了我們在分布式系統當中經常遇到的一類問題。事實上,在云計算時代之下,資源的匯聚已經成為滿足負載對于計算及存儲實際需求的一種必然手段。這類系統的特點在于包含大量需要管理的資源,而其中故障的出現頻率與整體規模則成正比關系。
在分布式系統當中,故障屬于一類常規事態,而非意外狀況——這意味著我們必須時刻做好心理準備。有鑒于此,相關社區專門創建出專門的工具,旨在幫助開發人員應對這方面問題,而Apache ZooKeeper正是其中之一。
ZooKeeper是一款極具實用性、現場測試能力并擁有廣泛用戶群體的中間件,專門用于構建分布式應用程序。在OpenStack當中,ZooKeeper也成為Nova ServiceGroup API后端中的組成部分。最近,ZooKeeper還與Ceilometer相集成,從而為Central Agent帶來更為理想的高可用性水平——當然,這方面話題我們以后將另行討論。
我們為什么需要ZooKeeper?
一般來講,當大家設計一款分布式應用程序時,常常會發現需要將各類流程加以協同才能執行預期任務。在大多數情況下,這種協作關系依賴于最基本的分布式協作機制。
Heat是一款OpenStack編排程序。大家可以利用它創建出一系列云資源,而這類資源會由一個模板文件負責指定,這就是堆棧的概念。Heat允許用戶對堆棧進行更新,但更新過程必須以原子方式進行,否則可能會導致資源復制或者相關性破壞等沖突。這類問題在并發更新場景下非常常見,而為了解決此類問題,Heat會在對堆棧進行更新之前首先設置一套所謂分布式互斥鎖。
在這類原型基礎之上進行開發是項極為困難的工作,而且經常帶來令人頭痛的麻煩。事實上,在分布式系統當中反復出現的這些問題早已成為技術圈中的共識。為了簡化開發人員的日常工作,雅虎實驗室創造出了Apache ZooKeeper項目,旨在為這些協作因素提供一套集中式API。歸功于ZooKeeper的幫助,現在我們已經能夠輕松實現多種不同協議,包括分布式鎖、屏障以及隊列等等。
ZooKeeper應用程序的架構與優勢
一款ZooKeeper應用程序由一臺或者多臺ZK服務器支撐而成,我們可以將其稱為一個“集合”,在應用程序端則為一組ZK客戶端。
其設計思路在于,該分布式應用程序的每一個節點都通過使用一個ZK客戶端在應用層級使用相關API,這意味著應用的運行將依賴于ZooKeeper服務器實現。
這種架構方案擁有以下幾項突出優勢:
我們可以立足于應用層提取大部分分布式同步負載,從而實現一套所謂KISS(即Keep It Simple, Stupid,保持一切簡單且具傻瓜式特性)架構。
常見的各類分布式協作元能夠實現開箱即用,因此開發人員無需再自行對其加以處理。
開發人員不需要處理服務故障等狀況,因為整套體系擁有出色的彈性。ZooKeeper以應用程序神經中心的姿態存在,因為它負責控制整個協作機制,因此眾多組件都需要依附于它以實現作用。出于這些理由,ZooKeeper在設計中引入了出色的分布式算法,從而提供開發人員所需要的高可靠性與可用性保障。一個ZooKeeper集合基于群體形式存在,且通常由三到五臺服務器構成。
ZooKeeper集合能夠在多種場景之下發揮作用,下面讓我們從實踐角度出發一同來了解。
實踐場景中的ZooKeeper
ZooKeeper的API非常簡單而且直觀,其數據模型基于以內存樹形式存儲的分層命名空間。該樹中的各項元素被稱為znode,以文件形式容納數據并能夠如目錄一般擁有子znode。
首先,大家需要確保自己的運行環境滿足系統配置要求,接下來我們就要著手部署一臺ZK服務器了:
$ wget http://apache.crihan.fr/dist/zookeeper/zookeeper-3.4.6/zookeeper-3.4.6.tar.gz
$ tar xzf zookeeper-3.4.6.tar.gz
$ cd zookeeper-3.4.6
$ cp conf/zoo_sample.cfg conf/zoo.cfg
$ ./bin/zkServer.sh start
現在ZooKeeper服務器已經能夠以獨立模式運行了,且會在默認情況下監聽127.0.0.1:2181。如果大家需要部署一整套服務器集合,則可以點擊此處閱讀其相關管理指南。
ZooKeeper命令行界面
我們可以利用ZooKeeper命令行界面(./bin/zkCli.sh)完成一些基礎性操作。其使用方式與shell控制臺非常相似,操作感受也與文件系統相當接近。
下面列出root znode“/”中的全部子znode:
[zk: localhost:2181(CONNECTED) 0] ls /
[zookeeper]
創建一個路徑為“/myZnode”的znode,其相關數據則為“myData”:
[zk: localhost:2181(CONNECTED) 1] create /myZnode myData
Created /myZnode
[zk: localhost:2181(CONNECTED) 2] ls /
[myZnode, zookeeper]
刪除一個znode:
[zk: localhost:2181(CONNECTED) 3] delete /myZnode
大家可以輸入“help”命令來查看更多操作命令。在本次示例當中,我們將使用應用程序編程接口(簡稱API)來編寫一款分布式應用程序。
Python ZooKeeper API
我們的這套ZooKeeper服務器是由Java編程語言構建而成,且綁定了多種由不同語言編寫而成的客戶端集合。在今天的文章中,我們將通過Kazzo這一Python捆綁客戶端來了解該API。
我們可以在虛擬環境下輕松完成Kazoo的安裝工作:
$ pip install kazoo
首先,我們需要接入一個ZooKeeper集合:
from kazoo import client as kz_client
my_client = kz_client.KazooClient(hosts='127.0.0.1:2181')
def my_listener(state):
if state == kz_client.KazooState.CONNECTED:
print("Client connected !")
my_client.add_listener(my_listener)
my_client.start(timeout=5)
在以上代碼當中,我們利用KazooClient類創建了一個ZK客戶端。其中的“hosts”參數負責定義該ZK服務器地址,并以逗號加以分隔,因此如果某臺服務器出現故障,那么該客戶端將自動嘗試接入其它服務器。
Kazoo能夠在連接狀態出現變化時向我們發出通知,根據當前具體狀況,這項功能可以非常實用地觸發我們預設的各類操作。舉例來說,當連接無法順利建立時,該客戶端應當停止發送命令,而這正是add_listener()方法的作用所在。
而start()方法則能夠在確認會話創建完畢之后,在客戶端與一臺ZK服務器之間建立起連接。每臺服務器都會追蹤每個客戶端中的一項會話,這種特性在實際分布式協作元方面起到非常重要的基礎性作用。
對znode進行增刪改查
與znode進行交互同樣非常簡單:
# create a znode with data
my_client.create(“/my_parent_znode”)
my_client.create(“/my_parent_znode/child_1”, “child_data_1”)
my_client.create(“/my_parent_znode/child_2”, “child_data_2”)
# get the children of a znode
my_client.get_children(“/my_parent_znode”)
# get the data of a znode
my_client.get(“/my_parent_znode/child_1”)
# update a znode
my_client.set(“/my_parent_znode/child_1”, b"child_new_data_1")
# delete a znode
my_client.delete(“/my_parent_znode/child_1”)
其中set()方法會接受一條version參數,而后者則允許我們執行類似于CAS的操作,如此一來即保證了任何使用者都無法在不讀取最新版本的前提下進行數據更新。
有時候,大家可能希望確保某個znode名稱獨一無二。我們可以通過使用連續znode(即sequential znode)的方式實現這項目標,相當于告知服務器在每段路徑的結尾添加一個單遞增計數器。
在這一點上,ZooKeeper的運作方式類似于一套普通的數據庫,不過更有趣的特性還在后面。
觀察者
觀察者機制可以算是ZooKeeper的核心功能之一,我們可以利用它對znode事件進行通知。換句話來說,每個客戶端都能夠訂閱某個指定znode的事件,并在其狀態發生變化時得到通知。要獲取這類通知,該客戶端必須注冊一項回調方法——該方法在特定事件發生時即被調用(通過后臺線程)。感興趣的朋友可以點擊此處查看ZooKeeper所支持的各不同事件類型(英文原文)。
下面來看一段示例代碼,我們可以在某znode的子集發生變更時觸發通知機制:
def my_func(event):
# check to see what the children are now
# Set a watcher on "/my_parent_znode", call my_func() when its children change
children = zk.get_children("/my_parent_znode", watch=my_func)
值得指出的是,一旦執行了回調,客戶端就必須對其進行重置以保證下次事件發生時能夠再次正常獲取通知。
臨時性znode
正如之前所提到,當客戶端與服務器相對接時,會建立一個會話。該會話會始終保持開啟,負責向服務器發送心跳消息。而在經過一段時間的閑置之后,如果服務器端沒有監聽到來自客戶端的更多活動,則該會話即被關閉。由于該會話的存在,服務器才能夠判斷目標客戶端是否仍處于活動狀態。
臨時性znode與正常znode沒有什么本質區別,最大的不同在于前者會在該會話過期時被服務器所自動釋放。
如果將觀察者與臨時性znode相結合,我們就能夠實現ZooKeeper的一項殺手級特性。事實上,這類特性可以說為我們的分布式協作元實現工作帶來了數量龐大的可能性。下面我們就一起來看看分布鎖機制。
分布鎖
分布鎖應該算是分布式應用程序當中出鏡頻率最高的機制了,這是因為我們會經常需要以互斥的方式訪問某些資源。
在ZooKeeper當中,這項任務可以說非常輕松:
my_lock = my_client.Lock("/lockpath", "my-identifier")
with lock: # blocks waiting for lock acquisition
# do something with the lock
其涉及的API與本地鎖完全一樣,但引擎之下到底發生了什么?要找到問題的答案,我們首先來聊聊分布式算法的設計方式。
任何一種分布式算法都必須滿足兩項特性:安全性與活性。
其中安全性確保了該算法絕對不會偏離自己的目標,而對于分布樂來說,這意味著只有一個節點能夠獲得該鎖。從直觀角度講,同一時段內不可能有兩個節點同時擁有分布鎖。
而活性則確保了該算法的持續遞進,在分布鎖這一場景當中,這意味著如果某個節點嘗試獲取該鎖、那么最終一定能夠獲取到。
以本地方式實現鎖機制屬于眾所周知的難題,而且有大量專門作為解決方案的算法出現——例如Dekker算法,而且每一種現代編程語言都會將其囊括在標準庫當中。不過需要強調的是,在分布式環境下這個問題會變得更加復雜。這兩大特性之所以難于實現,是因為各個節點隨時可能出現故障,而這勢必造成大量可能出現的故障場景。
ZooKeeper ensures these properties for us:則能夠幫助我們確保這兩大特性:
活性的保障:將多個臨時性znode加以結合以檢測故障節點,而觀察者機制則負責向其它節點發出通知。因此,如果某個節點獲得了分布鎖并出現故障,那么其它節點將立即識別到這一狀況。
安全性的保障:利用連續znode以確保各節點皆擁有彼此獨立的命名,這樣只有一個節點會獲得分布鎖。
我強烈建議大家點擊此處查看分布鎖說明文檔,其中提到了Kazoo的多種實現方式。
總結陳詞
構建一款分布式應用程序往往會成為一場令人頭痛的噩夢,因為我們必須要預料到一切隨時可能出現的異常狀況(即隨機出現的故障),同時處理多種元素彼此組合產生的指數級狀況增長(系統規模越大,狀況的具體數量也就越多)。ZooKeeper是一款非常便捷的工具,而且適合大家用于打理自己的基礎設施堆棧。有了它的幫助,我們可以將更多精力集中在應用程序邏輯身上。
在OpenStack當中,我們希望能夠充分發揮ZooKeeper的設計目標,即利用單獨一款通用型工具解決所有分布式系統帶來的復雜難題。因此,我們創建了一套名為Tooz的庫,用于實現一部分常見的分布式協作元。Tooz的正常運行依賴于多種不同后端驅動要素——ZooKeeper當然也是其中之一——而且能夠作用于所有OpenStack項目當中。
在下一篇文章中,我們將了解如何利用OpenStack Ceilometer讓Central Agent擁有出色的高可用性——其中涉及另一種重要的分布式元,即組成員(group membership)。屆時我們也將開發出自己第一款基于ZooKeeper的真正應用程序,咱們到時候見!