DevToolBox免费
博客

Dockerfile 最佳实践与多阶段构建

12 分钟阅读作者 DevToolBox

一份写得好的 Dockerfile 是高效、安全且可重复构建容器镜像的基础。本指南全面介绍 Dockerfile 最佳实践——从基础指令语法、层缓存到多阶段构建、安全加固,以及 Node.js、Python、Go 和 Java 的生产级示例。

为你的 Docker 化应用生成 docker-compose.yml

Dockerfile 基础

Dockerfile 是一个包含指令的文本文件,Docker 读取它来构建镜像。每条指令会在镜像中创建一个新层。以下是最重要的指令:

FROM — 基础镜像

每个 Dockerfile 都以 FROM 开头,它设置后续指令的基础镜像。

# Use an official base image
FROM node:20-alpine

# Use a specific digest for reproducibility
FROM node:20-alpine@sha256:abcdef...

# Use a version alias
FROM python:3.12-slim

RUN — 执行命令

RUN 在镜像构建过程中执行命令,每个 RUN 都会创建一个新层。

# Shell form (runs in /bin/sh -c)
RUN apt-get update && apt-get install -y curl

# Exec form (no shell processing)
RUN ["apt-get", "install", "-y", "curl"]

COPY — 复制文件

COPY 将文件和目录从构建上下文传输到镜像中。

# Copy a single file
COPY package.json /app/

# Copy a directory
COPY src/ /app/src/

# Copy with ownership (avoids extra chown layer)
COPY --chown=node:node . /app/

CMD — 默认命令

CMD 设置容器启动时运行的默认命令。只有最后一个 CMD 生效。

# Exec form (preferred)
CMD ["node", "server.js"]

# Shell form
CMD node server.js

ENTRYPOINT — 固定命令

ENTRYPOINT 设置一个始终运行的命令。CMD 的参数会追加到 ENTRYPOINT 后面。

# ENTRYPOINT + CMD pattern
ENTRYPOINT ["python", "manage.py"]
CMD ["runserver", "0.0.0.0:8000"]

# docker run myapp migrate   → python manage.py migrate
# docker run myapp            → python manage.py runserver 0.0.0.0:8000

WORKDIR — 工作目录

WORKDIR 为 RUN、CMD、ENTRYPOINT、COPY 和 ADD 指令设置工作目录。

WORKDIR /app

# All subsequent commands run in /app
COPY package.json .
RUN npm install
COPY . .

EXPOSE — 声明端口

EXPOSE 声明容器监听的端口。它实际上并不发布端口。

EXPOSE 3000
EXPOSE 8080/tcp
EXPOSE 8125/udp

层缓存——顺序很重要

Docker 会缓存每一层。如果某层没有变化,Docker 会重用缓存版本。这意味着指令的顺序会极大地影响构建速度。将不常变化的指令放在前面。

黄金规则:先复制依赖文件,再安装依赖,最后复制源代码。这样,除非包文件发生变化,依赖安装都会被缓存。

错误示例——每次代码变更都会破坏缓存

FROM node:20-alpine
WORKDIR /app

# BAD: Copying everything first means ANY file change
# invalidates the npm install cache
COPY . .
RUN npm install
CMD ["node", "server.js"]

正确示例——依赖单独缓存

FROM node:20-alpine
WORKDIR /app

# GOOD: Copy only dependency files first
COPY package.json package-lock.json ./
RUN npm ci --only=production

# Then copy source code (this layer changes often)
COPY . .
CMD ["node", "server.js"]

使用这种结构,修改源代码不会触发所有依赖的重新安装。只有最后的 COPY 层及后续层会被重新构建。

多阶段构建

多阶段构建允许在单个 Dockerfile 中使用多个 FROM 语句。每个 FROM 开始一个新的构建阶段。你可以将制品从一个阶段复制到另一个阶段,在最终镜像中省去不需要的内容。

优点:更小的最终镜像、生产环境无构建工具、更好的安全性、更清晰的关注点分离。

Node.js 多阶段示例

构建阶段编译 TypeScript,生产阶段只运行编译后的 JavaScript:

