docker 跨平台镜像构建,信创必备

tool   docker  
发布于 Aug 14, 2024

由于工作需要,再加上现在信创的火爆,之前单架构(x86)已经满足不了需求了,拥抱 arm 架构已成趋势,第一 arm 架构服务器便宜(以云平台为例,arm 比 x86 便宜30%),第二 arm 架构具有较先进的设计可能性能上更加优秀,总之,拥抱新事物,总会得到新的收获。

我相信大部分公司容器化使用的依然是 docker 吧,docker 镜像构建是容器化的基石,今天我就来带大家熟悉一下 docker 构建跨平台镜像 的玩法。

我的文章喜欢先贴官网权威文档,因为要让大家知道追本溯源,要想学到第一手资料,还是鼓励大家多多尝试阅读官网文档,当然,也不排除官网有时候写的比较浅显,覆盖不了具体的使用场景,实战性不强(外链接跳来跳去抓不住重点)。

https://docs.docker.com/build/building/multi-platform/

概念

这里所指的跨平台就是指一份代码可以编译运行在多种不同的 cpu 架构下,如 x86(amd), arm 架构,不是指不同的操作系统。

我们所熟知的 java 也宣称的是跨平台的,它其实是指 java 程序可以运行在任何安装了 jvm (java虚拟机)的操作系统上,它最初可能指的是操作系统层面,当然,jvm 也有 arm 架构的适配,也是完全的跨平台的。

交叉编译

目前主流的编程语言基本都是支持交叉编译的,所谓交叉编译是指在一种平台上编译生成另一种平台上可执行的程序,主要有 2 类需求:

  1. 不同操作系统之间的交叉编译,例如在 Windows 上编译生成 Linux 可执行程序
  2. 不同硬件架构之间的交叉编译,例如在 x86 架构上编译生成 ARM 架构可执行程序

不同的编程语言可能需要的相关依赖环境不同,我仅举例我熟悉的几种作为参考,如未覆盖您的技术栈,你可以自行搜索就可以找到相关的资源。

Java

上面也说了,Java 是跨平台的,Java 的跨平台是依托 JVM,所以 Java 直接正常打包编译即可,只需要打成 jar, war 字节码就可以了,不同的平台安装对应平台的 JVM 环境(JRE/JDK)即可。

Golang

golang 不像 Java 拥有 JVM,需要编译多份二进制文件,好在 golang 提供了交叉编译的选项,在golang中,你可以使用 GOOS 和 GOARCH 环境变量来指定目标平台:

# 编译 win/amd64
GOOS=windows GOARCH=amd64 go build -o yourapp.exe
# 编译 mac/amd64
GOOS=darwin GOARCH=amd64 go build -o yourapp_osx
# 编译 linux/arm64
GOOS=linux GOARCH=arm64 go build -o yourapp_arm64

由于 arm 还有不同的版本,golang 还提供了 GOARM 环境变量

# 编译 linux/arm/v7
GOOS=linux GOARCH=arm GOARM=7 go build -o yourapp_armv7

解释性语言

对于一些解释性语言,如 PHP,Python, Lua,HTML/CSS/JS 这一类,压根不需要编译,只要安装对应的环境即可(如安装 Python 环境,安装 PHP 环境,跟Java 类似)

好了,简单普及一下语言本身的交叉编译,回到正题,我们今天的主题是 如何构建跨平台的 容器镜像。

跨平台镜像

大家知道 docker 镜像是一个包含了操作系统核心文件的压缩包,我们在制作服务镜像前,肯定需要找一个基础镜像,如 openjdk, tomcat, centos, nginx 等等,而这些基础镜像除了具有不同的版本之外,还有不同架构,如下图:

https://hub.docker.com/_/python

一般情况下,你在执行 docker pull 的时候,docker 命令会根据你当前架构拉取对应的 OS/ARCH 的版本,如下执行 docker version 会列出 OS/Arch 信息。

