前言先交代一下背景。前几天帮一个朋友排查他那边 docker 创建镜像遇到的问题,累积下来长长一串:本地构建好好的,一到 CI 就网络超时;Dockerfile 怎么改都报 COPY 找不到文件;镜像倒是构建出来了,容器起来又直接 exec format error。每一项单看都不算难,真正让人头疼的是它们经常裹在一起,而且报错信息从来不会直接告诉你根因。
我这些年写 Dockerfile、给各种服务做镜像,踩过的坑不比任何人少。这篇不打算按部就班讲 Docker 入门,而是把创建镜像过程中最常见的几类问题拆开揉碎,说清楚它们的底层原因、排查思路和落地解法。适合刚开始写 Dockerfile 的人,也适合那些已经被构建任务折磨了一周、准备直接 --no-cache 硬刚的人。
1. 镜像构建失败的五张"面孔":先分类再动手
先给你吃一颗定心丸:docker build 的报错看似千奇百怪,但基本逃不出几张面孔。在动手改 Dockerfile 之前,先把问题归类,比什么都重要。
1.1 你以为的语法错误,多半是环境假设错了
我见过不少人在本地把 Dockerfile 调得很顺,一到别人的机器或者 CI 上就崩,第一反应是"Dockerfile 写错了"。其实 Dockerfile 很多时候根本没有语法问题,问题出在环境假设上。你的 Dockerfile 不是在开发机上执行的,而是在某个基础镜像的容器里执行的。这个容器用哪个 CPU 架构、里面的软件源能不能访问、DNS 能不能解析、基础镜像的 tag 指向什么内容,全都不是你本地那套环境。
比如你在本地 curl 一个下载地址是通的,不代表容器里能通;你本机是 x86_64,不代表目标服务器也是 x86_64;你昨天用 node:latest 构建成功,不代表今天这个 tag 拉下来的还是同一个镜像。把这些变量写进你的"排查清单",你会发现很多诡异问题其实一点也不诡异。
1.2 构建期、运行期、跨环境:三类问题的排查思路完全不同
我把镜像相关的问题按出现时机分成三类:
- 构建期问题:
docker build阶段就失败,比如 COPY 找不到文件、RUN 命令返回非零、网络超时。 - 运行期问题:镜像构建成功,
docker run启动时报错,比如 exec format error、not found、permission denied。 - 跨环境问题:本地成功 CI 失败、昨天成功今天失败、这台机器成功那台机器失败。
这三类的排查路径完全不同。构建期问题重点是看 Dockerfile 每一条指令的输入输出和基础环境;运行期问题重点看可执行文件格式、用户权限、入口脚本;跨环境问题重点看网络、架构、依赖版本和构建环境本身。下面几章我会按这个思路逐类展开。
我把高频报错和大概率根因放在一张表里,方便你对号入座:
| 报错信息 | 出现时机 | 大概率根因 |
|---|---|---|
| COPY failed: no source files | 构建早期 | 构建上下文路径不对、.dockerignore 误伤、符号链接未跟随 |
| exec format error | 容器启动 | 镜像架构和目标架构不匹配 |
| /bin/sh: 1: xxx: not found | 容器启动 | 入口脚本 CRLF、解释器缺失、依赖命令缺失 |
| network timed out | RUN 阶段 | 软件源不可达、DNS 解析失败 |
| manifest unknown | 拉取基础镜像 | tag 不存在、平台不支持 |
| Get ... no matching manifest | 拉取基础镜像 | 镜像没有对应平台的版本 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 构建上下文:第一个看不见的坑
很多人对 docker build 的认知是:在当前目录执行,然后 Dockerfile 被读取、执行。这个理解差了一条关键链路。
2.1 docker build 到底把什么东西"发"给了守护进程
实际上,客户端会把构建上下文里的全部文件打包成 tar,发送给 Docker 守护进程(或者 BuildKit),然后守护进程在这个上下文中逐条执行 Dockerfile。Dockerfile 里的 COPY ./xxx 只能从上下文里拿文件,拿不到宿主机上其他目录的东西。
所以当你 COPY 一个文件却报找不到时,先别怀疑磁盘上有没有这个文件,先确认它在不在"上下文的视角"里。这个视角由两个因素决定:docker build 命令后面跟的路径参数,以及 .dockerignore 文件。
2.2 上下文太大、文件缺失、符号链接:三个高频事故
第一个事故:在错误的目录执行 docker build。比如项目结构是 server/dist/server,你在项目根目录执行 docker build .,Dockerfile 里写的是 COPY server /app/server,结果永远报错。文件在 server/dist 下,不在 server 下。这种问题解决起来很简单,但经常因为"我明明看到文件在这里"而绕弯路。排查时第一件事就是看 docker build 的路径参数和 Dockerfile 的相对路径。
第二个事故:上下文太大。把 node_modules、.git、日志文件全部打进 tar,构建时间翻几倍,CI 磁盘被打满,偶尔还会出现莫名其妙的 tar 解析错误。我记得有个项目把构建上下文从 50MB 优化到 5MB 之后,构建时间缩短了三分之二,问题就是这么直白。想确认上下文大小,最简单的办法是在构建目录执行:
bash复制du -sh .
如果从这里看到几十 MB 甚至几百 MB,而业务代码只有几 MB,那基本可以确定 .dockerignore 该写了。也可以用 tar -cf - . | wc -c 粗略估算实际打包后的字节数。
第三个事故:符号链接。Docker 打包上下文时不会跟随链接跳到上下文之外的目录,链接在上下文内部指向相对路径通常没问题,但指向宿主机绝对路径的链接,在 Dockerfile 里 COPY 时会发现文件不存在,因为守护进程拿到的 tar 里根本没有这个文件内容。这种问题在 monorepo 里尤其常见,注意别为了省事创建指向外部目录的链接。
2.3 用 .dockerignore 管住上下文
我强烈建议每个项目都配一个 .dockerignore,它的规则和 .gitignore 差不多,但有一个细节容易踩坑:当父目录被忽略之后,用 ! 去恢复父目录下的某个子文件是不生效的。比如你写了:
dockerignore复制build/
!build/server
这个 !build/server 大概率不会被恢复,因为 Docker 在解析时父目录 build/ 已经被排除了,"恢复一个被排除目录里的文件"这个逻辑在大部分版本里都不成立。真想保留,要么不忽略 build/,要么用更精确的规则。另外 .dockerignore 里也可以排除 Dockerfile 本身、.git 目录和各类临时文件。
一个比较通用的起步模板:
dockerignore复制.git
.gitignore
node_modules
Dockerfile
.dockerignore
*.log
*.md
build/
dist/
注意,这个模板只是起步,你要根据项目实际情况调整,原则是:上下文只包含构建真正需要的最小集合。
3. 依赖安装阶段:网络、源和超时的三连击
构建镜像最密集的失败区就是 RUN 里安装依赖的阶段。用 apt-get 的经常在 update 或 install 时报 404、连接超时;用 pip 的报 timeout、SSL 错误、找不到版本;用 npm 的报 ECONNRESET、ETIMEDOUT。这些报错的底层原因高度一致:软件源地址在你的网络环境下不可达或访问很慢,DNS 解析失败,并发请求被限流,或者基础镜像的索引文件过期。
3.1 apt、pip、npm 各自的高频失败场景
先做一个小实验帮你定位:在 Dockerfile 里临时加一行 RUN,把出问题的那一步拆开,看看是网络不通还是命令本身的问题。比如 apt 报错时,可以先跑:
dockerfile复制RUN apt-get update || (cat /etc/resolv.conf && cat /etc/os-release)
这一步能快速告诉你两件事:容器里的 DNS 配置是什么,基础镜像的发行版版本是什么。很多源问题其实是基础镜像版本变了,原来配的源地址已经不被新版支持,404 就是这么来的。
3.2 让源和 DNS 配置"只进构建、不进镜像"
确认是源的问题之后,常规操作是换源。Debian/Ubuntu 系的镜像可以在 Dockerfile 里用 sed 替换软件源:
dockerfile复制RUN sed -i 's|deb.debian.org|mirrors.example.com|g' /etc/apt/sources.list \
&& apt-get update
这里要注意,新版 Ubuntu 已经改了格式,源配置不在 /etc/apt/sources.list 里,而在 /etc/apt/sources.list.d/ubuntu.sources,而且用的是 deb822 格式,sed 替换的字段会不一样。直接用 COPY 一个已经写好的源文件进去更省心,但同样要确认目标镜像的系统版本匹配。
pip 和 npm 同理,可以通过环境变量或者写入配置文件指定索引地址。建议把这些网络相关配置放进 ARG 或构建时的环境变量,而不是写死在镜像里,否则镜像被推到别的环境时,这些配置可能反而成了故障源。
DNS 问题方面,如果容器解析不了域名,可以先在 RUN 里手动测试,或者 docker build 时加 --dns 参数;如果团队网络有专门的内网 DNS,优先用那个,而不是依赖默认配置。构建机上的 DNS 和运行时的 DNS 不是一回事,这两个环境要分开排查。
3.3 构建参数与重试机制:把不确定性交给策略
网络从来不保证稳定,所以构建步骤要有"重试"意识。apt 可以加重试参数:
bash复制apt-get -o Acquire::Retries=3 update
pip 可以加 --timeout 和 --retries,npm 可以通过 .npmrc 配置 fetch-retries、fetch-timeout。另一个容易被忽略的是包缓存目录,apt 会把下载的 .deb 留在 /var/cache/apt/archives,pip 会把 wheel 留在 /root/.cache/pip,这些都会扩大镜像体积。推荐把清理写在同一个 RUN 里:
dockerfile复制RUN apt-get update \
&& apt-get install -y build-essential \
&& rm -rf /var/lib/apt/lists/*
还有一类常见失败是 Dockerfile 里用了 curl xxx | sh 这种"一步到位"的安装方式。它的问题是一旦下载地址失效或者被篡改,整个构建就不可复现,而且你几乎无法做重试和校验。建议改成下载固定版本文件,校验校验和,再解压执行。构建的可靠性本质上是"每一步都确定",网络请求是最不确定的变量,能锁就锁。
4. 层缓存是一把双刃剑:快是快了,坑也多了
4.1 缓存命中的原则:指令和输入都没变
Docker 的层缓存原理一句话就能讲清楚:每条指令生成一个只读层,构建时如果当前指令和之前完全一样,上一层的缓存命中,且这条指令引用的上下文文件内容也没变,就直接复用这一层。注意是"内容没变",不是"修改时间没变"。很多人在本地改了一下文件,然后重新 build,发现后面几步还在跑,就以为缓存失效;其实前面没变的层继续命中,是正常的。
真正让人困惑的是缓存命中了但结果和预期不符。一种典型场景:你改了 Dockerfile 靠后的某条指令,但前面的所有层都命中了缓存,构建输出很快,你甚至以为改动没生效。其实只要前面层的指令和输入没变,该命中就是会命中,这是机制的正常表现,不是 bug。想确认改动到底生效没有,最直接的办法是 --no-cache 强跑一次,把问题限定到具体指令上。
4.2 依赖清单先行:经典的 Dockerfile 指令排序
层缓存的最佳实践是:把最容易变化的放最后,把最不容易变化的放最前。对大多数项目来说,依赖清单比源码稳定,所以应该先把依赖清单 COPY 进去安装依赖,再 COPY 源码。Node 项目典型写法:
dockerfile复制FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
CMD ["node", "server.js"]
npm ci 会根据 lock 文件生成完全一致的 node_modules,而且它比 npm install 快、可复现。Python 项目同理,先 COPY requirements.txt(或 poetry.lock),RUN pip install 之后,再 COPY 源码。这样只要 lock 文件不变,依赖层就一直命中缓存,构建速度会明显改善。
另一个细节:系统依赖安装时最好把 apt-get update、安装、清理写在一个 RUN 里,不仅减少层数,也减少"update 缓存失效"带来的重复下载。
4.3 BuildKit 的 cache mount 与"缓存失效"的困惑
如果你在用 BuildKit(新版本 Docker 默认),可以更进一步,用 RUN --mount=type=cache 把包管理器的缓存挂到临时目录,这些数据不会进入镜像层,但会被构建机留着复用,多次构建时速度提升非常明显:
dockerfile复制# syntax = docker/dockerfile:1
RUN --mount=type=cache,target=/var/cache/apt \
apt-get update && apt-get install -y build-essential
cache mount 用起来很爽,但有个现实问题:它会把缓存一直留在构建机磁盘上,长时间不清理会越积越大;多节点并发构建同一个镜像时,也可能出现缓存文件的并发冲突。所以我会建议运维侧定期对构建机做磁盘清理,而不是只盯着 Dockerfile。还有一点容易踩坑:cache mount 的默认权限是 root,如果 RUN 里切了用户,可能需要用 id 参数指定 uid。
5. 基础镜像、架构与平台:一个 tag 引发的意外
5.1 同一个 tag,昨天能构建今天失败
基础镜像的 tag 是构建不稳定性的头号来源。node:latest、python:3.12-slim 这类 tag 是"会漂移"的,它指向的内容会随上游发布而更新。今天拉和下周拉,拿到的是不同的镜像。表现就是:同一个 Dockerfile,昨天构建成功,今天构建失败,你什么都没改。
解决办法是锁版本,甚至锁 digest:
dockerfile复制FROM node:20.11.0-bookworm-slim@sha256:xxxxxxxx
锁 digest 是最严格的方案,它保证任何时候拉下来的基础镜像都是同一份内容。代价是升级基础镜像时你得手动去更新这个值,建议在 CI 里加一个定期检查和更新的流程,而不是让它悄悄漂移。
5.2 在 x86 上构建 arm 镜像,或者反过来
另一个高频"构建成功但运行失败"的场景是架构不匹配。你在 x86_64 的机器上 docker build,默认拉取 x86_64 的基础镜像,生成的容器自然也是 x86_64;推到 arm64 的服务器上,启动直接报 exec format error。这不是镜像坏了,是 CPU 架构不对。
构建时可以显式指定平台:
bash复制docker build --platform linux/arm64 -t your-image:tag .
如果是多平台分发,用 buildx 一次构建多架构:
bash复制docker buildx build --platform linux/amd64,linux/arm64 -t your-image:tag --push .
多平台构建有两个附带成本:一是在 x86 机器上构建 arm 镜像通常需要模拟执行,部分指令会变慢;二是 Dockerfile 里如果有 RUN 步骤,不同平台的输出可能不完全一致,最终还要做平台维度的验证,不能只看构建通过就完事。
5.3 精简镜像的隐藏代价:缺少工具与 musl 兼容问题
追求小体积本身没问题,但很多人在选择精简镜像时忽略了一个关键问题:运行产物是否依赖特定运行库。alpine 用的是 musl,很多预编译的二进制、第三方 .so 是基于 glibc 构建的,强行放到 alpine 里跑会报各种奇怪的错,比如 not found 或者段错误。
另一个问题是调试能力。distroless 镜像里没有 shell,没有包管理器,容器启动失败时你连进去看一眼的机会都没有。docker exec 能做的非常有限,排查全靠日志和 docker cp。我的建议是:按需求选择,不要盲目跟风"最小镜像"。如果你的运行产物是自包含的二进制(比如 Go 的 CGO_ENABLED=0 编译产物),用精简镜像完全没问题;如果依赖系统库或者需要在容器里排查问题,留一个完整的运行环境可能更省事。
6. USER、时区与入口脚本:镜像构建成功只算完成一半
镜像构建成功只是第一步,容器能不能优雅启动、优雅退出才是真正的考验。这一章聊几个构建成功后才会暴露的定时炸弹。
6.1 exec form 和 shell form 的区别,以及信号处理
先看一个最常见的坑:ENTRYPOINT 和 CMD 的写法。
exec form:CMD ["app", "-flag"],Docker 会直接执行这个程序,进程 PID 1 就是应用本身,kill 信号能直接传给应用。shell form:CMD app -flag,Docker 会用 /bin/sh -c 包一层,PID 1 变成 shell,应用成了 shell 的子进程。很多打包了 Java、Node 服务的镜像用 shell form,docker stop 时信号被 shell 吞掉,应用没法优雅退出,容器会一直停在 terminating 状态。
如果应用需要处理多个信号或者要管理子进程,建议在镜像里引入 tini 这类 init 进程,让 PID 1 变成 tini,由它统一转发信号:
dockerfile复制ENTRYPOINT ["tini", "--", "your-app"]
这个改动对线上稳定性帮助不小,尤其是那些自己 fork 子进程的服务。
6.2 权限、UID 与挂载目录的常见冲突
还有一个经典组合:Dockerfile 里用 USER 切换到非 root 用户,运行时挂载宿主机目录,然后应用报 Permission denied。原因是宿主机目录的所有者 uid 和容器内用户的 uid 对不上。容器内的用户 uid 是 10001,宿主机目录属于 1000,即使名字显示都是 app,内核看的是 uid 而不是用户名。
解决思路有两个。要么在 COPY 和 RUN 阶段就固定 uid,比如 USER 10001,目录用 COPY --chown=10001:10001;要么在宿主机上提前把挂载目录的 owner 改成对应 uid。在容器编排环境里,还有安全上下文、fsGroup 等手段,核心思路都一样:uid 对上了,权限问题就消失了一大半。
顺带提醒一句:默认用 root 跑容器虽然省事,但一旦容器被攻破,攻击者就是 root 权限,这比 Dockerfile 多花五分钟配置 USER 的代价高得多。
6.3 入口脚本的 CRLF、解释器和 set -e
入口脚本的问题也值得单独说。最常见的是行尾符:在 Windows 上编辑过的脚本是 CRLF 行尾,放到 Linux 容器里执行,会报 /bin/sh: 1: /entrypoint.sh: not found,但文件明明存在。遇到这个报错,第一件事用 file 命令看脚本格式,或者直接在 Dockerfile 里做一次转换:
dockerfile复制RUN sed -i 's/\r$//' /entrypoint.sh
其次是执行权限。Dockerfile 里最好先 RUN chmod +x /entrypoint.sh,再把它设成 ENTRYPOINT,不然会报 Permission denied。再次是解释器。如果脚本 shebang 写的 /bin/bash,而基础镜像是 busybox 或者精简镜像没有 bash,直接启动失败。我个人建议入口脚本统一用 POSIX sh 语法,兼容性最好。
最后,脚本开头加 set -euo pipefail,这样某一步出错时脚本会立即退出,而不是带着错误继续执行,否则你看到的故障现象会被严重掩盖。
7. 一次完整排查实录:从报错到根因的完整链路
理论讲完,来两个真实场景的完整复盘。这里用 A 同学代称我那位朋友,他最近连续踩了两个坑。
7.1 案例一:CI 上构建超时,本地一切正常
A 同学找我说,他在 CI 上构建一个内部工具的镜像,每次都在固定步骤挂掉,本地同一份代码没问题。我先没看 Dockerfile,先让他把构建命令改成 --progress=plain,把默认的进度条输出换成完整日志。结果显示问题在 apt-get update 这一步,报错是和软件源建立连接超时。
接着我在 Dockerfile 里临时加了调试行,把容器里的 DNS 配置和系统版本打出来,发现两个信息:一是镜像里默认的软件源地址在当前 CI 网络下根本不可达;二是 CI 机器的 DNS 也没有正确匹配项目所在的网络。根因不是 Dockerfile 写错,而是构建环境的基础网络配置不适合默认软件源。
修复分三步:Dockerfile 里把软件源改成项目网络可达的镜像源;构建命令加 --dns 指定正确的 DNS;apt 命令加上重试参数。改动之后,构建时间从频繁超时变成稳定三五分钟,问题再没出现过。这个案例说明:同一个 Dockerfile 在不同网络环境下结果不同很正常,CI 的构建环境必须像镜像内容一样被"固化"。
7.2 案例二:COPY 找不到文件,文件却明明在
另一个案例是 A 同学 COPY server /app/server 报 no source files,他反复强调文件在。我让他把 docker build 的路径和 COPY 的源路径对齐后发现,他在项目根目录执行构建,但文件在 server/dist/server,路径差了一层。这个问题定位后改起来很快,但说明了一个现象:看到文件不代表它在"上下文视角"里。
还有一次是 .dockerignore 里写了 build/ 排除整个目录,文件被无辜挡在上下文外面。Docker 的 .dockerignore 规则里 ! 恢复子文件不一定生效,所以排除了 build/ 就真的把 build/ 全部排除了。处理办法是精确排除,排除非目标的子目录,而不是一刀切。
7.3 排查问题的通用顺序
我梳理一个自己常用的排查顺序,你可以直接当成 checklist 用:
- 加
--progress=plain --no-cache跑一次,拿到完整日志,先定位失败发生在哪一个阶段。 - 判断阶段:拉取基础镜像、COPY、RUN 安装依赖、RUN 编译、容器启动。不同阶段的根因方向完全不同。
- 用最小 Dockerfile 复现问题,去掉业务无关步骤,减少干扰变量。
- 每次只改一个变量,验证一个假设。不要同时改源、改 DNS、改基础镜像,否则永远分不清是谁救了谁。
- 构建通过后别急着推,先本地
docker run做冒烟验证,确认启动、健康检查、日志都没问题。
8. 可以从第一天就养成的镜像构建习惯
8.1 锁定一切能锁定的版本
镜像构建的稳定性,很大程度上取决于"锁定了什么"。基础镜像锁 digest,系统包锁版本,应用依赖用 lock 文件,下载的二进制锁版本和校验和。没有锁,你在 CI 上的每次构建都是一次开盲盒。
注意,锁版本也不是一劳永逸,基础镜像需要定期升级,否则会积累安全漏洞。建议在 CI 里加一个定期检查和升级的流程,把升级纳入变更记录,而不是放任 latest 漂移。
8.2 多阶段构建:把构建环境和运行环境分离
多阶段构建是我现在写 Dockerfile 的默认姿势。构建阶段里随意装编译器、依赖、开发工具,最后只把运行需要的产物 COPY 到干净的运行阶段镜像里。
一个非常典型的 Go 多阶段写法:
dockerfile复制FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/app .
FROM debian:bookworm-slim
COPY --from=build /bin/app /app
ENTRYPOINT ["/app"]
这样做的好处是最终镜像体积小、攻击面小,而且构建阶段用了什么工具不影响运行时。多阶段还有一个隐藏优势:它强制你把"构建依赖"和"运行依赖"分开,这本身就是一次很好的依赖梳理。
8.3 构建后的冒烟验证与元数据记录
构建完成不算完成,至少做一次最小冒烟测试,再决定要不要 push。普通镜像我至少会跑一次 docker run --rm your-image:tag --version;服务型镜像会先在本地起一个容器,等健康检查通过再停掉。这十几秒钟的成本,远低于给团队推了一个坏镜像的返工成本。
同时建议在 Dockerfile 里写清楚元数据,方便别人理解镜像的用途和版本:
dockerfile复制LABEL org.opencontainers.image.title="demo-service" \
org.opencontainers.image.version="1.0.0" \
org.opencontainers.image.description="internal demo service"
有条件的话,CI 构建完成后加一步漏洞扫描,检查镜像是否存在高危漏洞。问题越早暴露,修复成本越低。
最后说说我自己的习惯。每次构建失败,我不会先去改 Dockerfile,而是先问三个问题:这个失败发生在哪个阶段?这个阶段依赖哪些外部假设(网络、架构、基础镜像、权限)?我这次改动了哪个变量来验证假设?把这三个问题想清楚,大部分 docker 创建镜像遇到的问题都能在十分钟内定位。如果被折腾得实在没思路,优先怀疑网络和基础镜像 tag,它们是最不确定、也最常出问题的两个变量。
