跳过正文

Dockerfile 编写最佳实践

·962 字·5 分钟·
目录

一、基础原则
#

在写 Dockerfile 之前,确立几条核心原则,后续所有细节都是围绕这些原则展开:

  1. 每条指令一个职责:不要把不相关的操作塞进同一个 RUN,除非是为了合并 layer 避免缓存污染
  2. 最小权限:运行容器的进程不应该是 root,非必要不暴露端口,非必要不挂 volume
  3. 可重现构建:相同的源码和 Dockerfile 应该产出相同的镜像,避免依赖网络上的 latest tag 或浮动版本
  4. 显式优于隐式:版本号要 pin 住,基础镜像要指定 digest 或精确 tag

二、指令详解与最佳用法
#

FROM
#

# 差:latest 不稳定,每次构建可能拿到不同的基础镜像
FROM ubuntu:latest

# 好:pin 到精确版本
FROM ubuntu:24.04

# 更好:用 digest 确保内容不变(适合安全要求极高的场景)
FROM ubuntu:24.04@sha256:723ad8033f109978f8c7e6421ee684efb624eb5b9251b70c6788fdb2405d050b

多阶段构建时,给每个 stage 命名:

FROM golang:1.23-alpine AS builder
FROM gcr.io/distroless/static-debian12 AS runtime

RUN
#

合并相关命令,尤其是 apt-get updateapt-get install 必须在同一条 RUN,否则 layer 缓存会导致使用过期的 apt 索引:

# 错误:update 和 install 分开会有缓存问题
RUN apt-get update
RUN apt-get install -y curl

# 正确:合并 + 清理缓存
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
      curl \
      ca-certificates && \
    rm -rf /var/lib/apt/lists/*

利用 BuildKit 的 cache mount(不写入镜像层):

# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/var/cache/apt \
    apt-get update && \
    apt-get install -y --no-install-recommends curl

COPY vs ADD
#

永远优先用 COPY,只在必要时用 ADD

# COPY: 明确,只从本地复制文件/目录
COPY src/ /app/src/
COPY config.yaml /app/

# ADD 的额外功能(一般不需要):
# 1. 自动解压 tar 包
ADD archive.tar.gz /app/   # 会自动解压
# 2. 从 URL 下载(不推荐,应该在 RUN 里用 curl 并做 checksum 校验)
ADD https://example.com/file /tmp/

ADD 的行为对阅读者不够透明,且 URL 方式没有 checksum 校验,安全性差。

CMD 与 ENTRYPOINT
#

这两条是最容易混淆的指令,下面的对比表说明一切:

ENTRYPOINTCMD
作用容器的主命令(固定)主命令的参数(可覆盖)
覆盖方式docker run --entrypointdocker run ... [CMD]
推荐格式exec 格式(JSON 数组)exec 格式(JSON 数组)

常见组合方式

# 方式 1: 只用 CMD(完全可覆盖)
CMD ["python", "app.py"]
# docker run myimage              → python app.py
# docker run myimage bash         → bash(替换整个命令)

# 方式 2: 只用 ENTRYPOINT(命令固定,参数拼接)
ENTRYPOINT ["nginx"]
# docker run myimage              → nginx
# docker run myimage -g "daemon off;"   → nginx -g "daemon off;"

# 方式 3: ENTRYPOINT + CMD 组合(推荐用于服务)
ENTRYPOINT ["/server"]
CMD ["--port=8080", "--log-level=info"]
# docker run myimage              → /server --port=8080 --log-level=info
# docker run myimage --port=9090  → /server --port=9090(覆盖 CMD 部分)

不要用 shell 格式(会导致 PID 1 问题,下面详细说):

# 差:shell 格式,进程是 /bin/sh -c 的子进程
ENTRYPOINT python app.py

# 好:exec 格式,进程直接是 PID 1
ENTRYPOINT ["python", "app.py"]

ENV 与 ARG
#

# ARG: 仅在构建时有效,不写入最终镜像
ARG BUILD_VERSION=dev
ARG TARGETARCH

# ENV: 写入镜像,容器运行时可见
ENV APP_ENV=production
ENV LOG_LEVEL=info

# 两者结合:构建时传参,运行时可见
ARG APP_VERSION
ENV APP_VERSION=${APP_VERSION:-unknown}

构建时传入 ARG:

docker build --build-arg APP_VERSION=1.2.0 -t myapp:1.2.0 .

注意:不要通过 ARG 传递 secret,构建历史中可见。应使用 --mount=type=secret

# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=github_token \
    GITHUB_TOKEN=$(cat /run/secrets/github_token) \
    git clone https://oauth2:${GITHUB_TOKEN}@github.com/private/repo.git

EXPOSE
#

# EXPOSE 只是文档声明,不实际开放端口
# 实际映射需要 docker run -p 8080:8080
EXPOSE 8080
EXPOSE 9090  # metrics

即使不写 EXPOSE,容器内的进程监听端口照样可以被访问(只要端口映射正确)。EXPOSE 的价值在于文档化和 docker run -P 随机映射时使用。

VOLUME
#

# 声明匿名 volume,容器删除后数据丢失(除非显式挂载)
VOLUME ["/data", "/logs"]

生产环境中,建议在 Kubernetes 的 manifest 中显式声明 PVC,不依赖 Dockerfile 的 VOLUME 指令。

WORKDIR
#

# 用绝对路径,不要用 cd
WORKDIR /app

# 可以多次使用,路径会叠加
WORKDIR /app/src   # 等于 cd /app && mkdir src && cd src

HEALTHCHECK
#

# 基础 HTTP 健康检查
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:8080/health || exit 1

# 使用 wget(alpine 通常有 wget 但没有 curl)
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
    CMD wget -qO- http://localhost:8080/health || exit 1

# 对于没有 shell 的 distroless 镜像,需要内置健康检查二进制
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
    CMD ["/healthcheck"]

参数说明:

  • --interval:检查间隔(默认 30s)
  • --timeout:单次检查超时(默认 30s)
  • --start-period:容器启动后的等待时间,期间失败不计入 retries(默认 0s,启动慢的服务要调高)
  • --retries:连续失败多少次后标记为 unhealthy(默认 3)

USER
#

# 创建非 root 用户
RUN groupadd -g 1001 appgroup && \
    useradd -u 1001 -g appgroup -s /bin/false -r appuser

# 切换到非 root 用户(之后的所有指令都以此用户执行)
USER appuser

# distroless 镜像内置了 nonroot 用户
FROM gcr.io/distroless/static-debian12:nonroot
# 已经是 nonroot 用户,无需额外 USER 指令

三、PID 1 问题与信号处理
#

容器内的第一个进程(PID 1)有特殊职责:它负责接收和转发信号,回收僵尸进程。

问题:普通应用程序(如 Python/Node.js 进程)通常不处理这些职责,导致:

  • docker stop 发送 SIGTERM 后,应用不响应,等 10 秒后被 SIGKILL 强杀
  • 子进程变成僵尸进程无法回收

解决方案 1:使用 tini

# 安装 tini(轻量级 init)
RUN apt-get install -y tini

ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["python", "app.py"]

或者使用 Docker 内置的 --init 标志(不修改 Dockerfile):

docker run --init my-app

解决方案 2:使用 dumb-init

RUN apt-get install -y dumb-init

ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["/server"]

解决方案 3:应用层面优雅退出(Go 示例)

func main() {
    ctx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGTERM, syscall.SIGINT)
    defer stop()

    server := &http.Server{Addr: ":8080"}

    go func() {
        <-ctx.Done()
        shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()
        server.Shutdown(shutdownCtx)
    }()

    server.ListenAndServe()
}

四、完整生产级示例
#

Go 服务
#

# syntax=docker/dockerfile:1

# ── 构建阶段 ──────────────────────────────────────────────────
FROM golang:1.23-alpine AS builder

# 安装构建依赖
RUN apk add --no-cache git ca-certificates tzdata

WORKDIR /build

# 先复制依赖文件,利用缓存
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

# 复制源码
COPY . .

# 静态编译
ARG APP_VERSION=dev
ARG COMMIT_SHA=unknown
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -ldflags="-s -w \
              -X main.Version=${APP_VERSION} \
              -X main.CommitSHA=${COMMIT_SHA} \
              -extldflags '-static'" \
    -trimpath \
    -o /app/server \
    ./cmd/server

# 构建健康检查工具(如果需要在 distroless 中用)
RUN CGO_ENABLED=0 go build -o /app/healthcheck ./cmd/healthcheck

# ── 运行阶段 ──────────────────────────────────────────────────
FROM gcr.io/distroless/static-debian12:nonroot

# 时区数据
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# CA 证书(HTTPS 请求需要)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# 复制二进制
COPY --from=builder /app/server /server
COPY --from=builder /app/healthcheck /healthcheck

# 暴露端口(文档用途)
EXPOSE 8080 9090

# 健康检查
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
    CMD ["/healthcheck"]

# distroless:nonroot 已经是非 root 用户(UID 65532)
ENTRYPOINT ["/server"]

Python 服务
#

# syntax=docker/dockerfile:1

# ── 依赖安装阶段 ──────────────────────────────────────────────
FROM python:3.12-slim AS dependencies

WORKDIR /install

# 安装编译依赖
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
      gcc \
      libpq-dev && \
    rm -rf /var/lib/apt/lists/*

# 复制依赖声明
COPY requirements.txt ./

# 安装到独立目录,方便复制到运行镜像
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-cache-dir --prefix=/python-deps -r requirements.txt

# ── 运行阶段 ──────────────────────────────────────────────────
FROM python:3.12-slim AS runtime

# 安装运行时依赖(非编译时)
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
      libpq5 \
      dumb-init \
      curl && \
    rm -rf /var/lib/apt/lists/*

# 创建非 root 用户
RUN groupadd -g 1001 appgroup && \
    useradd -u 1001 -g appgroup -s /bin/false -r -d /app appuser

WORKDIR /app

# 复制已安装的 Python 依赖
COPY --from=dependencies /python-deps /usr/local

# 复制应用代码
COPY --chown=appuser:appgroup src/ ./src/

# 切换用户
USER appuser

EXPOSE 8000

# 健康检查
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

# dumb-init 解决 PID 1 问题
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["python", "-m", "uvicorn", "src.main:app", \
     "--host", "0.0.0.0", \
     "--port", "8000", \
     "--workers", "4", \
     "--no-access-log"]

五、常见误区总结
#

误区正确做法
root 用户运行应用创建专用用户,USER appuser
ENTRYPOINT 用 shell 格式用 exec 格式(JSON 数组)
FROM 用 latestPin 到精确版本
构建和运行用同一镜像多阶段构建
COPY 顺序不优化先复制依赖文件,后复制源码
不写 .dockerignore维护完善的 .dockerignore
不处理 SIGTERM使用 dumb-init/tini,或应用层优雅退出
不设置 HEALTHCHECK配置合理的健康检查(含 start-period)
ARG 传 secret--mount=type=secret
ADD 替代 COPY优先 COPY,ADD 只用于解压 tar
Wenzhuo Huang
作者
Wenzhuo Huang
搞运维的工程师,写代码的运维人。专注 Kubernetes、AWS、GitOps 与基础设施可靠性。这个博客既是我的技术笔记本,也是我踩过的坑的受害者档案。

相关文章

Docker 镜像优化实践

·900 字·5 分钟
覆盖多阶段构建、基础镜像选型(alpine/distroless/scratch)、layer 缓存优化、BuildKit cache mount、漏洞扫描等实战技巧,附优化前后对比数据。