1. 先搞清楚我要打的到底是什么:deb 包里的架构藏在哪三层
说出来你可能不信,我是在一台 Windows 11 办公机上,给一台 arm64 的 Linux 服务器打出了这辈子第一个 arm64 的 .deb 安装包。听起来不算难,但真正做完回头看,我发现整个过程踩掉的坑,几乎都集中在三件事上——而且这三件事,我当时在开始前全都想错了。
先说背景。目标机器是一台 ARM 架构的 Linux 云主机,跑的是 Debian 系系统,交付方式要求是 .deb 包。而我手头没有可以直接登录的 ARM 机器,只在 Windows 上有代码仓库和构建脚本。原本以为“windows 上打出 arm64 的 deb 包”就是开个 Linux 容器、把二进制交叉编译一下、塞进包里改个架构字段,一个下午肯定能搞定。结果折腾到晚上,才发现我还是太乐观了。
问题出在哪?在于很多人对“deb 包”的理解仍然停留在“一个安装包”的层面。实际上 deb 并不是某种编译产物,它只是一个 ar 格式的归档容器,里面装着两份 tar 打包的文件:一份是控制信息(control.tar.,包括 control、postinst、preinst 这些),一份是实际要安装的文件(data.tar.,也就是 usr/bin、etc/systemd/system 这些路径)。所谓“这是 arm64 的包”,并不是由包后缀名决定的,也不是由包目录名决定的,而是同时由好几个层面的信息共同决定。
我习惯把这件事拆成三层来看,这也是排查时最省心的框架:
| 层面 | 看什么 | 常见命令 | 期望结果 |
|---|---|---|---|
| 包描述层 | control 文件里的 Architecture 字段 | dpkg-deb -I xxx.deb | Architecture: arm64 |
| 二进制层 | 可执行文件的 ELF 机器类型 | file xxx;readelf -h xxx | ARM aarch64;Machine: AArch64 |
| 动态依赖层 | 二进制依赖的 so 库以及后续 dpkg 的依赖计算 | ldd xxx;dpkg-shlibdeps | 依赖全部指向 arm64 库 |
三层之间会互相影响,但又是独立的。第一层错了,包在安装时就会被 dpkg 拒收;第二层错了,包能装上但一运行就报 Exec format error;第三层错了,包装上后可能在运行时崩溃,或者依赖计算完全对不上。我当时犯的每一个错误,基本都对应了这三层之间的某一张“认知断层”。
1.1 你眼中的“架构”和打包器眼中的“架构”不是一回事
先说一个特别容易误导新手的点:deb 包的架构,打包器其实默认只认 control 文件里写的 Architecture 字段。这句话有两层意思。
第一层意思是:如果你用 dpkg-deb --build 打包,而 control 文件里没有写 Architecture,那么 dpkg-deb 会自动用当前构建机器的架构来填。也就是说,你在 Windows 上的 amd64 Debian 容器里打包,什么都不写,最后得到的包几乎一定是 amd64 的。这不是 bug,是 dpkg 的默认行为。
第二层意思是:dpkg 在安装时检查架构,也是看这个字段。它并不关心 data.tar 里那个二进制文件到底是不是 arm64 的。包内二进制是什么架构,dpkg 根本不管,它只负责把文件解压放到指定路径、跑控制脚本。所以在“包管理器视角”下,Architecture: arm64 的包一定能装到 arm64 系统上,哪怕包里的二进制实际上还是 x86_64。
这就会产生两个截然不同的失败模式。我后面会专门讲,但你得先意识到:deb 这个格式本身没有“编译”的概念,它只负责“搬运”文件。你真正要保证的,是搬运进去的文件确实是 arm64 可执行文件。
1.2 为什么在 Windows 上做这件事更容易想错
还有一个容易被忽略的差异:在 Windows 宿主机上,你根本没办法直接执行 file、readelf、ldd 这些检查工具,甚至连 dpkg-deb 都没有。所以你所有的判断都必须绕一层,先进 Linux 容器或者 WSL2 再操作。中间多了一层“环境翻译”,就多了一层想当然的空间。
我当时的第一反应是“先把包打出来再说”。这个顺序本身就错了。正确顺序应该是:先在交叉编译或模拟构建阶段确认二进制架构,再把打包当成一个收尾动作。换句话说,二进制架构是源头,deb 包只是上游产物。你在 Windows 上折腾一阵子,最后发现打出的是 amd64 包,问题基本都出在“源头上没管好架构”,而不是“打包命令写错了”。
这个认知不摆正,后面三个坑你一个都躲不掉。
2. 第一个想错的地方:Architecture 写对了,不代表二进制就是 arm64
我第一次打包时,做法非常简单粗暴:用 gcc 默认编译出 Linux x86_64 的二进制,然后手工写了 Architecture: arm64 的 control 文件,用 dpkg-deb --build 打包,文件名也改成了 myapp_1.0.0_arm64.deb。我当时觉得,这就是 arm64 的 deb 包了。传过去之后,安装确实成功了,因为 dpkg 只看 control 字段。结果运行的时候直接报:
cannot execute binary file: Exec format error这条报错基本就是“你手上的文件不是本机架构能执行的”最直白的翻译。我当时第一反应是检查 Docker 环境、检查依赖库,结果都没问题。最后用 file 看了一眼那个可执行文件,瞬间破防:
myapp: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2好家伙,打包了半天,打了个寂寞。问题的根子在于我把“deb 是 arm64 的”和“里面的二进制是 arm64 的”这两件事混为一谈了。deb 只是壳,壳上的标签写错了,会立刻被拆穿;标签写对了但内容不对,反而更隐蔽——它能装上,能让你觉得“成功了一半”,然后在你真正跑起来的时候给你致命一击。
2.1 两种失败模式,越隐蔽越危险
我把这个阶段的失败分成两种。第一种是 Architecture 字段还留在 amd64 没改,安装时 dpkg 会直接拒绝:
dpkg: error: package architecture (amd64) does not match system (arm64)这种失败虽然让你白忙一趟,但至少报错清晰,马上能定位。第二种就是我上面踩到的:Architecture 改成 arm64 了,但二进制没变,安装完全顺利,跑的时候才炸。这种失败更阴险,因为报错出现在运行时,而且 Exec format error 这种提示很容易让人误判成“权限问题”“文件损坏”或“动态库缺失”。
另外还有一种更冷门但真实存在的情况:postinst、prerm 这类控制脚本本身是 shell 脚本,脚本解释器是 /bin/sh,脚本内容不挑架构,所以脚本本身没问题。但脚本里如果执行了包内安装的二进制,那这个二进制就会立刻触发架构问题。我在另一个项目里就见过 postinst 里调用了包内的 helper 工具做数据库迁移,结果核心包能装,脚本跑到中途才 Exec format error,排查起来比二进制直接跑不了还费劲。所以检查范围不能只看主程序,包内所有 ELF 文件都要查。
2.2 把架构检查做成构建的一部分
在 Windows 上绕一层容器做检查,其实不麻烦,关键是顺序要对。我现在的做法是:在构建容器里,编译完二进制之后,紧接着就执行三连检查:
file bin/myapp readelf -h bin/myapp | grep -E 'Class|Machine|Type'期望的结果分别是:
ELF 64-bit LSB executable, ARM aarch64Class: ELF64Machine: AArch64
如果确认没问题,再把它塞进 deb 包的目录结构里去打包。检查脚本直接嵌进打包脚本里,只要查出二进制不是 aarch64,整个打包过程就中断。不要依赖肉眼去认名字,也不要依赖自己“记得”设置了 --host 参数。机器不会骗人,file 的输出才是唯一真相。
打包目录最小结构长这样:
myapp-1.0.0/ ├── DEBIAN/ │ └── control └── usr/ └── bin/ └── myappcontrol 里关键字段:
Package: myapp Version: 1.0.0 Architecture: arm64 Maintainer: Your Name <you@example.com> Description: myapp for arm64然后打包:
dpkg-deb --build --root-owner-group myapp-1.0.0 myapp_1.0.0_arm64.deb这里--root-owner-group很关键。Windows 和 Linux 容器里的用户 ID 通常不一致,不加这个参数打包,包内文件属主可能变成乱七八糟的 uid,传到目标机上安装后文件的 owner 全是错的。加上这个参数,dpkg 会强制把包内文件 owner 设为 root:root,这是发布 deb 包的常规操作。
2.3 控制脚本不挑架构,但包里的程序挑
再强调一次这次的教训核心:架构标签是给包管理器看的,真正决定能不能跑的是 ELF 文件本身。deb 的 format 是平台无关的,但它的内容载荷是平台强相关的。你可以在 x86 机器上轻松制作一个 arm64 的包,前提是你塞进去的二进制必须是 arm64 编译产物,或者交叉编译产物。改了 Architecture 而不改二进制,等于给罐头贴错了保质期,吃坏肚子是迟早的事。
3. 第二个想错的地方:Docker 能跑 arm64 镜像,不是 Docker 的默认能力
解决了二进制架构问题之后,我以为剩下的就是“交叉编译一下就行”。但很快遇到了第二个认知坑:环境。
我在 Windows 上做 Linux 构建,默认都是用 Docker 开一个 Linux 容器,因为这条路最省事。一开始我甚至想用现成的 arm64 容器直接编译,当时心想 Docker 不是号称跨架构吗?于是直接在命令行里敲了:
docker run --rm --platform linux/arm64 -t debian:bookworm-slim bin/bash如果是 Docker Desktop,这命令确实能跑,容器里的 uname -m 会告诉你 aarch64。但请注意,这件事并不是“Docker 天然支持”。它背后藏着一套机制:binfmt_misc 和 QEMU 用户态模拟。Docker Desktop 在启动自带的 Linux 虚拟机时,会自动注册 qemu-aarch64 到系统的 binfmt_misc 里。内核看到一个 ELF 文件是 AArch64,就会自动把它交给 qemu-aarch64 来翻译执行。你感知不到这层翻译,你只知道容器成功启动了,程序的行为跟原生 Linux arm64 一模一样。
问题是,“自动注册”只在 Docker Desktop 这种完整封装环境里存在。换成别的 Windows Docker 运行时,就不是这么回事了。
3.1 Docker Desktop 替你做的事:binfmt_misc 与 QEMU
你可以把 binfmt_misc 理解成内核的一个“文件格式路由表”。正常情况下,Linux 内核遇到可执行文件,会看它的 ELF header 来解析,但不会执行其他架构的 ELF。binfmt_misc 允许你注册一个“解释器”:当内核发现某个文件的开头特征匹配,就把文件整体交给指定的解释器程序执行。
QEMU 用户态模拟(qemu-aarch64 / qemu-aarch64-static)就是这个解释器。它被注册到 binfmt_misc 之后,你在 x86_64 的 Linux 内核里执行 arm64 ELF,内核见怪不怪,直接把执行权交给 QEMU,QEMU 在用户态翻译执行 arm64 指令。容器里的视角是“我就是一个普通的 arm64 Linux 环境”,实际上翻译发生在 QEMU 层面。
这就是为什么--platform linux/arm64在 Docker Desktop 上“有效”。有效的前提是注册了 binfmt。如果没注册,Docker 能拉取镜像、能解包、能尝试启动容器,但容器里第一个进程(/bin/sh)就已经是 arm64 ELF 了,内核根本不认识,启动瞬间就是:
standard_init_linux.go: exec: "/bin/sh": exec format error我当时第一次在非 Desktop 环境里看到这行报错时,第一反应是拉去重装 Docker。后来冷静下来才意识到,不是 Docker 坏了,而是 Docker 背后的“翻译层”没了。
3.2 换成 Docker Engine、CI Agent 就翻车
这句话对很多 Windows 用户来说不容易有体会,因为大家用的基本都是 Docker Desktop。但只要环境一变,问题就来了。比如:
- 在一个 Windows Server 上直接装了 Docker Engine(experimental 的 Windows 容器模式);
- 在 Jenkins agent 上装了 Docker,但后端是一个自定义的 Linux 虚拟机,镜像仓库又是内网的;
- 用了 docker-machine 或者远程 Docker context,连接到一个精简 Linux 主机。
这些环境里,绝大多数没有自动注册 binfmt_misc,也没有 qemu-aarch64。你执行同样的docker run --platform linux/arm64,大概率就是报错或直接拉取失败。另一个常见报错是:
no matching manifest for linux/arm64/v8 in the manifest list entries这说明镜像仓库里根本没有对应 arm64 架构的镜像层。官方 Debian/Ubuntu/alpine 镜像都做了 multi-arch manifest,所以不报这个错;但很多公司内网镜像、第三方老镜像只有 amd64 版本,这时候光有 binfmt 也没用,因为找不到 arm64 层可拉。
我当时就陷入了这种尴尬:Windows 办公机上的 Docker Desktop 没办法直接连公司内网仓库,CI agent 上又有全套 Docker Engine 但没有 binfmt。结果在办公机本地跑 arm64 容器玩得飞起,一到 CI 上马上崩。这种环境差异带来的割裂感,比打包本身还折磨人。
3.3 5 秒钟判定当前环境能不能跑 arm64 容器
现在我不猜了,先跑一条命令判定:
docker run --rm --platform linux/arm64 alpine uname -m如果环境正常,输出是aarch64。如果输出报exec format error,就说明这个 Docker 环境没有 binfmt 翻译层。如果是no matching manifest,说明镜像仓库缺少 arm64 的 manifest。
需要补翻译层的话,在 Linux 环境里可以手动注册:
docker run --privileged --rm tonistiigi/binfmt --install arm64这个命令会让一个特权容器帮你往宿主内核的 binfmt_misc 里写入 qemu-aarch64 注册项。执行完之后再跑上面的 uname 判断命令,通常就能通了。注意,--privileged在有些受限环境里不一定能跑,如果连这个容器都执行不起来,那基本可以放弃在这个环境模拟 arm64 容器了。
如果 binfmt 注册后还是拉不到 arm64 层,那问题就在于镜像源。这时候要么换官方 multi-arch 镜像,要么自己准备一个包含基础工具链的 arm64 镜像推到内网仓库。没有其他捷径。
3.4 实在不行,直接退回 WSL2 里的一套 Docker
还有一个经常被 Windows 用户忽略的“保底方案”:WSL2。WSL2 本身就是跑在 Windows 里的轻量级 Linux 虚拟机,内核是真实的 Linux 内核。在 WSL2 的发行版里安装 Docker Engine,然后通过 WSL2 的 Docker context 让 Windows 上的 docker 命令指向它,本质上你就拥有了一台“不折腾 binfmt 也随时能自己装解释器”的 Linux 主机。
具体做法不复杂:WSL2 里apt install docker.io qemu-user-static binfmt-support,然后启动 Docker。以后从 Windows 侧执行docker --context wsl或者直接wsl docker ...都能调用到 WSL2 里的 Docker。这样即使 Docker Desktop 出幺蛾子,你还是有一条完整的 Linux 构建通道。代价是文件挂载路径稍微绕一点,但换来的是架构模拟和工具链安装的自由度,值。
我当时因为公司策略不能随便用 Docker Desktop,最后就是靠着 WSL2 里自装 Docker 解决了 buildx 和 binfmt 的一致性问题。Windows 表面上还是 Windows,但背后的活全是 Linux 干的。
4. 第三个想错的地方:交叉编译不是万能钥匙,复杂 C/C++ 会被依赖反噬
环境通道打通了,我当时觉得自己已经是个成熟的跨架构工程师了。于是开始想交叉编译。交叉编译这事,不同语言之间的体验差距,堪比“开车”和“骑马”。
4.1 交叉编译最顺的,和最不顺的项目
最顺的是 Go。只要你的项目没有启用 cgo,交叉编译基本就是一条命令:
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o myapp .在 Windows 的 PowerShell 里写法略有不同:
$env:GOOS="linux" $env:GOARCH="arm64" $env:CGO_ENABLED="0" go build -o myapp .Go 静态链接的特性在这里简直太救了:只要 CGO_ENABLED=0,生成的可执行文件不依赖目标系统的动态库,拷到任何 Linux arm64 系统都能跑。deb 包里甚至不需要额外声明 Depends,安装路径丢进去就能用。Rust 的体验也类似,加一个 target 就行:
rustup target add aarch64-unknown-linux-gnu cargo build --target aarch64-unknown-linux-gnu --release但一旦项目是 C/C++,事情就没这么优雅了。我当时手里正好有一个用了 libcurl、openssl 和一堆 autotools 的 C 项目。我以为装个交叉编译器就能搞定:
apt install gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu ./configure --host=aarch64-linux-gnu make结果 configure 阶段就开始出问题。autotools 项目里很多AC_RUN_IFELSE宏会在配置期间编译一个小程序并实际运行它,用来探测系统属性。交叉编译时,这些小工具是 arm64 的,在 x86_64 的构建机上根本跑不起来。configure 脚本拿不到探测结果,只能默默当作“不支持”处理,各种宏定义错位,最后生成出来的代码行为跟你在 x86_64 上原生编译完全不是一回事。
这是交叉编译最操蛋的地方:不是编译不通过,而是编译“假通过”。你以为你用了 aarch64 编译器,实际上整个构建系统因为无法运行目标程序,已经静默地砍掉了一堆功能。
4.2 三个典型的交叉编译翻车点,一个一个说
第一,configure 探测程序执行不了。刚才说了,autotools 会在配置阶段运行交叉编译出来的可执行文件。交叉编译配置项里有个--build和--host的概念:build 表示当前构建机架构,host 表示目标运行架构。configure 需要同时知道这两个,才能在必要时用 build 架构的编译器编译“探测程序”,用 host 架构的编译器编译“最终程序”。很多老项目、老版本的构建脚本对多架构支持并不好,只给你留了--host,没有--build的自动推导,结果探测程序是 arm64 的,执行时直接被 binfmt 拒之门外——除非你的构建机自己也注册了 qemu,那就另说。但这就绕回第二节的坑了。
第二,依赖库版本不对。C 项目几乎不可能不依赖第三方库。你 apt 安装的是 amd64 的 libcurl-dev、libssl-dev,头文件倒是有,但链接时交叉编译器按自己的 sysroot 找 .so/.a,大概率找到的是/usr/lib/x86_64-linux-gnu下的宿主库,或者在交叉 sysroot 下找不到任何库。这时候常见的误导是:编译期靠头文件混过去了,链接期也可能因为各种隐式路径瞎凑合过去,最后产物的 dynamic section 里多了一条指向 x86 库的 NEEDED。等到 arm64 目标机上安装完,一跑就是error while loading shared libraries。
第三,dpkg-shlibdeps 的依赖计算会骗人。如果你用 dpkg-buildpackage 构建,它会跑 dh_shlibdeps,通过读取二进制文件的动态段来计算 Depends 字段。交叉编译产物如果混进了 x86 库,或者文件里记录的 GLIBC 版本符号和 arm64 版本对不上,生成的依赖清单就完全是错的。轻则多余的依赖,重则缺依赖、也没法在 arm64 系统上正确安装。
4.3 我最后用的,其实是模拟构建这条路
在交叉编译和模拟构建之间反复横跳之后,我的结论很简单:对于没有复杂外部依赖的小型项目,交叉编译很香;对于有大量 C/C++ 依赖的复杂项目,与其在交叉编译里跟依赖库死磕,不如直接在 arm64 容器里做“模拟原生构建”。
模拟原生构建的方式很简单,假设你已经确认 Docker 环境能跑 arm64 容器(按第三节那条 uname 命令测),直接进容器:
docker run --rm --platform linux/arm64 -v $(pwd):/build -w /build \ debian:bookworm-slim bash进容器后,你的感觉就是一台原生 arm64 Debian 机器。apt 装什么依赖,装的就是 arm64 版本的库;configure 的探测程序要运行就运行,QEMU 在底下兜着,不会报 Exec format error;dpkg-shlibdeps 算出来的依赖也天然和 arm64 目标对得上。付出的代价无非是 QEMU 翻译层的性能损失,大概比原生慢 5 到 10 倍,编译个大项目确实要等,但换来的是“行为和最终环境一致”。
如果你没有 Docker Desktop、也没有能跑 arm64 容器的环境,还有一个很冷门的备选:自己用 debootstrap 做一个 arm64 的 rootfs,配合 qemu-aarch64-static 进 chroot 构建。大致流程是:
# 在任意 Debian/Ubuntu x86_64 容器或 WSL2 里执行 apt install debootstrap qemu-user-static binfmt-support debootstrap --arch=arm64 bookworm /arm64-rootfs http://deb.debian.org/debian cp /usr/bin/qemu-aarch64-static /arm64-rootfs/usr/bin/ chroot /arm64-rootfs /bin/bash进了 chroot 之后,同样是一个“原生 arm64 用户态”。这种方式适合在没有 Docker 的受限环境里搭一套离线构建环境。麻烦是文件系统和网络隔离很原始,不如容器干净,但原理完全一样。
我最后在 Windows 上的组合拳就是:Go 的小服务全部走交叉编译;C/C++ 大项目进 arm64 容器模拟原生构建;所有产物最后统一走同一个验证流程。这是试错三天后沉淀下来的最稳路径。
5. 我现在在 Windows 上打 arm64 deb 的固定流程(可直接抄)
前面讲了那么多坑,我把最终沉淀下来的标准流程完整放一遍,你照着走基本不会再踩我再踩过的雷。
5.1 准备一个干净的构建容器
我会在 WSL2 里先启动一个 amd64 的 Debian 容器,作为“打包器宿主”。这个容器负责所有打包工具链:dpkg-dev、dpkg-deb、file、readelf。目标机如果是 Debian 12,基础镜像我就用debian:bookworm-slim;如果是 Ubuntu,就选对应版本的 Ubuntu。
docker run -it --rm -v $(pwd):/build -w /build debian:bookworm-slim bash容器里装齐工具:
apt update apt install -y dpkg-dev file binutils build-essential # 如果走交叉编译,额外装这个 apt install -y gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu libc6-dev-arm64-cross为什么这里我刻意用 amd64 容器做“打包宿主”,而不是直接用 arm64 容器?很简单:打包动作(dpkg-deb、tar、control 文件组织)本身不依赖目标架构,用原生 amd64 容器跑更快。真正需要 arm64 用户态的操作是“编译和验证”,放到单独的 arm64 容器里做。职责分离,速度和正确性都不耽误。
5.2 一个最小但完整的打包目录
如果是手工打包一个简单单文件程序,目录结构保持这个最小形态就行:
myapp-1.0.0/ ├── DEBIAN/ │ ├── control │ └── postinst └── usr/ └── bin/ └── myappcontrol 文件:
Package: myapp Version: 1.0.0 Architecture: arm64 Maintainer: Your Name <you@example.com> Depends: libc6 (>= 2.36) Description: myapp for arm64注意 Depends 别乱写。如果二进制是动态链接的,在 arm64 容器里先用readelf -d myapp | grep NEEDED看一下依赖了哪些 so,再对应到 Debian 的包名。如果是 Go 静态编译的,Depends 可以不写,或者只写一个基础libc6。
目录组织好后,在 amd64 的打包容器里执行:
dpkg-deb --build --root-owner-group myapp-1.0.0 myapp_1.0.0_arm64.deb如果你用的是 debhelper / dpkg-buildpackage 流程,打包命令就换成:
dpkg-buildpackage -us -uc -aarm64手工 dpkg-deb 适合简单包,debhelper 会自动生成 md5sums、做依赖检查、压缩控制,适合有完整 debian/ 目录的正式项目。
5.3 安装验证:用 arm64 容器模拟目标机
这一步是我最强调的。打出包后,不要直接往生产环境传。先在 arm64 容器里装一遍、跑一遍:
docker run --rm --platform linux/arm64 -v $(pwd):/pkg -w /pkg \ debian:bookworm-slim bash -c \ "dpkg -i myapp_1.0.0_arm64.deb && myapp --version"如果这一条命令能在本地 arm64 容器里顺利结束,至少证明包的结构、依赖、文件路径、动态库链接都没问题。如果容器里缺依赖,会当场报错,你也能借此精确补 Depends 字段。这比“传到服务器再试”成本低太多,也是纠错最狠的一个环节。
如果你的环境连 arm64 容器都跑不起来,那就只能退到 qemu-system-aarch64 起一台全系统模拟的 ARM64 虚拟机。但这条路重、慢、还要配内核和 rootfs,优先级放到最后。99% 的日常验证,一个 arm64 容器足够。
5.4 一些收尾经验和容易漏掉的细节
最后再整理几个文档里不会写、但实战中真正影响成败的小经验。
base 镜像和最终目标系统的 Debian/Ubuntu 大版本尽量保持一致。在 bookworm 容器里编译的二进制,链接的是 glibc 2.36;如果目标机是更老的 bullseye(glibc 2.31),装上去很可能会因为 GLIBC 版本问题跑不起来。反过来在更老的系统里编译则相对安全,不要太追求环境太新。
静态编译是 arm64 deb 包的“免死金牌”。只要是 Go 项目,能 CGO_ENABLED=0 就绝不打开;C/C++ 项目如果允许,优先尝试静态链接,能省掉后面 80% 的依赖问题。需要用动态库的话,只能老老实实走模拟原生构建,别想交叉编译一把梭。
包里的文件属主一定要用 root:root。Windows 和容器之间文件挂载后 uid 经常是乱的,打包时记得带
--root-owner-group,否则安装后一堆系统路径下出现奇怪属主,服务一启动就权限报错。postinst / prerm 脚本记得加可执行权限(chmod 0755)。deb 不会自动帮你 chmod 这些脚本,权限不对,安装时控制脚本直接跳过,服务没注册,你还一脸懵。
我把这套流程走顺之后,再回头想标题里那三个“想错了的地方”,其实都是同一个根源:我一开始把“arm64 的 deb 包”当成了一种静态标签,觉得贴上就行。但真正可交付的跨架构包,是“构建架构、打包架构、安装架构”三重巡检之后才成立的产物。在 Windows 上做到这件事,最重要的是别被 Windows 这个表层迷惑,尽早把注意力放到容器和二进制检查里去。