萬字長文:徹底搞懂容器鏡像構建

大家好,我是張晉濤。

我將在這篇文章中深入 Docker 的源碼,與你聊聊鏡像構建的原理。

Docker 架構

這裏我們先從宏觀上對 Docker 有個大概的認識,它整體上是個 C/S 架構;我們平時使用的 docker 命令就是它的 CLI 客戶端,而它的服務端是 dockerd 在 Linux 系統中,通常我們是使用 systemd 進行管理,所以我們可以使用 systemctl start docker 來啓動服務。(但是請注意,dockerd 是否能運行與 systemd 並無任何關係,你可以像平時執行一個普通的二進制程序一樣,直接通過 dockerd 來啓動服務,注意需要 root 權限)

實際上也就是

(圖片來源:docker overview)

docker CLI 與 dockerd 的交互是通過 REST API 來完成的,當我們執行 docker version 的時候過濾 API 可以看到如下輸出:

1➜  ~ docker version |grep API
2 API version:       1.41
3  API version:      1.41 (minimum version 1.12)

上面一行是 docker CLI 的 API 版本,下面則代表了 dockerd 的 API 版本,它的後面還有個括號,是因爲 Docker 具備了很良好的兼容性,這裏表示它最小可兼容的 API 版本是 1.12 。

對於我們進行 C/S 架構的項目開發而言,一般都是 API 先行, 所以我們先來看下 API 的部分。

當然,本文的主體是構建系統相關的,所以我們就直接來看構建相關的 API 即可。

接下來會說 CLI,代碼以 v20.10.5 爲準。最後說服務端 Dockerd 。

API

Docker 維護團隊在每個版本正式發佈之後,都會將 API 文檔發佈出來,可以通過 Docker Engine API 在線瀏覽,也可以自行構建 API 文檔。

首先 clone Docker 的源代碼倉庫, 進入項目倉庫內執行 make swagger-docs 即可在啓動一個容器同時將端口暴露至本地的 9000 端口, 你可以直接通過 http://127.0.0.1:9000 訪問本地的 API 文檔。

1(MoeLove) ➜  git clone https://github.com/docker/docker.git docker
2(MoeLove) ➜  cd docker
3(MoeLove) ➜  docker git:(master) git checkout -b v20.10.5 v20.10.5
4(MoeLove) ➜  docker git:(v20.10.5) make swagger-docs
5API docs preview will be running at http://localhost:9000

打開 http://127.0.0.1:9000/#operation/ImageBuild 這個地址就可以看到 1.41 版本的構建鏡像所需的 API 了。我們對此 API 進行下分析。

請求地址和方法

接口地址是 /v1.41/build 方法是 POST ,我們可以使用一個較新版本的 curl 工具來驗證下此接口(需要使用 --unix-socket 連接 Docker 監聽的 UNIX Domain Socket )。dockerd 默認情況下監聽在 /var/run/docker.sock ,當然你也可以給 dockerd 傳遞 --host 參數用於監聽 HTTP 端口或者其他路徑的 unix socket .

1/ # curl -X POST --unix-socket /var/run/docker.sock  localhost/v1.41/build 
2{"message":"Cannot locate specified Dockerfile: Dockerfile"}

從上面的輸出我們可以看到,我們確實訪問到了該接口,同時該接口的響應是提示需要 Dockerfile .

請求體

A tar archive compressed with one of the following algorithms: identity (no compression), gzip, bzip2, xz. string

請求體是一個 tar 歸檔文件,可選擇無壓縮、gzipbzip2xz 壓縮等形式。關於這幾種壓縮格式就不再展開介紹了,但值得注意的是 如果使用了壓縮,則傳輸體積會變小,即網絡消耗會相應減少。但壓縮 / 解壓縮需要耗費 CPU 等計算資源 這在我們對大規模鏡像構建做優化時是個值得權衡的點。

請求頭

