如果你在过去几年里购买了笔记本电脑,那么它很可能会搭载ARM处理器。苹果的M系列芯片让开发者们开始关注ARM技术,但真正的革命发生在云数据中心内部。

Google Cloud Axion是谷歌自主研发的基于ARM架构的芯片,专为满足现代云工作负载的需求而设计。其性能与成本指标十分引人注目:谷歌声称,与同级别的x86服务器相比,Axion在能效方面可提升高达60%,而在性价比方面则可提高多达65%。

AWS拥有Graviton,Azure则有Cobalt。ARM技术已经不再属于小众领域,它代表着整个云产业的发展方向。

然而,在开始这一转型过程时,几乎每个团队都会遇到一个问题:容器架构不兼容

如果你在搭载M系列处理器的Mac上构建了一个Docker镜像,然后尝试将其部署到x86服务器上,那么该镜像在启动时会出现“执行格式错误”的异常提示。

其实服务器本身并没有问题,它只是无法读取该镜像中包含的二进制代码。ARM架构的二进制文件与x86架构的二进制文件在机器层面上使用的是完全不同的编码格式,因此CPU根本无法执行那些为另一种架构设计的指令。

在本教程中,我们将彻底解决这个问题。你将学会如何创建一个统一的Docker镜像标签,这样无论是在ARM架构的服务器上还是x86架构的服务器上,该镜像都能自动运行正确版本的二进制程序——无需额外的构建流程或标签设置。之后,你还可以在Google Kubernetes Engine中配置节点资源,确保工作负载能够被准确地分配到那些成本效益更高的ARM架构节点上。

下面我们将分步骤来完成这些操作:

  • 编写一个Go语言HTTP服务器,该服务器能够在运行时检测自身所使用的CPU架构

  • 创建一个多阶段的Dockerfile,能够自动为“linux/amd64”和“linux/arm64”两种架构进行交叉编译,而无需使用速度较慢的QEMU仿真工具

  • 在Google Artifact Registry中创建一个多架构版本的镜像,这样无论使用哪种CPU架构,都可以通过这个统一的镜像来运行应用程序

  • 配置一个包含两个节点池的GKE集群:一个是标准的x86节点池,另一个是搭载ARM Axion处理器的节点池

  • 编写Kubernetes部署脚本,确保你的工作负载仅被分配到ARM架构的节点上

完成这些步骤后,你将会看到一个能够正常运行的Web服务端点,而从该服务器端点返回的信息中也会明确显示“arm64”这一字样,这说明你的应用程序确实已经在ARM架构的服务器上成功运行了。让我们开始吧。

目录

先决条件

在开始之前,请确保您已经准备好了以下所需物品:

  • 一个已开启计费的Google Cloud项目。如果您还没有项目,可以在console.cloud.google.com上创建一个。按照本教程操作所需的总费用约为5到10美元。

  • 已安装并完成身份验证的gcloudCLI。请运行gcloud auth login进行登录,然后运行gcloud config set project YOUR_PROJECT_ID将CLI配置为您的项目。

  • 版本为19.03或更高的Docker Desktop。Docker Buildx(我们将用于多架构构建的工具)也包含在Docker Desktop中。

  • 已安装kubectl。这是用于与Kubernetes集群交互的CLI工具。

  • 需要对Docker(镜像、层、Dockerfile)和Kubernetes(Pod、部署、服务)有基本的了解。您不需要成为专家,但至少应该知道这些概念的基本含义。

步骤1:设置您的Google Cloud项目

在编写任何应用程序代码之前,我们首先需要准备好云基础设施。这是后续所有操作的基础。

启用所需的API

在任何新的Google Cloud项目中,这些服务默认都是处于关闭状态的。请运行以下命令来开启我们所需要的三个API:

gcloud services enable \
  artifactregistry.googleapis.com \
  container.googleapis.com \
  containeranalysis.googleapis.com

下面分别说明每个API的作用:

  • artifactregistry.googleapis.com — 启用Artifact Registry,我们将会把Docker镜像存储在这里。

  • container.googleapis.com — 启用Google Kubernetes Engine (GKE),我们的集群将运行在这个平台上。

  • containeranalysis.googleapis.com — 为存储在Artifact Registry中的镜像提供漏洞扫描功能。

在Artifact Registry中创建Docker仓库

Artifact Registry是Google Cloud提供的容器镜像管理工具——在我们将构建好的镜像部署到集群之前,这些镜像会先存储在这里。请为本次教程专门创建一个仓库:

gcloud artifacts repositories create multi-arch-repo \
  --repository-format=docker \
  --location=us-central1 \
  --description="多架构教程用镜像"

各参数的含义如下:

  • --repository-format=docker — 指定该仓库用于存储Docker镜像。

  • — 指定镜像存储的Google Cloud区域。选择与您的集群运行位置相近的区域,以减少镜像下载延迟。您可以通过运行gcloud artifacts locations list来查看所有可用区域。

  • --description — 为仓库设置一个便于人类阅读的描述性标签,该标签会显示在控制台中。

验证Docker身份以将镜像推送到Artifact Registry

在将镜像推送到Google Cloud之前,Docker需要相应的认证信息。运行以下命令即可自动完成认证配置:

gcloud auth configure-docker us-central1-docker.pkg.dev

此操作会在你的~/.docker/config.json文件中添加一条认证配置信息。实际使用时,每当Docker尝试从us-central1-docker.pkg.dev这个路径下的URL获取或推送镜像时,它都会自动调用gcloud来获取有效的认证令牌。因此你无需手动执行docker login命令。

