Docker鏡像如何做到“一次構(gòu)建,到處運(yùn)行”?
在每個黑客的職業(yè)生涯中總有這么一個時刻需要為另一種 CPU 架構(gòu)編譯應(yīng)用程序。這種場景可能出現(xiàn)在為樹莓派項目編譯應(yīng)用程序,為嵌入式設(shè)備創(chuàng)建自定義鏡像,或者讓自己的軟件支持不同平臺。亦或是,我們只是想知道這個過程是怎么樣的,或者好奇最終匯編代碼和桌面電腦上無處不在的 x86-64/amd64 架構(gòu)匯編有何區(qū)別。
不論是哪種原因,通常我們都需要整理好行裝進(jìn)行一段朝圣之旅。但是這個旅程不是登上孤獨(dú)的山頂,而是通向地獄深淵,是一段從開發(fā)應(yīng)用程序的陽光平原走向計算機(jī)體系結(jié)構(gòu)的黑暗洞穴之旅:底層系統(tǒng)和嵌入式變化帶來的難以捉摸的世界。介于這次跋涉的前景堪憂,大部分黑客最終通過 Ctrl+Z 結(jié)束了旅程,回到了地面,一邊喘氣一邊警告同伴交叉編譯、QEMU 和 chroot 的恐怖之處。
好了,我可能有點(diǎn)夸張了。但是真相是為其他 CPU 架構(gòu)構(gòu)建應(yīng)用程序沒有那么直截了當(dāng)。多虧了 Docker 19.03 帶來實驗性的插件,讓多架構(gòu)構(gòu)建比以往要方便很多。
為了理解 Docker 對多架構(gòu)構(gòu)建支持的重要性,首先我們需要了解如何為陌生架構(gòu)構(gòu)建應(yīng)用程序。
背景:為陌生架構(gòu)編譯應(yīng)用的方法
注:讀者如果對本節(jié)概念已經(jīng)了解,或者只是想知道如何構(gòu)建鏡像,可以跳過本節(jié)。
讓我們快速了解下當(dāng)前對于為陌生架構(gòu)編譯應(yīng)用程序的方法。
方法 1:直接在目標(biāo)硬件上構(gòu)建
如果我們可以訪問目標(biāo)架構(gòu)硬件,同時操作系統(tǒng)上有我們所需的所有構(gòu)建數(shù)據(jù),那么就可以直接在硬件上編譯應(yīng)用程序。
例如,對我們特定場景下構(gòu)建多架構(gòu) Docker 鏡像,可以在樹莓派上安裝 Docker 運(yùn)行時環(huán)境,然后和在開發(fā)機(jī)上一樣,直接在上面通過應(yīng)用程序的 Dockerfile 構(gòu)建鏡像。該方法是可行的,因為樹莓派的官方操作系統(tǒng) Raspbian 支持本地安裝 Docker。
但是,如果我們沒法辦法方便的訪問目標(biāo)硬件呢?我們可以在開發(fā)機(jī)器上直接構(gòu)建非本地架構(gòu)的應(yīng)用程序嗎?
方法 2:模擬目標(biāo)硬件
還記得和 16 位任天堂游戲機(jī)一起的快樂時光嗎?當(dāng)時我只是一個小孩子,但是當(dāng)我長大一點(diǎn)之后,我發(fā)現(xiàn)對諸如《超級瑪麗》和《時空之輪》等經(jīng)典游戲非常懷念。不過我沒有機(jī)會擁有一臺超級任天堂游戲機(jī),但是多虧了像 ZSNES 這樣的模擬器,讓我能回到過去,在 32 位個人電腦上體驗這些經(jīng)典游戲帶來的樂趣。
通過模擬器,我們不僅能夠玩電子游戲,還能夠構(gòu)建非本地二進(jìn)制文件。當(dāng)然這里不是使用 ZSNES,而是使用更加強(qiáng)大更靈活的模擬器:QEMU。QEMU 是一個自由且開源的模擬器,支持許多通用架構(gòu),包括:ARM、Power-PC 和 RISC-V。通過運(yùn)行一個全功能模擬器,我們可以啟動一個可以運(yùn)行 Linux 操作系統(tǒng)的通用 ARM 虛擬機(jī),然后在虛擬機(jī)中設(shè)置開發(fā)環(huán)境,編譯應(yīng)用程序。
但是,如果仔細(xì)思考下,一個全功能虛擬機(jī)有一些浪費(fèi)資源。在該模式下,QEMU 會模擬整個系統(tǒng),包括諸如定時器、內(nèi)存控制器、SPI 和 I2C 總線控制器等硬件。但是大部分情況下,我們編譯應(yīng)用程序不會關(guān)心以上所提到的硬件特性。還能更好么?
方法 3:通過 binfmt_misc 模擬目標(biāo)架構(gòu)的用戶空間
在 Linux 系統(tǒng)上,QEMU 有另外一種操作模式,可以通過用戶模式模擬器來運(yùn)行非本地架構(gòu)的二進(jìn)制程序。該模式下,QEMU 會跳過方法 2 中描述的對整個目標(biāo)系統(tǒng)硬件的模擬,取而代之的是通過 binfmt_misc 在 Linux 內(nèi)核注冊一個二進(jìn)制格式處理程序,將陌生二進(jìn)制代碼攔截并轉(zhuǎn)換后再執(zhí)行,同時將系統(tǒng)調(diào)用按需從目標(biāo)系統(tǒng)轉(zhuǎn)換成當(dāng)前系統(tǒng)。最終對于用戶來說,他們會發(fā)現(xiàn)可以在本機(jī)運(yùn)行這些異構(gòu)二進(jìn)制程序。
通過用戶態(tài)模擬器和 QEMU,我們可以通過輕量級虛擬化(chroot 或者容器)來安裝其他 Linux 發(fā)行版,并像在本地一樣編譯我們需要的異構(gòu)二進(jìn)制程序。
下面我們會看到這將會是構(gòu)建多架構(gòu) Docker 鏡像的可選方式。
方法 4:使用交叉編譯器
最后,我們還有一種在嵌入式系統(tǒng)社區(qū)標(biāo)準(zhǔn)的做法:交叉編譯。
交叉編譯器是一個特殊的編譯器,它運(yùn)行在主機(jī)架構(gòu)上,但是可以為不同的目標(biāo)架構(gòu)生成的二進(jìn)制程序。例如,我們可以有一個 amd64 架構(gòu)的 C++ 交叉編譯器,目標(biāo)架構(gòu)是一個 aarch64(64 位 ARM)的嵌入式設(shè)備(例如一個智能手機(jī)或者其他東西)?;谶@種方式的一個現(xiàn)實中的例子是,世界上數(shù)十億安卓設(shè)備都使用這種方式來構(gòu)建軟件。
從性能上考慮,這種方式有和直接在目標(biāo)硬件上構(gòu)建(方法 1)相同的效率,因為它沒有運(yùn)行在模擬器上。但是交叉編譯的變數(shù)取決于使用的編程語言,如果是 Go 語言就非常方便。
搞糊涂了嗎?對于 Docker 鏡像來說會更復(fù)雜……
注意前面提到的所有編譯方式都只是生成單一的應(yīng)用程序二進(jìn)制文件。對于現(xiàn)代容器來說,當(dāng)我們引入 Docker 鏡像的時候,不僅僅是關(guān)于構(gòu)建單獨(dú)的二進(jìn)制文件,而是構(gòu)建一整個異構(gòu)容器鏡像!這比之前說的要更加麻煩。
如果所有這些聽上去很痛苦,不要難過,因為構(gòu)建非本地平臺二進(jìn)制程序本來就很痛苦。在此之上增加 Docker 帶來的復(fù)雜度,看起來應(yīng)該留給專家來處理。
感謝最新版本 Docker 運(yùn)行時環(huán)境帶來的實驗性擴(kuò)展,構(gòu)建多架構(gòu)鏡像現(xiàn)在比以前方便多了。
構(gòu)建多架構(gòu) Docker 鏡像
為了能夠更方便的構(gòu)建多架構(gòu) Docker 鏡像,我們可以使用最近發(fā)布的 Docker 擴(kuò)展:buildx。buildx 是下一代標(biāo)準(zhǔn) docker build 命令的前端,既我們熟悉的用于構(gòu)建 Docker 鏡像的命令。通過借助 BuildKit 的所有功能,buildx 擴(kuò)展了表中 docker build 命令的功能,成為 Docker 構(gòu)建系統(tǒng)的新后端。
讓我們花幾分鐘看下如何使用 buildx 來構(gòu)建多架構(gòu)鏡像。
步驟 1:開啟 buildx
要使用 buildx,首先要確認(rèn)我們的 Docker 運(yùn)行時環(huán)境已經(jīng)是最新版本 19.03。新版本中,buildx 事實上已經(jīng)默認(rèn)和 Docker 捆綁在一起,但是需要通過設(shè)置環(huán)境變量 DOCKER_CLI_EXPERIMENTAL 來開啟。讓我們在當(dāng)前命令行會話中開啟:
- $ export DOCKER_CLI_EXPERIMENTAL=enabled
通過檢查版本來驗證目前我們已經(jīng)可以使用 buildx:
- $ docker buildx version
- github.com/docker/buildx v0.3.1-tp-docker 6db68d029599c6710a32aa7adcba8e5a344795a7
可選步驟:從源碼構(gòu)建
如果要使用最新版本的 buildx,或者在當(dāng)前環(huán)境下設(shè)置 DOCKER_CLI_EXPERIMENTAL 環(huán)境變量不生效(例如我發(fā)現(xiàn)在 Arch Linux 系統(tǒng)中設(shè)置無效),我們可以從源碼構(gòu)建 buildx:
- $ 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
步驟 2:開啟 binfmt_misc 來運(yùn)行非本地架構(gòu) Docker 鏡像
如果讀者使用的是 Mac 或者 Windows 版本 Docker 桌面版,可以跳過這個步驟,因為 binfmt_misc 默認(rèn)開啟。
如果使用是 Linux 系統(tǒng),需要設(shè)置 binfmt_misc。在大部分發(fā)行版中,這個操作非常簡單,但是現(xiàn)在可以通過運(yùn)行一個特權(quán) Docker 容器來更方便的設(shè)置:
- $ docker run --rm --privileged docker/binfmt:66f9012c56a8316f9244ffd7622d7c21c1f6f28d
通過檢查 QEMU 處理程序來驗證 binfmt_misc 設(shè)置是否正確:
- $ ls -al /proc/sys/fs/binfmt_misc/
- total 0
- drwxr-xr-x 2 root root 0 Nov 12 09:19 .
- dr-xr-xr-x 1 root root 0 Nov 12 09:16 ..
- -rw-r--r-- 1 root root 0 Nov 12 09:25 qemu-aarch64
- -rw-r--r-- 1 root root 0 Nov 12 09:25 qemu-arm
- -rw-r--r-- 1 root root 0 Nov 12 09:25 qemu-ppc64le
- -rw-r--r-- 1 root root 0 Nov 12 09:25 qemu-s390x
- --w------- 1 root root 0 Nov 12 09:19 register
- -rw-r--r-- 1 root root 0 Nov 12 09:19 status
然后,驗證下指定架構(gòu)處理程序已經(jīng)啟用,例如:
- $ cat /proc/sys/fs/binfmt_misc/qemu-aarch64
- enabled
- interpreter /usr/bin/qemu-aarch64
- flags: OCF
- offset 0
- magic 7f454c460201010000000000000000000200b7
- mask ffffffffffffff00fffffffffffffffffeffff
步驟 3:將默認(rèn) Docker 鏡像構(gòu)建器切換成多架構(gòu)構(gòu)建器
默認(rèn)情況下,Docker 會使用舊的構(gòu)建器,不支持多架構(gòu)構(gòu)建。
為了創(chuàng)建一個新的支持多架構(gòu)的構(gòu)建器,運(yùn)行:
- $ docker buildx create --use --name mybuilder
驗證新的構(gòu)建器已經(jīng)生效:
- $ docker buildx ls
- NAME/NODE DRIVER/ENDPOINT STATUS PLATFORMS
- mybuilder * docker-container
- mybuilder0 unix:///var/run/docker.sock inactive
- default docker
- default default running linux/amd64, linux/arm64, linux/ppc64le, linux/s390x, linux/386, linux/arm/v7, linux/arm/v6
搞定?,F(xiàn)在 Docker 會使用新的構(gòu)建器,支持構(gòu)建多架構(gòu)鏡像。
步驟 4:構(gòu)建多架構(gòu)鏡像
好了,現(xiàn)在我們終于可以開始構(gòu)建一個多架構(gòu)鏡像了。為了演示這個功能,我們需要一個示例應(yīng)用。
讓我們創(chuàng)建一個簡單的 Go 應(yīng)用程序,輸出當(dāng)前運(yùn)行環(huán)境的架構(gòu)信息:
- $ cat hello.go
- package main
- import (
- "fmt"
- "runtime"
- )
- func main() {
- fmt.Printf("Hello, %s!\n", runtime.GOARCH)
- }
讓我們創(chuàng)建一個 Dockerfile 來容器化這個應(yīng)用
- $ cat Dockerfile
- FROM golang:alpine AS builder
- RUN mkdir /app
- ADD . /app/
- WORKDIR /app
- RUN go build -o hello .
- FROM alpine
- RUN mkdir /app
- WORKDIR /app
- COPY --from=builder /app/hello .
- CMD ["./hello"]
這是一個多階段 Dockerfile,通過 Go 編譯器構(gòu)建我們的應(yīng)用程序,然后將構(gòu)建出來的二進(jìn)制程序使用 Alpine Linux 鏡像創(chuàng)建成最小鏡像。
現(xiàn)在,讓我們使用 buildx 來構(gòu)建一個支持 arm、arm64 和 amd64 架構(gòu)的多架構(gòu)鏡像,并一次性推送到 Docker Hub:
- $ docker buildx build -t mirailabs/hello-arch --platform=linux/arm,linux/arm64,linux/amd64 . --push
是的,就是這樣?,F(xiàn)在 Docker Hub 上我們有了 支持 arm、arm64 和 amd64 架構(gòu)的多架構(gòu) Docker 鏡像。當(dāng)我們運(yùn)行 docker pull mirailabs/hello-arch 時,Docker 會根據(jù)機(jī)器的架構(gòu)來獲取匹配的鏡像。
如果讀者要問 buildx 是如何實現(xiàn)這個魔法的?好吧,在命令的背后,buildx 使用 QEMU 和 binfmt_misc 創(chuàng)建了三個 Docker 鏡像(arm、arm64 和 amd64 架構(gòu)每個創(chuàng)建一個)。當(dāng)構(gòu)建完成后,Docker 會創(chuàng)建一個清單,其中包含這三個鏡像以及他們對應(yīng)的架構(gòu)。換句話說,“多架構(gòu)鏡像”實際上是一個清單,列舉了每個架構(gòu)對應(yīng)的鏡像。
步驟 5:測試多架構(gòu)鏡像
讓我們來快速測試下多架構(gòu)鏡像,以確保它們都能夠正常工作。由于我們已經(jīng)設(shè)置了 binfmt_misc,因此在開發(fā)機(jī)器上已經(jīng)能夠執(zhí)行任何架構(gòu)的鏡像了。
首先,列出每個鏡像的散列值:
- $ docker buildx imagetools inspect mirailabs/hello-arch
- Name: docker.io/mirailabs/hello-arch:latest
- MediaType: application/vnd.docker.distribution.manifest.list.v2+json
- Digest: sha256:bbb246e520a23e41b0c6d38b933eece68a8407eede054994cff43c9575edce96
- Manifests:
- Name: docker.io/mirailabs/hello-arch:latest@sha256:5fb57946152d26e64c8303aa4626fe503cd5742dc13a3fabc1a890adfc2683df
- MediaType: application/vnd.docker.distribution.manifest.v2+json
- Platform: linux/arm/v7
- Name: docker.io/mirailabs/hello-arch:latest@sha256:cc6e91101828fa4e464f7eddec3fa7cdc73089560cfcfe4af16ccc61743ac02b
- MediaType: application/vnd.docker.distribution.manifest.v2+json
- Platform: linux/arm64
- Name: docker.io/mirailabs/hello-arch:latest@sha256:cd0b32276cdd5af510fb1df5c410f766e273fe63afe3cec5ff7da3f80f27985d
- MediaType: application/vnd.docker.distribution.manifest.v2+json
- Platform: linux/amd64
有了這些散列值的幫助,我們可以逐一運(yùn)行鏡像,并觀察其輸出:
- $ docker run --rm docker.io/mirailabs/hello-arch:latest@sha256:5fb57946152d26e64c8303aa4626fe503cd5742dc13a3fabc1a890adfc2683df
- Hello, arm!
- $ docker run --rm docker.io/mirailabs/hello-arch:latest@sha256:cc6e91101828fa4e464f7eddec3fa7cdc73089560cfcfe4af16ccc61743ac02b
- Hello, arm64!
- $ docker run --rm docker.io/mirailabs/hello-arch:latest@sha256:cd0b32276cdd5af510fb1df5c410f766e273fe63afe3cec5ff7da3f80f27985d
- Hello, amd64!
看上去很簡單,不是么?
總結(jié)
概括一下,本文我們了解了軟件支持多 CPU 架構(gòu)帶來的挑戰(zhàn),以及 Docker 的實驗性擴(kuò)展 buildx 如何幫助我們解決這些挑戰(zhàn)。通過使用 buildx,我們可以快速構(gòu)建一個多架構(gòu) Docker 鏡像,支持 arm、arm64 和 amd64 架構(gòu),而不需要修改 Dockerfile。同時這個鏡像可以推送到 Docker Hub,任何 Docker 支持的平臺都可以根據(jù)自己的架構(gòu)拉取對應(yīng)的鏡像。
未來,buildx 能力很有可能成為標(biāo)準(zhǔn) docker build 命令的一部分,我們可以不需要為使用這個功能做額外設(shè)置。把交叉編譯應(yīng)用程序比作跌入深淵的故事,不就將變成原始時代的鬼故事了。
前進(jìn),無懼多架構(gòu)!