前言

docker run 只是开始,把应用做成自己可控的镜像才算入门容器化。同样的应用,Dockerfile 写法不同,镜像可以从 1GB 到 20MB——这篇讲怎么写出又小又快又安全的镜像。

一、基础指令速览

FROM python:3.11-slim          # 基础镜像(一切的地基)
WORKDIR /app                   # 工作目录(不存在会自动创建)
COPY app.py requirements.txt ./   # 拷贝文件(比 ADD 更纯粹, 推荐)
RUN pip install -r requirements.txt   # 构建期执行命令
ENV TZ=Asia/Shanghai           # 环境变量
EXPOSE 8000                    # 声明端口(文档性质)
CMD ["python", "app.py"]       # 容器启动命令(可被 run 参数覆盖)
ENTRYPOINT ["./server"]        # 入口(不易被覆盖, 与 CMD 配合)

CMD vs ENTRYPOINT:

ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8000"]         # CMD 变成传给 ENTRYPOINT 的默认参数
# docker run myimg               -> python app.py --port 8000
# docker run myimg --port 9000   -> python app.py --port 9000

COPY vs ADD:日常一律 COPY;只有需要自动解压 tar(ADD app.tar.gz /opt/)或加远程 URL(不推荐)时才用 ADD。

shell 格式 vs exec 格式:

CMD python app.py          # shell 格式: 实际执行 /bin/sh -c "python app.py", PID1 是 sh
CMD ["python", "app.py"]   # exec 格式: python 直接是 PID1 ✅(信号能正确传递)

二、分层缓存:指令顺序的艺术

每条指令生成一层,层有缓存;某层失效,其后所有层全部重建。

# ❌ 错误示范:改一行代码, 依赖重装一遍
COPY . /app
RUN pip install -r /app/requirements.txt

# ✅ 正确:先拷依赖清单装依赖(很少变, 缓存命中), 再拷代码
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .

从上到下按变动频率从低到高排列:基础镜像 → 系统依赖 → 语言依赖 → 源代码。

三、多阶段构建:瘦身的王牌

编译期需要的工具链(JDK、Go 工具链、node_modules)运行期完全不需要。多阶段构建只把产物带走:

Go 应用(1GB+ → 20MB 以内)

# ---------- 阶段1: 构建 ----------
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download             # 依赖层缓存
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/server .

# ---------- 阶段2: 运行 ----------
FROM alpine:3.19
RUN adduser -D -u 10001 app && apk add --no-cache tzdata ca-certificates
COPY --from=builder /out/server /usr/local/bin/server
USER app
ENTRYPOINT ["server"]

甚至可以 FROM scratch(空镜像),Go 静态编译的程序直接跑,最终镜像 ≈ 二进制本身大小。

Python 应用

FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1

FROM base AS builder
WORKDIR /wheels
COPY requirements.txt .
RUN pip wheel --no-cache-dir -w . -r requirements.txt

FROM base
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/*.whl && rm -rf /wheels
COPY . .
RUN useradd -m appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "app:app", "-b", "0.0.0.0:8000"]

四、最佳实践清单

镜像体积

  • 选 slim/alpine 基础镜像(python:3.11-slim 而非 python:3.11)
  • apt install 后清理:rm -rf /var/lib/apt/lists/*
  • 多阶段构建,不带构建工具链
  • .dockerignore 排除无关文件(.git、node_modules、.env、测试数据)
# .dockerignore
.git
.venv
__pycache__
*.pyc
.env
tests/
docs/
*.md

可复现性

  • 基础镜像锁 digest:FROM python@sha256:abc123...(安全级别要求时)
  • 不用 latest 标签
  • 依赖清单锁版本(requirements.txt 带 ==、go.sum)

安全

  • 非 root 运行(上面示例的 adduser/useradd)
  • 不在镜像里放密钥:COPY .env . 是重大事故源头
  • 最小化安装:不需要的包不装(减少攻击面)
  • 镜像扫描:docker scout / Trivy
# Trivy 扫描漏洞
trivy image myapp:v1.2.0

运行健康

  • 日志输出到 stdout/stderr(交给 docker logs / 收集器)
  • 优雅退出:程序处理 SIGTERM,配合 docker stop -t 30
  • 定义 HEALTHCHECK(编排系统会更早发现问题)
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget -qO- http://127.0.0.1:8000/health || exit 1

五、构建与推送

# 构建(注意最后的点 = 构建上下文路径)
docker build -t myapp:v1.2.0 .

# 看构建过程与缓存命中
docker build --progress=plain -t myapp:v1.2.0 .

# 对比优化前后体积
docker images | grep myapp

# 推送私有仓库(如 Harbor)
docker tag myapp:v1.2.0 reg.local/team/myapp:v1.2.0
docker push reg.local/team/myapp:v1.2.0

六、常见坑

现象 原因
改代码构建依然用缓存 COPY 上下文没变化 / .dockerignore 排除了文件
容器时区不对 加 ENV TZ=Asia/Shanghai + 装 tzdata
docker stop 要等 10s PID1 是 sh 没转发信号,用 exec 格式 CMD
镜像越滚越大 历史层里有垃圾,用多阶段构建根除
容器里连不上外网 检查 DNS:docker run --rm busybox nslookup baidu.com

小结

原则 手段
快 分层缓存 + 指令按变动频率排序
小 多阶段构建 + slim 基础镜像 + .dockerignore
安全 非 root + 不放密钥 + 锁版本 + 扫描
可靠 stdout 日志 + 信号处理 + HEALTHCHECK

本文是「云原生」系列第 3 篇。