一份写得好的 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-slimRUN — 执行命令
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.jsENTRYPOINT — 固定命令
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:8000WORKDIR — 工作目录
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 MB | Musl 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,HIGHRUN 优化
每条 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-removeCOPY 与 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选项说明
--interval— interval — 检查间隔(默认:30s)--timeout— timeout — 检查最大等待时间(默认:30s)--retries— retries — 标记为不健康前的连续失败次数(默认:3)--start-period— start_period — 容器启动的宽限期(默认:0s)
ARG 与 ENV 对比
ARG 和 ENV 都定义变量,但它们在不同时间可用,生命周期也不同:
| 特性 | ARG | ENV |
|---|---|---|
| 可用时间 | 仅构建时 | 构建时 + 运行时 |
| 在最终镜像中 | 否 | 是 |
| 覆盖方式 | --build-arg 标志 | -e 标志或 .env 文件 |
| 默认值 | ARG NAME=default | ENV 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 用于用户可能覆盖的默认参数。