ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Dockerfile 创建镜像实战:分层缓存、多阶段构建与瘦身提速

Dockerfile 创建镜像实战:分层缓存、多阶段构建与瘦身提速

接手别人的项目时,最先让我皱眉的往往不是代码,而是那个“能跑但没人敢碰”的 Docker 镜像。我这些年陆续帮几个团队做过容器化落地,最后发现决定一个镜像能不能长期维护的,不是基础镜像够不够新,而是 Dockerfile 里那几行指令写得够不够克制。这篇就把“基于 Dockerfile 创建镜像”这件事从思路到落地拆一遍:为什么我几乎不用docker commit、每条指令背后到底发生了什么、怎么把 1.2GB 的镜像压到 120MB、构建时间从 6 分钟降到 40 秒,以及那些构建期报错到底怎么排。刚接触容器的同学可以照着抄作业,已经在天天写 Dockerfile 的同学,可以对照看看自己有没有踩到同样的坑。

1. 我为什么坚持用 Dockerfile,而不是 docker commit

1.1 两条路的本质差别:一份声明,还是一串手工操作

刚上手的人最容易走的路是:起一个基础容器,进去装依赖、改配置、传文件,最后docker commit出一个镜像。这条路确实快,十分钟就能得到一个“能用”的东西,但它的问题在于——你交出去的是一坨没有说明书的结果,而不是一个可重演的过程。半年后基础镜像打了安全补丁,你要重建一次,只能靠回忆:当时装的是哪个版本的包?那行 sed 到底改的是哪个文件?顺序反了会不会有问题?

Dockerfile 的价值不在于“Docker 要求你写它”,而在于它把构建过程变成了一份可评审、可版本控制、可 diff 的声明。镜像出了问题,你git log一下 Dockerfile,谁在什么时候把某个依赖升了级、谁把COPY改成了COPY . .,一清二楚。我见过最极端的一次事故,是某项目在容器里手工pip install了一个依赖,三个月后重建环境,装出来的是新版,上游改了接口,服务直接起不来——如果当初写的是 Dockerfile,这个问题在 code review 阶段就会被拦下来。

还有一层更隐蔽的收益:commit出来的镜像层是“脏”的。你在容器里apt-get update产生的缓存、下载的 tar 包、甚至~/.bash_history,全都被打进镜像里了。我见过一个 commit 出来的 1.4GB 镜像,用docker history一层层翻,最后发现里面躺着一个 400MB 的安装包和一个带编译工具的临时目录,而这些东西在运行期一点用都没有。

1.2 分层缓存:构建速度快不快,全看这几行指令的排序

Docker 镜像是由一层层只读层叠起来的,Dockerfile 里每一条RUN、COPY、ADD基本对应一层。构建时,引擎会对每条指令算一个缓存键:父层 ID + 指令本身的内容,COPY、ADD还会额外算上被复制文件内容的校验和。缓存键命中就直接复用,不命中就从这一层往后全部重算。

这条机制直接决定了 Dockerfile 的书写顺序。举个最常见的反例:

# 反例:每次改一行代码,都要重装一遍依赖 FROM node:20-alpine WORKDIR /app COPY . . RUN npm ci CMD ["node", "dist/main.js"]

COPY . .只要源码有任何改动(哪怕只是改了一个 README),缓存就失效,后面的npm ci全部重跑。改成下面这样:

# 正例:先拷依赖清单,再拷源码 FROM node:20-alpine WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . CMD ["node", "dist/main.js"]

日常改业务代码时,COPY package.json这一层的校验和没变,npm ci直接命中缓存,构建时间从几分钟掉到十几秒。这个技巧老生常谈,但真正落到项目里,我仍然能在八成以上的仓库里看到写反的顺序。判断标准很简单:变动频率低的放前面,变动频率高的放后面。

1.3 可复现性才是镜像真正的资产

