Chapter 03

Dockerfile 编写指南

掌握所有 Dockerfile 指令,理解分层缓存,写出高质量的镜像构建脚本

Dockerfile 是什么

Dockerfile 是一个纯文本文件,包含一系列有序指令,Docker 按照这些指令逐步构建镜像。每一条指令都可能创建一个新的镜像层(除元数据指令外)。


# 构建镜像
docker build -t myapp:1.0 .

# -t 指定镜像名:标签,. 指定构建上下文目录

所有指令详解

FROM — 基础镜像

每个 Dockerfile 必须以 FROM 开头(除了 ARG 可以在 FROM 之前),指定构建的起点。


FROM node:18-alpine        # Alpine Linux 变体,最小化
FROM ubuntu:22.04          # Ubuntu 完整版
FROM scratch               # 空镜像,用于静态二进制文件

# 多阶段构建中为每个阶段命名
FROM node:18-alpine AS builder

WORKDIR — 工作目录

设置后续指令(RUN、COPY、CMD 等)的工作目录。如目录不存在会自动创建,等价于 mkdir -p /app && cd /app


WORKDIR /app

# 推荐使用绝对路径,避免使用 RUN cd /some/path

COPY vs ADD

两者都能复制文件,但推荐优先使用 COPY,因为它行为简单可预期。


# COPY:简单地从构建上下文复制文件/目录到镜像
COPY package*.json ./        # 复制 package.json 和 package-lock.json
COPY src/ ./src/             # 复制整个 src 目录
COPY . .                     # 复制整个构建上下文(配合 .dockerignore)

# ADD:额外支持 URL 下载和 tar 自动解压
ADD https://example.com/file.tar.gz /tmp/  # 下载并解压
ADD app.tar.gz /app/         # 自动解压 tar 文件
指令功能推荐场景
COPY复制本地文件/目录到镜像几乎所有场景
ADD复制 + URL 下载 + tar 解压需要自动解压 tar 时

RUN — 执行命令

在镜像构建时执行命令,每条 RUN 创建一个新层。有两种语法形式:


# Shell 形式(通过 /bin/sh -c 执行,支持 Shell 特性)
RUN apt-get update && apt-get install -y curl

# Exec 形式(直接执行,不经过 Shell,推荐用于避免 Shell 注入)
RUN ["apt-get", "install", "-y", "curl"]