# ===== Stage 1: Build =====
FROM node:20-alpine AS builder
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build

# ===== Stage 2: Production =====
FROM node:20-alpine AS production
WORKDIR /app

# Install only production dependencies
COPY package.json package-lock.json ./
RUN npm ci --only=production && npm cache clean --force

# Copy compiled output from builder
COPY --from=builder /app/dist ./dist

# Security: run as non-root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

EXPOSE 3000
CMD ["node", "dist/server.js"]

镜像大小对比

方案镜像大小
单阶段 (node:20)~1.1 GB
单阶段 (node:20-alpine)~400 MB
多阶段 (构建 + alpine)~150 MB
多阶段 (distroless)~120 MB

.dockerignore

.dockerignore 文件将文件排除在构建上下文之外,减少构建时间并防止敏感文件被包含在镜像中。

没有 .dockerignore,Docker 会将项目中的所有文件发送到守护进程——包括 node_modules、.git 历史、.env 密钥和构建产物。

# .dockerignore

# Dependencies (will be installed in container)
node_modules
npm-debug.log*

# Version control
.git
.gitignore

# Environment / secrets
.env
.env.*
*.pem

# IDE and OS files
.vscode
.idea
.DS_Store
Thumbs.db

# Build output
dist
build
coverage

# Docker files (not needed inside container)
Dockerfile*
docker-compose*
.dockerignore

# Documentation
README.md
CHANGELOG.md
docs/

基础镜像选择

选择合适的基础镜像会影响镜像大小、安全性和兼容性。以下是常见基础镜像变体的对比:

变体大小软件包使用场景
node:20~1.1 GB完整 Debian,所有常用软件包开发、调试
node:20-slim~200 MB最小 Debian,基础软件包生产(良好平衡)
node:20-alpine~130 MBMusl libc,BusyBox——最小生产(最小体积)
gcr.io/distroless/nodejs20~120 MB无 shell,无包管理器生产(最高安全性)

建议:大多数应用使用 alpine 或 slim。当不需要 shell 调试时,使用 distroless 获得最高安全性。

安全最佳实践

容器安全从 Dockerfile 开始。遵循以下实践来最小化攻击面。

以非 root 用户运行

默认情况下,容器以 root 身份运行。始终创建并切换到非 root 用户:

FROM node:20-alpine
WORKDIR /app

COPY --chown=node:node . .
RUN npm ci --only=production

# Switch to the built-in non-root user
USER node

CMD ["node", "server.js"]
# For Debian-based images, create a user explicitly
FROM python:3.12-slim
WORKDIR /app

RUN groupadd -r appgroup && useradd -r -g appgroup -d /app appuser
COPY --chown=appuser:appgroup . .
RUN pip install --no-cache-dir -r requirements.txt

USER appuser
CMD ["python", "app.py"]

永远不要在构建参数中放置密钥

构建参数在镜像历史中可见。请使用运行时环境变量或 Docker secrets。

# BAD — secret visible in image history
ARG DB_PASSWORD
RUN echo "db_pass=$DB_PASSWORD" > /app/config

# GOOD — use runtime environment variables
ENV DB_PASSWORD=""
# Set at runtime: docker run -e DB_PASSWORD=secret myapp

# BEST — use Docker BuildKit secrets (not stored in layers)
RUN --mount=type=secret,id=db_pass \
    cat /run/secrets/db_pass > /app/config
# Build: docker build --secret id=db_pass,src=./password.txt .

扫描镜像漏洞

使用 Trivy、Snyk 或 Docker Scout 等工具扫描镜像:

# Trivy — popular open-source scanner
trivy image myapp:latest

# Docker Scout (built into Docker Desktop)
docker scout cves myapp:latest

# Snyk
snyk container test myapp:latest

# Scan during CI/CD pipeline
# GitHub Actions example:
# - name: Scan image
#   uses: aquasecurity/trivy-action@master
#   with:
#     image-ref: myapp:latest
#     severity: CRITICAL,HIGH

RUN 优化

每条 RUN 指令都会创建一个新层。在同一层中合并命令并清理,可以减小镜像大小。

合并 RUN 命令

