【保姆级教程】Docker服务在双架构(X86和ARM)编译统一实践

2023-04-01 13:26:12 浏览数 (1)

在现代计算机系统中,X86和ARM64是两种常见的处理器架构。为了满足不同架构的需求,Docker镜像也需要支持双架构编包形式。本文将介绍Docker镜像双架构编包统一的实践

    一、Docker镜像编包

    在Docker镜像中,通常使用多阶段构建来实现。在第一阶段,构建出对应架构服务的二进制文件;在第二阶段,运行对应架构的二进制文件,下面是两个基于Debian的Dockerfile文件示例,分别用于构建X86架构和ARM64架构的Docker镜像:

1.X86架构的Dockerfile文件示例:

代码语言:javascript复制
FROM debian:latest AS builder

RUN apt-get update && apt-get install -y build-essential

WORKDIR /app

COPY . .

RUN make

FROM debian:latest

WORKDIR /app

COPY --from=builder /app/app /app

CMD ["/app/app/install.sh"]

CMD ["/app/app/build.sh"]

 2.ARM64架构的Dockerfile文件示例:

代码语言:javascript复制
FROM arm64v8/debian:latest AS builder

RUN apt-get update && apt-get install -y build-essential

WORKDIR /app

COPY . .

RUN make

FROM arm64v8/debian:latest

WORKDIR /app

COPY --from=builder /app/app /app

CMD ["/app/app/install_arm.sh"]

CMD ["/app/app/build_arm.sh"]

      这两个Dockerfile文件的主要区别在于基础镜像的选择和FROM语句中的架构标识符。X86架构的Dockerfile文件使用了debian:latest作为基础镜像,而ARM64架构的Dockerfile文件使用了arm64v8/debian:latest作为基础镜像。此外,ARM64架构的Dockerfile文件在FROM语句中使用了arm64v8标识符,以指定ARM64架构。最后,CMD执行的安装脚本也不一样,应该是不同的环境需要不同的安装脚本。

    为了方便在双架构环境下部署Docker服务,可以编写一个bash脚本,以执行docker build命令的形式来调用上面两种Dockerfile文件的运行。下面是两个示例bash脚本:

1.X86的bash脚本

代码语言:javascript复制
#!/bin/bash

docker build -t myapp:x86 -f Dockerfile.x86 .

docker manifest create myapp:latest myapp:x86

docker manifest push myapp:latest

2.Arm64的bash脚本

代码语言:javascript复制
#!/bin/bash

docker build -t myapp:arm64 -f Dockerfile.arm64 .

docker manifest create myapp:latest myapp:arm64

docker manifest push myapp:latest

      这个bash脚本中,首先使用docker build命令分别构建X86架构和ARM64架构的Docker镜像,并分别打上myapp:x86和myapp:arm64的标签。然后,使用docker manifest create命令创建一个名为myapp:latest的manifest文件,并将myapp:x86和myapp:arm64的标签添加到manifest文件中。最后,使用docker manifest push命令将manifest文件推送到Docker Hub上,以便在不同架构的计算机系统上使用myapp:latest标签来获取Docker镜像。

具体流程大概是这样:

    二、Docker镜像多架构编包统一

从上面的流程图中,可以看到,编译双架构的镜像基本上需要两套完全独立的脚本,这显然会增加代码量和维护成本,那么有没有可以统一多架构编包的脚本和流程内?答案是显然的,下面就以上面的流程为例,生成一套多架构统一的编译脚本集。

1.合并build_docker.sh脚本

首先是编镜像的启动脚本build_docker.sh,这里之所以有两个脚本,是因为要执行不同的dockerfile,事实上,可以通过传入参数的形式,来动态决定执行不同的dockersfile,比如下面这个示例:

代码语言:javascript复制
#!/bin/bash

# 获取传入的架构参数
ARCH=$1

# 根据不同的架构参数,构建不同的Dockerfile文件
case $ARCH in
  "x86_64")
    DOCKERFILE="Dockerfile.x86_64"
    ;;
  "armv7l")
    DOCKERFILE="Dockerfile.armv7l"
    ;;
  "aarch64")
    DOCKERFILE="Dockerfile.aarch64"
    ;;
  *)
    echo "Unsupported architecture: $ARCH"
    exit 1
    ;;
esac

# 构建Docker镜像
docker build -t myimage:$ARCH -f $DOCKERFILE .

 当然,如果业务本身已经有很多参数了,问了避免混淆和命令层级的一致性,也可以使用opt的别名进行,比如:

代码语言:javascript复制
#!/bin/bash

# 默认架构为x86_64
ARCH="x86_64"

# 处理命令行参数
while getopts "a:" opt; do
  case $opt in
    a)
      ARCH=$OPTARG
      ;;
    ?)
      echo "Invalid option: -$OPTARG" >&2
      exit 1
      ;;
    :)
      echo "Option -$OPTARG requires an argument." >&2
      exit 1
      ;;
  esac
done

# 根据不同的架构参数,构建不同的Dockerfile文件
case $ARCH in
  "x86_64")
    DOCKERFILE="Dockerfile.x86_64"
    ;;
  "armv7l")
    DOCKERFILE="Dockerfile.armv7l"
    ;;
  "aarch64")
    DOCKERFILE="Dockerfile.aarch64"
    ;;
  *)
    echo "Unsupported architecture: $ARCH"
    exit 1
    ;;
esac

# 构建Docker镜像
docker build -t myimage:$ARCH -f $DOCKERFILE .

 这里有个优雅的点:如果没有指定-a选项,则默认使用x86_64架构,方便与已有编译脚本的融合和兼容。如果传入的架构参数不支持,脚本会输出错误信息并退出。

当然,业务需要可能是一次编译多个架构,那还需要对这个脚本再更新一下:

代码语言:javascript复制
#!/bin/bash

# 默认架构为x86_64
ARCHS=("x86_64")

# 处理命令行参数
while getopts "a:" opt; do
  case $opt in
    a)
      ARCHS =("$OPTARG")
      ;;
    ?)
      echo "Invalid option: -$OPTARG" >&2
      exit 1
      ;;
    :)
      echo "Option -$OPTARG requires an argument." >&2
      exit 1
      ;;
  esac
done

# 如果有两个以上的架构参数,则同时构建多个Docker镜像
if [ ${#ARCHS[@]} -ge 2 ]; then
  # 构建Docker镜像
  docker buildx build --platform "${ARCHS[@]/#/--platform }" -t myimage .
else
  # 只有一个架构参数,则按照之前的方式构建Docker镜像
  ARCH=${ARCHS[0]}
  # 根据不同的架构参数,构建不同的Dockerfile文件
  case $ARCH in
    "x86_64")
      DOCKERFILE="Dockerfile.x86_64"
      ;;
    "armv7l")
      DOCKERFILE="Dockerfile.armv7l"
      ;;
    "aarch64")
      DOCKERFILE="Dockerfile.aarch64"
      ;;
    *)
      echo "Unsupported architecture: $ARCH"
      exit 1
      ;;
  esac

  # 构建Docker镜像
  docker build -t myimage:$ARCH -f $DOCKERFILE .
fi

这里涉及到--platform的使用,对应的dockerfile为:

代码语言:javascript复制
FROM --platform=$BUILDPLATFORM golang:1.14 as builder

事实上,还有其他的dockerfile命令可以用

架构相关变量

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

那么通过这三种方式,做到了build_docker.sh脚本的统一

2.合并dockerfile文件

刚才,主要解决了build_docker.sh的合并统一,现在还要解决dockfile文件的一致的问题

在刚才的build_docker脚本中使用

docker build或者

docker buildx build --platform的命令运行dockerfile文件

这里由于dockerfile文件需要根据不同的架构进行编包,内容不同,所以写了两个文件dockerfile.x86和dockerfile.arm

如果可以将架构信息传递到dockerfile中,则可以将这两个文件合二为一

这里主要的执行命令为:

代码语言:javascript复制
docker build --build-arg ARCH=x86_64 -t myimage:x86_64 .

对应的dockerfile文件为:

代码语言:javascript复制
# 构建参数
ARG ARCH

# 根据不同的架构,选择不同的基础镜像
FROM ${ARCH}/debian:latest

# 安装必要的软件包
RUN apt-get update && apt-get install -y gcc g   make && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY . .

RUN make

FROM ${ARCH}/debian:latest

WORKDIR /app 

COPY --from=builder /app/app /app 

CMD ["/app/app/install.sh"]

CMD ["/app/app/build.sh"]

当然这里只是做了个样例,实际上除了FROM里面还有一些安装脚本需要选择,这里就需要用到了IF ELSE命令

 修改上面的脚本如下:

代码语言:javascript复制
# 构建参数
ARG ARCH

# 根据不同的架构,选择不同的基础镜像
FROM ${ARCH}/debian:latest

