http://www.infoq.com/cn/articles/docker-source-code-analysis-part2
1. 前言
如今,Docker作為業界領先的輕量級虛擬化容器管理引擎,給全球開發者提供了一種新穎、便捷的軟件集成測試與部署之道。在團隊開發軟件時,Docker可以提供可復用的運行環境、靈活的資源配置、便捷的集成測試方法以及一鍵式的部署方式。可以說,Docker的優勢在簡化持續集成、運維部署方面體現得淋漓盡致,它完全讓開發者從持續集成、運維部署方面中解放出來,把精力真正地傾注在開發上。
然而,把Docker的功能發揮到極致,並非一件易事。在深刻理解Docker架構的情況下,熟練掌握Docker Client的使用也非常有必要。前者可以參閱《Docker源碼分析》系列之Docker架構篇,而本文主要針對后者,從源碼的角度分析Docker Client,力求幫助開發者更深刻的理解Docker Client的具體實現,最終更好的掌握Docker Client的使用方法。即本文為《Docker源碼分析》系列的第二篇——Docker Client篇。
2. Docker Client源碼分析章節安排
本文從源碼的角度,主要分析Docker Client的兩個方面:創建與命令執行。整個分析過程可以分為兩個部分:
第一部分分析Docker Client的創建。這部分的分析可分為以下三個步驟:
- 分析如何通過docker命令,解析出命令行flag參數,以及docker命令中的請求參數;
- 分析如何處理具體的flag參數信息,並收集Docker Client所需的配置信息;
- 分析如何創建一個Docker Client。
第二部分在已有Docker Client的基礎上,分析如何執行docker命令。這部分的分析又可分為以下兩個步驟:
- 分析如何解析docker命令中的請求參數,獲取相應請求的類型;
- 分析Docker Client如何執行具體的請求命令,最終將請求發送至Docker Server。
3. Docker Client的創建
Docker Client的創建,實質上是Docker用戶通過可執行文件docker,與Docker Server建立聯系的客戶端。以下分三個小節分別闡述Docker Client的創建流程。
以下為整個docker源代碼運行的流程圖:
上圖通過流程圖的方式,使得讀者更為清晰的了解Docker Client創建及執行請求的過程。其中涉及了諸多源代碼中的特有名詞,在下文中會一一解釋與分析。
3.1. Docker命令的flag參數解析
眾所周知,在Docker的具體實現中,Docker Server與Docker Client均由可執行文件docker來完成創建並啟動。那么,了解docker可執行文件通過何種方式區分兩者,就顯得尤為重要。
對於兩者,首先舉例說明其中的區別。Docker Server的啟動,命令為docker -d或docker --daemon=true;而Docker Client的啟動則體現為docker --daemon=false ps、docker pull NAME等。
可以把以上Docker請求中的參數分為兩類:第一類為命令行參數,即docker程序運行時所需提供的參數,如: -D、--daemon=true、--daemon=false等;第二類為docker發送給Docker Server的實際請求參數,如:ps、pull NAME等。
對於第一類,我們習慣將其稱為flag參數,在go語言的標准庫中,同時還提供了一個flag包,方便進行命令行參數的解析。
交待以上背景之后,隨即進入實現Docker Client創建的源碼,位於./docker/docker/docker.go,該go文件包含了整個Docker的main函數,也就是整個Docker(不論Docker Daemon還是Docker Client)的運行入口。部分main函數代碼如下:
func main() { if reexec.Init() { return } flag.Parse() // FIXME: validate daemon flags here …… }
在以上代碼中,首先判斷reexec.Init()方法的返回值,若為真,則直接退出運行,否則的話繼續執行。查看位於./docker/reexec/reexec.go中reexec.Init()的定義,可以發現由於在docker運行之前沒有任何的Initializer注冊,故該代碼段執行的返回值為假。
緊接着,main函數通過調用flag.Parse()解析命令行中的flag參數。查看源碼可以發現Docker在./docker/docker/flag.go中定義了多個flag參數,並通過init函數進行初始化。代碼如下:
var ( flVersion = flag.Bool([]string{"v", "-version"}, false, "Print version information and quit") flDaemon = flag.Bool([]string{"d", "-daemon"}, false, "Enable daemon mode") flDebug = flag.Bool([]string{"D", "-debug"}, false, "Enable debug mode") flSocketGroup = flag.String([]string{"G", "-group"}, "docker", "Group to assign the unix socket specified by -H when running in daemon mode use '' (the empty string) to disable setting of a group") flEnableCors = flag.Bool([]string{"#api-enable-cors", "-api-enable-cors"}, false, "Enable CORS headers in the remote API") flTls = flag.Bool([]string{"-tls"}, false, "Use TLS; implied by tls-verify flags") flTlsVerify = flag.Bool([]string{"-tlsverify"}, false, "Use TLS and verify the remote (daemon: verify client, client: verify daemon)") // these are initialized in init() below since their default values depend on dockerCertPath which isn't fully initialized until init() runs flCa *string flCert *string flKey *string flHosts []string ) func init() { flCa = flag.String([]string{"-tlscacert"}, filepath.Join(dockerCertPath, defaultCaFile), "Trust only remotes providing a certificate signed by the CA given here") flCert = flag.String([]string{"-tlscert"}, filepath.Join(dockerCertPath, defaultCertFile), "Path to TLS certificate file") flKey = flag.String([]string{"-tlskey"}, filepath.Join(dockerCertPath, defaultKeyFile), "Path to TLS key file") opts.HostListVar(&flHosts, []string{"H", "-host"}, "The socket(s) to bind to in daemon mode\nspecified using one or more tcp://host:port, unix:///path/to/socket, fd://* or fd://socketfd.") }
這里涉及到了Golang的一個特性,即init函數的執行。在Golang中init函數的特性如下:
- init函數用於程序執行前包的初始化工作,比如初始化變量等;
- 每個包可以有多個init函數;
- 包的每一個源文件也可以有多個init函數;
- 同一個包內的init函數的執行順序沒有明確的定義;
- 不同包的init函數按照包導入的依賴關系決定初始化的順序;
- init函數不能被調用,而是在main函數調用前自動被調用。
因此,在main函數執行之前,Docker已經定義了諸多flag參數,並對很多flag參數進行初始化。定義的命令行flag參數有:flVersion、flDaemon、flDebug、flSocketGroup、flEnableCors、flTls、flTlsVerify、flCa、flCert、flKey等。
以下具體分析flDaemon:
- 定義:flDaemon = flag.Bool([]string{"d", "-daemon"}, false, "Enable daemon mode")
- flDaemon的類型為Bool類型
- flDaemon名稱為”d”或者”-daemon”,該名稱會出現在docker命令中
- flDaemon的默認值為false
- flDaemon的幫助信息為”Enable daemon mode”
- 訪問flDaemon的值時,使用指針* flDaemon解引用訪問
在解析命令行flag參數時,以下的語言為合法的:
- -d, --daemon
- -d=true, --daemon=true
- -d=”true”, --daemon=”true”
- -d=’true’, --daemon=’true’
當解析到第一個非定義的flag參數時,命令行flag參數解析工作結束。舉例說明,當執行docker命令docker --daemon=false --version=false ps時,flag參數解析主要完成兩個工作:
- 完成命令行flag參數的解析,名為-daemon和-version的flag參數flDaemon和flVersion分別獲得相應的值,均為false;
- 遇到第一個非flag參數的參數ps時,將ps及其之后所有的參數存入flag.Args(),以便之后執行Docker Client具體的請求時使用。
如需深入學習flag的解析,可以參見源碼命令行參數flag的解析。
3.2. 處理flag信息並收集Docker Client的配置信息
有了以上flag參數解析的相關知識,分析Docker的main函數就變得簡單易懂很多。通過總結,首先列出源代碼中處理的flag信息以及收集Docker Client的配置信息,然后再一一對此分析:
- 處理的flag參數有:flVersion、flDebug、flDaemon、flTlsVerify以及flTls;
- 為Docker Client收集的配置信息有:protoAddrParts(通過flHosts參數獲得,作用為提供Docker Client與Server的通信協議以及通信地址)、tlsConfig(通過一系列flag參數獲得,如*flTls、*flTlsVerify,作用為提供安全傳輸層協議的保障)。
隨即分析處理這些flag參數信息,以及配置信息。
在flag.Parse()之后的代碼如下:
if *flVersion { showVersion() return }
不難理解的是,當經過解析flag參數后,若flVersion參數為真時,調用showVersion()顯示版本信息,並從main函數退出;否則的話,繼續往下執行。
if *flDebug { os.Setenv("DEBUG", "1") }
若flDebug參數為真的話,通過os包的中Setenv函數創建一個名為DEBUG的系統環境變量,並將其值設為”1”。繼續往下執行。
if len(flHosts) == 0 { defaultHost := os.Getenv("DOCKER_HOST") if defaultHost == "" || *flDaemon { // If we do not have a host, default to unix socket defaultHost = fmt.Sprintf("unix://%s", api.DEFAULTUNIXSOCKET) } if _, err := api.ValidateHost(defaultHost); err != nil { log.Fatal(err) } flHosts = append(flHosts, defaultHost) }
以上的源碼主要分析內部變量flHosts。flHosts的作用是為Docker Client提供所要連接的host對象,也為Docker Server提供所要監聽的對象。
分析過程中,首先判斷flHosts變量是否長度為0,若是的話,通過os包獲取名為DOCKER_HOST環境變量的值,將其賦值於defaultHost。若defaultHost為空或者flDaemon為真的話,說明目前還沒有一個定義的host對象,則將其默認設置為unix socket,值為api.DEFAULTUNIXSOCKET,該常量位於./docker/api/common.go,值為"/var/run/docker.sock",故defaultHost為”unix:///var/run/docker.sock”。驗證該defaultHost的合法性之后,將defaultHost的值追加至flHost的末尾。繼續往下執行。
if *flDaemon { mainDaemon() return }
若flDaemon參數為真的話,則執行mainDaemon函數,實現Docker Daemon的啟動,若mainDaemon函數執行完畢,則退出main函數,一般mainDaemon函數不會主動終結。由於本章節介紹Docker Client的啟動,故假設flDaemon參數為假,不執行以上代碼塊。繼續往下執行。
if len(flHosts) > 1 { log.Fatal("Please specify only one -H") } protoAddrParts := strings.SplitN(flHosts[0], "://", 2)
以上,若flHosts的長度大於1的話,則拋出錯誤日志。接着將flHosts這個string數組中的第一個元素,進行分割,通過”://”來分割,分割出的兩個部分放入變量protoAddrParts數組中。protoAddrParts的作用為解析出與Docker Server建立通信的協議與地址,為Docker Client創建過程中不可或缺的配置信息之一。
var ( cli *client.DockerCli tlsConfig tls.Config ) tlsConfig.InsecureSkipVerify = true
由於之前已經假設過flDaemon為假,則可以認定main函數的運行是為了Docker Client的創建與執行。在這里創建兩個變量:一個為類型是client.DockerCli指針的對象cli,另一個為類型是tls.Config的對象tlsConfig。並將tlsConfig的InsecureSkipVerify屬性設置為真。TlsConfig對象的創建是為了保障cli在傳輸數據的時候,遵循安全傳輸層協議(TLS)。安全傳輸層協議(TLS) 用於兩個通信應用程序之間保密性與數據完整性。tlsConfig是Docker Client創建過程中可選的配置信息。
// If we should verify the server, we need to load a trusted ca if *flTlsVerify { *flTls = true certPool := x509.NewCertPool() file, err := ioutil.ReadFile(*flCa) if err != nil { log.Fatalf("Couldn't read ca cert %s: %s", *flCa, err) } certPool.AppendCertsFromPEM(file) tlsConfig.RootCAs = certPool tlsConfig.InsecureSkipVerify = false }
若flTlsVerify這個flag參數為真的話,則說明需要驗證server端的安全性,tlsConfig對象需要加載一個受信的ca文件。該ca文件的路徑為*flCA參數的值,最終完成tlsConfig對象中RootCAs屬性的賦值,並將InsecureSkipVerify屬性置為假。
// If tls is enabled, try to load and send client certificates if *flTls || *flTlsVerify { _, errCert := os.Stat(*flCert) _, errKey := os.Stat(*flKey) if errCert == nil && errKey == nil { *flTls = true cert, err := tls.LoadX509KeyPair(*flCert, *flKey) if err != nil { log.Fatalf("Couldn't load X509 key pair: %s. Key encrypted?", err) } tlsConfig.Certificates = []tls.Certificate{cert} } }
如果flTls和flTlsVerify兩個flag參數中有一個為真,則說明需要加載以及發送client端的證書。最終將證書內容交給tlsConfig的Certificates屬性。
至此,flag參數已經全部處理,並已經收集完畢Docker Client所需的配置信息。之后的內容為Docker Client如何實現創建並執行。
3.3. Docker Client的創建
Docker Client的創建其實就是在已有配置參數信息的情況,通過Client包中的NewDockerCli方法創建一個實例cli,源碼實現如下:
if *flTls || *flTlsVerify { cli = client.NewDockerCli(os.Stdin, os.Stdout, os.Stderr, protoAddrParts[0], protoAddrParts[1], &tlsConfig) } else { cli = client.NewDockerCli(os.Stdin, os.Stdout, os.Stderr, protoAddrParts[0], protoAddrParts[1], nil) }
如果flag參數flTls為真或者flTlsVerify為真的話,則說明需要使用TLS協議來保障傳輸的安全性,故創建Docker Client的時候,將TlsConfig參數傳入;否則的話,同樣創建Docker Client,只不過TlsConfig為nil。
關於Client包中的NewDockerCli函數的實現,可以具體參見./docker/api/client/cli.go。
func NewDockerCli(in io.ReadCloser, out, err io.Writer, proto, addr string, tlsConfig *tls.Config) *DockerCli { var ( isTerminal = false terminalFd uintptr scheme = "http" ) if tlsConfig != nil { scheme = "https" } if in != nil { if file, ok := out.(*os.File); ok { terminalFd = file.Fd() isTerminal = term.IsTerminal(terminalFd) } } if err == nil { err = out } return &DockerCli{ proto: proto, addr: addr, in: in, out: out, err: err, isTerminal: isTerminal, terminalFd: terminalFd, tlsConfig: tlsConfig, scheme: scheme, } }
總體而言,創建DockerCli對象較為簡單,較為重要的DockerCli的屬性有proto:傳輸協議;addr:host的目標地址,tlsConfig:安全傳輸層協議的配置。若tlsConfig為不為空,則說明需要使用安全傳輸層協議,DockerCli對象的scheme設置為“https”,另外還有關於輸入,輸出以及錯誤顯示的配置,最終返回該對象。
通過調用NewDockerCli函數,程序最終完成了創建Docker Client,並返回main函數繼續執行。
4. Docker命令執行
main函數執行到目前為止,有以下內容需要為Docker命令的執行服務:創建完畢的Docker Client,docker命令中的請求參數(經flag解析后存放於flag.Arg())。也就是說,需要使用Docker Client來分析docker 命令中的請求參數,並最終發送相應請求給Docker Server。
4.1. Docker Client解析請求命令
Docker Client解析請求命令的工作,在Docker命令執行部分第一個完成,直接進入main函數之后的源碼部分:
if err := cli.Cmd(flag.Args()...); err != nil { if sterr, ok := err.(*utils.StatusError); ok { if sterr.Status != "" { log.Println(sterr.Status) } os.Exit(sterr.StatusCode) } log.Fatal(err) }
查閱以上源碼,可以發現,正如之前所說,首先解析存放於flag.Args()中的具體請求參數,執行的函數為cli對象的Cmd函數。進入./docker/api/client/cli.go的Cmd函數:
// Cmd executes the specified command func (cli *DockerCli) Cmd(args ...string) error { if len(args) > 0 { method, exists := cli.getMethod(args[0]) if !exists { fmt.Println("Error: Command not found:", args[0]) return cli.CmdHelp(args[1:]...) } return method(args[1:]...) } return cli.CmdHelp(args...) }
由代碼注釋可知,Cmd函數執行具體的指令。源碼實現中,首先判斷請求參數列表的長度是否大於0,若不是的話,說明沒有請求信息,返回docker命令的Help信息;若長度大於0的話,說明有請求信息,則首先通過請求參數列表中的第一個元素args[0]來獲取具體的method的方法。如果上述method方法不存在,則返回docker命令的Help信息,若存在的話,調用具體的method方法,參數為args[1]及其之后所有的請求參數。
還是以一個具體的docker命令為例,docker –daemon=false –version=false pull Name。通過以上的分析,可以總結出以下操作流程:
(1) 解析flag參數之后,將docker請求參數”pull”和“Name”存放於flag.Args();
(2) 創建好的Docker Client為cli,cli執行cli.Cmd(flag.Args()…);
在Cmd函數中,通過args[0]也就是”pull”,執行cli.getMethod(args[0]),獲取method的名稱;
(3) 在getMothod方法中,通過處理最終返回method的值為”CmdPull”;
(4) 最終執行method(args[1:]…)也就是CmdPull(args[1:]…)。
4.2. Docker Client執行請求命令
上一節通過一系列的命令解析,最終找到了具體的命令的執行方法,本節內容主要介紹Docker Client如何通過該執行方法處理並發送請求。
由於不同的請求內容不同,執行流程大致相同,本節依舊以一個例子來闡述其中的流程,例子為:docker pull NAME。
Docker Client在執行以上請求命令的時候,會執行CmdPull函數,傳入參數為args[1:]...。源碼具體為./docker/api/client/command.go中的CmdPull函數。
以下逐一分析CmdPull的源碼實現。
(1) 通過cli包中的Subcmd方法定義一個類型為Flagset的對象cmd。
cmd := cli.Subcmd("pull", "NAME[:TAG]", "Pull an image or a repository from the registry")
(2) 給cmd對象定義一個類型為String的flag,名為”#t”或”#-tag”,初始值為空。
tag := cmd.String([]string{"#t", "#-tag"}, "", "Download tagged image in a repository")
(3) 將args參數進行解析,解析過程中,先提取出是否有符合tag這個flag的參數,若有,將其給賦值給tag參數,其余的參數存入cmd.NArg();若無的話,所有的參數存入cmd.NArg()中。
if err := cmd.Parse(args); err != nil { return nil }
(4) 判斷經過flag解析后的參數列表,若參數列表中參數的個數不為1,則說明需要pull多個image,pull命令不支持,則調用錯誤處理方法cmd.Usage(),並返回nil。
if cmd.NArg() != 1 { cmd.Usage() return nil }
(5) 創建一個map類型的變量v,該變量用於存放pull鏡像時所需的url參數;隨后將參數列表的第一個值賦給remote變量,並將remote作為鍵為fromImage的值添加至v;最后若有tag信息的話,將tag信息作為鍵為”tag”的值添加至v。
var ( v = url.Values{} remote = cmd.Arg(0) ) v.Set("fromImage", remote) if *tag == "" { v.Set("tag", *tag) }
(6) 通過remote變量解析出鏡像所在的host地址,以及鏡像的名稱。
remote, _ = parsers.ParseRepositoryTag(remote) // Resolve the Repository name from fqn to hostname + name hostname, _, err := registry.ResolveRepositoryName(remote) if err != nil { return err }
(7) 通過cli對象獲取與Docker Server通信所需要的認證配置信息。
cli.LoadConfigFile() // Resolve the Auth config relevant for this server authConfig := cli.configFile.ResolveAuthConfig(hostname)
(8) 定義一個名為pull的函數,傳入的參數類型為registry.AuthConfig,返回類型為error。函數執行塊中最主要的內容為:cli.stream(……)部分。該部分具體發起了一個給Docker Server的POST請求,請求的url為"/images/create?"+v.Encode(),請求的認證信息為:map[string][]string{"X-Registry-Auth": registryAuthHeader,}。
pull := func(authConfig registry.AuthConfig) error { buf, err := json.Marshal(authConfig) if err != nil { return err } registryAuthHeader := []string{ base64.URLEncoding.EncodeToString(buf), } return cli.stream("POST", "/images/create?"+v.Encode(), nil, cli.out, map[string][]string{ " X-Registry-Auth": registryAuthHeader, }) }
(9) 由於上一個步驟只是定義pull函數,這一步驟具體調用執行pull函數,若成功則最終返回,若返回錯誤,則做相應的錯誤處理。若返回錯誤為401,則需要先登錄,轉至登錄環節,完成之后,繼續執行pull函數,若完成則最終返回。
if err := pull(authConfig); err != nil { if strings.Contains(err.Error(), "Status 401") { fmt.Fprintln(cli.out, "\nPlease login prior to pull:") if err := cli.CmdLogin(hostname); err != nil { return err } authConfig := cli.configFile.ResolveAuthConfig(hostname) return pull(authConfig) } return err }
以上便是pull請求的全部執行過程,其他請求的執行在流程上也是大同小異。總之,請求執行過程中,大多都是將命令行中關於請求的參數進行初步處理,並添加相應的輔助信息,最終通過指定的協議給Docker Server發送Docker Client和Docker Server約定好的API請求。
5. 總結
本文從源碼的角度分析了從docker可執行文件開始,到創建Docker Client,最終發送給Docker Server請求的完整過程。
筆者認為,學習與理解Docker Client相關的源碼實現,不僅可以讓用戶熟練掌握Docker命令的使用,還可以使得用戶在特殊情況下有能力修改Docker Client的源碼,使其滿足自身系統的某些特殊需求,以達到定制Docker Client的目的,最大發揮Docker開放思想的價值。