干貨好文|使用 docker buildx 構建跨平臺Go鏡像

前言

為了在不同操作系統和處理器架構上運行應用,為不同平臺單獨構建程序版本是很常見的場景。當開發應用的平臺與部署的目標平臺不同時,實現這一目標并不容易。例如在 x86 架構上開發一個應用程序并將其部署到 ARM 平臺的機器上,通常需要準備 ARM 平臺的基礎設施用于開發和編譯。

一次構建多處部署的鏡像分發大幅提高了應用的交付效率,對于需要跨平臺部署應用但基礎設施不夠充分的場景,利用 docker buildx 構建跨平臺的鏡像是一種快捷高效的解決方案。

大部分鏡像托管平臺支持多平臺鏡像,這意味著鏡像倉庫中單個標簽可以包含不同平臺的多個鏡像,以 docker hub 的 python 鏡像倉庫為例,3.9.6 這個標簽就包含了 10 個不同系統和架構的鏡像(平臺 = 系統 + 架構)。

通過 docker pull 或 docker run 拉取一個支持跨平臺的鏡像時,docker 會自動選擇與當前運行平臺相匹配的鏡像。由于該特性的存在,在進行鏡像的跨平臺分發時,鏡像的消費端是無感知的,我們只需要關心鏡像的生產,即如何構建跨平臺的鏡像。

docker buildx

默認的 docker build 命令無法完成跨平臺構建任務,需要為docker 命令行工具安裝 buildx 插件擴展其功能。buildx 能夠使用由 Moby BuildKit 提供的構建鏡像額外特性,它能夠創建多個 builder 實例,在多個節點并行地執行構建任務,以及跨平臺構建。

啟用 Buildx

macOS 或 Windows 系統的 Docker Desktop,以及 Linux 發行版通過 deb 或者 rpm 包所安裝的 docker 內置了 buildx,不需要另行安裝。

如果你的 docker 沒有 buildx 命令,可以下載二進制包進行安裝:

首先從 Docker buildx 項目的 release 頁面找到適合自己平臺的二進制文件。

下載二進制文件到本地并重命名為 docker-buildx,移動到 docker 的插件目錄?~/.docker/cli-plugins。

向二進制文件授予可執行權限。

如果本地的 docker 版本高于 19.03,可以通過以下命令直接在本地構建并安裝,這種方式更為方便:

$ export DOCKER_BUILDKIT=1

$ docker build --platform=local -o . git://github.com/docker/buildx

$ mkdir -p ~/.docker/cli-plugins

$ mv buildx ~/.docker/cli-plugins/docker-buildx

使用 buildx 進行構建的方法如下:

docker buildx build .

buildx 和 docker build 命令的使用體驗基本一致,還支持 build 常用的選項如`-t`、`-f` 等。。

builder 實例

docker buildx 通過 builder 實例對象來管理構建配置和節點,命令行將構建任務發送至 builder 實例,再由 builder 指派給符合條件的節點執行。我們可以基于同一個 docker 服務程序創建多個 builder 實例,提供給不同的項目使用以隔離各個項目的配置,也可以為一組遠程 docker 節點創建一個 builder 實例組成構建陣列,并在不同陣列之間快速切換。

使用 docker buildx create 命令可以創建 builder 實例,這將以當前使用的 docker 服務為節點創建一個新的 builder 實例。要使用一個遠程節點,可以在創建示例時通過 DOCKER_HOST 環境變量指定遠程端口或提前切換到遠程節點的 docker context。下面以遠程節點創建一個新的 builder 實例并通過命令行選項指定其驅動、目標平臺和實例名稱:

$ export DOCKER_HOST=tcp://10.10.150.66:2375

$ docker buildx create --driver docker-container --platform linux/amd64,linux/arm64 --name remote-builderremote-builder

docker buildx ls 將列出所有可用的 builder 實例和實例中的節點:

$ docker buildx lsNAME/NODE ? ? ? ? DRIVER/ENDPOINT ? ? ? ? STATUS ? PLATFORMSremote-builder ? ?docker-container ? ? ? ? ? ? ? ? ?remote-builder0 tcp://10.10.150.66:2375 inactive linux/amd64*, linux/arm64*default * ? ? ? ? docker ? ? ? ? ? ? ? ? ? ? ? ? ? ?default ? ? ? ? default ? ? ? ? ? ? ? ? running ?linux/amd64, linux/386

實例創建之后可以繼續向它添加新的節點,通過 docker buildx create 命令的?--append 選項可將節點加入到?--name 選項指定的 builder 實例:

$ docker buildx create --name default --append remote-builder0