运行gcloud artifacts repositories list命令后产生的终端输出结果,其中显示了名为multi-arch-repo的多架构存储库信息,其格式为DOCKER,所在区域为us-central1

步骤2:创建GKE集群

既然Artifact Registry已经准备好接收镜像了,接下来我们就来创建Kubernetes集群。首先会使用x86架构的节点来构建标准集群,等我们有了需要部署的镜像后,再添加ARM架构的节点池。

gcloud container clusters create axion-tutorial-cluster \
  --zone=us-central1-a \
  --num-nodes=2 \
  --machine-type=e2-standard-2 \
  --workload-pool=PROJECT_ID.svc.id.goog

请将PROJECT_ID替换为你的实际Google Cloud项目ID。

各参数的作用如下:

  • --zone=us-central1-a — 在单个可用区域内创建集群。如果使用--region参数来创建区域级集群,节点会分布在三个不同的区域内,从而提高系统的可靠性;但对于本教程来说,使用单个区域会更简单,也能避免某些区域可能出现的容量不足问题。如果us-central1-a不可用,可以尝试使用us-central1-b

  • --num-nodes=2 — 表示在这个区域内创建2个x86架构的节点。至少需要2个节点,这样后续添加ARM架构的节点池时才能保证有足够的计算资源。

  • — 指定这个默认节点池所使用的机器类型。e2-standard-2是一种性价比较高的x86服务器,配备2个虚拟CPU和8GB内存,非常适合用于一般性的工作负载。

  • --workload-pool=PROJECT_ID.svc.id.goog — 启用Workload Identity功能。这是Google推荐的一种方式,可以让Kubernetes Pod在访问Google Cloud API时自动完成身份验证,从而避免需要在集群内部存储服务账户密钥文件。

执行这个命令需要几分钟时间。在等待命令运行的过程中,你可以继续编写应用程序的相关代码。我们会在第6步再回到这个集群的话题上来。

GCP控制台中的Kubernetes Engine集群页面,显示了名为axion-tutorial-cluster的集群信息,其状态为绿色勾号,所在区域为us-central1-a,同时还列出了Kubernetes的版本信息。

步骤3:编写应用程序代码

我们需要一个用于构建容器的应用程序。我们选择使用Go语言,原因有三个:

  1. Go编译后会生成一个静态链接的二进制文件。无需安装任何运行时环境或解释器,只需这个二进制文件即可。这样一来,生成的容器镜像就会变得非常简洁。

  2. Go具备内置的跨平台编译功能。我们可以通过设置两个环境变量,在x86系统的Mac上编译出ARM64架构的二进制文件,反之亦然。这一点在编写Dockerfile时会非常重要。

  3. Go通过runtime.GOARCH这个字段来表明二进制文件是为哪种架构编译的。我们的服务器在运行时会显示这一信息,从而确保正确的二进制文件能够在相应的硬件上正常运行。

首先创建项目目录:

mkdir -p hello-axion/app hello-axion/k8s
cd hello-axion/app

app/目录下初始化Go模块。这样当前目录就会生成go.mod文件:

go mod init hello-axion

go mod init是Go语言用于创建新模块的内置命令。它会生成一个go.mod文件,其中会指定模块名称(hello-axion)以及所需的最低Go版本。每个现代的Go项目都需要这个文件——没有它,编译器就无法确定如何获取所需的包。

现在在app/main.go文件中编写应用程序代码:

package main

import (
    "fmt"
    "net/http"
    "os"
    "runtime"
)

func handler(w http.ResponseWriter, r *http.Request) {
    hostname, _ := os.Hostname()
    fmt.Fprintf(w, "Hello from freeCodeCamp!\n")
    fmt.Fprintf(w, "架构类型 : %s\n", runtime.GOARCH)
    fmt.Fprintf(w, "操作系统 : %s\n", runtime.GOOS)
    fmt fprintf(w, "Pod的主机名 : %s\n", hostname)
}

func healthz(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    fmt.Fprintln(w, "ok")
}

func main() {
    http.HandleFunc("/", handler)
    http.HandleFunc("/healthz", healthz)
    fmt.Println("服务器正在8080端口上运行...")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        fmt.Fprintf(os.Stderr, "服务器错误: %v\n", err)
        os.Exit(1)
    }
}

确认这两个文件是否已经生成:

ls -la

你应该会看到go.modmain.go这两份文件。

让我们来了解一下这段代码的功能:

  • import "runtime"——导入了Go语言内置的runtime包,这个包提供了关于Go运行时环境的信息,包括CPU架构类型。

  • runtime.GOARCH——返回一个字符串,比如"arm64""amd64",表示这个二进制文件是为哪种架构编译的。当我们把应用程序部署到ARM架构的节点上时,这个值就会是arm64。这一点是我们验证代码是否正确运行的关键。

  • os.Hostname()——返回Pod的主机名,Kubernetes会将Pod的名字设置为主机名。这样我们之后在测试应用程序时,就能知道是哪个具体的Pod响应了请求。

  • handler——主要的HTTP处理函数,注册在根路径/上。它会将系统的架构类型、操作系统以及主机名写入响应内容中。

  • healthz——另一个处理函数,注册在路径/healthz上。它会返回HTTP 200状态码,并附带文本ok。Kubernetes会使用这个接口来检查容器是否正常运行、是否可以接收请求——我们稍后会在部署配置中设置这个功能。

  • http.ListenAndServe(":8080", nil)——在8080端口上启动服务器。如果启动失败(比如该端口已经被其他程序占用),它会输出错误信息,并以非零码退出,这样Kubernetes就能知道出现了问题。