使用 && 合并相关命令,并清理包管理器缓存:

不好——3 层,缓存残留

# Each RUN = new layer; apt cache stays in first layer
RUN apt-get update
RUN apt-get install -y curl wget git
RUN rm -rf /var/lib/apt/lists/*

好——1 层,缓存已清理

# Single layer, cache cleaned in same layer
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
       curl \
       wget \
       git \
    && rm -rf /var/lib/apt/lists/* \
    && apt-get purge -y --auto-remove

COPY 与 ADD 对比

COPY 和 ADD 都可以将文件传输到镜像中,但行为不同:

指令行为使用场景
COPY简单的文件/目录复制默认选择——大多数情况使用
ADD复制 + URL 下载 + tar 解压仅在需要 tar 自动解压时使用
# COPY — simple and predictable
COPY ./config /app/config

# ADD — auto-extracts tar archives
ADD app.tar.gz /app/

# For downloading files, prefer RUN + curl
RUN curl -fsSL https://example.com/file.tar.gz | tar xz -C /app/

最佳实践:始终使用 COPY,除非你特别需要 ADD 的 tar 解压功能。下载文件时,使用 RUN 配合 curl 或 wget——这样可以更好地控制缓存和错误处理。

HEALTHCHECK 健康检查

HEALTHCHECK 告诉 Docker 如何测试容器是否仍在正常工作。Docker 使用这个信息来判断容器是否需要重启。

HTTP 健康检查

# HTTP health check using curl
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD curl -f http://localhost:3000/health || exit 1

# HTTP health check using wget (for Alpine without curl)
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

# TCP port check (no HTTP endpoint needed)
HEALTHCHECK --interval=15s --timeout=3s --retries=5 \
  CMD nc -z localhost 3000 || exit 1

# Database health check
HEALTHCHECK --interval=10s --timeout=5s --retries=5 \
  CMD pg_isready -U postgres || exit 1

选项说明

  • --intervalinterval — 检查间隔(默认:30s)
  • --timeouttimeout — 检查最大等待时间(默认:30s)
  • --retriesretries — 标记为不健康前的连续失败次数(默认:3)
  • --start-periodstart_period — 容器启动的宽限期(默认:0s)

ARG 与 ENV 对比

ARG 和 ENV 都定义变量,但它们在不同时间可用,生命周期也不同:

特性ARGENV
可用时间仅构建时构建时 + 运行时
在最终镜像中
覆盖方式--build-arg 标志-e 标志或 .env 文件
默认值ARG NAME=defaultENV NAME=default
# ARG — only available during build
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine

# ARG after FROM must be re-declared
ARG APP_VERSION=1.0.0
RUN echo "Building version $APP_VERSION"

# ENV — available during build AND in running container
ENV NODE_ENV=production
ENV PORT=3000

# Common pattern: ARG → ENV (build-time default, runtime override)
ARG DEFAULT_PORT=3000
ENV PORT=${DEFAULT_PORT}
# Override at build: docker build --build-arg DEFAULT_PORT=8080 .
# Override at run:   docker run -e PORT=8080 myapp

真实多阶段 Dockerfile 示例

Node.js (Express/NestJS)

# ===== Build Stage =====
FROM node:20-alpine AS builder
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY tsconfig.json ./
COPY src/ ./src/
COPY public/ ./public/
RUN npm run build

# ===== Production Stage =====
FROM node:20-alpine
WORKDIR /app

ENV NODE_ENV=production

COPY package.json package-lock.json ./
RUN npm ci --only=production \
    && npm cache clean --force

COPY --from=builder /app/dist ./dist
COPY --from=builder /app/public ./public

RUN addgroup -S app && adduser -S app -G app
USER app

EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

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

Python (FastAPI/Django)

# ===== Build Stage =====
FROM python:3.12-slim AS builder
WORKDIR /app

# Install build dependencies
RUN apt-get update \
    && apt-get install -y --no-install-recommends gcc libpq-dev \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# ===== Production Stage =====
FROM python:3.12-slim
WORKDIR /app

# Install runtime dependencies only
RUN apt-get update \
    && apt-get install -y --no-install-recommends libpq5 \
    && rm -rf /var/lib/apt/lists/*

# Copy installed packages from builder
COPY --from=builder /install /usr/local

COPY . .

RUN groupadd -r app && useradd -r -g app -d /app app \
    && chown -R app:app /app
USER app

EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1

CMD ["gunicorn", "app.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000"]

Go (Gin/Fiber)

# ===== Build Stage =====
FROM golang:1.22-alpine AS builder
WORKDIR /app

# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download

COPY . .

# Build static binary with CGO disabled
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \
    -o /app/server ./cmd/server

# ===== Production Stage =====
FROM gcr.io/distroless/static-debian12
WORKDIR /app

COPY --from=builder /app/server .
COPY --from=builder /app/config ./config

EXPOSE 8080
USER nonroot:nonroot

ENTRYPOINT ["/app/server"]

Go 编译为单个静态二进制文件,非常适合 distroless 或 scratch 基础镜像。最终镜像通常不到 20MB。

Java (Spring Boot)

# ===== Build Stage =====
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app

# Cache Gradle/Maven dependencies
COPY build.gradle settings.gradle gradlew ./
COPY gradle/ ./gradle/
RUN ./gradlew dependencies --no-daemon

COPY src/ ./src/
RUN ./gradlew bootJar --no-daemon

# ===== Production Stage =====
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app

# Extract Spring Boot layers for better caching
COPY --from=builder /app/build/libs/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

RUN addgroup -S app && adduser -S app -G app
USER app

EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1

ENTRYPOINT ["java", "-jar", "app.jar"]

对于 Java,运行时需要 JRE 基础镜像。使用 jlink 创建自定义 JRE 可以进一步减小镜像大小。

常见问题

什么是多阶段 Docker 构建?

多阶段构建在单个 Dockerfile 中使用多个 FROM 语句。每个 FROM 开始一个新阶段。你可以有选择地将制品从一个阶段复制到另一个阶段,在最终镜像中省去构建工具、源代码和运行时不需要的依赖。这样可以生成更小、更安全的生产镜像。

如何减小 Docker 镜像大小?

使用多阶段构建分离构建和运行时环境。选择更小的基础镜像(alpine、slim 或 distroless)。在同一层中合并 RUN 命令并清理缓存。使用 .dockerignore 排除不必要的文件。从最终镜像中移除开发依赖。

基础镜像应该选 Alpine 还是 Debian slim?

Alpine 更小(约 5MB,而 slim 约 80MB),攻击面也更小。但 Alpine 使用 musl libc 而非 glibc,可能导致某些原生 Node.js 模块或 Python 包出现兼容性问题。遇到问题时可以切换到 slim。对于 Go,Alpine 完美适用,因为 Go 编译为静态二进制文件。

为什么不应该以 root 身份运行容器?

在容器内以 root 运行意味着如果攻击者利用了漏洞,他们在容器内拥有 root 权限。结合容器逃逸漏洞,这可能让他们获得主机的 root 访问权。以非 root 用户运行可以限制任何漏洞利用的损害。

CMD 和 ENTRYPOINT 有什么区别?

CMD 设置可以在运行容器时被覆盖的默认命令(docker run myimage /bin/sh 会替换 CMD)。ENTRYPOINT 设置一个始终运行的固定命令——传递给 docker run 的参数会追加到 ENTRYPOINT 后面。当容器应该始终运行特定可执行文件时使用 ENTRYPOINT,CMD 用于用户可能覆盖的默认参数。

𝕏 Twitterin LinkedIn
这篇文章有帮助吗?

保持更新

获取每周开发技巧和新工具通知。

无垃圾邮件,随时退订。

试试这些相关工具

🐳Docker Compose GeneratorYMLYAML Validator & Formatter.gi.gitignore Generator

相关文章

Docker Compose 速查表:服务、卷和网络

Docker Compose 核心参考:服务定义、卷挂载、网络配置、环境变量和常见栈示例。

Docker Compose env_file vs environment:何时使用哪个(附示例)

理解 Docker Compose 中 env_file 和 environment 的区别。何时使用、变量优先级、.env 文件行为和多环境设置。