docker buildx inspect、docker buildx stop 和 docker buildx rm 命令用于管理一個實例的生命周期。

docker buildx use 將切換到所指定的 builder 實例。

構建驅動

buildx 實例通過兩種方式來執行構建任務,兩種執行方式被稱為使用不同的「驅動」:

docker 驅動:使用 Docker 服務程序中集成的 BuildKit 庫執行構建。

docker-container 驅動:啟動一個包含 BuildKit 的容器并在容器中執行構建。

docker 驅動無法使用一小部分 buildx 的特性(如在一次運行中同時構建多個平臺鏡像),此外在鏡像的默認輸出格式上也有所區別:docker 驅動默認將構建結果以 Docker 鏡像格式直接輸出到 docker 的鏡像目錄(通常是?/var/lib/overlay2),之后執行 docker images 命令可以列出所輸出的鏡像;而 docker container 則需要通過?--output 選項指定輸出格式為鏡像或其他格式。

為了一次性構建多個平臺的鏡像,下文將使用 docker container 驅動的 builder 實例。

buildx 的跨平臺構建策略

根據構建節點和目標程序語言不同,buildx 支持以下三種跨平臺構建策略:

通過 QEMU 的用戶態模式創建輕量級的虛擬機,在虛擬機系統中構建鏡像。

在一個 builder 實例中加入多個不同目標平臺的節點,通過原生節點構建對應平臺鏡像。

分階段構建并且交叉編譯到不同的目標架構。

QEMU 通常用于模擬完整的操作系統,它還可以通過用戶態模式運行:以 binfmt_misc 在宿主機系統中注冊一個二進制轉換處理程序,并在程序運行時動態翻譯二進制文件,根據需要將系統調用從目標 CPU 架構轉換為當前系統的 CPU 架構。最終的效果就像在一個虛擬機中運行目標 CPU 架構的二進制文件。Docker Desktop 內置了 QEMU 支持,其他滿足運行要求的平臺可通過以下方式安裝:

$ docker run --privileged --rm tonistiigi/binfmt --install all

這種方式不需要對已有的 Dockerfile 做任何修改,實現的成本很低,但顯而易見效率并不高。

將不同系統架構的原生節點添加到 builder 實例中可以為跨平臺編譯帶來更好的支持,而且效率更高,但需要有足夠的基礎設施支持。

如果構建項目所使用的程序語言支持交叉編譯(如 C 和 Go),可以利用 Dockerfile 提供的分階段構建特性:首先在和構建節點相同的架構中編譯出目標架構的二進制文件,再將這些二進制文件復制到目標架構的另一鏡像中。下文會使用 Go 實現一個具體的示例。這種方式不需要額外的硬件,也能得到較好的性能,但只有特定編程語言能夠實現。

一次構建多個架構 Go 鏡像實踐

下面將以一個簡單的 Go 項目作為示例,假設示例程序文件 main.go 內容如下:

package main import

( ?

?"fmt" ? ?

"runtime"

)

func main(){? ??

fmt.Println("Hello world!") ? ?

fmt.Printf("Running in [%s] architecture.\n", runtime.GOARCH)

}

定義構建過程的 Dockerfile 如下:

FROM --platform=$BUILDPLATFORM golang:1.14 as builder

ARG TARGETARCH

WORKDIR /app

COPY main.go /app/main.go

RUN GOOS=linux

GOARCH=$TARGETARCH go build -a -o output/main main.go

FROM alpine:latest

WORKDIR /root

COPY --from=builder /app/output/main .

CMD /root/main

構建過程分為兩個階段:

在一階段中,我們將拉取一個和當前構建節點相同平臺的 golang 鏡像,并使用 Go 的交叉編譯特性將其編譯為目標架構的二進制文件。

然后拉取目標平臺的 alpine 鏡像,并將上一階段的編譯結果拷貝到鏡像中。

執行跨平臺構建

執行構建命令時,除了指定鏡像名稱,另外兩個重要的選項是指定目標平臺和輸出格式。

docker buildx build 通過?--platform 選項指定構建的目標平臺。Dockerfile 中的 FROM 指令如果沒有設置 '--platform'標志,就會以目標平臺拉取基礎鏡像,最終生成的鏡像也將屬于目標平臺。當使用 `docker-container` 驅動時,這個選項可以接受用逗號分隔的多個值作為輸入以同時指定多個目標平臺,所有平臺的構建結果將合并為一個整體的鏡像列表作為輸出,因此無法直接輸出為本地的 `docker images` 鏡像。此外Dockerfile 中可通過 BUILDPLATFORM、TARGETPLATFORM、BUILDARCH 和 TARGETARCH 等參數使用該選項的值。

