我第一次看 Dockerfile 的时候,脑子里全是问号:这个 FROM 是干什么的?RUN 为什么要用 && 连成一长串?CMD 和 ENTRYPOINT 看起来都是启动命令,到底有什么区别?后来有一次在奶茶店等单,看到后厨墙上贴的蜜雪冰城 SOP,突然就想明白了:Dockerfile 不就是镜像制作的 SOP 吗?
一杯冰鲜柠檬水,要保证在任意一家门店、任意一个店员手里做出来味道一致,靠的是一张固定步骤的“操作标准书”。从切柠檬、加糖浆、加冰,到封口、贴标签,每一步都有明确要求。Dockerfile 干的也是同一件事:把“制作一个环境”的过程写成一份可复制、可检查、可追溯的说明书,让任何人都能构建出行为一致的镜像。这篇文章我就用连锁奶茶店的 SOP 逻辑,把 Dockerfile 从语法到实践拆给你看。看完你至少能独立写出规范的 Dockerfile,也能知道怎么排查镜像构建中的常见问题。
1. 先搞懂:SOP 和 Dockerfile 到底像在哪
1.1 门店 SOP 解决什么问题
蜜雪冰城的标准化能力,核心就是 SOP。门店不需要研发来现场盯着,店员只需要按卡片上的步骤操作:水温多少、糖浆几泵、冰块几勺,照做就行。这样做的好处很直接:第一,新员工培训成本低;第二,出品质量稳定;第三,出了问题能回溯是哪一步没做对。
软件部署也是同样的困境。一个应用能不能跑起来,取决于操作系统版本、依赖库、配置文件、环境变量,甚至时区。如果每次部署都是运维手工敲命令,今天装这个、明天改那个,最后出来的环境一定长得不一样,线上出了问题根本没法解释。很多团队口中的“在我电脑上是好的”,本质就是环境不一致。
1.2 Dockerfile 是镜像生产车间的 SOP
Dockerfile 本质上就是一个文本文件,里面每条指令对应 SOP 里的一条操作项目。Docker 引擎在构建镜像时,会从上到下逐条执行这些指令,每执行一条就生成一个新的只读层,最后把所有层打包成一个镜像。镜像是构建的最终产物,而 Dockerfile 就是生产这个产物的完整操作标准。
你完全可以把它理解成“配方表 + 操作流程 + 质检标准”三合一。配方表回答“用什么原料”,对应 FROM 基础镜像;操作流程回答“怎么做”,对应 RUN、COPY、EXPOSE 等指令;质检标准回答“做好以后怎么检查”,对应 HEALTHCHECK 等指令。一个门店如果没有 SOP,一百家店能开出一百种味道;一个团队如果没有规范 Dockerfile,一百个人构建出来的镜像也能差出十万八千里。
1.3 底层逻辑:可复制、可审计、可追溯
SOP 之所以有价值,是因为它让“经验”变成了可复制的文本。Dockerfile 也一样,它的核心价值可以归纳成三句话:
- 可复制:同一份 Dockerfile,在任何一台装了 Docker 的机器上构建,得到的结果在行为上是一致的。这不是靠某个人水平高,而是靠流程固定。
- 可审计:每一行指令都有明确作用。Code Review 时可以像检查门店操作卡一样,逐条核对有没有违规项,比如是不是用了 root 用户、是不是把密码写死在了镜像里。
- 可追溯:镜像的每一层都是“生产记录”。用
docker history能看到每一层做了什么,镜像出问题可以往前一层一层追。
这也是我后来一直跟团队强调的观点:手动配置云服务器就像靠老师傅凭手感做奶茶,而 Dockerfile 是把老师傅的脑子和手艺变成一张能传下去的卡片。
2. 把 Dockerfile 拆成一张“配方表”
2.1 锅底与招牌:FROM 指令决定基础镜像
你进一家奶茶店,先看的是店里的招牌产品,而一家“镜像店”的招牌就是 FROM 指定的基础镜像。FROM 必须是 Dockerfile 里的第一条有效指令(除 ARG 外),它决定了你这面镜像的底子是什么操作系统、带着什么初始环境。
常见的基础镜像有几种选择:alpine 体积最小,适合跑静态工具和简单服务;ubuntu 和 debian 包管理器好用、软件丰富,适合需要调试和编译的场景;语言官方镜像如node:20-alpine、python:3.11-slim则直接帮你把运行时环境装好了。
选择基础镜像时有一条原则要记住:基础镜像决定了镜像体积的下限。你FROM ubuntu:latest,即使什么都不装,镜像也有七八十兆;而FROM alpine:3.19,基本盘只有几兆。后面每装一个包,体积都会在这个基础上继续堆。很多人的镜像几百兆、几个 G,往往不是应用本身大,而是底子选得太重了。
2.2 采购与备料:RUN 指令与包管理器
RUN 是在构建过程中执行的命令,作用相当于“在操作台上采购备料、处理食材”。每一次 RUN 都会启动一个临时容器,在容器里执行命令,然后把文件系统的变化保存为一层,最后临时容器被删除。
这里有一个特别关键的细节:为什么大家写apt-get install时要把apt-get update和安装命令用&&连起来?
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*因为 Dockerfile 每一条指令生成一层。如果你把update和install写成两条 RUN,那update生成的索引数据会留在那一层,而 apt 索引通常有几兆甚至几十兆,这些没用的数据会被原样带进最终镜像。同时,如果第一层变化导致第二层缓存失效,后面安装包也会跟着重跑。用&&把相关操作合并成一条 RUN,能把命令变成一个逻辑步骤,构建时也更好控制缓存。
另外,国内开发者在构建镜像时经常遇到包下载慢的问题。这个问题的根源是基础镜像默认配置的软件源站点距离远,DNS 解析和网络传输都比较慢。正规做法是把软件源替换为可用的国内镜像源,比如阿里云镜像源或者清华 TUNA 镜像源,然后再执行安装。这属于基础软件源的正常配置,不影响任何合规性。
2.3 原料搬运:COPY 与 ADD
把食材搬进后厨,对应 Dockerfile 里的 COPY 和 ADD。COPY 做的事情很纯粹:把构建上下文里的文件或目录复制进镜像的新层。
COPY package*.json /app/ COPY dist /usr/share/nginx/htmlADD 在 COPY 的基础上多支持两种能力:从 URL 下载文件、自动解压本地 tar 包。但我要给个建议:默认都用 COPY,除非你明确需要解压功能。ADD 的自动解压有时候会带来意外行为,比如你可能只是想把一个 tar 包带进镜像,结果它被静默解压了,后面找文件找半天。
这里必须解释一下“构建上下文”这个概念。当你执行docker build -t my-site .时,最后那个.不是随便写的,它代表把当前目录作为构建上下文发送给 Docker 引擎。Docker 引擎只能看到这个目录及其子目录里的文件,所以 COPY 也只能引用上下文里的文件。如果你的本地目录很大,又没有写.dockerignore,构建时会把 node_modules、.git 等一堆无关文件全部打包发送,又慢又容易出问题。这个概念特别像门店后厨:SOP 只会规定“使用仓库里的哪些原料”,仓库外面再多东西都跟这杯饮品无关。
2.4 上菜与摆盘:EXPOSE、CMD、ENTRYPOINT
饮品做好了,接下来就是“上菜”。这一部分对应三个指令。
EXPOSE 的作用很让新手困惑:它其实是“声明”,不是“发布”。写EXPOSE 80只表示“我这个容器里的应用预计监听 80 端口”,并没有真的把端口映射到宿主机。真正要让外部访问,运行时还是要用docker run -p 8080:80。所以 EXPOSE 更像菜单上写的“建议搭配吸管”,告诉你这个应用会用到哪些网络入口。
CMD 和 ENTRYPOINT 则负责定义容器启动时执行什么命令。两者的区别我用一句话总结:
- CMD 相当于默认值,容易被
docker run后面的命令覆盖。 - ENTRYPOINT 相当于固定入口,很难被覆盖,除非显式用
--entrypoint改掉。
最佳实践是把 ENTRYPOINT 用于固定程序,把 CMD 用于传默认参数。比如一个自定义脚本的镜像:
ENTRYPOINT ["/entrypoint.sh"] CMD ["--config", "/etc/app/config.yml"]如果你直接用docker run my-image --env prod,那么--env prod会替换掉 CMD,而 ENTRYPOINT 仍然固定执行/entrypoint.sh。
| 指令 | 是否可被docker run后参数覆盖 | 典型用途 |
|---|---|---|
| CMD | 可以 | 提供默认参数和默认启动命令 |
| ENTRYPOINT | 不可以(需显式 --entrypoint) | 固定应用入口、包装脚本 |
| EXPOSE | 不涉及启动命令 | 声明端口,辅助文档展示 |
3. 从一杯“冰鲜柠檬水”看一份 Dockerfile 的诞生
3.1 场景:做一个最简单的静态网站镜像
理论讲再多,不如亲手做一杯。我们设一个具体场景:手里有一个dist目录,里面是静态网页文件,目标是把它封装成一个 nginx 镜像,让任何机器都能一键跑起来。
初始目录结构大概是:
my-site/ ├── Dockerfile ├── .dockerignore └── dist/ └── index.html这个场景非常适合学习,因为它没有复杂依赖,核心就是“把文件放进镜像,用 nginx 提供服务”。从第一版到生产可用的版本,我们一步步演进。
3.2 第一版:能跑就行
第一版只追求“能跑”:
FROM nginx:1.25-alpine COPY dist /usr/share/nginx/html EXPOSE 80构建并运行:
docker build -t my-site:v1 . docker run -d -p 8080:80 my-site:v1浏览器访问http://localhost:8080,能看到页面就成功了。这个版本问题很多:没有健康检查、没有元信息、构建上下文没有过滤、没有资源限制,但它验证了最小闭环:镜像就是一个能自包含运行环境的产物。
第一版的意义在于让你先打通链路,不要一开始就把优化规则全堆上来。很多初学者一上来就背多阶段构建、安全加固,结果连镜像起没起来都不清楚,反而学得一塌糊涂。
3.3 第二版:把构建和运行拆开(多阶段构建)
现实中的dist目录通常不是手写的,而是前端工程执行npm run build生成的。如果我们直接把 node 环境装进 nginx 镜像,然后跑构建命令,镜像会变得特别笨重:node_modules 动辄几百兆,构建工具链全部留在最终镜像里。
这时候就该用多阶段构建:第一个阶段负责构建产物,第二个阶段只要把产物复制过来放好。
# 阶段一:构建静态文件 FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build # 阶段二:运行环境 FROM nginx:1.25-alpine COPY --from=build /app/dist /usr/share/nginx/html EXPOSE 80这里注意两个设计:第一,先复制package*.json,再复制剩余代码,而不是一上来COPY . .。这样做的目的是利用 Docker 构建缓存。只要依赖文件没变,npm install这一层就能复用,不会每次改代码都重新安装依赖,构建速度快一个量级。第二,COPY --from=build可以从上一个阶段拉取文件,最终镜像完全不包含 node 和 npm,体积基本就是 nginx 基础镜像加几个静态文件,可能从一两百兆直接降到几十兆。
多阶段构建对应的管理逻辑就是:后厨不需要把整个农场搬进来,只需要把做好的奶茶端出去就行。
3.4 第三版:把变量、健康检查、日志都写清楚
能跑和能上线之间,还得补一些细节:
- 标注维护者和版本信息,方便团队里面其他人知道这个镜像是谁维护的。
- 设置时区,避免日志时间和本地对不上。
- 声明健康检查,让 Docker 知道应用是不是真的活着。
- 自定义 nginx 配置,比如压缩、缓存策略等。
FROM nginx:1.25-alpine LABEL maintainer="yourname@example.com" LABEL app.name="my-site" LABEL app.version="1.2.0" ENV TZ=Asia/Shanghai COPY dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget -q -O - http://127.0.0.1/ || exit 1健康检查这段不是摆设。SOP 里要求店员每一小时试喝一杯、检查原料过期日期,HEALTHCHECK 就是镜像里的“试喝员”。如果不写健康检查,容器里 nginx 进程死了,容器也可能仍然处于 running 状态,流量打过来才发现挂了,监控还没报警,非常尴尬。
构建完成后你可以用docker image inspect查看镜像的 Labels、Healthcheck 信息,确认一切都写进去了。这也是审查 SOP 的一种方式。
3.5 构建时值得注意的几个优化点
- 基础镜像尽量固定 tag,用
nginx:1.25-alpine而不是nginx:latest,否则你今天构建和三个月后构建可能得到完全不同的底层系统,等于 SOP 在悄悄改变配方却不通知你。 - 构建时加上
--progress=plain可以看完整输出,排查问题更直观。 - 用
docker history my-site:v2查看每一层的大小,会发现很多意外惊喜,比如 cache 层特别大,说明某条 RUN 没清理缓存。
4. 构建镜像时常见的“翻车现场”
4.1 镜像体积为什么越建越大
镜像最常被吐槽的问题就是“我能跑,但镜像好几个 G”。体积膨胀的来源通常有几个:
- 基础镜像直接选 ubuntu-desktop 级别的镜像。
- 一次 RUN 里只装软件,没删包管理器缓存和临时文件。
- 直接把整个项目目录 COPY 进去,里面塞了 node_modules、dist、测试数据、图片原图。
- 没有使用多阶段构建,编译工具链全部留在运行镜像。
排查思路很简单:docker history 镜像名看每层大小,哪一层特别大就去查对应的指令。如果是缓存问题,就合并 RUN 并在末尾清理;如果是依赖问题,就改成多阶段构建。
| 体积来源 | 表现 | 解决手段 |
|---|---|---|
| 基础镜像过重 | 第一层体积就很大 | 换 alpine 或 slim 版本 |
| 包缓存未清理 | 某一条 RUN 层特别大 | 合并 update/install,末尾删除缓存 |
| 整个目录 COPY | 体积和项目目录一致 | 多阶段构建,只复制产物 |
| 编译工具残留在运行层 | 镜像里有 go/npm 等编译链 | 多阶段构建分离构建态和运行态 |
4.2 构建上下文太大导致构建慢
很多项目构建慢,不是因为 Dockerfile 写得差,而是因为上下文目录没有过滤。执行docker build时,Docker 客户端会把整个目录发送给 Docker daemon,node_modules几百兆,.git几百兆,都等于通过本地 socket 复制一遍,速度快不了。
解决办法是创建.dockerignore文件,语法和.gitignore类似:
node_modules .git dist *.log .DS_Store .vscode .idea写.dockerignore就是在给 SOP 划重点:后厨只要这几样原料,其他东西别往操作间里送。这个小文件经常被人忽略,但构建慢的时候,它的效果比任何优化技巧都明显。
4.3 软件包安装失败或下载缓慢
构建过程中,apt-get或yum安装失败是家常便饭。原因通常是基础镜像默认的软件源距离较远,连接不稳定,或者个别包源访问超时。最简单的处理方式是把软件源替换成可用的国内镜像源,例如阿里云、清华 TUNA 等。具体代码因发行版而异,这里以 Debian 系镜像为例:
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources \ && apt-get update \ && apt-get install -y curl \ && rm -rf /var/lib/apt/lists/*这里还要注意一个细节:如果公网网络本身不稳定,重试一次往往能过,但不要依赖重试,最好把软件源配置写进 Dockerfile,保证每次构建环境一致。
4.4 构建缓存失效导致每次重装依赖
先复制源码再装依赖,会引发两个问题:第一,每次修改代码都会导致后续所有层缓存失效;第二,npm 安装几百个包,构建等待时间长到可以喝三杯奶茶。
我见过不少团队提交的 Dockerfile,长这样:
COPY . /app RUN npm install RUN npm run build这几乎每次都在重新安装依赖。正确顺序是先复制依赖清单文件,利用缓存安装依赖,再复制代码:
WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build这两者看起来差别不大,实际构建时间差异非常明显。依赖不常变,代码常变,所以要把“依赖安装”放在“代码复制”前面,这跟 SOP 里“提前把糖浆备好”是一个道理。
4.5 容器启动后立刻退出
有的镜像构建成功,运行却秒退,日志也看不明白。核心原因通常是:容器只会在前台进程存活时保持运行。如果 CMD 里写的是:
CMD service nginx startservice 命令会启动后台守护进程然后退出,主进程一结束,容器就跟着退出了。这就好比 SOP 让店员“打开灯后离开厨房”,结果人一离开灯也灭了。
正确做法是用前台方式运行:
CMD ["nginx", "-g", "daemon off;"]或者直接运行应用本身,比如CMD ["node", "app.js"]。
排查这类问题可以先用docker logs 容器ID看输出,也可以临时覆盖 entrypoint 进入容器调试,例如:
docker run -it --rm --entrypoint sh my-site进去以后手动执行启动命令,一步步看哪里报错。
5. 像检查门店 SOP 一样审查 Dockerfile
5.1 安全审查:别让镜像变成后门
镜像作为部署产物,安全性不能等运行阶段才考虑。构建阶段就需要审查几类高风险点:
- 不要用 root 跑应用。很多镜像默认以 root 运行,一旦应用被打穿,攻击者直接获得容器内最高权限。建议在 Dockerfile 里创建专属用户:
RUN addgroup -S app && adduser -S app -G app USER app- 不要在镜像里写死敏感信息。比如数据库密码、API Key,能通过环境变量注入就绝不要写进 Dockerfile。
ENV写在历史层里,任何能拿到镜像的人都可以docker history或者docker inspect看回来。 - 固定镜像版本。不要笼统地写
FROM node:latest,最好精确到小版本甚至 digest。这样即使上游镜像 tag 被覆盖或删除,你也能构建出一模一样的镜像。 - 尽量精简包集。一个容器只干一件事,不需要的调试工具、vim、curl 都别装,缩少攻击面。
5.2 可维护性审查:别人看不懂的指令就是代码坏味道
SOP 要能让新员工看懂,Dockerfile 也一样。审查时不光要看不报错,还要看后续维护的人能不能快速上手。几个经验:
- 指令顺序是否利用了缓存?依赖前置,业务代码后置。
- 有没有用多阶段构建把编译态和运行态分开?如果一个镜像既装了 go 编译器又跑 go 程序,审查时可以直接打回。
- LABEL 是否完善?打上
maintainer、app.version、build.time,出了问题能追溯到人。 - 注释是否解释了“为什么”,而不是“是什么”。例如
# 先用固定 tag,避免 npm 版本漂移就比# 安装依赖有价值。
5.3 可复现性审查:明天构建和今天一样吗
可复现性是镜像和 SOP 高度一致的体现。审查时重点问几个问题:
- 基础镜像有没有用锁版本,甚至锁定 digest。
- 依赖安装有没有走 lockfile。node 项目用
npm ci而不是npm install,能确保本地装出的依赖和 lockfile 完全一致。 - 有没有使用环境变量提供配置,而不是在 Dockerfile 里写死。
- 构建时是否可传入构建参数
--build-arg,让同一个 Dockerfile 能构建出不同环境的镜像。
ARG APP_VERSION=unknown LABEL app.version="${APP_VERSION}" FROM nginx:1.25-alpine COPY dist /usr/share/nginx/html构建时指定:
docker build --build-arg APP_VERSION=1.2.0 -t my-site:1.2.0 .这种做法在需要给多个环境发布镜像时特别有用,不用复制一堆几乎相同的 Dockerfile。
5.4 综合审查表
| 审查维度 | 检查点 | 通过标准 |
|---|---|---|
| 可复制性 | FROM tag 是否固定 | 不使用 latest,优先 digest 锁定 |
| 安全性 | 是否使用 root | 有独立用户,USER 放在 CMD 之前 |
| 安全性 | 敏感信息是否写死 | 无密码/密钥出现在 ENV 或 COPY 中 |
| 可维护性 | 是否有 LABEL 和注释 | 有维护者、版本、构建时间等信息 |
| 构建效率 | 依赖是否前置 | 依赖安装早于源码 COPY |
| 构建效率 | 是否多阶段 | 编译环境与运行环境分离 |
| 体积 | 是否清理缓存 | apt/npm 临时文件被清理 |
| 可复现性 | 依赖锁文件 | node_modules 不入库,使用 npm ci |
6. 我的实操心得与两个小工具
分享几个实际操作中比较个人的经验。
第一,不要一开始就把所有最佳实践堆上去。我刚学 Dockerfile 时喜欢把健康检查、多阶段、非 root、ARG 全部塞进去,结果构建失败了我根本不知道是哪个环节出了问题。后来我改成“先最小化构建成功,再逐步加约束”的套路,每加一个特性就重新构建一次,反而学得快、用得好。这和门店培训是一个道理:先学会做一杯能喝的柠檬水,再学控制糖度、温度、成本。
第二,建议把 Dockerfile 当作代码来管理。团队协作时走 PR 和 Code Review,审查的重点就是上面那个综合审查表。一个团队如果把 Dockerfile 当成一次性脚本,写着“能用就行”,后面吃亏的一定是自己。镜像一旦变成生产依赖,它就是基础设施,必须有版本、有记录、有责任人。
最后分享两个我每次排查镜像都会用到的工具。第一个是docker history,它能快速看每层体积和指令,定位体积膨胀和缓存问题非常直观。第二个是开源工具dive,它可以交互式地查看每一层新增、修改、删除了哪些文件,能精确看到某个文件是被哪一层带进去的。那感觉就像把门店的采购单和后厨操作录像逐帧比对,一目了然。
镜像是可以随处搬运的成品,而 Dockerfile 是让它可复制的那张操作卡。学的时候多问一句“这一条指令在现实中对应什么动作”,很多知识点自动就串起来了。