第一次构建完 FastAPI 应用的 Docker 镜像时,看到 1.2GB 的那一刻我是崩溃的。一个几十 MB 的 Python 应用,为什么镜像会这么大?经过几轮迭代优化,最终把它瘦身到了 120MB——缩小了整整 10 倍。这个过程没有魔法,每一步都是可以复制的工程决策。

起点:为什么是 1.2GB?

最初的 Dockerfile 非常简单,也是最常见的「新手写法」:

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

问题出在 python:3.12 这个基础镜像——它是基于 Debian 的完整系统镜像,包含编译器、开发头文件、文档等大量运行时不需要的东西。仅基础镜像本身就接近 1GB。

第一刀:多阶段构建

多阶段构建是 Docker 镜像优化的核心武器。思路很简单:用一个阶段编译/构建,另一个阶段运行,只把最终产物复制到运行阶段。

# === 构建阶段 ===
FROM python:3.12-slim AS builder
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install --no-cache-dir uv && \
    uv pip install --system -r pyproject.toml

# === 运行阶段 ===
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY src/ ./src/
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

仅这一步,镜像就从 1.2GB → 350MB。构建工具链(gcc、头文件等)全部留在了 builder 阶段,不会进入最终镜像。

第二刀:Alpine vs Slim 的选择

接下来面临一个经典选择:python:3.12-slim(基于 Debian)还是 python:3.12-alpine(基于 Alpine Linux)?

Alpine 镜像极小,只有 ~50MB。但代价是它使用 musl libc 而不是 glibc,某些 Python 包(特别是带 C 扩展的,如 psutilpillow)需要额外的编译依赖,而且 musl 的 DNS 解析和线程行为与 glibc 有细微差异。

对于纯 Python 应用,我推荐 Slim——省去了 Alpine 的兼容性问题,同时保持了合理的体积。切换到 python:3.12-slim 后,基础镜像本身从 1GB 降到 130MB。再加上 --no-install-recommends 跳过 apt 的推荐包安装,最终基础层控制在 ~160MB。

pip install 的缓存清理技巧

很多人的 Dockerfile 里都有 pip install,但没注意到 pip 默认会缓存下载的包。在 Docker 构建中,这层缓存会被永久写入镜像层:

# ❌ 缓存会被保留在镜像中
RUN pip install -r requirements.txt

# ✅ 三个关键参数一起用
RUN python -m pip install --no-cache-dir \
    --no-deps \
    -r requirements.txt && \
    rm -rf /root/.cache/pip

--no-cache-dir 禁止 pip 缓存;python -m pip 确保使用当前 Python 解释器的 pip(避免多版本混乱);最后的 rm -rf 清理任何残留。这一步通常能减少 50-100MB。

为什么用 pyproject.toml 而不是 requirements.txt

迁移到 pyproject.toml 不仅仅是跟随社区趋势,对 Docker 构建也有实际好处:

[project]
name = "my-api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "fastapi>=0.115.0",
    "uvicorn[standard]>=0.30.0",
    "sqlalchemy[asyncio]>=2.0.30",
]

配合 uv.lockpoetry.lock,依赖的版本完全锁定且可重现。相比之下,requirements.txt 没有标准的锁文件机制,pip freeze 输出的是完整依赖树(包括子依赖),无法区分直接依赖和传递依赖——这在审计和安全扫描时是个大问题。

.dockerignore 的常见遗漏项

.dockerignore 是最容易被忽视的优化点。发送到 Docker daemon 的 build context 越大,构建越慢。以下是我经过多次教训后总结的必加项:

# 版本控制
.git
.gitignore

# Python
__pycache__
*.pyc
*.pyo
.venv
venv/
*.egg-info/

# IDE
.vscode/
.idea/

# 测试 & 文档
tests/
docs/
README.md

# Docker 自身
Dockerfile
.dockerignore

# 环境变量(敏感信息!)
.env
.env.*

特别要注意 .env 文件——如果不加进 .dockerignore,你的数据库密码、API 密钥可能就一起打包进了镜像。这不仅是体积问题,更是安全红线

安全扫描:Trivy 的基础使用

瘦身不是终点,安全同样重要。镜像变小后,用 Trivy 扫描漏洞是 CI/CD 流水线中的标准步骤:

# 安装(一行命令)
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh

# 扫描镜像
trivy image my-api:latest

# 只显示高危漏洞
trivy image --severity HIGH,CRITICAL my-api:latest

Alpine 镜像在 Trivy 扫描中的表现通常优于 Slim——因为软件包更少,攻击面更小。这也是 Alpine 的一个隐藏优势。

最终成果

完整的优化链路和每一步的体积变化:

  1. python:3.12 → 1.2GB(起点)
  2. 切换到 Slim → 350MB
  3. 多阶段构建 → 220MB
  4. pip 缓存清理 + .dockerignore → 170MB
  5. 删除不必要的系统包 → 150MB
  6. 最终打磨 → 120MB

从 1.2GB 到 120MB,10 倍的瘦身。每一层的优化都是可衡量、可复制的——这才是工程。下次构建镜像前,花 10 分钟审查你的 Dockerfile,说不定也能砍掉半 GB。