本文是对 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 构建能力的困境
- 5 台 2 核 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-gnu 和 aarch64-apple-darwin 的二进制文件到 Forgejo 通用包仓库和私有 Homebrew tap。再加一个目标,能有多难?
二、ARM64 的兼容性
Go 生态:天然友好
作者在"主控/控制/K3S 服务器"节点上运行的服务:
- k3s 本身
- cert-manager
- Traefik v3(还支持 HTTP/3)
- 完整的 Prometheus 监控栈,包含 Grafana
- 几个 PostgreSQL 集群
- Umami 分析服务
这些要么无处不在,要么用 Go 写成——Go 的交叉编译工具链非常成熟,ARM64 镜像早就存在了。
自定义组件的架构判断
Dockerfile 里下载 regclient、ffmpeg 静态构建等二进制文件的部分,只需给 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 过程:
- 在与目标环境匹配的构建环境里
- 运行
cargo install --path . - 运行
BEARDIST_CACHE=/tmp/beardist beardist build - 运行
./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 都完成后触发,负责:
- 用
multify.sh创建并推送 multi-arch manifest - 触发 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.1 和 fe80::/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 JSON
↓
CMS 软件(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 是有用的工具,但不是驾驶员。 "它每次都会把我带偏,但我才是开车的人。"
参考链接
- 原文:fasterthanli.me/articles/mo…
- 前传(计划外的灾难恢复):fasterthanli.me/articles/im…
- regclient(regctl 工具):github.com/regclient/r…
- timelord(文件时间戳工具):github.com/fasterthanl…
- Calico 网络插件:www.tigera.io/project-cal…
- just 任务运行器:github.com/casey/just
- bun JavaScript 运行时:bun.sh
- Forgejo Actions 文档:forgejo.org/docs/latest…