TKEStack适配ARM架构之路

2021-02-18 15:59:02 浏览数 (1)

1. 前言

腾讯TKEStack作为面向私有云业务场景的开源容器平台,应对的场景也会比较多样,比如国产服务器有一大阵营是基于arm架构的,那在国产化趋势下,客户的服务器架构可能会出现x86和arm混布在一起的情况;再比如随着IoT物联网的来临,以树莓派为代表的智能硬件上使用容器服务也会成为一种趋势。这意味着TKEStack单纯在x86服务器上运行是远远不够的,对于arm架构的支持,势在必行。

如何支持arm架构

简单来说,就是重新适配arm 架构:对于可执行文件,需要重新编译;对于容器镜像,需要重新构建。

因为不同架构的指令集不一样,在一个架构下编译并生成的二进制可执行文件,包含的是这个架构下的指令,直接将这个可执行文件放到另一架构上运行,会报cannot execute binary file类似错误。对于容器镜像,跨架构执行则会报:standard_init_linux.go:211: exec user process caused "exec format error"类似错误。如果平时遇见了上述错误,那一定是执行了架构不匹配的文件或容器镜像。

问题挑战

常规适配arm架构的做法就是在arm服务器上,把应用程序的编译、构建、打包的流程都走一遍,然后再将生成的arm组件包,跟x86的组件包分别命名,再打包一起交付给客户,然后部署时,由客户选择安装x86的组件或是arm组件。

常规流程不仅需额外引入一台arm服务器,在上面再搭建一套CI/CD流程,并且由于arm机器还未普及,该流程也限制了TKEStack开发人员及开源社区的参与。另外x86组件跟arm组件分别命名,这导致使用到这些组件的代码都要仔细重构或校验,以确保代码里使用了正确的版本,这也给代码维护人员带来了负担。

经过调研之后,TKEStack采取了不改变原本代码结构、构建流程的方式下,做到跨平台交付多架构组件的目标:通过充分利用容器技术及虚拟化技术,最小化了TKEStack适配arm(及未来其他架构)的改动量,也减轻了后续维护多架构的工作量。

2. 适配准备:组件梳理

TKEStack对外交付的是一个installer安装包,里面除了TKEStack本身组件外,还包含了搭建一个Kubernetes集群所需的依赖:docker、kubeadm、k8s组件。具体如下图所示:

图一:TKEStack组件梳理图一:TKEStack组件梳理

从组件梳理图可以看出,TKEStack的自研组件已经全量容器化了,所以TKEStack适配arm的核心就在于如何能够以统一的方式构建多个架构(x86 / arm)的容器镜像,并且在使用到这些容器镜像的地方,都能最小化代码改动,不因引入多个架构而导致部署容器时使用到错误的版本。

3. 容器技术:docker manifest list

在多个架构(x86 / arm)或者多个平台上(linux_amd64 / windows_amd64)上使用容器镜像时,就不得不提Docker公司在2017年9月提出的特性:docker manifest list。一份manifest list可以理解成是多个容器镜像汇总在一起的清单列表:清单里列明了每个镜像适用的平台或者架构,以及用于找到该镜像的哈希值。

如下图中间部分所示,命令 docker manifest inspect app:v1 查看了容器镜像 app:v1 的清单列表,得知在 linux/amd64(x86架构)平台上,app:v1 对应的容器镜像应为哈希 sha256:xxx 指向的镜像,而在 linux/arm64/v8(arm架构)平台上,则应对应哈希 sha256:yyy 指向的镜像。所以当客户端向镜像仓库发起请求,准备拉取 app:v1 镜像时,客户端会根据镜像仓库返回的清单列表,从中选出架构匹配的镜像,再去拉取相应的镜像。也就是说,从用户的角度来看,不用担心架构的差异,服务端会为用户屏蔽掉架构的区别。

图二:docker manifest list解析过程图二:docker manifest list解析过程

