Dockerfile 编写指南:从看懂到会用
约 6509 字大约 22 分钟
2026-07-10
一句话理解
Dockerfile 就是镜像的"配方"——每一条指令在最终镜像里留下一层"痕迹"(一个只读层)。写好 Dockerfile 的核心就两条:合并能合并的指令(减少层数),把不变的放前面(利用缓存)。
如果你已经读过 Docker 文件系统揭秘,就知道每一条
RUN都会生成一个新的只读层。Dockerfile 的编写技巧本质上就是在跟这个"层"的机制打交道。
一、动手实验:构建一个镜像,逐层拆解
下面是一个完全可复现的例子。在你的 Linux 机器上从头跑到尾,你能亲眼看到每层是怎么生成的。
Step 1:准备项目文件
# 创建项目目录
mkdir -p /tmp/dockerfile-lab/src
cd /tmp/dockerfile-lab
# 创建一个简单的"应用"——只是个 shell 脚本
cat > src/app.sh << 'EOF'
#!/bin/sh
echo "Hello from Docker!"
echo "App version: ${APP_VERSION:-unknown}"
echo "Running as user: $(whoami)"
echo "Working directory: $(pwd)"
# 模拟一个常驻进程
while true; do sleep 3600; done
EOF
chmod +x src/app.sh
# 确认文件结构
find . -type f
# .
# └── src/
# └── app.shStep 2:编写 Dockerfile
cat > Dockerfile << 'EOF'
# ===== Dockerfile 逐层拆解实验 =====
FROM alpine:3.19
# ↑ FROM 本身不生成层(基础镜像 alpine 已有 1 层,docker history 能看到)
# ARG:构建参数,只在 build 过程中可用
ARG APP_VERSION=1.0.0
# LABEL:镜像元数据(不生成文件系统层)
LABEL maintainer="[email protected]" \
version="${APP_VERSION}"
# ENV:环境变量(不生成文件系统层,但会留在镜像 JSON 里)
ENV APP_VERSION=${APP_VERSION} \
PORT=8080
# WORKDIR:在镜像内创建目录并设为工作目录(会生成一个约 8KB 的小层,存目录 inode 元数据)
WORKDIR /app
# COPY:拷入应用代码 —— 第 1 个文件系统层
COPY src/ ./src/
# RUN:给脚本加执行权限 —— 第 2 个文件系统层
RUN chmod +x src/app.sh
# EXPOSE:声明端口(不生成文件系统层)
EXPOSE 8080
# USER:创建一个非 root 用户然后切换 —— 第 4 个文件系统层(RUN)+ 元数据(USER)
RUN adduser -D appuser
USER appuser
# ENTRYPOINT:容器启动时执行的程序(不生成文件系统层)
ENTRYPOINT ["/app/src/app.sh"]
EOFStep 3:构建镜像,观察每层生成
docker build -t layer-lab .
# 输出示例(Docker 29.6.0, containerd snapshotter 模式):
# [+] Building 3.1s (9/9) FINISHED
# => [1/5] FROM docker.io/library/alpine:3.19@sha256:6baf4358...
# => [2/5] WORKDIR /app
# => [3/5] COPY src/ ./src/ ← 第 1 层
# => [4/5] RUN chmod +x src/app.sh ← 第 2 层
# => [5/5] RUN adduser -D appuser ← 第 3 层为什么是 [1/5] ~ [5/5]? BuildKit 把 Dockerfile 中会改变文件系统的指令(WORKDIR、COPY、RUN×2)编号为构建步骤。FROM 是第 1 步,剩下 4 步对应 4 个文件系统层。
Step 4:用 docker history 验证——哪些指令有文件大小,哪些是 0B
docker history layer-lab
# IMAGE CREATED CREATED BY SIZE
# 7a1c4b327582 2 minutes ago ENTRYPOINT ["/app/src/app.sh"] 0B ← 元数据
# <missing> 2 minutes ago USER appuser 0B ← 元数据
# <missing> 2 minutes ago RUN |1 APP_VERSION=1.0.0 /bin/sh -c adduser … 41kB ← 第 5 层!(RUN)
# <missing> 2 minutes ago EXPOSE [8080/tcp] 0B ← 元数据
# <missing> 2 minutes ago RUN |1 APP_VERSION=1.0.0 /bin/sh -c chmod +x… 4.1kB ← 第 4 层!(RUN)
# <missing> 2 minutes ago COPY src/ ./src/ # buildkit 16.4kB← 第 3 层!(COPY)
# <missing> 2 minutes ago WORKDIR /app 8.19kB← 第 2 层!(目录元数据)
# <missing> 2 minutes ago ENV APP_VERSION=1.0.0 PORT=8080 0B ← 元数据
# <missing> 2 minutes ago LABEL [email protected] version=1.0.0 0B ← 元数据
# <missing> 2 minutes ago ARG APP_VERSION=1.0.0 0B ← 元数据
# <missing> 9 months ago CMD ["/bin/sh"] 0B ← 元数据
# <missing> 9 months ago ADD alpine-minirootfs-3.19.9-x86_64.tar.gz /… 8.08MB← alpine 基础层
#
# 关键观察:
# SIZE > 0B 的行 → 文件系统层(RUN、COPY、WORKDIR 各占一个小层)
# SIZE = 0B 的行 → 纯元数据指令(ARG, LABEL, ENV, EXPOSE, USER, ENTRYPOINT, CMD...)
# CREATED BY 中的 "|1 APP_VERSION=1.0.0" 是 BuildKit 把 ARG 值内联进了 RUN 指令Step 5:用 docker inspect 看完整元数据
docker inspect layer-lab[
{
"Id": "sha256:7a1c4b32758245dfc80ebcea0dc6a978028719da4c0923bab611a9c0376cd561",
"RepoTags": ["layer-lab:latest"],
"Comment": "buildkit.dockerfile.v0",
"Config": {
"User": "appuser", // ← USER 写在这里
"ExposedPorts": { "8080/tcp": {} }, // ← EXPOSE 写在这里
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"APP_VERSION=1.0.0", // ← ENV 写在这里
"PORT=8080"
],
"Entrypoint": [ "/app/src/app.sh" ], // ← ENTRYPOINT 写在这里
"WorkingDir": "/app", // ← WORKDIR 写在这里
"Labels": {
"maintainer": "[email protected]", // ← LABEL 写在这里
"version": "1.0.0"
}
// 注意:Config 里没有 ARG(构建完就丢了)
// 注意:Config 里没有 HEALTHCHECK(这次去掉了)
// 注意:Config 里没有 Cmd(被 ENTRYPOINT 替代)
},
"RootFS": {
"Type": "layers",
"Layers": [ // ← 文件系统层 ID 链
"sha256:0b44b2151d78267ab6f2c76208c3be18688f49b2b0afd6852a9533f2cce121c5", // alpine 基础层
"sha256:a533cc96bb5ee32feecc5dc29e4e29340054346534aa2030f7b22b80fd0a292a", // WORKDIR /app (8.19kB)
"sha256:773eeaabf70ad200638ca1663c2086a83c2f447d299a76157c06a2169d1beeed", // COPY src/ ... (16.4kB)
"sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef", // RUN chmod ... (4.1kB)
"sha256:8121549a86f0c96e911eec33b6b428a800f58ae98a8a9515fe39330b655377d9" // RUN adduser ... (41kB)
]
}
}
]关键对比:
RootFS.Layers(5 层)存文件,Config存配置——两者互不重叠,没有一条指令会同时在两边出现。
Step 6:运行容器验证
docker run --rm layer-lab
# Hello from Docker!
# App version: 1.0.0
# Running as user: appuser
# Working directory: /appStep 7:总结——数层
这个 Dockerfile 共 14 条指令:
生成文件系统层的(5 条):
├── WORKDIR /app → 第 2 层(8.19kB,目录 inode 元数据)
├── COPY src/ ... → 第 3 层(16.4kB)
├── RUN chmod ... → 第 4 层(4.1kB)
└── RUN adduser ... → 第 5 层(41kB)
(+ alpine 基础层 1 层)
只写元数据的(10 条):
├── ARG, LABEL, ENV, EXPOSE, USER, ENTRYPOINT, CMD
└── 全部 SIZE = 0B,数据存在镜像 JSON 配置里
最终镜像 = 1(基础)+ 4(WORKDIR/COPY/RUN)= 5 层
⚠️ 注意:WORKDIR 不是纯元数据!它会在磁盘上生成约 8KB 的小层。核心收获:
docker history的 SIZE 列是区分文件系统层和元数据的终极标准。大于 0B 的才占磁盘空间,等于 0B 的只是镜像配置里的一行 JSON。
Step 8:每一层在磁盘上到底存在哪里?
RootFS.Layers 里的每个 sha256 都对应磁盘上一个真实的目录。有两种方式可以看到:
方式一:docker save 导出镜像,直接看层的 tar 包
# 把镜像导出为 tar,每一层是一个独立的 tar 文件
docker save layer-lab -o /tmp/layer-lab.tar
tar -tf /tmp/layer-lab.tar
# blobs/
# sha256/
# 17a39c0ba978... ← alpine 基础层(tar 包,里面是 bin/ etc/ lib/ ...)
# 6c9f82e228dc... ← WORKDIR /app(tar 包,里面是 app/ 目录)
# 8fe2d3a5e83b... ← COPY src/ ...(tar 包,里面是 src/app.sh)
# 4f4fb700ef54... ← RUN chmod ...(tar 包,只有 app.sh 的权限变更)
# 2037c0fe711a... ← RUN adduser ...(tar 包,/etc/passwd 等系统文件变更)
# ec32fca1c255... ← 镜像 Config JSON
# 7a1c4b327582... ← 镜像 Manifest
# ...(还有 manifest list、attestation 等)
# index.json
# manifest.json
# oci-layout
# ⚠️ 注意:docker save 导出的是 OCI 格式,blobs 的 sha256 是压缩后内容的哈希,
# 和 docker inspect 中 RootFS.Layers 的 sha256(未压缩内容的哈希 / diff_id)不是同一个值!
# 两者的对应关系记录在 manifest.json 和 Config JSON 中。
# 查看 manifest.json,找到 blobs 和层的对应关系:
tar -xf /tmp/layer-lab.tar manifest.json -O | python3 -m json.tool
# [{
# "Config": "blobs/sha256/ec32fca1c255...",
# "Layers": [
# "blobs/sha256/17a39c0ba978...", ← 层 1:alpine 基础
# "blobs/sha256/6c9f82e228dc...", ← 层 2:WORKDIR
# "blobs/sha256/8fe2d3a5e83b...", ← 层 3:COPY
# "blobs/sha256/4f4fb700ef54...", ← 层 4:RUN chmod
# "blobs/sha256/2037c0fe711a..." ← 层 5:RUN adduser
# ]
# }]
# 查看某一层的内容(以 COPY 层为例):
tar -tzf /tmp/layer-lab.tar blobs/sha256/8fe2d3a5e83bb8b166f361f530033da7e542d700cf230455cfbbd62874631ace
# src/
# src/app.sh ← 这就是 COPY src/ ./src/ 这一层的内容!方式二:在宿主机上看 containerd 如何管理这些层(以本实验环境为例)
本实验环境:Ubuntu 22.04 + Docker 29.6.0,使用 containerd snapshotter 模式(而非传统的 overlay2 模式)。层的实际文件在 /var/lib/containerd/ 下。
⚠️ 重要:在 containerd snapshotter 模式下,
ctr snapshot ls显示的 KEY 与docker inspect中RootFS.Layers的 sha256 不是同一个值。
RootFS.Layers的 sha256 = diff_id(未压缩内容的哈希)ctr snapshot ls的 KEY = 压缩后 blob 的内容哈希(与docker save导出的 blobs sha256 相同)
# Step 1:查看 snapshot 列表(KEY 是压缩内容的哈希,PARENT 表示父子关系)
ctr -n moby snapshot ls | grep -E "^sha256:" | head -10
# sha256:17a39c0ba978... sha256:... Committed ← alpine 基础层
# sha256:6c9f82e228dc... sha256:17a39c... Committed ← WORKDIR,父层是 alpine
# sha256:8fe2d3a5e83b... sha256:6c9f82e... Committed ← COPY,父层是 WORKDIR
# sha256:4f4fb700ef54... sha256:8fe2d3a... Committed ← RUN chmod
# sha256:2037c0fe711a... sha256:4f4fb7... Committed ← RUN adduser
# ...
# ↑ KEY = 压缩层内容的 sha256(≠ RootFS.Layers 的 sha256)
# ↑ PARENT = 这一层叠在谁上面(形成链式结构)
# Step 2:用 ctr 查看某一层的详细信息(以 COPY 层为例)
ctr -n moby snapshot info sha256:8fe2d3a5e83bb8b166f361f530033da7e542d700cf230455cfbbd62874631ace
# {
# "Kind": "Committed",
# "Name": "sha256:8fe2d3a5e83bb8b166f361f530033da7e542d700cf230455cfbbd62874631ace",
# "Parent": "sha256:6c9f82e228dcfdc2dadcbada0530e2e51b86fdd51366a6e21d7c6949c28edc56"
# }
# ↑ 确认 COPY 层的 PARENT 是 WORKDIR 层
# Step 3:sha256 和磁盘目录编号的映射——藏在 metadata.db 里
# containerd snapshotter 用独立的 BoltDB 存这个映射。
# 方式 A:装个 boltdb 浏览器(推荐,一次性)
go install github.com/br0xen/boltbrowser@latest
~/go/bin/boltbrowser /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/metadata.db
# 进入 TUI 界面后:
# → 选 "snapshots" bucket
# → 左边是数字 ID(如 "1", "2", "3"...)
# → 右边就是对应的 sha256 值(ctr snapshot ls 的 KEY)
# → 数字 ID = snapshots/ 下的目录名
# 例如:ID "3" → sha256:8fe2d3a5... ← 这就是 COPY 层对应的目录!
# 方式 B:没有 go 环境?用 xxd + grep 暴力搜(不用装任何东西)
xxd /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/metadata.db | grep -A2 -B2 "8fe2d3a5"
# 能看到 sha256 出现在某个数字 ID 附近,那个数字就是目录名
# 知道数字 ID 后,直接去看文件:
ls /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/3/fs/
# src/
# └── app.sh ← COPY 层的内容,sha256:8fe2d3a5... = 编号 3两个 sha256 的关系:
docker inspect中的RootFS.Layerssha256 是未压缩内容的哈希(diff_id),ctr snapshot ls的 KEY 是压缩后内容的哈希。docker save导出的 blobs 用的是压缩后的哈希,所以和ctr snapshot ls的 KEY 一致。日常使用时,用docker inspect的RootFS.Layers来标识层即可。
2.1 docker pull 原理:下载的也是层文件
"拉镜像"听起来很玄乎,其实就是把别人 build 好的层(tar 包)下载到本地的 snapshots 目录。跟你 docker build 产出的层存在同一个地方。
# 拉一个没缓存的小镜像,观察它做了什么
docker pull alpine:3.19
# 输出(首次拉取时):
# 3.19: Pulling from library/alpine
# 0b44b2151d78: Pull complete ← 下载了一个层
# Digest: sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
# Status: Downloaded newer image for alpine:3.19
# 拉完看看——这个层存在哪?
docker inspect alpine:3.19 | jq '.[0].RootFS.Layers'
# [
# "sha256:0b44b2151d78267ab6f2c76208c3be18688f49b2b0afd6852a9533f2cce121c5" ← 只有 1 层
# ]
# 这个 sha256 和 docker pull 输出里的 "0b44b2151d78...: Pull complete" 是同一个!
# 它就是被下载到本地的那个层。去哪看?
# containerd snapshotter 模式:
ctr -n moby snapshot ls | grep "0b44b215"
# sha256:0b44b2151d78... Committed ← 在这!
# 传统 overlay2 模式:
ls /var/lib/docker/overlay2/0b44b2151d78.../diff/
# bin/ etc/ lib/ ... ← 下载的就是这些文件
docker pull= 下载 tar 包 → 解压到 snapshots/ 或 overlay2/ 目录。docker build= 本地执行指令 → 把结果也存到同一个 snapshots/ 或 overlay2/ 目录。 两者的产物格式完全一样——都是层的 sha256 目录。这就是为什么docker pull能复用你本地已有的层("Already exists"),也是为什么拉取公共基础镜像时经常看到某些层瞬间完成。
2.2 每层的"内容"到底是什么?
层不是完整的文件系统——每层只存储相对于上一层的"增量"(新增和修改的文件)。
层 0(alpine 基础镜像) 层 3(RUN echo "layer3" >> /layer1.txt)
┌──────────────────────┐ ┌──────────────────────────┐
│ /bin/ │ │ /layer1.txt │ ← 只有被修改的 layer1.txt
│ /etc/ │ │ (内容: layer1\nlayer3) │ 其他文件(bin/, etc/)不重复存
│ /lib/ │ └──────────────────────────┘
│ ... │
└──────────────────────┘ 层 2(COPY script.sh /script.sh)
┌──────────────────────────┐
层 1(RUN echo "layer1" > /layer1.txt) │ /script.sh │ ← 只有新加的文件
┌──────────────────────┐ └──────────────────────────┘
│ /layer1.txt │ ← 只有这一个新文件
│ (内容: layer1) │
└──────────────────────┘每一层在磁盘上就是一个目录:
# 查看镜像的所有层(链式 ID)
docker inspect layer-demo | jq '.[0].RootFS.Layers'
# [
# "sha256:7e01a...", ← 层 0: alpine 基础镜像
# "sha256:b2c3d...", ← 层 1: RUN echo "layer1"...
# "sha256:e4f5a...", ← 层 2: COPY script.sh...
# "sha256:9a8b7..." ← 层 3: RUN echo "layer3"...
# ]这四个 sha256 就是四个目录的 ID。去磁盘上看:
# 传统 overlay2 路径
ls /var/lib/docker/overlay2/
# 7e01a.../ ← 层 0(alpine)
# b2c3d.../ ← 层 1
# e4f5a.../ ← 层 2
# 9a8b7.../ ← 层 3
# 每个目录下都有一个 diff/,里面就是该层的增量文件
ls /var/lib/docker/overlay2/b2c3d.../diff/
# layer1.txt ← 层 1 的唯一产物
# containerd snapshotter 路径
ls /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/
# 56/fs/ ← 层 0
# 57/fs/ ← 层 1
# 58/fs/ ← 层 2
# 59/fs/ ← 层 32.3 这些层是怎么叠成容器的文件系统的?
回顾 Docker 文件系统揭秘 的核心概念:
docker run layer-demo 时,Docker 做的事:
mount -t overlay overlay \
-o lowerdir=层0:层1:层2:层3,upperdir=容器可写层,workdir=work \
/merged
层 3(最上层镜像层)──┐
层 2 ────────────────┤
层 1 ────────────────┤── lowerdir(只读)
层 0(基础镜像)─────┘
容器可写层 ──────────── upperdir(可读写)
─────────────────────
merged = 容器内看到的 /docker build 做的事,就是逐条执行指令,每条 RUN/COPY/ADD 的结果存成一个新目录。docker run 做的事,就是把这些目录用 OverlayFS 叠起来。
等等——"每条指令"都生成一层吗? 不是。指令分三种:
| 一定会生成层的指令 | 只写元数据的指令 | 特殊:会生成小层的指令 |
|---|---|---|
RUN — 执行命令 | CMD — 默认启动命令 | WORKDIR — 创建目录(≈8KB) |
COPY — 拷贝文件 | ENTRYPOINT — 入口程序 | |
ADD — 拷贝+解压 | EXPOSE — 声明端口 | |
ENV — 环境变量 | ||
USER — 切换用户 | ||
LABEL — 标签 | ||
ARG — 构建参数 |
WORKDIR比较特殊:它不像RUN/COPY那样产生实质文件变更,但确实会创建一个约 8KB 的小层来存储目录的 inode 元数据。在 BuildKit 下docker history可以看到它 SIZE ≈ 8KB。而EXPOSE、ENV、CMD这些则完全零字节——它们只修改镜像的 JSON 元数据(docker inspect能看到),不会在磁盘上生成 overlay2 目录。
2.4 为什么"顺序重要"?从层的角度看缓存
# 场景:你修改了源码 app.py
# ❌ 顺序不好
COPY . /app # 层 2:源码变了 → 这层重建
RUN pip install -r requirements.txt # 层 3:被迫重建(即使依赖没变!)
# ✅ 顺序好
COPY requirements.txt /app/ # 层 2:requirements.txt 没变 → 命中缓存,跳过
RUN pip install -r /app/requirements.txt # 层 3:命中缓存,跳过
COPY app.py /app/ # 层 4:只有这层重建每一层的缓存 key 由"指令内容 + 上一层的 ID"共同决定。前面任何一层变了,后面所有层都跟着重建——就像多米诺骨牌。这就是为什么要把最不容易变的操作(装系统依赖)放最前面,最常变的(拷源码)放最后面。
2.5 一个 Dockerfile 到底有多少层?
镜像总层数 = 基础镜像的层数 + 你自己写的 RUN/COPY/ADD 条数。
FROM本身不生成新层。 它只是指定"从这个镜像开始"——基础镜像已经有自己的层了(docker history能看到),这些层原封不动地成为你镜像的前几层。打个比方:FROM像是说"拿那摞书过来",而不是"再加一本书"。
FROM python:3.12-alpine # 基础镜像,假设它有 4 层
RUN adduser -D app # 层 1(RUN)
WORKDIR /app # ← 小层(~8KB,存目录 inode)
COPY requirements.txt ./ # 层 2(COPY)
RUN pip install -r requirements.txt # 层 3(RUN)
COPY . . # 层 4(COPY)
ENV APP_ENV=production # ← 不生成层!
EXPOSE 8000 # ← 不生成层!
USER app # ← 不生成层!
CMD ["python", "main.py"] # ← 不生成层!
# ───────────────────────────────────────────
# 文件系统层:基础镜像 4 层 + 你自己的(RUN×2 + COPY×2 + WORKDIR)= 5 个新层
# 总层数 = 4(基础镜像)+ 5(你的)= 9 层口诀:数
RUN、COPY、ADD就行,WORKDIR会额外产生一个约 8KB 的小层。docker history里 Size > 0B 的条目就是文件系统层。
怎么查一个镜像到底有多少层? 用
docker history也能数,但更精确的方式是直接看文件系统层的 ID 数组:# 查看所有文件系统层的 ID docker inspect <镜像名> | jq '.[0].RootFS.Layers' # [ # "sha256:7e01a...", # "sha256:b2c3d...", # ... # ] # 直接数有多少层 docker inspect <镜像名> | jq '.[0].RootFS.Layers | length' # 9
RootFS.Layers里每个 sha256 就是一个文件系统层——不含元数据指令。这个数字就是docker run时 lowerdir 的层数(再加一个容器自己的 upperdir)。
三、核心指令速览
FROM — 指定基础镜像
FROM ubuntu:22.04 # 从某个镜像开始
FROM golang:1.21-alpine AS builder # 给阶段起个别名(配合 COPY --from 使用)
FROM scratch # 从零开始,什么都不带(适合静态编译的二进制)RUN — 在镜像层里执行命令
# 每一条 RUN 生成一个新层
RUN apt-get update
RUN apt-get install -y nginx # ❌ 两条 RUN 会留下 apt 缓存,删不掉
# ✅ 合并到一条,安装完立刻清理
RUN apt-get update && \
apt-get install -y nginx && \
rm -rf /var/lib/apt/lists/*COPY vs ADD — 把文件拷进镜像
COPY app /app/ # ✅ 推荐:简单直接,拷贝本地文件到镜像
COPY --from=builder /out / # 从上一个构建阶段拷贝
ADD archive.tar.gz /app/ # ADD 会自动解压 tar,其他行为跟 COPY 一样
ADD https://example.com/file /app/ # ADD 还能从 URL 下载(不推荐,用 RUN curl 更可控)原则:能用 COPY 就别用 ADD,行为更可预测。
CMD vs ENTRYPOINT — 容器启动时执行什么
# CMD:默认命令,docker run 后面跟的参数会覆盖它
CMD ["nginx", "-g", "daemon off;"]
# docker run my-image → 执行 nginx -g "daemon off;"
# docker run my-image /bin/bash → CMD 被覆盖,执行 /bin/bash
# ENTRYPOINT:入口程序,docker run 后面的参数会追加给它
ENTRYPOINT ["nginx"]
CMD ["-g", "daemon off;"]
# docker run my-image → nginx -g "daemon off;"
# docker run my-image -t → nginx -t(CMD 被覆盖,但 ENTRYPOINT 不变)| 组合 | 行为 |
|---|---|
只有 CMD | docker run <img> [args] → args 直接替换 CMD |
只有 ENTRYPOINT | docker run <img> [args] → ENTRYPOINT + args |
ENTRYPOINT + CMD | docker run <img> [args] → ENTRYPOINT + args(args 替换 CMD) |
实战口诀:
ENTRYPOINT写"程序名",CMD写"默认参数"。这样用户docker run my-image --debug时只需要传参数,不用重复写程序名。
WORKDIR — 设置工作目录
WORKDIR 设置的是镜像里的目录,不是你本机的目录。 你可以把它理解成"镜像内部的 cd"——告诉 Docker 后续的 RUN、CMD、ENTRYPOINT 应该从哪个目录开始执行。
为什么需要 WORKDIR? 看看没有它的后果:
# ❌ 没有 WORKDIR,所有操作都在根目录 / 下
COPY app / # 文件散落在 /
RUN ls # 列出的是 /(根目录),里面混着 bin、etc、app 文件,一团乱
CMD ["python", "/app/server.py"] # 每次都要写绝对路径,麻烦
# ✅ 有 WORKDIR,操作集中在项目目录
WORKDIR /app # 镜像里创建 /app 目录,后续操作都在这里
COPY . . # 文件拷到 /app/ 下
RUN ls # 列出 /app/ 的内容,干净清晰
CMD ["python", "server.py"] # 相对路径即可,因为已经在 /app 里了WORKDIR 和 RUN cd 有什么区别? 这是新手最容易搞混的地方:
# ❌ 错误:cd 只在当前 RUN 的 shell 里生效,下一条 RUN 会回到 /
RUN cd /app
RUN touch file.txt # file.txt 创建在了 / 而不是 /app!
# ✅ 正确:用 WORKDIR,效果持久
WORKDIR /app
RUN touch file.txt # 创建在 /app/file.txt
RUN ls # 列出的是 /app/ 的内容原因:每条 RUN 启动一个全新的 shell,cd 的效果不会跨 RUN 保留。而 WORKDIR 修改的是镜像层的元数据,之后所有 RUN、CMD、ENTRYPOINT 都从该目录启动。
建议:始终用绝对路径(
WORKDIR /app而不是WORKDIR app),可读性好,不会因为上一个 WORKDIR 是/还是别的目录而导致行为不一致。
WORKDIR会生成层吗? 在 BuildKit 下,WORKDIR /app会生成一个约 8KB 的小层——存的是目录的 inode 元数据(.和..条目)。它不是纯元数据(SIZE ≠ 0B),但也远小于 RUN/COPY 产生的层。所以严格来说,WORKDIR处于"纯元数据"和"重文件系统层"之间的灰色地带:会占一点磁盘空间,但基本可以忽略不计。这也是为什么前面动手实验中RootFS.Layers有 5 层而非 4 层的原因。
ENV vs ARG — 变量
# ARG:构建时变量,只在 docker build 过程中可用,不会留在镜像里
ARG VERSION=1.0.0
RUN echo "Building version $VERSION"
# ENV:环境变量,会留在镜像里,容器运行时也能读到
ENV APP_ENV=production
ENV PATH="/app/bin:$PATH" # 经常用来追加 PATH
# docker build --build-arg VERSION=2.0.0 . ← 构建时覆盖 ARG
# docker run -e APP_ENV=staging my-image ← 运行时覆盖 ENVEXPOSE — 声明端口
EXPOSE 8080 # ⚠️ 这只是文档和 -P 随机端口映射用,并不会真正发布端口
# 要真正暴露端口,还得 docker run -p 8080:8080VOLUME — 声明挂载点
VOLUME /data # 声明 /data 应该从宿主机挂载
# ⚠️ 会在这个位置生成一个新层,后续 RUN 对 /data 的修改都会丢失
# 实际挂载还是靠 docker run -v 或 compose volumesUSER — 切换用户
RUN useradd -m appuser
USER appuser # 之后的所有 RUN/CMD/ENTRYPOINT 都用这个用户执行四、层缓存机制——为什么顺序很重要
Docker 构建时,每条指令的结果会被缓存。下次构建如果指令和上下文都没变,直接用缓存而不是重新执行。
# ❌ 缓存利用率差的写法
COPY . /app # 源码一改,这层缓存失效
RUN apt-get install -y nodejs # 连带着这层也得重跑(浪费时间)
# ✅ 充分利用缓存的写法
RUN apt-get install -y nodejs # 1. 先装依赖(很少变,几乎永远命中缓存)
COPY package.json package-lock.json /app/ # 2. 拷依赖描述文件
RUN npm ci # 3. 装 npm 包(package.json 没变就命中缓存)
COPY . /app # 4. 最后才拷源码(经常变,但前面的层都缓存了)核心原则:变更越频繁的内容越靠后,越稳定的越靠前。
五、多阶段构建——镜像瘦身的终极大招
编译型语言(Go、Rust、Java、C++)的最大痛点:编译环境又大又重,但最终产物只是一个二进制。
# ❌ 单阶段:编译工具链全留在镜像里,体积巨大
FROM golang:1.21
COPY . .
RUN go build -o server .
CMD ["./server"]
# 最终镜像 ≈ 800MB(golang 工具链 + 源码 + 二进制)
# ✅ 多阶段:编译和运行分离
FROM golang:1.21 AS builder # 阶段 1:编译
COPY . .
RUN go build -o server .
FROM alpine:3.19 # 阶段 2:运行(全新基础镜像)
COPY --from=builder /go/server /server # 只拷贝编译产物
CMD ["/server"]
# 最终镜像 ≈ 15MB(只有 alpine + 一个二进制文件)为什么最终镜像会变小?关键点:每个
FROM都是一个"重置按钮"。阶段 1 和阶段 2 是两个完全独立的镜像,不是拼在一起的。阶段 1 里装 Go 工具链、下载依赖、编译——这些事都在"builder"这个临时镜像里发生。到了阶段 2,
FROM alpine:3.19相当于说"前面的全扔了,重新从 alpine 开始",然后COPY --from=builder只把编译好的二进制文件挑出来拷贝进新镜像。打个比方:你在厨房(阶段 1)炒菜,锅碗瓢盆油盐酱醋全堆在台面上。做完后端到餐厅(阶段 2),你只把炒好的菜端过去——厨房里的锅碗瓢盆不会跟着上桌。最终镜像里没有 Go 编译器、没有源码、没有
go mod download下载的依赖包,只有一个干净的小二进制文件。用
docker history能直接验证——最终镜像的层列表里只有 alpine 的层 + 一个 COPY 层,完全看不到阶段 1 的那些RUN go build层的影子。
多阶段构建不限于编译语言。前端项目也同样适用:
# React/Vue/Angular 前端项目的多阶段构建
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # 产出 dist/ 目录
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
# 最终镜像 ≈ 40MB(只有 nginx + 静态文件),构建时的 node_modules 全丢了六、常用实战模式
模式 1:非 root 运行
FROM alpine:3.19
RUN adduser -D appuser
COPY app /app
RUN chown -R appuser:appuser /app
USER appuser
CMD ["/app/server"]模式 2:健康检查
FROM nginx:alpine
COPY default.conf /etc/nginx/conf.d/
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost/health || exit 1模式 3:用 .dockerignore 减小构建上下文
# .dockerignore 文件(放在 Dockerfile 同级目录)
node_modules/
.git/
*.md
.env
dist/构建时 Docker 会把整个目录发给 daemon(构建上下文)。.dockerignore 可以排除不需要的文件,既加快构建速度,又避免误把 .env 等敏感文件打进镜像。
模式 4:用 tini 正确处理信号
FROM alpine:3.19
RUN apk add --no-cache tini
COPY app /app
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/app/server"]tini 是一个轻量的 init 进程,负责转发信号(SIGTERM)和回收僵尸进程。如果你发现 docker stop 要等 10 秒才停掉容器,大概率是应用没有正确处理 PID 1 的信号——加 tini 就能解决。
Docker 1.13+ 可以不用装 tini:
docker run --init ...会自动注入。
模式 5:活用构建缓存挂载
# BuildKit 提供了 --mount=type=cache,让包管理器缓存跨构建复用
# syntax=docker/dockerfile:1
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build--mount=type=cache 是 BuildKit 的特性,能让 npm ci / apt-get / pip install 等包管理器的缓存目录在多次构建间持久化,大幅加速重复构建。
七、常见错误与解决
| 错误 | 原因 | 修正 |
|---|---|---|
| 镜像很大(>1GB) | 构建工具链没清理 | 用多阶段构建,或同一 RUN 里安装后立即清理 |
docker build 每次都重新下载依赖 | COPY . . 放在了 RUN npm install 前面 | 先 COPY package.json → RUN npm install → 再 COPY . . |
| 容器启动就退出 | 前台进程没持续运行 | 确认 CMD/ENTRYPOINT 启动的是前台进程(nginx 要 daemon off;) |
docker stop 等很久 | 进程没有处理 SIGTERM | 用 tini 或 docker run --init,或应用内处理好信号 |
环境变量在 docker build 时为空 | 混淆了 ARG 和 ENV | ARG 只在构建时可读;ENV 构建和运行时都可读 |
COPY --from=builder 找不到文件 | 阶段名拼写错误或路径写错 | 构建时注意 AS 别名是否正确,路径是阶段内的路径 |
/data 目录在后续 RUN 中为空 | VOLUME 声明后对该目录的修改会丢失 | 不要在 VOLUME 之后对那个目录做任何操作 |
八、速查表
| 指令 | 作用 | 是否生成新层 | 备注 |
|---|---|---|---|
FROM | 指定基础镜像 | - | 必须是第一条 |
RUN | 执行命令 | ✅ | 核心的层生成指令 |
COPY | 拷贝本地文件 | ✅ | 推荐,行为可预测 |
ADD | 拷贝 + 自动解压 | ✅ | 尽量用 COPY 代替 |
CMD | 默认启动命令 | ❌ | 可被 docker run 参数覆盖 |
ENTRYPOINT | 入口程序 | ❌ | 不会被覆盖,参数追加 |
WORKDIR | 切换工作目录 | ✅ (≈8KB) | 生成小层存目录 inode,推荐用绝对路径 |
ENV | 设置环境变量 | ❌ | 只写镜像 JSON 元数据,会留在镜像里 |
ARG | 构建参数 | ❌ | 只在构建时存在 |
EXPOSE | 声明端口 | ❌ | 纯文档,不实际发布端口 |
VOLUME | 声明挂载点 | ✅ | 会初始化卷,后续 RUN 对该目录的修改丢失 |
USER | 切换用户 | ❌ | 影响后续所有指令 |
HEALTHCHECK | 健康检查 | ❌ | 告诉 Docker 怎么判断容器是否正常 |
SHELL | 指定 shell | ❌ | Windows 镜像可能需要 ["powershell", "-Command"] |
总结
写好 Dockerfile 的三个核心原则:
- 减少层数:合并能合并的
RUN指令,安装和清理在同一层完成 - 利用缓存:不变的放前面(系统依赖),常变的放后面(业务代码)
- 多阶段构建:编译环境和运行环境分离,最终镜像只带运行时需要的东西
再回头看 Docker 文件系统揭秘,你会发现:Dockerfile 每一条
RUN/COPY不过是在往/var/lib/docker/overlay2/或 containerd 的snapshots/里新增一个目录。所谓的"镜像"就是这些目录的集合,"容器"就是用mount -t overlay把它们叠起来——Dockerfile 的编写技巧,就是管理这些"层"的技巧。