我最早接触 Docker 的时候,其实没太把 Dockerfile 当回事。因为先学了docker run,感觉容器也就是一个轻量虚拟机,往里扔命令就够了,何必写什么配置文件。直到有次在服务器上构建一个项目,我手动敲完十几条命令,隔天又得全部重来一遍,才意识到 Dockerfile 不是给 Docker 看的说明书,它是给"构建过程"立的一套规矩。你用什么基础镜像、装哪些依赖、怎么拷代码、用什么用户启动,全部写进这份文件里,镜像才能稳定复现。这篇文章我就把 Dockerfile 的文件结构和 docker 镜像构建的完整过程掰开讲一遍,从每条指令的语义到 docker build 引擎背后的分层缓存逻辑,再附带一次真实的镜像优化复盘。适合刚开始接触容器、或者写过 Dockerfile 但总觉得"能跑但说不清为什么"的开发者看。
1. Dockerfile 的本质:一份讲究顺序的镜像配方单
1.1 为什么镜像不能直接"手搓"
很多人对镜像的理解停留在"给一个基础系统装软件"。你完全可以手动启动一个 ubuntu 容器,进去apt install、拷贝文件、再docker commit成一个新镜像。这条路走得过,但彻底告别了可重现性——装了什么、改了哪些配置、依赖了什么版本,全凭记忆。
Dockerfile 本质上就是把"手动搭建一个运行环境"的全部操作,固化成一份可读、可版本控制、可自动执行的配方单。它的每个指令都对应一次文件系统变更,docker build 按顺序执行这些指令,最后输出一个镜像。有了这份文件,任何人在任何机器上构建,得到的内容都基本一致,这才是它存在的核心价值。
1.2 顺序就是构建过程的"执行剧本"
初学者最容易忽略的是 Dockerfile 的顺序语义。每个指令都建立在前一个指令产生的文件系统之上,后面的指令永远看不到前面指令没执行时存在的文件。听上去是废话,但在实际编写时特别容易踩:有人先COPY . .再写WORKDIR /app,结果代码拷到了容器根目录;有人把RUN命令写在COPY package.json之前,导致 npm install 时根本找不到依赖清单文件。
还有一点特别容易被忽略:Dockerfile 第一个非注释指令必须是FROM,前面只允许放ARG。为什么?因为 FROM 声明了镜像的构建起点,它决定了后续所有 RUN 指令的基础环境和可用包管理器。把 FROM 写在后面,前面的指令就没有可运行的文件系统了。
提示:构建上下文(docker build 时 . 所指的那个目录)和容器内部文件系统是两个不同的概念。你需要理解"构建环境"与"运行环境"的边界,后面第 3 章我会展开讲。
1.3 两类指令:构建时指令与运行时指令
Dockerfile 里的指令可以粗略分成两类:
- 构建时指令:在 docker build 执行阶段生效,比如
FROM、RUN、COPY、ADD、ARG。它们负责"造出"镜像层。 - 运行时指令:在容器启动阶段生效,比如
CMD、ENTRYPOINT、EXPOSE、VOLUME、USER。它们写入镜像的元数据,真正执行要等到docker run。
这个区分的好处是,你会天然理解为什么RUN apt-get install会立刻下载软件包,而CMD ["nginx", "-g", "daemon off;"]只是在镜像配置里写了一个"启动时执行"的标记。二者发生的时间完全不同,很多新人以为 Dockerfile 里的指令都是容器启动时跑一遍,这是最大的误解。
2. Dockerfile 指令逐个拆解:从 FROM 到 ENTRYPOINT,每条指令都可能在坑你
2.1 基础镜像选择:FROM 决定了 90% 的体积与安全基线
FROM 是镜像的地基。同一个应用,基于node:18和基于node:18-alpine,最终镜像大小会差出 300MB 以上。我见过很多人直接写FROM ubuntu:latest,然后在里面装 Python、装 Node、装各种编译工具,最终打出一个 2GB 的镜像。这个做法在本地跑没问题,一推到镜像仓库、再拉下来部署,就能体会到什么叫"带宽焦虑"。
选基础镜像的实践原则就几条:
- 优先选择官方镜像,比如
python、node、nginx、alpine,官方维护的镜像补丁及时。 - 在满足运行需求的前提下,优先选 alpine 或 slim 变体。alpine 基于 musl libc,和大多数 glibc 编译的二进制不兼容,这一点后面会提到,但它体积小是真的香。
- 固定标签版本而不是用
latest。latest是个漂移的靶子,你今年构建的镜像和明年构建的镜像可能就完全不是同一个系统了。
2.2 WORKDIR 与 ENV:先定好"家",再干别的
WORKDIR /app看似是"创建一个目录并切进去",实际上它影响后面所有RUN、CMD、ENTRYPOINT、COPY、ADD指令的默认目录。所以它通常放在 FROM 之后、其他指令之前,作为整个构建过程的工作坐标。
ENV则用来设置环境变量。常见的用法是设置NODE_ENV=production、JAVA_HOME这类运行配置。这里要留心一点:ENV设置的环境变量,在构建阶段和运行阶段都会生效。这意味着如果在ENV里带了敏感信息,最终镜像里也会留着这个值,任何能拿到镜像的人都能看到。所以密钥类信息务必要用构建参数(ARG)或者构建时挂载的密钥文件处理,放进ENV等于公开。
2.3 RUN:构建过程中的"施工指令",最容易把镜像撑大
RUN是执行命令的指令,格式上分为 shell 形式和 exec 形式:
RUN apt-get update && apt-get install -y curl RUN ["curl", "-L", "https://example.com/file.tar.gz", "-o", "file.tar.gz"]shell 形式等价于/bin/sh -c "...",会走一层 shell 解析;exec 形式则是直接运行指定程序,不经过 shell,信号处理也更干净。这两种形式在 Dockerfile 里都能用,但 exec 形式更推荐在写入CMD和ENTRYPOINT时使用,因为 shell 形式启动的进程不是 PID 1,容器收到退出信号时会有转发问题。
RUN指令典型的坑有两个。第一,每条 RUN 都会产生一个新的镜像层,层数越多,镜像管理的开销越大。早期 Docker 时代,大家会刻意把多条RUN合并成一条,用&&连接,减少层级。现在新版 BuildKit 已经能在部分场景下自动合并层,但这种"合并命令"的写法并不过时,因为它减少了中间态文件的保留。第二,包管理工具安装完的缓存不会自动清理。比如apt-get install之后,/var/lib/apt/lists里的索引文件还躺在镜像里,这些缓存会让镜像无谓地变大。正确做法是在同一条 RUN 的末尾清理:
RUN apt-get update && \ apt-get install -y --no-install-recommends curl && \ rm -rf /var/lib/apt/lists/*--no-install-recommends这个参数也值得记住,它告诉 apt 不要安装推荐但非必需的软包,省体积效果立竿见影。
2.4 COPY 与 ADD:代码进入镜像的两条不同路径
COPY负责把构建上下文里的文件复制到容器文件系统。它是最常用的文件送入指令,因为它语义纯粹、可预测。ADD在 COPY 的功能基础上多了两个行为:自动解压本地 tar 包、支持远程 URL 下载。听起来很方便,但在实践中我几乎不推荐用 ADD 做远程下载或自动解压。
原因很简单:ADD 从 URL 下载文件时,不会自动设置文件权限,下载失败时不会自动清理缓存,而且你无法在下载过程中用&&组合操作。更重要的是,自动解压会让你在"我明明复制了一个压缩包,结果容器里面是解压后的目录"这种预期落差里排查半天。如果真的要解压,在COPY之后再执行RUN tar -xzf一把梭,行为和结果都更可控。
COPY还有一个隐藏的重要特性:它只能复制构建上下文内的文件。你写COPY ../config.yml /app/是没用的,docker build 会把上下文之外的文件直接拒绝。这背后的设计是为了保证构建操作是自包含的——所有输入都在一个明确的目录里。
2.5 CMD 与 ENTRYPOINT:容器启动时到底执行什么
这两个指令是 Dockerfile 里最容易绕晕的组合。基础规则是:
ENTRYPOINT定义容器的"主进程"。CMD提供默认参数,或者在ENTRYPOINT没写时作为启动命令。
如果两者都写成 exec 形式,那么容器启动时实际执行的命令是:
ENTRYPOINT 的命令 + CMD 提供的参数一个常见的组合是:
FROM python:3.12-slim WORKDIR /app COPY . . ENTRYPOINT ["python", "app.py"] CMD ["--host", "0.0.0.0"]docker run myimage --port 8080时,--port 8080会替换掉 CMD 的内容,最终执行的是python app.py --port 8080。如果docker run myimage后面什么都不加,最终执行python app.py --host 0.0.0.0。
新手最容易犯的错误是:写多个 CMD,或者把 CMD 写成 shell 形式但希望它把参数传给 ENTRYPOINT。
ENTRYPOINT ["python", "app.py"] CMD --host 0.0.0.0这第二种写法会执行python app.py --host 0.0.0.0吗?不会。shell 形式 CMD 在 exec 形式 ENTRYPOINT 下不会被解析为参数列表,它会被当作一个整体字符串传给 ENTRYPOINT,结果变成python app.py "python app.py --host 0.0.0.0",直接报错。保持"两个都用 exec 形式"这个一致性,可以减少很多诡异问题。
还要特别注意,用 shell 形式写 ENTRYPOINT 时,容器主进程是/bin/sh,它不会转发 SIGTERM 信号。你在宿主机上敲docker stop,等半天容器才停,多半就是这个原因。
2.6 其他常用指令:EXPOSE、USER、VOLUME、HEALTHCHECK
EXPOSE 8080只是一个"声明",它不真正发布端口给宿主机。容器起来要能被外部访问,还是得靠docker run -p 8080:80。EXPOSE 的价值更多是文档作用:告诉使用者这个镜像默认监听哪些端口。
USER用来切换运行用户。容器内默认是 root,这个在安全评审里经常是最高危项。我建议在 Dockerfile 里创建一个专用用户:
RUN groupadd -r app && useradd -r -g app app USER appVOLUME声明一个数据卷挂载点。它会在容器运行时创建一个匿名卷。它的作用更多是告诉使用方"这个目录的数据是持久的",但直接写在 Dockerfile 里其实有点反直觉——因为你无法在构建时给这个卷挂载具体宿主机目录。实践中更推荐在 docker run / compose 里配置卷,而不是在 Dockerfile 里声明 VOLUME。
HEALTHCHECK则给容器定义一个健康检查命令。镜像里有它,编排平台就能周期性地探测容器存活状态。写一个简单的 HTTP 探活:
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 13. docker build 执行时,引擎盖下到底发生了什么
3.1 构建上下文、分层提交与临时容器
很多教程会一笔带过"docker build 会把上下文发送给守护进程",但这一步其实是构建过程中最容易被忽略的性能瓶颈。当你执行:
docker build -t demo-app:v1 .Docker 会把当前目录(也就是.)打包成构建上下文,发送给 Docker 守护进程。这一步对网络文件系统特别敏感:如果项目目录里有 node_modules 之类的大目录,上下文可能有几百 MB,光是传输就可能花几十秒。
构建上下文的用途,是让 Dockerfile 里的COPY能拿到本地文件。但要注意,构建上下文是所有指令共享的"输入物料池",而不是"最终镜像内容"。你的代码体积大没关系,但如果把不必要的文件都塞进上下文,每条 COPY 指令在比对文件内容时都要多花时间。
接下来是分层机制。每一条会改变文件系统的指令(FROM 之后的 RUN、COPY、ADD),在执行时都会:
- 启动一个临时容器。
- 在上一个镜像层之上执行该指令。
- 将容器的文件系统差异(新增、修改、删除的文件)打包成一个新的只读镜像层。
- 提交这个层,作为下一层的基础。
最终镜像就是这一堆不可变层的堆叠。docker history能清晰地看到每一层的创建指令和大小。
3.2 构建缓存:为什么改一行代码,前面所有层都得重跑
缓存逻辑是整个构建过程中最值得理解的部分。Docker 在逐层构建时会检查"这一层的输入是否和之前的构建一致",如果一致就直接复用上一步产出的镜像层,跳过重新执行。这就是为什么第二次构建往往会快很多。
但缓存的比对规则比想象中严格:
FROM指令:基础镜像的 ID 必须一致。RUN指令:指令字符串必须完全一致,此外还要看"父层"是否命中缓存。如果前面的层变了,这一层就算指令完全一样,也会因为文件系统内容变化而重建。COPY/ADD:除了指令字符串,还会对要复制的文件内容做校验和比对。只要任何一个文件内容变化,这一层以及其后所有层的缓存全部失效。
这个规则直接引出一个编排艺术:把变更频繁的步骤尽可能往后放,把大概率不变的步骤放在前面。典型的示范是 Node 项目:
COPY package.json package-lock.json ./ RUN npm ci COPY . .第一次构建时,这两个步骤都会执行;后续改代码后再次构建,因为package.json没变,COPY package.json那层的校验和一致,RUN npm ci层直接命中缓存,只有最后COPY . .以及它后面的层会重建。这样做能省掉大部分依赖安装时间。
3.3 逐行读懂一次真实的构建日志
理论知识说再多,不如看一次真实的日志。假设我在一个示例项目里执行构建,日志大致是这样的:
$ docker build -t demo-app:v1 . #1 [internal] load build definition from Dockerfile #1 transferring dockerfile: 1.2KB done #2 [internal] load metadata for docker.io/library/node:20-alpine #2 DONE 0.8s #3 [internal] load .dockerignore #3 transferring context: 450B done #4 [internal] load build context #4 transferring context: 12.1MB 0.9s done #5 [build-stage 1/5] FROM docker.io/library/node:20-alpine@sha256:xxxx #5 DONE 0.0s #6 [build-stage 2/5] WORKDIR /app #6 CACHED #7 [build-stage 3/5] COPY package*.json ./ #7 CACHED #8 [build-stage 4/5] RUN npm ci #8 CACHED #9 [build-stage 5/5] COPY . . #9 DONE 3.2s看这个日志的层次感:
[internal]开头的步骤是 Docker 自己在处理元数据:读取 Dockerfile、加载 .dockerignore、统计构建上下文,这些不产生镜像层。FROM node:20-alpine这一步显示DONE 0.0s,是因为本地已经有这个基础镜像,如果是第一次拉取,这里会显示pulling和下载进度。[build-stage 2/5]到[build-stage 4/5]都显示CACHED,说明这些层在之前构建时已经产生并且输入没有变化,直接复用。- 只有最后的
COPY . .重新执行了,因为它依赖的源文件可能发生了变化。
如果日志里显示CACHED的层不是你期望的,最直接的办法就是docker build --no-cache强制重建。这个参数是个万能钥匙,但别滥用,它会放弃所有缓存层,让构建时间回到冷启动水平。
3.4 BuildKit 带来了什么变化
大部分新版 Docker 默认启用 BuildKit,它和传统构建器相比有几个明显改善:
第一,指令可以并行执行。Dockerfile 中互不依赖的步骤会并发跑,整体构建时间明显缩短。
第二,更聪明的缓存。传统构建器的缓存只认"指令字符串 + 父层";BuildKit 在 COPY 时还能更好地做内容感知的缓存,对大型 monorepo 比较友好。
第三,支持额外的挂载类型。比如在 RUN 中使用--mount=type=cache可以给包管理器建立一个持久缓存目录:
RUN --mount=type=cache,target=/root/.npm \ npm install这样依赖包在构建间不会每次重新下载,而是缓存在构建器的缓存区里。注意,--mount=type=cache,id=xxx的缓存卷是构建器维护的,不会被打进镜像层。这个特性对 CI 环境特别管用。
当然 BuildKit 也不是没有学习成本。DOCKER_BUILDKIT=1是老版本镜像需要做的显式开关,新版本默认开启;跨平台构建时它的选项也更复杂。不过只要是近两年的 Docker 环境,直接按默认来就行。
4. 一次真实项目的镜像构建优化复盘:从 250MB 降到 86MB
4.1 原始 Dockerfile 的问题诊断
我接手过一个内部工具的前端项目,它的 Dockerfile 长这样:
FROM node:18 WORKDIR /app COPY . . RUN npm install RUN npm run build EXPOSE 3000 CMD ["npm", "start"]这个项目镜像体积 250MB 左右,每次构建要近 5 分钟。先诊断问题:
FROM node:18用的是完整版 Node 镜像,自带大量开发工具链,体积就很大。COPY . .把整个项目目录拷进镜像,包括node_modules(如果本地已经安装过)和.git目录,这些都是无用负担。npm install每次都没有复用锁文件缓存,而且没有使用npm ci,依赖解析时间不可控。CMD ["npm", "start"]默认跑的是开发服务器,生产环境也在跑 dev server,既浪费内存又有安全隐患。- 容器里用 root 用户运行。
这几条问题叠在一起,镜像体积、构建速度、运行安全都处于不行状态。
4.2 多阶段构建:分离"构建工具链"和"运行环境"
优化的核心思路是多阶段构建。整个构建过程拆成两个阶段:
- 阶段一(builder):使用带完整工具链的基础镜像,完成代码编译、依赖安装。这个阶段没必要进入最终镜像,产物只是编译后的静态文件。
- 阶段二(runtime):使用最小化的运行镜像,只拷贝阶段一的产物进去。
Dockerfile 写成这样:
# 阶段一:构建 FROM node:18-alpine AS build-stage WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build # 阶段二:运行 FROM nginx:1.25-alpine COPY --from=build-stage /app/dist /usr/share/nginx/html EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]注意AS build-stage给第一阶段起了名字,第二阶段用COPY --from=build-stage精确拿走只需要的产物。最终镜像里完全没有node_modules、没有源码、没有编译工具链,只有 nginx 和静态文件。这是 Dockerfile 里最值得练习的写法。
如果项目是 Node 服务而非静态站,第二阶段可以换成:
FROM node:18-alpine ENV NODE_ENV=production WORKDIR /app COPY --from=build-stage /app/dist ./dist COPY package.json package-lock.json ./ RUN npm ci --only=production USER node CMD ["node", "dist/server.js"]这样运行时镜像里只有生产依赖、编译产物和 Node 运行时,开发依赖完全不在里面。
4.3 瘦身三件套:.dockerignore、npm ci、权限收敛
光靠多阶段还不够,还有三个细节要同步处理。
第一,加 .dockerignore。文件内容和 .gitignore 类似,作用是指定哪些内容不进入构建上下文。至少包含:
node_modules .git .gitignore Dockerfile *.log distnode_modules必须排除,因为COPY . .时如果把它一起复制进构建上下文,不仅传输入慢,还会覆盖掉容器里已有的依赖目录,造成不可预知的冲突。清掉这些之后,构建上下文从十几 MB 降到几百 KB,整个构建过程中的文件统计、COPY 校验都轻快多了。
第二,用 npm ci 取代 npm install。npm ci严格按 package-lock.json 安装,不解析版本范围、不做排序优化,速度明显更快,而且能保证开发环境和构建环境装出来的依赖完全一致。如果 CI 上拉取的依赖不是从 npm registry 而是私有镜像源,要记得配.npmrc。
第三,收敛运行用户。静态站阶段直接用官方 nginx 镜像,它默认已经是 nginx 用户,无需额外处理;Node 服务阶段则要显式加USER node。用了USER node之后,如果应用需要监听 80 或 443 端口,会因为没有 root 权限而失败。解决方案是提前在 Dockerfile 里改监听端口(Node 应用监听 3000 或 8080),或者用 setcap 赋予低端口能力——实践中最省事的是让应用监听非特权端口。
4.4 优化后的效果与复盘
优化完成后做一次结果对比:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 镜像体积 | 约 250MB | 约 86MB(nginx 方案更小) |
| 构建时间 | 约 5 分钟 | 约 1 分 20 秒 |
| 构建缓存复用 | 无效(COPY . . 导致频繁失效) | 有效(依赖装在前,源码在后) |
| 运行权限 | root | 非 root |
| 生产运行模式 | dev server | 编译产物 + 静态服务 |
这套方案改完以后,落到本地的效果是镜像推送和拉取的速度都快了很多。更重要的是,构建时间不再是 CI 流水线上的拖油瓶,改动一行代码后的增量构建基本只重跑最后两步。
这里我要提醒一句,多阶段构建不是万能的。如果你的应用在运行阶段真的需要编译工具链(比如做在线代码执行的沙箱),强行优化成两阶段反而要绕路。但绝大多数 Web 前后端服务都适用。
5. 沉淀下来的几个构建习惯
踩过几次坑以后,我现在写 Dockerfile 会先想这几个问题:基础镜像用官方哪个变体、哪些文件要送进构建上下文、依赖安装能不能前置、最终运行用户是不是 root。这四个问题过一遍,一个 Dockerfile 的质量基本就有底了。
还有一个操作层面的小技巧:在本地验证 Dockerfile 时,不要一上来就跑大镜像。先用docker build看日志,如果RUN指令报错,可以临时在报错那一步改用交互模式调试,比如把当前指令改成RUN /bin/sh构建后进入容器看现场。等排查清楚再恢复成正式的 RUN 指令。这种做法比盯着错误日志猜原因快得多。
最后再分享一个实用的边界认知:Dockerfile 不是万能的配置中心,它只管"构建镜像"这一件事。像启动参数、环境变量覆盖、存储卷挂载这些,属于docker run/ docker-compose 的管辖范围。把这两层职责分开,写出来的 Dockerfile 才足够清晰。