那天 CI 排队像周五晚上的电梯,人人都在等。镜像 2GB,一次构建像搬个冰箱。有人调侃说“拉个镜像我去冲杯咖啡”,回来咖啡凉了,镜像还在拉。我这脸上挂不住:这玩意儿是我维护的,必须得瘦。

目标与结论

先说结论:不是玄学,就是过日子的手艺活。换小箱子、只带必需品、干完活把工具还给工地。听着像大道理,我也是边做边踩坑,最后从 2GB 收到了 50MB,多的没学会,省事是真学会了。

第一步:基础镜像要对路

我从最容易的一刀下去:基础镜像。

我们原来用的是一大家子镜像,像租房还带大衣柜那种。换成轻量镜像,立刻瘦一圈。好比原来开 SUV 去楼下买酱油,现在骑个小电驴。第一刀下去,镜像从 2GB 掉到九百多 MB,人还挺得意。结果第二天线上告警:某个原生模块在 musl 上抽风。那一刻我意识到,减肥不是饿肚子,而是学会吃对东西。

第二步:多阶段构建分清“工地”和“新家”

我就改成两段式过日子:白天在工地吵吵嚷嚷,晚上回家就安静。

翻译成 Docker:多阶段构建。第一阶段像工地,锤子钉子刮刀样样齐全(编译器、构建工具、调试工具随便装);第二阶段是新家,地板刚打蜡,不让沾泥(只放产物和必需运行时)。这步下去,镜像从九百多 MB 掉到三百来 MB,地面终于能看到纹理了。

第三步:装完就打扫,别带缓存回家

接下来是打扫卫生。安装依赖时把缓存、临时文件顺手清掉。apt 的列表、pip 的缓存、npm 的 cache,都是“装修剩下的灰”。清完之后我发现,镜像又瘦了一大圈。就像做饭收拾利索,下一顿也能快几分钟。

第四步:运行时极简化(distroless/静态链接)

最后一刀,我把运行时再做了个断舍离。该静态链接就静态链接,该用 distroless 就用。那种“没有锅碗瓢盆,只有饭菜”的镜像,一上秤就是轻。第一次推上镜像仓库,我看着 50MB 那个数字,有点恍惚:原来我也是能做减肥顾问的人。

为了有点说服力,我把过程里两个我亲手改过的例子留在这。不是教程口条,就当复盘笔记。

实战示例:Go 静态编译 + distroless

一个是 Go:

FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o app

FROM gcr.io/distroless/static:nonroot
WORKDIR /app
COPY --from=builder /src/app /app/app
USER nonroot:nonroot
ENTRYPOINT ["/app/app"]

这玩意儿省心:静态编译,产物一个可执行文件,搬进 distroless 就能跑。就像打包带一口锅,能煮能煎,不占地方。出故障要排查?我会额外做个 debug 版本,用 alpine 带个 busybox,出事切过去看看,再切回干净版。

实战示例:Node 多阶段 + 缓存命中

另一个是 Node:

FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build

FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/dist /app/dist
CMD ["node", "dist/index.js"]

这次我老老实实先拷 package.json,让“装依赖”那一层能吃缓存。dev 依赖留在工地不带回家。alpine 确实轻,但我也踩过坑:有的原生模块对 musl 就不客气。我的折中是:构建阶段搞定编译,运行阶段尽量不碰这些模块,实在不行换成 slim。该认怂就认怂,别为了面子把时间都赔进去。

只带要用的:.dockerignore 与依赖参数

“只带要用的”这件事,说起来像鸡汤,做起来是细活。

我开始写 .dockerignore,把 .git、测试数据、脚手架残留都挡在门外。安装软件时一把做完,顺手擦地:

RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates curl \
 && rm -rf /var/lib/apt/lists/*

pip 我会加 --no-cache-dir--no-compile,npm 就 npm ci --omit=dev。这一圈做下来,镜像层数不那么碎,体重秤也少挑刺。你要是愿意再勤快点,打开 BuildKit 给依赖一个缓存目录,下次构建更快,像把常用调料放灶台边上,伸手就着。

留一把工具:可进入的调试变体

当然也有被现实打脸的时候。distroless 干净是干净,出了问题你连个 shell 都进不去。我后来认了个“家里常备工具箱”的规矩:每个服务留一个 debug 变体,忙完活再把工具箱收起来。还有证书这种细节,没装好 HTTPS 就翻车。看起来小题,翻起来是真不小。

为了不把自己锁在门外,我会额外准备一个调试变体 Dockerfile,思路不变,只是把运行阶段换成可进入的镜像:

FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build

# 调试变体:保留最小可用的 shell 环境以便排错
FROM alpine:3.20 AS debug
RUN apk add --no-cache nodejs-current npm bash curl ca-certificates
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/dist /app/dist
CMD ["node", "dist/index.js"]

线上默认用干净版,需要排查时切到 debug 变体,问题解决再切回去。别嫌麻烦,这一步常常省的是通宵。

最后清单:一步步瘦下来的关键点

等一圈下来,我回头补了个小清单,留给以后的我:

  1. 基础镜像能小就小,别盲目追轻,先跑通。
  2. 多阶段构建分清“工地”和“新家”。
  3. 依赖装完就打扫,缓存别带回家。
  4. .dockerignore 不要偷懒,能挡住一堆麻烦。
  5. 预留一个可进入的 debug 版本,别把自己锁门外。

效果与结语

最后的效果?

我们从 2GB 掉到 50MB,拉取速度像坐直梯。CI 构建时间少了快一半,节点磁盘告警没再见过。更重要的是,后面新项目仿照这一套,一开始就“清爽出生”,不至于长大了再强行节食。

如果你家镜像也正发福,别一口吃个胖子(也别一刀砍成竹竿)。先从基础镜像动手,再试多阶段构建,装完就打扫。两三天后看一眼体重秤,如果数字不动,来骂我;如果数字下来了,你大概率会像我一样,知道这事就是点手艺活。