Categories
程式開發

如何構建多架構 Docker 鏡像?


在每個黑客的職業生涯中總有這麼一個時刻需要為另一種CPU架構編譯應用程序。這種場景可能出現在為樹莓派項目編譯應用程序,為嵌入式設備創建自定義鏡像,或者讓自己的軟件支持不同平台。亦或是,我們只是想知道這個過程是怎麼樣的,好奇最終彙編代碼和桌面電腦上無處不在的x86-64/amd64架構彙編有何區別。

不論是哪種原因,通常我們都需要整理好行裝進行一段朝聖之旅。但是這個旅程不是登上孤獨的山頂,而是通向地獄深淵,是一段從開發應用程序的陽光平原走向計算機體系結構的黑暗洞穴之旅:底層系統和嵌入式變成帶來的難以捉摸的世界。介於這次跋涉的前景堪憂,大部分黑客最終通過Ctrl+Z結束了旅程,回到了地面,一邊喘氣一邊警告同伴交叉編譯、QEMU和chroot的恐怖之處。

好了,我可能有點誇張了。但是真相是為其他CPU架構構建應用程序沒有那麼直截了當。多虧了Docker 19.03帶來實驗性的插件,讓多架構構建比以往要方便很多。

為了理解Docker對多架構構建支持的重要性,首先我們需要了解如何為陌生架構構建應用程序。

背景:為陌生架構編譯應用的方法

注:讀者如果對本節概念已經了解,或者只是想知道如何構建鏡像,可以跳過本節。

讓我們快速了解下當前對於為陌生架構編譯應用程序的方法。

方法1:直接在目標硬件上構建

如果我們可以訪問目標架構硬件,同時操作系統上有我們所需的所有構建夠據,那麼就可以直接在硬件上編譯應用程序。

例如,對我們特定場景下構建多架構Docker鏡像,可以在樹莓派上安裝Docker運行時環境,然後和在開發機上一樣,直接在上面通過應用程序的Dockerfile構建鏡像。該方法是可行的,因為樹莓派的官方操作系統Raspbian支持本地安裝Docker。

但是,如果我們沒法辦法方便的訪問目標硬件呢?我們可以在開發機器上直接構建非本地架構的應用程序嗎?

方法2:模擬目標硬件

還記得和16位任天堂遊戲機一起的快樂時光嗎?當時我只是一個小孩子,但是當我長大一點之後,我發現對諸如《超級瑪麗》和《時空之輪》等經典遊戲非常懷念。不過我沒有機會擁有一台超級任天堂遊戲機,但是多虧了像ZSNES這樣的模擬器,讓我能回到過去,在32位個人電腦上體驗這些經典遊戲帶來的樂趣。

通過模擬器,我們不僅能夠玩電子遊戲,還能夠構建非本地二進製文件。當然這裡不是使用ZSNES,而是使用更加強大更靈活的模擬器:QEMU。 QEMU是一個自由且開源的模擬器,支持許多通用架構,包括:ARM、Power-PC和RISC-V。通過運行一個全功能模擬器,我們可以啟動一個可以運行Linux操作系統的通用ARM虛擬機,然後在虛擬機中設置開發環境,編譯應用程序。

但是,如果仔細思考下,一個全功能虛擬機有一些浪費資源。在該模式下,QEMU會模擬整個系統,包括諸如定時器、內存控制器、SPI和I2C總線控制器等硬件。但是大部分情況下,我們編譯應用程序不會關心以上所提到的硬件特性。還能更好麼?

方法3:通過binfmt_misc模擬目標架構的用戶空間

在Linux系統上,QEMU有另外一種操作模式,可以通過用戶模式模擬器來運行非本地架構的二進製程序。該模式下,QEMU會跳過方法2中描述的對整個目標系統硬件的模擬,取而代之的是通過binfmt_misc在Linux內核註冊一個二進制格式處理程序,將陌生二進制代碼攔截並轉換後再執行,同時將系統調用按需從目標系統轉換成當前系統。最終對於用戶來說,他們會發現可以在本機運行這些異構二進製程序。

通過用戶態模擬器和QEMU,我們可以通過輕量級虛擬化(chroot或者容器)來安裝其他Linux發行版,並像在本地一樣編譯我們需要的異構二進製程序。

下面我們會看到這將會是構建多架構Docker鏡像的可選方式。

方法4:使用交叉編譯器

最後,我們還有一種在嵌入式系統社區標準的做法:交叉編譯。

交叉編譯器是一個特殊的編譯器,它運行在主機架構上,但是可以為不同的目標架構生成的二進製程序。例如,我們可以有一個amd64架構的C++交叉編譯器,目標架構是一個aarch64(64位ARM)的嵌入式設備(例如一個智能手機或者其他東西)。基於這種方式的一個現實中的例子是,世界上數十億安卓設備都使用這種方式來構建軟件。

從性能上考慮,這種方式擁有和直接在目標硬件上構建(方法1)相同的效率,因為它沒有運行在模擬器上。但是交叉編譯的變數取決於使用的​​編程語言(對於Go語言就非常方便)。

搞糊塗了嗎?對於Docker鏡像來說會更複雜……

注意前面提到的所有編譯方式都只是生​​成單一的應用程序二進製文件。對於現代容器來說,當我們引入Docker鏡像的時候,不僅僅是關於構建單獨的二進製文件,而是構建一整個異構容器鏡像!這比之前說的要更加麻煩。

如果這些聽上去都很痛苦,不要難過,因為構建非本地平台二進製程序本來就很痛苦。在此之上增加Docker帶來的複雜度,看起來應該留給專家來處理。

感謝最新版本Docker運行時環境帶來的實驗性擴展,構建多架構鏡像現在比以前方便多了。