步骤4:使用Docker Buildx启用多架构构建

在编写Dockerfile之前,我们需要了解一个基本的限制因素,因为这一限制会直接影响Dockerfile的编写方式。

为什么默认情况下Docker镜像是针对特定架构设计的

中央处理器只能理解为其特定的指令集架构(ISA)编写的指令。ARM64和x86_64属于不同的指令集架构,这意味着它们使用不同的机器级操作规范。当你编译一个Go程序时,编译器会将源代码转换成仅适用于某一种指令集架构的二进制文件;这种二进制文件无法在另一种指令集架构的平台上运行。

当你用常规方法构建Docker镜像(docker build)时,镜像中包含的二进制文件其实是针对你的本地机器的指令集架构编译生成的。如果你使用的是苹果硅芯片Mac电脑,那么生成的二进制文件就是ARM64格式的;如果将这样的镜像上传到x86架构的服务器上,Docker在尝试执行该二进制文件时,操作系统会拒绝执行它:

standard_init_linux.go:228: exec user process caused: exec format error

也就是说,操作系统在告诉:“这个二进制文件是为另一种处理器设计的,我根本不知道该如何处理它。”

解决方案:使用一个适用于所有架构的镜像标签

Docker通过一种称为清单列表的结构来解决这个问题。清单列表实际上是一个指针表,它包含了多个镜像引用——每个架构对应一个镜像引用——而这些镜像都使用同一个标签。

当服务器请求下载hello-axion:v1镜像时,实际发生的过程如下:

  1. Docker会联系注册服务并请求获取hello-axion:v1的清单列表。

  2. 注册服务会返回这个清单列表,其内部结构如下:

{
  "manifests": [
    { "digest": "sha256:a1b2...", "platform": { "architecture": "amd64", "os": "linux" } },
    { "digest": "sha256:c3d4...", "platform": { "architecture": "arm64", "os": "linux" } }
  ]
}
  1. Docker会检查当前机器的架构类型,然后从中找到对应的镜像条目,并仅下载该特定架构的镜像层。因此,x86架构的镜像永远不会被下载到ARM架构的服务器上,反之亦然。

使用同一个标签,实际上可以获取两种不同架构的镜像;对于你的部署配置来说,这一机制是完全透明的。

安装Docker Buildx

Docker Buildx是一种用于生成这些清单列表的命令行工具。它基于BuildKit引擎开发,且随Docker Desktop一起提供。运行以下命令即可创建并激活一个新的构建工具实例:

docker buildx create --name multiarch-builder --use
  • --name multiarch-builder — 为这个构建工具指定一个易于记忆的名称。你可以创建多个这样的构建工具,此命令会创建一个名为multiarch-builder的新构建工具。

  • — 立即将这个新构建工具设置为当前活动的构建工具,因此之后所有的docker buildx build命令都会使用它。

现在启动这个构建工具,并确认它是否支持我们所需要的平台:

docker buildx inspect --bootstrap
  • --bootstrap — 如果构建工具容器尚未运行,就会启动它,并显示其完整的配置信息。

你应该会看到如下这样的输出结果:

Name:          multiarch-builder
Driver:        docker-container
Platforms:     linux/amd64, linux/arm64, linux/arm/v7, linux/386, ...

Platforms这一行列出了这个构建工具能够为哪些架构生成镜像。只要在列表中看到了linux/amd64linux/arm64,那就说明你可以使用它来为x86和ARM架构生成镜像了。

终端输出显示了multiarch-builder的详细信息,包括名称、使用的驱动程序docker-container,以及列出了linux/amd64和linux/arm64的Platforms列表。

步骤5:编写Dockerfile

现在我们可以开始编写Dockerfile了。我们将结合使用两种技术:多阶段构建来确保最终生成的镜像体积尽可能小,以及交叉编译技巧来避免缓慢的CPU模拟过程。

创建一个名为app/Dockerfile的文件,并填写以下内容:

# -----------------------------------------------------------
# 第一阶段:构建
# -----------------------------------------------------------
# $BUILDPLATFORM = 运行此构建任务的机器(例如你的笔记本电脑)
# \(TARGETOS / \)TARGETARCH = 我们正在为目标平台构建镜像
# -----------------------------------------------------------
FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS builder

ARG TARGETOS
ARG TARGETARCH

WORKDIR /app

COPY go.mod .
RUN go mod download

COPY main.go .

RUN GOOS=\(TARGETOS GOARCH=\)TARGETARCH go build -ldflags="-w -s" -o server main.go

# -----------------------------------------------------------
# 第二阶段:运行时环境配置
# -----------------------------------------------------------
FROM alpine:latest

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

WORKDIR /app
COPY --from=builder /app/server .

EXPOSE 8080
CMD ["./server"]

这里面包含了很多复杂的操作步骤,让我们仔细了解一下吧。

第一阶段:构建工具

FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS builder

这是文件中最重要的这一行。`\(BUILDPLATFORM\)`是一个特殊的构建参数,Docker Buildx会自动将其添加到构建过程中——这个参数的值等于执行构建操作的机器所使用的平台(也就是你的笔记本电脑)。通过将构建过程绑定到`\)BUILDPLATFORM\)`这个参数上,Go编译器就会在你的机器上以原生方式运行,而不会在CPU模拟器中运行。正是这一点使得多架构构建能够快速完成。

