萬字長文:徹底搞懂容器鏡像構(gòu)建
大家好,我是張晉濤。
我將在這篇文章中深入 Docker 的源碼,與你聊聊鏡像構(gòu)建的原理。
文章過長,目錄如下:

Docker 架構(gòu)
這里我們先從宏觀上對 Docker 有個大概的認識,它整體上是個 C/S 架構(gòu);我們平時使用的 docker 命令就是它的 CLI 客戶端,而它的服務(wù)端是 dockerd 在 Linux 系統(tǒng)中,通常我們是使用 systemd 進行管理,所以我們可以使用 systemctl start docker 來啟動服務(wù)。(但是請注意,dockerd 是否能運行與 systemd 并無任何關(guān)系,你可以像平時執(zhí)行一個普通的二進制程序一樣,直接通過 dockerd 來啟動服務(wù),注意需要 root 權(quán)限)
實際上也就是
(圖片來源:docker overview)
docker CLI 與 dockerd 的交互是通過 REST API 來完成的,當我們執(zhí)行 docker version 的時候過濾 API 可以看到如下輸出:
? ~ docker version |grep API
API version: 1.41
API version: 1.41 (minimum version 1.12)
上面一行是 docker CLI 的 API 版本,下面則代表了 dockerd 的 API 版本,它的后面還有個括號,是因為 Docker 具備了很良好的兼容性,這里表示它最小可兼容的 API 版本是 1.12 。
對于我們進行 C/S 架構(gòu)的項目開發(fā)而言,一般都是 API 先行, 所以我們先來看下 API 的部分。
當然,本文的主體是構(gòu)建系統(tǒng)相關(guān)的,所以我們就直接來看構(gòu)建相關(guān)的 API 即可。
接下來會說 CLI,代碼以 v20.10.5 為準。最后說服務(wù)端 Dockerd 。
API
Docker 維護團隊在每個版本正式發(fā)布之后,都會將 API 文檔發(fā)布出來,可以通過 Docker Engine API 在線瀏覽,也可以自行構(gòu)建 API 文檔。
首先 clone Docker 的源代碼倉庫, 進入項目倉庫內(nèi)執(zhí)行 make swagger-docs 即可在啟動一個容器同時將端口暴露至本地的 9000 端口, 你可以直接通過 http://127.0.0.1:9000 訪問本地的 API 文檔。
(MoeLove) ? git clone https://github.com/docker/docker.git docker
(MoeLove) ? cd docker
(MoeLove) ? docker git:(master) git checkout -b v20.10.5 v20.10.5
(MoeLove) ? docker git:(v20.10.5) make swagger-docs
API docs preview will be running at http://localhost:9000
打開 http://127.0.0.1:9000/#operation/ImageBuild 這個地址就可以看到 1.41 版本的構(gòu)建鏡像所需的 API 了。我們對此 API 進行下分析。
請求地址和方法
接口地址是 /v1.41/build 方法是 POST ,我們可以使用一個較新版本的 curl 工具來驗證下此接口(需要使用 --unix-socket 連接 Docker 監(jiān)聽的 UNIX Domain Socket )。dockerd 默認情況下監(jiān)聽在 /var/run/docker.sock ,當然你也可以給 dockerd 傳遞 --host 參數(shù)用于監(jiān)聽 HTTP 端口或者其他路徑的 unix socket .
/ # curl -X POST --unix-socket /var/run/docker.sock localhost/v1.41/build
{"message":"Cannot locate specified Dockerfile: Dockerfile"}
從上面的輸出我們可以看到,我們確實訪問到了該接口,同時該接口的響應(yīng)是提示需要 Dockerfile .
請求體
“A tar archive compressed with one of the following algorithms: identity (no compression), gzip, bzip2, xz. string
”
請求體是一個 tar 歸檔文件,可選擇無壓縮、gzip、bzip2、xz 壓縮等形式。關(guān)于這幾種壓縮格式就不再展開介紹了,但值得注意的是 如果使用了壓縮,則傳輸體積會變小,即網(wǎng)絡(luò)消耗會相應(yīng)減少。但壓縮/解壓縮需要耗費 CPU 等計算資源 這在我們對大規(guī)模鏡像構(gòu)建做優(yōu)化時是個值得權(quán)衡的點。
請求頭
因為要發(fā)送的是個 tar 歸檔文件,Content-type 默認是 application/x-tar 。另一個會發(fā)送的頭是 X-Registry-Config,這是一個由 Base64 編碼后的 Docker Registry 的配置信息,內(nèi)容與 $HOME/.docker/config.json 中的 auths 內(nèi)的信息一致。
這些配置信息,在你執(zhí)行 docker login 后會自動寫入到 $HOME/.docker/config.json 文件內(nèi)的。這些信息被傳輸?shù)?dockerd 在構(gòu)建過程中作為拉取鏡像的認證信息使用。
請求參數(shù)
最后就是請求參數(shù)了,參數(shù)有很多,通過 docker build --help 基本都可以看到對應(yīng)含義的,這里不再一一展開了,后面會有一些關(guān)鍵參數(shù)的介紹。
小結(jié)
上面我們介紹了 Docker 構(gòu)建鏡像相關(guān)的 API,我們可以直接訪問Docker Engine 的 API 文檔。或者通過源碼倉庫,自己來構(gòu)建一個本地的 API 文檔服務(wù),使用瀏覽器進行訪問。
通過 API 我們也知道了該接口所需的請求體是一個 tar 歸檔文件(可選擇壓縮算法進行壓縮),同時它的請求頭中會攜帶用戶在鏡像倉庫中的認證信息。這提醒我們, 如果在使用遠程 Dockerd 構(gòu)建時,請注意安全,盡量使用 tls 進行加密,以免數(shù)據(jù)泄漏。
CLI
API 已經(jīng)介紹完了,我們來看下 docker CLI,我以前的文章中介紹過現(xiàn)在 Docker 中有兩個構(gòu)建系統(tǒng),一個是 v1 版本的 builder 另一個是 v2 版本的即 BuildKit 我們來分別深入源碼來看看在構(gòu)建鏡像時,他們各自的行為吧。
準備代碼
CLI 的代碼倉庫在 https://github.com/docker/cli 本文的代碼以 v20.10.5 為準。
通過以下步驟使用此版本的代碼:
(MoeLove) ? git clone https://github.com/docker/cli.git
(MoeLove) ? cd cli
(MoeLove) ? cli git:(master) git checkout -b v20.10.5 v20.10.5
逐步分解
docker 是我們所使用的客戶端工具,用于與 dockerd 進行交互。關(guān)于構(gòu)建相關(guān)的部分, 我們所熟知的便是 docker build 或者是 docker image build,在 19.03 中新增的是 docker builder build ,但其實他們都是同一個只是做了個 alias 罷了:
// cmd/docker/docker.go#L237
if v, ok := aliasMap["builder"]; ok {
aliases = append(aliases,
[2][]string{{"build"}, {v, "build"}},
[2][]string{{"image", "build"}, {v, "build"}},
)
}
真正的入口函數(shù)其實在 cli/command/image/build.go;區(qū)分如何調(diào)用的邏輯如下:
func runBuild(dockerCli command.Cli, options buildOptions) error {
buildkitEnabled, err := command.BuildKitEnabled(dockerCli.ServerInfo())
if err != nil {
return err
}
if buildkitEnabled {
return runBuildBuildKit(dockerCli, options)
}
// 省略掉了對于 builder 的實際邏輯
}
這里就是判斷下是否支持 buildkit
// cli/command/cli.go#L176
func BuildKitEnabled(si ServerInfo) (bool, error) {
buildkitEnabled := si.BuildkitVersion == types.BuilderBuildKit
if buildkitEnv := os.Getenv("DOCKER_BUILDKIT"); buildkitEnv != "" {
var err error
buildkitEnabled, err = strconv.ParseBool(buildkitEnv)
if err != nil {
return false, errors.Wrap(err, "DOCKER_BUILDKIT environment variable expects boolean value")
}
}
return buildkitEnabled, nil
}
當然,從這里可以得到兩個信息:
通過 dockerd的配置可開啟buildkit。在/etc/docker/daemon.json中添加如下內(nèi)容,并重啟dockerd即可:
{
"features": {
"buildkit": true
}
}
在 dockerCLI 上也可開啟buildkit的支持,并且 CLI 的配置可覆蓋服務(wù)端配置。通過export DOCKER_BUILDKIT=1即可開啟buildkit的支持,設(shè)置為 0 則關(guān)閉(0/false/f/F 之類的也都是相同的結(jié)果)
從上面的介紹也看到了,對于原本默認的 builder 而言, 入口邏輯在 runBuild 中, 而對于使用 buildkit 的則是 runBuildBuildKit 接下來,我們對兩者進行逐步分解。
builder v1
在 runBuild 函數(shù)中,大致經(jīng)歷了以下階段:
參數(shù)處理
最開始的部分是一些對參數(shù)的處理和校驗。
stream和compress不可同時使用。
因為如果我們指定了 compress 的話,則 CLI 會使用 gzip 將構(gòu)建上下文進行壓縮,這樣也就沒法很好的通過 stream 的模式來處理構(gòu)建的上下文了。
當然你也可能會想,從技術(shù)上來講,壓縮和流式?jīng)]有什么必然的沖突,是可實現(xiàn)的。事實的確如此,如果從技術(shù)的角度上來講兩者并非完全不能一起存在,無非就是增加解壓縮的動作。但是當開啟 stream 模式,對每個文件都進行壓縮和解壓的操作那將會是很大的資源浪費,同時也增加了其復(fù)雜度,所以在 CLI 中便直接進行了限制,不允許同時使用 compress 和 stream
不可同時使用 stdin讀取Dockerfile和build context。
在進行構(gòu)建時,如果我們將 Dockerfile 的名字傳遞為 - 時,表示從 stdin 讀取其內(nèi)容。
例如,某個目錄下有三個文件 foo bar 和 Dockerfile,通過管道將 Dockerfile 的內(nèi)容通過 stdin 傳遞給 docker build
(MoeLove) ? x ls
bar Dockerfile foo
(MoeLove) ? x cat Dockerfile | DOCKER_BUILDKIT=0 docker build -f - .
Sending build context to Docker daemon 15.41kB
Step 1/3 : FROM scratch
--->
Step 2/3 : COPY foo foo
---> a2af45d66bb5
Step 3/3 : COPY bar bar
---> cc803c675dd2
Successfully built cc803c675dd2
可以看到通過 stdin 傳遞 Dockerfile 的方式能成功的構(gòu)建鏡像。接下來我們嘗試通過 stdin 將 build context 傳遞進去。
(MoeLove) ? x tar -cvf x.tar foo bar Dockerfile
foo
bar
Dockerfile
(MoeLove) ? x cat x.tar| DOCKER_BUILDKIT=0 docker build -f Dockerfile -
Sending build context to Docker daemon 10.24kB
Step 1/3 : FROM scratch
--->
Step 2/3 : COPY foo foo
---> 09319712e220
Step 3/3 : COPY bar bar
---> ce88644a7395
Successfully built ce88644a7395
可以看到通過 stdin 傳遞 build context 的方式也可以成功構(gòu)建鏡像。
但如果 Dockerfile 的名稱與構(gòu)建的上下文都指定為 - 即 docker build -f - - 時,會發(fā)生什么呢?
(MoeLove) ? x DOCKER_BUILDKIT=0 docker build -f - -
invalid argument: can't use stdin for both build context and dockerfile
就會報錯了。所以, 不能同時使用 stdin 讀取 Dockerfile 和 build context 。
build context支持四種行為。
switch {
case options.contextFromStdin():
// 省略
case isLocalDir(specifiedContext):
// 省略
case urlutil.IsGitURL(specifiedContext):
// 省略
case urlutil.IsURL(specifiedContext):
// 省略
default:
return errors.Errorf("unable to prepare context: path %q not found", specifiedContext)
}
從 stdin 傳入,上文已經(jīng)演示過了,傳遞給 stdin 的是 tar 歸檔文件。當然也可以是指定一個具體的 PATH,我們通常使用的 docker build . 便是這種用法;
或者可以指定一個 git 倉庫的地址,CLI 會調(diào)用 git 命令將倉庫 clone 至一個臨時目錄,進行使用;
最后一種是,給定一個 URL 地址,該地址可以是 一個具體的 Dockerfile 文件地址 或者是 一個 tar 歸檔文件的下載地址 。
這幾種基本就是字面上的區(qū)別,至于 CLI 的行為差異,主要是最后一種,當 URL 地址是一個具體的 Dockerfile 文件地址,在這種情況下 build context 相當于只有 Dockerfile 自身,所以并不能使用 COPY 之類的指定,至于 ADD 也只能使用可訪問的外部地址。
可使用 .dockerignore忽略不需要的文件
我在之前的文章中有分享過相關(guān)的內(nèi)容。這里我們看看它的實現(xiàn)邏輯。
// cli/command/image/build/dockerignore.go#L13
func ReadDockerignore(contextDir string) ([]string, error) {
var excludes []string
f, err := os.Open(filepath.Join(contextDir, ".dockerignore"))
switch {
case os.IsNotExist(err):
return excludes, nil
case err != nil:
return nil, err
}
defer f.Close()
return dockerignore.ReadAll(f)
}
.dockerignore是一個固定的文件名,并且需要放在build context的根目錄下。類似前面提到的,使用一個Dockerfile文件的 URL 地址作為build context傳入的方式,便無法使用.dockerignore。.dockerignore文件可以不存在,但在讀取的時候如果遇到錯誤,便會拋出錯誤。通過
.dockerignore將會過濾掉不希望加入到鏡像內(nèi),或者過濾掉與鏡像無關(guān)的內(nèi)容。
最后 CLI 會將 build context 中的內(nèi)容經(jīng)過 .dockerignore 過濾后,打包成為真正的 build context 即真正的構(gòu)建上下文。這也是為什么有時候你發(fā)現(xiàn)自己明明在 Dockerfile 里面寫了 COPY xx xx 但是最后沒有發(fā)現(xiàn)該文件的情況。很可能就是被 .dockerignore 給忽略掉了。這樣有利于優(yōu)化 CLI 與 dockerd 之間的傳輸壓力之類的。
dockerCLI 還會去讀取~/.docker/config.json中的內(nèi)容。
這與前面 API 部分所描述的內(nèi)容基本是一致的。將認證信息通過 X-Registry-Config 頭傳遞給 dockerd 用于在需要拉取鏡像時進行身份校驗。
調(diào)用 API 進行實際構(gòu)建任務(wù)
當一切所需的校驗和信息都準備就緒之后,則開始調(diào)用 dockerCli.Client 封裝的 API 接口,將請求發(fā)送至 dockerd,進行實際的構(gòu)建任務(wù)。
response, err := dockerCli.Client().ImageBuild(ctx, body, buildOptions)
if err != nil {
if options.quiet {
fmt.Fprintf(dockerCli.Err(), "%s", progBuff)
}
cancel()
return err
}
defer response.Body.Close()
到這里其實一次構(gòu)建的過程中 CLI 所處理的流程就基本結(jié)束了,之后便是按照傳遞的參數(shù)進行進度的輸出或是將鏡像 ID 寫入到文件之類的。這部分就不進行展開了。
小結(jié)
整個過程大致如下圖:
從入口函數(shù) runBuild 開始,經(jīng)過判斷是否支持 buildkit ,如果不支持 buildkit 則繼續(xù)使用 v1 的 builder。接下來讀取各類參數(shù),按照不同的參數(shù)執(zhí)行各類不同的處理邏輯。這里需要注意的就是 Dockerfile 及 build context 都可支持從文件或者 stdin 等讀入,具體使用時,需要注意。另外 .dockerignore 文件可過濾掉 build context 中的一些文件,在使用時,可通過此方法進行構(gòu)建效率的優(yōu)化,當然也需要注意,在通過 URL 獲取 Dockerfile 的時候,是不存在 build context 的,所以類似 COPY 這樣的命令也就無法使用了。當所有的 build context 和參數(shù)都準備就緒后,接下來調(diào)用封裝好的客戶端,將這些請求按照本文開始之初介紹的 API 發(fā)送給 dockerd ,由其進行真正的構(gòu)建邏輯。
最后當構(gòu)建結(jié)束后,CLI 根據(jù)參數(shù)決定是否要顯示構(gòu)建進度或者結(jié)果。
buildkit
接下來我們來看看 buildkit 如何來執(zhí)行構(gòu)建,方法入口與 builder 一致,但是在 buildkitEnabled 處,由于開啟了 buildkit 支持,所以跳轉(zhuǎn)到了 runBuildBuildKit。
func runBuild(dockerCli command.Cli, options buildOptions) error {
buildkitEnabled, err := command.BuildKitEnabled(dockerCli.ServerInfo())
if err != nil {
return err
}
if buildkitEnabled {
return runBuildBuildKit(dockerCli, options)
}
// 省略掉了對于 builder 的實際邏輯
}
創(chuàng)建會話
但是與 builder 不同的是,這里先執(zhí)行了一次 trySession 函數(shù)。
// cli/command/image/build_buildkit.go#L50
s, err := trySession(dockerCli, options.context, false)
if err != nil {
return err
}
if s == nil {
return errors.Errorf("buildkit not supported by daemon")
}
這個函數(shù)是用來做什么的呢?我們來找到該函數(shù)所在的文件 cli/command/image/build_session.go
// cli/command/image/build_session.go#L29
func trySession(dockerCli command.Cli, contextDir string, forStream bool) (*session.Session, error) {
if !isSessionSupported(dockerCli, forStream) {
return nil, nil
}
sharedKey := getBuildSharedKey(contextDir)
s, err := session.NewSession(context.Background(), filepath.Base(contextDir), sharedKey)
if err != nil {
return nil, errors.Wrap(err, "failed to create session")
}
return s, nil
}
當然還包括它其中最主要的 isSessionSupported 函數(shù):
// cli/command/image/build_session.go#L22
func isSessionSupported(dockerCli command.Cli, forStream bool) bool {
if !forStream && versions.GreaterThanOrEqualTo(dockerCli.Client().ClientVersion(), "1.39") {
return true
}
return dockerCli.ServerInfo().HasExperimental && versions.GreaterThanOrEqualTo(dockerCli.Client().ClientVersion(), "1.31")
}
isSessionSupported 很明顯是用于判斷是否支持 Session,這里由于我們會傳入 forStream 為 false ,而且當前的 API 版本是 1.41 比 1.39 大,所以此函數(shù)會返回 true 。其實在 builder 中也執(zhí)行過相同的邏輯,只不過是在傳遞了 --stream 參數(shù)后,使用 Session 獲取一個長連接以達到 stream 的處理能力。
這也就是為什么會有下面 dockerCli.ServerInfo().HasExperimental && versions.GreaterThanOrEqualTo(dockerCli.Client().ClientVersion(), "1.31") 這個判斷存在的原因了。
當確認支持 Session 時,則會調(diào)用 session.NewSession 創(chuàng)建一個新的會話。
// github.com/moby/buildkit/session/session.go#L47
func NewSession(ctx context.Context, name, sharedKey string) (*Session, error) {
id := identity.NewID()
var unary []grpc.UnaryServerInterceptor
var stream []grpc.StreamServerInterceptor
serverOpts := []grpc.ServerOption{}
if span := opentracing.SpanFromContext(ctx); span != nil {
tracer := span.Tracer()
unary = append(unary, otgrpc.OpenTracingServerInterceptor(tracer, traceFilter()))
stream = append(stream, otgrpc.OpenTracingStreamServerInterceptor(span.Tracer(), traceFilter()))
}
unary = append(unary, grpcerrors.UnaryServerInterceptor)
stream = append(stream, grpcerrors.StreamServerInterceptor)
if len(unary) == 1 {
serverOpts = append(serverOpts, grpc.UnaryInterceptor(unary[0]))
} else if len(unary) > 1 {
serverOpts = append(serverOpts, grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(unary...)))
}
if len(stream) == 1 {
serverOpts = append(serverOpts, grpc.StreamInterceptor(stream[0]))
} else if len(stream) > 1 {
serverOpts = append(serverOpts, grpc.StreamInterceptor(grpc_middleware.ChainStreamServer(stream...)))
}
s := &Session{
id: id,
name: name,
sharedKey: sharedKey,
grpcServer: grpc.NewServer(serverOpts...),
}
grpc_health_v1.RegisterHealthServer(s.grpcServer, health.NewServer())
return s, nil
}
它創(chuàng)建了一個長連接會話,接下來的操作也都會基于這個會話來做。接下來的操作與 builder 大體一致,先判斷 context 是以哪種形式提供的;當然它也與 builder 一樣,是不允許同時從 stdin 獲取 Dockerfile 和 build context 。
switch {
case options.contextFromStdin():
// 省略處理邏輯
case isLocalDir(options.context):
// 省略處理邏輯
case urlutil.IsGitURL(options.context):
// 省略處理邏輯
case urlutil.IsURL(options.context):
// 省略處理邏輯
default:
return errors.Errorf("unable to prepare context: path %q not found", options.context)
}
這里的處理邏輯與 v1 builder 保持一致的原因,主要在于用戶體驗上,當前的 CLI 的功能已經(jīng)基本穩(wěn)定,用戶也已經(jīng)習(xí)慣,所以即使是增加了 BuildKit 也并沒有對主體的操作邏輯造成多大改變。
選擇輸出模式
BuildKit 支持了三種不同的輸出模式 local tar 和正常模式(即存儲在 dockerd 中), 格式為 -o type=local,dest=path 如果需要將構(gòu)建的鏡像進行分發(fā),或是需要進行鏡像內(nèi)文件瀏覽的話,使用這個方式也是很方便的。
outputs, err := parseOutputs(options.outputs)
if err != nil {
return errors.Wrapf(err, "failed to parse outputs")
}
for _, out := range outputs {
switch out.Type {
case "local":
// 省略
case "tar":
// 省略
}
}
其實它支持的模式還有第 4 種, 名為 cacheonly 但它并不會像前面提到的三種模式一樣,有個很直觀的輸出,而且用的人可能會很少,所以就沒有單獨寫了。
讀取認證信息
dockerAuthProvider := authprovider.NewDockerAuthProvider(os.Stderr)
s.Allow(dockerAuthProvider)
這里的行為與上面提到的 builder 的行為基本一致,這里主要有兩個需要注意的點:
Allow() 函數(shù)
func (s *Session) Allow(a Attachable) {
a.Register(s.grpcServer)
}
這個 Allow 函數(shù)就是允許通過上面提到的 grpc 會話訪問給定的服務(wù)。
authprovider
authprovider 是 BuildKit 提供的一組抽象接口集合,通過它們可以訪問到機器上的配置文件,進而拿到認證信息,行為與 builder 基本一致。
高階特性:secrets 和 ssh
我其他的文章講過這兩種高階特性的使用了,本篇中就不再多使用進行過多說明了,只來大體看下該部分的原理和邏輯。
secretsprovider 和 sshprovider 都是 buildkit 在提供的,利用這兩種特性可以在 Docker 鏡像進行構(gòu)建時更加安全,且更加靈活。
func parseSecretSpecs(sl []string) (session.Attachable, error) {
fs := make([]secretsprovider.Source, 0, len(sl))
for _, v := range sl {
s, err := parseSecret(v)
if err != nil {
return nil, err
}
fs = append(fs, *s)
}
store, err := secretsprovider.NewStore(fs)
if err != nil {
return nil, err
}
return secretsprovider.NewSecretProvider(store), nil
}
關(guān)于 secrets 方面,最終的 parseSecret 會完成格式相關(guān)的校驗之類的;
func parseSSHSpecs(sl []string) (session.Attachable, error) {
configs := make([]sshprovider.AgentConfig, 0, len(sl))
for _, v := range sl {
c := parseSSH(v)
configs = append(configs, *c)
}
return sshprovider.NewSSHAgentProvider(configs)
}
而關(guān)于 ssh 方面,則與上方的 secrets 基本一致,通過 sshprovider 允許進行 ssh 轉(zhuǎn)發(fā)之類的,這里不再深入展開了。
調(diào)用 API 發(fā)送構(gòu)建請求
這里主要有兩種情況。
當 build context是從stdin讀,并且是一個tar文件時
buildID := stringid.GenerateRandomID()
if body != nil {
eg.Go(func() error {
buildOptions := types.ImageBuildOptions{
Version: types.BuilderBuildKit,
BuildID: uploadRequestRemote + ":" + buildID,
}
response, err := dockerCli.Client().ImageBuild(context.Background(), body, buildOptions)
if err != nil {
return err
}
defer response.Body.Close()
return nil
})
}
它會執(zhí)行上述這部分邏輯,但同時也要注意,這是使用的是 Golang 的 goroutine,到這里也并不是結(jié)束,這部分代碼之后的代碼也同樣會被執(zhí)行。這就說到了另一種情況了(通常情況)。
使用 doBuild完成邏輯
eg.Go(func() error {
defer func() {
s.Close()
}()
buildOptions := imageBuildOptions(dockerCli, options)
buildOptions.Version = types.BuilderBuildKit
buildOptions.Dockerfile = dockerfileName
buildOptions.RemoteContext = remote
buildOptions.SessionID = s.ID()
buildOptions.BuildID = buildID
buildOptions.Outputs = outputs
return doBuild(ctx, eg, dockerCli, stdoutUsed, options, buildOptions)
})
那 doBuild 會做些什么呢?它同樣也調(diào)用了 API 向 dockerd 發(fā)起了構(gòu)建請求。
func doBuild(ctx context.Context, eg *errgroup.Group, dockerCli command.Cli, stdoutUsed bool, options buildOptions, buildOptions types.ImageBuildOptions, at session.Attachable) (finalErr error) {
response, err := dockerCli.Client().ImageBuild(context.Background(), nil, buildOptions)
if err != nil {
return err
}
defer response.Body.Close()
// 省略
}
從以上的介紹我們可以先做個小的總結(jié)。當 build context 從 stdin 讀,并且是個 tar 歸檔時,實際會向 dockerd 發(fā)起兩次 /build 請求 而一般情況下只會發(fā)送一次請求。
那這里會有什么差別呢?此處先不展開,我們留到下面講 dockerd 服務(wù)端的時候再來解釋。
小結(jié)
這里我們對開啟了 buildkit 支持的 CLI 構(gòu)建鏡像的過程進行了分析,大致過程如下:
從入口函數(shù) runBuild 開始,判斷是否支持 buildkit ,如果支持 buildkit 則調(diào)用 runBuildBuildKit。與 v1 的 builder 不同的是,開啟了 buildkit 后,會首先創(chuàng)建一個長連接的會話,并一直保持。其次,與 builder 相同,判斷 build context 的來源,格式之類的,校驗參數(shù)等。當然,buildkit 支持三種不同的輸出格式 tar, local 或正常的存儲于 Docker 的目錄中。另外是在 buildkit 中新增的高階特性,可以配置 secrets 和 ssh 密鑰等功能。最后,再調(diào)用 API 與 dockerd 交互完成鏡像的構(gòu)建。
服務(wù)端:dockerd
上面分別介紹了 API, CLI 的 v1 builder 和 buildkit ,接下來我們看看服務(wù)端的具體原理和邏輯。
Client 函數(shù)
還記得上面部分中最后通過 API 與服務(wù)端交互的 ImageBuild 函數(shù)嗎?在開始 dockerd 的介紹前,我們來看下這個客戶端接口的具體內(nèi)容。
// github.com/docker/docker/client/image_build.go#L20
func (cli *Client) ImageBuild(ctx context.Context, buildContext io.Reader, options types.ImageBuildOptions) (types.ImageBuildResponse, error) {
query, err := cli.imageBuildOptionsToQuery(options)
if err != nil {
return types.ImageBuildResponse{}, err
}
headers := http.Header(make(map[string][]string))
buf, err := json.Marshal(options.AuthConfigs)
if err != nil {
return types.ImageBuildResponse{}, err
}
headers.Add("X-Registry-Config", base64.URLEncoding.EncodeToString(buf))
headers.Set("Content-Type", "application/x-tar")
serverResp, err := cli.postRaw(ctx, "/build", query, buildContext, headers)
if err != nil {
return types.ImageBuildResponse{}, err
}
osType := getDockerOS(serverResp.header.Get("Server"))
return types.ImageBuildResponse{
Body: serverResp.body,
OSType: osType,
}, nil
}
沒有什么太特別的地方,行為與 API 一致。通過這里我們確認它確實訪問的 /build 接口,所以,我們來看看 dockerd 的 /build 接口,看看它在構(gòu)建鏡像的時候做了什么。
dockerd
由于本文集中討論的是構(gòu)建系統(tǒng)相關(guān)的部分,所以也就不再過多贅述與構(gòu)建無關(guān)的內(nèi)容了,我們直接來看,當 CLI 通過 /build 接口發(fā)送請求后,會發(fā)生什么。
先來看該 API 的入口:
// api/server/router/build/build.go#L32
func (r *buildRouter) initRoutes() {
r.routes = []router.Route{
router.NewPostRoute("/build", r.postBuild),
router.NewPostRoute("/build/prune", r.postPrune),
router.NewPostRoute("/build/cancel", r.postCancel),
}
}
dockerd 提供了一套類 RESTful 的后端接口服務(wù),處理邏輯的入口便是上面的 postBuild 函數(shù)。
該函數(shù)的內(nèi)容較多,我們來分解下它的主要步驟。
buildOptions, err := newImageBuildOptions(ctx, r)
if err != nil {
return errf(err)
}
newImageBuildOptions 函數(shù)就是構(gòu)造構(gòu)建參數(shù)的,將通過 API 提交過來的參數(shù)轉(zhuǎn)換為構(gòu)建動作實際需要的參數(shù)形式。
buildOptions.AuthConfigs = getAuthConfigs(r.Header)
getAuthConfigs 函數(shù)用于從請求頭拿到認證信息
imgID, err := br.backend.Build(ctx, backend.BuildConfig{
Source: body,
Options: buildOptions,
ProgressWriter: buildProgressWriter(out, wantAux, createProgressReader),
})
if err != nil {
return errf(err)
}
這里就需要注意了: 真正的構(gòu)建過程要開始了。使用 backend 的 Build 函數(shù)來完成真正的構(gòu)建過程
// api/server/backend/build/backend.go#L53
func (b *Backend) Build(ctx context.Context, config backend.BuildConfig) (string, error) {
options := config.Options
useBuildKit := options.Version == types.BuilderBuildKit
tagger, err := NewTagger(b.imageComponent, config.ProgressWriter.StdoutFormatter, options.Tags)
if err != nil {
return "", err
}
var build *builder.Result
if useBuildKit {
build, err = b.buildkit.Build(ctx, config)
if err != nil {
return "", err
}
} else {
build, err = b.builder.Build(ctx, config)
if err != nil {
return "", err
}
}
if build == nil {
return "", nil
}
var imageID = build.ImageID
if options.Squash {
if imageID, err = squashBuild(build, b.imageComponent); err != nil {
return "", err
}
if config.ProgressWriter.AuxFormatter != nil {
if err = config.ProgressWriter.AuxFormatter.Emit("moby.image.id", types.BuildResult{ID: imageID}); err != nil {
return "", err
}
}
}
if !useBuildKit {
stdout := config.ProgressWriter.StdoutFormatter
fmt.Fprintf(stdout, "Successfully built %s\n", stringid.TruncateID(imageID))
}
if imageID != "" {
err = tagger.TagImages(image.ID(imageID))
}
return imageID, err
}
這個函數(shù)看著比較長,但主要功能就以下三點:
NewTagger是用于給鏡像打標簽,也就是我們的-t參數(shù)相關(guān)的內(nèi)容,這里不做展開。通過判斷是否使用了
buildkit來調(diào)用不同的構(gòu)建后端。
useBuildKit := options.Version == types.BuilderBuildKit
var build *builder.Result
if useBuildKit {
build, err = b.buildkit.Build(ctx, config)
if err != nil {
return "", err
}
} else {
build, err = b.builder.Build(ctx, config)
if err != nil {
return "", err
}
}
處理構(gòu)建完成后的動作。
到這個函數(shù)之后,就分別是 v1 builder 與 buildkit 對 Dockerfile 的解析,以及對 build context 的操作了。
這里涉及到的內(nèi)容與我下一篇文章《高效構(gòu)建 Docker 鏡像的最佳實踐》的內(nèi)部關(guān)聯(lián)比較大,此處就不再進行展開了。敬請期待下一篇文章。
總結(jié)
本文首先介紹了 Docker 的 C/S 架構(gòu),介紹了構(gòu)建鏡像所用的 API , API 文檔可以在線查看或者本地構(gòu)建。之后深入到 Docker CLI 的源碼中,逐步分解 v1 builder 與 buildkit 在構(gòu)建鏡像時執(zhí)行的過程的差異。最后,我們深入到 dockerd 的源碼中,了解到了對不同構(gòu)建后端的調(diào)用。至此,Docker 構(gòu)建鏡像的原理及主體代碼就介紹完畢。
但這還并不是結(jié)束,我會在后續(xù)文章中分享鏡像構(gòu)建的相關(guān)實踐,敬請期待!


