2023 年,我接手一个 Node 后端服务的容器化。镜像第一次构建出来,我看了一眼大小:1.4GB。当时没多想——能跑就行。可很快,各种问题接二连三地冒出来。CI 流水线每次构建,光是打镜像、推镜像就要七八分钟;线上出故障要回滚,从镜像仓库把上一个版本拉到机器上,又是好几分钟,故障窗口被这一下生生拉长;镜像仓库的磁盘,每隔几周就报警一次"满了"。我沉下心,去翻这 1.4GB 里到底装了什么,越翻越心惊:里面有一整套 gcc 编译工具链——可我的服务跑起来根本不需要编译;有几百兆的 devDependencies——测试框架、打包工具、类型定义,全是构建时才用、运行时一个都碰不到的东西;有整个 .git 版本历史;甚至还躺着一个我构建时随手 curl 下来、用完忘了删的安装包。这个镜像,与其说是"我的服务",不如说是"我的服务 + 一整套开发环境 + 一堆垃圾",被囫囵打包在了一起。后来我花了大半天,把它压到了 80MB——服务本身,一行代码都没动。这件事逼着我把镜像为什么会胀这么大、它的分层结构到底是怎么回事、基础镜像该怎么选、多阶段构建解决的是什么、层缓存又该怎么用,彻底理清了一遍。本文是这份梳理的完整复盘。
问题背景:一个 1.4GB 镜像引发的连环麻烦
背景:一个 Node 后端服务,容器化后镜像 1.4GB
连环麻烦:
- ★ CI 每次构建:打镜像 + 推镜像要七八分钟,流水线奇慢
- ★ 线上回滚:从仓库拉上一个版本到机器,又要好几分钟 ——
故障窗口被硬生生拉长
- ★ 镜像仓库磁盘:每隔几周就报警"满了"
翻开这 1.4GB,里面装的是:
- ★ 一整套 gcc 编译工具链 —— 服务运行根本不需要编译
- ★ 几百兆 devDependencies —— 测试/打包/类型,全是构建期才用的
- ★ 整个 .git 版本历史
- ★★ 一个构建时 curl 下来、用完忘删的安装包
★★ 真相:这个镜像不是"我的服务",而是"我的服务 +
一整套开发环境 + 一堆垃圾"被囫囵打包在了一起。
后来压到 80MB,服务代码一行没动。
★ 本文要做的:把镜像为什么会胀大、分层结构、基础镜像
怎么选、多阶段构建、层缓存,彻底讲透。
为什么镜像会这么大:先看懂它的"分层"结构
# === ★ 想给镜像瘦身,先得搞懂它的身体是怎么长的 ===
# === ★ 镜像不是一个整体文件,是一摞"层" ===
# ★ ★ Docker 镜像,不是一个单一的大文件。它是由一【层
# 一层】只读的文件系统层,自下而上叠起来的。
# ★ ★ Dockerfile 里几乎每一条指令(FROM / RUN / COPY /
# ADD),都会产生一个【新的层】。每一层记录的,是它
# 相对【上一层】做了哪些文件改动(加了啥、改了啥)。
# ★ ★ 运行容器时,这些只读层叠在一起,顶上再加一个可写
# 层 —— 这一摞,就是容器看到的完整文件系统(联合文件
# 系统,union filesystem)。
# === ★★ 最反直觉的一点:"删除",并不会让镜像变小 ===
# ★ ★ 你在后面的层里,删掉一个前面层创建的文件 —— 镜像
# 【不会因此变小】。
# ★ ★★ 为什么?因为前面那一层是【只读】的、【独立存在】
# 的。你的"删除"动作,只是在【上面的层】里,做了一个
# "这个文件不可见"的标记。被删的文件【本体】,还实实
# 在在地躺在下面那一层里,照样占着体积。
# ★ ★ 这就解释了一个经典困惑:你在 Dockerfile 里 RUN 了
# 一句 rm -rf 删掉一大坨东西,docker images 一看,镜像
# 体积【纹丝不动】。
# === ★ 推论:每一层都"只进不出",大小只增不减 ===
# ★ ★ 既然每一层都独立、只读、且后层删不掉前层的东西 ——
# 那么镜像的最终大小,约等于【每一层各自贡献的大小,
# 全部累加】。某一层里塞进去的东西,无论后面怎么折腾,
# 都【会一直跟着这个镜像】。
# ★ 瘦身的第一性原理:不是"事后删",而是【一开始就别让
# 不该进的东西,进到任何一层里】。
# === ★ 工具:用 docker history 看清每层多大 ===
# ★ ★ docker history <镜像> 能列出镜像的每一层,以及
# 每层对应的指令和它贡献的大小。哪一层把镜像撑胖了,
# 一目了然 —— 这是瘦身的第一步:先定位"肥肉"在哪层。
# === 小结 ===
# ★ 镜像不是单一文件,是一层层只读文件系统层自下而上叠
# 起来的,Dockerfile 几乎每条指令(FROM/RUN/COPY/ADD)
# 都产生一个新层,每层记录它相对上一层的文件改动;运行
# 容器时这些只读层叠加再加一个可写层,就是容器看到的
# 文件系统(联合文件系统)。★★ 最反直觉的一点:在后面
# 的层里删掉前面层创建的文件,镜像不会变小 —— 因为前层
# 只读且独立存在,"删除"只是在上层做了个"不可见"标记,
# 被删文件本体还躺在下层照样占体积;这就解释了为什么
# RUN 一句 rm -rf 删一大坨,docker images 体积纹丝不动。
# ★ 推论:每层只进不出大小只增不减,镜像最终大小约等于
# 每层贡献累加,某层塞进去的东西会一直跟着镜像;瘦身
# 第一性原理不是"事后删"而是一开始就别让不该进的东西
# 进到任何一层。★ 用 docker history 能看清每层多大、
# 哪条指令把镜像撑胖,这是瘦身第一步。
# ★ 用 docker history 定位"肥肉"在哪一层 —— 瘦身的第一步
# === 看某个镜像每一层的大小和来源指令 ===
docker history my-service:1.4gb
# 典型输出(精简后)—— 一眼就能看出谁是元凶:
# IMAGE CREATED BY SIZE
# COPY . /app 380MB # ★ 整个目录(含 node_modules/.git)全拷进来了
# RUN npm install 520MB # ★★ 装了全部依赖,含 devDependencies
# RUN apt-get install -y build-essential ... 240MB # ★ 编译工具链 —— 运行期根本用不到
# RUN curl -O https://.../some-sdk.tar.gz 90MB # ★★ 临时下载物,忘了它会永久占体积
# FROM node:20 1.1GB # ★ 地基就选了个最重的
# === 看镜像总大小 ===
docker images my-service
# ★ 结论:1.4GB 不是某一处的锅,是每一层都"贡献"了肥肉。
# 后面三节,就是逐层把这些肥肉削掉。
第一刀:选对基础镜像,地基决定下限
# === ★ Dockerfile 第一行 FROM,定了你镜像的"地基" ===
# === ★ 同一个运行时,官方往往给好几个"体型" ===
# ★ ★ 以 Node 为例,官方镜像有好几个变种,体型天差地别:
# - ★ node:20 —— 基于【完整的 Debian】,自带一整套
# 系统工具(各种命令行、库),地基就 1GB 量级;
# - ★ node:20-slim —— 砍掉了大量"非必需"的系统包,
# 只留运行所必需的,通常一两百兆;
# - ★★ node:20-alpine —— 基于极简的 Alpine Linux,
# 整个地基就几十兆。
# ★ 几乎所有主流运行时(Python、Java、Go 镜像)都有
# 类似的 slim / alpine 变种。
# === ★ 还有更极致的:distroless ===
# ★ ★ distroless 镜像里,连 shell、连包管理器【都没有】
# —— 只有你的应用运行时,和应用本身。
# ★ ★ 好处是双份的:① 体积压到极致;② 攻击面也压到
# 极致 —— 里面没有 shell,攻击者就算进来了也"无处
# 下手"。代价是:没 shell,你 docker exec 进去
# 调试的老习惯就用不了了。
# === ★★ 选 alpine 唯一要留心的坑 ===
# ★ ★ Alpine 用的 C 标准库是 musl libc,而不是绝大多数
# Linux 用的 glibc。★★ 极少数依赖了 glibc 特有行为的
# 原生模块(尤其是某些带 C 扩展的库),在 alpine 上会
# 编译失败或运行报错。
# ★ 实践建议:优先试 slim;追求极致小,再上 alpine,
# 但上了之后【务必完整跑一遍测试】,确认原生依赖没事。
# === ★ 这一刀的威力 ===
# ★ ★ 仅仅把 FROM node:20 改成 FROM node:20-slim 或
# alpine —— 一行不改业务逻辑,镜像地基就能从 1GB
# 量级,直接砍到一两百兆甚至几十兆。
# ★ 这是性价比最高、最该第一个动的一刀。
# === 小结 ===
# ★ Dockerfile 第一行 FROM 决定镜像地基。同一个运行时
# 官方往往给好几个体型:以 Node 为例 node:20 基于完整
# Debian 自带一整套系统工具地基 1GB 量级,node:20-slim
# 砍掉大量非必需系统包通常一两百兆,node:20-alpine 基于
# 极简 Alpine Linux 地基几十兆;Python/Java/Go 镜像都有
# 类似 slim/alpine 变种。★ 更极致的是 distroless:连
# shell 和包管理器都没有,只有运行时和应用,体积和攻击
# 面都压到极致,代价是没 shell 不能 docker exec 进去
# 调试。★★ 选 alpine 唯一要留心的坑:它用 musl libc
# 不是 glibc,极少数依赖 glibc 特有行为的原生模块会
# 编译失败或运行报错;建议优先试 slim,追求极致小再上
# alpine 但务必完整跑一遍测试。★ 这一刀威力极大:仅把
# FROM 改成 slim/alpine 业务逻辑一行不改,地基就从 1GB
# 量级砍到一两百兆甚至几十兆,性价比最高最该第一个动。
# ★ 同一个服务,三种基础镜像,体积天差地别 —— FROM 是第一刀
# === 方案 A:默认的完整镜像 —— 地基就 1GB 量级 ===
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
# ★ 最终镜像:1GB+,自带一整套用不到的 Debian 系统工具
# === 方案 B:slim —— 砍掉非必需系统包,一两百兆 ===
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
# ★ 最终镜像:200MB 量级,业务逻辑一行没改
# === 方案 C:alpine —— 极简地基,几十兆 ===
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
# ★★ 最终镜像:几十兆;但上 alpine 后务必完整跑测试,
# 确认原生依赖在 musl libc 下没有兼容问题
第二刀:多阶段构建,把"构建期"彻底丢掉
# === ★ 这一刀,是镜像瘦身里最关键、削得最狠的一刀 ===
# === ★★ 一个被忽视的核心矛盾 ===
# ★ ★ 想清楚一件事:【构建】这个镜像需要的东西,和
# 【运行】这个镜像需要的东西,是【两套完全不同的东西】。
# - ★ 构建期需要:编译器、构建工具、devDependencies、
# 源代码 —— 用来把源码"加工"成可运行的产物;
# - ★ 运行期只需要:运行时(node / jre)、生产依赖、
# 以及构建好的【产物】(编译后的 js / class 文件)。
# ★ ★ 单阶段构建的根本错误:它把【构建期】用到的一切
# —— 编译器、devDependencies、源码 —— 统统留在了
# 最终镜像里。可这些东西,运行时一个都用不到。
# === ★ 多阶段构建(multi-stage build)的思路 ===
# ★ ★ 在【同一个 Dockerfile】里,写【多个 FROM】。每个
# FROM,就开启一个独立的"构建阶段"。
# ★ ★ 阶段一(builder):用一个【全功能】的镜像 —— 装上
# 所有编译工具、装上全部依赖(含 dev)、把源码拷进来、
# 跑完整的构建。这个阶段,要多大就多大,不心疼。
# ★ ★ 阶段二(runtime):用一个【极简】的镜像(slim /
# alpine / distroless),然后用 COPY --from=builder,
# 【只把阶段一产出的"成品"】,精准地拷过来。
# === ★★ 关键:阶段一,会被整个丢弃 ===
# ★ ★ 最终镜像,【只包含最后一个阶段】(阶段二)。阶段
# 一连同它那一大堆编译工具、devDependencies、源码 ——
# 在构建结束后,【整个被丢弃】,一个字节都不会进入
# 最终镜像。
# ★ ★ 你等于:在一个"宽敞的车间"里把零件加工好,然后
# 只把【成品】拿进"精装的展厅",车间连同满地铁屑,
# 全部留在门外。
# ★ 这一刀下去,那几百兆的编译工具链和 devDependencies,
# 被干干净净地切掉。
# === ★ 一个常被忽略的好处:构建产物的纯净 ===
# ★ ★ COPY --from 是"精准拷贝",你明确指定拷哪个目录。
# 这逼着你想清楚"我的成品到底是哪些文件",顺手就杜绝
# 了"把一堆中间垃圾也带进去"的可能。
# === 小结 ===
# ★ 这是瘦身最关键削得最狠的一刀。★★ 核心矛盾:构建这个
# 镜像需要的东西和运行它需要的东西是两套完全不同的 ——
# 构建期需要编译器/构建工具/devDependencies/源码把源码
# 加工成产物,运行期只需要运行时/生产依赖/构建好的产物;
# 单阶段构建的根本错误是把构建期的一切统统留在了最终
# 镜像里,可运行时一个都用不到。★ 多阶段构建:同一个
# Dockerfile 里写多个 FROM,每个 FROM 开启一个独立阶段
# —— 阶段一 builder 用全功能镜像装编译工具/全部依赖、
# 跑完整构建(要多大多大不心疼),阶段二 runtime 用极简
# 镜像再用 COPY --from=builder 只把阶段一产出的成品精准
# 拷过来。★★ 关键:最终镜像只包含最后一个阶段,阶段一
# 连同编译工具/devDependencies/源码在构建结束后整个被
# 丢弃一个字节都不进最终镜像 —— 等于在宽敞车间加工好
# 零件只把成品拿进精装展厅、车间连铁屑全留门外。★ 顺带
# 好处:COPY --from 是精准拷贝逼你想清楚成品到底是哪些
# 文件,杜绝了把中间垃圾也带进去。
# ★ 多阶段构建:builder 阶段尽情用工具,runtime 阶段只留成品
# ========== 阶段一:builder —— 用全功能镜像,跑完整构建 ==========
FROM node:20 AS builder
WORKDIR /app
# ★ 装【全部】依赖(含 devDependencies)—— 构建需要它们
COPY package*.json ./
RUN npm ci
# ★ 拷源码,跑构建(如 tsc 编译、webpack 打包)
COPY . .
RUN npm run build # ★ 产物落在 /app/dist
# ★ 再单独装一份【只含生产依赖】的 node_modules,给运行期用
RUN npm ci --omit=dev
# ========== 阶段二:runtime —— 极简镜像,只 COPY 成品 ==========
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
# ★★ 关键:用 COPY --from=builder,只把【成品】精准拷过来 ——
# 编译工具、devDependencies、源码 .ts 文件,统统不要
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# ★ builder 阶段连同它那一身编译工具,构建完整个被丢弃,
# 一个字节都不会进入这个最终镜像
CMD ["node", "dist/server.js"]
第三刀:用好层缓存,顺序决定快慢
# === ★ 这一刀不只为瘦身,更为"构建快" ===
# === ★ 先理解:Docker 构建时的"层缓存" ===
# ★ ★ docker build 是一层一层执行的。对每一层,Docker
# 会判断:"这一层的输入,和上次构建时相比,变了吗?"
# - 没变 -> 直接【复用上次的缓存层】,这一步瞬间跳过;
# - 变了 -> 这一层【重新执行】。
# ★ ★★ 最关键的规则:一旦某一层的缓存【失效】了,它
# 【后面的所有层】,缓存【全部连带失效】,统统要重新
# 执行 —— 哪怕后面那些层的内容其实根本没变。
# === ★ 推论:Dockerfile 指令,要按"变化频率"排序 ===
# ★ ★ 既然"一层失效、后面全失效",那么:【越不常变】的
# 指令,要放【越前面】;【越常变】的,放【越后面】。
# 这样,日常那些频繁的小改动,只会让靠后的几层失效,
# 前面的重活(如装依赖)一直命中缓存。
# === ★★ 经典案例:依赖安装 vs 拷贝源码 ===
# ★ ★ 反面顺序:先 COPY 全部源码,再 RUN npm install。
# 后果:你改一行业务代码,COPY 这层立刻失效 ->
# 它后面的 npm install 跟着失效 -> 【每次构建都把
# 几百个依赖,完完整整重装一遍】,几分钟就这么没了。
# ★ ★★ 正确顺序:① 先【只】COPY package.json + lock
# 文件;② RUN npm install;③ 【最后】才 COPY 源码。
# 这样:只要你的依赖清单没变,npm install 这层就
# 【永远命中缓存】;你改业务代码,只会让最后那个
# 极轻量的 COPY 失效。构建从几分钟,变成几秒。
# === ★ 别忘了 .dockerignore ===
# ★ ★ 执行 docker build 时,Docker 会先把【整个构建上下
# 文目录】打包,发给 Docker daemon。如果你不写
# .dockerignore,那么 node_modules、.git、本地日志、
# 临时文件……全都会被打包发过去。
# ★ ★ 后果有两个:① 上下文巨大,每次 build 光打包就慢;
# ② 一个不小心,这些垃圾就被 COPY . . 顺手拷进了镜像。
# ★ 解法:写一个 .dockerignore,把 node_modules、.git、
# 日志、测试产物等,统统排除在构建上下文之外。
# === 小结 ===
# ★ 这一刀不只为瘦身更为构建快。Docker 构建一层层执行,
# 对每层判断输入和上次比变了吗 —— 没变就复用上次缓存层
# 瞬间跳过,变了就重新执行;★★ 最关键规则:一旦某层
# 缓存失效,它后面所有层缓存全部连带失效统统重新执行,
# 哪怕后面那些层内容根本没变。★ 推论:Dockerfile 指令
# 按变化频率排序,越不常变的放越前面、越常变的放越后面,
# 这样日常频繁小改动只让靠后几层失效、前面的重活一直
# 命中缓存。★★ 经典案例:反面顺序先 COPY 全部源码再
# RUN npm install,改一行业务代码 COPY 层失效连带 npm
# install 失效、每次构建都把几百个依赖完整重装一遍;
# 正确顺序是先只 COPY package.json+lock、再 npm install、
# 最后才 COPY 源码,依赖清单没变 npm install 就永远命中
# 缓存,构建从几分钟变几秒。★ 别忘了 .dockerignore:
# docker build 会先把整个构建上下文目录打包发给 daemon,
# 不写它 node_modules/.git/日志全被打包过去 —— 上下文
# 巨大 build 慢、还可能被 COPY . . 顺手拷进镜像。
# ★ 反例:源码和依赖安装的顺序错了 —— 每次构建都重装依赖
FROM node:20-alpine
WORKDIR /app
COPY . . # ★★ 致命:先拷了【全部源码】
RUN npm ci --omit=dev # ★ 改任何一行业务代码,上面 COPY 失效
# -> 这层连带失效 -> 几百个依赖重装一遍
CMD ["node", "server.js"]
# ★ 正例:按"变化频率"排序 —— 不常变的依赖清单放前面
FROM node:20-alpine
WORKDIR /app
# ★ 第 1 步:只拷依赖清单(它不常变)
COPY package*.json ./
# ★★ 第 2 步:装依赖 —— 只要 package.json 没变,这层永远命中缓存
RUN npm ci --omit=dev
# ★ 第 3 步:最后才拷源码(它天天变)—— 改代码只让这一层失效
COPY . .
CMD ["node", "server.js"]
# ★ .dockerignore —— 把这些垃圾挡在构建上下文之外
# 放在项目根目录,docker build 时自动生效
node_modules # ★ 体积巨大,且镜像里会重新 npm ci,根本不需要它
.git # ★ 整个版本历史,几十上百兆,镜像里完全用不到
*.log # 本地日志
dist # ★ 构建产物,应在镜像内构建,而非从本地拷
.env # ★★ 本地环境变量/密钥,绝不能进镜像
coverage # 测试覆盖率报告
.vscode # 编辑器配置
README.md # 文档类,运行不需要
Dockerfile # Dockerfile 自身也没必要进上下文
工程坑:那些会让瘦身前功尽弃的细节
# === ★ 前三刀削完,这些坑会让你白忙一场 ===
# === ★★ 坑 1:清理,必须和"产生"在【同一个 RUN】里 ===
# ★ ★ 回到第一节那个铁律:后层删不掉前层的东西。所以:
# RUN apt-get install ... 是一层;你另起一个
# RUN rm -rf /var/lib/apt/lists/* 想清缓存 —— 没用!
# 缓存在【前一层】,你这层删,前层那份原封不动。
# ★ ★★ 正解:把"安装"和"清理",用 && 串在【同一个
# RUN】里。这样它们在同一层完成,这一层最终记录的,
# 就是"装好、且已清理干净"的状态。
# === ★ 坑 2:别用 latest 标签 ===
# ★ ★ FROM node:latest,或给自己的镜像打 :latest ——
# 都会让"这次到底用/部署的是哪个版本"变得【不可考】。
# ★ ★ 后果:① 今天 build 和下周 build,latest 可能已是
# 不同版本,构建结果不可复现;② 线上要回滚,你根本
# 不知道"上一个 latest"是哪个,无从回滚。
# ★ 正解:永远用【明确的版本号】(node:20.11-alpine)。
# === ★★ 坑 3:密钥,绝不能进构建过程 ===
# ★ ★ 一个高危错误:构建时把密钥(npm 私库 token、API
# key)用 ENV / ARG 传进去,或 COPY 一个 .env 进镜像,
# 想着"用完后面再删掉"。
# ★ ★★ 没用!还是分层那条铁律:你后面删了,密钥还躺在
# 前面的层里。任何人 docker history 一扒、把镜像层
# 解开一翻,密钥【明文可见】。
# ★ 正解:用构建工具的【专门 secret 机制】(如 BuildKit
# 的 --mount=type=secret)—— 它让密钥在构建时可用,
# 但【绝不写入任何一层】。
# === ★ 坑 4:合并 RUN 减少层,但别过度 ===
# ★ ★ 把多个 RUN 合并成一个(用 &&),能减少层数、也
# 利于"同层清理"。但【别把所有 RUN 揉成一条】——
# 那样缓存粒度太粗,改一点点,整条巨型 RUN 全部重跑。
# ★ 原则:按"逻辑 + 变化频率"合理分层,不是越少越好。
# === ★ 坑 5:镜像也要做安全扫描 ===
# ★ ★ 镜像瘦下来了,不等于安全。基础镜像、依赖包里,
# 都可能带【已知漏洞(CVE)】。把镜像扫描接入 CI
# (如 Trivy、Grype),定期扫,别让带洞的镜像上线。
# ★ 顺带:镜像越小、装的东西越少,攻击面本来就越小 ——
# 瘦身和安全,方向是一致的。
# === 认知 ===
# ★ 前三刀削完这些坑会让你白忙一场。★★ 坑 1 清理必须和
# "产生"在同一个 RUN 里:后层删不掉前层的东西,RUN
# apt-get install 是一层、另起一个 RUN rm 清缓存没用
# (缓存在前一层、你这层删前层原封不动),正解是把安装
# 和清理用 && 串在同一个 RUN 里、同层完成。★ 坑 2 别用
# latest 标签:会让"用/部署的是哪个版本"不可考 —— 今天
# 和下周 build 的 latest 可能不同版本构建不可复现、线上
# 回滚不知道上一个 latest 是哪个,正解永远用明确版本号。
# ★★ 坑 3 密钥绝不能进构建过程:用 ENV/ARG 传密钥或 COPY
# .env 进镜像想着用完再删没用 —— 分层铁律,后面删了
# 密钥还躺在前面的层里,docker history 一扒、镜像层一
# 解开就明文可见;正解用构建工具专门的 secret 机制
# (如 BuildKit --mount=type=secret)让密钥构建时可用
# 但绝不写入任何层。★ 坑 4 合并 RUN 减少层但别过度,
# 全揉成一条缓存粒度太粗改一点整条重跑,按逻辑和变化
# 频率合理分层。★ 坑 5 镜像也要做安全扫描:基础镜像和
# 依赖可能带已知漏洞 CVE,把扫描(Trivy/Grype)接入 CI
# 定期扫;镜像越小装的越少攻击面越小,瘦身和安全同向。
# ★ 反例:安装和清理分在两个 RUN —— 清理这层等于白做
FROM debian:12-slim
RUN apt-get update && apt-get install -y curl # ★ 这一层:含 apt 缓存
RUN rm -rf /var/lib/apt/lists/* # ★★ 没用!缓存在上一层,删不掉
# ★ 正例:安装 + 清理,用 && 串在【同一个 RUN】里
FROM debian:12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/* # ★ 同层完成:这层只记"装好且已清理"的状态
# ★★ 反例:用 ARG/ENV 传密钥 —— docker history 一扒就明文泄露
FROM node:20-alpine
ARG NPM_TOKEN # ★★ 危险:这个值会留在镜像层历史里
RUN npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN \
&& npm ci
# ★ 正例:用 BuildKit secret —— 密钥构建时可用,但不写入任何层
FROM node:20-alpine
COPY package*.json ./
# ★ --mount=type=secret 挂载的密钥,只在这条 RUN 执行时存在,不留痕
RUN --mount=type=secret,id=npm_token \
npm config set //registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token) \
&& npm ci --omit=dev
# 构建时:docker build --secret id=npm_token,src=./npm_token.txt .
命令速查
Docker 镜像瘦身:三刀 + 五坑
=============================================================
分层铁律 镜像是一摞只读层;后层删不掉前层的文件
-> "事后删"无效,只能"一开始就别让它进任何一层"
诊断 docker history <镜像> 看每层多大、谁是肥肉
docker images <镜像> 看总大小
瘦身三刀
-------------------------------------------------------------
第一刀 选基础镜像 FROM full -> slim -> alpine -> distroless
一行 FROM,地基从 1GB 砍到几十兆
第二刀 多阶段构建 builder 阶段尽情用工具 -> runtime 阶段
COPY --from 只拷成品;builder 整个丢弃
第三刀 用好层缓存 按变化频率排序:不常变的放前面
先 COPY 依赖清单 + 装依赖,最后才 COPY 源码
五个坑
-------------------------------------------------------------
坑1 清理 install 和 rm 必须用 && 串在【同一个 RUN】
坑2 标签 别用 latest,永远用明确版本号(可复现/可回滚)
坑3 密钥 ENV/ARG 传密钥会留在层历史 -> 用 BuildKit secret
坑4 分层 别把所有 RUN 揉成一条,缓存粒度会太粗
坑5 安全 镜像扫描接入 CI(Trivy/Grype),定期扫 CVE
口诀:后层删不掉前层 —— 别让垃圾进任何一层
构建期的东西,别带进运行期的镜像
指令按变化频率排,不常变的放前面吃缓存
避坑清单
- Docker 镜像是一摞只读层叠起来的,在后面的层删掉前面层的文件镜像不会变小——被删文件本体还躺在前层照样占体积,RUN 一句 rm 删一大坨体积纹丝不动
- 瘦身的第一性原理不是"事后删",而是一开始就别让不该进的东西(编译工具、临时下载物、devDependencies)进到任何一层
- FROM 那一行决定镜像地基大小,优先用 slim / alpine / distroless 变种,一行不改业务逻辑就能从 1GB 量级砍到几十兆
- alpine 用 musl libc 不是 glibc,极少数依赖 glibc 特有行为的原生模块会不兼容,上 alpine 后务必完整跑一遍测试
- 构建期需要的(编译器、devDependencies、源码)和运行期需要的(运行时、生产依赖、构建产物)是两套东西,用多阶段构建把构建期整个丢弃
- 多阶段构建里 builder 阶段连同它的编译工具在构建结束后整个被丢弃,最终镜像只含最后一个阶段,用 COPY --from 只精准拷成品
- 一旦某一层缓存失效它后面所有层缓存全部连带失效,Dockerfile 指令要按变化频率排序——不常变的放前面、常变的放后面
- 先 COPY 全部源码再 npm install 会让每次改代码都重装依赖,正确顺序是先 COPY 依赖清单、装依赖、最后才 COPY 源码
- 清理动作(如 rm apt 缓存)必须和产生它的安装动作用 && 串在同一个 RUN 里,另起一个 RUN 删上一层的东西完全无效
- 密钥绝不能用 ENV/ARG 传进构建或 COPY 进镜像,后面删了它仍留在层历史里 docker history 一扒就明文泄露,要用 BuildKit secret 机制
总结
这一趟把镜像瘦身彻底理清的过程,纠正了我一个特别隐蔽、却影响深远的认知偏差——我一直把"构建一个东西"和"交付一个东西",当成了【同一件事】。在我过去的脑子里,容器化的逻辑朴素得像一条直线:我有一个项目目录,里面有源码、有依赖、有各种工具配置,我写一个 Dockerfile,把这个目录"装进"一个镜像,这个镜像就能跑了。我从没问过自己一个问题:这个目录里的东西,是不是【每一样】都该进到那个最终要上线、要反复分发、要在生产机器上运行的镜像里?那个 1.4GB 的镜像,就是这个偏差结结实实的产物——我把"我开发时所处的整个环境",原封不动地、不加分辨地,当成了"我要交付的东西"。可它们根本不是一回事。我开发时需要编译器,但我的服务跑起来不需要;我构建时需要测试框架和打包工具,但运行时一个都用不上;我的工作目录里有 .git 有日志有临时文件,可这些和"让服务在生产环境跑起来"毫无关系。多阶段构建之所以是那决定性的一刀,正是因为它在工具层面,把我脑子里这条糊涂的直线,清清楚楚地切成了两段:一段叫"构建",发生在一个可以脏、可以乱、可以堆满工具的车间里;另一段叫"运行",发生在一个只该放成品的、干净的展厅里。我要交付的,从来只是展厅里那件成品,而不是那个车间。想通这件事,我意识到我真正修正的,是一种"打包一切"的惰性——因为分辨"什么该留、什么该扔"是费脑子的,而把整个目录囫囵塞进去是不费脑子的,所以我默认选择了后者,然后用一句"能跑就行"心安理得地放过了自己。可"能跑"这个标准,实在是太低了。一个 1.4GB 的镜像确实"能跑",但它让 CI 慢、让回滚慢、让仓库爆满、还把一堆编译工具和潜在的密钥泄漏风险一起带上了线。镜像分层那条最反直觉的铁律——"后面的层,删不掉前面层的东西"——更是把这种惰性的代价钉死了:你塞进去的每一样东西,无论后来怎么后悔、怎么 rm,它都会永远地、沉默地跟着这个镜像,直到你重新、干净地构建它。这件事给我的最终启发,早已超出了 Docker 本身:任何一次"交付",无论交付的是一个镜像、一个安装包、一份发给客户的文档,还是一个对外的 API,都值得我停下来,认真地问一句——我手里这一大堆东西里,对方【真正需要】的,到底是哪一小部分?把这个问题想清楚,然后只交付那一部分。交付的本质,不是"把我有的全给你",而是"把你需要的、干干净净地给你"。这中间那道需要费心思去分辨、去切割的边界,正是"专业"和"能跑就行"之间,真正的距离。
—— 别看了 · 2026