因爲要發送的是個 tar 歸檔文件,Content-type 默認是 application/x-tar 。另一個會發送的頭是 X-Registry-Config,這是一個由 Base64 編碼後的 Docker Registry 的配置信息,內容與 $HOME/.docker/config.json 中的 auths 內的信息一致。

這些配置信息,在你執行 docker login 後會自動寫入到 $HOME/.docker/config.json 文件內的。這些信息被傳輸到 dockerd 在構建過程中作爲拉取鏡像的認證信息使用。

請求參數

最後就是請求參數了,參數有很多,通過 docker build --help 基本都可以看到對應含義的,這裏不再一一展開了,後面會有一些關鍵參數的介紹。

小結

上面我們介紹了 Docker 構建鏡像相關的 API,我們可以直接訪問 Docker Engine 的 API 文檔。或者通過源碼倉庫,自己來構建一個本地的 API 文檔服務,使用瀏覽器進行訪問。

通過 API 我們也知道了該接口所需的請求體是一個 tar 歸檔文件(可選擇壓縮算法進行壓縮),同時它的請求頭中會攜帶用戶在鏡像倉庫中的認證信息。這提醒我們, 如果在使用遠程 Dockerd 構建時,請注意安全,儘量使用 tls 進行加密,以免數據泄漏。

CLI

API 已經介紹完了,我們來看下 docker CLI,我以前的文章中介紹過現在 Docker 中有兩個構建系統,一個是 v1 版本的 builder 另一個是 v2 版本的即 BuildKit 我們來分別深入源碼來看看在構建鏡像時,他們各自的行爲吧。

準備代碼

CLI 的代碼倉庫在 github.com/docker/cli 本文的代碼以 v20.10.5 爲準。

通過以下步驟使用此版本的代碼:

1(MoeLove) ➜  git clone https://github.com/docker/cli.git
2(MoeLove) ➜  cd cli
3(MoeLove) ➜  cli git:(master) git checkout -b v20.10.5 v20.10.5

逐步分解

docker 是我們所使用的客戶端工具,用於與 dockerd 進行交互。關於構建相關的部分, 我們所熟知的便是 docker build 或者是 docker image build,在 19.03 中新增的是 docker builder build ,但其實他們都是同一個只是做了個 alias 罷了:

1if v, ok := aliasMap["builder"]; ok {
2    aliases = append(aliases,
3        [2][]string{{"build"}, {v, "build"}},
4        [2][]string{{"image", "build"}, {v, "build"}},
5    )
6}

真正的入口函數其實在 cli/command/image/build.go;區分如何調用的邏輯如下:

 1func runBuild(dockerCli command.Cli, options buildOptions) error {
 2	buildkitEnabled, err := command.BuildKitEnabled(dockerCli.ServerInfo())
 3	if err != nil {
 4		return err
 5	}
 6	if buildkitEnabled {
 7		return runBuildBuildKit(dockerCli, options)
 8	}
 9    
10}

這裏就是判斷下是否支持 buildkit

 1func BuildKitEnabled(si ServerInfo) (bool, error) {
 2	buildkitEnabled := si.BuildkitVersion == types.BuilderBuildKit
 3	if buildkitEnv := os.Getenv("DOCKER_BUILDKIT"); buildkitEnv != "" {
 4		var err error
 5		buildkitEnabled, err = strconv.ParseBool(buildkitEnv)
 6		if err != nil {
 7			return false, errors.Wrap(err, "DOCKER_BUILDKIT environment variable expects boolean value")
 8		}
 9	}
10	return buildkitEnabled, nil
11}

當然,從這裏可以得到兩個信息:

1{
2  "features": {
3    "buildkit": true
4  }
5}

從上面的介紹也看到了,對於原本默認的 builder 而言, 入口邏輯在 runBuild 中, 而對於使用 buildkit 的則是 runBuildBuildKit 接下來,我們對兩者進行逐步分解。

builder v1

runBuild 函數中,大致經歷了以下階段:

參數處理

最開始的部分是一些對參數的處理和校驗。

因爲如果我們指定了 compress 的話,則 CLI 會使用 gzip 將構建上下文進行壓縮,這樣也就沒法很好的通過 stream 的模式來處理構建的上下文了。

當然你也可能會想,從技術上來講,壓縮和流式沒有什麼必然的衝突,是可實現的。事實的確如此,如果從技術的角度上來講兩者並非完全不能一起存在,無非就是增加解壓縮的動作。但是當開啓 stream 模式,對每個文件都進行壓縮和解壓的操作那將會是很大的資源浪費,同時也增加了其複雜度,所以在 CLI 中便直接進行了限制,不允許同時使用 compressstream

在進行構建時,如果我們將 Dockerfile 的名字傳遞爲 - 時,表示從 stdin 讀取其內容。

例如,某個目錄下有三個文件 foo barDockerfile,通過管道將 Dockerfile 的內容通過 stdin 傳遞給 docker build

 1(MoeLove) ➜  x ls
 2bar  Dockerfile  foo
 3(MoeLove) ➜  x cat Dockerfile | DOCKER_BUILDKIT=0 docker build -f - .
 4Sending build context to Docker daemon  15.41kB
 5Step 1/3 : FROM scratch
 6 ---> 
 7Step 2/3 : COPY foo foo
 8 ---> a2af45d66bb5
 9Step 3/3 : COPY bar bar
10 ---> cc803c675dd2
11Successfully built cc803c675dd2

可以看到通過 stdin 傳遞 Dockerfile 的方式能成功的構建鏡像。接下來我們嘗試通過 stdinbuild context 傳遞進去。

 1(MoeLove) ➜  x tar -cvf x.tar foo bar Dockerfile 
 2foo                                                     
 3bar                         
 4Dockerfile
 5(MoeLove) ➜  x cat x.tar| DOCKER_BUILDKIT=0 docker build -f Dockerfile -
 6Sending build context to Docker daemon  10.24kB
 7Step 1/3 : FROM scratch
 8 ---> 
 9Step 2/3 : COPY foo foo
10 ---> 09319712e220
11Step 3/3 : COPY bar bar
12 ---> ce88644a7395
13Successfully built ce88644a7395

可以看到通過 stdin 傳遞 build context 的方式也可以成功構建鏡像。

但如果 Dockerfile 的名稱與構建的上下文都指定爲 -docker build -f - - 時,會發生什麼呢?

1(MoeLove) ➜  x DOCKER_BUILDKIT=0 docker build -f - -             
2invalid argument: can't use stdin for both build context and dockerfile

就會報錯了。所以, 不能同時使用 stdin 讀取 Dockerfilebuild context

 1switch {
 2case options.contextFromStdin():
 3    
 4case isLocalDir(specifiedContext):
 5    
 6case urlutil.IsGitURL(specifiedContext):
 7    
 8case urlutil.IsURL(specifiedContext):
 9    
10default:
11    return errors.Errorf("unable to prepare context: path %q not found", specifiedContext)
12}

stdin 傳入,上文已經演示過了,傳遞給 stdin 的是 tar 歸檔文件。當然也可以是指定一個具體的 PATH,我們通常使用的 docker build . 便是這種用法;

或者可以指定一個 git 倉庫的地址,CLI 會調用 git 命令將倉庫 clone 至一個臨時目錄,進行使用;

最後一種是,給定一個 URL 地址,該地址可以是 一個具體的 Dockerfile 文件地址 或者是 一個 tar 歸檔文件的下載地址

這幾種基本就是字面上的區別,至於 CLI 的行爲差異,主要是最後一種,當 URL 地址是一個具體的 Dockerfile 文件地址,在這種情況下 build context 相當於只有 Dockerfile 自身,所以並不能使用 COPY 之類的指定,至於 ADD 也只能使用可訪問的外部地址。