# docker version
Client: Docker Engine - Community
 ....
 OS/Arch:           linux/amd64
 ....

Server: Docker Engine - Community
 Engine:
  ....
  OS/Arch:          linux/amd64
  ....

构建镜像

为了直观的演示镜像构建,我们一步步的依据一个最简单的 golang 程序来进行实操(下文也会用到这 2 个文件)。

代码就一个文件 main.go

package main
 
import (
    "fmt"
    "runtime"
)
 
func main() {
    fmt.Println("Hello world!")
    fmt.Printf("Running in [%s] architecture.\n", runtime.GOARCH)
}

Dockerfile, 采用 2 步构建法

FROM golang:1.18 as builder

WORKDIR /app
COPY main.go /app/main.go
RUN go build -a -o output/main main.go

FROM alpine:latest
WORKDIR /root
COPY --from=builder /app/output/main .
CMD /root/main

amd64构建

在 amd64 机器(也就是x86_64机器)执行构建并运行

# 构建 test 镜像
docker build -t test .
# 输出
[+] Building 12.5s (13/13) FINISHED                                                                                                                                                                                  docker:default
 => [internal] load build definition from Dockerfile                                                                                                                                                                           0.4s
 => => transferring dockerfile: 234B                                                                                                                                                                                           0.1s
 => [internal] load metadata for docker.io/library/golang:1.18                                                                                                                                                                 0.0s
 => [internal] load metadata for docker.io/library/alpine:latest                                                                                                                                                               0.0s
 => [internal] load .dockerignore                                                                                                                                                                                              0.0s
 => => transferring context: 2B                                                                                                                                                                                                0.0s
 => [builder 1/4] FROM docker.io/library/golang:1.18                                                                                                                                                                           0.3s
 => [stage-1 1/3] FROM docker.io/library/alpine:latest                                                                                                                                                                         0.0s
 => [internal] load build context                                                                                                                                                                                              0.2s
 => => transferring context: 202B                                                                                                                                                                                              0.0s
 => [stage-1 2/3] WORKDIR /root                                                                                                                                                                                                0.1s
 => [builder 2/4] WORKDIR /app                                                                                                                                                                                                 0.0s
 => [builder 3/4] COPY main.go /app/main.go                                                                                                                                                                                    0.1s
 => [builder 4/4] RUN go build -a -o output/main main.go                                                                                                                                                                      11.0s
 => [stage-1 3/3] COPY --from=builder /app/output/main .                                                                                                                                                                       0.1s
 => exporting to image                                                                                                                                                                                                         0.1s
 => => exporting layers                                                                                                                                                                                                        0.1s
 => => writing image sha256:c33dda75362ac818aa0b1766910d2c452c6756011b0d2ba7f9551ced3ee30e09                                                                                                                                   0.0s
 => => naming to docker.io/library/test 

# 运行 test 镜像
docker run --rm -it test
# 输出
Hello world!
Running in [amd64] architecture.

arm64构建

在 arm64 机器上执行构建并运行

