用“蜜雪冰城 SOP”的方式理解 Dockerfile:镜像制作说明书
摘要:Dockerfile 是构建镜像的“自动化说明书”,镜像如同“类”,容器如同“实例”。本文从镜像与容器的本质区别出发,拆解 FROM、WORKDIR、COPY、RUN、CMD 等核心指令,深入分层缓存优化原理,帮你彻底掌握 Docker 镜像制作的标准化流程。
📑 目录
- 为什么需要一份“自动化说明书”?
- Dockerfile 是什么?—— 镜像的“制作配方”
- 镜像 vs 容器:类与实例的关系
- Dockerfile 构建流程:从配方到成品
- 核心指令详解:每一行都在做什么
- 分层缓存:Dockerfile 性能优化的第一法则
- 配套文件:.dockerignore
- 完整命令闭环:构建与运行
- 完整示例:Node.js 项目的 Dockerfile
- 互动讨论
为什么需要一份“自动化说明书”?
在开发中,你经常需要在不同机器上运行同一个 Node.js 项目。每次手动安装 Node、复制代码、npm install、配置环境——步骤繁琐且容易出错。
如果有一个标准化的操作手册,写清“先做什么、再做什么、最后做什么”,任何人照着做,出来的结果都一样——就像蜜雪冰城的 SOP(标准操作手册):写清“先加茶、再加奶、放 3 勺糖、摇匀”,任何店员照着做,出来的味道都一样。
Dockerfile 就是这样一份“自动化说明书”。 它把安装环境、复制代码、安装依赖、配置启动命令等一系列操作写成文本指令,Docker 引擎照着执行,就能在任何机器上生成一模一样的运行环境。
Dockerfile 是什么?—— 镜像的“制作配方”
Dockerfile 是“制作镜像的说明书”,而不是“直接创建容器的指令集”。它定义的是如何构建一个静态的、可复用的镜像文件。
一份最简单的 Dockerfile 长这样:
dockerfile
# 1. 先选择一个基础镜像
FROM node
# 2. 在容器中切换到 /app 文件夹,设置工作目录
WORKDIR /app
# 3. 把本地代码复制进容器
COPY index.js .
# 4. 容器启动时执行的命令
CMD ["node", "index.js"]
这个配方告诉 Docker:
- 从 Node.js 官方镜像开始
- 在镜像内部创建
/app目录并切换进去 - 把本地的
index.js复制到镜像的/app目录下 - 容器启动时自动执行
node index.js
一句话本质:Dockerfile 是“制作镜像的说明书”。它把“怎么造一个镜像”的步骤写清楚,Docker 照着做就能造出一个一模一样的镜像。
镜像 vs 容器:类与实例的关系
在写 Dockerfile 之前,必须先建立一个核心认知:
| 概念 | 类比 | 说明 |
|---|---|---|
| 镜像(Image) | 类(Class) | 静态的、只读的模板,躺在硬盘上不运行 |
| 容器(Container) | 实例(Instance) | 基于镜像启动的活体进程,占用 CPU 和内存 |
镜像本身不能运行,必须通过 docker run 实例化为容器才能启动服务。
就像你写了一个 class User {},它只是一个模板;必须 new User() 才能创建一个真实的对象去干活。
技术链条:
text
Dockerfile → docker build → 镜像(Image) → docker run → 容器(Container)
Dockerfile 构建流程:从配方到成品
当你执行 docker build -t my-app . 时,Docker 引擎按以下流程工作:
text
1. 读取 Dockerfile 中的指令
2. 每一条指令生成一个“层(Layer)”
3. 所有层叠加起来,形成一个只读的镜像文件系统
4. 镜像被保存到本地,可以复用、分发、上传到仓库
每一层都是只读的,叠加之后形成完整的文件系统。这种分层设计让镜像可以复用——如果你改了最后一层,前面的层直接从缓存读取,不需要重新构建。
核心指令详解:每一行都在做什么
FROM:选择地基
dockerfile
FROM node:18-alpine
- 作用:指定基础镜像。你不能从零造一个操作系统,必须站在巨人的肩膀上。
- 为什么用
alpine:Alpine 是极致精简的 Linux,只有几 MB,生产环境首选。如果你用node:18(完整版),镜像体积可能超过 900MB;用node:18-alpine,只有 100MB 出头。
关键认知:
FROM决定了你的容器里跑的是什么操作系统和预装环境。
WORKDIR:设置工作目录
dockerfile
WORKDIR /app
- 作用:设置镜像内部的“当前工作目录”,相当于
cd /app。之后所有COPY、RUN、CMD都在此目录下执行。 - 关键点:
WORKDIR设置的是镜像内部的路径,与宿主机无关。如果目录不存在,Docker 会自动创建。
常见误区:
WORKDIR不是选择宿主机的目录,而是选择镜像内部的目录。Dockerfile 中除了COPY的源路径(左边),所有路径都指向镜像内部。
COPY:搬运材料
dockerfile
COPY package*.json ./
COPY . .
-
作用:把宿主机上的文件复制进镜像内部。
-
路径规则:
- 源路径(左边):宿主机,相对于构建上下文(
docker build命令中的.) - 目标路径(右边):镜像内部,相对于
WORKDIR
- 源路径(左边):宿主机,相对于构建上下文(
物理数据流:
text
宿主机文件 → COPY → 镜像内部文件系统
RUN:构建时执行(造镜像)
dockerfile
RUN npm install
- 执行时机:
docker build阶段(造镜像时) - 作用:在镜像内部执行命令,安装依赖、编译代码等。执行结果会固化进镜像文件系统,成为镜像的一层。
- 与
CMD的本质区别:RUN是“造镜像时执行”,结果被永久保存;CMD是“容器启动时执行”,不改变镜像。
关键认知:
RUN负责“造车时安装零件”,CMD负责“开车时点火的默认动作”。
EXPOSE:声明端口(文档说明)
dockerfile
EXPOSE 1314
- 作用:只做文档说明,告诉运维人员“这个容器打算用 1314 端口”。它不会实际打开端口,真正的端口映射由
docker run -p完成。 - 类比:就像产品说明书上写“本产品使用 220V 电压”——它只是告诉你需要什么,而不是真的给你通电。
CMD:运行时执行(启动容器)
dockerfile
CMD ["node", "index.js"]
-
执行时机:
docker run阶段(启动容器时) -
作用:定义容器启动后默认执行的命令。用户可以通过
docker run my-image node other.js覆盖它。 -
与
RUN的区别:RUN:构建时执行,结果固化进镜像CMD:运行时执行,只是“默认值”,不改变镜像
关键认知:
CMD就像说明书最后一句“开机后自动运行 XXX”——它只是默认行为,用户随时可以覆盖。
指令对比速查
| 指令 | 执行阶段 | 是否改变镜像 | 能否被覆盖 |
|---|---|---|---|
RUN | docker build(构建时) | ✅ 是,结果固化进镜像 | ❌ 不能 |
CMD | docker run(运行时) | ❌ 否,只是默认命令 | ✅ 可以被 docker run 覆盖 |
分层缓存:Dockerfile 性能优化的第一法则
Docker 的每一行指令都会生成一个缓存层(Layer) 。当某一层发生变化时,Docker 会从那一层开始重新构建,之前的层直接复用缓存。
为什么 COPY 要分两步?
dockerfile
COPY package*.json ./ # 第一步:先复制依赖清单
RUN npm install # 安装依赖(这一步会被缓存)
COPY . . # 第二步:再复制源码(放最后)
把 COPY package.json 和 RUN npm install 放在源码复制之前,是为了把“重操作”和“频繁变动的代码”解耦:
| 场景 | 结果 |
|---|---|
只改了 index.js,package.json 没变 | npm install 层直接复用缓存(省去几十秒) |
改了 package.json | npm install 重新执行(必须的) |
核心原则:把不变的内容放前面,易变的放后面。package.json 通常比源码更稳定,所以先复制它并安装依赖。
分层缓存机制可以理解为一个栈:检查哪些层变了,然后把改变后面的部分全部重新来一遍。把不变的内容放前面,易变的放后面,是 Dockerfile 优化的第一法则。
常见错误写法
dockerfile
# ❌ 错误:先复制所有源码,再安装依赖
COPY . .
RUN npm install
这种写法下,任何代码改动都会导致 npm install 重新执行——每次改一行代码,就要等几十秒重新安装依赖。
dockerfile
# ✅ 正确:先复制依赖清单,安装依赖,最后复制源码
COPY package*.json ./
RUN npm install
COPY . .
这种写法下,只有 package.json 变化时才会重新执行 npm install。
配套文件:.dockerignore
dockerignore
node_modules
.git
.env
*.log
- 作用:告诉 Docker 在
COPY . .时跳过这些文件和目录。 - 为什么重要:避免把本地
node_modules(几百 MB)打包进镜像,导致镜像体积暴涨。也避免把.env等敏感文件泄露进镜像。
完整命令闭环:构建与运行
docker build(造镜像)
bash
docker build -t my-app:v1 .
| 参数 | 含义 |
|---|---|
-t my-app:v1 | 给镜像起名(my-app 是名字,v1 是标签) |
. | 构建上下文路径,告诉 Docker 去当前目录找 Dockerfile |
构建上下文(Build Context) :docker build 命令中的 . 指定了“哪些文件可以发给 Docker 引擎”。Docker 会把当前目录下的文件(除了 .dockerignore 中排除的)打包发送给 Docker 守护进程作为构建材料。
docker run(跑容器)
bash
docker run -d --name my-app-container -p 1314:1314 my-app:v1
| 参数 | 含义 |
|---|---|
-d | 后台运行 |
--name my-app-container | 给容器起名 |
-p 1314:1314 | 端口映射(宿主机 1314 → 容器 1314) |
完整示例:Node.js 项目的 Dockerfile
dockerfile
# 1. 选地基:使用精简的 Node.js 18 Alpine 镜像
FROM node:18-alpine
# 2. 进车间:设置镜像内部的工作目录
WORKDIR /app
# 3. 先搬清单(不变的内容,利用缓存)
COPY package*.json ./
# 4. 装依赖(重操作,被缓存)
RUN npm install
# 5. 再搬源码(常变的内容,放最后)
COPY . .
# 6. 贴标签(文档说明,告诉运维这个容器用 1314 端口)
EXPOSE 1314
# 7. 开机启动(容器启动时默认执行)
CMD ["node", "index.js"]
构建与运行
bash
# 构建镜像
docker build -t my-node-app:v1 .
# 运行容器
docker run -d --name my-app -p 1314:1314 my-node-app:v1
互动讨论
💬 镜像和容器的区别到底是什么?
镜像是静态的、只读的模板(类比:类),躺在硬盘上不运行。容器是镜像的运行实例(类比:实例),是真正的活体进程。镜像不能直接运行,必须通过 docker run 实例化为容器。
💬 RUN 和 CMD 的区别是什么?
RUN 在 docker build 阶段执行,结果固化进镜像(类比:造车时装零件)。CMD 在 docker run 阶段执行,只是默认启动命令(类比:开车时点火的默认动作)。CMD 可以被 docker run 覆盖,RUN 不能。
💬 COPY 为什么要分两步?
利用分层缓存机制。先复制 package.json 并执行 npm install,这一层会被缓存。后续只改源码不改依赖时,npm install 直接复用缓存,避免每次重新安装几十秒。
💬 WORKDIR 是在宿主机还是镜像内部?
镜像内部。Dockerfile 中除了 COPY 的源路径(左边),所有路径都指向镜像内部的文件系统。
💬 EXPOSE 和 -p 是什么关系?
EXPOSE 是文档声明,告诉运维“这个容器打算用这个端口”,不实际打开端口。-p 是真正的端口映射,在 docker run 时由用户指定。