如果没有`–platform=$BUILDPLATFORM`这个参数,Buildx就不得不使用`QEMU`这种完整的CPU模拟器,在你的x86机器上创建ARM64构建环境(或者反过来也是如此)。虽然QEMU可以正常工作,但其运行速度通常比原生执行慢5到10倍。对于一个依赖众多的项目来说,这意味着构建时间可能会从2分钟延长到20分钟。

ARG TARGETOS ARG TARGETARCH

这两行代码表明,我们的Dockerfile期望接收名为`TARGETOS`和`TARGETARCH`的构建参数。Buildx会根据你在构建时传递的`–platform`参数自动添加这些参数。对于`linux/arm64`这样的目标架构,`TARGETOS`的值会是`linux`,而`TARGETARCH`的值会是`arm64`。

COPY go.mod . RUN go mod download

我们首先复制`go.mod`文件,然后再复制其余的源代码文件。Docker是逐层构建镜像的,并且会缓存每一层构建结果。因此,如果我们先复制`go.mod`文件,就可以为后续的`go mod download`操作创建一个已缓存的层。

在以后的构建过程中,只要`go.mod`文件没有发生变化,Docker就会直接跳过下载步骤——即使源代码本身发生了变化也是如此。这样就能显著提高迭代开发的效率。

RUN GOOS=\(TARGETOS GOARCH=\)TARGETARCH go build -ldflags="-w -s" -o server main.go

这就是交叉编译的步骤。`GOOS`和`GOARCH`是Go语言内置的交叉编译环境变量。设置这些变量的作用是让Go编译器为与当前运行机器不同的目标平台生成二进制文件。我们这些变量的值是由Buildx添加到的`\(TARGETOS\)`和`\)TARGETARCH`构建参数决定的。

`-ldflags=”-w -s”`这个选项会从生成的二进制文件中删除调试符号表和DWARF调试信息。虽然这对程序的运行行为没有影响,但可以让二进制文件的体积减少大约30%。

阶段2:运行时镜像的构建

FROM alpine:latest

这一行代码会基于Alpine Linux创建一个全新的镜像。Alpine Linux是一种体积非常小的Linux发行版,其大小仅约为5MB。重要的是,`alpine:latest`本身也是一个多架构镜像,因此Docker会根据当前构建阶段所针对的平台自动选择`arm64`或`amd64`版本的Alpine Linux。

第一阶段生成的所有内容——包括Go工具链、源代码文件以及中间对象文件——都会被丢弃。最终生成的镜像中包含Alpine Linux系统和我们的二进制程序。与传统的单阶段Go镜像相比(大小约为300MB),这种构建方法产生的镜像体积不到15MB。

RUN addgroup -S appgroup && adduser -S appuser -G appgroup,然后执行 USER appuser

这两条命令会创建一个非root用户,并将其设置为容器的默认用户。以root身份运行容器存在安全风险——如果攻击者利用了应用程序中的漏洞,他们就能获得容器内的root权限。而使用非root用户则可以限制这种风险的扩散范围。

COPY --from=builder /app/server .

这就是多架构构建的运作方式:--from=builder 这个选项告诉Docker从builder阶段复制文件,而不是从本地磁盘复制。只有编译后的二进制文件(即server)才会被纳入最终的镜像中。

步骤6:构建并推送多架构镜像

现在,既然应用程序和Dockerfile都已经准备好了,我们就可以通过一条命令同时为两种架构构建镜像,并将它们推送到Artifact Registry中。

app/目录中运行以下命令:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1 \
  --push \
  .

请将PROJECT_ID替换为你的实际GCP项目ID。

这条命令的各个部分分别起到以下作用:

  • docker buildx build — 使用Buildx CLI而不是标准的docker build命令。进行多架构构建时,必须使用Buildx。

  • --platform linux/amd64,linux/arm64 — 指令Buildx分别为x86 Intel/AMD架构和ARM64架构构建镜像。这两个构建过程会并行进行。由于我们的Dockerfile使用了$BUILDPLATFORM这一跨平台编译机制,因此这两个构建过程都可以在你的机器上直接运行,而无需使用QEMU模拟器。

  • -t us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1 — 这是指定镜像在Artifact Registry中的存储路径。其格式始终为REGION-docker.pkg.dev/PROJECT_ID/REPO_NAME/IMAGE_NAME:TAG

  • --push — 多架构镜像无法被加载到本地Docker守护进程中(因为该进程只能识别单架构镜像)。这个选项告诉Buildx跳过本地存储,直接将构建完成的镜像列表及其两种架构版本推送到注册中心。

  • . — 指定构建上下文目录,即Docker会在此目录中查找Dockerfile以及构建过程中所需的文件。

在构建过程进行时,请注意观察输出结果。你会看到BuildKit同时在两个架构平台上进行构建操作:

 => [linux/amd64 builder 1/5] FROM golang:1.23-alpine
 => [linux/arm64 builder 1/5] FROM golang:1.23-alpine
 ...
 =>> pushing manifest for us-central1-docker.pkg.dev/.../hello-axion:v1

终端显示的docker buildx build输出结果,其中包含两条并行构建进程,分别对应linux/amd64和linux/arm64架构,并且最后一行显示了镜像被推送到Artifact Registry的位置。

在 Artifact Registry 中验证多架构镜像