我在之前的文章中有分享過相關的內容。這裏我們看看它的實現邏輯。

 1func ReadDockerignore(contextDir string) ([]string, error) {
 2	var excludes []string
 3
 4	f, err := os.Open(filepath.Join(contextDir, ".dockerignore"))
 5	switch {
 6	case os.IsNotExist(err):
 7		return excludes, nil
 8	case err != nil:
 9		return nil, err
10	}
11	defer f.Close()
12
13	return dockerignore.ReadAll(f)
14}

最後 CLI 會將 build context 中的內容經過 .dockerignore 過濾後,打包成爲真正的 build context 即真正的構建上下文。這也是爲什麼有時候你發現自己明明在 Dockerfile 裏面寫了 COPY xx xx 但是最後沒有發現該文件的情況。 很可能就是被 .dockerignore 給忽略掉了。 這樣有利於優化 CLI 與 dockerd 之間的傳輸壓力之類的。

這與前面 API 部分所描述的內容基本是一致的。將認證信息通過 X-Registry-Config 頭傳遞給 dockerd 用於在需要拉取鏡像時進行身份校驗。

當一切所需的校驗和信息都準備就緒之後,則開始調用 dockerCli.Client 封裝的 API 接口,將請求發送至 dockerd,進行實際的構建任務。

1response, err := dockerCli.Client().ImageBuild(ctx, body, buildOptions)
2if err != nil {
3    if options.quiet {
4        fmt.Fprintf(dockerCli.Err(), "%s", progBuff)
5    }
6    cancel()
7    return err
8}
9defer response.Body.Close()

到這裏其實一次構建的過程中 CLI 所處理的流程就基本結束了,之後便是按照傳遞的參數進行進度的輸出或是將鏡像 ID 寫入到文件之類的。 這部分就不進行展開了。

小結

整個過程大致如下圖:

從入口函數 runBuild 開始,經過判斷是否支持 buildkit ,如果不支持 buildkit 則繼續使用 v1 的 builder。接下來讀取各類參數,按照不同的參數執行各類不同的處理邏輯。這裏需要注意的就是 Dockerfilebuild context 都可支持從文件或者 stdin 等讀入,具體使用時,需要注意。另外 .dockerignore 文件可過濾掉 build context 中的一些文件,在使用時,可通過此方法進行構建效率的優化,當然也需要注意,在通過 URL 獲取 Dockerfile 的時候,是不存在 build context 的,所以類似 COPY 這樣的命令也就無法使用了。當所有的 build context 和參數都準備就緒後,接下來調用封裝好的客戶端,將這些請求按照本文開始之初介紹的 API 發送給 dockerd ,由其進行真正的構建邏輯。

最後當構建結束後,CLI 根據參數決定是否要顯示構建進度或者結果。

buildkit

接下來我們來看看 buildkit 如何來執行構建,方法入口與 builder 一致,但是在 buildkitEnabled 處,由於開啓了 buildkit 支持,所以跳轉到了 runBuildBuildKit

 1func runBuild(dockerCli command.Cli, options buildOptions) error {
 2	buildkitEnabled, err := command.BuildKitEnabled(dockerCli.ServerInfo())
 3	if err != nil {
 4		return err
 5	}
 6	if buildkitEnabled {
 7		return runBuildBuildKit(dockerCli, options)
 8	}
 9    
10}

創建會話

但是與 builder 不同的是,這裏先執行了一次 trySession 函數。

1s, err := trySession(dockerCli, options.context, false)
2if err != nil {
3    return err
4}
5if s == nil {
6    return errors.Errorf("buildkit not supported by daemon")
7}

這個函數是用來做什麼的呢?我們來找到該函數所在的文件 cli/command/image/build_session.go

 1func trySession(dockerCli command.Cli, contextDir string, forStream bool) (*session.Session, error) {
 2	if !isSessionSupported(dockerCli, forStream) {
 3		return nil, nil
 4	}
 5	sharedKey := getBuildSharedKey(contextDir)
 6	s, err := session.NewSession(context.Background(), filepath.Base(contextDir), sharedKey)
 7	if err != nil {
 8		return nil, errors.Wrap(err, "failed to create session")
 9	}
10	return s, nil
11}

