跳到主要内容
预计阅读 22 分钟

给容器写一份”出厂说明书” —— Dockerfile编写技巧与高效镜像构建策略

手动装配容器就像手工捏泥人——每次做出来都不太一样。Dockerfile让你拥有了一台”模具机”,一次设计,批量生产,个个一致。

📋 开篇自测:你已经知道多少?

  1. 你能说出Dockerfile中至少5个常用指令吗?
  2. COPYADD 有什么区别?什么时候用哪个?
  3. 什么是”多阶段构建”?它解决了什么问题?

一、为什么需要Dockerfile

上一章我们提到,可以用 docker commit 把一个修改过的容器保存为新镜像。但这种方式有几个致命的缺陷:

不可追溯:你在容器里敲了一堆命令,装了哪些包、改了哪些配置,谁也不知道。三个月后连你自己都忘了。就像一个厨师炒菜从不写菜谱,完全凭感觉——味道也许不错,但别人无法复制。

不可重复:你今天commit的镜像,明天用同样的步骤不一定能做出一模一样的东西,因为网络上的软件包版本可能更新了。

臃肿:每次操作都会增加一层,很容易不小心把临时文件、日志、缓存等无用内容也打包进镜像。

Dockerfile就是为了解决这些问题而生的。它是一个纯文本文件,用一系列指令描述了”如何从零开始构建一个镜像”。你可以把它想象成镜像的”施工图纸”——有了图纸,任何人在任何时间、任何地方都能建造出一模一样的”房子”。


二、第一个Dockerfile:从零开始

让我们从最简单的例子开始。假设你有一个Python写的Web应用,目录结构如下:

my-app/
├── app.py
├── requirements.txt
└── Dockerfile

app.py 的内容:

from flask import Flask
app = Flask(__name__)

@app.route('/')
def hello():
    return "Hello from Docker!"

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

requirements.txt

flask==3.0.0

现在来写Dockerfile:

# 选择基础镜像——相当于选择建房子的地基
FROM python:3.12-slim

# 设置工作目录——容器内所有后续操作都在这个目录下进行
WORKDIR /app

# 先复制依赖文件(利用缓存,后面会详细解释)
COPY requirements.txt .

# 安装Python依赖
RUN pip install --no-cache-dir -r requirements.txt

# 复制应用代码
COPY app.py .

# 声明容器对外暴露的端口
EXPOSE 5000

# 容器启动时执行的命令
CMD ["python", "app.py"]

构建镜像:

docker build -t my-flask-app:v1 .

这条命令告诉Docker:用当前目录(.)下的Dockerfile来构建镜像,并给它起名为 my-flask-app,标签为 v1

运行它:

docker run -d -p 5000:5000 --name flask-demo my-flask-app:v1

打开浏览器访问 http://localhost:5000,你应该能看到 “Hello from Docker!”。

逐行解读

让我把每条指令拆开来讲:

FROM python:3.12-slim —— 每个Dockerfile的第一条有效指令必须是FROM。它指定基础镜像,就像盖房子先打地基。python:3.12-slim 是Python官方提供的精简版镜像,包含了Python运行环境但去掉了很多不必要的系统工具,体积小了不少。

WORKDIR /app —— 设定后续指令的工作目录。如果目录不存在,Docker会自动创建。这比在每条命令里都写绝对路径优雅得多。

COPY requirements.txt . —— 把宿主机上的文件复制到容器内。这里的 . 代表WORKDIR设定的 /app 目录。

RUN pip install … —— 在构建过程中执行命令。RUN指令的每一次执行都会生成新的一层。

EXPOSE 5000 —— 声明容器运行时会监听5000端口。注意这只是一个文档性质的声明,并不会真的打开端口。实际端口映射还是要在 docker run -p 时指定。

CMD [“python”, “app.py”] —— 指定容器启动后默认执行的命令。整个Dockerfile中只能有一条CMD,如果写了多条,只有最后一条生效。

🤔 想一想 为什么我们先复制 requirements.txt 并安装依赖,然后才复制 app.py?能不能一步到位全部COPY过去?(提示:这和Docker的层缓存机制有关)


三、Dockerfile核心指令大全

FROM —— 选择地基

# 使用官方Python镜像
FROM python:3.12-slim

# 使用特定版本的Node.js
FROM node:20-alpine

# 使用最小的Linux发行版
FROM alpine:3.19

# 从空白镜像开始(用于Go等静态编译语言)
FROM scratch

选择基础镜像有个原则:够用就好,越小越好。Alpine版本(基于Alpine Linux,只有5MB左右)通常是首选。但要注意,Alpine使用musl libc而不是glibc,某些C语言扩展可能需要额外处理。

RUN —— 执行构建命令

