引言:镜像大小为什么值得在意
很多个人站长开始用 Docker 之后,会遇到一个很实际的问题:明明只是部署一个简单的网站应用,构建出来的镜像却有七八百兆甚至一个多 G,拉取镜像要等半天,服务器磁盘空间很快就告急了。镜像体积大,影响的远不止磁盘占用:推送到镜像仓库更慢、部署新环境更慢、冷启动更慢,而且体积越大意味着包含的无用文件越多,攻击面也越大。本篇文章就结合我自己的实践,把 Docker 镜像瘦身的完整思路和常用手段讲清楚,从最简单的 .dockerignore 到多阶段构建,一步步把镜像从 1GB 压到 200MB 以内。
一、先看清现状:镜像到底大在哪里
瘦身之前,先要搞清楚镜像里的空间都被谁吃掉了。docker images 命令只能看到总体积,想看清每一层的内容,可以用 docker history 查看每一层的构建指令和大小:
docker history myapp:latest
docker system df从 docker history 的输出里,你往往会发现几个"大块头":一是基础镜像本身就很大,比如 ubuntu:22.04 的 rootfs 有七八十兆,python:3.11 这类官方镜像通常两三百兆起步;二是包管理器缓存,apt 装完软件后留下的 /var/lib/apt/lists 缓存、pip 的缓存目录,动辄几十上百兆;三是构建过程的中间产物,比如编译源码产生的 .o 文件、node_modules 里的开发依赖、Python 的 __pycache__ 目录等。
更直观的方法是使用 dive 这个开源工具,它可以逐层展示镜像里每个文件的增删情况和大小占比,一眼就能看出哪些层最臃肿、哪些文件是垃圾。对于经常做镜像优化的站长来说,dive 几乎是必备工具。
二、第一招:写好 .dockerignore,别把垃圾带进构建上下文
很多人的镜像之所以大,第一步就输在了构建上下文上。Docker 构建时会把项目目录打包发送给守护进程,如果目录里有 node_modules、.git、dist、日志文件这些动辄几百兆的东西,它们会先被完整地发一遍,虽然不一定最终进入镜像,但会拖慢构建,而且稍不注意就会被 COPY . . 复制进镜像。
.dockerignore 的写法和 .gitignore 类似,在项目根目录创建它,把不需要进入构建上下文的文件和目录全部排除:
.git
.gitignore
node_modules
__pycache__
*.pyc
.env
*.log
dist
build
.idea
.vscode
Dockerfile
docker-compose.yml这一招看似简单,却是性价比最高的,特别是对 Node.js 项目,node_modules 动辄几百兆,一旦被 COPY 进镜像,后续所有优化都白费。写完 .dockerignore 之后,可以用 docker build 时的上下文大小提示来确认效果。
三、第二招:选择合适的基础镜像
基础镜像的选择直接决定了镜像体积的下限。同样是 Python 环境,python:3.11 完整版约 340MB,python:3.11-slim 约 120MB,python:3.11-alpine 只有约 50MB,差距非常明显。
选择原则可以概括为:能选 slim 就不选完整版,能用 Alpine 就不选 slim,但要注意 Alpine 的坑。Alpine 基于 musl libc 而不是 glibc,绝大多数纯 Python、纯 Go、纯 Node 应用都能正常跑,但以下情况要谨慎:一是有 C 扩展的应用,比如依赖 pandas、numpy、psycopg2 的 Python 项目,在 Alpine 上要么编译慢要么有兼容问题;二是依赖特定 glibc 行为的二进制程序。遇到这种情况,退回 slim 版本更省心。
另外,如果你的应用是编译型语言,比如 Go 或 Rust,终极方案是 FROM scratch,也就是空镜像,只把你编译好的静态二进制文件放进去,最终镜像可以做到十几兆甚至几兆,这在后面讲多阶段构建时会详细展开。
四、第三招:减少层数,清理包管理器缓存
Dockerfile 里每一条 RUN、COPY、ADD 指令都会产生一个新的镜像层,层数越多,镜像越臃肿,构建和拉取都越慢。减少层数的常规做法是把相关的 RUN 指令用 && 合并成一条,并把清理动作放在同一条指令里,确保清理发生在同一层,这样清理掉的文件不会残留在历史层里:
RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx \
&& rm -rf /var/lib/apt/lists/*注意两个细节:一是 --no-install-recommends 参数,它告诉 apt 不要安装推荐但不是必需的软件包,能省掉一大批用不上的依赖;二是结尾的 rm -rf /var/lib/apt/lists/* 必须和 apt-get 在同一个 RUN 里,否则 apt 的索引缓存会永远留在镜像里。pip 也一样,安装后顺手清理缓存:
RUN pip install --no-cache-dir -r requirements.txt如果项目里用的是 npm,可以考虑 npm ci 加上配置,把 npm 的缓存目录设置到临时位置,安装完直接删掉,或者干脆用 npm install --production 只装生产依赖。前端项目的 node_modules 往往是体积大头,这一步收益非常明显。
五、第四招:多阶段构建,把编译环境与运行环境彻底分离
多阶段构建是 Docker 17.05 引入的特性,也是目前瘦身效果最显著的手段,尤其适合需要编译的应用。它的核心思想是:在第一个阶段(builder)里使用完整的基础镜像、安装全部编译工具、执行编译,然后在第二个阶段(runtime)里只拷贝编译产物和运行所需的文件,用干净的小镜像作为最终镜像。编译工具链和中间产物全部被丢弃,一点都不会进入最终镜像。
以 Go 应用为例,构建镜像从接近 1GB 可以压到 20MB 以内:
# 阶段一:编译
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server .
# 阶段二:运行
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]这里 CGO_ENABLED=0 编译出纯静态二进制,-ldflags="-s -w" 去掉调试符号进一步压缩体积,最终拷贝到只有几十兆的 Alpine 里,镜像体积直接从几百兆降到二十兆上下。Node.js 项目也可以用同样的思路:第一个阶段用完整 node 镜像安装依赖并执行构建(比如打包前端),第二个阶段用 node:slim 只拷贝 package.json、node_modules 和构建产物,开发依赖根本不会进到最终镜像。
六、实战案例:一个 Python 应用的瘦身全过程
光讲理论不够直观,分享一个我实际处理过的案例。一个基于 Flask 的小型网站,最初的 Dockerfile 是网上抄来的,直接基于 python:3.11 完整镜像,把所有源码 COPY 进去,然后 pip install,构建出来的镜像约 780MB。
瘦身过程分四步。第一步,加 .dockerignore,排除 .git、__pycache__、日志和本地虚拟环境,这一步让构建上下文从几百兆降到几兆。第二步,基础镜像从 python:3.11 换成 python:3.11-slim,体积直接少了两百多兆。第三步,把 apt 和 pip 的缓存清理放进同一条 RUN,又省掉几十兆。第四步,检查后发现项目里根本没有需要编译的 C 扩展,于是干脆再激进一点,改用多阶段构建:第一阶段在 python:3.11-slim 里安装依赖并生成 wheel 缓存,第二阶段只拷贝 site-packages 和源码。最终镜像从 780MB 降到 210MB,部署时拉取镜像的时间从原来的两分多钟缩短到十几秒。
这个案例说明,瘦身并不需要什么高深技巧,把上面几招按顺序执行一遍,大部分项目都能轻松砍掉一半以上的体积。如果应用本身很简单,比如只有一个静态文件服务器,甚至可以做到 10MB 以内。
七、进阶技巧:BuildKit、squash 与 distroless
如果基础手段都用完了还想继续压缩,可以试试下面几个进阶方向。
BuildKit 是 Docker 新一代构建引擎,开启后有几个实用特性:一是支持 COPY --link,可以更好地利用缓存;二是支持通过 --mount=type=cache 把包管理器缓存挂载到外部,既加快重复构建又不把缓存写进镜像层;三是支持 --secret 安全地传入构建密钥。开启方式是在构建命令前加 DOCKER_BUILDKIT=1,或者在 Dockerfile 顶部写 syntax 指令,新版 Docker 默认已经启用。
--squash 是另一种思路:构建完成后把所有层合并成一层,去掉历史层中已经被删除但还占着空间的文件。它的代价是失去层缓存带来的增量优势,而且部分容器运行时不支持,适合对体积有极致要求、且构建不频繁的场景。更推荐的做法是优先用多阶段构建和 BuildKit 缓存挂载,它们在不牺牲缓存的情况下解决问题。
distroless 镜像(由 Google 维护)介于完整发行版和 scratch 之间:它包含运行应用所需的运行时库,但不含 shell、包管理器、任何调试工具。用它做运行阶段的好处是体积小且攻击面小,因为没有 shell 就没有反弹 shell 的可能。缺点是没有 shell 意味着 docker exec 进去无法执行命令,排查问题不方便,需要依赖容器日志和健康检查。个人站长如果对安全性要求高,可以尝试,否则 Alpine 已经够用。
八、瘦身后的注意事项
镜像瘦身不是一味追求小,有几个实际问题必须提前考虑,否则会在生产环境踩坑。
第一是时区和语言环境。Alpine 和 slim 镜像默认没有时区数据,容器里执行 date 显示 UTC 时间,日志时间和北京时间差八个小时,非常影响排查问题。解决方法是显式安装 tzdata 并设置环境变量:
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo Asia/Shanghai > /etc/timezone第二是字体和证书。如果你的应用涉及验证码、图片处理、HTTPS 请求,Alpine 镜像里可能缺少 ca-certificates 和基础字体,表现为 HTTPS 请求报证书错误、图片文字显示为方块。解决方案是 apk add --no-cache ca-certificates tzdata fontconfig 之类的显式安装。
第三是 Alpine 的 musl 兼容问题,前面提过,如果应用依赖需要编译的 C 库,尽量用 slim 而不是 Alpine,别为了省几十兆体积跟兼容性问题死磕。第四是健康检查,镜像变小之后 Dockerfile 里记得加 HEALTHCHECK 指令,让编排系统能感知容器是否真的活着,配合 --restart=always 才能实现故障自愈。
总结
Docker 镜像瘦身是一套组合拳:.dockerignore 挡住构建上下文的垃圾,选对基础镜像定好体积下限,合并 RUN 指令并清理包管理器缓存,多阶段构建把编译环境与运行环境彻底分离,最后用 BuildKit、squash、distroless 等进阶手段追求极致。按这套流程走下来,绝大多数应用把镜像压缩一半以上毫无压力,拉取、部署、冷启动速度都会明显提升。建议每次构建完用 dive 看一眼,把瘦身当成构建流程的常规环节而不是一次性任务,长期坚持下来,你的服务器磁盘和部署体验都会感谢你。