推送操作完成后,请导航至 GCP 控制台 → Artifact Registry → 仓库 → multi-arch-repo,然后点击 hello-axion

您不会看到任何镜像——而是会看到一个标有 “Image Index” 的选项。这就是我们创建的清单列表。点击进入该列表,会发现其中包含两个子镜像,它们分别对应不同的架构:一个是 linux/amd64,另一个是 linux/arm64

您也可以通过命令行来查看这些信息:

docker buildx imagetools inspect \
  us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1

Google Cloud Artifact Registry 控制台显示 hello-axion 作为一个包含两个子镜像的清单列表:其中一个标记为 linux/amd64,另一个标记为 linux/arm64,每个子镜像都有各自的摘要信息和大小。

输出结果会列出清单列表中包含的所有镜像信息。您会看到 linux/amd64linux/arm64 这两个条目——它们就是我们的实际镜像。此外,还会看到两条标有 “Platform: unknown/unknown” 且被标记为 “attestation-manifest” 的条目。这些实际上是 构建来源记录,Docker Buildx 会自动添加这些记录,以证明该镜像是如何以及在哪里生成的(这是一种名为 SLSA attestation 的供应链安全功能)。

您需要关注的是 linux/amd64linux/arm64 这两个条目。请注意 arm64 条目的摘要信息——在后续的验证步骤中,我们会使用这个信息来确认集群是否下载到了正确的镜像版本。

步骤 7:添加 Axion ARM 节点池

我们现在已经得到了一个通用镜像,接下来需要为它找到合适的运行环境。

回想一下我们在第 2 步中创建的集群——该集群运行的是 e2-standard-2 架构的 x86 机器。现在我们要添加另一个运行 ARM 机器的节点池。这一操作属于关键的架构调整:通过构建一个 混合架构集群,我们可以将不同的工作负载分配到不同的硬件上。

选择适合您的 ARM 机器类型

目前,Google Cloud 在 GKE 中提供了两种基于 ARM 架构的机器系列:

>

系列名称 示例机型 产品特点
Tau T2A t2a-standard-2 第一代 Google ARM 处理器(Ampere Altra架构)。在多个地区均有供应,非常适合初学者使用。
Axion (C4A) c4a-standard-2 Google 自研的 ARM 处理器(Arm Neoverse V2 核心)。最新一代产品,性价比极高,目前供应范围仍在逐步扩大。

本教程使用t2a-standard-2,因为这种机型非常容易获取。对于c4a-standard-2来说,这些命令也是完全相同的——只需将--machine-type参数的值进行相应的调整即可。如果你的区域中没有t2a-standard-2这种机型,那么在运行下面的节点池创建命令时,GKE会立即告诉你这一点,你可以尝试使用邻近的区域。

创建ARM节点池

将这个ARM节点池添加到现有的集群中:

gcloud container node-pools create axion-pool \
  --cluster=axion-tutorial-cluster \
  --zone=us-central1-a \
  --machine-type=t2a-standard-2 \
  --num-nodes=2 \
  --node-labels=workload-type=arm-optimized

各参数的作用如下:

  • --cluster=axion-tutorial-cluster — 这是我们在第2步中创建的集群名称。节点池总是会被添加到现有的集群中。

  • — 这个参数必须与你创建集群时所选择的区域相匹配。

  • --machine-type=t2a-standard-2 — GKE会识别出这是一种ARM机型,并会自动为这些节点配置兼容ARM系统的Container-Optimized OS(COS)。因此你无需在操作系统层面进行任何特殊设置。

  • — 该区域将创建2个ARM节点,这样的数量足以满足我们部署3个副本的需求,同时还能保证集群的正常运行。

  • --node-labels=workload-type=arm-optimized — 这个参数会为这个节点池中的所有节点添加一个自定义标签。我们在部署配置文件中会使用这个标签来指定需要使用的节点。在实际的集群环境中,使用描述性强的自定义标签(而不是仅仅依赖自动生成的kubernetes.io/arch=arm64标签)是一种良好的实践——这样能够更清楚地表达节点池的实际用途。

执行这个命令需要几分钟时间。等命令完成后,让我们确认一下我们的集群现在是否已经拥有了这两个节点池:

gcloud container clusters get-credentials axion-tutorial-cluster --zone=us-central1-a

kubectl get nodes --label-columns=kubernetes.io/arch

get-credentials命令会配置kubectl工具,使其能够与新的集群进行身份验证。get nodes命令则会列出所有节点,并在结果中添加一列,显示kubernetes.io/arch标签的值。

你应该会看到类似以下的输出:

NAME                                    STATUS   ARCH    AGE
gke-...default-pool-abc...              Ready    amd64   15m
gke-...default-pool-def...              Ready    amd64   15m
gke-...axion-pool-jkl...                Ready    arm64   3m
gke-...axion-pool-mno...                Ready    arm64   3m

其中,默认的x86节点池使用amd64作为架构类型,而新的Axion节点池则使用arm64。这个kubernetes.io/arch标签是由GKE自动添加的——你无需手动设置它,该标签会根据硬件的实际架构来生成。

使用kubectl get nodes命令获取的终端输出结果中,其中ARCH列显示:两个属于default-pool池的节点使用amd64架构,而两个属于axion-pool池的节点则使用arm64架构。

步骤8:将应用程序部署到ARM节点池中

我们使用的是多架构版本的镜像,同时也拥有一个混合架构的集群。在编写部署配置文件之前,有几点非常重要需要了解:Kubernetes默认情况下并不了解也不关心镜像所使用的具体架构