对于TKEStack,只要TKEStack在构建完多架构容器镜像后,并推送重命名后的x86架构容器镜像(带amd64后缀,如app-amd64:v1)跟arm架构容器镜像(带arm64后缀,如app-arm64:v1)至镜像仓库后,再生成一份不带架构后缀(如app:v1)的清单列表 manifest list指向多架构镜像,就可以在用户无感知的情况下,既能实现原本x86的机器正常拉取amd64的镜像,也能让新增的arm的机器拉取到arm64的镜像:

图三:docker manifest list构建及推送过程图三:docker manifest list构建及推送过程

到此,支持多架构后最小化代码改动的目标已有了方案:通过额外引入一层清单列表 manifest list,屏蔽多架构信息,让原本的代码以及用户在使用到容器镜像时,不因引入多个架构镜像而混乱。

4. 虚拟化及内核技术:QEMU 和 binfmt_misc

目标之二:保持原本容器镜像的构建流程,不因支持多架构后,因额外引入硬件平台要求,而限制了开源社区的参与。想要达到这个目标,就得实现跨平台构建容器镜像:在已普及的x86平台上,编译构建适用arm平台的容器镜像。

QEMU可以模拟很多平台,所以只要想办法在构建跨平台的容器镜像时,将其他平台的可执行文件传递给QEMU,由QEMU模拟对应的平台并执行,就可以达到跨平台构建的目的。而Linux 内核中的 binfmt_misc功能,刚好能将任意类型的可执行文件,传递至指定的用户态应用程序运行。所以只要在x86平台上安装QEMU模拟器,并在binfmt_misc中注册QEMU,让Linux遇到其他平台的执行文件时就传递给QEMU,这样就可以实现跨平台执行arm指令了。

5. 整体解决方案

通过上述容器技术及虚拟化技术后,TKEStack适配arm架构的整体方案如下:

  • (1)预先安装支持多架构的QEMU模拟器,并将QEMU注册到内核binfmt_misc中,然后在构建容器镜像时,通过--platform参数指定需要构建的目标架构,比如 docker build --platform linux/amd64 用来构建x86架构的镜像,docker build --platform linux/arm64 用来构建arm架构的镜像。如此就可以在原本自动构建流程里,生成跨平台容器镜像。
  • (2)构建流程里生成的容器镜像,镜像名严格按照架构信息打上后缀区分开来(app-amd64:v1 / app-arm64:v1),并逐个推送至镜像仓库后,再额外创建一个不带架构后缀的清单列表manifest list(app:v1),指向这些带了架构后缀的镜像,用以屏蔽多架构信息,不因镜像名含有架构后缀而改变使用体验。

这套方案的通用代码已经提取放到github上了:https://github.com/Shangru-WU/multi-arch-example。github上的示例主要有两个操作:(1)make image构建多平台镜像,(2)make push构建多平台镜像后,再推送镜像及manifest list至镜像仓库。

makefile代码解析

代码语言:javascript复制
.PHONY: docker.buildx.install
docker.buildx.install:
	@$(ROOT_DIR)/build/lib/docker-buildx.sh docker_buildx:multi_arch_support

.PHONY: image
image: docker.buildx.install
	VERSION=$(VERSION) $(ROOT_DIR)/build/lib/image-build.sh image_build:build

.PHONY: push
push: docker.buildx.install
	VERSION=$(VERSION) $(ROOT_DIR)/build/lib/image-build.sh image_build:push

make image跟make push操作都会依赖于docker.buildx.install,docker.buildx.install主要是完成跨平台构建镜像的准备工作。这里选择docker buildx而不是直接使用原生docker build,是因为buildx将会是下一代镜像构建的标准。buildx兼容现有docker build的特性,并额外对docker build进行优化。比如docker build在构建镜像时,只会按着Dockerfile上面步骤一步步串行执行下来。但buildx会尝试分析Dockerfile上的哪些步骤并无相互依赖,然后并行执行这些步骤,以提升构建速度。

