一、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 格式,不推荐
CMD 和 RUN 最大的区别:
| 指令 | 什么时候执行 | 执行几次 |
|---|---|---|
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 build → FROM 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 | 基础镜像越小越好 |
