《Python编程实战》15.1 多阶段 Dockerfile 与镜像瘦身

从单阶段 Dockerfile 的 1.67GB 讲到多阶段构建的 259MB:实测 builder/runtime 分离、.dockerignore、层缓存重建耗时、非 root 用户、PYTHONUNBUFFERED 与 HEALTHCHECK,并给出镜像瘦身的真实数字与取舍。

本节目标:把一个 FastAPI 应用从「单阶段 1.67GB 镜像」瘦身到「多阶段 259MB」,讲透 builder/runtime 分离、层缓存顺序、非 root 运行与构建规范,并用本机实测数字说明每一步省了多少。
适用版本:Python 3.12+(镜像内 3.12.15,宿主实测 3.14.6);Docker 29.5.2(本机守护进程可用,构建已实测)

15.1 多阶段 Dockerfile 与镜像瘦身

前 14 章我们把应用、测试、配置都做扎实了,但它还只是「你机器上的一堆文件」。容器化解决的是最后一公里:把运行环境连同代码一起封成一个不可变的镜像,让「在我机器上能跑」变成「在哪台机器上都能跑」。这一节只谈一件事——怎么把这个镜像做得又小又稳。

15.1.1 先看代价:单阶段 Dockerfile

最直白的写法是把所有东西塞进一个阶段:

FROM python:3.12
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

演示应用极简,main.py 是一个带 /health 与 /items 的 FastAPI,requirements.txt 只有 fastapi==0.143.0 与 uvicorn[standard]==0.54.0:

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI(title="demo")


class Item(BaseModel):
    name: str
    price: float


@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok"}


@app.post("/items")
def create(item: Item) -> dict[str, object]:
    return {"id": 1, "item": item.model_dump(), "total": round(item.price * 1.13, 2)}

这份 Dockerfile 能跑,但它有两个致命问题:基础镜像 python:3.12 是完整版(含 gcc、大量系统库),而运行应用根本不需要编译器;COPY . . 放在 pip install 之前,任何代码改动都会让依赖层缓存失效。本机实测这个镜像 1.67GB。

15.1.2 多阶段:builder / runtime 分离

多阶段的思路是用两个阶段干两件事:builder 阶段负责「编译和安装」,runtime 阶段只拿走「装好的产物」。编译器、构建缓存、源码全留在 builder,不进最终镜像。

# ---------- Stage 1: builder(含 gcc 等构建工具) ----------
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt

# ---------- Stage 2: runtime(精简基础镜像,无构建工具) ----------
FROM python:3.12-slim AS runtime
WORKDIR /app
RUN useradd -m -u 1000 appuser
COPY --from=builder /root/.local /home/appuser/.local
COPY main.py .
ENV PATH=/home/appuser/.local/bin:$PATH \
    PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

三个关键动作:

  • pip install --user:把包装进 /root/.local,而不是系统目录。这样 runtime 阶段一句 COPY --from=builder /root/.local ... 就能整包搬走,不必重装。
  • FROM python:3.12-slim:runtime 用 slim 基础镜像,砍掉编译器和文档,只留运行必需的库。
  • COPY --from=builder:这是多阶段的灵魂——只从上一阶段取你要的文件,/root/.local 之外的一切(包括 pip 缓存、源码、gcc)都被丢弃。

本机实测:同样的应用,多阶段镜像 259MB,是单阶段的 1/6.4。用 docker history 看每层贡献:

== demo-multi layers (top→bottom) ==
        0B  CMD ["uvicorn" "main:app" "--host" "0.0.0.0" "--port" "8000"]
        0B  USER appuser
    12.3kB  COPY main.py .
    44.3MB  COPY dir:... .local          # 应用依赖
    69.6kB  RUN useradd -m -u 1000 appuser
     4.99MB  apt-get install ...          # 基础镜像里的系统层
      110MB  debian.sh --arch 'arm64'     # 基础镜像根层

可见 110MB 的 Debian 根层 + 约 50MB 的 Python 运行时是省不掉的地板,能省的正是那 44.3MB 依赖 + 编译器——单阶段里它们和 gcc 一起躺在镜像里,多阶段里 gcc 从未进入 runtime。

本机实测说明:构建环境到 deb.debian.org 的 apt 源不可达(Unable to locate package gcc),因此 builder 阶段改用自带构建工具的完整版 python:3.12 作基底,而非在 slim 上 apt-get install gcc。两者都体现「构建工具只留在 builder」的同一个原则,读者环境若能访问 apt 源,可把 builder 换成 slim + --no-install-recommends gcc。

15.1.3 .dockerignore:别把垃圾送进构建上下文

COPY . . 会把当前目录所有文件打进构建上下文,包括 .venv/、__pycache__/、.git/、本地数据库。它们既撑大镜像,又频繁变化导致缓存失效。用 .dockerignore 挡住:

.venv/
__pycache__/
*.pyc
.git/
tests/
Dockerfile*
.dockerignore

注意把 Dockerfile* 也 ignore 掉——Dockerfile 本身不该进镜像。构建上下文越小,docker build 上传到守护进程越快,这是大仓库里最容易被忽视的提速点。

15.1.4 层缓存:变化少的放前面

Docker 每一行指令产生一个层,某层变了,它之后的所有层都要重建。所以顺序原则是「变化频率低的放前面」。COPY . . 在 pip install 之前是经典反模式:改一行代码就重装全部依赖。

本机实测,仅修改 main.py 后重建:

naive rebuild after code change: 24.4s   (依赖层失效→重装)
multi rebuild after code change: 2.3s    (依赖层命中→秒级)

多阶段版把 COPY main.py 放在依赖层之后,代码改动只重建最后两层,依赖层命中缓存,重建从 24 秒降到 2 秒。CI 里每次提交都重建镜像,这个差别会乘以提交次数。

15.1.5 非 root 运行

默认容器以 root 运行,一旦应用被攻破,攻击者在容器内就是 root,能装包、改文件、探测内网。第 9 章的「最小权限」原则在容器里的落点就是建一个普通用户并 USER 切过去:

RUN useradd -m -u 1000 appuser
COPY --from=builder /root/.local /home/appuser/.local
ENV PATH=/home/appuser/.local/bin:$PATH
USER appuser

实测进入容器验证:

$ docker exec demo-multi-run whoami
appuser
$ docker exec demo-multi-run id -u
1000

配合只读根文件系统(运行时 docker run --read-only)和 --tmpfs /tmp 挂载可写临时目录,能进一步压缩被攻破后的破坏面。

15.1.6 PYTHONUNBUFFERED 与 PYTHONDONTWRITEBYTECODE

两个环境变量几乎每个 Python 镜像都该有:

变量作用不设的后果
PYTHONUNBUFFERED=1stdout/stderr 不缓冲,立即刷出日志卡在缓冲区,docker logs 看不到,崩溃时丢日志
PYTHONDONTWRITEBYTECODE=1不写 .pyc 文件容器内产生 __pycache__,只读文件系统下报错

PYTHONUNBUFFERED 在容器里尤其重要——标准输出被重定向到日志驱动,缓冲会让「实时日志」变成「延迟日志」,排查故障时非常致命。

15.1.7 HEALTHCHECK:让编排系统知道容器还活着

HEALTHCHECK 告诉 Docker(以及 K8s、Swarm)容器是否健康。它应该打一个轻量的、不依赖外部服务的端点:

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

用标准库 urllib 而不是 curl,是因为 slim 镜像里没有 curl,装它又多一层依赖。--start-period 给应用启动留出宽限,避免刚启动就被判死。

15.1.8 构建规范与 lint(hadolint)

Dockerfile 也有 lint 工具。hadolint 本机未安装(which hadolint 为空),所以下面是它常见的规则,未实测:

hadolint Dockerfile

它会检查的典型问题:固定基础镜像版本(别用 latest)、合并 RUN 减少层数、apt-get 后清理 /var/lib/apt/lists/*、避免 sudo、COPY 优先于 ADD。即便不装 hadolint,把这套规范写进代码评审清单也够用。能装上就 hadolint Dockerfile 接进 CI,Dockerfile 也能享受静态检查。

15.1.9 镜像瘦身对比

把本机实测的数字汇总:

方案镜像体积说明
单阶段(python:3.12 全量)1.67GB含 gcc、pip 缓存,构建简单
多阶段(slim runtime)259MBbuilder 保留构建工具,runtime 只留产物
理论下限(Debian 根层 + 运行时)~160MB基础镜像地板,依赖另计

瘦身的收益不只是磁盘:拉取更快(CI 拉镜像时间直接缩短)、攻击面更小(少一个 gcc 就少一类编译型攻击)、冷启动更快(层少、文件少,容器启动更轻)。

15.1.10 跑起来验证:镜像真的能服务

构建成功不等于能服务。把镜像跑起来,实测打两个接口:

docker run -d --rm -p 18000:8000 --name demo-multi-run demo-multi
docker exec demo-multi-run whoami     # -> appuser
GET  /health -> 200 {"status":"ok"}
POST /items  -> 200 {"id":1,"item":{"name":"book","price":100.0},"total":113.0}

/items 返回的 total=113.0 正是 100 * 1.13,说明容器里 FastAPI + Pydantic 的校验与计算都正常。docker inspect --format '{{.State.Health.Status}}' 会先从 starting 变为 healthy,这就是上一小节 HEALTHCHECK 在起作用——镜像不仅要能构建,还要能被编排系统判定为「健康」。

15.1.11 基础镜像怎么选

runtime 的基础镜像不只有 slim 一种,选错会踩坑:

基础镜像体积量级特点坑
python:3.12(全量)~1GB+带 gcc、完整工具链太大,不适合做 runtime
python:3.12-slim~150MBDebian 精简版,glibc推荐默认
python:3.12-alpine~50MB基于 musl libc,最小编译型包常无 musl wheel,需现场编译
distroless~65MB无 shell、无包管理器调试困难,无 pip

Alpine 看着最小,但 musl libc 与 glibc 不兼容:很多含 C 扩展的包(numpy、psycopg2、cryptography)在 Alpine 上没有预编译 wheel,要现场编译——构建变慢,镜像反而可能更大。除非确知依赖兼容,否则 slim 是更稳的默认。distroless 最安全(没有 shell,攻击者进来了也无命令可用)但进不去排查,适合成熟稳定的服务。

延伸阅读

小结

  • 单阶段镜像把编译器和依赖一起打包,本机实测 1.67GB;多阶段用 builder/runtime 分离降到 259MB(约 1/6.4)。
  • 多阶段的核心是 COPY --from=builder 只取产物:pip install --user 装到 /root/.local,runtime 整包搬走,编译器永不进镜像。
  • 层缓存顺序决定重建速度:COPY . . 放前面,改代码就重装依赖(实测 24.4s);放后面只需 2.3s。
  • 非 root 运行(USER appuser,uid 1000)+ 只读文件系统是最小权限的容器落点。
  • PYTHONUNBUFFERED=1 保证日志实时,PYTHONDONTWRITEBYTECODE=1 适配只读文件系统;HEALTHCHECK 用标准库打 /health。
  • hadolint 本机未装,规范照讲;能装就接进 CI。

镜像做好了,下一个问题是:镜像里那个 uvicorn 进程该怎么配?开几个 worker、为什么开这么多——这正是下一节要实测的应用服务器进程模型。

阅读导航:上一节:桌面 GUI 快速实现 · 下一节:应用服务器进程模型与调优 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时