如果现在直接使用标准的部署配置方式,调度器会寻找任何具备足够CPU和内存资源的可用节点来放置Pod容器——这些节点可能是x86架构的,而不是专门为ARM架构设计的Axion节点。虽然多架构版本的部署配置文件能够妥善处理这种情况(无论哪种架构的节点,都会运行正确的二进制文件),但这样一来,我们就无法实现当初为使用Axion节点而带来的成本优化效果了。

为了确保Pod容器只能被放置到ARM节点上,我们使用了nodeSelector这一机制。

nodeSelector的工作原理

nodeSelector实际上是Pod配置文件中的一组键值对。在Kubernetes调度器尝试放置某个Pod容器之前,它会检查所有可用节点的标签信息。如果某个节点不具备nodeSelector中指定的所有标签,调度器就会忽略这个节点,从而导致该Pod容器处于Pending状态,而不会被放置在错误的节点上。

这种强制性的约束条件正是我们为实现成本优化所所需要的。这与Node Affinity中的软偏好设置模式(preferredDuringSchedulingIgnoredDuringExecution)形成了对比——后者表示“尽量使用ARM架构的节点,但在必要时也可以退而使用x86架构的节点”。虽然软偏好设置有助于提高系统的灵活性,但它们却违背了专门为ARM架构设计的节点池的存在意义。我们需要的正是这种强制性的约束条件。

编写部署配置文件

创建k8s/deployment.yaml文件:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-axion
  labels:
    app: hello-axion
spec:
  replicas: 3
  selector:
    matchLabels:
      app: hello-axion
  template:
    metadata:
      labels:
        app: hello-axion
    spec:
      nodeSelector:
        kubernetes.io/arch: arm64

      containers:
      - name: hello-axion
        image: us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1
        ports:
        - containerPort: 8080
        livenessProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: 3
          periodSeconds: 5
        resources:
          requests:
            cpu: "250m"
            memory: "64Mi"
          limits:
            cpu: "500m"
            memory: "128Mi"

请将PROJECT_ID替换为你的项目ID。以下是各关键配置项的作用:

replicas: 3 — 这个设置告诉Kubernetes始终保持三个该Pod实例处于运行状态。如果其中一个实例崩溃或对应的节点发生故障,调度器会立即启动一个新的实例进行替换。设置为3个副本还意味着在us-central1区域内的每个ARM节点上都会运行一个Pod,这样就可以将负载分散到不同的可用区中。

selector.matchLabelstemplate.metadata.labels — 这两个配置必须保持一致。selector用于指定该Deployment“拥有”哪些Pod,而templatemetadata.labels则决定了这些Pod会被标记上哪些标签。如果这两者不匹配,Kubernetes就无法正常管理这些Pod。

nodeSelector: kubernetes.io/arch: arm64 — 这个配置起到了关键作用:Kubernetes调度器会在考虑资源可用性之前,过滤掉所有没有这个标签的节点。由于GKE会自动为所有ARM节点添加kubernetes.io/arch=arm64标签,因此我们的Pod只会被调度到axion-pool节点上运行。

livenessProbe — 该组件会定期执行GET /healthz请求。如果连续多次检测失败(说明容器处于死锁状态或无法响应),Kubernetes会重新启动该容器。initialDelaySeconds: 5表示在首次进行检查之前,系统会有5秒钟的时间来启动相关服务。

readinessProbe — 与livenessProbe类似,但用途不同。当readinessProbe检测失败时,Kubernetes会将该Pod从服务的负载均衡器中移除,从而避免有流量被发送到该Pod上。在Pod启动阶段,这一机制非常重要——只有当Pod表明自己已经准备好运行时,才会开始接收请求。

resourcesrequests — 该配置为该Pod预留了250m的CPU资源以及64Mi的内存。调度器会根据这些数值来判断某个节点是否有足够的空间来运行该Pod。如果不设置这些请求值,节点就可能会被过度占用资源。

resources.limits — 该配置为容器设置了上限:CPU使用量不得超过500m,内存使用量不得超过128Mi。如果容器的资源使用量超过了这些限制,Kubernetes会限制其CPU使用率或直接终止该容器。这样就可以防止某个异常运行的Pod占用同一节点上其他正常工作的负载。

关于“污点”与“容忍设置”的说明

当你熟悉了nodeSelector的用法后,接下来可以在生产环境中为ARM节点池添加“污点”配置。所谓“污点”,其实就是一种限制机制:任何没有对应“容忍设置”的Pod都无法被部署到带有污点的节点上。

这样一来,集群中的其他工作负载就不会无意中占用ARM节点的资源。你可以在创建节点池时添加相应的污点配置,例如:

# 在创建节点池的命令中添加--node-taints选项:
--node-taints=workload-type=arm-optimized:NoSchedule

在Pod的配置文件中也需要指定相应的容忍度设置:

tolerations:
- key: "workload-type"
  operator: "Equal"
  value: "arm-optimized"
  effect: "NoSchedule"

为了简化教学流程,我们在本教程中没有介绍这一步骤,但实际上,在多租户集群中,正是通过这种机制来确保不同类型的工作负载能够被严格区分开来的。

编写服务配置文件

我们还需要创建一个Kubernetes Service,以便让外部网络能够访问这些Pod。请创建文件k8s/service.yaml

apiVersion: v1
kind: Service
metadata:
  name: hello-axion-svc
