我们的服务要部署到客户机房,交付方式是 Docker 镜像。第一版打包出来
docker images一看:1.82 GB。后果很具体:推送到客户服务器(专线,带宽一般)要 8~10 分钟;改一行代码重新构建,缓存没命中的话又要重传大部分层;镜像拉取慢导致扩容慢;更要命的是客户那边磁盘配额紧张,几台机器各存几份镜像就把盘占了。
我花了一天半做瘦身,最终 210MB,构建时间从 6 分钟(全量)降到 40 秒(有缓存)。这篇记录完整过程:怎么知道镜像里装了什么、基础镜像怎么选、多阶段构建怎么写、层缓存顺序为什么重要,以及踩的坑。
TL;DR:瘦身的四板斧——换小的基础镜像(slim / alpine)、多阶段构建(编译依赖留在 builder 阶段)、
.dockerignore(别把 .git 和测试数据打进去)、同层清理(apt clean、--no-cache-dir,且必须在同一个 RUN 里)。ffmpeg 不要apt install,直接 COPY 静态二进制(前面编译静态 ffmpeg 那篇的产物)。最后加非 root 用户和漏洞扫描。判断体积问题一定要用dive或docker history看层,不要瞎猜。
目录
- 一、先看清楚:这 1.8GB 都是什么
- 二、基础镜像怎么选
- 三、多阶段构建:编译依赖不留到运行镜像
- 四、.dockerignore:最省事的一步
- 五、层缓存:COPY 的顺序决定了构建速度
- 六、同层清理:写在下一行就白写了
- 七、ffmpeg 怎么放进镜像
- 八、体积变化的完整记录
- 九、构建速度:BuildKit 与缓存挂载
- 十、安全:非 root、密钥与漏洞扫描
- 十一、坑清单
一、先看清楚:这 1.8GB 都是什么
不要猜,要看。两个工具:
# 1. 看每层的大小
docker history --human --format "{{.Size}}\t{{.CreatedBy}}" viddown:latest
# 2. 更直观:交互式看每层加了哪些文件
dive viddown:latest
dive 是我强烈推荐的工具(开源,wget 一个二进制就能用),它会列出每一层新增/修改/删除的文件,并且告诉你有多少空间是"浪费"的(被后续层删除的文件,依然占着前一层的空间)。
第一次看的结果让我很意外,前几大块是:
| 内容 | 大小 | 该不该在镜像里 |
|---|---|---|
基础镜像 python:3.11 |
1.01 GB | 不该,太大了 |
build-essential + 各种 -dev 包 |
320 MB | 不该(只在编译时用) |
pip 缓存(~/.cache/pip) |
180 MB | 不该 |
| 站点依赖(numpy/pandas/torch?) | 240 MB | 部分该 |
.git 目录 |
45 MB | 不该 |
tests/ + 测试素材 |
60 MB | 不该 |
__pycache__ |
30 MB | 不该 |
光是"不该在镜像里的"就超过 600MB。这里先讲一个反直觉的点:
在 Dockerfile 里 RUN rm -rf /xxx 不会减小镜像体积。 因为镜像是分层的,删除操作只是在新的一层打了"白名单"标记,被删文件的数据依然存在于上一层里。这也是为什么 dive 会用"wasted space"这个指标告诉你真相。
二、基础镜像怎么选
同一份 Python 应用,不同基础镜像的对比(我实测的):
| 基础镜像 | 大小 | glibc | -wheel 兼容性 | 适合 |
|---|---|---|---|---|
python:3.11 |
1.01 GB | glibc | 最好 | 需要编译的东西多、不想折腾 |
python:3.11-slim |
130 MB | glibc | 好 | 大多数场景首选 |
python:3.11-alpine |
60 MB | musl | 一般 | 纯 Python + 有 wheel 的依赖 |
python:3.11-slim-bookworm |
130 MB | glibc | 好 | 同上,指定 Debian 版本更稳 |
gcr.io/distroless/python3 |
50 MB | glibc | 依赖自己装 | 极致精简,没有 shell,调试难 |
我的结论:默认用 slim,别轻易上 alpine。
理由很实在:alpine 用的是 musl libc,而很多 Python 包只提供 glibc 的 wheel(manylinux)。遇到没有 wheel 的包就要现场编译,编译又需要装 build-essential 那一套……最后镜像不但没小,构建还慢了好几倍。
我实际踩的例子:
psycopg2-binary:有 musl 的 wheel 吗?没有(只有 manylinux)。alpine 上要装postgresql-dev+gcc编译,徒增 200MB 编译环境。numpy/pandas/pillow:新版本有 musl 的 wheel(musllinux),能用,但老版本没有。lxml、cryptography:编译耗时大户。
判断方法:先试 alpine,构建时看有没有出现满屏的 Building wheel for xxx。出现了就说明在编译,这时候要么换回 slim,要么用多阶段构建(在 builder 阶段编译,运行阶段只装产物——见下一节)。
另外,slim 镜像缺一些常用工具(curl、ping、netstat),调试时不方便。我的做法是调试用的东西临时装,不进最终镜像:
# 调试时临时装(运行中的容器)
docker exec -it <container> sh -c "apt-get update && apt-get install -y curl procps"
或者单独维护一个 -debug 变体镜像。
三、多阶段构建:编译依赖不留到运行镜像
这是瘦身最有效的一招。思路:用一个"胖"的 builder 镜像把依赖编译成 wheel,再用"瘦"的运行镜像只装 wheel。
# ---------- 阶段 1:builder,只负责产出 wheel ----------
FROM python:3.11-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential gcc libpq-dev libffi-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY requirements.txt .
# 用 cache mount 加速 pip 下载(需要 BuildKit)
RUN --mount=type=cache,target=/root/.cache/pip \
pip wheel --wheel-dir=/wheels -r requirements.txt
# ---------- 阶段 2:运行时 ----------
FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 # 不生成 .pyc,省空间也省 IO
# 运行时库(编译用的 -dev 包不需要)
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/*
# 只拷贝 wheel,不拷贝编译环境
COPY --from=builder /wheels /wheels
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir --no-index --find-links=/wheels /wheels/* \
&& rm -rf /wheels
WORKDIR /app
COPY . .
RUN useradd -m -u 1000 app && chown -R app:app /app
USER app
CMD ["gunicorn", "-c", "gunicorn.conf.py", "video_downloader.wsgi:application"]
关键点:
pip wheel把依赖打成 .whl 文件,第二阶段pip install --no-index --find-links=/wheels离线安装,不再需要网络和编译器;--no-cache-dir:不保留 pip 缓存(省 180MB);libpq5而不是libpq-dev:运行时只需要动态库,不需要头文件;PYTHONDONTWRITEBYTECODE=1:不生成__pycache__(省 30MB,且减少容器层写入)。
如果依赖全是纯 wheel(不需要编译),其实单阶段 + --no-cache-dir 就够了,多阶段反而复杂。先试单阶段,体积不满意再上多阶段。
四、.dockerignore:最省事的一步
没有 .dockerignore 的话,COPY . . 会把当前目录所有东西都塞进构建上下文,包括 .git、本地数据库文件、日志、测试数据。
.git
.gitignore
*.pyc
__pycache__/
.pytest_cache/
.idea/
.vscode/
*.sqlite3
*.log
tests/
docs/
blog_drafts/
deploy/
desktop_tool/
local_data/
.env
*.mp4
*.mkv
.env 一定要排除。我见过有人把 .env 打进镜像然后推到公开仓库的——数据库密码、支付密钥全泄露。
效果:构建上下文从 480MB 降到 12MB,光是 docker build 的"上传上下文"这一步就从 20 秒变成 1 秒。这是性价比最高的一步,两分钟搞定。
顺带说一句:COPY . . 本身也值得改成精确复制:
COPY manage.py gunicorn.conf.py ./
COPY video_downloader/ ./video_downloader/
COPY downloader/ ./downloader/
COPY tools/ ./tools/
COPY static/ ./static/
好处是改了一个 app 的文件不会让其他 app 的层缓存失效。缺点是要维护这个列表(新增目录会漏)。我一般在"目录结构稳定之后"才这么做,早期用 COPY . . + .dockerignore 更省心。
五、层缓存:COPY 的顺序决定了构建速度
Docker 的层缓存规则:某一层的指令和文件内容没变,且它之前的层都没变,就复用缓存。一旦某一层变了,后面全部重新构建。
错误的顺序(很多人这么写):
COPY . . # ← 代码一变,这一层就变
RUN pip install -r requirements.txt # ← 于是这层也重跑,几分钟
改一行代码 → 依赖重装 → 构建 5 分钟。
正确的顺序:
COPY requirements.txt . # ← 只有依赖变了才会失效
RUN pip install -r requirements.txt
COPY . . # ← 代码频繁变,放最后
这个改动让我的日常构建从 5 分钟降到 40 秒。
几个容易踩的细节:
COPY会看文件内容的校验和,不是看 mtime。所以COPY . .里任何一个字节变了都算失效。RUN apt-get update && apt-get install要写在同一行。分开写的话,apt-get update的层被缓存住,几个月后装包会用到过期的索引(经典的"昨天还能构建今天就不行了")。- 先装不常变的,后装常变的。比如系统包 → 依赖 → 代码 → 静态资源。
六、同层清理:写在下一行就白写了
这是最常见的错误:
# 错:清理在单独一层,前一层的数据还在
RUN apt-get update && apt-get install -y build-essential
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
正确的写法是全部塞进同一个 RUN:
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
同理 pip:
RUN pip install --no-cache-dir -r requirements.txt # 用参数,别事后 rm
--no-install-recommends 也别忘:apt 默认会装"推荐"的包,一大堆用不上的东西,能省几十上百 MB。
验证有没有真的清掉:
dive viddown:latest # 看每层的 wasted space
七、ffmpeg 怎么放进镜像
视频服务必然要 ffmpeg。三种做法对比:
| 做法 | 镜像增量 | 说明 |
|---|---|---|
apt-get install ffmpeg |
+280 MB | 带一堆用不到的编解码库,版本看发行版 |
| 自己编译(在镜像里) | +150 MB(且构建慢) | 可控但要装编译环境 |
| COPY 静态二进制 | +18 MB | 前面那篇编译的产物,一个文件搞定 |
第三种明显最优,因为我们本来就有静态编译的 ffmpeg(那篇文章讲的产物):
# 从构建上下文直接 COPY(把静态 ffmpeg 放在 build 目录里)
COPY ffmpeg-static/ffmpeg ffmpeg-static/ffprobe /usr/local/bin/
RUN chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe
# 或者从一个专门的镜像里拿
COPY --from=mwader/static-ffmpeg:7.1 /ffmpeg /usr/local/bin/
COPY --from=mwader/static-ffmpeg:7.1 /ffprobe /usr/local/bin/
用静态二进制还有个额外好处:运行镜像不需要装任何 ffmpeg 的依赖库(libx264、libx265 全在里面),slim 镜像也能直接跑。
验证:
docker run --rm viddown:latest ffmpeg -version
docker run --rm viddown:latest ffmpeg -hide_banner -encoders 2>/dev/null | grep -E 'libx264|libx265'
别忘了在 CI 里验证,我有一次构建的镜像里 ffmpeg 没执行权限(chmod 漏了),上线才发现。
八、体积变化的完整记录
每一步的效果(同一份代码):
| 步骤 | 镜像大小 | 累计减少 |
|---|---|---|
初始状态(python:3.11 + 全量) |
1820 MB | — |
换 python:3.11-slim |
940 MB | -48% |
加 .dockerignore |
880 MB | -52% |
| 多阶段构建(编译依赖剥离) | 430 MB | -76% |
| ffmpeg 换成静态二进制 | 265 MB | -85% |
同层清理 + --no-cache-dir + 禁 pyc |
228 MB | -87% |
清理运行期不需要的 apt 包(libpq-dev→libpq5) |
210 MB | -88% |
构建时间(有缓存 / 无缓存):
| 场景 | 改代码后 | 全新构建 |
|---|---|---|
| 优化前 | 5 分 20 秒 | 6 分 10 秒 |
| 优化后 | 38 秒 | 3 分 05 秒 |
推送时间(10MB/s 专线):180 秒 → 21 秒。这个才是日常最爽的改善——改个 bug 重新部署,从"等十分钟"变成"等半分钟"。
九、构建速度:BuildKit 与缓存挂载
启用 BuildKit(Docker 23+ 默认开启,老版本要手动):
DOCKER_BUILDKIT=1 docker build -t viddown:latest .
BuildKit 带来的几个实用特性:
1. 缓存挂载(前面 Dockerfile 里用过的):
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
把 pip 缓存存在宿主机上,跨构建复用。第一次之后,即使 requirements 变了,未变动的包也能直接命中缓存,依赖安装从 2 分钟降到 20 秒。
apt 同理:
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
apt-get update && apt-get install -y ...
2. 并行构建:BuildKit 会分析依赖图,能并行的层并行跑。
3. 敏感信息挂载(不进镜像层):
RUN --mount=type=secret,id=pip_conf,target=/etc/pip.conf \
pip install -r requirements.txt
docker build --secret id=pip_conf,src=./pip.conf .
这个是解决"构建时需要私有源凭据"的正解——用 ARG 传密码会让密码留在镜像历史里(docker history 能看到)。
4. 多平台构建(如果需要 arm64):
docker buildx build --platform linux/amd64,linux/arm64 -t viddown:latest --push .
注意:多平台构建用 QEMU 模拟,速度会慢很多(编译型依赖尤其)。
十、安全:非 root、密钥与漏洞扫描
瘦身之后顺手把安全做了,这几件事都不费时间:
1. 不要用 root 跑
RUN useradd -m -u 1000 app
COPY --chown=app:app . /app
USER app
容器里跑 root 意味着:一旦有漏洞(比如依赖库 RCE),攻击者拿到的是容器内的 root,配合挂载的目录或者内核漏洞,逃逸风险大幅上升。
2. 密钥不进镜像
.env进.dockerignore;- 构建时需要的凭据用
--mount=type=secret; - 运行时用环境变量或挂载文件注入,绝不
COPY进镜像; - 检查:
docker run --rm viddown:latest sh -c 'env | grep -i -E "key|pass|secret"'
docker history viddown:latest | grep -i -E "password|secret|token"
3. 漏洞扫描
trivy image viddown:latest
trivy 是开源的,一条命令扫出系统包和语言依赖的 CVE。我把它加进了 CI,高危漏洞直接阻断构建。
从上一次扫描结果看,大部分漏洞来自:老版本的 openssl、libssl、以及 Python 依赖里的老包。定期更新基础镜像(docker pull python:3.11-slim 然后重新构建)能解决大部分。
4. 只读根文件系统(进阶)
docker run --read-only --tmpfs /tmp viddown:latest
应用不能往容器里写东西,需要写的地方显式挂 tmpfs 或 volume。这个改动需要应用配合(比如日志要输出到 stdout),收益是大幅降低被入侵后的持久化风险。我们后来做了,代价不大。
十一、坑清单
RUN rm -rf不会减小体积 → 分层机制,要在同一层清理。- 没写
.dockerignore→.git、测试数据、.env全进镜像。 COPY . .写在pip install之前 → 改一行代码就重装所有依赖。apt-get update和install分两行 → 用过期索引装包,某天突然构建失败。- alpine 上编译 Python 包 → 又慢又大。先确认所有依赖都有 musllinux wheel 再考虑。
- 装了
-dev包但运行时不需要 → 用libpq5而不是libpq-dev。 apt install ffmpeg→ 280MB 且版本不可控。用静态二进制。- 静态二进制忘了
chmod +x→ 运行时permission denied。 - 用
ARG传密码 → 留在docker history里。用 secret mount。 - 容器里跑 root → 安全风险。建普通用户。
- 没禁
PYTHONDONTWRITEBYTECODE→ 一堆.pyc占空间、污染层。 - 构建缓存没利用 → 每次 6 分钟。调整 COPY 顺序 + BuildKit cache mount。
- 不看层大小就瞎优化 → 先
dive或docker history看清楚大头在哪。 - 多平台构建没测就上生产 → QEMU 编译出来的东西有时候行为不一致,要在目标架构上真机验证。
- 镜像里装了 curl/vim 等调试工具 → 生产镜像不需要,用
-debug变体或docker exec临时装。
最后说一个我自己的体会:镜像瘦身这件事,收益最大的不是磁盘,是迭代速度。
1.8GB 的时候,改一行代码要等 10 分钟才能看到效果,于是人会下意识地"攒一堆改动一起发",这又导致出问题不好定位。210MB 之后,部署变成了一件随手就能做的事(40 秒),大家开始小步快跑,出问题也能快速回滚。工具的速度会改变人的工作方式,这个说法在镜像体积上体现得特别明显。
另外提醒一句:瘦身之后一定要完整验证一遍。我第二次优化的时候删掉了一个看着"应该没用"的 apt 包,结果是某个 PDF 处理功能依赖的系统库,上线后才发现。现在的做法是——瘦身完成后跑一遍完整的功能测试(包括那些不常用的工具功能),再推镜像。别让"优化"变成"事故"。