# 构建 test 镜像
docker build -t test .
# 输出
[+] Building 12.5s (13/13) FINISHED                                                                                                                                                                                  docker:default
 => [internal] load build definition from Dockerfile                                                                                                                                                                           0.4s
 => => transferring dockerfile: 234B                                                                                                                                                                                           0.1s
 => [internal] load metadata for docker.io/library/golang:1.18                                                                                                                                                                 0.0s
 => [internal] load metadata for docker.io/library/alpine:latest                                                                                                                                                               0.0s
 => [internal] load .dockerignore                                                                                                                                                                                              0.0s
 => => transferring context: 2B                                                                                                                                                                                                0.0s
 => [builder 1/4] FROM docker.io/library/golang:1.18                                                                                                                                                                           0.3s
 => [stage-1 1/3] FROM docker.io/library/alpine:latest                                                                                                                                                                         0.0s
 => [internal] load build context                                                                                                                                                                                              0.2s
 => => transferring context: 202B                                                                                                                                                                                              0.0s
 => [stage-1 2/3] WORKDIR /root                                                                                                                                                                                                0.1s
 => [builder 2/4] WORKDIR /app                                                                                                                                                                                                 0.0s
 => [builder 3/4] COPY main.go /app/main.go                                                                                                                                                                                    0.1s
 => [builder 4/4] RUN go build -a -o output/main main.go                                                                                                                                                                      11.0s
 => [stage-1 3/3] COPY --from=builder /app/output/main .                                                                                                                                                                       0.1s
 => exporting to image                                                                                                                                                                                                         0.1s
 => => exporting layers                                                                                                                                                                                                        0.1s
 => => writing image sha256:c33dda75362ac818aa0b1766910d2c452c6756011b0d2ba7f9551ced3ee30e09                                                                                                                                   0.0s
 => => naming to docker.io/library/test 

# 运行 test 镜像
# docker run --rm -it test
# 输出
Hello world!
Running in [arm64] architecture.

综述

在 amd64 和 arm64 机器上默认会根据当前机器架构选择镜像拉取,构建基于当前架构的软件包。

因此你能够想到,如果公司有多平台架构构建需求,一种最简单的方式就是在不同架构的机器上进行构建即可,下面我们就来介绍跨平台镜像的构建。

构建跨平台镜像

跨平台镜像本质上是多个镜像,在上面的python镜像截图里大家也看到了,每个镜像都有一个 digest 标识唯一性。

构建跨平台镜像有多种方式,总结下来常用的有如下几种:

  1. 手动,将不同平台的单独的镜像通过类似链接的方式合到一起,可以想象成把小花和小明组成了一个家庭
  2. 通过QEMU模拟器实现编译构建跨平台镜像(本质在一台机器上,创建了不同的cpu指令集虚拟环境)
  3. 通过远程实体编译机器实现编译构建跨平台镜像(任务分派给两个不同架构的机器去干活)

手动合并

手动合并方式效率比较低,步骤比较多,但是比较好理解,自由度更大,你甚至可以把一个 nginx 和 一个 tomcat 合成一个 apache, 主打一个月老乱牵线

推送镜像

我们以上面构建的 不同架构的 test 镜像为例,首先将它们改名分别推送到仓库(这里指私有或公有仓库,例子用的我的仓库,你是执行不了的,可以换成你拥有的仓库)

# amd64 机器
docker tag test chinaxiang/test:v1.0-amd64
docker push chinaxiang/test:v1.0-amd64
# arm64 机器
docker tag test chinaxiang/test:v1.0-arm64
docker push chinaxiang/test:v1.0-arm64

创建Manifest

使用 docker manifest create 命令创建一个 Manifest List,并添加两种架构的镜像作为不同的平台。

docker manifest  create chinaxiang/test:v1.0 chinaxiang/test:v1.0-amd64 chinaxiang/test:v1.0-arm64

注解Manifest

对于 amd64 镜像进行注解

docker manifest annotate chinaxiang/test:v1.0 chinaxiang/test:v1.0-amd64 --os linux --arch amd64

对于 arm64 镜像进行注解

docker manifest annotate chinaxiang/test:v1.0 chinaxiang/test:v1.0-arm64 --os linux --arch arm64

推送Manifest

docker manifest push chinaxiang/test:v1.0

工具脚本

提炼为一个脚本

#!/bin/bash
# 可以替换为你的
crossImage=chinaxiang/test:v1.0
amdImage=chinaxiang/test:v1.0-amd64
armImagechinaxiang/test:v1.0-arm64
# 4 步操作
docker manifest create $crossImage $amdImage $armImage
docker manifest annotate $crossImage $amdImage --os linux --arch amd64
docker manifest annotate $crossImage $armImage --os linux --arch arm64
docker manifest push $crossImage