# 最佳实践:多条命令用 && 合并,减少层数,用 \ 换行提高可读性
RUN apt-get update \
    && apt-get install -y \
        curl \
        git \
        vim \
    && rm -rf /var/lib/apt/lists/*  # 清理 apt 缓存,减小镜像体积
⚠️

不要拆分 apt-get update 和 install — 如果分两层写,update 的结果会被缓存,install 时可能拿到过期的包列表,导致构建失败。始终将 updateinstall 放在同一个 RUN 命令中。

ENV — 环境变量

设置的环境变量在容器运行时可见,也可在 Dockerfile 后续指令中引用。


ENV NODE_ENV=production
ENV PORT=3000
ENV APP_HOME=/app

# 在后续指令中引用
WORKDIR $APP_HOME

# 运行时可用 -e 覆盖
# docker run -e NODE_ENV=development ...

ARG — 构建时参数

仅在构建阶段可见的参数,运行时不可见(不同于 ENV)。


# 声明 ARG(可有默认值)
ARG APP_VERSION=1.0.0
ARG BUILD_DATE

# 使用 ARG
LABEL version=$APP_VERSION

# 构建时传入
# docker build --build-arg APP_VERSION=2.0.0 .
🚫

不要用 ARG 传递密钥 — ARG 的值会出现在 docker history 中,不够安全。密钥应使用 BuildKit Secret 特性(第8章介绍)。

EXPOSE — 声明端口

仅作为文档说明,告诉使用者这个容器监听哪些端口。不会实际开放端口,需要用 -p 参数实际映射。


EXPOSE 3000
EXPOSE 80/tcp
EXPOSE 53/udp

CMD vs ENTRYPOINT

这是最容易混淆的两个指令。理解它们的关系是关键:


# CMD:容器的默认命令,可被 docker run 末尾的命令覆盖
CMD ["node", "server.js"]

# ENTRYPOINT:容器的入口点,不易被覆盖(需要 --entrypoint 参数)
ENTRYPOINT ["node"]

# 组合使用:ENTRYPOINT 是可执行文件,CMD 是默认参数
ENTRYPOINT ["nginx", "-g", "daemon off;"]

# 典型组合:固定入口,灵活默认参数
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["node", "server.js"]
场景用法
容器就是该程序本身(如工具容器)ENTRYPOINT,CMD 做默认参数
容器可以运行不同命令只用 CMD
需要启动脚本做初始化ENTRYPOINT 指向 shell 脚本,CMD 是程序启动命令

VOLUME — 数据卷挂载点


# 声明容器内的目录为挂载点(数据会持久化)
VOLUME /var/lib/mysql
VOLUME ["/data", "/logs"]

USER — 切换用户

安全最佳实践:不要以 root 运行容器内的进程。


# 创建专用用户组和用户
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser

# 切换到该用户(之后的 RUN/CMD/ENTRYPOINT 都以此用户执行)
USER appuser

CMD ["node", "server.js"]

HEALTHCHECK — 健康检查


HEALTHCHECK \
  --interval=30s \     # 每30秒检查一次
  --timeout=10s \     # 超时10秒视为失败
  --start-period=40s \ # 启动后40秒内失败不计入
  --retries=3 \        # 连续3次失败才标记为 unhealthy
  CMD curl -f http://localhost:3000/health || exit 1

LABEL — 元数据标签


LABEL maintainer="team@example.com"
LABEL version="1.0" \
      description="My App Image" \
      org.opencontainers.image.source="https://github.com/org/repo"

.dockerignore 文件

类似 .gitignore,指定构建时不要包含在上下文(Context)中的文件和目录,减少构建时间和镜像大小。


# .dockerignore 示例
node_modules/
.git/
.gitignore
*.md
.env
.env.*
coverage/
.nyc_output/
dist/
build/
*.log
.DS_Store
__pycache__/
*.pyc
.pytest_cache/
venv/
*.egg-info/
💡

为什么重要 — 如果 node_modules 不在 .dockerignore 中,执行 COPY . . 时会把本机的 node_modules(可能有数万个文件)全部发送给 Docker daemon,极大拖慢构建速度。

分层缓存原理

Docker 构建时会缓存每一层。只要某层的指令和输入文件不变,就直接使用缓存。但某层失效会导致之后所有层全部重新构建


# ❌ 错误顺序:改动代码会导致所有依赖重新安装
FROM node:18-alpine
WORKDIR /app
COPY . .                    # 任何代码改动都使此层失效
RUN npm install             # 每次都要重新安装,很慢

# ✅ 正确顺序:利用缓存,只有 package.json 变了才重新安装
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./       # 只复制依赖描述文件(很少变动)
RUN npm ci                  # 仅在 package.json 变化时重新安装
COPY . .                    # 再复制代码(即使代码变了,依赖层缓存有效)
RUN npm run build

缓存失效的触发条件:

完整示例:Node.js + React 应用


# Dockerfile for a React frontend

# === 构建阶段 ===
FROM node:18-alpine AS builder

WORKDIR /app

# 先复制依赖描述文件,利用缓存
COPY package*.json ./

# 安装依赖(npm ci 更严格,适合 CI 环境)
RUN npm ci --only=production=false

# 复制源代码
COPY . .

# 构建生产版本
RUN npm run build

# === 运行阶段 ===
FROM nginx:1.25-alpine

# 复制构建产物到 nginx 静态文件目录
COPY --from=builder /app/build /usr/share/nginx/html

# 复制自定义 nginx 配置(处理 SPA 路由)
COPY nginx.conf /etc/nginx/conf.d/default.conf

# 健康检查
HEALTHCHECK --interval=30s --timeout=5s \
  CMD wget -q -O /dev/null http://localhost || exit 1

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

最佳实践清单