docker buildx build支持豐富的輸出行為,通過--output=[PATH,-,type=TYPE[,KEY=VALUE]?選項可以指定構建結果的輸出類型和路徑等,常用的輸出類型有以下幾種:

local:構建結果將以文件系統格式寫入 dest 指定的本地路徑, 如?--output type=local,dest=./output。

tar:構建結果將在打包后寫入 dest 指定的本地路徑。

oci:構建結果以 OCI 標準鏡像格式寫入 dest 指定的本地路徑。

docker:構建結果以 Docker 標準鏡像格式寫入 dest 指定的本地路徑或加載到 docker 的鏡像庫中。同時指定多個目標平臺時無法使用該選項。

image:以鏡像或者鏡像列表輸出,并支持 push=true 選項直接推送到遠程倉庫,同時指定多個目標平臺時可使用該選項。

registry:type=image,push=true 的精簡表示。

對本示例我們執行如下 docker buildx build 命令:

$ docker buildx build --platform linux/amd64,linux/arm64,linux/arm -t registry.cn-hangzhou.aliyuncs.com/waynerv/arch-demo -o type=registry .

該命令將在當前目錄同時構建 linux/amd64、 linux/arm64 和 linux/arm 三種平臺的鏡像,并將輸出結果直接推送到遠程的阿里云私人鏡像倉庫中。

構建過程可拆解如下:

docker 將構建上下文傳輸給 builder 實例。

builder 為命令行?--platform 選項指定的每一個目標平臺構建鏡像,包括拉取基礎鏡像和執行構建步驟。

導出構建結果,鏡像文件層被推送到遠程倉庫。

生成一個清單 JSON 文件,并將其作為鏡像標簽推送給遠程倉庫。

驗證構建結果

運行結束后可以通過 docker buildx imagetools 探查已推送到遠程倉庫的鏡像:

$ docker buildx imagetools inspect registry.cn-hangzhou.aliyuncs.com/waynerv/arch-demo:latest

Name: ? ? ?registry.cn-hangzhou.aliyuncs.com/waynerv/arch-demo:latest

MediaType: application/vnd.docker.distribution.manifest.list.v2+json

Digest:sha256:e2c3c5b330c19ac9d09f8aaccc40224f8673e12b88ff59cb68971c36b76e95ca ? ? ? ? ? ?

Manifests: ?

Name: ? ? ?registry.cn-hangzhou.aliyuncs.com/waynerv/arch-demo:latest@sha256:cb6a7614ee3db03c8858e3680b1585f32a6fe3de9b371e37e25cf42a83f6e0ba ?

MediaType: application/vnd.docker.distribution.manifest.v2+json ?

Platform: ?linux/amd64

Name: ? ? ?registry.cn-hangzhou.aliyuncs.com/waynerv/arch-demo:latest@sha256:034aa0077a452a6c2585f8b4969c7c85d5d2bf65f801fcc803a00d0879ce900e ?

MediaType: application/vnd.docker.distribution.manifest.v2+json ?

Platform: ?linux/arm64 ? ? ? ? ? ? ? ?

Name: ? ? ?registry.cn-hangzhou.aliyuncs.com/waynerv/arch-demo:latest@sha256:db0ee3a876fb789d2e733471385eef0a056f64ee12d9e7ef94e411469d054eb5 ?

MediaType: application/vnd.docker.distribution.manifest.v2+json ?

Platform: ?linux/arm/v7

最后在不同的平臺以 latest 標簽拉取并運行鏡像,驗證構建結果是否正確。

使用 Docker Desktop 時,其本身集成的虛擬化功能可以運行不同平臺的鏡像,可以直接以 sha256 值拉取鏡像:

$ docker run --rm registry.cn-hangzhou.aliyuncs.com/waynerv/arch-demo:latest@sha256:cb6a7614ee3db03c8858e3680b1585f32a6fe3de9b371e37e25cf42a83f6e0baHello world!

Running in [amd64] architecture.

$ docker run --rm registry.cn-hangzhou.aliyuncs.com/waynerv/arch-demo:latest@sha256:034aa0077a452a6c2585f8b4969c7c85d5d2bf65f801fcc803a00d0879ce900eHello world!

Running in [arm64] architecture.

如何交叉編譯 Golang 的 CGO 項目

支持交叉編譯到常見的操作系統和 CPU 架構是 Golang 的一大優勢,但以上示例中的解決方案只適用于純 Go 代碼,如果項目中通過 cgo 調用了 C 代碼,情況會變得更加復雜。

準備交叉編譯環境和依賴

為了能夠順利編譯 C 代碼到目標平臺,首先需要在編譯環境中安裝目標平臺的 C 交叉編譯器(通常基于 gcc),常用的 Linux 發行版會提供大部分平臺的交叉編譯器安裝包,可以直接通過包管理器安裝。

其次還需要安裝目標平臺的 C 標準庫(通常標準庫會作為交叉編譯器的安裝依賴,不需要單獨安裝),另外取決于你所調用的 C 代碼的依賴關系,可能還需要安裝一些額外的 C 依賴庫(如 libopus-dev 之類)。

我們將使用 amd64 架構的 golang:1.14 官方鏡像作為基礎鏡像執行編譯,其使用的 Linux 發行版為 Debian。假設交叉編譯的目標平臺是 linux/arm64,則需要準備的交叉編譯器為 gcc-aarch64-linux-gnu,C 標準庫為 libc6-dev-arm64-cross,安裝方式為:

$ apt-get update$ apt-get install gcc-aarch64-linux-gnu

libc6-dev-arm64-cross 會同時被安裝。

得益于 Debian 包管理器 dpkg 提供的多架構安裝能力,假如我們的代碼依賴 libopus-dev 等非標準庫,可通過?:?的方式安裝其 arm64 架構的安裝包:

$ dpkg --add-architecture arm64$ apt-get update$ apt-get install -y libopus-dev:arm64

交叉編譯 CGO 示例

假設有如下 cgo 的示例代碼:

package main

/*

#include <stdlib.h>

*/

import "C"

import "fmt"

func Random() int {

? ?return int(C.random())

}

func Seed(i int) {

? ?C.srandom(C.uint(i))

}

func main() ?{

? ?rand := Random()

? ?fmt.Printf("Hello %d\n", rand)

}

將使用的 Dockerfile 如下:

FROM --platform=$BUILDPLATFORM golang:1.14 as builder

ARG TARGETARCH

RUN apt-get update && apt-get install -y gcc-aarch64-linux-gnu WORKDIR /app

COPY . /app/

RUN if [ "$TARGETARCH" = "arm64" ]; then CC=aarch64-linux-gnu-gcc && CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \

CGO_ENABLED=1 GOOS=linux GOARCH=$TARGETARCH CC=$CC CC_FOR_TARGET=$CC_FOR_TARGET go build -a -ldflags '-extldflags "-static"' -o /main main.go

Dockerfile 中通過 apt-get 安裝了 gcc-aarch64-linux-gnu 作為交叉編譯器,示例程序較為簡單因此不需要額外的依賴庫。在執行 go build 進行編譯時,需要通過 CC 和 CC_FOR_TARGET 環境變量指定所使用的交叉編譯器。

為了基于同一份 Dockerfile 執行多個目標平臺的編譯(假設目標架構只有 amd64/arm64),最下方的 RUN 指令使用了一個小技巧,通過 Bash 的條件判斷語法來執行不同的編譯命令:

假如構建任務的目標平臺是 arm64,則指定 CC 和 CC_FOR_TARGET 環境變量為已安裝的交叉編譯器(注意它們的值有所不同)。

假如構建任務的目標平臺是 amd64,則不指定交叉編譯器相關的變量,此時將使用默認的 gcc 作為編譯器。

最后使用 buildx 執行構建的命令如下:

$ docker buildx build --platform linux/amd64,linux/arm64 -t registry.cn-hangzhou.aliyuncs.com/waynerv/cgo-demo -o type=registry .

總結

有了 Buildx 插件的幫助,在缺少基礎設施的情況下,我們也能使用 docker 方便地構建跨平臺的應用鏡像。

但默認通過 QEMU 虛擬化目標平臺指令的方式有明顯的性能瓶頸,如果編寫應用的語言支持交叉編譯,我們可以通過結合 buildx 和交叉編譯獲得更高的效率。

本文最后介紹了一種進階場景的解決方案:如何對使用了 CGO 的 Golang 項目進行交叉編譯,并給出了編譯到 linux/arm64 平臺的示例。


參考資料

Leverage multi-CPU architecture support:

https://docs.docker.com/desktop/multi-arch/

Docker Buildx:

https://docs.docker.com/buildx/working-with-buildx/#build-with-buildx

docker buildx build:

https://docs.docker.com/engine/reference/commandline/buildx_build/#output

C? Go? Cgo! - go.dev:

https://go.dev/blog/cgo

Cross-Compiling Golang (CGO) Projects

https://dh1tw.de/2019/12/cross-compiling-golang-cgo-projects/

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容