通过QEMU

官方地址:

https://github.com/tonistiigi/binfmt

QEMU 就是指令集模拟环境,需要 docker version >= 19.03,linux kernel >= 4.8 (uname -a 可以查看内核版本),建议用比较新的吧(太旧的版本有问题别来找我,因为我也没踩过),我这里演示用的 docker version = 26.1.3,直接一键安装:

docker run --privileged --rm tonistiigi/binfmt --install all
# 输出
installing: s390x OK
installing: mips64le OK
installing: mips64 OK
installing: arm64 OK
installing: arm OK
installing: ppc64le OK
installing: riscv64 OK
{
  "supported": [
    "linux/amd64",
    "linux/arm64",
    "linux/riscv64",
    "linux/ppc64le",
    "linux/s390x",
    "linux/386",
    "linux/mips64le",
    "linux/mips64",
    "linux/arm/v7",
    "linux/arm/v6"
  ],
  "emulators": [
    "qemu-aarch64",
    "qemu-arm",
    "qemu-mips64",
    "qemu-mips64el",
    "qemu-ppc64le",
    "qemu-riscv64",
    "qemu-s390x"
  ]
}

# 可以查看
ls -al /proc/sys/fs/binfmt_misc/
total 0
drwxr-xr-x 2 root root 0 Aug 13 22:27 .
dr-xr-xr-x 1 root root 0 Aug 13 21:57 ..
-rw-r--r-- 1 root root 0 Aug 13 22:27 qemu-aarch64
-rw-r--r-- 1 root root 0 Aug 13 22:27 qemu-arm
-rw-r--r-- 1 root root 0 Aug 13 22:27 qemu-mips64
-rw-r--r-- 1 root root 0 Aug 13 22:27 qemu-mips64el
-rw-r--r-- 1 root root 0 Aug 13 22:27 qemu-ppc64le
-rw-r--r-- 1 root root 0 Aug 13 22:27 qemu-riscv64
-rw-r--r-- 1 root root 0 Aug 13 22:27 qemu-s390x
--w------- 1 root root 0 Aug 13 22:27 register
-rw-r--r-- 1 root root 0 Aug 13 22:27 status

正常如果不需要那么多平台,可以有选择性的安装

docker run --privileged --rm tonistiigi/binfmt --install arm64,amd64,riscv64

现在我们来创建一个 multi-builder. 一定要指定 --driver docker-container

docker buildx create --name multi-builder --driver docker-container
docker buildx ls
NAME/NODE            DRIVER/ENDPOINT                   STATUS     BUILDKIT   PLATFORMS
multi-builder        docker-container                                        
 \_ multi-builder0    \_ unix:///var/run/docker.sock   inactive              
default*             docker                                                  
 \_ default           \_ default                       running    v0.13.2    linux/amd64, linux/amd64/v2, linux/amd64/v3, linux/amd64/v4, linux/386, linux/arm64, linux/riscv64, linux/ppc64le, linux/s390x, linux/mips64le, linux/mips64, linux/arm/v7, linux/arm/v6

添加之后,可以通过下面的命令启动构造器(不过直接使用的话也会自动拉起)

docker buildx inspect multi-builder --bootstrap

使用 multi-builder 构建跨平台镜像(–push 会将构建的镜像推送到中央仓库)

# --push 前需要登录自己的仓库账号
docker buildx build -t chinaxiang/test:v1.1 --platform linux/amd64,linux/arm64 --builder multi-builder --push .

执行这一步的时候,会自动拉起一个 容器 :moby/buildkit:buildx-stable-1

docker ps
CONTAINER ID   IMAGE                           COMMAND                  CREATED          STATUS          PORTS     NAMES
86a404e167d3   moby/buildkit:buildx-stable-1   "buildkitd --allow-i…"   22 minutes ago   Up 22 minutes             buildx_buildkit_multi-builder0

