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 能不能读

五、几个教训

  1. Temporary failure resolving 不等于"网络不通"。如果 root 进程解析正常、偏偏 apt 失败,优先怀疑 _apt 降权 + 文件权限/挂载的问题。
  2. 构建步骤里的 /etc/resolv.conf 是只读的。改 DNS 要在宿主机或者 Dockerdaemon 层面改,别在 RUN 里硬写。
  3. getent hosts 会骗人,它只返回一个地址族。验证解析用 getent ahosts。
  4. ping 通 ≠ DNS 正常。ping 可能恰好用上了仅剩可用的 AAAA 记录;解析链路半丢包的问题,只有 apt 这种并发查询才会暴露出来。
  5. 改 resolv.conf 之前先查写入点。这文件多半是生成物,找到是谁写的(resolvconf / DHCP / NetworkManager),否则改完重启就被打回原形。
  6. 排查要在出错的那一步里做。一个 RUN 里塞上"配置、路由、解析、权限"四行诊断,一次把四个维度测完,比反复猜、反复构建快太多了。

希望这篇能帮你省下一个下午。