構建多架構Docker鏡像

為了能夠更方便的構建多架構Docker鏡像,我們可以使用最近發布的Docker擴展:buildx。 buildx是下一代標準docker build命令的前端,既我們熟悉的用於構建Docker鏡像的命令。通過借助BuildKit的所有功能,buildx擴展了表中docker build命令的功能,成為Docker構建系統的新後端。

讓我們花幾分鐘看下如何使用buildx來構建多架構鏡像。

步驟1:開啟buildx

要使用buildx,首先要確認我們的Docker運行時環境已經是最新版本 19.03。新版本中,buildx事實上已經默認和Docker捆綁在一起,但是需要通過設置環境變量DOCKER_CLI_EXPERIMENTAL來開啟。讓我們在當前命令行會話中開啟:

$ export DOCKER_CLI_EXPERIMENTAL=enabled

通過檢查版本來驗證目前我們已經可以使用buildx:

$ docker buildx version
github.com/docker/buildx v0.3.1-tp-docker 6db68d029599c6710a32aa7adcba8e5a344795a7

可選步驟:從源碼構建

如果要使用最新版本的buildx,或者在當前環境下設置DOCKER_CLI_EXPERIMENTAL環境變量不生效(例如我發現在Arch Linux系統中設置無效),我們可以從源碼構建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系統,需要設置binfmt_misc。在大部分發行版中,這個操作非常簡單,但是現在可以通過運行一個特權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

然後,驗證下指定架構處理程序已經啟用,例如:

$ 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會使用舊的構建器,不支持多架構構建。

為了創建一個新的支持多架構的構建器,需要運行:

$ docker buildx create --use --name mybuilder

驗證新的構建器已經生效:

$ 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

搞定。現在Docker會使用新的構建器,支持構建多架構鏡像。

步驟4:構建多架構鏡像

好了,現在我們終於可以開始構建一個多架構鏡像了。為了演示這個功能,我們需要一個示例應用。

讓我們創建一個簡單的Go應用程序,輸出當前運行環境的架構信息:

$ cat hello.go
package main

import (
        "fmt"
        "runtime"
)

func main() {
        fmt.Printf("Hello, %s!n", runtime.GOARCH)
}

讓我們創建一個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鏡像創建成最小鏡像。

現在,讓我們使用buildx來構建一個支持arm、arm64和amd64架構的多架構鏡像,並一次性推送到Docker Hub:

$ docker buildx build -t mirailabs/hello-arch --platform=linux/arm,linux/arm64,linux/amd64 . --push

是的,就是這樣。現在Docker Hub上我們有了支持arm、arm64和amd64架構的多架構Docker鏡像。當我們運行docker pull mirailabs/hello-arch時,Docker會根據機器的架構來獲取匹配的鏡像。

如果讀者要問buildx是如何實現這個魔法的?好吧,在命令的背後,buildx使用QEMU和binfmt_misc創建了三個Docker鏡像(arm、arm64和amd64架構每個創建一個)。當構建完成後,Docker會創建一個清單,其中包含這三個鏡像以及他們對應的架構。換句話說,“多架構鏡像”實際上是一個清單,列舉了每個架構對應的鏡像。

步驟5:測試多架構鏡像

讓我們來快速測試下多架構鏡像,以確保它們都能夠正常工作。由於我們已經設置了binfmt_misc,因此在開發機器上已經能夠執行任何架構的鏡像了。

首先,列出每個鏡像的散列值:

$ 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:[email protected]:5fb57946152d26e64c8303aa4626fe503cd5742dc13a3fabc1a890adfc2683df
  MediaType: application/vnd.docker.distribution.manifest.v2+json
  Platform:  linux/arm/v7

  Name:      docker.io/mirailabs/hello-arch:[email protected]:cc6e91101828fa4e464f7eddec3fa7cdc73089560cfcfe4af16ccc61743ac02b
  MediaType: application/vnd.docker.distribution.manifest.v2+json
  Platform:  linux/arm64

  Name:      docker.io/mirailabs/hello-arch:[email protected]:cd0b32276cdd5af510fb1df5c410f766e273fe63afe3cec5ff7da3f80f27985d
  MediaType: application/vnd.docker.distribution.manifest.v2+json
  Platform:  linux/amd64

有了這些散列值的幫助,我們可以逐一運行鏡像,並觀察其輸出:

$ docker run --rm docker.io/mirailabs/hello-arch:[email protected]:5fb57946152d26e64c8303aa4626fe503cd5742dc13a3fabc1a890adfc2683df
Hello, arm!

$ docker run --rm docker.io/mirailabs/hello-arch:[email protected]:cc6e91101828fa4e464f7eddec3fa7cdc73089560cfcfe4af16ccc61743ac02b
Hello, arm64!

$ docker run --rm docker.io/mirailabs/hello-arch:[email protected]:cd0b32276cdd5af510fb1df5c410f766e273fe63afe3cec5ff7da3f80f27985d
Hello, amd64!

看上去很簡單,不是麼?

總結

概括一下,本文我們了解了軟件支持多CPU架構帶來的挑戰,以及Docker的實驗性擴展buildx如何幫助我們解決這些挑戰。通過使用buildx,我們可以快速構建一個多架構Docker鏡像,支持arm、arm64和amd64架構,而不需要修改Dockerfile。同時這個鏡像可以推送到Docker Hub,任何Docker支持的平台都可以根據自己的架構拉取對應的鏡像。

未來,buildx能力很有可能稱為標準docker build命令的一部分,我們可以不需要為使用這個功能做額外設置。

前進,無懼多架構!

參考文獻

原文鏈接:

https://mirailabs.io/blog/multiarch-docker-with-buildx/