docker buildx ls
NAME/NODE            DRIVER/ENDPOINT                   STATUS    BUILDKIT   PLATFORMS
multi-builder        docker-container                                       
 \_ multi-builder0    \_ unix:///var/run/docker.sock   running   v0.15.1    linux/amd64, linux/amd64/v2, linux/amd64/v3, linux/amd64/v4, linux/arm64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/mips64le, linux/mips64, linux/arm/v7, linux/arm/v6
default*             docker                                                 
 \_ default           \_ default                       running   v0.13.2    linux/amd64, linux/amd64/v2, linux/amd64/v3, linux/amd64/v4, linux/386, linux/arm64, linux/riscv64, linux/ppc64le, linux/s390x, linux/mips64le, linux/mips64, linux/arm/v7, linux/arm/v6切换默认的builder,有*号的表示当前builder
docker buildx use multi-builder
docker buildx ls
NAME/NODE            DRIVER/ENDPOINT                   STATUS    BUILDKIT   PLATFORMS
multi-builder*       docker-container                                       
 \_ multi-builder0    \_ unix:///var/run/docker.sock   running   v0.15.1    linux/amd64, linux/amd64/v2, linux/amd64/v3, linux/amd64/v4, linux/arm64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/mips64le, linux/mips64, linux/arm/v7, linux/arm/v6
default              docker                                                 
 \_ default           \_ default                       running   v0.13.2    linux/amd64, linux/amd64/v2, linux/amd64/v3, linux/amd64/v4, linux/386, linux/arm64, linux/riscv64, linux/ppc64le, linux/s390x, linux/mips64le, linux/mips64, linux/arm/v7, linux/arm/v6

通过多台节点

为了构建更加放心,应对更加复杂的构建过程,你也可以采用基于远程节点的方式创建构建器。

创建远程节点需开启 docker 的 tcp 监听,更改其 service 启动文件,添加一条记录 ` -H tcp://0.0.0.0:2375`

vim /usr/lib/systemd/system/docker.service
...
ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:2375 -H fd:// --containerd=/run/containerd/containerd.sock
...

systemctl daemon-reload
systemctl restart docker

创建实例 amd64 机器节点

export DOCKER_HOST=tcp://172.31.154.78:2375
docker buildx create --name remote-builder --driver docker-container --platform linux/amd64 --node node-amd64

追加一台 arm64 机器节点,多一条 --append 参数

export DOCKER_HOST=tcp://172.31.154.79:2375
docker buildx create --name remote-builder --driver docker-container --platform linux/amd64 --append --node node-arm64

这样就会新增一个包含 2 个节点的 builder

docker buildx use remote-builder

# 启动(可选)下面使用的时候也会自动拉起
docker buildx inspect --bootstrap remote-builder

# 临时构建,只在本地cache存在,如果需要推送需要配合 --push 参数
docker buildx build --platform linux/amd64,linux/arm64 .
docker buildx ls
NAME/NODE            DRIVER/ENDPOINT                   STATUS    BUILDKIT   PLATFORMS
multi-builder        docker-container                                       
 \_ multi-builder0    \_ unix:///var/run/docker.sock   running   v0.15.1    linux/amd64, linux/amd64/v2, linux/amd64/v3, linux/amd64/v4, linux/arm64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/mips64le, linux/mips64, linux/arm/v7, linux/arm/v6
remote-builder*      docker-container                                       
 \_ node-amd64        \_ tcp://172.31.154.78:2375      running   v0.15.1    linux/amd64*, linux/amd64/v2, linux/amd64/v3, linux/amd64/v4, linux/arm64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/mips64le, linux/mips64, linux/arm/v7, linux/arm/v6
 \_ node-arm64        \_ tcp://172.31.154.79:2375      running   v0.15.1    linux/amd64*, linux/amd64/v2, linux/amd64/v3, linux/amd64/v4, linux/arm64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/mips64le, linux/mips64, linux/arm/v7, linux/arm/v6