spec:
  selector:
    app: hello-axion
  ports:
  - protocol: TCP
    port: 80
    targetPort: 8080
  type: LoadBalancer
  • selector: app: hello-axion — 这个Service会通过标签来识别相应的Pod。任何带有app: hello-axion标签的Pod都会被添加到这个Service的负载均衡池中。

  • port: 80 — 外部网络可以通过这个端口访问该Service所管理的Pod们。

  • targetPort: 8080 — 流量会被转发到Pod上的这个端口。由于我们的Go服务器监听的是8080端口,因此必须确保这两个端口号是一致的。

  • type: LoadBalancer — 这个设置会促使GKE为该Service配置一个外部Google Cloud负载均衡器,并为其分配一个公网IP地址。这样,外部网络才能访问到这个Service。

应用这两个配置文件

kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml

kubectl apply命令会依次读取这两个配置文件,然后根据其中描述的内容创建或更新相应的资源。如果这些资源还不存在,系统就会创建它们;如果已经存在,Kubernetes只会应用其中的变更部分,而不会不必要的重启Pod。

实时观察Pod的启动过程:

kubectl get pods -w

使用-w选项可以实时监控变化情况,系统会在发生变化时立即显示更新结果。你应该会看到Pod的状态从Pending变为ContainerCreating,最终变为Running。当所有Pod的状态都显示为Running时,就可以按Ctrl+C停止监控了。

步骤9:验证部署结果

现在一切都已经运行起来了。但我们还需要确认一些细节——不仅仅是确保Pod已经启动,还要确认它们被分配到了正确的节点上,并且正在运行正确版本的程序。

确认Pod的部署位置

kubectl get pods -o wide

使用-o wide选项可以在输出结果中添加更多列信息,其中包括每个Pod被分配到的节点名称。请查看NODE这一列:

NAME                          READY   STATUS    NODE
hello-axion-7b8d9f-abc12      1/1     Running   gke-axion-tutorial-axion-pool-a-...
hello-axion-7b8d9f-def34      1/1     Running   gke-axion-tutorial-axion-pool-b-...
hello-axion-7b8d9f-ghi56      1/1     Running   gke-axion-tutorial-axion-pool-c-...

所有三个Pod都应该显示包含axion-pool的节点名称,而不应该有任何Pod显示default-pool

确认这些节点确实是ARM架构的

选取其中一个节点名称,验证其架构标签:

kubectl get node NODE_NAME --show-labels | grep kubernetes.io/arch

NODE_NAME替换为上一步命令中使用的某个节点名称。你应该会看到如下结果:

kubernetes.io/arch=arm64

这就是GKE在为ARM硬件配置资源时自动添加的标签。我们的nodeSelector正是根据这个标签来将Pod分配到相应的节点上的。

终端窗口分为上下两部分:上半部分显示使用kubectl get pods -o wide命令查询的结果,所有Pod都被分配到了名称中包含“axion-pool”的节点上;下半部分显示使用kubectl get node命令查询的结果,其中标签信息中明确写着kubernetes.io/arch=arm64。

直接询问应用程序本身

这是最能验证我们的假设的步骤。我们的Go服务器会报告当前正在运行的二进制文件的架构信息,让我们直接来询问它吧。

使用kubectl port-forward命令,在你的本地机器上创建一个从端口8080到目标 Deployment上端口8080的安全隧道:

kubectl port-forward deployment/hello-axion 8080:8080

这个命令会持续在前台运行——请打开第二个终端窗口,然后执行以下命令:

curl http://localhost:8080

你应该会看到如下输出:

Hello from freeCodeCamp!
Architecture : arm64
OS           : linux
Pod hostname : hello-axion-7b8d9f-abc12

Architecture : arm64。这说明了我们的Go二进制文件确实是为ARM64架构编译的,并且正在ARM64 CPU上运行。我们构建的那个多架构镜像标签确实能够自动实现预期的功能。

使用curl http://localhost:8080查询时终端显示的输出结果,包括四行信息:Hello from freeCodeCamp、Architecture: arm64、OS: linux以及Pod的hostname。

额外福利:观察清单文件的实际运行效果

想看看多架构镜像索引功能在实际中是如何工作的吗?首先停止端口转发操作,然后执行以下命令:

docker buildx imagetools inspect \
  us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1

PROJECT_ID替换为你的实际Google Cloud项目ID。

在清单列表中,你会看到四条记录。其中两条是真实的镜像,分别为Platform: linux/amd64Platform: linux/arm64;而另外两条则显示为Platform: unknown/unknown,并且带有attestation-manifest注释。这些实际上就是构建来源记录,Docker Buildx会自动将这类记录添加到每一张镜像中。这一功能属于供应链安全体系中的认证机制(SLSA认证),它能够证明某张镜像是如何以及在哪里被创建的。

你可能会注意到,如果检查正在运行的Pod中记录的图像摘要的话:

kubectl get pod POD_NAME \
  -o jsonpath '{.status.containerStatuses[0].imageID}'

POD_NAME替换为之前提到的某个Pod名称。

返回的摘要与顶层清单列表摘要相匹配,而不是专门针对arm64平台的摘要。这种行为是符合预期的。现代Kubernetes(使用containerd)记录的是清单列表摘要,而非经过平台转换后的摘要。当节点下载到正确的镜像版本时,平台转换就已经完成了。

证明正确二进制文件正在运行的最可靠依据就是你目前已经掌握的信息:标记为kubernetes.io/arch=arm64的节点,以及应用程序报告的Architecture: arm64这一信息。