我一直跟组里的人强调一句话:镜像不是“打包好的程序”,而是“一份能重演的环境快照”。这两者的区别在故障时特别明显。比如某次线上问题,你需要确认三个月前那个版本的镜像里curl到底是 7.88 还是 8.4,用 Dockerfile 构建的镜像,翻一下当时的 tag 对应的 Dockerfile 就知道;用 commit 出来的镜像,只能进容器里curl --version碰运气。

可复现性的另一个杀手是:latest。我踩过的最贵的一次坑,是某个内部基础镜像用了latest,上游维护者某天把 Debian 版本从 11 换到了 12,我们这边 CI 照常跑,构建成功,部署也成功,然后服务在启动阶段因为 glibc 版本差异挂了一半的实例。从那以后我们内部定了条规矩:所有 FROM 必须带明确的版本号或 digest,latest只在本地试验时用。

注意:FROM node:20-alpine这种写法在小版本上仍然会漂移,上游重新推 20-alpine 时,你拿到的可能是新的补丁版本。要求极致稳定的话,用摘要锁定:FROM node:20.11.1-alpine@sha256:xxxx。

2. 指令逐个拆解:哪些是骨架,哪些是陷阱

2.1 FROM:基础镜像选错,后面全是补救

FROM是整个镜像的地基,它决定了体积下限、libc 实现、包管理器和调试手段。很多人选基础镜像的唯一标准是“体积小”,结果在 alpine 上被 musl libc 折腾半天。我整理了一张实际项目里常用的对照表,数字是我这边不同项目实测的解压后体积,会随版本浮动,看量级就行:

基础镜像解压后体积适合什么要注意什么
scratch0静态编译的 Go / Rust 二进制没有 shell,没有证书,没有时区数据
distroless/static约 2MB同上,但自带 CA 证书与时区没有包管理器,排查只能靠外部工具
alpine:3.20约 8MB追求极致体积的小服务musl libc,部分预编译二进制和原生模块不兼容
debian:12-slim约 75MB通用后端服务,兼容性优先不含编译工具链,需要自己装
node:20-alpine约 130MBNode 服务原生模块要在这里重新编译
node:20约 1.1GB构建阶段,需要完整工具链直接当运行镜像属于灾难

选型逻辑其实就三条:运行期不需要的东西一律不放进运行镜像;有原生扩展或预编译二进制的,优先选 glibc 系(debian-slim);调试能力要提前想清楚,distroless 很干净,但出问题时你连ls都没有,只能docker cp或者临时换个调试镜像。

2.2 RUN:合并、清理、锁版本,三条铁律

RUN是写得最多、也最容易出问题的一条。第一条铁律是同一个软件源的 update 和 install 必须写在同一条 RUN 里。分开写的经典症状是:构建时apt-get update命中了几天前的缓存,装包时报404 Not Found或者The following signatures were invalid。因为 update 那一层被缓存了,索引早就过期了。

# 正确写法:一条 RUN 里完成 update、install、清理 RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ && rm -rf /var/lib/apt/lists/*