default              docker                                                 
 \_ default           \_ default                       running   v0.13.2    linux/amd64, linux/amd64/v2, linux/amd64/v3, linux/amd64/v4, linux/arm64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/mips64le, linux/mips64, linux/arm/v7, linux/arm/v6

节点添加了,万一需要移除怎么办?也简单,执行下面的命令即可:--leave 参数

docker buildx create --name remote-builder --leave --node node-amd64

利用跨平台变量

有时候在构建的时候需要根据所属的架构进行适当的调整编译参数,这个时候在 Dockerfile 中可以使用到一些变量以辅助我们编写合适的构建步骤。常见变量如下:

  • TARGETPLATFORM, 构建镜像的目标平台,例如 linux/amd64, linux/arm/v7, windows/amd64。
  • TARGETOS, TARGETPLATFORM 的 OS 类型,例如 linux, windows
  • TARGETARCH, TARGETPLATFORM 的架构类型,例如 amd64, arm
  • TARGETVARIANT, TARGETPLATFORM 的变种,该变量可能为空,例如 v7
  • BUILDPLATFORM, 构建镜像主机平台,例如 linux/amd64
  • BUILDOS, BUILDPLATFORM 的 OS 类型,例如 linux
  • BUILDARCH, BUILDPLATFORM 的架构类型,例如 amd64
  • BUILDVARIANT, BUILDPLATFORM 的变种,该变量可能为空,例如 v7

新 Dockerfile

FROM --platform=$BUILDPLATFORM golang:alpine AS build
ARG TARGETPLATFORM
ARG BUILDPLATFORM
RUN echo "I am running on $BUILDPLATFORM, building for $TARGETPLATFORM" > /log
FROM alpine
COPY --from=build /log /log

执行构建,可以看到不同的架构下,RUN 命令输出的内容不同。

docker buildx use multi-builder
docker buildx build --platform linux/amd64,linux/arm64 .
# 输出
[+] Building 13.4s (14/14) FINISHED                                                                                                                                                                        docker-container:multi-builder
...
 => [linux/amd64 build 2/2] RUN echo "I am running on linux/amd64, building for linux/amd64" > /log                                                                                                                                  0.9s
 => [linux/amd64->arm64 build 2/2] RUN echo "I am running on linux/amd64, building for linux/arm64" > /log                                                                                                                           0.9s
...

删除构造器

在上面的实践过程,我们添加了本地的 multi-builder 和 remote-builder, 如果我们不需要了,可以将其进行删除,删除命令如下:

docker buildx stop remote-builder
docker buildx rm remote-builder

总结

本文的分享不是针对完全小白的,有一定的技术门槛,需要你熟悉 docker 及容器化必要的知识,另外对于 服务器 的一些操作也是有一些要求的,不过我相信你应该能看得懂。

一些机器或环境如果自己电脑不具备,比如 arm 机器,可以在云平台上开一个按量付费的主机,操作几个小时再释放就可以了,几毛钱的事情,很便宜,下一篇文章可以给大家介绍介绍怎么快速的在云上开一台测试机,妥妥的比自己搭建虚拟机要便捷的多,并且地域,网络环境,操作系统,CPU架构,应有尽有,如下图。 我就选了一台新加坡的按量计费的机器,每小时 5 分钱,公网带宽也是按量,就远程连接一下,几乎可以忽略不计,用10个小时也才不到 1 块钱,特别香。

好了,今天的分享就到这里,内容有点多,有点干哟!喜欢记得三连哟!

本次的分享到此结束,希望对你有所帮助。

如果你对我分享的内容感兴趣,欢迎扫码关注公众号:新质程序猿,并设置星标,推送更实时哟!

本文由 黄彦祥 创作,采用 知识共享署名 3.0 中国大陆许可协议 进行许可。
可自由转载、引用,但需署名作者且注明文章出处。