从零写出生产级 Dockerfile:核心指令 + 层缓存 + 多阶段构建,告别臃肿镜像 - PC2005

从零写出生产级 Dockerfile:核心指令 + 层缓存 + 多阶段构建,告别臃肿镜像 - PC2005

一、Dockerfile 是什么?

Dockerfile 是一个文本文件,里面写的是"怎么构建一个镜像"的指令。

FROM alpine:latest
RUN echo "hello" > /tmp/test
CMD ["cat", "/tmp/test"]

每一行指令,最终会变成镜像里的一层。Docker 按顺序执行这些指令,最终产出可以运行的镜像。

做一个 Dockerfile 需要什么?

  • Docker 已安装
  • 一个文本编辑器
  • 一个想容器化的应用(没有就用个脚本练手)

二、核心指令(先跑起来)

1. FROM — 基础镜像,一切从这里开始

每个 Dockerfile 必须以 FROM 开头。

FROM alpine:latest          # Alpine Linux,极小 (~7MB)
FROM python:3.12-slim       # Python 运行环境
FROM nginx:alpine           # Nginx + Alpine
FROM ubuntu:22.04           # Ubuntu 完整系统
FROM scratch                # 空镜像,从零开始

基础镜像决定了你的镜像"起点"有多大:

基础镜像 大小 适用场景
alpine ~7 MB 静态二进制、轻量服务
python:3.12-slim ~50 MB Python 应用
nginx:alpine ~27 MB Web 服务
ubuntu ~80 MB 需要完整系统工具

2. RUN — 构建时执行命令

RUN apk add --no-cache curl
RUN pip install -r requirements.txt
RUN echo "构建时执行" > /tmp/test

关键点RUN 在构建时执行,每一行 RUN 都会增加一层

为什么合并 RUN 和清理缓存很重要?

先看一个实验:

# ❌ 两个 RUN:先创建文件,再删除
FROM alpine:latest
RUN dd if=/dev/zero of=/bigfile bs=1M count=10   # 创建 10MB 文件
RUN rm /bigfile                                    # "删除"这个文件

构建结果:23.4 MB

原因?用 docker history 看每一层:

SIZE     层内容
10.5MB   第 1 个 RUN:创建 /bigfile(10MB 文件在这层留下了!)
4.1kB    第 2 个 RUN:rm /bigfile(只是记了个"删了",文件还在上一层)

每一层都是一个独立的快照。 第 2 层的"删除"只是在第 2 层标记了"已删除",但第 1 层的 10MB 文件依然存在。

第 2 层(记了"已删除"  ← 这只是个标记)
第 1 层(10MB 文件)    ← 文件还在!
基础镜像

文件只是被"挡住了",不是真的没了。这就是为什么镜像比你想象的大

# ✅ 一个 RUN:创建并删除,文件没进过镜像
FROM alpine:latest
RUN dd if=/dev/zero of=/bigfile bs=1M count=10 && rm /bigfile

构建结果:12.9 MB(差了一倍!)

SIZE     层内容
4.1kB    同一个 RUN 创建又删除,文件从来没进入任何一层

放到 apt 的场景也是一样的道理:

# ❌ 错误写法
RUN apt-get update                         # 把包列表下载到 /var/lib/apt/lists/(~30MB)
RUN apt-get install -y curl                # 装 curl
RUN rm -rf /var/lib/apt/lists/*            # 在不同层"删除"包列表
# 实际:30MB 的包列表还在上一层!最终镜像白白多了 30MB# ✅ 正确写法
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# 下载→安装→删除 在同一层,包列表没进过任何一层

原则:同一个逻辑的操作放在一个 RUN 里,用 && 连接。

3. CMD — 容器启动时执行的命令

CMD ["python", "app.py"]      # ✅ 推荐写法(JSON 数组格式)
CMD python app.py              # ❌ shell 格式,不推荐

CMDRUN 最大的区别:

指令 什么时候执行 执行几次
RUN 构建时执行 1 次,构建时
CMD 运行时执行(docker run 每次启动容器都执行

测试验证:

FROM alpine:latest
RUN echo "RUN:构建时执行"
CMD ["echo", "CMD:运行时执行"]
# 构建时(RUN 执行)
docker build -t test .# 运行时(CMD 执行)
docker run --rm test
# 输出:CMD:运行时执行

4. WORKDIR — 工作目录

WORKDIR /app
# 之后的 COPY、RUN、CMD 都在 /app 目录下执行

WORKDIR 等价于 cd,但更好——目录不存在时会自动创建

WORKDIR /app          # 自动创建 /app
COPY app.py .         # 复制到 /app/app.py
RUN pwd               # 输出 /app
CMD ["python", "app.py"]  # 从 /app 启动

5. COPY — 把文件放进镜像

# 把本地的 index.html 复制到镜像的 /usr/share/nginx/html/
COPY index.html /usr/share/nginx/html/# 配合 WORKDIR 使用更简洁
WORKDIR /usr/share/nginx/html
COPY index.html .     # 复制到当前工作目录

COPY vs ADD

指令 区别
COPY 只复制文件,不做其他事。推荐优先用
ADD 复制文件 + 自动解压 tar + 支持 URL。只在需要解压时用

三、层缓存 — 为什么顺序很重要

Docker 的构建是分层的。每一层构建完会缓存起来,下次同一层没有变化就直接用缓存

错误写法(改代码就要重装依赖)

COPY . .
RUN pip install -r requirements.txt    # ← 代码改了,依赖也要重装

正确写法(依赖放前面,代码放后面)

COPY requirements.txt .                 # ← requirements.txt 不常变,走缓存
RUN pip install -r requirements.txt     # ← 走缓存,不用重装
COPY . .                                # ← 只重新复制代码

验证效果:

# 第一次构建:正常安装
docker build -t myapp .
# pip install... 耗时 20s# 第二次构建(代码改了,依赖没改)
docker build -t myapp .
# COPY requirements.txt → CACHED
# RUN pip install → CACHED           ← 走缓存!
# COPY . → 重新复制代码
# 总耗时:< 1秒

原则:把不常变的东西放前面,常变的东西放后面。


四、多阶段构建 — 镜像瘦身的核心技巧

"编译环境要大没关系,运行环境要小"。

用多个 FROM,每个 FROM 是一个阶段。最后只取需要的产物。

场景:Go 语言应用

# ===== 阶段 1:编译 =====
FROM golang:alpine AS build     # 364 MB,包含 Go 编译器
WORKDIR /src
COPY server.go .
RUN go build -o /server server.go# ===== 阶段 2:运行 =====
FROM alpine:latest              # ~7 MB,只有系统
COPY --from=build /server /server
CMD ["/server"]

关键指令:COPY --from=

从其他阶段复制文件,而不是从宿主机。

COPY --from=build /server /server        # 从 build 阶段复制
COPY --from=nginx:alpine /usr/share/nginx/html/index.html /index.html  # 从其他镜像复制

最终效果:

镜像 大小
golang:alpine(仅编译用) 364 MB 丢弃
alpine:latest(基础系统) 7 MB
最终产物 21 MB

编译需要的 Go 编译器、工具链全部被丢掉了,只留编译好的二进制。

适用于任何语言

# Python 也可以多阶段
FROM python:3.12-slim AS builder
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txtFROM python:3.12-slim
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY app.py .
CMD ["python", "app.py"]

多阶段构建的核心思想:每个阶段各司其职,最终产物只保留运行时需要的东西。


五、进阶指令

1. ARG vs ENV — 两种变量

指令 作用时间 作用域 覆盖方式
ARG 构建时 只在 docker build 时存在 --build-arg VERSION=2.0
ENV 运行时 容器启动后仍然存在 -e APP_ENV=dev
ARG VERSION=1.0               # 默认值 1.0,构建时可覆盖
ENV APP_ENV=production         # 运行时的环境变量# 如果需要把 ARG 的值带到运行时,赋值给 ENV
ARG VERSION
ENV APP_VERSION=$VERSION
# 构建时修改 ARG
docker build --build-arg VERSION=2.0 -t myapp .# 运行时修改 ENV
docker run -e APP_ENV=development myapp

2. ENTRYPOINT vs CMD — 入口 vs 默认参数

两个都是容器启动时执行的命令,区别在于能不能被覆盖

ENTRYPOINT ["echo"]           # 主程序,固定不可变
CMD ["Hello, World!"]          # 默认参数,可以被覆盖
运行方式 效果
docker run image 执行 echo "Hello, World!"
docker run image Hi 执行 echo Hi(覆盖了 CMD)
docker run --entrypoint bash image 覆盖 ENTRYPOINT

组合模式

# ENTRYPOINT 是"这个容器是干什么的"
# CMD 是"默认怎么干"
ENTRYPOINT ["python"]
CMD ["app.py"]

3. HEALTHCHECK — 健康检查

Docker 默认只看进程是否活着,不管服务能不能用。HEALTHCHECK 让 Docker 主动检查你的服务。

HEALTHCHECK --interval=10s --timeout=3s --retries=2 \CMD curl -f http://localhost:5000/ || exit 1
参数 说明
--interval 每隔多久检查一次
--timeout 每次检查超时时间
--retries 连续失败几次算不健康
--start-period 启动后等多久再开始检查

查看健康状态:

docker inspect 容器名 --format '{{.State.Health.Status}}'
# starting → healthy / unhealthy

4. EXPOSE — 声明端口(只是文档)

EXPOSE 8080

仅仅是个说明,告诉用户"这个容器监听 8080 端口"。不真的暴露端口。要暴露还是要加 -p

5. USER — 别用 root

生产环境不要用 root 运行应用。但 USER app 不会自动创建用户,必须先创建再切换:

FROM alpine:latest# 第一步:创建用户
RUN addgroup -S app && adduser -S app -G app# 第二步:切换到该用户
USER app

常见错误:只 USER 不创建

USER nonexistent    # ← 这个用户不存在!

构建不会报错,但运行时直接挂掉:

docker: Error response from daemon: unable to find user nonexistent

不同基础镜像创建用户的命令

基础镜像 创建用户命令
Alpine adduser -S app -G app
Ubuntu/Debian useradd -r -s /bin/false app
通用 RUN addgroup -S app && adduser -S app -G app

六、完整的生产级 Dockerfile(实际项目)

下面两个是我实际生产在用的 Dockerfile,分别对应 Java 后端和 Next.js 前端。

Java / Spring Boot 后端

# ── 阶段 1:编译 ──
# Maven + JDK 21 的构建环境,镜像很大但只是临时用
FROM maven:3.9-eclipse-temurin-21 AS build# 设置工作目录,之后所有命令都在 /app 下执行
WORKDIR /app# 先复制 pom.xml(利用层缓存:pom.xml 不改就不重装依赖)
COPY pom.xml .
# 复制 Maven 私服配置(如果有私有仓库)
COPY .mvn/settings.xml settings.xml# 离线下载所有依赖(go-offline 会下载 pom.xml 定义的所有 jar 包,但只下载不编译)
RUN mvn dependency:go-offline -B -s settings.xml# pom.xml 和 settings.xml 的层缓存命中后,才复制源码
COPY src ./src# 真正编译打包,跳过测试
RUN mvn clean package -DskipTests -B -s settings.xml# ── 阶段 2:运行 ──
# 只用 JRE(Java 运行环境),没有编译器,小得多
FROM eclipse-temurin:21-jreWORKDIR /app# 从 build 阶段复制打好的 jar 包
COPY --from=build /app/target/ROOT.jar app.jar# 声明容器内端口(只起文档作用,真要暴露还得 docker run -p)
EXPOSE 8080# 容器启动命令:JSON 数组格式,正确接收 SIGTERM 信号
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

用了什么技巧?

技巧 对应指令
多阶段构建 FROM maven:... AS buildFROM eclipse-temurin:21-jre
层缓存加速 COPY pom.xml 在前,COPY src 在后,依赖只装一次
JSON 格式 ENTRYPOINT ["java", "-jar", "app.jar"]
产物分离 编译阶段 2GB+,运行阶段只用 JRE,小得多

Next.js 前端

# ── 阶段 1:构建 ──
# Node.js 22 的编译环境,装依赖 + 打包
FROM node:22-alpine AS builderWORKDIR /app# 先复制依赖描述文件,利用层缓存加速
COPY package.json package-lock.json ./# npm ci 比 npm install 更快更严格,完全按 lock 文件安装
RUN npm ci# 复制所有源码(这层常变,但上面的依赖层已经缓存了)
COPY . .# 删除 postbuild 脚本(避免构建时触发额外操作),然后构建
RUN npm pkg delete scripts.postbuild && npm run build# ── 阶段 2:运行 ──
FROM node:22-alpine AS runnerWORKDIR /app# 设置生产环境变量
ENV NODE_ENV=production# 只复制运行需要的文件,不要 node_modules 里的 devDependencies
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public# 声明端口(文档作用)
EXPOSE 3000# 启动 Node.js 服务
CMD ["node", "server.js"]

用了什么技巧?

技巧 对应指令
多阶段构建 builder 装依赖 + 构建,runner 只跑产物
层缓存 package.json + package-lock.json 先复制,npm ci 走缓存
按需复制 只复制运行需要的文件(standalone、static、public)
npm ci npm install 更快更严格,锁定版本

七、总结

指令速查表

指令 作用 执行时机
FROM 指定基础镜像 必需,第一行
RUN 执行命令(构建时) 构建时
CMD 容器启动命令 运行时
COPY 复制文件到镜像 构建时
WORKDIR 设置工作目录 构建时
ARG 构建时变量 构建时
ENV 运行时环境变量 构建时 + 运行时
ENTRYPOINT 容器入口程序 运行时
HEALTHCHECK 健康检查 运行时
EXPOSE 声明端口(文档)
USER 切换用户 构建时 + 运行时

Dockerfile 最佳实践

原则 说明
不常变的放前面 利用层缓存,加速构建
每个 RUN 只做一件事 但同一件事的多个命令用 && 合并,减少层数
多阶段构建 编译环境和运行环境分离,镜像体积差 10 倍以上
不用 root 运行 建一个普通用户,USER app
不要用 ADD 除非要解压 优先用 COPY
.dockerignore 过滤掉 node_modules.git 等无用文件
尽量用 Alpine 基础镜像越小越好