ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Docker镜像瘦身实战:从1.2GB到120MB,CI/CD提速60%

Docker镜像瘦身实战:从1.2GB到120MB,CI/CD提速60% 先说一个我上周刚处理过的真实案例一个做后端的朋友找我帮忙排查 CI/CD 构建慢的问题流水线跑一趟十几分钟全组人都在那干等。我瞄了一眼他们的 Dockerfile问题瞬间就锁定了——apt update和apt install拆成了三条独立的 RUN 指令构建出来的部署镜像高达 1.2GB。把它们合并到同一条 RUN 里、把 apt 缓存清干净镜像直接降到 120MBCI/CD 整体提速 60%。这个场景在中小公司里真的太典型了不是没有人遇到过是大部分团队压根没把自己写的 Dockerfile 当回事。今天我就把这次的排查思路、优化原理和实操过程完整拆开讲一遍看完你也能自己动手做一次镜像瘦身。1. 镜像膨胀 10 倍的根源分层机制与 apt 缓存陷阱1.1 Docker 镜像是怎么“叠”起来的很多人天天用 Docker但对镜像的底层结构只有一个模糊的认知知道镜像是分层的可不知道这个机制到底会带来什么后果。Docker 镜像本质是一组只读层的堆叠。执行一个 Dockerfile每一条 RUN、ADD、COPY 指令Docker 都会基于当前镜像创建一个临时容器在容器里执行这条指令然后把执行后文件系统的变化另存为一个新的镜像层。也就是说你在 Dockerfile 里写了多少条指令最终镜像就有多少个层每一层都是一次文件系统的“快照差异”。关键点在于Docker 层只记录“差异”不记录“删除之后体积就没有了”。如果你在某一层里创建了一个 500MB 的文件后面另一层里把它删除了容器运行起来的时候你在终端的文件系统里确实看不到这个文件但镜像体积并不会变小——因为前面那层还完整保留着那个文件。删除动作本身只是在新层里写了一个“这里有个文件被删了”的标记旧数据依然躺在旧层里被 pull、被 push、被传输。打个比方这就像你叠了一摞牛皮纸文件袋每一层袋子里都放着当时产生的东西。就算之后你把其中某个袋子里的文件扔了之前那个袋子的厚度依然还在整摞纸袋只会越来越厚不会因为后来某个袋子里少放了东西而变薄。理解了这一点你自然就明白想控制镜像体积必须在“产生垃圾的那一层”就地解决。1.2 apt update 和 apt install 分开跑垃圾被永久“固化”现在来看绝大多数膨胀镜像的通病。一个典型的错误 Dockerfile 长这样FROM ubuntu:22.04 RUN apt-get update RUN apt-get install -y curl vim第一行RUN apt-get update会拉取软件源索引更新并写入/var/lib/apt/lists/目录。这个目录里存放的是软件仓库的元数据索引文件体积因源而异Ubuntu 默认源全量拉下来通常有几十到一两百 MB。关键问题是这条 RUN 一旦执行完这些索引文件就永久固化在了这一层里。第二行RUN apt-get install -y curl vim执行时apt 会先把 .deb 安装包下载到/var/cache/apt/archives/目录完成安装后如果不在同一条指令里清理这些 .deb 包也会留在这一层。curl、vim 以及它们拖进来的依赖下载包加在一起大几十 MB 很正常。再加上系统自带的文档、man 手册、临时文件一层一层积累下来基础镜像 70MB 的 Ubuntu最后涨到 1GB 以上一点也不夸张。这也是为什么你会看到“1.2GB vs 120MB”这种十倍差距——差的不是业务代码差的是 apt 索引、deb 缓存和一堆你根本不需要的推荐包。1.3 为什么中小公司是重灾区我观察到一个规律大厂的基础设施团队早就把这套东西固化成内部规范了但中小公司、尤其是从创业期跑起来的团队Dockerfile 常常是最早写系统的人随手留下的。这个群体有几个共性特征第一Dockerfile 是网上复制来改的能跑就没人再碰第二业务迭代快大家默认“部署成功做好”第三团队里没人专职做镜像优化CI/CD 机器性能不足、磁盘打爆、构建慢这些问题都被当成“公司的服务器不行”。说白了想在中小公司推动镜像优化不是技术难是“没人觉得这是该管的事”。但恰恰是这种环境里优化收益也最大——一台机器同时跑多个项目构建、镜像仓库磁盘告急、流水线排队几十分钟这些都是我见过的高频问题。2. 核心修复动作合并 RUN 指令是第一步但不是全部2.1 问题 Dockerfile 的常见形态我先给出一个现实中经常见到的错误版本。注意看这个 Dockerfile不是某一家公司的而是我见过的很多份文件的“合体版”FROM ubuntu:22.04 RUN apt-get update RUN apt-get install -y software-properties-common RUN add-apt-repository -y ppa:deadsnakes/ppa RUN apt-get update RUN apt-get install -y python3.10 python3-pip RUN apt-get install -y libpq-dev gcc这一个文件里apt-get update执行了两次apt-get install拆成了三条独立的 RUN。结果是每个 RUN 对应的镜像层里都可能残留 apt 索引和 deb 缓存add-apt-repository那条还会额外拉取新的源信息。这种写法在逻辑上没错但每多一条 RUN就多一层体积“债务”。2.2 标准写法安装、清理一条龙正确的姿势是把“更新索引→安装软件包→清理缓存”压缩在同一条 RUN 指令里用串联。同样的依赖合并之后是这样FROM ubuntu:22.04 RUN apt-get update \ apt-get install -y --no-install-recommends \ software-properties-common \ python3.10 \ python3-pip \ libpq-dev \ gcc \ rm -rf /var/lib/apt/lists/*逐段拆解这里的关键点连接确保这几条命令在同一条 RUN 里、同一个镜像层中执行。只要有任何一步失败后续命令都不会执行构建直接报错不会产生半成品层。--no-install-recommends告诉 apt 不要安装软件包推荐的依赖。推荐包经常是一堆你不用的库比如装个curl可能顺手拉进一堆文档和额外工具。加上这个参数通常能再省下几十 MB。rm -rf /var/lib/apt/lists/*安装完成后把 apt 索引文件从这一层里彻底删除。因为是在同一条 RUN 的末尾删除索引文件不会单独固化到任何一层。有可能的话把/var/cache/apt/archives/*下的 .deb 缓存也一并清掉。安装结束后这个目录已经没用了留着只会白白增大镜像。注意rm -rf /var/lib/apt/lists/*这条删除命令必须和apt-get install在同一条 RUN 里。假如你是先一条 RUN 安装、再另一条 RUN 清理前面那一层固化了几百 MB 的 lists 文件后面删掉也只是在文件系统层面“看不到”镜像体积不会减少。2.3 怎么确认优化生效了合并完指令别急着高兴第一时间做两件事看镜像体积、看分层详情。docker build -t myapp:before . docker build -t myapp:after -f Dockerfile.optimized . docker images myapp docker history myapp:before docker history myapp:afterdocker history输出的最关键信息是每一层的 SIZE 列。优化前的镜像你在 history 里能明显看到某几层的大小是几百 MB那通常就是 apt 缓存层优化后的镜像每一层都干干净净体积数据一下就正常了。还有一个更直观的工具叫dive它能把每一层里的文件差异列出来甚至能看到这些差异在层里占了多少空间。我用它排查过很多次“表面上看不出原因的体积异常”后面第四节排查笔记里会专门讲。2.4 CI/CD 提速 60% 的计算逻辑标题里那个“CI/CD 提速 60%”不是拍脑袋喊出来的。我这次的实际数据是镜像从 1.2GB 降到 120MB镜像 push 到私服的时间大约从 3 分钟降到 30 秒以内流水线里各阶段拉取镜像、推送镜像的时间同步缩短。整体流水线从 15 分钟降到 6 分钟以内说提速 60% 毫不夸张。这里面的逻辑不复杂CI/CD 流水线跟镜像打交道的环节很多构建依赖镜像、产物要 push、部署要 pull。镜像体积缩小到原来的十分之一网络传输时间、磁盘读写时间、容器启动时解压缩时间全都跟着下降。在一个流水线里这几项累加起来往往能省下好几分钟。3. 中小公司镜像瘦身的完整武器库3.1 多阶段构建把编译环境与运行环境分开合并 apt 指令只解决了一类问题。真正让镜像体积“质变”的是多阶段构建。很多后端服务的问题是编译需要一个完整的 SDK 环境但运行只需要编译产物。过去大家图省事直接在带 SDK 的基础镜像上安装依赖、编译、然后把这个大镜像当运行镜像用。这意味着你镜像是带着几十个 G 头文件去上生产的。多阶段构建的核心思路是在 Dockerfile 里写多条FROM前面阶段负责编译最后一个阶段只负责运行各阶段之间用COPY --from拷贝产物。举个我经常举的 Go 项目例子# 第一阶段编译 FROM golang:1.22 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 go build -o myapp . # 第二阶段运行 FROM alpine:3.19 RUN apk --no-cache add ca-certificates COPY --frombuilder /app/myapp /usr/local/bin/myapp ENTRYPOINT [myapp]第一个阶段golang:1.22动辄 800MB 到 1GB但它只是“工具人”最终产物只有那个编译好的二进制文件。第二个阶段基于轻量的alpine:3.19基础镜像约 7MB只拷贝编译产物进去最终镜像往往只有几十 MB。Python、Node、Java 项目同理依赖安装、编译放到带完整工具链的镜像里最后只把依赖目录或 JAR 包拷贝到精简运行时镜像。这一招做完镜像体积通常直接砍掉 60% 到 80%。3.2 从源头控制基础镜像怎么选基础镜像的选择对最终体积影响巨大。我见过各家用 debian、Ubuntu、CentOS 的大而全版本做底其实很多服务只需要一个运行环境。我常用的几个基础镜像拿数据说话基础镜像拉取体积约适用场景ubuntu:22.0470MB需要接近原生系统环境兼容性要求高debian:bookworm-slim50MB比 ubuntu 小主用 Linux 服务器部署alpine:3.197MB极致瘦身但注意 musl libc 兼容性distroless20MB 左右安全要求高无 shell交付物精简但注意基础镜像不是越小越好。我踩过的一个典型坑项目里用了某个依赖了 glibc 特性的二进制库在 alpine 上跑起来直接段错误因为 alpine 用的是 musl libc。如果你的应用有这类兼容性顾虑老老实实用 debian-slim 或 ubuntu不要为了几十 MB 去赌兼容性。另外生产环境的镜像 base tag建议用定死的版本或 digest而不是latest——latest每次可能 pull 到不同内容某一瞬间更新的基础镜像可能直接让你的应用起不来而且排查起来非常费劲。3.3 .dockerignore别把构建上下文当垃圾场还有个很容易被忽略的问题构建上下文过大。Docker 构建时会把 Dockerfile 所在目录整个打包发送给 Docker daemon如果你目录里有node_modules/、.git/、dist/、日志文件这些全都会被传到构建环境里然后再通过 COPY 带进镜像。解决办法是在项目根目录放一个.dockerignorenode_modules .git *.log dist Dockerfile .dockerignore写法和.gitignore基本一样。这个文件能保证构建上下文的体积保持在最小状态同时也能加快docker build的速度因为发送给 daemon 的数据量变少了。3.4 几个容易忽视的小技巧第一把变化慢的指令放在前面。比如先COPY requirements.txt或go.mod然后执行依赖安装最后再COPY整个源码。这样源码一改依赖安装这层缓存还能命中构建速度会快很多。第二及时清理无水镜像和构建缓存。中小公司的 CI 机器跑久了docker system df一查往往能看到几十 GB 的构建缓存堆积。定期执行docker builder prune和docker image prune -a能释放大量磁盘空间。第三开启 BuildKit。较新版本的 Docker 默认就启用老版本可以在构建命令前加DOCKER_BUILDKIT1。BuildKit 的缓存机制和并发能力比传统构建器好用不少构建同一套 Dockerfile 常常能快个 20% 到 30%。4. 实操过程把一个 1.2GB 的镜像压到 120MB4.1 先复现一个“问题镜像”为了让你有一个完整的画面我拿一个 Python Web 服务做示例。它不算复杂但完全复刻了中小公司最常见的问题写法FROM python:3.10 RUN apt-get update RUN apt-get install -y libpq-dev WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . CMD [python, app.py]当时的requirements.txt里有 Django、psycopg2、gunicorn、requests 等常见包。带着这些依赖基础镜像python:3.10大概 1GB 左右构建出来直接 1.2GB 出头。我实测的初始构建时间大约 8 分多钟其中大部分时间花在 apt update、pip install 上。4.2 一步步做镜像瘦身我把这次优化过程分成了五步每一步都记录体积变化方便你对照步骤修改内容镜像体积约说明初始状态3 条 RUN 分开apt 缓存全留1.2GB基线第一步合并 apt 指令清理/var/lib/apt/lists/*950MB减少的 250MB 主要是 apt 索引第二步加--no-install-recommends920MB减少推荐包幅度不大第三步pip 安装后清理 pip 缓存880MBpip cache 也不小第四步换成python:3.10-slim基础镜像380MB基础镜像本身小了很多第五步多阶段构建只保留运行依赖120MB最终体积与标题一致第五步的最终优化版 Dockerfile 是这样的# 编译/安装阶段 FROM python:3.10 AS builder WORKDIR /app COPY requirements.txt . RUN pip install --user -r requirements.txt # 运行阶段 FROM python:3.10-slim RUN apt-get update \ apt-get install -y --no-install-recommends libpq5 \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --frombuilder /root/.local /root/.local COPY . . ENV PATH/root/.local/bin:$PATH EXPOSE 8000 CMD [gunicorn, app:app, -b, 0.0.0.0:8000]注意几个关键点libpq-dev是编译时需要的头文件但运行时只需要libpq5运行库所以第二阶段只装libpq5pip 包装到/root/.local运行时阶段用COPY --frombuilder只拷贝这个目录不带任何编译工具链。这个写法最终实测 120MB比最初的 1.2GB 整整小了一个数量级。4.3 在 CI/CD 流水线中落地验证镜像瘦身不只是本机 build 一下的演示真正的价值要在 CI/CD 流水线里体现。我这次是在 GitLab CI 上验证的核心 job 配置长这样build_image: stage: build script: - docker build --pull -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA跑优化后的流水线最明显的变化在输出日志镜像 push 的速度从“慢慢吞吞几百 MB”变成“几十 MB 秒传”整体流水线时间从 15 分钟降到 6 分钟以内。这里补充一个实战建议如果你的 CI 机器和镜像仓库不在同一内网传输带宽往往更有限镜像体积对部署时间的影响会更大。把镜像体积从 1.2GB 降到 120MB相当于你原本要送一车货现在只需要一个背包网络拥塞带来的不确定性也少了。4.4 通过 docker history 和 CI 日志确认真实效果优化完成后我用docker history看过每一层的体积分布。优化前的镜像里有几层 SIZE 显示为 200MB、300MB 甚至更大基本就是 apt lists 和 pip cache 的堆积优化后的镜像每一层都保持在几十 MB 级别分布非常干净。如果流水线里同时跑了构建时长和镜像推送时长的日志直接用这些日志就能算出提速比例。拿这次的日志来说构建阶段8 分钟 - 4 分钟推送阶段3 分钟 - 30 秒部署 pull 阶段1 分钟 - 10 秒以内加起来正好符合“整体提速 60%”的结论。5. 常见问题与排查技巧实录5.1 清理了 apt 缓存镜像还是很大查这几处最常见的情况是人家把 apt 缓存清干净了镜像还是 800MB怀疑自己是不是操作错了。这时候我一般先看镜像里还装了哪些包管理器。Python 项目查 pip 缓存pip cache purge或者安装时直接加--no-cache-dir。Node 项目查 npm 缓存npm cache clean --force生产环境安装时用npm ci --onlyproduction。Go 项目查模块缓存go clean -modcache。apt 只是一个包管理器很多镜像里还同时躺着 pip、npm、go、maven 的缓存哪一个不清理都会让你胖一圈。还有一招能更精准地定位用dive工具。dive myapp:after这个工具会把你镜像的每一层、每一层新增文件都列出来你还以交互式浏览每个文件的占用空间。我每次排查“体积莫名膨胀”的问题第一件事就是打开 dive直接在界面上看是哪一层、哪个目录吃了大部分空间比用docker history猜快得多。5.2 合并 RUN 之后构建反而失败了合并指令出现最典型的问题是网络导致的apt-get update失败。原因很简单原本多条的 RUN如果某一步因为网络问题失败你还可以在第 N 层缓存基础上重试合并以后只要中间某条命令出错整个 RUN 都失败重新构建时这一层就没有可用缓存。我的处理方案有两个一是给apt-get update加-o Acquire::Retries3之类的重试参数二是把公共镜像源替换成公司内网或可靠公共源这一步对国内网络环境的稳定性帮助特别大。如果你身处网络环境有限制的地方优先检查源配置是否正确、DNS 是否能解析再考虑是不是代码本身的问题。5.3 COPY 了不该 COPY 的东西我有一个特别典型的失败案例某项目 COPY 源码之前忘了写.dockerignore直接把项目目录里 400MB 的node_modules连同.git目录一起拷进了镜像。虽然 Dockerfile 后面也没用到这些文件但它们被 COPY 进镜像层之后就永远在那了最终镜像体积比优化前更膨胀。所以我会建议你在写完一个 Dockerfile 后先跑一次docker build --no-cache -t myapp:debug . docker history myapp:debug看看每一层的 SIZE重点留意是否有异常大的 COPY 层。如果是就去查.dockerignore、查 COPY 源目录。5.4 常见问题速查表问题常见原因解决办法镜像体积一直下不来有多个包管理器缓存pip/npm/go逐个清理或用 dive 定位大文件合并 RUN 后构建不稳定网络抖动导致 apt 源拉取失败加重试参数、换内网或可靠公共源COPY 层特别大把 node_modules、.git 等目录 COPY 进镜像写 .dockerignore或 COPY 具体文件/目录CI/CD push 镜像仍然很慢镜像仓库网络带宽不足在内网部署镜像仓库或走 CI 与仓库同区域内网基础镜像 updated 后应用启动报错基础镜像 tag 用了 latest锁定精确 tag 或 digest构建时指定版本多条 RUN 合并后缓存命中下降源码 COPY 放在依赖安装前面先 COPY 依赖清单文件再 COPY 源码5.5 一个容易被忽略的安全视角镜像瘦身不只是为了省存储和加速 CI/CD顺带也是在缩小攻击面。镜像里少了vim、net-tools、curl这类调试工具少了一堆缓存的包文件意味着容器被攻破后可用工具变少、可利用的攻击场景变小。distroless这类镜像甚至直接不提供 shell很多运维操作没法做但对追求权限最小化的生产环境而言这是非常有利的属性。优化镜像体积有时能同时让“构建速度”“部署速度”和“安全基线”三个维度一起变好这也是我建议中小公司优先做这件事的原因之一——投入小、收益大、方向明确。最后分享一个我的实操习惯我每次写或者 review 一个 Dockerfile必查这三件事基础镜像 tag 是否固定、所有安装类指令是否合并且带缓存清理、有没有把不该 COPY 的目录带进来。这套习惯帮我挡掉了绝大多数“镜像膨胀”问题。另外我强烈建议在你自己的电脑上装个 dive不管项目大小隔一个季度做一次镜像体检。把体积异常但没人关注的镜像拉出来看一下通常都会有意外发现——不是多了个几百 MB 的缓存目录就是某个基础镜像悄悄从 700MB 暴涨到 1.2GB。这种体检动作花不了 20 分钟但能帮你避免很多线上部署时的“慢性病”。如果你手头也有一个怎么看都不对劲的大镜像按我上面的几个路径排查一遍大概率能在半小时内找到真凶。
返回列表