如果你的服务在同事电脑上跑得好好的,换到新机器就各种缺依赖,那你大概率听过这句话:“它在我的机器上是好的。”Dockerfile 就是专门用来终结这句话的工具。它不是一门编程语言,也不是复杂的配置框架,而是一份描述如何构建镜像的清单:用哪个基础镜像、装哪些包、代码放到哪个目录、容器启动时执行什么命令,全部用纯文本写清楚。任何一台装了 Docker 的机器,只要拿到这份清单,就能按同样的步骤构建出行为一致的镜像。刚接触容器化、想把项目用 Docker 跑起来,或者已经被一堆镜像折腾得焦头烂额的人,都可以继续往下看。
1. 为什么需要 Dockerfile:镜像不是黑盒,而是一张可复述的配方
早期有一部分人是这样生成镜像的:先拉一个基础镜像,把容器跑起来,然后手动进入容器敲命令、装依赖、复制代码,最后用docker commit把容器固化成一个镜像。这套流程在一个人、一台机器上确实能跑通,但放到团队协作和长期维护的视角里,问题一个接一个。
1.1 手工做镜像的三个痛点
第一个痛点是不可追溯。容器里到底执行过哪些操作,如果不靠终端历史记录或者自己临时写笔记,事后根本说不清。尤其遇到“这个镜像里的某个包是什么时候装的、为什么装”这种问题,基本上只能靠拍脑袋。
第二个痛点是不可复现。换一台机器、换个时间点,重新手工做一遍,系统源、包版本、临时文件都可能不一样,最后产出的镜像和原来的镜像存在细微差别。这种差别平时看不出来,一旦线上出问题,排查成本会成倍上升。
第三个痛点是不可共享。别人拿到手工提交的镜像,只能看到最终状态,看不到它是怎么来的。想改一个依赖版本,只能重新进入容器再折腾一遍,然后重新提交。项目里几个人分别维护自己的“私房镜像”,版本漂移只是时间问题。
1.2 Dockerfile 把“怎么做”变成了仓库里的一份文本
Dockerfile 的核心差异在于,它把整个构建过程沉淀成文本。这份文本可以提交到版本控制里,可以走代码评审,可以对比改动记录,还能和项目代码放在同一个仓库里发布。下面拿“手工提交镜像”和“Dockerfile 构建”做个对比:
| 维度 | docker commit | Dockerfile 构建 |
|---|---|---|
| 过程可见性 | 操作记录在终端里,事后难追溯 | 每条指令都写在文件里,随时可查 |
| 可重放性 | 靠记忆和文档手工重做 | 同一份文件在不同机器上执行同样指令 |
| 变更控制 | 难以区分某个改动对应哪个依赖 | 每条指令就是一次变更 |
| 团队协作 | 每个成员可能维护自己的镜像 | 统一配方,按需构建 |
这个对比不复杂,但很能说明问题。把镜像当成“结果”管理,迟早会被版本漂移反噬;把镜像当成“配方”管理,才谈得上真正的可交付。Dockerfile 不是用来替代你写业务代码,而是用来替你交代运行环境。
2. 指令与层的关系:写长还是写碎,答案不是单行命令的多寡
很多新人看 Dockerfile 的第一反应是:这不就是把终端里的命令按顺序抄一遍吗?如果只是按顺序抄,不同人写出来的 Dockerfile 不应该有这么大的差距。真正拉开差距的,是对“层”的理解。
2.1 每一行指令都是一层,这句话决定了一切
Dockerfile 里大部分指令都会生成一个只读层,比如FROM、RUN、COPY等。容器运行后,Docker 在这些只读层之上再叠加一个可写层。层会参与构建缓存、影响镜像大小、影响网络传输,所以指令怎么写,直接决定构建快不快、镜像大不大。
我把这个过程类比成盖房子:每一行指令相当于一层预制板,后一层在前一层上施工。后续施工只要发现前面某一层有变动,这一层以及它上面的所有层都得重新做。因此有经验的人会刻意把“频繁变动的指令”往后放。比如先复制依赖清单,再复制业务代码,因为依赖列表的变动频率远低于源码;依赖安装那层一旦命中缓存,后续构建能省掉大量时间。
2.2 常用指令速查:把“什么时候用”也写清楚
我实际项目中用得最多的指令也就这几条,按用途分成三组。
基础类:FROM指定基础镜像,必须放在第一行。基础镜像不一定非要是“操作系统镜像”,也可以是某个自带运行时的镜像。ARG声明构建参数,只在构建阶段可用,不会保留在最终容器的环境变量里。ENV设置环境变量,既影响构建阶段,也会保留在容器运行环境里。
文件类:WORKDIR切换或创建工作目录,相当于cd,但比cd更可靠,因为后续的RUN、CMD都会基于它执行。COPY把构建上下文里的文件复制进镜像;ADD也能做同样的事,还支持自动解压 tar 包、从 URL 下载,但能不用就不用。RUN负责执行命令,一般是安装依赖、做编译、设置权限。
启动类:EXPOSE声明容器监听端口,本质是“告诉使用者”的元信息,真正的端口映射还要靠运行时用-p参数。CMD提供容器启动时的默认命令,可以被覆盖。ENTRYPOINT定义入口命令,适合固定启动程序,配合CMD传参是最常见也最稳定的组合。
2.3 两条关于层的实战心法
第一条:能用一条RUN尽量用一条。不是强迫别人写“又长又丑的单行命令”,而是因为每多一条RUN就会多一层,层数多了,构建和推送都会更慢,镜像的安全分析范围也会扩大。
第二条:凡是“先COPY再RUN”的写法,都要考虑缓存顺序。如果RUN负责安装依赖,而依赖清单复制放在业务代码复制之后,那么只要代码改动一行,依赖安装层就会被连带失效,白白浪费几分钟。这两条心法比背全量指令列表更值钱,因为大多数业务项目根本用不到那些冷门指令。
3. 一个十几行的可运行实例:把 Python 服务塞进镜像的完整动作
理论说再多,不如一个能直接跑的例子。下面这个例子是我的惯用骨架,适合绝大多数简单的 Python Web 服务。
3.1 完整 Dockerfile 先看整体
假设项目根目录下有app.py和requirements.txt,那么 Dockerfile 可以这样写:
# 使用精简版 Python 镜像 FROM python:3.11-slim # 设置工作目录 WORKDIR /app # 先复制依赖清单,利用缓存 COPY requirements.txt . # 安装依赖 RUN pip install --no-cache-dir -r requirements.txt # 再复制业务代码 COPY . . # 声明服务端口 EXPOSE 8000 # 指定启动命令 CMD ["python", "app.py"]整个文件十几行,但已经覆盖了大部分场景的骨架。注意最后CMD用的是数组形式,而不是CMD python app.py。数组形式是推荐写法,它不会默认触发 shell 解析,不容易在参数里引入奇怪的空格或特殊字符问题。
3.2 逐行解释里面藏着什么细节
FROM选择了slim版本,而不是完整版 Python 镜像。精简版少了编译工具和大量文档,体积更小,却能满足绝大多数 Python 服务场景。WORKDIR放在最前面,避免后面到处做相对路径拼接。第一个COPY只复制requirements.txt,把依赖安装层和业务代码层隔开,这是前面讲的缓存顺序的实际应用。
RUN安装时加了--no-cache-dir,避免 pip 把缓存文件写进镜像,能省出几十 MB 空间。第二个COPY . .复制全部业务代码,.dockerignore会拦截掉不需要的文件。EXPOSE 8000只是声明,宿主机真正要访问,需要构建完成后用-p映射端口。最后的CMD使用 exec 形式,容器内 PID 1 直接运行python,信号处理和进程退出行为都比 shell 形式更干净。
3.3 构建、运行和验证的三条命令
在当前目录执行:
docker build -t my-web-demo:0.1 . docker run -d -p 8000:8000 --name demo my-web-demo:0.1 docker logs demo第一条命令最后的.代表“构建上下文”是当前目录,不是 Dockerfile 所在目录的别名。第二条命令把容器 8000 端口映射到宿主机 8000,-d表示后台运行,--name demo给容器起名,方便后续管理。第三条命令用于查看日志,确认服务是否成功启动。
如果代码启动后没有立刻输出,可以执行docker exec -it demo /bin/sh进入容器,查看进程和端口状态,排查启动失败原因。
提示:如果容器日志出现权限类报错,优先检查当前用户能否写入工作目录。生产环境通常不建议用 root 跑服务,但这个例子为了演示可运行性,先保持最简单的写法。
4. 构建上下文、.dockerignore 和缓存:影响构建速度的三个隐藏因素
前面例子能跑,但真正交付时,很多人会在构建速度上吃亏。问题通常出在三个不显眼的地方:构建上下文体积、.dockerignore有没有写、缓存有没有命中。
4.1 构建上下文到底是什么
docker build后面那个点,表示把当前目录作为构建上下文发送给 Docker 守护进程。Dockerfile 里的COPY . .能看到的文件范围,就是整个构建上下文。这个机制带来的坑是:如果项目目录里有node_modules、.git、venv这种又大又杂的目录,每次构建都会把这些文件整体打包发送,哪怕你的 Dockerfile 根本用不到它们。
解决办法不是把目录清空,而是写.dockerignore。它的语法和.gitignore基本一致,用来声明哪些文件不要进入构建上下文。
4.2 .dockerignore 应该怎么写
我通常在项目根目录放这样一份:
.git __pycache__/ *.pyc .vscode/ .idea/ venv/ .venv/ node_modules/ dist/ *.log .DS_Store写完.dockerignore后,可以用下面这条命令检查上下文体积:
docker build --no-cache --progress=plain -t check-size .构建日志开头会显示本次传了多少字节的上下文。我见过不少项目因为缺少这个文件,每次构建要多传几百 MB 无关文件,构建速度肉眼可见地慢。
4.3 缓存命中的基本规则
构建过程中每一层都有自己的缓存键。只要指令文本没变,且COPY过来的源文件内容没变,这一层就能命中缓存;一旦某一层失效,它后面的所有层也会全部重做。我用一个小表格记录最常遇到的情况:
| 写着什么 | 什么变了会失效 | 对后续构建的影响 |
|---|---|---|
| FROM python:3.11-slim | 基础镜像 tag 变化 | 该层起全部重做 |
| RUN pip install -r ... | 指令文本或前置 COPY 内容变化 | 该层及后续重做 |
| COPY requirements.txt . | 文件内容变化 | 该层及后续重做 |
| COPY . . | 任意业务文件变化 | 该层及后续重做 |
| CMD ["python", "app.py"] | 指令文本变化 | 影响元信息层 |
所以,把容易变化的放后面,把不容易变化的放前面,不是洁癖,而是实打实的构建效率优化。如果项目需要多个环境分别构建,也可以用docker build -f Dockerfile.dev指定不同的 Dockerfile,但小项目一个 Dockerfile 通常就够用。
5. 多阶段构建与镜像瘦身:小体积镜像不是靠运气
前面例子适合应用开发阶段。到了“要交付”的时候,镜像体积常常成为绕不开的话题:基础镜像动辄几百 MB,编译工具链塞进去之后更大。多阶段构建是我最推荐的处理方式。
5.1 多阶段构建的原理
一个 Dockerfile 里可以出现多个FROM,每个FROM开启一个独立阶段。后面的阶段可以用COPY --from=阶段名把前一个阶段里的文件复制过来,而不需要继承前面阶段的全部指令。这样最终镜像只保留真正需要的东西。
比如一个编译型 Go 服务,可以这样写:
# 阶段一:负责构建 FROM golang:1.22-alpine AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o /out/app . # 阶段二:负责运行 FROM alpine:3.20 COPY --from=builder /out/app /usr/local/bin/app EXPOSE 8080 ENTRYPOINT ["app"]构建阶段会拉取依赖、编译二进制,包含完整的 Go 工具链;运行阶段只从一个干净的精简镜像启动,把编译产物复制进去。执行命令:
docker build -t my-go-demo:0.1 . docker run --rm -it my-go-demo:0.1最终镜像里没有源码、没有编译器、没有任何构建缓存,体积常常能从上 GB 降到几十 MB 甚至更小。这种效果靠启动容器后手动删文件是做不到的,因为每一层的文件都会被保留。
5.2 按“能跑”和“要交付”两套思路写
先按“能跑”写:一个基础镜像、装依赖、复制代码、启动命令,让流程先通起来。再按“要交付”写:把编译、安装这类重活放进前一阶段,把命令和运行产物放进后一阶段,最后检查镜像里是否有不该存在的文件。
如果项目不允许用多个FROM,或者基础镜像工具链比较复杂,至少要保证:默认构建不带调试器、不带包管理器缓存、不保留临时文件。这个习惯能避免很多无谓的体积膨胀。
下面是一张常见基础镜像选择思路对照表:
| 场景 | 推荐起点 | 理由 |
|---|---|---|
| Python 应用 | python:3.11-slim | 自带 pip,体积适中 |
| Node 应用 | node:20-alpine | 工具链相对完整 |
| 编译型应用 | golang:...-alpine 做 builder | 编译工具链隔离在构建阶段 |
| 极简运行 | 精简镜像加编译产物 | 体积最小,适合单二进制 |
需要注意的是,某些精简发行版用的是另一种 C 库,和常见发行版不完全一样。如果应用依赖特定 C 库,可能需要额外适配。我的习惯是先测试再换,而不是为了体积无脑换到最小基础镜像。
最后说一个我自己的习惯:写完 Dockerfile 先不做大优化,而是先让它能稳定构建、能跑通,再考虑体积和缓存顺序。镜像从“能用”到“好用”的差别,通常就藏在这些不起眼的细节里。把 Dockerfile 当作项目的一部分持续维护,它才会成为容器化路上最省心的那部分。