我只是想加个 ARM64,结果搞了一整夜 IPv6

2 阅读7分钟

本文是对 More devops than I bargained for的整理与翻译


内容结构概览

一、背景:从一次灾难恢复到更大的优化冲动
    - 从 x86_64 专用服务器(41/月)迁移到 aarch64 实例(12/月)
    - 动机:成本优化 + 把 arm64 加入 CI 目标

二、ARM64 的兼容性问题
    - Go 语言生态:天然支持,ARM64 镜像早已存在
    - 自定义组件:home-drawio(bun 打包的 draw.io 转 SVG 工具)
    - Justfile 构建脚本的完整代码和输出
    - LLM 辅助开发的哲学:先用 bash 原型,再用 Rust 精化

三、多架构容器镜像的构建方案
    - 问题:如何同时构建和推送适用于多架构的 OCI 镜像
    - 方案:arch-specific 标签 + multi-arch manifest 列表
    - docker manifest create 方式(docker 环境下)
    - repack.sh 脚本:不依赖 docker daemon 的 regctl 方案
        * 时间戳重置:可复现构建的关键
        * regctl image mod + layer-add 的核心命令
    - beardist 的自举(bootstrap)问题:用自己构建自己

四、Forgejo Actions 的三阶段 CI 流程
    - mac-build:非容器化 runner,静态链接直接替换二进制
    - linux-build:矩阵策略,arm64 + amd64 两个 runner 并行
    - trigger-formula-update* multify.sh 手工构建 multi-arch manifest JSON
        * 触发 Homebrew tap 更新

五、x86_64 构建能力的困境
    - 52 核 amd64 虚拟机,30 分钟超时,跑不完 838 个依赖
    - Mac Studio 加 x86_64 QEMU 虚拟机的方案:三个致命缺陷
    - 网站已经宕机,必须快速解决

