1. ARM Linux 上移植 Docker 的“移植”二字到底指什么
1.1 为什么不能只靠包管理器安装
ARM Linux 平台深度移植 Docker 容器运行时,听起来像是敲一条安装命令就能收工的事,但真做起来,你会发现从内核配置到用户态二进制,从存储驱动到网络方案,每一层都可能让你卡上半天。我第一次在一台只有 4GB 内存的 ARM 设备上跑容器时,第一反应也是“apt install docker.io 不就完事了吗”,结果 docker info 直接给我报存储驱动不可用,容器网络时通时断,后来静下心把整套流程拆开,才意识到“移植”和“安装”之间隔着大量需要自己补上的工程细节。
包管理器能帮你做的,只是把发行版仓库里预编译好的 Docker 二进制放到位,但二进制只是容器运行时最表层的东西。ARM 设备和 x86 环境最大的区别在于:内核配置五花八门,特别是在嵌入式开发板和定制内核的设备上,很多必要的内核特性被裁剪掉了。包管理器不会替你去检查 CONFIG_OVERLAY_FS 是否存在,不会帮你把 br_netfilter 模块加载好,也不会关心你的系统到底用的 cgroup v1 还是 cgroup v2。这些恰恰是决定容器能不能稳定跑起来的关键。
所以这里说的“深度移植”,可以拆成四层工作:内核特性适配、运行时二进制构建、用户态依赖补齐、运行配置调优。四层都过了,容器才算真正“长”在这块 ARM 平台上,而不是“放”在上面。
1.2 移植前要确认的五项基础信息
动手之前,先花十分钟把目标平台的信息摸清楚,能省掉后面一大半的排错时间。我一般固定在终端里执行下面这一串命令,把结果存下来作为后续操作基线。
uname -a uname -m cat /etc/os-release docker version 2>/dev/null || true cat /proc/filesystems | grep -E "overlay|cgroup" stat -f /sys/fs/cgroup其中这几项最重要:
第一是 CPU 架构。同样是 ARM,aarch64(64 位)和 armv7l(32 位)在二进制完全不同的,编译参数也不同,后者跑 Docker 的镜像生态要麻烦得多。第二是内核版本,Docker 对内核特性要求较多,5.10 以上的内核通常更省心,但不少 ARM 板卡厂商还停留在 4.x 内核。第三是发行版类型和版本,它决定了你手里有多少现成的编译依赖可以用。第四是 init 系统,systemd 和 sysvinit 对 Docker 的开机自启和 cgroup 驱动配置方式不一样。第五是存储空间和内存,Docker 的 overlay2 驱动需要在根文件系统上留出余量,日志不限制的情况下增长非常快。
1.3 一个典型的 ARM 移植场景画像
用一个我实际接触过的场景来具象化:某智能网关项目,采用一块 arm64 主板,4GB 内存,32GB eMMC 存储,单千兆网口,内核由厂商提供但基于 5.4 版本裁剪过,发行版是 Debian 系的定制系统。需求是把 8 个业务服务容器化,并且要让部分容器获得局域网独立 IP,以便外部设备直接访问。
这个场景很有代表性,因为你会发现每个需求都指向一个具体的技术选型:8 个容器意味着资源限制要做,cgroup 参数必须对;单网口还要独立 IP,意味着需要在 bridge 之外的网络模式里做文章;厂商定制内核则意味着不能默认信任所有容器特性都存在。整条链路正好覆盖了移植工作的全部关键技术点。
2. 内核侧特性核对与补强:容器运行时的地基工程
2.1 用 /proc/config.gz 地毯式核对内核配置
Docker 对内核的依赖,本质上是 Linux 内核提供的一组“集装箱”原语:命名空间做隔离,cgroup 做资源限制,OverlayFS 做镜像分层,netfilter/iptables 做网络转发,seccomp 做系统调用过滤。这些原语如果在内核编译时被裁掉,用户态再怎么配置都白搭。
最直接的核对方式是查看内核配置文件。多数发行版内核开启了 CONFIG_IKCONFIG,可以直接解压查看:
zcat /proc/config.gz | grep -E 'CONFIG_(NAMESPACES|CGROUPS|OVERLAY_FS|SECCOMP|BRIDGE|NF_NAT|VETH)'如果 /proc/config.gz 不存在,就去 /boot/ 下找:
grep -E 'CONFIG_(NAMESPACES|CGROUPS|OVERLAY_FS|SECCOMP|BRIDGE|NF_NAT|VETH)' /boot/config-$(uname -r)正常情况下,下面这些配置项至少都应该是=y或=m:
CONFIG_NAMESPACES=y CONFIG_CGROUPS=y CONFIG_CGROUP_CPUACCT=y CONFIG_CGROUP_DEVICE=y CONFIG_CGROUP_FREEZER=y CONFIG_CGROUP_SCHED=y CONFIG_CPUSETS=y CONFIG_MEMCG=y CONFIG_KEYS=y CONFIG_VETH=y CONFIG_BRIDGE=y CONFIG_BRIDGE_NETFILTER=y CONFIG_NETFILTER_XT_MATCH_ADDRTYPE=y CONFIG_NETFILTER_XT_MATCH_IPVS=y CONFIG_IP_NF_NAT=y CONFIG_NF_NAT=y CONFIG_OVERLAY_FS=y CONFIG_SECCOMP=y我在一块国产 ARM 板子上就遇到过 CONFIG_MEMCG 缺失的情况。表面上看容器能启动,但 docker run -m 256m 完全不生效,内存限制形同虚设。这种问题用户态无法弥补,只能重新编译内核或者找厂商提供新内核。
2.2 overlay 与网络相关内核模块的加载验证
配置项是编译期决定的,但很多特性是以模块形式存在,运行时要手动加载。Docker 的 overlay2 驱动依赖 overlay 模块,bridge 网络依赖 br_netfilter 模块。我先检查模块状态,再决定要不要加载:
lsmod | grep -E 'overlay|br_netfilter' modprobe overlay modprobe br_netfilter加载完成后确认一下 sysctl 参数是否已生效:
sysctl net.bridge.bridge-nf-call-iptables如果输出为 0,说明 bridge 上的 IPv4 流量不会经过 iptables 过滤,这会导致容器跨网络通信时出现各种诡异问题。建议保持为 1。同时把模块固化到开机加载列表里,避免重启后失效:
cat > /etc/modules-load.d/docker.conf <<'EOF' overlay br_netfilter EOF这里有个容易忽略的细节:有些 ARM 内核虽然编译了 br_netfilter 模块,但加载时会报Operation not permitted,常见原因是内核开启了 Secure Boot 或模块签名校验,而厂商没有提供对应签名的模块。遇到这种情况,要么找厂商要签名模块,要么把功能直接编译进内核。
2.3 cgroup 版本对运行时的实际影响
现代 ARM 系统上 cgroup v2 越来越常见,它和 v1 在 Docker 的 cgroup 驱动选择上完全不同。v2 统一了 cpu、memory、io 的控制方式,并要求用户态通过 cgroupfs 或 systemd 操作。Docker 在 v2 环境下推荐使用 systemd cgroup driver,否则某些资源限制会不起作用。
判断当前系统用的是 v1 还是 v2:
stat -fc %T /sys/fs/cgroup输出cgroup2fs就是 v2,输出tmpfs大概率还是 v1。如果内核同时支持 v1 和 v2,还可以看看启动参数里是否加了cgroup_no_v1=all之类的约束。
移植时我的建议是:发行版是 systemd 系的,就统一把 Docker 的 cgroup driver 设成 systemd,并在 daemon.json 里写死:
{ "exec-opts": ["native.cgroupdriver=systemd"] }如果系统里跑着 kubelet 之类的东西,cgroup driver 不一致会导致节点资源视图错乱,这是 ARM 边缘节点上比较常见的坑,后面在故障速查里还会再提到。
3. 从源码构建 Docker 运行时全家桶:交叉编译的完整链路
3.1 直接目标板构建 vs 交叉编译怎么选
包管理器装不上满意版本时,源码构建就成了必然选择。这里先说结论:如果目标 ARM 设备内存低于 8GB,别直接在板子上编 Docker Engine,交叉编译会快得多,也友好得多。
我最初试过在 4GB 内存的板子上直接 make,Go 编译器的内存需求加上 Docker 源码的工程量,直接触发 OOM,swap 疯狂读写,一次构建耗掉近两个小时。换成交叉编译后,在普通 x86 机器上十几分钟就能产出全部二进制。交叉编译还有一个好处:可以用比较新的 Go 工具链,避免目标板上工具链老旧导致 Go 模块兼容问题。
交叉编译要设置的环境变量很简单:
export GOOS=linux export GOARCH=arm64如果目标是 32 位 ARM,把 GOARCH 换成 arm 并加上 GOARM=7 之类的参数。注意 CGO_ENABLED 的选择:关闭 CGO 能生成纯静态二进制,部署时不需要目标板上有匹配的 glibc,但像 runc 这样的组件需要 seccomp 支持,纯静态编译时通常要依赖 libseccomp 的开发包,所以我会在交叉编译环境里先安装好依赖再决定是否开启 CGO。
3.2 构建 Docker Engine、containerd、runc 的具体步骤与版本匹配
Docker 运行时不是单一二进制,至少包含 dockerd、containerd、runc、docker CLI、docker-init、docker-proxy 这几个组件。它们之间有明显版本耦合,不能随手拿最新版乱配。我的习惯是不追新,选一套经过社区验证的稳定版本组合,比如 Docker Engine 20.10 系列配合对应的 containerd 1.6 和 runc 1.1,在网上搜索具体版本时以官方 release 页为准。
构建依赖在 Debian/Ubuntu 系环境里这样准备:
apt-get install -y \ curl git make gcc \ libseccomp-dev pkg-config \ libsystemd-devGo 工具链交叉编译时,还需要让 pkg-config 能找到目标架构的库文件。我一般先在 x86 交叉工具链里安装 arm64 的 libseccomp 库,或者干脆设置PKG_CONFIG_PATH指向交叉环境目录。这一步如果漏了,编译 runc 时会报找不到 seccomp.h。
以 containerd 为例,编译流程大致是:
cd /opt/src/containerd make把产出的二进制拷到目标板:
install -m 755 bin/containerd /usr/local/bin/ install -m 755 bin/containerd-shim /usr/local/bin/ install -m 755 bin/ctr /usr/local/bin/runc 的构建更敏感,因为它直接和内核的 seccomp、cgroup 交互:
cd /opt/src/runc make BUILDTAGS='seccomp' install -m 755 runc /usr/local/bin/最后构建 Docker Engine 本体。源码树里通过 make 命令产出 dockerd:
cd /opt/src/docker VERSION=20.10.24 make binary install -m 755 bundles/binary-daemon/dockerd /usr/local/bin/Docker CLI 和其它辅助二进制同理,放在 /usr/local/bin 或 /usr/bin 都行。我习惯统一放 /usr/local/bin,并且用docker --version、dockerd --version、runc --version、containerd --version四个命令确认版本和架构符合预期。
3.3 构建期和安装期的典型错误排查
构建期最常见的问题,是libseccomp缺失导致的编译失败。runc 源码里对 seccomp 的检测很严格,缺失时不会默默降级,而是直接编译报错。解决办法就是补上libseccomp-dev,并把 pkg-config 路径指对。
另一个高频问题出现在把二进制拷贝到 ARM 板后执行时报错:
exec user process caused: exec format error这个信息说明你拿到的二进制架构和当前系统不匹配。可以用file命令确认:
file /usr/local/bin/dockerd输出里应该包含aarch64或x86-64。如果架构没问题,再检查是不是动态链接的二进制缺少依赖库。纯静态编译(CGO_ENABLED=0)可以规避大部分动态库问题,但 runc 的 seccomp 特性建议保留 CGO,这需要目标系统上有可用的 seccomp 运行时库,一般发行版默认都有。
安装期还会遇到一个不算错误但很烦人的情况:dockerd 能启动,但日志里反复出现failed to load listeners: no sockets found via default listener。这通常是 dockerd 期望的默认 socket 目录不存在或权限不对。解决方式是提前创建目录:
mkdir -p /var/run/docker groupadd docker usermod -aG docker <用户名>注意,不同版本的 Docker 对 socket 路径的默认值有差异,20.10 以后默认走/var/run/docker.sock,而 containerd 的 socket 在/run/containerd/containerd.sock。如果 containerd 没有先启动,dockerd 会反复重连,这时候看日志会非常迷惑,其实只要把 containerd 的 systemd 单元先启起来就能解决。
4. daemon 配置、网络方案与存储驱动的 ARM 落地选择
4.1 存储驱动选型与 overlay2 的落地细节
x86 服务器上 Docker 默认用 overlay2,到了 ARM 平台却不能想当然。很多 ARM 定制内核确实带了 overlay 支持,但也有些内核把 OverlayFS 当模块编译却没装驱动;还有些老内核只支持 overlay 但不支持 metacopy 等特性,导致 docker info 里 storage driver 退回 vfs。vfs 不是不能跑,但每次容器层改动都会复制整个镜像层,32GB eMMC 的板子用不了几天就满了。
我的推荐顺序是:overlay2 优先,fuse-overlayfs 作为无 root 环境下的备选,vfs 只用作临时验证。配置写在 /etc/docker/daemon.json:
{ "storage-driver": "overlay2", "exec-opts": ["native.cgroupdriver=systemd"], "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" }, "iptables": true, "ip-masq": true }配置完重启 Docker:
systemctl restart docker docker info | grep -iE 'storage driver|cgroup driver'如果 storage driver 仍然不是 overlay2,先看内核模块:
lsmod | grep overlay modprobe overlay docker info实在没模块时,还可以临时用dockerd --storage-driver=vfs验证业务,但这不是长期方案,最终还是要补内核模块或换内核。
4.2 bridge、host、macvlan、ipvlan 的取舍与配置
ARM 场景里,网络选型往往比 x86 服务器更纠结,因为很多设备只有一个物理网口,又要承担业务又要维护多个容器的通信。bridge 模式是默认方案:Docker 创建一个 docker0 网桥,用 NAT 让容器访问外部,适合大多数内部服务。缺点是外部设备很难直接访问容器,必须做端口映射。
如果业务要求外部设备直接访问容器地址,macvlan 很实用。它能给容器分配和宿主机同网段的独立 IP,配置也简单:
docker network create -d macvlan \ --subnet=192.168.1.0/24 \ --gateway=192.168.1.1 \ -o parent=eth0 mymacvlan然后启动容器时指定网络:
docker run --rm -d --network mymacvlan --ip 192.168.1.50 nginxmacvlan 有一个典型限制:容器和宿主机之间默认不能互通。因为 macvlan 虚拟接口绑定了物理网卡后,宿主机自身的 IP 栈被旁路了。解决办法是额外创建一个 macvlan bridge 接口给宿主机使用,但这会增加不少复杂度。如果你的边缘设备上只需要容器之间互通,ipvlan 可能是更好的选择,它共享物理网卡的 IP 地址,性能更好,但ipvlan 的 L2 模式和 L3 模式都有各自的子网限制,ARM 平台上遇到驱动异常时很难排查。
这里我最真实的心得是:ARM 单网口设备上,别把网络方案搞得过于花哨。80% 的场景用 bridge + 端口映射就够了,只有确实需要“外部设备直连容器 IP”时再上 macvlan。网络模式每复杂一层,故障排查成本就翻一倍,尤其是在没有专业网管工具的边缘现场。
4.3 systemd 单元与开机自启的配置细节
Docker 移植的最后一公里,是让它在系统启动后自动恢复,并且保持正确的启动顺序。systemd 单元文件虽然各个发行版自带,但定制系统里经常没有,或者路径不标准。我习惯自己写一个最小化的 docker.service 放到 /etc/systemd/system/ 下:
[Unit] Description=Docker Application Container Engine After=network-online.target containerd.service Wants=network-online.target Requires=containerd.service [Service] Type=notify ExecStart=/usr/local/bin/dockerd --config-file=/etc/docker/daemon.json ExecReload=/bin/kill -s HUP $MAINPID LimitNOFILE=1048576 LimitNPROC=infinity LimitCORE=infinity Restart=always RestartSec=5 TimeoutStartSec=0 [Install] WantedBy=multi-user.target注意我特意把 containerd.service 写成 Requires,而不是 Wants。两者差别在于:Wants 只表示“最好先启动”,不保证顺序;Requires 会严格约束含 containerd 先启动,否则 dockerd 起了也连不上 containerd socket。经历过一次“dockerd 启动成功但容器全部报错”的现场问题后,我就把顺序绑定写死了。
配置好之后执行:
systemctl daemon-reload systemctl enable docker containerd systemctl restart docker还有一个容易踩的坑:daemon.json 里配置的>docker run --rm --platform linux/arm64 alpine uname -m
输出应当是aarch64或armv7l。如果这里报 exec format error,说明镜像架构选错了,或者在 x86 机器上构建的镜像没有打 arm64 标签。
第二步,跑一个带资源限制的容器,验证 cgroup 是否真的生效:
docker run --rm -m 128m --cpus 1 alpine \ sh -c "dd if=/dev/zero of=/dev/null bs=1M count=1024"在 4GB 内存的板子上,如果这条命令直接成功,说明内存限制可能没生效;真正的限制效果是进程会被 cgroup 卡住或 OOM 掉。可以用docker stats实时看数据。
第三步,验证容器外网访问。这一步最容易出问题,因为 ARM 板往往工作在复杂网络环境里,有公司内网代理、多网关等情况。基础测试:
docker run --rm alpine ping -c 2 223.5.5.5这里记得换你自己网络环境里可达的地址,别照抄。如果 ping 不通,优先查 iptables FORWARD 链和 br_netfilter 模块。
第四步,验证容器数据持久化,确认挂载卷在重启后还能正常读到数据。这一步主要是为了排除 overlay2 挂载问题,有些定制内核在文件系统挂载参数上兼容性较差,容器重启后数据会“看起来消失”。
5.2 三个典型运行期故障与排查链路
故障一:容器启动但外网不通。我遇到这类问题时,排查链路基本固定。先看宿主机能否访问外网,能通就说明问题出在转发链上。执行:
iptables -L FORWARD -n -v sysctl net.ipv4.ip_forward lsmod | grep br_netfilter常见原因是 ip_forward 被关掉,或者 br_netfilter 没加载,导致 bridge 流量不经过 NAT 链。解决:
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf sysctl -p modprobe br_netfilter故障二:docker info 显示 storage driver 是 vfs。常见于 overlay 模块未加载或内核不支持。排查链路:lsmod | grep overlay、dmesg | grep overlay、modprobe overlay、重启 dockerd。如果模块加载失败而内核支持,去查模块签名;如果内核不支持,只能换内核或用内核自带的 overlayfs 配置重编。
故障三:重启后容器没有自动恢复。这个坑其实不是移植特有的,但 ARM 边缘设备经常断电重启,问题会被放大。解法分两层:第一层给容器加--restart=always,第二层确保 systemd 里 docker.service 的Restart=always生效。如果发现容器重启后状态是 Exited 而不是 Running,先看docker inspect里的 RestartPolicy,再看系统启动时间线里 containerd 是否在 docker 之前完成启动。
5.3 我总结的 ARM 容器运行稳定性经验
移植完成后,稳定性往往比功能更难搞。几块 ARM 板子跑下来,我总结了一套自己的检查习惯。
第一,日志必须限制。ARM 设备的存储通常是 eMMC 或 SD 卡,容量小且写寿命有限。daemon.json 里的 log-opts 建议从一开始就配好,每容器日志上限 10m 比较稳妥,否则某个服务疯狂打日志会把整块存储写满。
第二,swap 要克制。ARM 设备内存不大,很多人喜欢开 swap,但容器频繁 swap 会把 eMMC 写入放大问题激化,拖慢整机。我一般把vm.swappiness调到 10 以下,并且只在内存换页确实频繁时才考虑加 swap。
第三,资源限制不能省。边缘设备上多个容器共用一个 CPU,如果不限制任意一个容器,某个失控服务就能把整个板子拖死。建议所有容器都显式加上--memory和--cpus,哪怕只是给一个宽松上限。
第四,定期做镜像和容器清理。ARM 板子上docker system prune是一个好习惯,但要注意别把正在使用的镜像清了。我在自动化脚本里只清理匿名卷和悬空镜像,保留标签镜像。
第四,迁移新平台时,不要只跑 hello-world 就宣布“移植完成”。至少要跑一个业务镜像,并让它在设备上连续运行超过 24 小时,同时关注板子温度和内存占用。很多 ARM 平台的坑是热管理,而不是 Docker 本身,温度过高时 CPU 降频,容器时延就会剧烈抖动。
我个人在实际项目里,一直保留着一个固定脚本,把所有内核检查项、模块加载、版本号、daemon 配置全部打印出来。到了新板子就把基线脚本跑一遍,和已知稳定平台做 diff。这个方法帮我节省了大量迁移排错时间。最后再分享一个小技巧:如果 ARM 设备内存不大,别急着给容器加内存,优先把日志限制和重启策略配好,这两个动作对稳定性的提升,往往比单纯调大资源更明显。希望这篇攻略能让你在 ARM 容器移植这条路上少走一点弯路。