當然還包括它其中最主要的 isSessionSupported 函數:

1func isSessionSupported(dockerCli command.Cli, forStream bool) bool {
2	if !forStream && versions.GreaterThanOrEqualTo(dockerCli.Client().ClientVersion(), "1.39") {
3		return true
4	}
5	return dockerCli.ServerInfo().HasExperimental && versions.GreaterThanOrEqualTo(dockerCli.Client().ClientVersion(), "1.31")
6}

isSessionSupported 很明顯是用於判斷是否支持 Session,這裏由於我們會傳入 forStreamfalse ,而且當前的 API 版本是 1.41 比 1.39 大,所以此函數會返回 true 。其實在 builder 中也執行過相同的邏輯,只不過是在傳遞了 --stream 參數後,使用 Session 獲取一個長連接以達到 stream 的處理能力。

這也就是爲什麼會有下面 dockerCli.ServerInfo().HasExperimental && versions.GreaterThanOrEqualTo(dockerCli.Client().ClientVersion(), "1.31") 這個判斷存在的原因了。

當確認支持 Session 時,則會調用 session.NewSession 創建一個新的會話。

 1func NewSession(ctx context.Context, name, sharedKey string) (*Session, error) {
 2	id := identity.NewID()
 3
 4	var unary []grpc.UnaryServerInterceptor
 5	var stream []grpc.StreamServerInterceptor
 6
 7	serverOpts := []grpc.ServerOption{}
 8	if span := opentracing.SpanFromContext(ctx); span != nil {
 9		tracer := span.Tracer()
10		unary = append(unary, otgrpc.OpenTracingServerInterceptor(tracer, traceFilter()))
11		stream = append(stream, otgrpc.OpenTracingStreamServerInterceptor(span.Tracer(), traceFilter()))
12	}
13
14	unary = append(unary, grpcerrors.UnaryServerInterceptor)
15	stream = append(stream, grpcerrors.StreamServerInterceptor)
16
17	if len(unary) == 1 {
18		serverOpts = append(serverOpts, grpc.UnaryInterceptor(unary[0]))
19	} else if len(unary) > 1 {
20		serverOpts = append(serverOpts, grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(unary...)))
21	}
22
23	if len(stream) == 1 {
24		serverOpts = append(serverOpts, grpc.StreamInterceptor(stream[0]))
25	} else if len(stream) > 1 {
26		serverOpts = append(serverOpts, grpc.StreamInterceptor(grpc_middleware.ChainStreamServer(stream...)))
27	}
28
29	s := &Session{
30		id:         id,
31		name:       name,
32		sharedKey:  sharedKey,
33		grpcServer: grpc.NewServer(serverOpts...),
34	}
35
36	grpc_health_v1.RegisterHealthServer(s.grpcServer, health.NewServer())
37
38	return s, nil
39}

它創建了一個長連接會話,接下來的操作也都會基於這個會話來做。接下來的操作與 builder 大體一致,先判斷 context 是以哪種形式提供的;當然它也與 builder 一樣,是不允許同時從 stdin 獲取 Dockerfilebuild context

 1switch {
 2case options.contextFromStdin():
 3    
 4case isLocalDir(options.context):
 5    
 6case urlutil.IsGitURL(options.context):
 7    
 8case urlutil.IsURL(options.context):
 9    
10default:
11    return errors.Errorf("unable to prepare context: path %q not found", options.context)
12}

這裏的處理邏輯與 v1 builder 保持一致的原因,主要在於用戶體驗上,當前的 CLI 的功能已經基本穩定,用戶也已經習慣,所以即使是增加了 BuildKit 也並沒有對主體的操作邏輯造成多大改變。