# 安装必要的软件包
RUN apt-get update && apt-get install -y gcc g   make && rm -rf /var/lib/apt/lists/*

# 复制应用程序源代码
COPY app /app

# 根据不同的架构,选择不同的应用程序目录
RUN if [ "$ARCH" = "x86_64" ]; then 
        cp -r /app/install_x86.sh /app/install.sh; 
    elif [ "$ARCH" = "arm64v8" ]; then 
        cp -r /app/install_arm.sh /app/install.sh; 
    else 
        echo "Unsupported architecture: $ARCH"; 
        exit 1; 
    fi

# 运行安装程序
CMD ["/app/install.sh"]

 通过上面的方法,基本上实现了一个dockerfile文件的多架构镜像编译

那么情况变成了这个样子:

离成功又进了一步

3.合并安装依赖脚本(install.sh)

和上面类似,通过变量传入进行修改

代码语言:javascript复制
#!/bin/bash

# 构建参数
ARCH=$1

# 安装不同的环境依赖
if [ "$ARCH" = "x86_64" ]; then
    apt-get update && apt-get install -y gcc g   make
elif [ "$ARCH" = "arm64v8" ]; then
    apt-get update && apt-get install -y gcc-aarch64-linux-gnu g  -aarch64-linux-gnu make
else
    echo "Unsupported architecture: $ARCH"
    exit 1
fi

# 下载并编译golang程序
wget https://example.com/myapp.tar.gz
tar -xzf myapp.tar.gz
cd myapp
GOOS=linux GOARCH=$ARCH go build -o myapp

# 运行golang程序
./myapp

当然,一般来说依赖安装会稍微复杂一些,有些涉及的是ARm和非ARM的版本问题,有的是版本号的升级和降级,除了上文额if和else之外,还可以使用sed –i命令进行个性化修改,比如安装脚本是这样的:

代码语言:javascript复制
#!/bin/bash

# 安装x86架构的环境依赖
apt-get update && apt-get install -y gcc g   make libssl-dev

# 下载并编译golang程序
wget https://example.com/myapp.tar.gz
tar -xzf myapp.tar.gz
cd myapp
GOOS=linux GOARCH=amd64 go build -o myapp

# 运行golang程序
./myapp

那外部调用脚本可以用下面的方式进行调整

代码语言:javascript复制
#!/bin/bash

# 修改install.sh中的环境依赖
sed -i 's/apt-get install -y gcc g   make libssl-dev/apt-get install -y gcc-aarch64-linux-gnu g  -aarch64-linux-gnu make libssl-dev/g' install.sh

# 调用安装脚本
./install.sh

当然sed -i是比较灵活的修改方式,需要注意可维护性,不然,可能出现改一个脚本,导致一堆脚本不可用

当然,看到这里,可能有个疑问,dockerfile的多架构适配是不是也可以用sed -i的方法,而不用ARG的传参?

这里笔者也比较了下两者的不同

最后,这里的建议是把基本的安装依赖作为基础镜像单独存储,这样可以避免在多个业务镜像中重复编译

大概是这样:

  三、golang多架构编译

1.Golang多系统多架构编译

在Golang中,我们可以通过不同的文件后缀来实现多架构编译。这是因为Golang的编译器可以根据文件后缀来判断需要编译的架构类型。首先,让我们来了解一下不同的文件后缀代表的含义。在Golang中,文件后缀通常由两部分组成,分别是操作系统(GOOS)和架构(GOARCH)。例如,文件名为“hello_windows_amd64.exe”,其中“windows”代表操作系统为Windows,“amd64”代表架构为64位的x86架构。

下面是一个基本的Golang多系统多架构编译示例:

代码语言:javascript复制
package main

import "fmt"

func main() {
    fmt.Println("Hello, world!")
}

 在Linux操作系统下,可以使用以下命令编译该程序:

代码语言:javascript复制
$ go build -o hello_windows_amd64.exe

 在ARM处理器架构下,可以使用以下命令编译该程序:

代码语言:javascript复制
go build -o hello_linux_amd64

这个方法在很多的ARM的信创适配上比较常用

以github上比较常见的日志库为例:

适配时报了这个错误

因为使用了Dup2这个方法报错,dup2是dup命令的一种,还有dup和dup3命令,三者的区别如下

dup(int filedes)函数返回一个可用的与filedes共享文件表项的最小描述符

dup2(int filedes,int filedes2)是使用一个描述符filedes2去指向filedes文件表项(也是共享)

dup3(int oldfd, int newfd, int flags)和dup2相似,不同在于,可以通过指定flags为O_CLOEXEC强制置位新文件描述符的 close-on-exec 标志

事实上,三个方法除了功能上的差异外,在平台适配上也有些不同:

Darwin(MacOS)的X86架构支持: Dup2

Linux的X86架构支持:    Dup2、Dup3

Linux的arm架构支持: Dup3

所以进行适配时,可以根据不同的平台编译不同的文件分别定义对应的方法实现,比如:

2.CGo多系统多架构编译

CGO是Go语言中用于与C语言进行交互的工具,它可以让我们在Go语言中调用C语言的函数和使用C语言的库。在进行CGO编译时,我们需要考虑多系统多架构的问题,以确保我们的程序可以在不同的操作系统和架构中正常运行。

下面是一些CGO多系统多架构编译的方法:

2.1 使用CGO_ENABLED环境变量

使用CGO_ENABLED环境变量。CGO_ENABLED环境变量可以用来控制CGO是否启用。在进行多系统多架构编译时,我们可以设置CGO_ENABLED环境变量为0,这样就可以禁用CGO,从而避免在不同的操作系统和架构中出现问题。

下面是一个具体的例子,假设我们需要编译一个使用了libcurl库的Go程序,并且需要在Linux和Windows操作系统中分别编译出x86和x64架构的程序。我们可以使用以下命令来进行编译:

代码语言:javascript复制
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o myprogram-linux-amd64 main.go 
CGO_ENABLED=1 GOOS=linux GOARCH=386 go build -o myprogram-linux-386 main.go 
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -o myprogram-windows-amd64.exe main.go 
CGO_ENABLED=1 GOOS=windows GOARCH=386 go build -o myprogram-windows-386.exe main.go

这个命令会分别编译出Linux和Windows操作系统中的x86和x64架构的程序

2.2 使用交叉编译工具

使用交叉编译工具。交叉编译工具可以让我们在一台机器上编译出多个不同操作系统和架构的程序。在进行CGO编译时,我们可以使用交叉编译工具来编译出多个不同操作系统和架构的程序,从而确保我们的程序可以在不同的操作系统和架构中正常运行。

下面是一个详细的cgo交叉编译的例子,假设我们需要编译一个使用了libcurl库的Go程序,并且需要在Linux和Windows操作系统中分别编译出x86和x64架构的程序。

安装交叉编译工具 首先,我们需要安装交叉编译工具。在Ubuntu系统中,我们可以使用以下命令来安装交叉编译工具:

代码语言:javascript复制
sudo apt-get install gcc-arm-linux-gnueabihf
sudo apt-get install gcc-mingw-w64-x86-64

 这个命令会安装arm-linux-gnueabihf和mingw-w64-x86-64交叉编译工具,分别用于编译ARM和Windows x64架构的程序。

编写Go程序 接下来,我们需要编写一个使用了libcurl库的Go程序。假设我们的程序代码如下:

代码语言:javascript复制
package main

// #cgo LDFLAGS: -lcurl
// #include <curl/curl.h>
import "C"

import (
    "fmt"
    "unsafe"
)

func main() {
    curl := C.curl_easy_init()
    if curl == nil {
        fmt.Println("Failed to initialize curl")
        return
    }
    defer C.curl_easy_cleanup(curl)

    url := C.CString("https://www.example.com")
    defer C.free(unsafe.Pointer(url))

    C.curl_easy_setopt(curl, C.CURLOPT_URL, url)

    res := C.curl_easy_perform(curl)
    if res != C.CURLE_OK {
        fmt.Println("Failed to perform curl request")
        return
    }

    fmt.Println("Curl request succeeded")
}

 这个程序使用了libcurl库来发送HTTP请求。在程序中,我们使用了CGO LDFLAGS关键字来链接libcurl库,并使用了C语言的头文件来调用libcurl库的函数。

编译ARM架构的程序 接下来,我们需要编译ARM架构的程序。我们可以使用以下命令来编译ARM架构的程序:

代码语言:javascript复制
CGO_ENABLED=1 GOOS=linux GOARCH=arm GOARM=7 CC=arm-linux-gnueabihf-gcc go build -o myprogram-arm main.go

这个命令会使用arm-linux-gnueabihf-gcc交叉编译工具来编译ARM架构的程序,并使用CGO LDFLAGS关键字来链接libcurl库。其中,GOOS=linux表示编译Linux操作系统的程序,GOARCH=arm表示编译ARM架构的程序,GOARM=7表示编译ARMv7架构的程序。

编译Windows x64架构的程序 最后,我们需要编译Windows x64架构的程序。我们可以使用以下命令来编译Windows x64架构的程序:

代码语言:javascript复制
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc go build -o myprogram-windows.exe main.go

 这个命令会使用x86_64-w64-mingw32-gcc交叉编译工具来编译Windows x64架构的程序,并使用CGO LDFLAGS关键字来链接libcurl库。其中,GOOS=windows表示编译Windows操作系统的程序,GOARCH=amd64表示编译x64架构的程序。

这里还有一个比较好的例子:

如何使用 docker buildx 构建跨平台 Go 镜像

2.3 使用CGO LDFLAGS等关键字

使用CGO LDFLAGS等关键字。在进行CGO编译时,我们可以使用CGO LDFLAGS等关键字来指定需要链接的库和编译选项。这些关键字可以让我们在不同的操作系统和架构中使用不同的链接库和编译选项,从而确保我们的程序可以在不同的操作系统和架构中正常运行。

#cgo指令符是用于在Go语言中调用C语言函数和库的关键字。它可以让我们在Go语言中使用C语言的函数和库,从而扩展Go语言的功能。在进行cgo多架构编译时,我们可以使用#cgo指令符来指定不同操作系统和架构下的编译选项。

下面是一些#cgo指令符在cgo多架构编译中的使用方法:

#cgo CFLAGS #cgo CFLAGS指令符可以用来指定C语言编译器的编译选项。在进行多架构编译时,我们可以使用#cgo CFLAGS指令符来指定不同操作系统和架构下的编译选项。例如,我们可以使用以下指令符来指定ARM架构下的编译选项:

代码语言:javascript复制
#cgo CFLAGS: -march=armv7-a -mfpu=neon

这个指令符会在ARM架构下使用-march=armv7-a和-mfpu=neon编译选项来编译C语言代码。

#cgo LDFLAGS #cgo LDFLAGS指令符可以用来指定链接器的选项。在进行多架构编译时,我们可以使用#cgo LDFLAGS指令符来指定不同操作系统和架构下的链接选项。例如,我们可以使用以下指令符来指定Windows x64架构下的链接选项:

代码语言:javascript复制
#cgo LDFLAGS: -L/usr/local/lib -lcurl

 这个指令符会在Windows x64架构下使用-L/usr/local/lib和-lcurl链接选项来链接libcurl库。

#cgo windows #cgo windows指令符可以用来指定Windows操作系统下的编译选项。在进行多架构编译时,我们可以使用#cgo windows指令符来指定不同操作系统下的编译选项。例如,我们可以使用以下指令符来指定Windows操作系统下的编译选项:

代码语言:javascript复制
#cgo windows CFLAGS: -D_WIN32_WINNT=0x0601

 这个指令符会在Windows操作系统下使用-D_WIN32_WINNT=0x0601编译选项来编译C语言代码

#cgo linux

#cgo windows指令符可以用来指定Linux操作系统下的编译选项。在进行多架构编译时,我们可以使用#cgo linux指令符来指定不同操作系统下的编译选项。例如,我们可以使用以下指令符来指定Linux操作系统下的编译选项:

代码语言:javascript复制
#cgo linux CFLAGS: -D_GNU_SOURCE

 这个指令符会在Linux操作系统下使用-D_GNU_SOURCE编译选项来编译C语言代码。

#cgo darwin #cgo darwin指令符可以用来指定macOS操作系统下的编译选项。在进行多架构编译时,我们可以使用#cgo darwin指令符来指定不同操作系统下的编译选项。例如,我们可以使用以下指令符来指定macOS操作系统下的编译选项:

代码语言:javascript复制
#cgo darwin CFLAGS: -mmacosx-version-min=10.10

 这个指令符会在macOS操作系统下使用-mmacosx-version-min=10.10编译选项来编译C语言代码。

#cgo linux,arm64 和 #cgo linux,amd64

代码语言:javascript复制
#cgo linux,amd64 LDFLAGS: /lib/linux/liba.a
#cgo linux,arm64 LDFLAGS: /lib/linux/liba_arm.a

 等价于

代码语言:javascript复制
#cgo linux,!arm64 LDFLAGS: /lib/linux/liba.a
#cgo linux,!amd64 LDFLAGS: /lib/linux/liba_arm.a

通过上面两个代码层级的编译一致性,可以得到在编译阶段也可以做到合并统一

这时流程图变成了这样的

完美!

0 人点赞