第二条是装完立刻清理。rm -rf /var/lib/apt/lists/*、npm cache clean --force、pip install --no-cache-dir,这些动作必须和安装写在同一个RUN里。分开写没用,因为上一层已经把文件写进镜像了,下一层删掉只是多了一个“白障”层,体积一点没省。

第三条是版本要锁。apt-get install curl和pip install requests都是浮动的,上游一发新版,你的构建结果就变了,而且经常是在你最不希望它变的时候变。生产镜像里的关键依赖,能写死版本号就写死,实在不行至少锁大版本。

提示:--no-install-recommends这个参数别省,它能挡掉一大批“推荐但非必需”的包,一个镜像少个几十 MB 很常见。

2.3 COPY 与 ADD:能用 COPY 就别用 ADD

ADD比COPY多两个能力:自动解压本地 tar 包、支持从 URL 下载。听起来很方便,但这两个能力我都建议你别用。自动解压的问题是不可控——你只想拷一个.tar.gz进镜像备查,结果它给你解开了;从 URL 下载的问题更多:下载的东西不进构建缓存的内容校验、权限默认是 600、也没法校验完整性。

我的习惯是统一用 COPY,需要下载就COPY+RUN curl,这样下载的 URL、校验步骤、失败处理全都在明面上。

# 不推荐 ADD https://example.invalid/tool-v1.2.3.tar.gz /tmp/tool.tar.gz # 推荐 COPY tool-v1.2.3.tar.gz /tmp/ RUN tar -xzf /tmp/tool.tar.gz -C /usr/local/bin \ && chmod +x /usr/local/bin/tool \ && rm -f /tmp/tool.tar.gz

另外提一个容易忽略的点:COPY进来的文件默认属主是root:root。如果你后面切了非 root 用户运行,应用要写这个目录就会Permission denied。BuildKit 支持COPY --chown=node:node ./dist ./dist,这一手能省掉后面RUN chown -R产生的一整层。

2.4 ENV、ARG、WORKDIR:构建期状态到底该怎么管

这三个指令经常被混用,但它们的生命周期完全不同。ARG只在构建期存在,构建完就没了;ENV会写进镜像,容器运行时能读到。判断标准很直接:运行期要用的放 ENV,只影响构建过程的放 ARG。

ARG有个容易踩的细节:写在FROM之前的ARG只能在FROM行里用,进不了构建阶段,要再用一次必须重新声明。

ARG NODE_VERSION=20 FROM node:${NODE_VERSION}-alpine # 这里必须重新声明才能用 ARG NODE_VERSION RUN echo "building with node ${NODE_VERSION}"

还有一个必须提醒的坑:ARG的值虽然不进镜像的ENV,但会被记录在镜像的构建历史里,docker history一翻就能看到。所以私钥、口令这类东西绝对不能走--build-arg,需要构建期凭证的场景用 BuildKit 的--secret,它挂载的文件不会留在任何一层里。

WORKDIR则是被严重低估的一条。它不只是“切换目录”,还会在目录不存在时自动创建。我见过有人在 Dockerfile 里写RUN cd /app && npm install,结果依赖装到了根目录的临时位置,后面的CMD完全找不到——因为RUN cd只对当前那条指令生效,下一条指令又回到了默认目录。统一用WORKDIR就没这个事。

2.5 CMD 与 ENTRYPOINT:容器收到停止信号却没反应,多半是这里

这两个指令的区别,一句话说清:ENTRYPOINT是“这个容器是什么”,CMD是“默认参数是什么”。ENTRYPOINT一般不会被docker run后面的参数覆盖(除非加--entrypoint),CMD会被完全覆盖。

但比语义更容易出事的是写法。不加方括号的 shell form,会被包成/bin/sh -c "你的命令",真正的进程变成 shell 的子进程,shell 成了 PID 1。这时候docker stop发的 SIGTERM 打到的是 shell,shell 不一定转发给子进程,10 秒后被 SIGKILL 强杀,你的优雅退出逻辑一行都没执行,日志也没来得及刷盘。

# shell form:PID 1 是 /bin/sh,信号容易丢 CMD node dist/main.js # exec form:node 直接是 PID 1 CMD ["node", "dist/main.js"]

不过 exec form 也不是万能药。业务进程作为 PID 1 时,还承担着回收僵尸进程的职责,很多语言运行时处理得并不好。我的做法是在 Dockerfile 里不管这事,运行时统一加--init,让一个轻量 init 进程去做 PID 1,业务进程当它的子进程,信号转发和僵尸回收都正常了。这个习惯是从一次“容器停不掉、每次都要等 10 秒超时”的事故之后养成的。

3. 完整实操:从零构建一个可交付的 Node 应用镜像

3.1 项目结构与第一版 Dockerfile

拿一个典型的 Node 服务举例,目录结构大致是这样:

demo-service/ ├── src/ │ └── main.ts ├── package.json ├── package-lock.json ├── tsconfig.json ├── .dockerignore └── Dockerfile

最容易写出的第一版通常长这样:

FROM node:20 WORKDIR /app COPY . . RUN npm install RUN npm run build EXPOSE 3000 CMD ["node", "dist/main.js"]

这版能跑,但问题一堆:基础镜像 1.1GB,node_modules里带着全部开发依赖,COPY . .把本地的node_modules也拷进去了,构建产物和源码混在同一层。我第一次量这版镜像,docker images显示 1.2GB,传给同事拉取要等两分多钟。

顺便说个坑:如果你的宿主机是 ARM 架构(比如近几年的苹果笔记本),本地node_modules里的原生模块是 arm64 的,COPY . .拷进 linux/amd64 的镜像里,运行时就会报invalid ELF header或者cannot open shared object file。这个错误信息特别有迷惑性,很多人会去怀疑基础镜像,其实是本地依赖被带了进去。解决办法就一个:.dockerignore里把node_modules排除掉,依赖一律在镜像内安装。

3.2 多阶段构建:把 1.2GB 压到 120MB

多阶段构建的核心思想是:构建工具和构建产物分开住,只有产物进最终镜像。上面那个项目改写成三阶段:

# 阶段一:只装生产依赖 FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev # 阶段二:装全量依赖并编译 FROM node:20-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build # 阶段三:只放产物 FROM node:20-alpine AS runtime ENV NODE_ENV=production WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY package.json ./ USER node EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \ CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1 CMD ["node", "dist/main.js"]

几个关键点值得展开说。COPY --from=deps这种写法直接把另一个阶段的目录搬过来,构建阶段的编译器、缓存、源码、测试依赖全部留在那个阶段里,不进最终镜像。npm ci --omit=dev用的是 lock 文件精确安装,比npm install更可控,构建结果不会因为语义化版本范围而漂移。

USER node这一行是我强制要求加的。官方 Node 镜像自带一个 uid 1000 的node用户,切过去之后容器就不是 root 了。代价是/app目录的属主还是 root,应用如果要往这个目录写文件(比如日志、临时上传),就会失败。两个选择:要么给需要写的目录单独COPY --chown=node:node或RUN mkdir -p /app/tmp && chown node:node /app/tmp,要么干脆把可变数据写到挂载卷里。我倾向后者,容器就该是无状态的。

HEALTHCHECK用的是 busybox 自带的wget,alpine 里现成可用,不用额外装东西。--start-period=15s这个参数很多人不写,结果应用启动慢的时候,健康检查在启动阶段就开始判失败,容器被反复重启。给它留出启动窗口,能避免大量假警报。

这一套下来,最终镜像在 120MB 上下,主要构成是node:20-alpine的基础层加上生产依赖。从 1.2GB 到 120MB,体积降到十分之一,镜像拉取时间按内网 10MB/s 算,从两分钟降到十几秒。如果有几十上百个节点同时扩容,这个差距会直接体现在发布窗口上。

3.3 构建、打标签与产物验证

构建命令本身很简单,但有几个参数值得固定成团队习惯:

# 常规构建,同时打一个语义化 tag 和一个 commit 短哈希 tag docker build \ --build-arg NODE_VERSION=20 \ -t demo-service:1.4.0 \ -t demo-service:git-a1b2c3d \ -f Dockerfile .

-t打两个标签是有讲究的:语义化版本给人看,commit 哈希给机器用,出问题能一路追溯到具体代码。--build-arg传版本号是我比较推荐的用法,基础镜像升级只需要改 CI 里的一个变量,不用动 Dockerfile。

构建完别急着推仓库,先验三件事。第一,看体积和层数:docker history demo-service:1.4.0能看清每一层多大、对应的指令是什么,哪一层突然膨胀一眼就能发现。第二,确认没把敏感文件带进去:docker run --rm --entrypoint sh demo-service:1.4.0 -c "ls -la /app",重点看有没有.env、*.pem、.git这类东西。第三,跑一次真实启动:docker run --rm -p 3000:3000 demo-service:1.4.0,确认端口通了、健康检查变绿了,再往下走。

提示:想要更直观的层分析,可以用dive这类工具,它会把每一层新增和删除的文件都列出来,找出“加了又删”的浪费层特别有效。

4. 提速与瘦身:可以量化的那些手段

4.1 .dockerignore:构建上下文比你以为的贵得多

docker build .最后那个点,是构建上下文。传统构建器会把这个目录整个打包发给守护进程,BuildKit 虽然改成了按需传输,但扫描和校验的成本还在。如果目录里有node_modules、.git、构建产物、测试覆盖率报告,这些文件都会被纳入扫描,构建还没开始,光传上下文就花掉几十秒。

一个能直接抄的.dockerignore:

node_modules .git .gitignore npm-debug.log* coverage .nyc_output .env .env.* *.md Dockerfile .dockerignore .vscode .idea dist

注意最后那个dist要看你项目的构建方式:如果编译在镜像内完成,dist必须排除,否则本地残留的旧产物会COPY . .进镜像,把新编译的东西覆盖掉,症状是“改了代码但镜像行为没变”,排查起来极其费劲。反过来,如果编译在宿主机或 CI 前置步骤完成,镜像只负责打包产物,那就不能排除dist。这个判断没有标准答案,取决于你的流水线设计。

4.2 缓存命中率:用数据说话,别凭感觉

我在一个中型项目里做过对比测试,同一个 Dockerfile,分别改三种文件后构建,记录耗时(本地 SSD、依赖约 400 个包):

改动内容优化前的 Dockerfile调整 COPY 顺序后引入多阶段后
只改一行业务代码3 分 50 秒22 秒15 秒
改 package.json 加一个依赖4 分 10 秒3 分 40 秒1 分 50 秒
什么都不改,重复构建3 分 45 秒8 秒5 秒

第二行值得注意:加依赖必然要重装,这一步省不掉,但多阶段能把它限制在构建阶段,运行镜像那几层依然命中缓存。第三行“什么都不改”最能反映 Dockerfile 质量,理想情况下应该接近零耗时。

想确认缓存到底有没有命中,构建时加--progress=plain,输出里会明确写CACHED还是RUN。我排查“为什么构建这么慢”时,第一步永远是看这行输出,而不是猜。

4.3 体积账:每一百兆都在为发布窗口付费

很多人觉得镜像大点无所谓,磁盘便宜。但真正付费的地方不是磁盘,是分发。一个 1.2GB 的镜像,在内网 10MB/s 的环境下拉一次两分钟;一次扩容 50 个实例,理论上要 100 分钟的总带宽时间,实际还会互相抢带宽。同样的场景换成 120MB,两分钟就能铺完。

体积的主要来源就那么几块,按影响力排序:基础镜像(1.1GB vs 130MB,差距最大)、开发依赖(Node 项目里 devDependencies 常常占node_modules的一半以上)、构建缓存和临时文件(apt 索引、pip 缓存、源码)、以及忘了清理的下载包。按这个顺序砍,收益最明显。

还有个小技巧:如果最终镜像只需要跑一个静态编译的二进制,scratch或者distroless/static能把体积压到个位数 MB。代价是没有 shell,出问题时没法进去看,只能靠日志和外部工具。我的判断标准是——核心服务用 distroless,边缘服务用 alpine,需要频繁排查的用 debian-slim,别一刀切。

5. 报错与排查实录

5.1 构建期高频报错速查

下面这些报错,我在不同项目里几乎都见过至少一次,整理成表方便对号入座:

报错片段根因处理方式
exec user process caused: no such file or directory常见于入口脚本被 Windows 编辑成 CRLF 换行;或 alpine 镜像里跑了 glibc 编译的二进制用dos2unix或编辑器改成 LF;换 glibc 系基础镜像
standard_init_linux.go: exec format error构建架构和运行架构不一致,比如在 ARM 机器上构建了 amd64 镜像用--platform显式指定目标架构
failed to compute cache key: "/xxx" not foundCOPY的源路径不存在,或被.dockerignore排除了检查路径与忽略规则,注意COPY的源是相对构建上下文
Cannot connect to the Docker daemon守护进程没起来,或当前用户没有访问权限检查服务状态与用户组配置
invalid reference format镜像标签含大写字母、空格或非法字符标签统一小写,用-分隔
no space left on device构建缓存和悬空镜像堆积定期清理悬空镜像与构建缓存
404 Not Found出现在装包阶段apt-get update被缓存,索引过期update 与 install 合并到同一条RUN
Permission denied出现在入口脚本COPY进来的脚本没有执行权限加chmod +x,或用 BuildKit 的--chmod

这张表里,第一行那个no such file or directory是最具欺骗性的——文件明明在那儿,报的却是“不存在”。原因是内核执行脚本时读到了 shebang 行末尾的\r,去找一个叫/bin/sh\r的解释器,当然找不到。团队里只要有人在 Windows 上改过entrypoint.sh,这个坑就会复现一次。

5.2 构建成功但容器起不来,四类原因占九成

这类问题比构建报错更折磨人,因为构建阶段一切正常,问题藏在运行期。

第一类是监听地址写成了127.0.0.1。容器内的回环地址只对本容器有效,端口映射过来也访问不到。应用配置里必须绑0.0.0.0。这个错误在本地开发时完全感知不到,一上容器就暴露,我见过不止一个项目在这里卡了半天。

第二类是文件属主和权限。切了USER node之后,应用要写的目录还是 root 的,启动直接抛EACCES。日志里往往只有一行权限错误,容易被当成别的问题。

第三类是环境变量缺失。本地.env里有数据库地址,镜像里没有,进程启动即退出。这类问题的排查技巧是:docker run --rm --entrypoint sh 镜像 -c env看一眼实际注入了什么,比猜快得多。

第四类是启动命令找不到文件。多阶段构建最容易出这个,COPY --from=build拷的路径写错了,dist目录根本没进去,CMD执行时自然报错。验证方法是构建完立刻docker run --rm --entrypoint sh 镜像 -c "ls -la /app",别等部署了才发现。

5.3 我自己踩过的几个坑,写出来给后来人省点时间

第一个坑跟:latest有关,前面提过,代价是半个集群的实例启动失败。从那以后我们内部定了规矩:基础镜像必须带明确版本,CI 里加一条检查,出现FROM xxx:latest直接构建失败。

第二个坑是COPY . .把本地node_modules带进镜像。表现是本地跑得好好的,镜像里报原生模块加载失败,错误信息指向.node文件。查了半天才反应过来,本地装的是 macOS 的二进制,镜像里是 Linux。这个坑的根因就是.dockerignore没写全,现在我的习惯是新建项目第一件事就是写.dockerignore,比写 Dockerfile 还早。

第三个坑是构建参数里塞了私有仓库的凭证,方便是方便,后来做安全扫描时发现docker history里明文可见。现在统一改成 BuildKit 的--secret,凭证只在那条RUN期间以文件形式挂载,不落任何一层。

第四个坑跟HEALTHCHECK有关。一开始我写成 shell form,exit 1的退出码被 shell 吞掉了,健康检查永远显示 healthy,直到服务真的挂了才发现这个检查形同虚设。改成CMD wget ... || exit 1这种显式判断才生效。这个教训是:健康检查本身也要验证,故意让服务不健康,看状态会不会变红,才算真的配好了。

我在实际项目里的体会是,Dockerfile 这东西看着简单,但它是少数几个“写错一行、影响整条交付链路”的文件。我们的做法是把它跟业务代码一样纳入评审,任何改动都要说明理由;CI 里加两道卡口,一道查基础镜像版本是否锁定,一道查镜像体积是否超过阈值。这两道卡口拦下来的问题,比事后排查省的时间多得多。另外一个小建议:每隔一段时间把构建缓存清一遍再完整构建一次,确保从零开始也能成功——只靠缓存的构建,等于没验证过。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进