一个 50 行的 Flask 应用打出 1.2G 的 Docker 镜像------这不是段子,是我第一次写 Dockerfile 的真实经历。
引言
第一次把项目容器化的时候,我写出了这样的 Dockerfile:
dockerfile
FROM python:3.12
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
CMD ["python", "app.py"]
docker build 跑了 5 分钟,出来一个 1.2G 的镜像。每次 CI/CD 流水线卡在 docker push 那一步,部署一次要等 8 分钟。后来花了两个月系统性地优化,最终把镜像从 1.2G 压到了 80MB,构建时间从 8 分钟降到 40 秒。
这篇文章总结了五个关键优化步骤,每一步都附带了可复用的 Dockerfile 片段和背后的原理说明。
1. 多阶段构建:分离编译环境与运行环境
多阶段构建(Multi-stage Build)是 Docker 17.05 引入的特性,也是镜像瘦身最重要的手段。核心思想很简单:用一个阶段做构建,用另一个阶段做运行,两个阶段共享产物但不共享环境。
1.1 为什么需要多阶段?
传统的单阶段 Dockerfile 有一个根本矛盾:构建时需要编译器、头文件、开发工具链,但运行时一个都不需要。单阶段构建无法摆脱这些构建依赖------你的最终镜像里带着 gcc、git、make 等几百 MB 永远用不上的东西。
1.2 多阶段 Dockerfile 模板
dockerfile
# === 阶段 1:builder(构建环境) ===
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt
# === 阶段 2:runner(运行环境) ===
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "app.py"]
关键指令:
FROM ... AS builder:给第一阶段命名,后续可以引用。COPY --from=builder:从 builder 阶段拷贝文件到当前阶段,只拷贝你指定的路径。builder 阶段装的 gcc、git 等全部留在第一阶段,不会进入最终镜像。pip install --user:把 Python 包装到/root/.local,这是一个独立目录,方便后续精确拷贝。
1.3 多阶段的效果
| 阶段 | 镜像大小 | 包含内容 |
|---|---|---|
builder(python:3.12) |
~1GB | Python + gcc + git + make + ... |
runner(python:3.12-slim) |
~150MB + 你的代码 | Python 运行时 + 你的依赖 |
builder 阶段在构建完成后被丢弃,只保留 runner 阶段的镜像。你可以用 docker image ls 看到 builder 的中间镜像,用 docker image prune 清理。
2. 选择正确的基础镜像
基础镜像的选择直接决定了镜像的"地板"有多高。Python 官方提供了多个 tag,它们的大小差异巨大:
| 镜像 Tag | 压缩后大小 | 适用场景 |
|---|---|---|
python:3.12 |
~1GB | ❌ 不要用于生产 |
python:3.12-slim |
~150MB | ✅ 生产环境首选 |
python:3.12-alpine |
~50MB | ⚡ 极致瘦身 |
2.1 slim vs alpine 的选择
slim 版基于 Debian,使用 glibc。砍掉了编译工具链、文档、man pages,但保留了运行时必需的库。兼容性最好------几乎所有 Python 包都有兼容 glibc 的预编译 wheel。
alpine 版基于 Alpine Linux,使用 musl libc。比 slim 再小 100MB,但代价是某些依赖 C 扩展的 Python 包(如 pandas、lxml、grpcio)需要从源码编译或安装额外的系统包:
dockerfile
FROM python:3.12-alpine
RUN apk add --no-cache gcc musl-dev libffi-dev # pandas 等包需要的系统依赖
建议:默认用 slim,只有在对镜像大小有极致要求时再迁移到 alpine。
2.2 锁版本,不要用 latest
dockerfile
FROM python:3.12-slim # ✅ 明确版本
FROM python:slim # ❌ latest tag,今天和明天可能不一样
3. 层缓存优化:Dockerfile 的书写顺序决定构建速度
Docker 的构建是分层进行的------每个 RUN、COPY、ADD 指令产生一个新层。如果某一层的输入没有变化,Docker 会复用缓存,跳过该层的执行。
核心原则:把变化频率最低的文件放在前面,变化频率最高的放在最后。
3.1 错误 vs 正确的 COPY 顺序
dockerfile
# ❌ 错误:代码变了,依赖要重新安装
COPY . /app
RUN pip install -r requirements.txt
dockerfile
# ✅ 正确:依赖文件单独 COPY,充分利用缓存
COPY requirements.txt /app/
RUN pip install -r /app/requirements.txt
COPY . /app
原理分析:
requirements.txt 的修改频率远低于源代码。当你改了一行业务逻辑,requirements.txt 的 hash 不变 → pip install 层命中缓存 → 只有最后一个 COPY . /app 层被重建。
构建时间对比(一个典型的 Python 项目):
| 场景 | 构建时间 |
|---|---|
| 依赖 COPY 在后(每次重装) | ~3 分钟 |
| 依赖 COPY 在前(缓存命中) | ~2 秒 |
3.2 .dockerignore:别让垃圾文件破坏缓存
COPY . /app 会把当前目录的所有文件复制进镜像------包括 .git、__pycache__、.venv、node_modules 等。这些文件不仅增大镜像体积,还会导致不必要的缓存失效。
dockerignore
# .dockerignore
__pycache__
*.pyc
*.pyo
.env
.git
.gitignore
.venv
venv
node_modules
*.log
.DS_Store
docker-compose.yml
README.md
一个包含 .git 目录的项目,.dockerignore 能减少 100MB+ 的 context 传输。
4. pip install 的优化参数
pip install 默认行为会在镜像中留下大量运行时不需要的产物。三个参数可以直接砍掉:
dockerfile
RUN pip install \
--no-cache-dir \ # 不保存 pip 下载缓存
--no-compile \ # 不生成 .pyc 字节码文件
-r requirements.txt
各参数说明:
--no-cache-dir:pip 默认会把下载的.whl文件缓存到~/.cache/pip,这在容器里完全没用------容器是无状态的,下次构建不会复用这个缓存。去掉后能省 100MB+。--no-compile:Python 默认会在安装包时编译.pyc文件。在容器里这些字节码文件几乎不会被用到(因为容器启动后通常只执行一次入口脚本),去掉后能省几十 MB。
4.1 依赖拆分:生产环境不需要 pytest
dockerfile
# requirements.txt ------ 只放运行时依赖
flask==3.0
gunicorn==22.0
# requirements-dev.txt ------ 开发/测试工具
pytest>=8.0
pytest-cov>=5.0
black>=24.0
Dockerfile 里只装生产依赖:
dockerfile
RUN pip install --no-cache-dir -r requirements.txt
这比"全装进去然后不需要的放着"直接省掉几十个包。
5. Distroless 镜像:安全与瘦身的终极选择
Google 开源的 distroless 镜像只包含你的应用及其运行时依赖------连 shell 都没有。
dockerfile
# builder 阶段
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# runner 阶段:distroless
FROM gcr.io/distroless/python3
COPY --from=builder /root/.local /root/.local
COPY . /app
WORKDIR /app
ENV PATH=/root/.local/bin:$PATH
CMD ["app.py"]
Distroless 镜像的特点:
- 没有包管理器(
apt、apk、pip均不可用) - 没有 shell(
/bin/sh不存在) - 没有 curl、wget 等工具
- 只包含 Python 解释器和标准库
安全收益: 即使攻击者找到了一个 RCE 漏洞进入了容器,他也无法执行任何命令------因为没有 shell 可以执行。这符合最小攻击面原则。
调试限制: distroless 镜像不能用 docker exec -it <container> /bin/sh 进入容器。调试时使用 slim 镜像,确认问题后切换到 distroless。
镜像大小对比(以同一个 Flask 应用为例):
| 基础镜像 | 最终镜像大小 |
|---|---|
python:3.12 |
1.2GB |
python:3.12-slim |
180MB |
python:3.12-slim + 多阶段 |
120MB |
python:3.12-alpine + 多阶段 |
80MB |
| distroless + 多阶段 | 50MB |
完整瘦身 Dockerfile
把以上五个优化整合到一起:
dockerfile
# ===== builder stage =====
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir --no-compile -r requirements.txt
# ===== runner stage =====
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
EXPOSE 5000
USER 1000:1000
CMD ["python", "app.py"]
关键设计决策:
USER 1000:1000:以非 root 用户运行,最小化安全风险。EXPOSE 5000:声明端口,配合 docker-compose 或 K8s 做服务发现。ENV PATH:确保 Python 能找到装在/root/.local里的包。
最终效果:镜像从 1.2G → 80MB,瘦了 93%。构建时间从 8 分钟降到 40 秒。
总结
| 优化步骤 | 核心操作 | 节省的体积 |
|---|---|---|
| 多阶段构建 | FROM ... AS builder + COPY --from=builder |
~500MB(排除编译工具) |
| 换基础镜像 | python:3.12 → python:3.12-slim |
~850MB |
| 层缓存优化 | COPY requirements.txt 前置 |
增量构建从 3 分钟 → 2 秒 |
| pip 优化 | --no-cache-dir + 依赖拆分 |
~100MB+ |
| distroless | gcr.io/distroless/python3 |
进一步 30~50MB |
镜像瘦身不是追求数字好看------更小的镜像意味着更快的构建、更快的拉取、更快的扩缩容。在微服务架构中,几十个服务每个都小 90%,累计的时间节省是巨大的。
参考链接:
如果这篇文章帮你优化了镜像大小,欢迎点赞、收藏、关注。有问题可以在评论区交流。