我们那条内部订单服务,第一次打出来的镜像有多大?我记得很清楚:1.24GB。一个跑在 4 核 8G 机器上的 Go/Java 小服务,push 到私有仓库要 4 分多钟,K8s 滚动更新时新节点拉镜像得等上十几分钟,最离谱的一次,节点磁盘直接被几个历史镜像塞满,Pod 一直 Pending 到有人手动去删 tag。更气人的是,这服务里真正需要的运行时文件加起来不到 200MB。多出来的 1GB 到底在哪?拆开一看就明白了。
如果你手里也有那种「几百 MB 的镜像,其实业务就那么点东西」的服务,或者节点磁盘总被镜像占满,可以先看看这两家的机器做参考:RackNerd(全球VPS,特价常推)、狗云DOGYUN(高性价比云服务器,多机房可选),跑容器的话内存给到 4G 起步会比较从容,剩下的精力就可以放在把镜像本身整瘦这件事上。

一、先搞清楚镜像为什么这么大:层是只进不出的
Docker 镜像不是一个文件,是一串只读层(layer)叠起来的。你 Dockerfile 里每写一条 RUN、COPY、ADD,就多一层。关键在这儿:每层只记录相对上一层的增量,而且一旦某层生成,后面的层删不掉它。
举个最容易踩的例子,很多人写 Dockerfile 习惯这样:
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
看起来把 apt 缓存删了,很干净对吧?实际上那个 rm 只是第三层里"标记删除",第二层里 60MB 左右的索引文件原封不动还在镜像里,docker images 看到的体积一分没少。要真正瘦身,就得把这些动作写在同一条 RUN 里面,让它们落在同一层里。搞明白这一条,后面几个改动就都是顺水推舟了。

二、第一个改动:多阶段构建,把构建过程彻底踢出最终镜像
这是收益最大的一招,也是最容易理解的。我们原来的 Dockerfile 是这样:编译前端、装依赖、打包、把整个项目目录 COPY 进镜像。这里面最亏的是编译用的 Go SDK、Maven、Node 工具链,全都被带进了生产镜像,可生产环境压根用不着它们。
多阶段构建(multi-stage build)的思路是:一个 stage 负责编译(可以随便重、随便脏),另一个 stage 只负责运行(干净到极致),最终镜像只取自最后一个 stage。用起来大概是这样:
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN go build -o /app/server ./cmd/server
FROM alpine:3.19
WORKDIR /app
COPY --from=builder /app/server .
ENTRYPOINT ["./server"]
就这么几行改动,编译用的 SDK 和中间产物全留在了 builder 阶段,生产镜像里只有那个可执行文件。上面说的那条 1.24GB 的镜像,换成这种写法之后直接掉到 180MB 左右,push 时间从 4 分钟变成 40 秒。

三、第二个改动:换基础镜像,alpine 减掉一大半
基础镜像选什么,基本决定了体积的下限。centos:7 约 1.2GB,debian:11-slim 约 80MB,alpine:3.19 不到 8MB。生产镜像直接用 alpine,体积能再砍掉一大截。
但 alpine 不是无脑换。它的 musl libc 和 glibc 不一样,有几个坑要提前知道:一是有些 Python 包(比如带 C 扩展的 numpy 老版本、psycopg2)在 musl 下编译会报错,得指定 --no-cache-dir 并装 build-base,或者干脆用 debian-slim;二是时区要额外装 tzdata,不然 Asia/Shanghai 会变成 UTC;三是证书目录不一样,Alpine 是 /etc/ssl/certs,写死路径的应用发 HTTPS 请求会失败。如果你的服务依赖重、又不敢折腾,就先用 debian-slim,安全又省事;纯 Go、纯 Rust、纯静态前端的项目,用 alpine 最爽。
四、第三个改动:写一份 .dockerignore
这一步几乎零成本,但很多人就是没做。默认情况下 COPY . . 会把整个构建上下文塞进去,包括 node_modules、.git 目录、target、venv、本地日志、测试用的几百 MB 数据集。我见过一个项目,.git 目录 400MB,node_modules 800MB,全被打进镜像了。
建个 .dockerignore,把这些排掉:
.git
node_modules
target
.venv
*.log
data/
.env*
顺便说一句,.dockerignore 和 .gitignore 是两回事,别拿 gitignore 直接顶替,作用机制不一样。

五、第四个和第五个改动:合并 RUN 清缓存,别把源码塞进去
第四个改动前面讲过了,就是把装包和清缓存合并到同一条 RUN:
RUN apk add --no-cache curl tzdata && \
rm -rf /var/cache/apk/*
alpine 用 --no-cache 一个参数就够,debian 系则是 apt-get update && apt-get install -y --no-install-recommends xxx && rm -rf /var/lib/apt/lists/*。
第五个改动是意识层面的:最终镜像里不要留源码、测试文件、`.git` 历史。Go 项目用 go build 出二进制,Node 项目只拷 package.json + dist,Python 项目只装 wheel 出来的包。让源码待在代码仓库里,别跟着镜像跑。
六、怎么验证效果,以及两个容易忽略的细节
别凭感觉,用 docker history 镜像名:tag 看每一层多大,哪一层臃肿一目了然。想看得更细可以装个 dive,它能逐层展开对比文件系统差异,效率工具的取舍大家自己看。还有一点:镜像瘦身后,docker push 传的是增量层,所以旧的大镜像哪怕删了 tag,底层数据还占着磁盘,得等 GC 或者手动 docker image prune 才能真正释放,这一步经常被忘了。
还有个反直觉的:镜像小不等于跑得快。alpine 镜像启动确实快几十毫秒,但如果你为了省体积去换了个冷门基础镜像,漏洞更新和生态支持全靠自己,反而更麻烦。瘦身和可维护性之间,得自己拿捏。
七、什么场景别折腾
如果你的镜像本来就只有 50MB,或者是一个纯静态站点镜像,那折腾这些没意义。真正值得动手的,是那种「基础镜像 1GB 起步、业务文件却只有几十 MB」的中间件镜像:Redis、MySQL、Nginx 这些用官方镜像直接拉不亏,因为它们本身的体积已经优化过了,硬自己重写 Dockerfile 反而容易出安全问题。先把 docker images 列出来,按体积排序,从最大的那三个开始改,投入产出比最高。
本地开发环境的话,用 Docker Desktop 或 OrbStack 这类工具跑容器、拉镜像都方便,不用每次自己敲 docker run 那串长参数,省事很多。


喜欢
顶
难过
囧
围观
无聊



