写一个生产级 Dockerfile
能跑起来的 Dockerfile 五分钟就能写出来,难的是三件事:镜像要小、构建要快、进程要能优雅退出。多阶段构建解决体积,依赖清单先复制解决缓存,exec 形式解决信号传递。本章按这三条主线把指令逐条讲清,并给出 Go、Python、Node 三份可直接改用的模板。总原则一句话:镜像越小越好,但别为了几十 MB 牺牲可调试性——alpine 省体积,却常常缺 glibc、缺字体、连 ps 都没有。
指令逐条解释
| 指令 | 作用 | 生产要点 |
|---|---|---|
FROM | 指定基础镜像 | 固定具体版本号,禁用 latest;构建与运行阶段分开 |
RUN | 构建时执行命令 | 多条命令用 && 合并,并在同层清理缓存 |
COPY | 复制文件进镜像 | 先排除无用文件;依赖清单单独一行好利用缓存 |
ENV / ARG | 环境变量与构建参数 | ARG 值会留在镜像历史里,不要用它传密钥 |
USER | 切换运行用户 | 非 root 运行,安全硬要求 |
CMD / ENTRYPOINT | 启动命令与固定入口 | 用 exec 数组形式,见本章倒数第二节 |
多阶段构建:Go
FROM golang:1.23-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags "-s -w -X main.version=1.4.0" -o /out/app ./cmd/app
FROM alpine:3.20
# 时区与中文字体:乱码与 8 小时时差都是缺这两个包
RUN apk add --no-cache ca-certificates tzdata font-noto-cjk \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
ENV TZ=Asia/Shanghai LANG=C.UTF-8
WORKDIR /app
COPY --from=builder /out/app /app/app
RUN adduser -D -u 10001 appuser && chown -R appuser:appuser /app
USER appuser
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8000/healthz || exit 1
ENTRYPOINT ["/app/app"]
多阶段构建:Python
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends tzdata fonts-noto-cjk \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && rm -rf /var/lib/apt/lists/*
ENV TZ=Asia/Shanghai PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
COPY --from=builder /install /usr/local
WORKDIR /app
COPY . .
RUN useradd -u 10001 -m appuser && chown -R appuser:appuser /app
USER appuser
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", \
"-w", "2", "-b", "0.0.0.0:8000", "--access-logfile", "-"]
多阶段构建:Node(构建产物交给 Nginx)
FROM node:20-bookworm-slim AS builder
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:1.27-alpine
RUN apk add --no-cache tzdata && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
COPY --from=builder /app/dist /usr/share/nginx/html
.dockerignore:上下文越小构建越快
.git
node_modules
.venv
dist
logs
*.log
.env*
*.pem
node_modules 与 .venv 不排除,构建上下文可能从几 MB 涨到几百 MB;.env 与证书更要排除,否则会被打进镜像层。
层缓存与构建顺序
Docker 的每一层都带缓存,某层输入变了,它和它之后的所有层全部失效,因此变化越少的放越前面——先复制依赖清单再安装依赖,就是这个原则最直接的应用。
| 顺序 | 内容 | 变化频率 |
|---|---|---|
| 1 | FROM 基础镜像 | 极低 |
| 2 | 系统依赖 apt-get install、依赖清单、安装依赖 | 低到中 |
| 3 | 源码 COPY . . 与构建 | 高 |
docker build --platform linux/amd64 -t myapi:1.4.0 .
# Apple Silicon 上构建给 x86 服务器用的镜像,必须显式指定平台
docker buildx build --platform linux/amd64,linux/arm64 -t myapi:1.4.0 --push .
CMD 与 ENTRYPOINT 的区别与信号传递
CMD gunicorn app:app -b 0.0.0.0:8000 # 反例:shell 形式,信号收不到
CMD ["gunicorn", "app:app", "-b", "0.0.0.0:8000"] # 正例:exec 形式
| 组合 | 行为 | 结果 |
|---|---|---|
ENTRYPOINT ["app"] + CMD ["--port=8000"] | 参数可被覆盖 | 推荐写法 |
CMD ["app"] 单独用 | 整条命令可被覆盖 | 简单容器够用 |
| 两者都用 shell 形式 | 都经过 sh | 信号丢失,坚决避免 |
docker stop 先给 PID 1 发 SIGTERM,等 10 秒(默认)后 SIGKILL。PID 1 若是 shell,信号不会转发,正在处理的请求直接被砍,用户看到 502。
验证方法
docker images myapi:1.4.0 --format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}'
docker run -d --name t --rm -p 127.0.0.1:18000:8000 myapi:1.4.0
sleep 30 && docker inspect t --format '{{.State.Health.Status}}' # 期望 healthy
docker exec -it t id # 期望 uid=10001
time docker stop t # 期望 1~2 秒退出
常见坑
| 坑 | 现象 | 做法 |
|---|---|---|
FROM python:3 浮动 tag | 今天能构建,明天报错 | 固定到 3.12-slim |
没配 .dockerignore | 构建慢、镜像里混入 .env | 排除依赖目录与密钥 |
COPY . . 在装依赖之前 | 改一行代码就重装全部依赖 | 先复制依赖清单再安装 |
ENV 里写密钥 | 镜像历史可见 | 运行期用 -e 或 secrets |
| 只装 slim 不装 tzdata/fonts | 时间差 8 小时、中文变方框 | 显式安装 tzdata、fonts-noto-cjk |
小结:Dockerfile 的三条主线是体积、速度、信号——多阶段构建把编译环境与运行环境分开,依赖清单先复制让层缓存尽量复用,exec 形式的 CMD/ENTRYPOINT 让进程收到 SIGTERM 优雅退出;固定基础镜像版本、配好 .dockerignore、非 root 运行、装齐 tzdata 与中文字体,才有资格叫生产级。