镜像是容器的基础,而 Dockerfile 是镜像的「源代码」。这篇先讲清楚 Dockerfile 是什么、为什么需要它、能干什么,再对比 docker build 和 docker commit 这两种产出镜像的方式。
一、Dockerfile 概述
1. 是什么
Dockerfile 是一个纯文本文件,里面按顺序写了一组指令,描述「如何从一个基础镜像,一步步构建出我们想要的镜像」。
docker build 读取 Dockerfile,逐条执行指令,每条指令产生一个只读的镜像层,最终叠加成一个完整镜像。
一个最小示例:
FROM python:3.11-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "main.py"]
2. 为什么需要
在没有 Dockerfile 之前,做一个自定义镜像通常是这样:
docker run启动一个基础镜像;- 在容器里手动装依赖、改配置、放代码;
docker commit把容器打成新镜像。
这种方式有几个明显问题:
- 不可复现:手敲的命令没记录,换台机器、换个人就装不出一样的环境;
- 不可审查:镜像里到底装了什么、改了什么,只能靠
docker history猜; - 不好维护:基础镜像升级、依赖换版本,得从头再来一遍;
- 不适合协作:没法走 Git 评审,也没法接 CI。
Dockerfile 把「构建过程」本身变成了代码,上述问题自然就解决了:写一次,到处构建,结果一致。
3. 能干什么
围绕「构建一个可运行的镜像」,Dockerfile 主要负责四件事:
- 指定基础环境:通过
FROM选择起点(如python:3.11-slim、nginx:1.25、alpine:3.20),决定系统、运行时和已有工具链; - 准备应用内容:
COPY/ADD放文件,RUN装依赖、编译代码,WORKDIR设工作目录,ENV设环境变量; - 声明运行时行为:
CMD/ENTRYPOINT指定启动命令,EXPOSE声明端口,USER指定运行用户,VOLUME声明挂载点; - 配合工程化流程:纳入 Git 走 Code Review、在 CI 里自动构建推送、用多阶段构建分离编译与运行环境。
二、docker build 与 docker commit
两者都能产出镜像,但思路完全不同。
1. docker commit:从容器快照出镜像
把一个正在运行或已停止的容器,连同它当前的文件系统状态,打包成一个新镜像。
用法:
docker commit [OPTIONS] <容器ID或名称> <镜像名>:<标签>
常用选项:
| 选项 | 含义 |
|---|---|
-a | 作者信息 |
-m | 提交说明 |
-c | 附加 Dockerfile 指令(如 CMD、ENV) |
示例:
# 启动一个基础容器,进去随便改点东西
docker run -it --name tmp ubuntu:22.04 bash
# 容器内:
apt update && apt install -y curl
exit
# 把这个容器打成新镜像
docker commit -a "author" -m "add curl" tmp my-ubuntu:with-curl
2. docker build:从 Dockerfile 构建镜像
按 Dockerfile 里写好的指令,逐条执行并分层缓存,最终生成镜像。
用法:
docker build [OPTIONS] <构建上下文路径>
常用选项:
| 选项 | 含义 |
|---|---|
-t | 给镜像打 名字:标签 |
-f | 指定 Dockerfile 路径(默认是上下文目录下的 Dockerfile) |
--no-cache | 不使用构建缓存 |
--build-arg | 传入 ARG 参数 |
示例:
# 在当前目录下,使用 ./Dockerfile 构建
docker build -t my-app:1.0 .
# 指定 Dockerfile 路径
docker build -t my-app:1.0 -f deploy/Dockerfile .
注意最后那个 .,它不是「当前目录」的随手一写,而是构建上下文——会被整个打包发给 Docker 引擎,COPY / ADD 只能引用上下文里的文件。
3. 两者怎么选
| 维度 | docker commit | docker build |
|---|---|---|
| 输入 | 一个容器的当前状态 | 一份 Dockerfile + 构建上下文 |
| 过程 | 直接快照,不可见 | 按指令逐层执行,过程可见 |
| 可复现 | 差,依赖人工操作 | 好,同一份 Dockerfile 结果一致 |
| 可审查 | 只能 docker history 猜 | Dockerfile 即文档 |
| 可维护 | 改动需重来一遍 | 改 Dockerfile 即可重建 |
| 适合协作 | 不适合 | 可纳入 Git、走 CI |
| 典型场景 | 临时调试、保存现场排查问题 | 生产镜像构建、CI/CD |
判断标准很简单:生产镜像一律用 docker build,docker commit 留给两类场景:
- 容器里调试到一半,想保存现场以便后续复现;
- 拿到一个跑着的容器,先
commit出来,再用docker history/ 进容器看内容,反推出一份 Dockerfile。
三、Dockerfile 常用指令
下面按一份 Dockerfile 里通常的书写顺序,逐个介绍常用指令。
1. FROM:指定基础镜像
每个 Dockerfile 必须以 FROM 开头(ARG 例外),指定起始镜像。
FROM python:3.11-slim
要点:
- 同一个 Dockerfile 可以有多个
FROM,对应多阶段构建; - 用具体版本(
3.11-slim)而不是latest,避免镜像漂移; scratch是一个特殊的空镜像,常用于 Go 等静态编译语言。
2. MAINTAINER:声明维护者(已废弃)
MAINTAINER author <author@example.com>
MAINTAINER 从 Docker 1.13 起被官方标记为 deprecated,仅作了解。新写的 Dockerfile 统一用 LABEL maintainer=... 代替。
3. LABEL:给镜像打标签
给镜像添加键值对元数据,常用于声明维护者、版本、来源等信息。可以通过 docker inspect 查看。
LABEL maintainer="author@example.com" \
version="1.0" \
description="Python demo app"
多个 LABEL 会各产生一层,建议合并写在一条里。
4. ENV:设置环境变量
构建期和运行期都生效。
ENV APP_HOME=/app \
PYTHONUNBUFFERED=1
容器启动后可以用 docker run -e KEY=VALUE 覆盖。
5. RUN:构建期执行命令
在镜像里跑一条命令,结果作为新的一层。两种写法:
# shell 形式:通过 /bin/sh -c 执行
RUN apt-get update && apt-get install -y curl
# exec 形式:直接 exec,不经过 shell
RUN ["apt-get", "install", "-y", "curl"]
要点:
- 多条命令尽量合成一条
RUN,用&&串起来,减少层数; - 装完包记得清缓存,比如
apt-get clean && rm -rf /var/lib/apt/lists/*; RUN是构建期执行,结果固化进镜像;想在容器启动时执行的,用CMD或ENTRYPOINT。
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
6. ADD 与 COPY:把文件放进镜像
两者都能把构建上下文里的文件复制进镜像,但语义不同:
| 指令 | 行为 |
|---|---|
COPY | 单纯复制构建上下文里的文件 |
ADD | 在 COPY 基础上,支持自动解压 tar、支持 URL 下载 |
COPY requirements.txt /app/
ADD app.tar.gz /app/ # 会自动解压到 /app/
默认建议用 COPY。ADD 的隐式行为容易踩坑(比如不想解压的 tar 被自动解压、URL 下载没有缓存校验),需要解压时显式 RUN tar 反而更清晰。
7. WORKDIR:设置工作目录
设置后续指令以及容器启动时的当前目录。目录不存在会自动创建。
WORKDIR /app
不要用 RUN cd /app 来代替,cd 只在那一条 RUN 里有效,下一条又回到根目录了。
8. VOLUME:声明匿名卷
VOLUME ["/data"]
声明一个挂载点,启动容器时如果没显式 -v,Docker 会自动创建匿名卷挂上去。常用于数据库、需要持久化的数据目录。
9. EXPOSE:声明端口
EXPOSE 8080
注意:EXPOSE 只是元数据声明,不会真的把端口发布到宿主机。要让外部能访问,还得在 docker run 时加 -p 8080:8080。
10. CMD 与 ENTRYPOINT:指定启动命令
这是两个最容易混的指令,区分清楚很重要。
CMD 提供容器启动时的默认命令,可以被 docker run 后面的参数完全覆盖:
CMD ["python", "main.py"]
docker run my-app # 实际执行:python main.py
docker run my-app python other.py # 实际执行:python other.py(CMD 被覆盖)
ENTRYPOINT 定义容器的入口程序,docker run 后面的参数会追加到它后面:
ENTRYPOINT ["python"]
CMD ["main.py"]
docker run my-app # 实际执行:python main.py
docker run my-app other.py # 实际执行:python other.py
docker run my-app -m pip list # 实际执行:python -m pip list
选用建议:
| 场景 | 推荐 |
|---|---|
| 镜像就是一个固定的应用,想让用户灵活传参 | ENTRYPOINT + CMD(默认参数) |
| 镜像更像一个工具箱,可执行命令不固定 | 只用 CMD |
容器要当成一个 CLI 用(如 docker run alpine ls) | 不写 ENTRYPOINT,留空 |
两者都优先使用 exec 形式(JSON 数组),shell 形式会多套一层 /bin/sh -c,导致信号无法直达进程。
11. ARG:构建期参数
只在构建期生效,不会留在最终镜像里。可以通过 docker build --build-arg 传入:
ARG APP_VERSION=1.0
RUN echo "building $APP_VERSION"
docker build --build-arg APP_VERSION=2.1 -t my-app .
ARG 和 ENV 的区别:
ARG | ENV | |
|---|---|---|
| 作用域 | 仅构建期 | 构建期 + 运行期 |
| 容器内是否可见 | 否 | 是 |
| 覆盖方式 | --build-arg | -e |
注意:ARG 是唯一可以出现在 FROM 之前的指令,常用于把基础镜像版本参数化:
ARG PYTHON_VERSION=3.11
FROM python:${PYTHON_VERSION}-slim
12. USER:切换运行用户
默认以 root 运行,生产镜像建议切到非特权用户,降低容器逃逸的风险。
RUN useradd -m -u 1000 app
USER app
USER 之后的 RUN、CMD、ENTRYPOINT 都以该用户身份执行。需要装包等 root 操作,要放在 USER 之前。
13. ONBUILD:延迟到下游构建时执行
把一些指令"埋"进当前镜像,当别人 FROM 这个镜像构建新镜像时,这些指令才会触发。
# 在基础镜像 my-base 里:
FROM python:3.11-slim
ONBUILD COPY . /app
ONBUILD RUN pip install -r /app/requirements.txt
下游使用:
# 下游 Dockerfile 只要一行:
FROM my-base
# 构建时会自动触发上面两条 ONBUILD 指令
适合做"应用模板镜像"。但 ONBUILD 是隐式行为,下游用户看不到实际跑了什么,用得不多,了解即可。
14. STOPSIGNAL:自定义停止信号
docker stop 默认给容器主进程发 SIGTERM,等待 10 秒还没退出再发 SIGKILL。如果应用监听的是别的信号,可以用 STOPSIGNAL 改:
STOPSIGNAL SIGQUIT
常见场景:Nginx 用 SIGQUIT 表示"优雅退出(处理完现有请求再关)",而 SIGTERM 是"立刻关"。
15. HEALTHCHECK:健康检查
声明一条命令,Docker 会周期性执行来判断容器是否健康。docker ps 的 STATUS 列会显示 healthy / unhealthy。
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1
参数:
| 参数 | 含义 | 默认 |
|---|---|---|
--interval | 两次检查间隔 | 30s |
--timeout | 单次检查超时 | 30s |
--start-period | 启动宽限期,期间失败不计入 | 0s |
--retries | 连续失败多少次判定为 unhealthy | 3 |
命令的退出码决定结果:0 健康,1 不健康。
注意:在 Kubernetes 环境下,HEALTHCHECK 通常被 livenessProbe / readinessProbe 取代,K8s 不会用镜像里声明的健康检查。所以这条指令更多用于单机 Docker 或 docker-compose 场景。
16. 一份完整示例
把上面这些指令组合起来,看一个真实的 Python 应用 Dockerfile:
ARG PYTHON_VERSION=3.11
FROM python:${PYTHON_VERSION}-slim
LABEL maintainer="author@example.com" \
version="1.0"
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
# 先拷依赖文件,利用缓存
COPY requirements.txt .
RUN pip install -r requirements.txt
# 再拷代码(代码改动不会让上一层缓存失效)
COPY . .
# 切到非 root 用户
RUN useradd -m -u 1000 app
USER app
VOLUME ["/app/data"]
EXPOSE 8000
STOPSIGNAL SIGTERM
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1
ENTRYPOINT ["python"]
CMD ["main.py"]
这里有一个常见的优化技巧:把变化频率低的指令放前面,变化频率高的放后面。requirements.txt 不常变,先 COPY 进去装好依赖;代码改动频繁,放最后,这样改代码不会让前面的依赖层缓存失效,重新构建会快很多。
四、加快镜像构建
镜像构建慢,通常不是 Docker 本身慢,而是缓存没用好或做了不必要的事。下面按"投入产出比"从高到低介绍几种优化手段。
1. 理解构建缓存:失效规则
docker build 是逐层缓存的:每条指令算一层,命中缓存就直接复用,不会重新执行。但缓存有失效规则:
- 当前指令的文本变了 → 这一层及后面所有层都失效;
COPY/ADD的源文件内容变了 → 这一层及后面所有层都失效;- 基础镜像(
FROM)变了 → 全部失效。
关键点是**“及后面所有层都失效”**——所以指令的顺序至关重要。
2. 调整指令顺序:稳定的放前面
最常见的优化,先拷依赖描述文件、装依赖,再拷源码:
反例(每次改代码都要重装依赖):
COPY . /app
RUN pip install -r /app/requirements.txt
正例:
COPY requirements.txt /app/
RUN pip install -r /app/requirements.txt
COPY . /app
requirements.txt 不变时,前两层都命中缓存,只有最后 COPY . /app 那一层会重跑,速度差几十倍。
同样的思路适用于 Node 的 package.json + package-lock.json、Go 的 go.mod + go.sum、Maven 的 pom.xml。
3. 用 .dockerignore 缩小上下文
docker build . 会把整个上下文目录打包发给 Docker 引擎。如果项目里有 .git、node_modules、__pycache__、日志文件,会让上下文又大又慢,还可能让 COPY . . 的缓存被无关文件影响。
在项目根目录建一个 .dockerignore:
.git
.gitignore
node_modules
__pycache__
*.pyc
*.log
.env
.idea
.vscode
build/
dist/
写法和 .gitignore 一致。这是最容易被忽略但收益最高的优化之一。
4. 合并 RUN,减少层数和体积
每条 RUN 都会产生一层,且前一层已经写入的内容,后一层删不掉(只是被遮蔽,体积仍在镜像里)。
反例:
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean
RUN rm -rf /var/lib/apt/lists/*
正例:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
合并后只有一层,且清理操作和安装在同一层,缓存目录不会"残留"在镜像里。
5. 选更小的基础镜像
基础镜像直接决定了最终镜像的大小下限。常见三档:
| 基础镜像 | 体积 | 说明 |
|---|---|---|
python:3.11 | ~1GB | Debian 完整版,工具齐全 |
python:3.11-slim | ~150MB | Debian 精简版,默认选这个 |
python:3.11-alpine | ~50MB | Alpine 系统,最小但 glibc 替换为 musl,部分 wheel 包不兼容 |
镜像越小,构建快、推送快、拉取快,全链路都受益。但 alpine 在 Python 场景容易踩坑(编译慢、包装不上),Go 等静态语言用 alpine 没问题。
6. 多阶段构建:把编译产物剥离出来
很多语言在构建期需要编译器、构建工具,运行期完全用不上。多阶段构建可以只把产物搬到最终镜像,把臃肿的编译环境扔掉。
Go 示例:
# ===== 阶段 1:编译 =====
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app
# ===== 阶段 2:运行 =====
FROM alpine:3.20
COPY --from=builder /out/app /app
ENTRYPOINT ["/app"]
最终镜像只有几 MB,没有 Go 工具链。COPY --from=<阶段名> 是关键,可以从前面任意一个 FROM 阶段(甚至外部镜像)拷文件。
7. 使用 BuildKit 与缓存挂载
Docker 18.09+ 引入了 BuildKit 构建器,相比传统构建器更快、更并行、支持更多缓存特性。新版 Docker Desktop 默认开启;命令行可以这么开:
DOCKER_BUILDKIT=1 docker build -t my-app .
或永久开启(写入 /etc/docker/daemon.json):
{ "features": { "buildkit": true } }
BuildKit 最实用的特性是 RUN --mount=type=cache,可以把包管理器的缓存目录挂出来,跨多次构建复用:
# syntax=docker/dockerfile:1.4
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
这样即使依赖列表变了,pip 也不用重新下载所有包,从本地缓存装即可。Node(/root/.npm)、Go(/go/pkg/mod)、apt(/var/cache/apt)同理。
第一行 # syntax=docker/dockerfile:1.4 是启用新语法所必需的注释。
8. CI 环境下复用缓存
本地构建有缓存目录,CI 里每次都是新机器,缓存全失效,构建会很慢。两种常见方案:
--cache-from:从一个已存在的镜像导入缓存。CI 里先docker pull上次成功的镜像,再docker build --cache-from=...;- BuildKit 的远程缓存:用
--cache-to=type=registry,ref=...把缓存推到镜像仓库,下次构建用--cache-from=type=registry,ref=...拉回来。
docker buildx build \
--cache-from=type=registry,ref=registry.example.com/my-app:cache \
--cache-to=type=registry,ref=registry.example.com/my-app:cache,mode=max \
-t my-app:latest \
--push .
9. 优化清单
按收益从高到低,按需采用:
- 加
.dockerignore; - 调整指令顺序,依赖描述文件先拷;
- 合并
RUN,安装和清理放同一层; - 选
slim或 alpine 基础镜像; - 多阶段构建,剥离编译产物;
- 开 BuildKit,用
--mount=type=cache复用包管理器缓存; - CI 里用
--cache-from跨流水线复用缓存。