選擇輸出模式

BuildKit 支持了三種不同的輸出模式 local tar 和正常模式(即存儲在 dockerd 中), 格式爲 -o type=local,dest=path 如果需要將構建的鏡像進行分發,或是需要進行鏡像內文件瀏覽的話,使用這個方式也是很方便的。

 1outputs, err := parseOutputs(options.outputs)
 2if err != nil {
 3    return errors.Wrapf(err, "failed to parse outputs")
 4}
 5
 6for _, out := range outputs {
 7    switch out.Type {
 8    case "local":
 9        
10    case "tar":
11        
12    }
13}

其實它支持的模式還有第 4 種, 名爲 cacheonly 但它並不會像前面提到的三種模式一樣,有個很直觀的輸出,而且用的人可能會很少,所以就沒有單獨寫了。

讀取認證信息

1dockerAuthProvider := authprovider.NewDockerAuthProvider(os.Stderr)
2s.Allow(dockerAuthProvider)

這裏的行爲與上面提到的 builder 的行爲基本一致,這裏主要有兩個需要注意的點:

1func (s *Session) Allow(a Attachable) {
2	a.Register(s.grpcServer)
3}

這個 Allow 函數就是允許通過上面提到的 grpc 會話訪問給定的服務。

authproviderBuildKit 提供的一組抽象接口集合,通過它們可以訪問到機器上的配置文件,進而拿到認證信息,行爲與 builder 基本一致。

高階特性:mount secretsssh

我其他的文章講過這兩種高階特性的使用了,本篇中就不再多使用進行過多說明了,只來大體看下該部分的原理和邏輯。

secretsprovidersshprovider 都是 buildkit 在提供的,利用這兩種特性可以在 Docker 鏡像進行構建時更加安全,且更加靈活。

 1func parseSecretSpecs(sl []string) (session.Attachable, error) {
 2	fs := make([]secretsprovider.Source, 0, len(sl))
 3	for _, v := range sl {
 4		s, err := parseSecret(v)
 5		if err != nil {
 6			return nil, err
 7		}
 8		fs = append(fs, *s)
 9	}
10	store, err := secretsprovider.NewStore(fs)
11	if err != nil {
12		return nil, err
13	}
14	return secretsprovider.NewSecretProvider(store), nil
15}

關於 secrets 方面,最終的 parseSecret 會完成格式相關的校驗之類的;

1func parseSSHSpecs(sl []string) (session.Attachable, error) {
2	configs := make([]sshprovider.AgentConfig, 0, len(sl))
3	for _, v := range sl {
4		c := parseSSH(v)
5		configs = append(configs, *c)
6	}
7	return sshprovider.NewSSHAgentProvider(configs)
8}

而關於 ssh 方面,則與上方的 secrets 基本一致,通過 sshprovider 允許進行 ssh 轉發之類的,這裏不再深入展開了。

調用 API 發送構建請求

這裏主要有兩種情況。

 1buildID := stringid.GenerateRandomID()
 2if body != nil {
 3    eg.Go(func() error {
 4        buildOptions := types.ImageBuildOptions{
 5            Version: types.BuilderBuildKit,
 6            BuildID: uploadRequestRemote + ":" + buildID,
 7        }
 8
 9        response, err := dockerCli.Client().ImageBuild(context.Background(), body, buildOptions)
10        if err != nil {
11            return err
12        }
13        defer response.Body.Close()
14        return nil
15    })
16}

它會執行上述這部分邏輯,但同時也要注意,這是使用的是 Golang 的 goroutine,到這裏也並不是結束,這部分代碼之後的代碼也同樣會被執行。這就說到了另一種情況了 (通常情況)。

 1eg.Go(func() error {
 2    defer func() {
 3        s.Close()
 4    }()
 5
 6    buildOptions := imageBuildOptions(dockerCli, options)
 7    buildOptions.Version = types.BuilderBuildKit
 8    buildOptions.Dockerfile = dockerfileName
 9    buildOptions.RemoteContext = remote
10    buildOptions.SessionID = s.ID()
11    buildOptions.BuildID = buildID
12    buildOptions.Outputs = outputs
13    return doBuild(ctx, eg, dockerCli, stdoutUsed, options, buildOptions)
14})

