Docker 构建连环踩坑:解析失败、只读 resolv.conf、还有 apt 的降权陷阱
先说结论:一个 docker compose up -d --build,报错换了四种姿势,最后发现是两个独立的问题叠在一起——一个是宿主机 DNS 配置埋的雷,一个是 apt
降权机制在特殊挂载下的坑。中间还被自己的"修复动作"坑了一次。记录一下整个排查过程,希望后人少走弯路。
环境交代一下:一台自用的 Debian 12(bookworm)Linux 服务器,上面跑着 Docker(fuse-overlayfs 存储驱动)和一堆自托管服务,网络环境是国内普通宽带,Docker还装了个 Tailscale 容器。要构建的镜像是一个 Node 服务 + OpenBitFun CLI 的组合镜像,里面需要 apt 装几个基础工具(ca-certificates、curl、git、tini 之类)。
一、事情是这样的:报错换了四次姿势
第 1 次:apt 默认源解析不了
=> ERROR [stage-1 2/12] RUN apt-get update && apt-get install -y ...
Ign:1 http://deb.debian.org/debian bookworm InRelease
Err:1 http://deb.debian.org/debian bookworm InRelease
Temporary failure resolving 'deb.debian.org'
E: Package 'ca-certificates' is not available ...
老朋友了,国内网络嘛,第一反应就是"换源"。
第 2 次:换了阿里云源,还是解析不了
Err:1 http://mirrors.aliyun.com/debian bookworm InRelease
Temporary failure resolving 'mirrors.aliyun.com'
这就有点意思了——连国内源的域名都解析不了,说明问题根本不在源。而且更诡异的是,宿主机上 ping deb.debian.org 是通的。宿主机好好的,一到构建就挂。
第 3 次:我的"修复"动作自己先炸了
排查中发现构建容器里的 DNS 配置可能不对,于是在 Dockerfile 的 RUN 里加了一行,强制写入可用的 DNS:
printf 'nameserver 223.5.5.5\n' > /etc/resolv.conf
结果报错直接换了个画风:
/bin/sh: 1: cannot create /etc/resolv.conf: Read-only file system
行吧,BuildKit 构建步骤里的 /etc/resolv.conf
是只读挂载的,根本写不进去。更要命的是脚本开头有 set -e,这一步直接退出,连 apt
都没跑到。属实是自己给自己挖了个坑。(记住这个坑,后面"教训"里还会提到。)
第 4 次:加诊断,真相浮出水面
老实一点,不猜了,直接在同一个 RUN 步骤里把"配置、网络、解析、权限"四个维度一次测完:
== dns pre-fix ==
nameserver 223.5.5.5
nameserver 119.29.29.29
nameserver 8.8.8.8
nameserver 2408:8888::8
== routes ==
wlp3s0 00000000 0100A8C0 ... # 默认路由 192.168.0.1,这是宿主机的网络栈
== resolve mirrors.aliyun.com ==
36.250.65.178 STREAM mirrors.aliyun.com.w.alikunlun.com # 解析成功!
...
+ apt-get update
Err:1 ... Temporary failure resolving 'mirrors.aliyun.com' # 然而 apt 还是失败
好家伙,同一步骤、同一个网络命名空间里:
| 检查项 | 结果 |
|---|---|
| DNS 配置(resolv.conf) | ✅ 正确 |
| 网络命名空间(路由表里有物理网卡) | ✅ 真 host 网络 |
getent ahosts(root 进程解析) | ✅ 成功 |
npm ci(root 进程联网下载) | ✅ 成功 |
apt-get update | ❌ 秒失败 |
所有能测的都是绿的,唯独 apt 挂了,而且是 1
秒内的"秒失败"。范围一下就被压缩到最小了。
二、根因:其实是两个坑叠在一起
排查绕了几个弯,就是因为这不是一个问题,是两个。
根因 1:宿主机 DNS 配置里埋着一个"死地址"
看 /etc/resolv.conf,第一位 nameserver 是
192.168.0.104。查了一下,这个地址是服务器自己——但本机 53 端口上根本没有 DNS 服务(nslookup 直接connection refused,只有 5353 的 mDNS
在)。应该是某次网络环境变动留下的手写配置,忘了清理。
于是每次解析的流程是:先对这个死地址连撞 3 次 → 全部超时 → 轮到 8.8.8.8。而 8.8.8.8 在国内的稳定性嘛,dddd。apt
要连续解析多个域名、失败后还要退避重试(1→2→4→8
秒),撞上丢包就全军覆没。
这里还有个很有迷惑性的现象:解析"只回一半"。getent hosts 只看到 IPv6地址(AAAA),看不到 IPv4(A)。这是 glibc 并发发 A/AAAA
双查询、中间链路只放行一半响应的特征。另外提醒一句:getent hosts 本身就只显示一个地址族,验证解析请用 getent ahosts,别被它带偏了。
根因 2:apt 会降权到 _apt 用户,而它读不到只读挂载的 resolv.conf
这是最隐蔽的一个。apt 在下载阶段默认会降权到 _apt 用户执行(APT 的sandbox 机制)。而前面已经验证过,构建步骤里的 /etc/resolv.conf
是只读挂载。当这个文件对 _apt 不可读时,glibc 拿不到 resolver 配置,getaddrinfo立刻返回 EAI_AGAIN,apt 就报 Temporary failure resolving。
这个理论完美解释了所有疑点:
- 为什么是"秒失败"?读不到配置文件是立即失败,根本走不到网络超时;
- 为什么 root 的
getent、npm ci全都正常?因为它们是 root 跑的,读得到文件; - 为什么换源、换网络、加
--network=host都没用?因为问题不在网络。
附带嫌疑:Docker 里的 Tailscale
服务器上还跑着 Docker 版 Tailscale,它会动宿主机的 DNS/路由行为,也会影响BuildKit 的默认构建网络(最开始的 deb.debian.org
解析失败可能就有它的贡献)。对策很简单:构建联网步骤显式走 host 网络,绕开构建网络。
三、怎么解决的
1. 把宿主机 DNS 换成活的
死地址直接替换成国内双 DNS(建议留着 8.8.8.8 做后备,别裸删):
sudo sed -i 's/^nameserver 192\.168\.0\.104$/nameserver 223.5.5.5\nnameserver 119.29.29.29/' /etc/resolv.conf
getent ahosts deb.debian.org # 期望看到 151.101.x 的 IPv4 返回
注意 /etc/resolv.conf 文件头写着"由 resolvconf
生成,手改会被覆盖"。所以想一劳永逸,得写进 resolvconf 的配置里:
grep -q '^name_servers=' /etc/resolvconf.conf 2>/dev/null \
|| echo 'name_servers="223.5.5.5 119.29.29.29"' | sudo tee -a /etc/resolvconf.conf
顺带一提,改之前最好先查一下这个死地址是谁写进去的:
grep -rn "192.168.0.104" /etc/resolvconf.conf /etc/network /etc/dhcpcd.conf \
/etc/NetworkManager /etc/dhcp 2>/dev/null
grep -rn "domain-name-servers" /var/lib/dhcp /var/lib/dhcpcd 2>/dev/null | head
如果是 DHCP 下发的,得去路由器改;手写进去的(比如我这种)直接替换就行。
2. Dockerfile 的修正
# 联网步骤显式走 host 网络,绕开被 Tailscale 干扰的构建网络
RUN --network=host set -eux; \
# apt 换国内镜像源(sed deb.debian.org -> mirrors.aliyun.com,略); \
# 关键一行:apt 以 root 跑下载,绕开 _apt 读不到只读 resolv.conf 的坑
apt-get -o APT::Sandbox::User=root -o Acquire::ForceIPv4=true -o Acquire::Retries=3 update; \
apt-get -o APT::Sandbox::User=root -o Acquire::ForceIPv4=true install -y --no-install-recommends \
ca-certificates curl git openssh-client tini; \
rm -rf /var/lib/apt/lists/*
几个要点:
APT::Sandbox::User=root是关键先生。构建期的一次性容器里以 root 下载,没有安全问题,别纠结;- 千万不要在 RUN 里写
/etc/resolv.conf,只读挂载,写了就 exit 2(别问我怎么知道的); - 镜像源用 build arg 参数化,方便以后换;
- npm 同理:
RUN --network=host npm config set registry https://registry.npmmirror.com && npm ci。
3. 构建方式
顺便发现 compose 的 bake 构建路径在本机表现不太稳定,直接手动 build 反而稳(用的还是 --network=host 这个验证过的姿势):
docker build --network=host -t my-image:latest -f deploy/Dockerfile .
docker compose up -d # 不带 --build,直接用刚打好的镜像
改完之后,构建一把过,验证脚本全绿。收工。
四、诊断速查(建议直接收藏)
下次再遇到类似问题,别猜,一行行跑这个:
# 1. 宿主机 DNS 现状 + 写入点排查
cat /etc/resolv.conf
grep -rn "192.168.0.104" /etc/resolvconf.conf /etc/network /etc/dhcpcd.conf \
/etc/NetworkManager /etc/dhcp 2>/dev/null
# 2. 分别问不同的 nameserver,看是谁不干活
nslookup deb.debian.org 223.5.5.5
nslookup deb.debian.org 8.8.8.8
# 3. 全量地址族检查(getent hosts 只显示一个地址族,会误导)
getent ahosts deb.debian.org
# 4. 构建容器内的"真相四件套",加进 RUN 步骤开头
echo "== dns =="; cat /etc/resolv.conf
echo "== routes =="; cat /proc/net/route | head -5 # 有物理网卡默认路由 = host 网络
echo "== resolve =="; getent ahosts mirrors.aliyun.com
echo "== perms =="; ls -l /etc/resolv.conf # 权限决定 _apt 能不能读
五、几个教训
Temporary failure resolving不等于"网络不通"。如果 root 进程解析正常、偏偏 apt 失败,优先怀疑_apt降权 + 文件权限/挂载的问题。- 构建步骤里的
/etc/resolv.conf是只读的。改 DNS 要在宿主机或者 Dockerdaemon 层面改,别在 RUN 里硬写。 getent hosts会骗人,它只返回一个地址族。验证解析用getent ahosts。- ping 通 ≠ DNS 正常。ping 可能恰好用上了仅剩可用的 AAAA 记录;解析链路半丢包的问题,只有 apt 这种并发查询才会暴露出来。
- 改 resolv.conf 之前先查写入点。这文件多半是生成物,找到是谁写的(resolvconf / DHCP / NetworkManager),否则改完重启就被打回原形。
- 排查要在出错的那一步里做。一个 RUN 里塞上"配置、路由、解析、权限"四行诊断,一次把四个维度测完,比反复猜、反复构建快太多了。
希望这篇能帮你省下一个下午。
评论