# Shell形式
RUN apt-get update && apt-get install -y curl

# Exec形式
RUN ["apt-get", "install", "-y", "curl"]

一个重要的最佳实践:合并多条RUN指令。因为每条RUN都会创建一个新的层,层数越多镜像越大。

# 不推荐:三条RUN = 三层
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# 推荐:合并为一条RUN = 一层
RUN apt-get update \
    && apt-get install -y curl \
    && rm -rf /var/lib/apt/lists/*

注意最后的清理操作(rm -rf /var/lib/apt/lists/*)。如果安装和清理分成两条RUN,清理操作虽然删掉了文件,但那些文件所在的层已经固化了,镜像体积并不会减小。只有在同一条RUN中完成安装和清理,才能真正减小镜像体积。

COPY 和 ADD —— 复制文件

# COPY:简单地复制文件
COPY app.py /app/
COPY config/ /app/config/

# ADD:增强版COPY,支持两个额外功能
ADD https://example.com/file.tar.gz /app/  # 从URL下载
ADD archive.tar.gz /app/                    # 自动解压压缩包

建议:除非你需要自动解压本地压缩包的功能,否则一律使用COPY。因为COPY的行为更明确、更可预测。ADD的隐式行为有时候会给你带来意想不到的”惊喜”。对于从URL下载文件的场景,虽然ADD从Dockerfile 1.6起已支持 --checksum=sha256:<hash> 参数进行文件校验,但仍不支持HTTP ETag缓存验证。如果你需要更精细的下载控制(缓存、重试、条件下载等),使用 RUN curlRUN wget 依然是更灵活的选择。

ENV 和 ARG —— 变量

# ENV:设置环境变量,在构建和运行时都可用
ENV NODE_ENV=production
ENV APP_PORT=3000

# ARG:设置构建参数,只在构建时可用
ARG VERSION=1.0
ARG BUILD_DATE
# 构建时传入ARG
docker build --build-arg VERSION=2.0 --build-arg BUILD_DATE=$(date +%Y%m%d) -t myapp .

# 运行时覆盖ENV
docker run -e NODE_ENV=development myapp

CMD 和 ENTRYPOINT —— 启动命令

这两个指令经常把新手搞混。简单地说:

CMD 是默认命令,可以被 docker run 后面的参数完全覆盖:

CMD ["python", "app.py"]
# 使用默认CMD
docker run myapp
# 实际执行:python app.py

# 覆盖CMD
docker run myapp python test.py
# 实际执行:python test.py

ENTRYPOINT 是固定入口,docker run 后面的参数会被追加到它后面:

ENTRYPOINT ["python"]
CMD ["app.py"]
# 使用默认参数
docker run myapp
# 实际执行:python app.py

# 追加参数(替换的是CMD部分)
docker run myapp test.py
# 实际执行:python test.py

一个形象的比喻:ENTRYPOINT是动词(“吃”),CMD是默认宾语(“米饭”)。docker run 后面的参数可以换掉宾语(“吃面条”),但动词不变。

其他实用指令

# WORKDIR:设置工作目录
WORKDIR /app

# EXPOSE:声明端口
EXPOSE 8080

# VOLUME:声明挂载点
VOLUME ["/data"]

# USER:切换运行用户(安全性考虑)
USER appuser

# HEALTHCHECK:健康检查
HEALTHCHECK --interval=30s --timeout=3s \
  CMD curl -f http://localhost:8080/health || exit 1

# LABEL:添加元数据
LABEL maintainer="team@example.com"
LABEL version="1.0"

⚠️ 常见误区

  • 误区一:“EXPOSE会自动打开端口”。EXPOSE只是文档声明,不会真正开放端口。你仍然需要在docker run时用-p来映射端口。
  • 误区二:“每条指令越细越好”。恰恰相反,RUN指令应该尽量合并以减少层数。但COPY指令可以分开写来利用缓存。
  • 误区三:“基础镜像用latest就行”。和运行容器一样,构建镜像时也应该指定具体的基础镜像版本,确保构建的可重复性。

四、构建缓存:让构建飞起来

Docker构建镜像时有一套聪明的缓存机制:如果某一层的指令和上下文没有变化,就直接使用缓存结果,不需要重新执行。

这就是为什么我们在前面的例子中先复制 requirements.txt 再复制代码:

# 第一层(很少变化):复制依赖清单
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 第二层(经常变化):复制应用代码
COPY app.py .

开发过程中,你修改代码的频率远高于修改依赖的频率。如果先复制依赖文件、安装依赖,这一层在大部分构建中都能命中缓存。只有最后的代码层需要重建。

如果你把它们合在一起:

# 不推荐的写法
COPY . .
RUN pip install --no-cache-dir -r requirements.txt

那么每次你改了代码,COPY那一层的缓存就失效了,pip install也得重新来过。项目依赖多的话,白白浪费几分钟构建时间。

缓存失效的规则:一旦某一层缓存失效,它后面的所有层都会失效。所以Dockerfile中指令的顺序至关重要——把变化频率低的指令放前面,变化频率高的放后面

# 构建时可以看到缓存命中情况
docker build -t myapp .
# 输出中会看到 "CACHED" 字样

# 强制不使用缓存
docker build --no-cache -t myapp .

五、多阶段构建:给镜像”瘦身”

假设你有一个Go语言项目。Go是编译型语言,编译需要Go SDK(大约1GB),但运行只需要编译产物(一个几MB的二进制文件)。如果把SDK也打包进最终镜像,那就是背着整个工具箱去跑马拉松。

多阶段构建就是为了解决这个问题:

# ===== 第一阶段:编译 =====
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o myapp .

# ===== 第二阶段:运行 =====
FROM alpine:3.19
WORKDIR /app
# 从第一阶段复制编译产物
COPY --from=builder /build/myapp .
EXPOSE 8080
CMD ["./myapp"]

最终镜像只包含Alpine系统(5MB)和你的二进制文件(假设10MB),总共15MB左右。而如果不用多阶段构建,镜像体积可能超过1GB。

这就像做蛋糕:你需要搅拌机、烤箱、各种量杯来制作蛋糕(构建阶段),但客人只需要吃到蛋糕本身(运行阶段)。你不会把整个厨房搬到餐桌上。

多阶段构建不限于两个阶段,你可以根据需要定义任意多个阶段:

# 阶段1:安装依赖
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --production

# 阶段2:构建前端
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# 阶段3:最终运行镜像
FROM node:20-alpine
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json .
EXPOSE 3000
CMD ["node", "dist/index.js"]

六、.dockerignore:告诉Docker什么不该打包

构建镜像时,Docker会把指定目录下的所有文件(称为”构建上下文”)发送给Docker守护进程。如果你的项目目录里有node_modules(可能几百MB)、.git目录(可能也很大)、或者各种日志和临时文件,它们都会被无意义地传输过去,严重拖慢构建速度。

.dockerignore 文件就像 .gitignore 一样,告诉Docker哪些文件不需要包含在构建上下文中:

# .dockerignore
node_modules
.git
.gitignore
*.md
.env
.env.*
dist
coverage
.idea
.vscode
__pycache__
*.pyc
*.log
Dockerfile
docker-compose.yml

重要安全提示:一定要把 .env 等包含敏感信息(数据库密码、API密钥等)的文件加入 .dockerignore。否则这些信息会被打包进镜像,任何获取到镜像的人都能看到。

🤔 想一想 如果你忘了创建 .dockerignore 文件,最坏的情况会是什么?(提示:不只是构建慢的问题,还有安全风险)


七、镜像优化实战清单

总结一份实用的镜像优化清单:

  1. 选择合适的基础镜像:优先用 -alpine-slim 版本
  2. 合并RUN指令:减少层数,在同一条RUN中完成安装和清理
  3. 利用构建缓存:把不常变的指令放前面,常变的放后面
  4. 使用多阶段构建:分离构建环境和运行环境
  5. 用 .dockerignore 排除无关文件:减小构建上下文
  6. 不要安装多余的包:用 --no-install-recommends(apt)或 --no-cache(apk)
  7. 使用非root用户运行:在Dockerfile末尾添加 USER 指令
  8. 固定基础镜像版本:不要用 latest
# 一个优化良好的Node.js项目Dockerfile示例
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --production

FROM node:20-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json .
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD wget -q -O /dev/null http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]

📝 掌握度自测

  1. 写出一个最简单的Dockerfile,要求基于Alpine镜像,安装curl工具,启动时执行 curl --version

  2. 解释为什么”在同一条RUN指令中安装和清理”能减小镜像体积,而分成两条RUN就不行?

  3. CMD和ENTRYPOINT的区别是什么?如果两者同时存在,它们是怎么配合的?

  4. 什么是多阶段构建?请举一个你认为它最能发挥价值的场景。

  5. 列举三个你会放进 .dockerignore 文件的条目,并说明为什么要排除它们。

💡 自我评估

  • 全部答对:你已经掌握了Dockerfile的核心技巧,可以自信地为自己的项目编写生产级Dockerfile了。
  • 答对3-4题:基础扎实,建议亲手写一个Dockerfile来加深理解,特别是多阶段构建部分。
  • 答对1-2题:Dockerfile的指令比较多,建议先记住FROM、RUN、COPY、CMD这四个最核心的,其他的用到再查也来得及。

购买课程解锁全部内容

告别「在我电脑上能跑」:Docker 容器化实战

¥29.90