提示

返回博客列表

视频处理镜像从 1.8GB 瘦到 210MB:Docker 多阶段构建与层缓存的完整实践

我们的服务要部署到客户机房,交付方式是 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 都是什么

不要猜,要看。两个工具:

# 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"]

关键点:

  1. pip wheel 把依赖打成 .whl 文件,第二阶段 pip install --no-index --find-links=/wheels 离线安装,不再需要网络和编译器;
  2. --no-cache-dir:不保留 pip 缓存(省 180MB);
  3. libpq5 而不是 libpq-dev:运行时只需要动态库,不需要头文件;
  4. 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 秒。

几个容易踩的细节:

  1. COPY 会看文件内容的校验和,不是看 mtime。所以 COPY . . 里任何一个字节变了都算失效。
  2. RUN apt-get update && apt-get install 要写在同一行。分开写的话,apt-get update 的层被缓存住,几个月后装包会用到过期的索引(经典的"昨天还能构建今天就不行了")。
  3. 先装不常变的,后装常变的。比如系统包 → 依赖 → 代码 → 静态资源。

六、同层清理:写在下一行就白写了

这是最常见的错误:

# 错:清理在单独一层,前一层的数据还在
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),收益是大幅降低被入侵后的持久化风险。我们后来做了,代价不大。

十一、坑清单

  1. RUN rm -rf 不会减小体积 → 分层机制,要在同一层清理。
  2. 没写 .dockerignore → .git、测试数据、.env 全进镜像。
  3. COPY . . 写在 pip install 之前 → 改一行代码就重装所有依赖。
  4. apt-get update 和 install 分两行 → 用过期索引装包,某天突然构建失败。
  5. alpine 上编译 Python 包 → 又慢又大。先确认所有依赖都有 musllinux wheel 再考虑。
  6. 装了 -dev 包但运行时不需要 → 用 libpq5 而不是 libpq-dev。
  7. apt install ffmpeg → 280MB 且版本不可控。用静态二进制。
  8. 静态二进制忘了 chmod +x → 运行时 permission denied。
  9. 用 ARG 传密码 → 留在 docker history 里。用 secret mount。
  10. 容器里跑 root → 安全风险。建普通用户。
  11. 没禁 PYTHONDONTWRITEBYTECODE → 一堆 .pyc 占空间、污染层。
  12. 构建缓存没利用 → 每次 6 分钟。调整 COPY 顺序 + BuildKit cache mount。
  13. 不看层大小就瞎优化 → 先 dive 或 docker history 看清楚大头在哪。
  14. 多平台构建没测就上生产 → QEMU 编译出来的东西有时候行为不一致,要在目标架构上真机验证。
  15. 镜像里装了 curl/vim 等调试工具 → 生产镜像不需要,用 -debug 变体或 docker exec 临时装。

最后说一个我自己的体会:镜像瘦身这件事,收益最大的不是磁盘,是迭代速度。

1.8GB 的时候,改一行代码要等 10 分钟才能看到效果,于是人会下意识地"攒一堆改动一起发",这又导致出问题不好定位。210MB 之后,部署变成了一件随手就能做的事(40 秒),大家开始小步快跑,出问题也能快速回滚。工具的速度会改变人的工作方式,这个说法在镜像体积上体现得特别明显。

另外提醒一句:瘦身之后一定要完整验证一遍。我第二次优化的时候删掉了一个看着"应该没用"的 apt 包,结果是某个 PDF 处理功能依赖的系统库,上线后才发现。现在的做法是——瘦身完成后跑一遍完整的功能测试(包括那些不常用的工具功能),再推镜像。别让"优化"变成"事故"。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部