Docker源碼分析(二):Docker Client創建與命令執行


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開放思想的價值。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM