本节目标:把一个 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=1 | stdout/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) | 259MB | builder 保留构建工具,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 | ~150MB | Debian 精简版,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,攻击者进来了也无命令可用)但进不去排查,适合成熟稳定的服务。
延伸阅读
- Python 部署与分发:Docker、PyPI 发布与可复现环境 —— Docker、打包与发布的完整路径
- 输入校验、依赖供应链与漏洞加固 —— 非 root、只读文件系统、密钥外置的来龙去脉
- 应用服务器进程模型与调优
—— 镜像里那个
uvicorn到底怎么跑
小结
- 单阶段镜像把编译器和依赖一起打包,本机实测 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 快速实现 · 下一节:应用服务器进程模型与调优 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。