Docker 镜像瘦身实战:从 1.2GB 压到 85MB 的完整过程
镜像 1.2GB 不是"功能多",是你把构建机上的垃圾原封不动打包发给了全世界。
去年我给一个 Node 服务做部署优化,拉一次镜像要 10 分钟,服务器磁盘动不动就爆。压完之后镜像只剩 85MB,拉取从 10 分钟降到 40 秒,冷启动快了一倍。这篇把整个过程和每个手段的收益拆开讲,你可以照着对自己的项目做一遍。
一、先搞清楚镜像为什么大
镜像是由一层层只读层叠出来的,每一层里的文件即使后面删掉,也还留在那一层里。所以"先 COPY 再 RM"不减体积,"同一层里装完就删"才减。常见的体积来源:
| 来源 | 典型占比 | 说明 |
|---|---|---|
| 基础镜像选错 | 30%~60% | node:20 约 1GB,node:20-alpine 只有 130MB |
| 构建依赖被打进运行层 | 20%~40% | devDependencies、编译工具链(gcc/make) |
| 源码与构建上下文 | 10%~30% | .git、测试、文档、node_modules 被 COPY 进去 |
| 包管理器缓存 | 5%~15% | apt/pip/npm 的缓存目录没清理 |
先用 docker history 镜像名 看每层大小,或者装个 dive 逐层分析,找到最大的几层再动手,比盲改有效得多。
bash
docker images # 看总大小
docker history myapp:latest # 看每层是谁贡献的
dive myapp:latest # 逐层浏览,标记浪费
二、三板斧:多阶段构建 + 换基础镜像 + .dockerignore
1. 多阶段构建(收益最大)
思路很简单:编译阶段需要 gcc、devDependencies、源码,运行阶段只需要产物。让构建在第一阶段发生,第二阶段只拷贝产物。
优化前的典型错误 Dockerfile:
dockerfile
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/main.js"]
这一个文件把上面表格里的坑全踩了:基础镜像 1GB、COPY . . 把 .git 和本机 node_modules 都带进来、devDependencies 全量安装。
优化后:
dockerfile
# ---- 构建阶段:随便装,反正不进最终镜像 ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # 先拷 package.json 装依赖,充分利用层缓存
COPY . .
RUN npm run build
RUN npm prune --omit=dev # 只留生产依赖
# ---- 运行阶段:干净的 alpine,只放产物 ----
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package*.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
这一个改动通常就能砍掉 60%~80% 体积。注意两个细节:
- 先 COPY
package.json再装依赖、最后才 COPY 源码------这样改代码不会击穿依赖缓存层,构建从几分钟回到几秒。 npm ci代替npm install,版本严格按 lock 文件来,避免"我本地好好的"。
2. 基础镜像选型
| 镜像 | 大小 | 适合 |
|---|---|---|
node:20 |
~1GB | 不推荐,除非离不开 glibc 专属依赖 |
node:20-slim |
~200MB | 需要 Debian 生态、glibc 二进制依赖 |
node:20-alpine |
~130MB | 纯 JS 依赖首选,最小 |
换 alpine 有一个常见翻车点:依赖里有原生二进制包 (如 sharp、bcrypt、sqlite3)时,alpine 用 musl 而不是 glibc,可能装不上或运行报错。两个解法:改用官方提供 musl 预编译版本的包(bcryptjs 替代 bcrypt),或退回 -slim。Python 项目同理,python:3.12-slim 是比 alpine 更稳妥的默认选择。
3. .dockerignore:把垃圾挡在构建上下文外
COPY . . 之前没有 .dockerignore,等于把本机目录整个塞给 Docker。在项目根目录加:
text
.git
node_modules
dist
build
coverage
*.log
.env*
Dockerfile
.dockerignore
README.md
两个关键点:node_modules 必须忽略(本机和容器架构不同,混用必炸);.env* 必须忽略(我见过把生产数据库密码打进公开镜像的事故)。
三、依赖与缓存清理的细节
Node 项目
npm ci --omit=dev或npm prune --omit=dev,把 devDependencies 从运行层踢出去,这一步通常省 100~300MB。- 如果必须
npm install,同一层里装完就清缓存:RUN npm install && npm cache clean --force。
Python 项目
dockerfile
FROM python:3.12-slim AS base
RUN pip install --no-cache-dir -r requirements.txt
--no-cache-dir 一个参数省几十到上百 MB。编译型依赖(如 psycopg2)在 slim 里要 apt-get install gcc,记得装完立刻删:
dockerfile
RUN apt-get update && apt-get install -y --no-install-recommends gcc \
&& pip install psycopg2 \
&& apt-get purge -y gcc && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
所有清理必须写在同一个 RUN 里------分开写就是两个层,前面装的东西还是留在镜像里。
Go 项目(最爽)
Go 的多阶段构建收益最夸张,产物是静态二进制:
dockerfile
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app .
FROM gcr.io/distroless/static
COPY --from=builder /app /app
ENTRYPOINT ["/app"]
最终镜像可以压到 10~20MB,连 shell 都没有,攻击面也小。
四、优化前后对比
我那个 Node 服务的实测数据:
| 优化手段 | 镜像体积 | 累计降幅 |
|---|---|---|
| 原始(node:20 + COPY . .) | 1.2GB | --- |
| 换 alpine + .dockerignore | 620MB | -48% |
| 多阶段构建 + omit=dev | 110MB | -91% |
| 加 --no-cache-dir / 清缓存 | 85MB | -93% |
拉取时间从 ~10 分钟降到 ~40 秒(按 10Mbps 带宽算),服务器磁盘占用直接少一个数量级,CI 里构建缓存命中后整个镜像构建只要 30 秒。
五、几个容易忽略的坑
latest标签陷阱 :永远给镜像打明确版本或 git sha(myapp:20260918-a1b2c3),回滚时你才会感谢自己。- 时区与 locale :alpine 默认 UTC,日志时间对不上,加
RUN apk add --no-cache tzdata && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime。 - 健康检查 :镜像瘦身后别丢运维能力,
HEALTHCHECK CMD wget -qO- http://localhost:3000/health || exit 1(alpine 里没有 curl,用 wget)。 - 别为了瘦身砍掉 curl/wget 后又需要调试 :需要临时进容器排查时用
docker debug(新版)或临时起一个带工具的 sidecar,不要把调试工具永久打进生产镜像。 - CI 里也要清理 :构建机
docker system prune -f定期跑,否则磁盘爆的是构建机不是镜像。
六、总结
镜像瘦身不是玄学,就是一句话:运行时需要什么就放什么,其他全部留在构建阶段 。动手顺序建议------先看 docker history 找大头,再上多阶段构建,然后换 alpine/slim,最后补 .dockerignore 和缓存清理。今晚就可以拿自己项目试一遍,90% 的项目能砍掉 70% 以上的体积,而这一切不需要你改一行业务代码。