第一次构建完 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 扩展的,如 psutil、pillow)需要额外的编译依赖,而且 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.lock 或 poetry.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 的一个隐藏优势。
最终成果
完整的优化链路和每一步的体积变化:
- python:3.12 → 1.2GB(起点)
- 切换到 Slim → 350MB
- 多阶段构建 → 220MB
- pip 缓存清理 + .dockerignore → 170MB
- 删除不必要的系统包 → 150MB
- 最终打磨 → 120MB
从 1.2GB 到 120MB,10 倍的瘦身。每一层的优化都是可衡量、可复制的——这才是工程。下次构建镜像前,花 10 分钟审查你的 Dockerfile,说不定也能砍掉半 GB。