用“蜜雪冰城 SOP”的方式理解 Dockerfile:镜像制作说明书

51 阅读10分钟

用“蜜雪冰城 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:

  1. 从 Node.js 官方镜像开始
  2. 在镜像内部创建 /app 目录并切换进去
  3. 把本地的 index.js 复制到镜像的 /app 目录下
  4. 容器启动时自动执行 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。之后所有 COPYRUNCMD 都在此目录下执行。
  • 关键点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”——它只是默认行为,用户随时可以覆盖。

指令对比速查

指令执行阶段是否改变镜像能否被覆盖
RUNdocker build(构建时)✅ 是,结果固化进镜像❌ 不能
CMDdocker run(运行时)❌ 否,只是默认命令✅ 可以被 docker run 覆盖

分层缓存:Dockerfile 性能优化的第一法则

Docker 的每一行指令都会生成一个缓存层(Layer) 。当某一层发生变化时,Docker 会从那一层开始重新构建,之前的层直接复用缓存。

为什么 COPY 要分两步?

dockerfile

COPY package*.json ./   # 第一步:先复制依赖清单
RUN npm install          # 安装依赖(这一步会被缓存)
COPY . .                # 第二步:再复制源码(放最后)

把 COPY package.json 和 RUN npm install 放在源码复制之前,是为了把“重操作”和“频繁变动的代码”解耦:

场景结果
只改了 index.jspackage.json 没变npm install 层直接复用缓存(省去几十秒)
改了 package.jsonnpm 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 时由用户指定。