doBuild 會做些什麼呢?它同樣也調用了 API 向 dockerd 發起了構建請求。

1func doBuild(ctx context.Context, eg *errgroup.Group, dockerCli command.Cli, stdoutUsed bool, options buildOptions, buildOptions types.ImageBuildOptions, at session.Attachable) (finalErr error) {
2	response, err := dockerCli.Client().ImageBuild(context.Background(), nil, buildOptions)
3	if err != nil {
4		return err
5	}
6	defer response.Body.Close()
7    
8}

從以上的介紹我們可以先做個小的總結。 build contextstdin 讀,並且是個 tar 歸檔時,實際會向 dockerd 發起兩次 /build 請求 而一般情況下只會發送一次請求。

那這裏會有什麼差別呢?此處先不展開,我們留到下面講 dockerd 服務端的時候再來解釋。

小結

這裏我們對開啓了 buildkit 支持的 CLI 構建鏡像的過程進行了分析,大致過程如下:

從入口函數 runBuild 開始,判斷是否支持 buildkit ,如果支持 buildkit 則調用 runBuildBuildKit。與 v1 的 builder 不同的是,開啓了 buildkit 後,會首先創建一個長連接的會話,並一直保持。其次,與 builder 相同,判斷 build context 的來源,格式之類的,校驗參數等。當然,buildkit 支持三種不同的輸出格式 tar, local 或正常的存儲於 Docker 的目錄中。另外是在 buildkit 中新增的高階特性,可以配置 secretsssh 密鑰等功能。最後,再調用 API 與 dockerd 交互完成鏡像的構建。

服務端:dockerd

上面分別介紹了 API, CLI 的 v1 builderbuildkit ,接下來我們看看服務端的具體原理和邏輯。

Client 函數

還記得上面部分中最後通過 API 與服務端交互的 ImageBuild 函數嗎?在開始 dockerd 的介紹前,我們來看下這個客戶端接口的具體內容。

 1func (cli *Client) ImageBuild(ctx context.Context, buildContext io.Reader, options types.ImageBuildOptions) (types.ImageBuildResponse, error) {
 2	query, err := cli.imageBuildOptionsToQuery(options)
 3	if err != nil {
 4		return types.ImageBuildResponse{}, err
 5	}
 6
 7	headers := http.Header(make(map[string][]string))
 8	buf, err := json.Marshal(options.AuthConfigs)
 9	if err != nil {
10		return types.ImageBuildResponse{}, err
11	}
12	headers.Add("X-Registry-Config", base64.URLEncoding.EncodeToString(buf))
13
14	headers.Set("Content-Type", "application/x-tar")
15
16	serverResp, err := cli.postRaw(ctx, "/build", query, buildContext, headers)
17	if err != nil {
18		return types.ImageBuildResponse{}, err
19	}
20
21	osType := getDockerOS(serverResp.header.Get("Server"))
22
23	return types.ImageBuildResponse{
24		Body:   serverResp.body,
25		OSType: osType,
26	}, nil
27}

沒有什麼太特別的地方,行爲與 API 一致。 通過這裏我們確認它確實訪問的 /build 接口,所以,我們來看看 dockerd/build 接口,看看它在構建鏡像的時候做了什麼。

dockerd

由於本文集中討論的是構建系統相關的部分,所以也就不再過多贅述與構建無關的內容了,我們直接來看,當 CLI 通過 /build 接口發送請求後,會發生什麼。

先來看該 API 的入口:

