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