六、IPv6 深水区(More like IPv5)
    - K3s 的网络模型:节点 - Pod - 覆盖网络
    - 私有地址空间:IPv4(10.42.0.0/16+ ULA IPv6(fd00:42::/48- Traefik 使用 host networking:Pod IP 就是节点公网 IP
    - 普通 Pod 的私有 IP 和覆盖网络
    - traceroute 演示 Pod 间路由(IPv4 和 IPv6 双栈)

七、NAT 的工作原理与问题
    - 169.254.1.1 和 fe80:: 的链路本地地址:不是地址分配失败,是 CNI 的设计
    - 出口流量的 NAT 链路:Pod → 节点 → 公网 IP(SNAT)
    - 节点 domino 在家用网络后面:双重 NAT(NAT44)
    - IPv6 也面临同样问题:NAT66(ULA → 节点公网 IPv6)
    - curl ifconfig.me 发现的真相:Pod 地址和公网出口地址完全不同

八、最终解法:从 Flannel 换到 Calico
    - 核心区别一:WireGuard 加密节点间流量(Flannel 默认是明文 VXLAN/UDP)
    - 核心区别二:NAT66 支持,让 ULA 地址的 Pod 能访问 IPv6 公网
    - 给 domino 节点单独创建 IP 池的思路
    - 凌晨 4 点的顿悟:只是想加个 arm64 而已

九、deploy 脚本的最终形态
    - 完整的 kubectl dry-run → 审批 → apply → commit → push 流程

十、小结

一、背景:灾难恢复留下的胃口

前不久,作者经历了一次计划外的灾难恢复,这反而激起了他的兴趣:多搞一点!多一点宕机!多一点 Kubernetes manifest!多一点 DNS!

计划本来很简单。作者喜欢 Hetzner 的专用服务器,但它们"不够可替换"——新服务器要等好几分钟,有时还要付安装费,而且为了托管静态网站和跑 K3S 控制平面,那台 32GB RAM + 16 核的 x86_64 机器实在太大了,价格也差不多是应该付的两倍。

于是决定:从 41 欧/月的 x86_64 专用服务器,迁移到 12 欧/月的 aarch64 实例——8 个 Ampere 核心,16GB RAM。

顺手一想:他最近刚给自己的 CMS 软件搭好了 CI/CD,能构建和发布 x86_64-unknown-linux-gnuaarch64-apple-darwin 的二进制文件到 Forgejo 通用包仓库和私有 Homebrew tap。再加一个目标,能有多难?


二、ARM64 的兼容性

Go 生态:天然友好

作者在"主控/控制/K3S 服务器"节点上运行的服务:

  • k3s 本身
  • cert-manager
  • Traefik v3(还支持 HTTP/3)
  • 完整的 Prometheus 监控栈,包含 Grafana
  • 几个 PostgreSQL 集群
  • Umami 分析服务

这些要么无处不在,要么用 Go 写成——Go 的交叉编译工具链非常成熟,ARM64 镜像早就存在了。

自定义组件的架构判断

Dockerfile 里下载 regclientffmpeg 静态构建等二进制文件的部分,只需给 Claude 3.5 Sonnet 一个"让这些也支持 arm64"的提示,就能得到正确的 bash 脚本:

# 根据架构映射包名
if [ "${ARCH_NAME}" == "amd64" ]; then
    PKG_ARCH="x86_64-unknown-linux-gnu"
elif [ "${ARCH_NAME}" == "arm64" ]; then
    PKG_ARCH="aarch64-unknown-linux-gnu"
else
    echo "Error: Unsupported architecture: ${ARCH_NAME}" >&2
    exit 1
fi

curl --fail --location --retry 3 --retry-delay 5 \
    -H "Authorization: token ${FORGEJO_READWRITE_TOKEN}" \
    "https://code.bearcove.cloud/api/packages/bearcove/generic/home-drawio/${HOME_DRAWIO_VERSION}/${PKG_ARCH}.tar.xz" \
    -o "${TEMP_DIR}/home-drawio.tar.xz"

作者的 LLM 使用哲学:让生成的工具先展示计划,请求用户确认,显示进度,结束时打印操作摘要和错误列表。他在 DevOps 场景里广泛使用这种方式——有几个关键点需要非常扎实,其余的都是胶水代码。通常先用 bash 或 TypeScript 做原型,需要速度或正确性时再移植到 Rust。

home-drawio:bun 打包的自定义组件

home-drawio 是一个能把 draw.io 图表转换为 SVG 的二进制文件,之前用 node.js 做,现在用 bun 编译为字节码打包:

Justfile 构建配置(just 任务运行器,对作者来说替代了 make)的完整输出:

just build
Starting build process...
Installing dependencies...
Done in 213ms using pnpm v10.7.1
Building project...
 [571ms]  bundle  700 modules
 [143ms] compile  dist/home-drawio
Running the native executable...
[2025-04-07T11:18:34.317Z] Parse XML took 0.31ms
...
Get SVG took 11.74ms
Build process completed successfully!
Checking binary size...
-rwxrwxrwx@ 1 amos  staff    95M Apr  7 13:18 dist/home-drawio
dist/home-drawio: Mach-O 64-bit executable arm64

三、多架构容器镜像

问题:怎么构建和推送支持多架构的镜像

这是作者遇到的第一个真正陌生的领域。

他以前直接构建并推送带版本标签的镜像:

code.bearcove.cloud/bearcove/beardist:latest
code.bearcove.cloud/bearcove/home:33.0.0

现在需要的方式:先推架构特定标签,再用这两个标签合成一个 multi-arch manifest:

code.bearcove.cloud/bearcove/beardist:latest-arm64
code.bearcove.cloud/bearcove/beardist:latest-amd64

如果用 docker,可以这样创建 manifest:

docker manifest rm "${BASE}/${target}:latest" || true
docker manifest create "${BASE}/${target}:latest" \
    $(for platform in $PLATFORMS; do
        arch=$(echo $platform | cut -d/ -f2);
        echo "${BASE}/${target}:latest-${arch}";
    done)
docker manifest push "${BASE}/${target}:latest"

repack.sh:不依赖 docker daemon 的方案

作者的 CI 环境里不想接入 docker daemon,因此使用 regctl(来自 regclient)——它不需要 docker 守护进程,直接操作 OCI 镜像格式。

repack.sh 脚本的核心逻辑:

OCI_LAYOUT_DIR="/tmp/beardist-oci-layout"
OUTPUT_DIR="/tmp/beardist-output"
IMAGE_NAME="code.bearcove.cloud/bearcove/beardist:${TAG_VERSION:+${TAG_VERSION}-}${ARCH_NAME}"
BASE_IMAGE="code.bearcove.cloud/bearcove/build:${ARCH_NAME}"

# 准备文件层
rm -rf "$OCI_LAYOUT_DIR"
mkdir -p "$OCI_LAYOUT_DIR/usr/bin"
cp -v "$OUTPUT_DIR/beardist" "$OCI_LAYOUT_DIR/usr/bin/"

# 关键:把所有时间戳重置为 epoch,保证可复现构建
touch -t 197001010000.00 "$OCI_LAYOUT_DIR/usr/bin/beardist"

# 在 BASE_IMAGE 上叠加新层,创建新镜像
regctl image mod "$BASE_IMAGE" --create "$IMAGE_NAME" \
    --layer-add "dir=$OCI_LAYOUT_DIR"

# 推送
regctl image copy "$IMAGE_NAME"{,}

时间戳重置是关键。这不是 Nix,但提供了很多 Nix 给他带来的价值:不依赖 docker 组装 OCI 镜像,保证"如果某一层没有变化,就可以直接复用"。

一个 OCI 层的本质是:把目录打成 tar 包,再计算 SHA256。regctl image mod --layer-add "dir=..." 就是在做这件事。

beardist 的自举问题

beardist 本身作为 multi-arch 镜像分发,而且它用自己来构建自己,所以需要一个初始的 bootstrap 过程:

  1. 在与目标环境匹配的构建环境里
  2. 运行 cargo install --path .
  3. 运行 BEARDIST_CACHE=/tmp/beardist beardist build
  4. 运行 ./repack.sh

此后,CI 就可以用 beardist:latest 镜像来构建 beardist 本身了。这条链已经运行了几周,还没断过。


四、Forgejo Actions 的三阶段 CI 流程

完整的 CI 配置定义了三个 job:

mac-build

运行在非容器化的 mac-arm runner 上,直接构建 macOS 二进制:

mac-build:
    runs-on: mac-arm
    steps:
        - uses: actions/checkout@v4
        - run: |
            beardist build
            if [[ "${{ github.ref }}" == refs/tags/* ]]; then
                # beardist 是静态链接的,可以直接原地替换
                cp /tmp/beardist-output/beardist $(which beardist)
            fi

linux-build

矩阵策略,同时在 arm64 和 amd64 runner 上构建:

linux-build:
    strategy:
        matrix:
            include:
                - runs-on: linux-arm64
                  artifact: aarch64-unknown-linux-gnu
                  platform: linux/arm64
                - runs-on: linux-amd64
                  artifact: x86_64-unknown-linux-gnu
                  platform: linux/amd64
    container:
        image: code.bearcove.cloud/bearcove/beardist:latest
        volumes:
            - /var/persistent-build-storage:/var/persistent-build-storage
    steps:
        - uses: actions/checkout@v4
        - run: |
            beardist build
            if [[ "${{ github.ref }}" == refs/tags/* ]]; then
                regctl registry login code.bearcove.cloud -u token -p "${{ secrets.FORGEJO_READWRITE_TOKEN }}"
                ./repack.sh
            fi

trigger-formula-update

在两个 linux-build 都完成后触发,负责:

  1. multify.sh 创建并推送 multi-arch manifest
  2. 触发 Homebrew tap 的 formula 更新

由于 CI 里没有 docker,只有 regctl,multify.sh 手工构造 manifest JSON:

# 获取两个架构镜像的 digest 和 manifest 大小
AMD64_DIGEST=$(regctl manifest head code.bearcove.cloud/bearcove/beardist:${AMD64_TAG} --platform linux/amd64)
ARM64_DIGEST=$(regctl manifest head code.bearcove.cloud/bearcove/beardist:${ARM64_TAG} --platform linux/arm64)
AMD64_SIZE=$(regctl manifest get ... --format raw-body | wc -c)
ARM64_SIZE=$(regctl manifest get ... --format raw-body | wc -c)

# 生成标准的 OCI manifest list JSON
cat <<EOF > manifest.json
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
  "manifests": [
    {
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "size": $AMD64_SIZE,
      "digest": "$AMD64_DIGEST",
      "platform": { "architecture": "amd64", "os": "linux" }
    },
    {
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "size": $ARM64_SIZE,
      "digest": "$ARM64_DIGEST",
      "platform": { "architecture": "arm64", "os": "linux" }
    }
  ]
}
EOF

# 推送 manifest
regctl manifest put \
    --content-type application/vnd.docker.distribution.manifest.list.v2+json \
    code.bearcove.cloud/bearcove/beardist:latest < manifest.json

整个 multify.sh 执行只需 1.45 秒。


五、x86_64 构建能力的困境

beardist 自身的构建完成了,但 CMS 软件(代号 home)就另当别论了:

home on  main via  v1.85.1
 cat Cargo.lock | grep -F '[[package' | wc -l
     838

838 个依赖!

当时作者手头的 amd64 资源:

  • 一台用 UTM 在 macOS 上跑的 x86_64 虚拟机
  • 5 台 2 核 amd64 机器

试了一下,Forgejo Actions 的 job timeout 30 分钟直接踢掉了——根本跑不完。

能不能在 Mac Studio(32GB RAM)上再加一台 x86_64 Linux 虚拟机?理论上可以,但:

  • 少 6GB RAM 对剪 4K 视频太残忍
  • QEMU 的 x86_64 模拟很慢,多核模拟更慢
  • USB SATA 固态也慢(内部存储不够放所有虚拟机)

而且,作者还花了好几个小时想搞清楚怎么让 IPv6 在"Mac Studio → QEMU 虚拟机 → 容器"的三层结构里正常工作——

因为,这是 IPv6 导致的问题。


六、IPv6 深水区

K3s 的网络模型

在 Kubernetes 里,工作负载在"容器"里运行,容器跑在"Pod"里,Pod 调度到"节点"上。

在这个集群里,节点就是 Hetzner Cloud 的 VM:

 k get nodes
NAME     STATUS   ROLES                       AGE   VERSION
domino   Ready    <none>                      15h   v1.31.6+k3s1
flam     Ready    <none>                      18h   v1.31.6+k3s1
hawk     Ready    <none>                      18h   v1.31.6+k3s1
heim     Ready    <none>                      18h   v1.31.6+k3s1
kaya     Ready    <none>                      18h   v1.31.6+k3s1
marl     Ready    <none>                      18h   v1.31.6+k3s1
styx     Ready    control-plane,etcd,master   18h   v1.31.6+k3s1

节点不需要有公网 IP,它们可以在 NAT 后面,只要能连到 K3S 服务器、注册为节点、加入覆盖网络就行。

Pod 的 IP 地址

集群配置中定义的 CIDR:

cluster-cidr: 10.42.0.0/16,fd00:42::/48
service-cidr: 10.43.0.0/16,fd00:43::/112

这些都不是"公网可路由"的地址:

  • 10.42.0.0/16 是 IPv4 私有地址空间
  • fd00:42::/48 是 IPv6 ULA(Unique Local Address)

把任意一个发往互联网路由器,它会直接丢掉这个包。

但它们完全适合用于 k3s 设置的私有覆盖网络,让 Pod 之间互相通信。

Traefik 的特殊情况:host networking

Traefik 是 HTTP 反向代理,每个边缘节点上跑一个 Pod:

{"nodeName":"styx","podIP":"49.13.119.8"}
{"nodeName":"heim","podIP":"157.180.27.172"}
{"nodeName":"hawk","podIP":"116.202.24.111"}

这些 Pod 使用 host networking,所以 Pod IP 就是节点的公网 IP。当 DNS 把 fasterthanli.me 指向某个节点时,80 和 443 端口的连接会直接到 Traefik。

ssh root@49.13.119.8 -- "ip addr show eth0 | grep -E '(inet|inet6)'"
    inet 49.13.119.8/32 brd 49.13.119.8 scope global dynamic eth0
    inet6 2a01:4f8:c17:34b1::1/64 scope global
    inet6 fe80::9400:4ff:fe32:8ea/64 scope link

普通 Pod 的 IP 地址

home 命名空间下的 Pod 就不同了:

{"nodeName":"heim","podIP":"10.42.40.130"}
{"nodeName":"hawk","podIP":"10.42.123.3"}
{"nodeName":"marl","podIP":"10.42.71.66"}

全部在 10.42.0.0/16 里。这些是私有地址,外部不可访问,但 Pod 之间可以互相访问:

k exec -n home cub-dc9f5b494-bhnjr -it -- \
    curl -H 'Host: fasterthanli.me' -I http://10.42.40.130:1111

HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
x-source: eu-north-1.heim.cub-dc9f5b494-bhnjr

这就是覆盖网络的意义。 但这和"能访问互联网"(egress)是两回事。


七、NAT 的工作原理与问题

创建测试 Pod

nicolaka/netshoot 镜像创建一个网络诊断 Pod:

apiVersion: v1
kind: Pod
metadata:
    name: net-shooter
spec:
    containers:
        - name: net-shooter
          image: nicolaka/netshoot
          command: [sleep, infinity]
    nodeSelector:
        provider: hcloud

查看这个 Pod 的路由表:

k exec net-shooter -it -- ip -4 route show
default via 169.254.1.1 dev eth0
169.254.1.1 dev eth0 scope link

k exec net-shooter -it -- ip -6 route show
fd00:42:0:1d1b:89d4:e2d6:158f:6f0f dev eth0 proto kernel metric 256 pref medium
fe80::/64 dev eth0 proto kernel metric 256 pref medium
default via fe80::ecee:eeff:feee:eeee dev eth0 metric 1024 pref medium

169.254.1.1fe80::/64 都是链路本地地址(link-local)——这两个地址通常只在 DHCP 失败时才会出现,但在这里,这是 CNI 的刻意设计:Pod 的默认网关是一个链路本地地址,实际的路由决策由节点上的 iptables/ip6tables 规则来完成。

出口流量是如何路由的

简单情况:Pod 在 Hetzner Cloud VM 上,发起到公网 IPv4 地址的请求。

路由链路:

Pod (10.42.x.x) → 节点 eth0 (公网 IPv4) → 公网

这是标准的 SNAT(Source NAT):节点把 Pod 的私有源 IP 替换成自己的公网 IP,再发出去。

如果向公网询问"我的 IP 是什么":

k exec net-shooter -it -- curl ifconfig.me

得到的是节点的公网 IP,而不是 Pod 的私有 IP——NAT 就是这样工作的。

domino 节点的双重 NAT 问题

domino 这个节点有点特殊——它跑在家用网络里,所以有双重 NAT:

Pod (10.42.x.x) → 节点的家用网络私有 IP → 家用路由器公网 IP → 公网

这叫做 NAT44 的双重叠加,也是作者深夜最头疼的地方之一。

IPv6 也有同样的问题:NAT66

Pod 的 IPv6 地址来自 fd00:42::/48,这是 ULA 地址,同样不是公网可路由的。

当 Pod 要访问公网 IPv6 地址时,同样需要 NAT——把 ULA 源地址转换成节点的公网 IPv6 地址。这就是 NAT66(IPv6 到 IPv6 的地址转换)。

IPv6 的原始设计意图之一就是消除 NAT——每台设备都有公网可路由地址。但 Kubernetes 的 Pod CIDR 设计让这个愿景在集群内部失效了,我们依然需要 NAT,只不过是 IPv6 版的。

作者在凌晨 4 点发现的真相: domino 节点在家用 NAT 后面,所以做了双重 NAT(NAT44),IPv6 也在做 NAT66。这意味着他花了数小时在三层虚拟化结构里排查 IPv6 连通性,最后发现问题根本不是配置错误,而是这套架构在这个节点上天然就是受限的。


八、最终解法:从 Flannel 换到 Calico

作者没有详细说明所有排查步骤,直接给出结论:把 CNI 插件从 Flannel 换成了 Calico。

核心区别一:WireGuard 加密节点间流量

Flannel 默认使用 VXLAN over UDP 传输覆盖网络流量,是明文的。

Calico 建立 WireGuard 网络,节点间流量加密。

(作者注:Flannel 也支持 WireGuard,只是默认没有启用。)

核心区别二:NAT66 支持

Calico 能原生支持 NAT66——让使用 ULA 地址的 Pod 能访问 IPv6 公网,这是 Flannel 做不到的。

给特定节点单独创建 IP 池

理论上,对于 domino 这种在家用网络后面的节点,可以给它创建一个专用的 IP 地址池,避免双重 NAT 的问题。但作者承认,凌晨 4 点,他已经太害怕再碰这个配置了,先让系统能跑起来再说。

凌晨 4 点的心声

我从来没有想过要学这些,我只是想在环境里加个 arm64 而已。Dockerfile 解决问题的时代太美好了。让我出去。让我出去。


九、deploy 脚本的最终形态

作者还顺带分享了他更新后的 deploy 脚本,把之前的 yq/rsync 方案换成了纯 kubectl

#!/bin/zsh -euo pipefail

# 定义染色函数
colorize() {
  sed -E $'s/(unchanged)/\\033[1;34m\\1\\033[0m/g; s/(created)/\\033[1;32m\\1\\033[0m/g; s/(configured)/\\033[1;33m\\1\\033[0m/g; s/(deleted)/\\033[1;31m\\1\\033[0m/g'
}

# 准备 kubectl 参数(支持指定子目录或文件)
kubectl_args=("-R")
if [ $# -eq 0 ]; then
  kubectl_args+=("-f" "manifests/")
else
  for manifest in "$@"; do
    kubectl_args+=("-f" "$manifest")
  done
fi

# 先做 dry run
echo "Performing dry run of kubectl apply..."
kubectl apply "${kubectl_args[@]}" --dry-run=server | colorize

# 请求确认
echo "Do you want to apply these changes? (y/n)"
read -r response

if [[ "$response" =~ ^[Yy]$ ]]; then
  echo "Applying changes..."
  kubectl apply "${kubectl_args[@]}" | colorize
else
  echo "Operation cancelled."
  exit 1
fi

# 自动 commit 和 push
echo "Enter a commit message:"
read -r commit_message

git add .
git commit -m "$commit_message"
git push

echo "Changes have been committed and pushed."

一个体现作者工程风格的脚本:先展示计划(dry-run),请求确认,执行,自动把变更提交到 git。基础设施即代码——不只是 Kubernetes manifest 本身,连部署操作的历史记录也都在版本控制里。


小结

这篇文章的完整因果链:

想省点钱,把控制节点从 x86_64 换成 arm64
  ↓
需要构建 arm64 Linux 的容器镜像
  ↓
需要理解 multi-arch OCI manifest 是怎么工作的
  ↓
beardist 要用 regctl 手工构造 manifest JSONCMS 软件(838 个依赖)在 2 核 amd64 机器上 30 分钟跑不完
  ↓
想在 Mac Studio 上跑 x86_64 QEMU 虚拟机
  ↓
IPv6 在容器-虚拟机-Mac Studio 三层结构里不通
  ↓
凌晨排查发现是 NAT66 的问题
  ↓
从 Flannel 换到 Calico,支持 WireGuard + NAT66
  ↓
凌晨 4 点,"我只是想加个 arm64 而已"

几个对读者有实际价值的经验:

OCI 镜像不一定需要 docker daemon。 regctl 可以在没有 docker 的环境里完整操作 OCI 镜像:叠加层、推送、创建 multi-arch manifest。只要理解"OCI 层就是带 SHA256 的 tar 包"这个基本事实,一切都可以用普通工具完成。

可复现构建的关键是时间戳。repack.sh 里把所有文件时间戳重置为 epoch(touch -t 197001010000.00),保证"内容不变 → 哈希不变 → 层不变 → 镜像层可复用"。这不是 Nix,但提供了相同的关键属性。

K8s 里的 IPv6 并不像想象中简单。 即使集群配置了双栈,Pod 的 IPv6 地址通常是 ULA 地址,和公网 IPv6 之间还有 NAT66。Calico 支持 NAT66,Flannel 默认不支持。如果需要 Pod 能访问 IPv6 公网,这是一个需要在 CNI 选型时考虑的问题。

LLM 是有用的工具,但不是驾驶员。 "它每次都会把我带偏,但我才是开车的人。"


参考链接