顶层清单列表摘要

步骤10:成本节约与权衡

实际的操作已经完成,现在让我们来讨论一下为什么这些努力是值得的。

成本计算

在撰写本文时,ARM在Google Cloud上的性价比与相应的x86机器相比如下(价格仅为大致数值,会随时间变化——在做出决策之前,请查看官方定价页面):

实例类型 vCPU数量 内存容量 每小时大致费用
n2-standard-4(x86) 4 16 GB 约0.19美元/小时
t2a-standard-4(Tau ARM) 4 16 GB 约0.14美元/小时
c4a-standard-4(Axion) 4 16 GB 约0.15美元/小时

这意味着每个节点的计算成本降低了25%到30%。再加上Google宣称Axion在相关工作负载下能提供高达65%的性价比提升——也就是说,可能需要更少的节点来处理相同的流量——那么节省的费用就会进一步增加。

下面来看一下当一个服务持续运行20个节点一年时,成本会降低多少:

  • 20 × n2-standard-4 × (0.19美元/小时 × 8,760小时) = 每年33,288美元

  • 20 × t2a-standard-4 × (0.14美元/小时 × 8,760小时) = 每年24,528美元

这意味着在未考虑任何折扣的情况下,每年可以节省大约8,760美元的计算成本。

何时选择ARM才是正确的

ARM最适合以下场景:

  • 无状态API服务器和Web应用程序——比如我们构建的那个应用。ARM在处理高吞吐量、低延迟的网络任务时表现优异。

  • 后台工作进程和队列处理器——那些不需要依赖x86特定二进制文件的长期运行的服务。

  • 用Go、Rust或Python编写的微服务——这些语言对ARM64平台有很好的支持,并且默认就是跨平台开发的。

何时需要谨慎操作

  • 原生库依赖问题 — 一些较旧的C语言库、专有的SDK,或是编译好的机器学习模型运行时程序,并没有ARM64版本。在进行迁移之前,务必仔细检查你的依赖关系树。

  • 持续集成流程也需要支持ARM架构 — 你的自动化测试应该能够在ARM架构上运行,而不仅仅是在x86架构上。那些仅在ARM架构上才会出现故障的镜像,比那些根本不支持ARM架构的镜像更难调试。

  • <>在优化之前先进行性能测试 — 虽然采用ARM架构确实能够节省成本,但在正式实施之前,还是需要先测量一下应用程序在ARM架构上的实际运行表现。并非所有类型的工作负载都能从这种架构转换中获益。

清理工作

完成迁移后,请及时进行清理操作,以避免产生不必要的费用:

# 从集群中删除Kubernetes资源
kubectl delete -f k8s/

# 删除ARM节点池
gcloud container node-pools delete axion-pool \
  --cluster=axion-tutorial-cluster \
  --zone=us-central1-a

# 删除整个集群
gcloud container clusters delete axion-tutorial-cluster \
  --zone=us-central1-a

# 从Artifact Registry中删除相关镜像(可选操作——存储成本很低)
gcloud artifacts docker images delete \
  us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1

总结

让我们回顾一下你刚才所完成的工作,以及每个环节的重要性。

你首先创建了一个Go语言应用程序、一个Dockerfile,然后使用`docker buildx build`命令生成了两个镜像——一个是针对x86架构的,另一个是针对ARM64架构的。这两个镜像被封装在同一个Manifest List标签中,因此任何下载该标签的服务器都会自动获取到合适版本的二进制文件,而你无需维护额外的部署流程或标签。

你还配置了一个GKE集群,其中包含了两个运行不同CPU架构的节点池,然后通过`nodeSelector`机制确保那些针对ARM架构优化的工作负载只会被分配到ARM节点上,而不会错误地出现在x86节点上。这样,你的应用程序就能在正确的架构环境下运行,同时也能有效节省成本。

你在这里练习的各种方法并不局限于这个演示案例。同样的Dockerfile技术可以应用于任何支持跨平台编译的语言;同样の`nodeSelector`机制也可以用于确保任何你需要部署在ARM架构上的工作负载都能得到正确处理。随着未来越来越多的团队将服务迁移到ARM架构上,掌握这些技能将会变得非常有用。

后续可以尝试的方向:

  • 为你的项目添加一个GitHub Actions工作流,在每次代码提交时自动执行`docker buildx build --platform linux/amd64,linux/arm64`命令,从而实现整个迁移过程的自动化。

  • 检查你现有的无状态服务是否支持ARM架构,然后尝试将它们迁移到新的架构上。

  • 对于那些可以在两种架构上都运行但更倾向于使用ARM架构的工作负载,可以研究一下`Node Affinity`机制,看看它是否能作为一种替代方案。

  • 也可以了解一下`GKE Autopilot`,因为它现在已经支持ARM节点了,而且能够自动处理节点池的管理工作。

祝构建过程顺利。

项目文件结构

hello-axion/
├── app/
│   ├── main.go          — Go语言编写的HTTP服务器代码
│   ├── go.mod           — Go模块定义文件
│   └── Dockerfile       — 多阶段Docker构建脚本
└── k8s/
    ├── deployment.yaml  — 包含nodeSelector配置及监控机制的部署文件
    └── service.yaml     — LoadBalancer类型的服务配置文件

本教程所需的所有源代码文件均可在对应的GitHub仓库中找到:https://github.com/Amiynarh/multi-arch-docker-gke-arm

Comments are closed.