Docker源码分析(二):Docker Client创建与命令执行
http://www.infoq.com/cn/articles/docker-source-code-analysis-part2
1. 前言
如今,Docker作為業(yè)界領(lǐng)先的輕量級虛擬化容器管理引擎,給全球開發(fā)者提供了一種新穎、便捷的軟件集成測試與部署之道。在團隊開發(fā)軟件時,Docker可以提供可復用的運行環(huán)境、靈活的資源配置、便捷的集成測試方法以及一鍵式的部署方式。可以說,Docker的優(yōu)勢在簡化持續(xù)集成、運維部署方面體現(xiàn)得淋漓盡致,它完全讓開發(fā)者從持續(xù)集成、運維部署方面中解放出來,把精力真正地傾注在開發(fā)上。
然而,把Docker的功能發(fā)揮到極致,并非一件易事。在深刻理解Docker架構(gòu)的情況下,熟練掌握Docker Client的使用也非常有必要。前者可以參閱《Docker源碼分析》系列之Docker架構(gòu)篇,而本文主要針對后者,從源碼的角度分析Docker Client,力求幫助開發(fā)者更深刻的理解Docker Client的具體實現(xiàn),最終更好的掌握Docker Client的使用方法。即本文為《Docker源碼分析》系列的第二篇——Docker Client篇。
2. Docker Client源碼分析章節(jié)安排
本文從源碼的角度,主要分析Docker Client的兩個方面:創(chuàng)建與命令執(zhí)行。整個分析過程可以分為兩個部分:
第一部分分析Docker Client的創(chuàng)建。這部分的分析可分為以下三個步驟:
- 分析如何通過docker命令,解析出命令行flag參數(shù),以及docker命令中的請求參數(shù);
- 分析如何處理具體的flag參數(shù)信息,并收集Docker Client所需的配置信息;
- 分析如何創(chuàng)建一個Docker Client。
第二部分在已有Docker Client的基礎(chǔ)上,分析如何執(zhí)行docker命令。這部分的分析又可分為以下兩個步驟:
- 分析如何解析docker命令中的請求參數(shù),獲取相應請求的類型;
- 分析Docker Client如何執(zhí)行具體的請求命令,最終將請求發(fā)送至Docker Server。
3. Docker Client的創(chuàng)建
Docker Client的創(chuàng)建,實質(zhì)上是Docker用戶通過可執(zhí)行文件docker,與Docker Server建立聯(lián)系的客戶端。以下分三個小節(jié)分別闡述Docker Client的創(chuàng)建流程。
以下為整個docker源代碼運行的流程圖:
上圖通過流程圖的方式,使得讀者更為清晰的了解Docker Client創(chuàng)建及執(zhí)行請求的過程。其中涉及了諸多源代碼中的特有名詞,在下文中會一一解釋與分析。
3.1. Docker命令的flag參數(shù)解析
眾所周知,在Docker的具體實現(xiàn)中,Docker Server與Docker Client均由可執(zhí)行文件docker來完成創(chuàng)建并啟動。那么,了解docker可執(zhí)行文件通過何種方式區(qū)分兩者,就顯得尤為重要。
對于兩者,首先舉例說明其中的區(qū)別。Docker Server的啟動,命令為docker -d或docker --daemon=true;而Docker Client的啟動則體現(xiàn)為docker --daemon=false ps、docker pull NAME等。
可以把以上Docker請求中的參數(shù)分為兩類:第一類為命令行參數(shù),即docker程序運行時所需提供的參數(shù),如: -D、--daemon=true、--daemon=false等;第二類為docker發(fā)送給Docker Server的實際請求參數(shù),如:ps、pull NAME等。
對于第一類,我們習慣將其稱為flag參數(shù),在go語言的標準庫中,同時還提供了一個flag包,方便進行命令行參數(shù)的解析。
交待以上背景之后,隨即進入實現(xiàn)Docker Client創(chuàng)建的源碼,位于./docker/docker/docker.go,該go文件包含了整個Docker的main函數(shù),也就是整個Docker(不論Docker Daemon還是Docker Client)的運行入口。部分main函數(shù)代碼如下:
func main() {if reexec.Init() {return}flag.Parse()// FIXME: validate daemon flags here…… }在以上代碼中,首先判斷reexec.Init()方法的返回值,若為真,則直接退出運行,否則的話繼續(xù)執(zhí)行。查看位于./docker/reexec/reexec.go中reexec.Init()的定義,可以發(fā)現(xiàn)由于在docker運行之前沒有任何的Initializer注冊,故該代碼段執(zhí)行的返回值為假。
緊接著,main函數(shù)通過調(diào)用flag.Parse()解析命令行中的flag參數(shù)。查看源碼可以發(fā)現(xiàn)Docker在./docker/docker/flag.go中定義了多個flag參數(shù),并通過init函數(shù)進行初始化。代碼如下:
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() runsflCa *stringflCert *stringflKey *stringflHosts []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函數(shù)的執(zhí)行。在Golang中init函數(shù)的特性如下:
- init函數(shù)用于程序執(zhí)行前包的初始化工作,比如初始化變量等;
- 每個包可以有多個init函數(shù);
- 包的每一個源文件也可以有多個init函數(shù);
- 同一個包內(nèi)的init函數(shù)的執(zhí)行順序沒有明確的定義;
- 不同包的init函數(shù)按照包導入的依賴關(guān)系決定初始化的順序;
- init函數(shù)不能被調(diào)用,而是在main函數(shù)調(diào)用前自動被調(diào)用。
因此,在main函數(shù)執(zhí)行之前,Docker已經(jīng)定義了諸多flag參數(shù),并對很多flag參數(shù)進行初始化。定義的命令行flag參數(shù)有: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”,該名稱會出現(xiàn)在docker命令中
- flDaemon的默認值為false
- flDaemon的幫助信息為”Enable daemon mode”
- 訪問flDaemon的值時,使用指針* flDaemon解引用訪問
在解析命令行flag參數(shù)時,以下的語言為合法的:
- -d, --daemon
- -d=true, --daemon=true
- -d=”true”, --daemon=”true”
- -d=’true’, --daemon=’true’
當解析到第一個非定義的flag參數(shù)時,命令行flag參數(shù)解析工作結(jié)束。舉例說明,當執(zhí)行docker命令docker --daemon=false --version=false ps時,flag參數(shù)解析主要完成兩個工作:
- 完成命令行flag參數(shù)的解析,名為-daemon和-version的flag參數(shù)flDaemon和flVersion分別獲得相應的值,均為false;
- 遇到第一個非flag參數(shù)的參數(shù)ps時,將ps及其之后所有的參數(shù)存入flag.Args(),以便之后執(zhí)行Docker Client具體的請求時使用。
如需深入學習flag的解析,可以參見源碼命令行參數(shù)flag的解析。
3.2. 處理flag信息并收集Docker Client的配置信息
有了以上flag參數(shù)解析的相關(guān)知識,分析Docker的main函數(shù)就變得簡單易懂很多。通過總結(jié),首先列出源代碼中處理的flag信息以及收集Docker Client的配置信息,然后再一一對此分析:
- 處理的flag參數(shù)有:flVersion、flDebug、flDaemon、flTlsVerify以及flTls;
- 為Docker Client收集的配置信息有:protoAddrParts(通過flHosts參數(shù)獲得,作用為提供Docker Client與Server的通信協(xié)議以及通信地址)、tlsConfig(通過一系列flag參數(shù)獲得,如*flTls、*flTlsVerify,作用為提供安全傳輸層協(xié)議的保障)。
隨即分析處理這些flag參數(shù)信息,以及配置信息。
在flag.Parse()之后的代碼如下:
if *flVersion {showVersion()return}不難理解的是,當經(jīng)過解析flag參數(shù)后,若flVersion參數(shù)為真時,調(diào)用showVersion()顯示版本信息,并從main函數(shù)退出;否則的話,繼續(xù)往下執(zhí)行。
if *flDebug {os.Setenv("DEBUG", "1")}若flDebug參數(shù)為真的話,通過os包的中Setenv函數(shù)創(chuàng)建一個名為DEBUG的系統(tǒng)環(huán)境變量,并將其值設(shè)為”1”。繼續(xù)往下執(zhí)行。
if len(flHosts) == 0 {defaultHost := os.Getenv("DOCKER_HOST")if defaultHost == "" || *flDaemon {// If we do not have a host, default to unix socketdefaultHost = fmt.Sprintf("unix://%s", api.DEFAULTUNIXSOCKET)}if _, err := api.ValidateHost(defaultHost); err != nil {log.Fatal(err)}flHosts = append(flHosts, defaultHost)}以上的源碼主要分析內(nèi)部變量flHosts。flHosts的作用是為Docker Client提供所要連接的host對象,也為Docker Server提供所要監(jiān)聽的對象。
分析過程中,首先判斷flHosts變量是否長度為0,若是的話,通過os包獲取名為DOCKER_HOST環(huán)境變量的值,將其賦值于defaultHost。若defaultHost為空或者flDaemon為真的話,說明目前還沒有一個定義的host對象,則將其默認設(shè)置為unix socket,值為api.DEFAULTUNIXSOCKET,該常量位于./docker/api/common.go,值為"/var/run/docker.sock",故defaultHost為”unix:///var/run/docker.sock”。驗證該defaultHost的合法性之后,將defaultHost的值追加至flHost的末尾。繼續(xù)往下執(zhí)行。
if *flDaemon {mainDaemon()return}若flDaemon參數(shù)為真的話,則執(zhí)行mainDaemon函數(shù),實現(xiàn)Docker Daemon的啟動,若mainDaemon函數(shù)執(zhí)行完畢,則退出main函數(shù),一般mainDaemon函數(shù)不會主動終結(jié)。由于本章節(jié)介紹Docker Client的啟動,故假設(shè)flDaemon參數(shù)為假,不執(zhí)行以上代碼塊。繼續(xù)往下執(zhí)行。
if len(flHosts) > 1 {log.Fatal("Please specify only one -H")}protoAddrParts := strings.SplitN(flHosts[0], "://", 2)以上,若flHosts的長度大于1的話,則拋出錯誤日志。接著將flHosts這個string數(shù)組中的第一個元素,進行分割,通過”://”來分割,分割出的兩個部分放入變量protoAddrParts數(shù)組中。protoAddrParts的作用為解析出與Docker Server建立通信的協(xié)議與地址,為Docker Client創(chuàng)建過程中不可或缺的配置信息之一。
var (cli *client.DockerClitlsConfig tls.Config) tlsConfig.InsecureSkipVerify = true由于之前已經(jīng)假設(shè)過flDaemon為假,則可以認定main函數(shù)的運行是為了Docker Client的創(chuàng)建與執(zhí)行。在這里創(chuàng)建兩個變量:一個為類型是client.DockerCli指針的對象cli,另一個為類型是tls.Config的對象tlsConfig。并將tlsConfig的InsecureSkipVerify屬性設(shè)置為真。TlsConfig對象的創(chuàng)建是為了保障cli在傳輸數(shù)據(jù)的時候,遵循安全傳輸層協(xié)議(TLS)。安全傳輸層協(xié)議(TLS) 用于兩個通信應用程序之間保密性與數(shù)據(jù)完整性。tlsConfig是Docker Client創(chuàng)建過程中可選的配置信息。
// If we should verify the server, we need to load a trusted caif *flTlsVerify {*flTls = truecertPool := 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 = certPooltlsConfig.InsecureSkipVerify = false}若flTlsVerify這個flag參數(shù)為真的話,則說明需要驗證server端的安全性,tlsConfig對象需要加載一個受信的ca文件。該ca文件的路徑為*flCA參數(shù)的值,最終完成tlsConfig對象中RootCAs屬性的賦值,并將InsecureSkipVerify屬性置為假。
// If tls is enabled, try to load and send client certificatesif *flTls || *flTlsVerify {_, errCert := os.Stat(*flCert)_, errKey := os.Stat(*flKey)if errCert == nil && errKey == nil {*flTls = truecert, 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參數(shù)中有一個為真,則說明需要加載以及發(fā)送client端的證書。最終將證書內(nèi)容交給tlsConfig的Certificates屬性。
至此,flag參數(shù)已經(jīng)全部處理,并已經(jīng)收集完畢Docker Client所需的配置信息。之后的內(nèi)容為Docker Client如何實現(xiàn)創(chuàng)建并執(zhí)行。
3.3. Docker Client的創(chuàng)建
Docker Client的創(chuàng)建其實就是在已有配置參數(shù)信息的情況,通過Client包中的NewDockerCli方法創(chuàng)建一個實例cli,源碼實現(xiàn)如下:
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參數(shù)flTls為真或者flTlsVerify為真的話,則說明需要使用TLS協(xié)議來保障傳輸?shù)陌踩?#xff0c;故創(chuàng)建Docker Client的時候,將TlsConfig參數(shù)傳入;否則的話,同樣創(chuàng)建Docker Client,只不過TlsConfig為nil。
關(guān)于Client包中的NewDockerCli函數(shù)的實現(xiàn),可以具體參見./docker/api/client/cli.go。
func NewDockerCli(in io.ReadCloser, out, err io.Writer, proto, addr string, tlsConfig *tls.Config) *DockerCli {var (isTerminal = falseterminalFd uintptrscheme = "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,} }總體而言,創(chuàng)建DockerCli對象較為簡單,較為重要的DockerCli的屬性有proto:傳輸協(xié)議;addr:host的目標地址,tlsConfig:安全傳輸層協(xié)議的配置。若tlsConfig為不為空,則說明需要使用安全傳輸層協(xié)議,DockerCli對象的scheme設(shè)置為“https”,另外還有關(guān)于輸入,輸出以及錯誤顯示的配置,最終返回該對象。
通過調(diào)用NewDockerCli函數(shù),程序最終完成了創(chuàng)建Docker Client,并返回main函數(shù)繼續(xù)執(zhí)行。
4. Docker命令執(zhí)行
main函數(shù)執(zhí)行到目前為止,有以下內(nèi)容需要為Docker命令的執(zhí)行服務:創(chuàng)建完畢的Docker Client,docker命令中的請求參數(shù)(經(jīng)flag解析后存放于flag.Arg())。也就是說,需要使用Docker Client來分析docker 命令中的請求參數(shù),并最終發(fā)送相應請求給Docker Server。
4.1. Docker Client解析請求命令
Docker Client解析請求命令的工作,在Docker命令執(zhí)行部分第一個完成,直接進入main函數(shù)之后的源碼部分:
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)}查閱以上源碼,可以發(fā)現(xiàn),正如之前所說,首先解析存放于flag.Args()中的具體請求參數(shù),執(zhí)行的函數(shù)為cli對象的Cmd函數(shù)。進入./docker/api/client/cli.go的Cmd函數(shù):
// 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函數(shù)執(zhí)行具體的指令。源碼實現(xiàn)中,首先判斷請求參數(shù)列表的長度是否大于0,若不是的話,說明沒有請求信息,返回docker命令的Help信息;若長度大于0的話,說明有請求信息,則首先通過請求參數(shù)列表中的第一個元素args[0]來獲取具體的method的方法。如果上述method方法不存在,則返回docker命令的Help信息,若存在的話,調(diào)用具體的method方法,參數(shù)為args[1]及其之后所有的請求參數(shù)。
還是以一個具體的docker命令為例,docker –daemon=false –version=false pull Name。通過以上的分析,可以總結(jié)出以下操作流程:
(1) 解析flag參數(shù)之后,將docker請求參數(shù)”pull”和“Name”存放于flag.Args();
(2) 創(chuàng)建好的Docker Client為cli,cli執(zhí)行cli.Cmd(flag.Args()…);
在Cmd函數(shù)中,通過args[0]也就是”pull”,執(zhí)行cli.getMethod(args[0]),獲取method的名稱;
(3) 在getMothod方法中,通過處理最終返回method的值為”CmdPull”;
(4) 最終執(zhí)行method(args[1:]…)也就是CmdPull(args[1:]…)。
4.2. Docker Client執(zhí)行請求命令
上一節(jié)通過一系列的命令解析,最終找到了具體的命令的執(zhí)行方法,本節(jié)內(nèi)容主要介紹Docker Client如何通過該執(zhí)行方法處理并發(fā)送請求。
由于不同的請求內(nèi)容不同,執(zhí)行流程大致相同,本節(jié)依舊以一個例子來闡述其中的流程,例子為:docker pull NAME。
Docker Client在執(zhí)行以上請求命令的時候,會執(zhí)行CmdPull函數(shù),傳入?yún)?shù)為args[1:]...。源碼具體為./docker/api/client/command.go中的CmdPull函數(shù)。
以下逐一分析CmdPull的源碼實現(xiàn)。
(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參數(shù)進行解析,解析過程中,先提取出是否有符合tag這個flag的參數(shù),若有,將其給賦值給tag參數(shù),其余的參數(shù)存入cmd.NArg();若無的話,所有的參數(shù)存入cmd.NArg()中。
if err := cmd.Parse(args); err != nil { return nil }(4)?判斷經(jīng)過flag解析后的參數(shù)列表,若參數(shù)列表中參數(shù)的個數(shù)不為1,則說明需要pull多個image,pull命令不支持,則調(diào)用錯誤處理方法cmd.Usage(),并返回nil。
if cmd.NArg() != 1 { cmd.Usage() return nil}(5)?創(chuàng)建一個map類型的變量v,該變量用于存放pull鏡像時所需的url參數(shù);隨后將參數(shù)列表的第一個值賦給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 + namehostname, _, err := registry.ResolveRepositoryName(remote)if err != nil {return err}?
(7)?通過cli對象獲取與Docker Server通信所需要的認證配置信息。
cli.LoadConfigFile()// Resolve the Auth config relevant for this serverauthConfig := cli.configFile.ResolveAuthConfig(hostname)(8)?定義一個名為pull的函數(shù),傳入的參數(shù)類型為registry.AuthConfig,返回類型為error。函數(shù)執(zhí)行塊中最主要的內(nèi)容為:cli.stream(……)部分。該部分具體發(fā)起了一個給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函數(shù),這一步驟具體調(diào)用執(zhí)行pull函數(shù),若成功則最終返回,若返回錯誤,則做相應的錯誤處理。若返回錯誤為401,則需要先登錄,轉(zhuǎn)至登錄環(huán)節(jié),完成之后,繼續(xù)執(zhí)行pull函數(shù),若完成則最終返回。
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請求的全部執(zhí)行過程,其他請求的執(zhí)行在流程上也是大同小異。總之,請求執(zhí)行過程中,大多都是將命令行中關(guān)于請求的參數(shù)進行初步處理,并添加相應的輔助信息,最終通過指定的協(xié)議給Docker Server發(fā)送Docker Client和Docker Server約定好的API請求。
5. 總結(jié)
本文從源碼的角度分析了從docker可執(zhí)行文件開始,到創(chuàng)建Docker Client,最終發(fā)送給Docker Server請求的完整過程。
筆者認為,學習與理解Docker Client相關(guān)的源碼實現(xiàn),不僅可以讓用戶熟練掌握Docker命令的使用,還可以使得用戶在特殊情況下有能力修改Docker Client的源碼,使其滿足自身系統(tǒng)的某些特殊需求,以達到定制Docker Client的目的,最大發(fā)揮Docker開放思想的價值。
轉(zhuǎn)載于:https://www.cnblogs.com/davidwang456/articles/9579573.html
總結(jié)
以上是生活随笔為你收集整理的Docker源码分析(二):Docker Client创建与命令执行的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 几张图帮你理解 docker 基本原理及
- 下一篇: Docker源码分析(三):Docker