docker buildx安装解析

buildx的准备工作全在$(ROOT_DIR)/build/lib/docker-buildx.sh脚本里,主要是判断docker版本,docker版本需大于等于19.03或者docker api版本大于等于1.40,然后下载buildx插件并启用docker实验特性export DOCKER_CLI_EXPERIMENTAL=enabled,再紧接着就是安装QEMU多架构模拟器以及注册到内核。注册过程也会检查内核版本,内核版本需大于等于4.8方能顺利注册。

镜像构建解析

通过docker buildx完成跨平台构建镜像的准备工作后,构建镜像流程就比较简单了,主要是通过 docker buildx build --platform 指定目标平台进行构建:

代码语言:javascript复制
PLATFORMS=${PLATFORMS:-"linux_amd64 linux_arm64"}
for platform in ${PLATFORMS}; do
	os=${platform%_*}
	arch=${platform#*_}
	image_plat="${os}/${arch}"
	image_name="${DES_REGISTRY}-${arch}:${VERSION}"
	echo "===========> Building docker image ${image_name}"
	docker buildx build --platform ${image_plat} --load -t ${image_name} -f ${ROOT_DIR}/build/docker/Dockerfile ${ROOT_DIR}
done

示例代码里Dockerfile采用的是多阶段构建的方式,这也是为了达到最小化代码改动而引入的:第一阶段构建直接在Dockerfile里执行make build,意思就是在原平台怎么编译代码的,就算是跨平台也采用同样的编译流程,保留原本make build的方式。编译完后再把编译结果拷贝至最后的构建的容器镜像。

代码语言:javascript复制
FROM golang:1.15.8 AS builder
ARG TARGETPLATFORM
RUN echo "building for ${TARGETPLATFORM}"
WORKDIR ${WORKDIR}
COPY . .
RUN make build
#####################################
FROM alpine:3.12.3
COPY --from=builder ${WORKDIR}/bin/main /app/
ENTRYPOINT ["/app/main"]

镜像推送解析

docker buildx build执行完后,多平台容器镜像已经生成,后续推送到镜像仓库时,则额外执行了docker manifest create及docker manifest annotate操作,为多平台镜像创建个manifest list,并把两者关联起来。最后再执行docker manifest push将清单列表也推送至镜像仓库。

代码语言:javascript复制
docker push ${image_name}
docker manifest create --amend ${manifest_name} ${image_name}
docker manifest annotate ${manifest_name} ${image_name} 
	--os ${os} --arch ${arch} ${variant}
docker manifest push --purge ${manifest_name}

 至此通用的构建流程就走完了:(1)不改变原本make build方式,只是在外层多加了跨平台构建;(2)不改变原本拉取镜像方式,只是在推送镜像时多加了层清单列表manifest list。

6. 小结

容器化技术确实带来了很多很多便利,在一开始适配arm架构的过程中,谁也不曾料到,原来最后可以不对原本流程做任何重构的,只需要在外层引入新的技术便可。这也对那些已经无人维护,但又要进行国产化适配的代码带来了希望:在不入侵原有体系的情况下,达到适配的目的。即便需要进行适配、编译、调试,也可以在本地通过跨架构的方案实现,而不用一遍遍的来回将代码拷贝至arm编译机调试。

当然本文主要是作为一个大方向的通用方法论,毕竟实际过程中会遇见很多意料之外的事。没有任何方案能十全十美的,在多架构适配过程中,我们也遇见过代码里的系统调用在arm平台上不支持,需要改整段代码的情况,或者遇见在UOS(统一操作系统)里,有些系统信息不是按照标准方式返回的,需要额外绕过的情况。这种情况只能是见一个处理一个,但整体思路依旧是保持着适配时最小化改动这样的目标前行。

相关代码指引:https://github.com/Shangru-WU/multi-arch-example

0 人点赞