1func (r *buildRouter) initRoutes() {
2	r.routes = []router.Route{
3		router.NewPostRoute("/build", r.postBuild),
4		router.NewPostRoute("/build/prune", r.postPrune),
5		router.NewPostRoute("/build/cancel", r.postCancel),
6	}
7}

dockerd 提供了一套類 RESTful 的後端接口服務,處理邏輯的入口便是上面的 postBuild 函數。

該函數的內容較多,我們來分解下它的主要步驟。

1buildOptions, err := newImageBuildOptions(ctx, r)
2if err != nil {
3    return errf(err)
4}

newImageBuildOptions 函數就是構造構建參數的,將通過 API 提交過來的參數轉換爲構建動作實際需要的參數形式。

1buildOptions.AuthConfigs = getAuthConfigs(r.Header)

getAuthConfigs 函數用於從請求頭拿到認證信息

1imgID, err := br.backend.Build(ctx, backend.BuildConfig{
2    Source:         body,
3    Options:        buildOptions,
4    ProgressWriter: buildProgressWriter(out, wantAux, createProgressReader),
5})
6if err != nil {
7    return errf(err)
8}

這裏就需要注意了: 真正的構建過程要開始了。使用 backend 的 Build 函數來完成真正的構建過程

 1func (b *Backend) Build(ctx context.Context, config backend.BuildConfig) (string, error) {
 2	options := config.Options
 3	useBuildKit := options.Version == types.BuilderBuildKit
 4
 5	tagger, err := NewTagger(b.imageComponent, config.ProgressWriter.StdoutFormatter, options.Tags)
 6	if err != nil {
 7		return "", err
 8	}
 9
10	var build *builder.Result
11	if useBuildKit {
12		build, err = b.buildkit.Build(ctx, config)
13		if err != nil {
14			return "", err
15		}
16	} else {
17		build, err = b.builder.Build(ctx, config)
18		if err != nil {
19			return "", err
20		}
21	}
22
23	if build == nil {
24		return "", nil
25	}
26
27	var imageID = build.ImageID
28	if options.Squash {
29		if imageID, err = squashBuild(build, b.imageComponent); err != nil {
30			return "", err
31		}
32		if config.ProgressWriter.AuxFormatter != nil {
33			if err = config.ProgressWriter.AuxFormatter.Emit("moby.image.id", types.BuildResult{ID: imageID}); err != nil {
34				return "", err
35			}
36		}
37	}
38
39	if !useBuildKit {
40		stdout := config.ProgressWriter.StdoutFormatter
41		fmt.Fprintf(stdout, "Successfully built %s\n", stringid.TruncateID(imageID))
42	}
43	if imageID != "" {
44		err = tagger.TagImages(image.ID(imageID))
45	}
46	return imageID, err
47}

這個函數看着比較長,但主要功能就以下三點:

 1useBuildKit := options.Version == types.BuilderBuildKit
 2
 3var build *builder.Result
 4if useBuildKit {
 5    build, err = b.buildkit.Build(ctx, config)
 6    if err != nil {
 7        return "", err
 8    }
 9} else {
10    build, err = b.builder.Build(ctx, config)
11    if err != nil {
12        return "", err
13    }
14}

到這個函數之後,就分別是 v1 builderbuildkitDockerfile 的解析,以及對 build context 的操作了。

這裏涉及到的內容與我下一篇文章《高效構建 Docker 鏡像的最佳實踐》的內部關聯比較大,此處就不再進行展開了。敬請期待下一篇文章。

總結

本文首先介紹了 Docker 的 C/S 架構,介紹了構建鏡像所用的 API , API 文檔可以在線查看或者本地構建。之後深入到 Docker CLI 的源碼中,逐步分解 v1 builderbuildkit 在構建鏡像時執行的過程的差異。最後,我們深入到 dockerd 的源碼中,瞭解到了對不同構建後端的調用。至此,Docker 構建鏡像的原理及主體代碼就介紹完畢。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://juejin.cn/post/6939736837439094815?utm_source=gold_browser_extension