## 18.3\. 組件分析
### Agent (代理)
在 Puppet 運行過程之中,第一個提到的組件就是?`agent`?進程。歷史上,這是一個獨立的稱為`puppedd`?的可執行程序,但在 2.6 中,我們把 Puppet 變成了一個唯一的可執行程序,現在,可以使用?`puppet agent`?來調用它,和 Git 的使用方式很類似。這個代理本身的功能不多,主要是用于實現上面圖中客戶端的工作流的配置和代碼。
### Facter
在 agent 之后的下一個組件是一個稱為 Facter 的外部工具,這是一個用于檢測本機信息的非常簡單的工具。這些信息包括操作系統、IP 地址、主機名等,但 Facter 非常易于擴展,很多機構在使用中會增加它們自己的插件來檢測一些個性化信息。Facter 發現的信息會由 agent 發送給服務器,之后,服務器就接過了接力棒,繼續工作流。
### 外部節點分類器
在服務端,第一個遇到的組件稱為外部節點分類器(External Node Classifier),簡稱為 ENC。ENC 接受主機名作為輸入,返回一個包含對應主機的高級配置信息的數據結構。ENC 通常是一個獨立的服務或程序:可能是一個其他的開源項目,比如 Puppet Dashboard 或 Foreman,或者是繼承的已有的數據存儲,比如 LDAP。ENC 的目的是,確定一臺主機從功能上屬于那些類,以及需要用哪些參數來配置這些類。比如,一個給定的主機可能屬于?`debian`?和?`webserver`?類,`datacenter`?參數應該設置為?`atlanta`。
注意,在 Puppet 2.7 中,ENC 不是一個必選組件,用戶可以在 Puppet 代碼中直接指定節點的配置。大約在 Puppet 項目開始兩年之后,ENC 才被添加進來,因為我們開始意識到,對節點的功能進行劃分和配置節點從本質上說是不同的兩件事,將它們劃分到兩個不同的程序中可能比擴展語言來支持兩種功能要更合理。盡管不是必須,但 ENC 仍然是推薦配置,并且將來某天可能會成為必選組件(到那時,Puppet 會提供一個擁有足夠必備功能的 ENC,不必擔心)。
一旦服務器收到 ENC 的分類信息,或是來自 Facter (經過 agent)的系統信息后,它會將所有信息綁定到一個節點對象上,并將它送入編譯器。
### 編譯器(Compiler)
如前所述,Puppet 有一個自定義的語言,用于指定系統配置。它的編譯起實際上有三個部分:一個 Yacc 風格的分析器生成器和一個定制的詞法分析器;一組用于創建我們的抽象語法樹(AST)的類;以及 Compiler 類,它用于處理所有這些類,以及實現編譯器作為系統的一部分所提供的API的函數之間的交互。
編譯器要處理的最復雜的事情是,大部分 Puppet 配置代碼是在第一次被引用時才延遲加載的(減少加載時間,同時避免缺少一些實際不必要的依賴資源時產生的無關日志),這意味著不會有顯式的調用來加載并分析代碼。
Puppet 的解析器使用了一個使用開源的?[Racc](https://github.com/tenderlove/racc)?構建的正常的?[Yacc 風格的解析器生成器](http://dinosaur.compilertools.net/)。可不幸的是,在 Puppet 項目開始時,沒有可用的詞法分析器生成器,所以只好使用了一個定制的詞法分析器。
因為我們在 Puppet 中使用了 AST,所以 Puppet 語法中的每個語句都可以被求值為 Puppet AST 類的一個實例(`Puppet::Parser::AST::Statement`),這些 AST 實例不會被直接執行操作,而會被放入一個語法樹之中,被一起執行。當一個服務器為很多不同節點服務時,使用 AST 會帶來一些性能上的收益,因為這樣可以一次解析,多次編譯。同時這也給了我們一個機會,來對 AST 進行一些內省(introspection),讓我們得到一些額外的信息和能力,如果直接解析執行是無法得到這些的。
在 Puppet 項目開始時,可參考的 AST 的例子并不多,這部分已經經過了很多的演化,發展到現在,我們的形式看起來是比較獨一無二的。我們不會直接針對整個配置生成一個單獨的AST,相反,我們創建很多小的 AST,按照名字切開。比如,如下代碼:
~~~
class ssh {
package { ssh: ensure => present }
}
~~~
會創建一個新的 AST,包含一個?`Puppet::Parser::AST::Resource`?實例,并將這個 AST 命名為 "ssh",存儲在存儲這個特定環境的所有類的哈希表中。(這里略過了構建類的細節,不過對于這里的討論來說,這些是不必要的。)
給定 AST 和(來自 ENC 的)Node 對象,編譯器取出 node 對象(如果存在的話)指定的類,查找并進行求值。在這個求值的過程中,編譯器構建了不同不同的域的樹,每個類有自己的作用域。這意味著 Puppet 是動態作用域的:如果一個 class include 了另一個類,那么里面的類就可以訪問外面類的變量。這的確是一個噩夢,我們正在著手消除這個問題。
作用域樹是臨時數據結構,一旦編譯完成就會被釋放,但編譯的輸出也隨著編譯的過程逐漸完成。我們把這個輸出產品稱為 Catalog(目錄),但它實際是一張資源和它們的關系構成的圖。變量、控制結構或是函數都不會存在在 catalog 之中,catalog 是純數據,并可以被轉化為 JSON、YAML 或其他各種格式。
在編譯過程中,我們會創建一些包含(containment)關系,一個類"包含(contains)"類中定義的所有資源(比如, 前面的例子中,ssh 類包含 ssh 包)。類可以包含一個定義,而這個定義本身也可以包含一個或多個定義,或其他獨立的資源。一個 catalog 傾向于一個扁平的、彼此無連接的圖:很多類,每個都有少數的幾個層次。
這種圖的一個別扭的方面是,它還包含了“依賴(dependency)”關系,比如一個服務依賴于一個包(可能因為安裝了包才能創建服務),但這些依賴關系是由資源的參數指定的,而非圖結構的邊。我們的圖類(由于歷史原因,稱為?`SimpleGraph`)不支持在同一張圖里同時有“包含”邊和“依賴”邊,所以,我們不得不為了不同的需求在它們之間來回轉換。
### 事務 (Transaction)
一旦 catalog 完全構建好了(假設沒有失敗),就會送給 Transaction。在一個區分客戶機和服務器的系統中,Transaction 運行在客戶機上,如圖 18.2,它通過 HTTP 協議下載 Catalog。
Puppet 的 transaction 類提供了實際進行系統修改操作的框架,而我們討論過的其他東西都構建于其上或是進行對象的分發傳遞。和數據庫之類的一般系統中的事務不同 Puppet transaction 的行為并不具有原子性等特征。
transaction 的工作相當直接:在圖中按照各種關系指定的順序進行遍歷,并確保各種資源保持同步。正如上面提到的,它不得不將圖從包含邊(比如?`Class[ssh]`?包含?`Package[ssh]`)轉換為依賴邊(比如?`Service[ssh]`?依賴于?`Package[ssh]`),然后對圖進行標準的拓撲排序,按順序選擇每種資源。
對于給定的資源,我們進行簡單的三步操作:獲取資源的當前狀態,與期望的狀態進行比較,進行必要的改動,以滿足期望。比如,有如下代碼:
~~~
file { "/etc/motd":
ensure => file,
content => "Welcome to the machine",
mode => 644
}
~~~
transaction 會檢查?`/etc/motd`?的內容,如果和指定的狀態不匹配的話,會修復不一致的地方。如果 /etc/motd 是一個目錄,那么它會備份其中的文件,再刪除目錄,然后創建一個內容和權限都符合要求的文件。
進行操作的過程實際上是通過一個簡單的?`ResourceHarness`?類來控制的,這個類定義了事務和資源之間的接口。這樣做可以減少類之間的連接,并可以讓他們互相獨立地進行修改。
### 資源抽象層 (Resource Abstraction Layer)
事務類是 Puppet 完成工作的核心,但所有的工作實際都是由資源抽象層(RAL)來完成的,從架構上講,這一層也是 Puppet 中最有意思的組件。
RAL 是 Puppet 中創建的第一個組件,與語言部分不同,這部分對用戶能做的事情進行了清晰的定義。RAL 的工作就是定義一個資源究竟是什么,要實現一個資源需要在系統中進行什么操作,而 Puppet 語言正是用來操作由 RAL 建模的資源的。正因如此,RAL 也是系統中最重要和最難改動的組件。我們希望能修改 RAL 中的很多東西,而且也在過去的多年中進行了很多重大改進(最難的莫過于增加 Provider 了),但是在 RAL 中,還是有很多工作需要在日后慢慢修改。
在編譯器子系統中,我們將資源和資源類型分別進行了建模(分別命名為?`Puppet::Resource`?和 Puppet::Resource::Type)。我們的目標是讓這些類也成為 RAL 的核心,不過,目前這兩種行為(資源和類型)被封裝到了同一個類之中 ——?`Puppet::Type`。(這個類的命名十分糟糕,這是因為定義這個類的時間遠早于我們開始使用“資源”這個名詞的時間,在那時,我們在主機間進行數據通信時,是直接對內存類型進行序列化的,事到如今,想要再去重新調整命名已經非常困難了。)
當?`Puppet::Type`?被最早設計出來的時候,似乎把資源和類型的行為放到同一個類里是有道理的,畢竟資源是資源類型的實例。但隨著時間的推移,越來越發現,資源及其類型不適合于放在一個傳統的繼承關系構成的模型里。比如,資源類型定義了資源可以有哪些參數,但不管資源接受哪些參數(可能全部接受)。這樣,我們的?`Puppet::Type`?就擁有了類級別的行為——規定資源類型的行為,和實例級別的行為——規定資源實例如何行為。同時,它還負責管理注冊和獲取資源類型的功能,如果你需要 "user" 類型,你可以調用?`Puppet::Type.type(:user)`.
這種混合的行為導致了?`Puppet::Type`?不太容易維護。整個類有不到 2000 行代碼,但卻在三個層面上工作 —— 資源、資源類型,和組員類型管理器 —— 這讓它變得難以理解。這就是為什么這個模塊是重構的主要目標的原因,不過它本身更多的是拼接在一起的代碼,而不是面向用戶的設計,所以,修正它要比直接根據功能重寫更困難。
除了?`Puppet::Type`?之外,RAL 中還有兩個重要的類,其中最有趣的一個我們稱之為 Provider。在 RAL 剛剛被開發出來時,每種資源都是由參數定義和如何管理它們的代碼混在一起構成的。比如,我們要定義 "content" 參數,然后提供一個方法來讀取文件的內容,以及另一個用于修改內容的方法:
~~~
Puppet::Type.newtype(:file) do
...
newproperty(:content) do
def retrieve
File.read(@resource[:name])
end
def sync
File.open(@resource[:name], "w") { |f| f.print @resource[:content] }
end
end
end
~~~
這是個簡化的例子(比如我們內部實際使用的校驗和,而非讀取全部內容),但這里可以大致了解處理思路。
這樣就讓事情變得非常難于管理了,因為我們需要為每個資源類型管理很多不同的屬性。目前 Puppet 支持超過 30 種包管理工具,這樣,很難在一個 Package 資源類型中去支持所有這些管理工具了。于是,我們提供了一種資源類型和管理相應類型的資源的方法之間的一個清晰接口 —— 實際上,資源類型是指資源類型的名字和它們支持的屬性。 Provider 為所有的資源類型的屬性定義了 getter 和 setter 方法,以清晰直觀的方式命名。例如,這是一個 provider 實現上述屬性的代碼示例:
~~~
Puppet::Type.newtype(:file) do
newproperty(:content)
end
Puppet::Type.type(:file).provide(:posix) do
def content
File.read(@resource[:name])
end
def content=(str)
File.open(@resource[:name], "w") { |f| f.print(str) }
end
end
~~~
在這個簡單的例子里,似乎還多了一點代碼,但這更易于理解和維護,特別是當屬性的數量或是屬性提供者的數量變多的時候。
本節開始處曾經提到,Transaction 并不直接改動系統,而是通過 RAL 來完成的。現在,我們可以清楚地看到,是 provider 來進行的這想具體工作。事實上,總體上講,provider 是 Puppet 當中,唯一真正直接觸及系統的部分。transaction 請求文件內容,provider 就會為它讀取,transaction 要求文件的內容要被改動,provider 就去改動它。注意,盡管如此,provider 從不決定如何影響系統 —— 如何影響系統這個問題是由 Transaction 來決定的,provider 僅僅是執行任務。這種架構可以讓 Transaction 能夠完全控制系統,卻不需要了解文件、用戶和包這些具體細節,而且,這個劃分可以讓 Puppet 可以擁有一個完全模擬執行的模式,在這種模式下,我們可以保證系統完全不會受到任何影響。
RAL之中的另一個主要的類型負責參數本身。我們支持三種類型的參數: metaparameters, 這種參數影響所有資源類型(比如,是否在模擬模式中運行);參數,它們是不直接寫入到磁盤上的一些值(比如,是否在查找文件時進入符號鏈接);還有屬性(property),它們規范了你要修改磁盤上的資源的哪方面的內容(比如文件的內容,或者一個服務是否在運行著)。區分屬性和參數的不同十分困難,但你可以這么想,屬性是哪些 provider 中有 getter 和 setter 方法的,這樣就易于分辨了。
### 報告 (Reporting)
隨著 transaction 遍歷整張圖,并使用 RAL 來修改系統的配置,Puppet 同時也會同時生成一份報告。這份報告包含了在對系統應用修改的過程中發生的事件。這些事件也完整地飯贏了工作進行的情況:它們會在資源改變時記錄時間戳,已有的值和新的值,以及所有產生的信息,以及變動成功或是失敗(或者實在模擬運行模式)。
這些事件封裝在?`ResourceStatus`?對象之中,映射到相應的資源。這樣,對于一個給定的 Transaction,你可以知道其中運行的所有資源,這些改變是否成功,以及你可能希望知道的關于這些改動的元數據。
一旦 transaction 完成了,一些基本的性能參數也會被計算出來,并存儲到報告中,之后發送給服務器(如果配置了服務器的話)。當報告被發送出去的時候,配置過程就完成了,agent 會回到睡眠模式,或者進程退出。
## 18.4\. 基礎架構
現在我們已經從整體上理解了 Puppet 做了什么,如何做到的,值得再花一點看看其他部分了,這些部分并沒有顯示出什么過人之處,但對于完成工作也是十分必要的。
### Plugins
Puppet 的一個突出優點是它非常易于擴展。在 Puppet 里,至少有 12 類擴展,大部分擴展都可以被所有人使用。比如,你可以在這些方面寫出你自己的擴展:
* 資源類型和自定義 provider
* 報告處理程序,比如存儲報告到專用的數據庫中
* Indirector,用于和已有數據存儲交互
* 用于獲取你的主機上的額外信息的 facts
不過,Puppet 的分布式本質意味著 agent 需要某種方式來取回并加載新的擴展。為此,在每次 Puppet 啟動之前,第一件事請就是找到可用的服務器,下載所有 plugin。其中可能包括新的資源類型或 provider,新 facts,或者是新的報告處理器。
這意味著我們可以在不改動核心 Puppet 包的同時,升級大部分的 Puppet agent 功能。對于一些自定義的 Puppet 部署,這更是特別有用。
### Indirector
到目前為止,你可能已經發現,Puppet 的開發歷史中有一些壞名字的類,而對于大部分人來說,這一個是最無法容忍的。Indirector 是一個極具擴展性的控制反轉(IoC)框架。控制反轉系統允許你講功能的開發和如何控制使用什么功能獨立開。在 Puppet 的例子中,這允許我們使用很多插件,來提供非常不同的功能,比如可以通過 HTTP 訪問編譯器,也可以直接在進程中加載,這些可以通過一個笑得配置改變而不需要修改代碼就可以實現。換句話說,按照 Wikipedia 的 “控制反轉” 頁面的描述,Puppet Indirector 是一個服務定位器的實現。所有從一個類到另一個類的切換都經由 Indirector,通過一個標準的類 REST 接口完成(比如,我們支持 find, search, save 以及 destroy 方法),這樣,講 Puppet 從無服務器模式切到客戶機/服務器模式的操作,很大程度上說,是一個配置 agent 使用 HTTP 作為獲取 catalog 的方法,而非直接訪問 compiler 的問題。
因為作為一個控制反轉框架,Indirector 的配置必須嚴格地和代碼的路徑分開,這個類本身非常難于理解,特別是你在 debug 為什么使用給定的代碼路徑時。
### 網絡
Puppet 的原型寫于 2004 年,當時的關于 RPC 的問題是 XMLRPC 與 SOAP 之爭。我們選擇了 XMLRPC,它工作得很好,但是有一個大部分其他方法都有的問題:不鼓勵在模塊間使用標準接口,而且對于獲取簡單的一個結果這樣的操作有些過于復雜了。因為 XMLRPC 編碼的需要,導致了幾乎每個對象都在內存中至少出現兩次,對于大文件來說代價很高,我們也為此遇到過很嚴重的內存問題。
從 0.25 發布開始(開始于 2008 年),我們開始了將網絡通信向類 REST 模型遷移的過程,但我們沒有直接修改網絡,而是選擇了一種更復雜的方案。我們開發了 Indirector 作為組件間通信的標準框架,然后構建了一個 REST 端點作為一個可選方案。我們用了兩個 Release 來完整支持 REST,目前還沒有完全完成(從使用 YAML)到使用 JSON 作為序列化方案的轉換。我們進行 YAML 到 JSON 的轉換有兩點主要的原因:首先,Ruby 之中,處理 YAML 非常慢,而 Ruby 處理 JSON 則快很多;其次,大部分 web 應用都轉向了 JSON,這讓 JSON 看起來更加可移植一些。當然,對于 Puppet 的情況來說,使用 YAML 并非是為了語言間可移植性考慮的,,而且 YAML 配置對于不同版本的 Puppet 可能都經常不兼容,因為它本質上是用來序列化 Ruby 內部對象的。
我們的下一個主版本更新將完全移除 XMLRPC 的支持。
## 18.5\. 經驗教訓
從實現的角度講,我們對于 Puppet 中實現的各種解耦非常自豪:描述語言與 RAL 是完全解耦的,Transaction 無法直接訪問系統,而 RAL 本身不會做任何策略判斷。這些抽象和解耦讓應用的開發者可以更加專注于工作流的開發,并可以獲得很多關于發生了什么、為什么發生的信息。
Puppet 的可擴展性和可配置性也是它的一個主要優點,任何人都可以在 Puppet 的基礎上,輕易地開發應用而無需修改其內核。我們也使用提供給用戶的同樣的接口來開發各種功能。
Puppet 的簡單和易用性一直是它的主要優點。雖然讓它跑起來還是有點困難,不過可以強出市場上的其他產品好幾里地了。這些簡單性是以增加了很多工程量為代價的,特別是在維護和更多的設計工作方面的工作量,但是,如果能讓用戶可以更加集中在他們的問題上,而非工具上的話,這些開銷也是值得的。
Puppet 的可配執性是個非常好的特性,不過我們做得好像有點過了。你可以有很多方法來讓 Puppet 工作起來,并且,在 Puppet 上太容易構建工作流了,這在有的時候可能讓人趕到困窘。我們的一個短期目標是,減少你可以調整的 Puppet 配置,避免用戶很容易地把 Puppet 調壞,而且,這樣我們在升級的時候也可以考慮更少的邊界情況了。
我們的改變也有些慢了。有很多重構都已經等待數年卻仍然沒有進行。對用戶來說,短期內這意味著更穩定的系統,但卻更難于維護,而且用戶也更難于向社區回饋代碼。
最后,我們花費了很長時間來認識到,描述我們的設計語言的最恰當的詞就是簡單性。我們現在已經開始在考慮簡單性之外的設計目標了,我們開始采用更好的決策框架來決定是否增加或移除特性,開始通過考慮背后的深層次原因來做出決定。
## 18.6\. Conclusion
Puppet 是一個簡單的系統,也是一個復雜的系統。它由很多的部分組成,但各個部分之間的耦合非常松,每個部分從2005年至今都發生了很多變化。它是一個可以用于處理各種配置問題的框架,但作為一個應用,它非常簡單易用。
在未來,我們的成功將依賴于更加堅實、更加簡單的框架,并讓應用在增強能力的同時,保持易用性。
- 前言(卷一)
- 卷1:第1章 Asterisk
- 卷1:第3章 The Bourne-Again Shell
- 卷1:第5章 CMake
- 卷1:第6章 Eclipse之一
- 卷1:第6章 Eclipse之二
- 卷1:第6章 Eclipse之三
- 卷1:第8章 HDFS——Hadoop分布式文件系統之一
- 卷1:第8章 HDFS——Hadoop分布式文件系統之二
- 卷1:第8章 HDFS——Hadoop分布式文件系統
- 卷1:第12章 Mercurial
- 卷1:第13章 NoSQL生態系統
- 卷1:第14章 Python打包工具
- 卷1:第15章 Riak與Erlang/OTP
- 卷1:第16章 Selenium WebDriver
- 卷1:第18章 SnowFlock
- 卷1:第22章 Violet
- 卷1:第24章 VTK
- 卷1:第25章 韋諾之戰
- 卷2:第1章 可擴展Web架構與分布式系統之一
- 卷2:第1章 可擴展Web架構與分布式系統之二
- 卷2:第2章 Firefox發布工程
- 卷2:第3章 FreeRTOS
- 卷2:第4章 GDB
- 卷2:第5章 Glasgow Haskell編譯器
- 卷2:第6章 Git
- 卷2:第7章 GPSD
- 卷2:第9章 ITK
- 卷2:第11章 matplotlib
- 卷2:第12章 MediaWiki之一
- 卷2:第12章 MediaWiki之二
- 卷2:第13章 Moodle
- 卷2:第14章 NginX
- 卷2:第15章 Open MPI
- 卷2:第18章 Puppet part 1
- 卷2:第18章 Puppet part 2
- 卷2:第19章 PyPy
- 卷2:第20章 SQLAlchemy
- 卷2:第21章 Twisted
- 卷2:第22章 Yesod
- 卷2:第24章 ZeroMQ