2022 年我做一个 Node.js 后端服务,要用 Docker 打包上线。第一版的 Dockerfile 我做得很省事:FROM node,把整个项目 COPY 进去,RUN npm install,然后 CMD 启动。本地 docker build 一跑——真不错:几分钟就构建完,容器能起来,接口能通。我心里很踏实:"Docker 嘛,把代码拷进去、依赖装好、能跑起来,不就行了。"可等这个镜像真正进了 CI/CD、要反复构建反复推送拉取,一串问题冒了出来。第一种最先把我打懵:镜像体积大得离谱——一个就几行代码的服务,打出来的镜像一点几个 G。第二种最磨人:每次只改了一行业务代码,重新构建却把 npm install 整个重跑一遍,几百个依赖全部重新下载,一次构建等好几分钟。第三种:镜像一大,推送到镜像仓库、再从仓库拉到生产机,每次都要传一点几个 G,CI 流水线大半时间耗在传输上。第四种最隐蔽:我后来扒开镜像一看,里面塞满了根本不该进去的东西——编译器、构建工具、.git 目录、测试文件、连我本机的临时文件都被打了进去,又臃肿、攻击面又大。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"Dockerfile 就是把代码拷进去、把依赖装好、能跑起来就行"。这句话把 Dockerfile 当成了一个"能跑就行"的流水账。可它不是。Docker 镜像是一层一层叠起来的(分层),Dockerfile 里的每一条指令,基本都会生成一个新的层。镜像的最终体积,是所有层的累加;构建的快慢,取决于有多少层能命中缓存、不必重跑。所以写 Dockerfile,根本不是"把命令按顺序堆上去",而是要懂:基础镜像该选哪个、指令该怎么排才能让缓存生效、构建用的工具怎么才能不进最终镜像、哪些文件压根不该被打进去。真正写好一个 Dockerfile,核心不是"让它能 build 出一个能跑的镜像",而是理解镜像的分层机制、选对基础镜像、利用好分层缓存、用多阶段构建隔离构建工具、用 .dockerignore 把垃圾挡在门外。这篇文章就把 Docker 镜像优化梳理一遍:为什么"能 build 出来就行"是错的、镜像分层和缓存到底怎么回事、基础镜像怎么选、指令怎么排才能让缓存生效、多阶段构建怎么用,以及非 root 用户、镜像扫描、版本固定这些把镜像真正做扎实要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个"能 build 出来、容器能跑"的 Dockerfile,进了 CI/CD 之后冒出一串问题:几行代码的服务,镜像体积一点几个 G;每次只改一行代码,npm install 全部重跑,构建慢得磨人;镜像太大,推送拉取每次都要传一两个 G,流水线大半时间耗在传输;镜像里塞满了编译器、构建工具、.git、测试文件这些根本不该进去的东西。
我当时的错误认知:"Dockerfile 就是把代码拷进去、依赖装好、能跑起来就行。"
真相:"能 build 出来"和"build 出一个好镜像",是两件完全不同的事。理解它们的差距,要先理解 Docker 镜像的一个核心机制——分层(layer)。一个镜像不是一个整体的大文件,它是一层一层叠起来的:你的 Dockerfile 里,基本上每一条 FROM / COPY / RUN 指令,都会生成一个新的层。这个机制带来两个直接后果。第一,镜像的体积,是所有层的累加——你在某一层装了一堆东西,哪怕后面一层把它删了,那一层的体积依然算在镜像里。第二,构建会缓存每一层:重新构建时,Docker 会从上往下逐层检查,只要某一层和它依赖的东西没变,就直接复用缓存;可一旦某一层变了,它下面的所有层,缓存全部作废、统统重跑。我那串问题,根子全在这里:镜像大,是因为选了一个臃肿的基础镜像,又把构建工具和垃圾文件全打进了层里;构建慢,是因为我把"几乎不变的依赖安装"排在了"频繁改动的代码拷贝"后面,代码一改,依赖层的缓存就跟着失效。
要把 Docker 镜像优化做对,需要几块认知:
- 为什么"能 build 出来就行"是错的——镜像是分层的,体积和缓存都由分层决定;
- 基础镜像选型——一个
FROM写错,镜像就先胖了几百 M; - 分层缓存——指令的顺序,直接决定每次构建快还是慢;
- 多阶段构建——把编译器、构建工具挡在最终镜像之外;
- .dockerignore、非 root 用户、镜像扫描这些工程坑怎么处理。
一、为什么"能 build 出来就行"是错的
先把这件最根本的事钉死:Docker 镜像不是一个打包好的压缩文件,它是一摞"只读层"叠起来的。你 Dockerfile 里写的每一条指令,几乎都在这摞东西上面再压一层。这个"分层"不是实现细节,而是你必须时时记在脑子里的东西——因为它同时决定了两件大事:镜像有多大,和构建有多快。镜像的大小,是每一层大小的总和,中间某一层产生的垃圾,后面就算删掉,那一层照样占着体积;构建的快慢,则取决于你改动的东西,会让多少层的缓存失效。不理解分层就写 Dockerfile,你就是在闭着眼睛往一摞层里乱塞东西——它能跑,只是因为你还没遇到它该有多大、该有多快的考验。
下面这段 Dockerfile,就是我那个"又大又慢"的第一版:
# 反面教材:能 build 出来,但又大又慢
FROM node:20 # 完整版基础镜像,本身就接近 1 GB
WORKDIR /app
COPY . . # 一上来就把整个项目拷进去
RUN npm install # 再装依赖
CMD ["node", "server.js"]
# 破绽一:FROM node:20 是完整版,自带编译器、Python、Git,几百 M 起步。
# 破绽二:先 COPY . . 再 npm install —— 改一行代码,依赖就全部重装。
# 破绽三:COPY . . 把 .git、node_modules、测试文件、日志全打进了镜像。
这段 Dockerfile 在本地第一次构建时表现不错,因为第一次构建,本来就要把所有层都跑一遍,你感受不到缓存的存在;镜像大不大,在本地也没人催你推送。它的问题不在指令本身——每条指令都合法、都能执行——而在一个被忽略的前提:它默认"镜像就是个能跑的整体,指令怎么排都无所谓"。可镜像是分层的。于是那串问题就有了解释:镜像一点几个 G,是因为 FROM node:20 这个完整基础镜像本身就近 1 个 G,再叠上完整的 node_modules 和被一起打进来的垃圾;改一行就全部重装,是因为 COPY . . 排在 npm install 前面——代码一改,COPY 这层就变了,它下面的 npm install 层缓存立刻作废;镜像里全是垃圾,是因为 COPY . . 不加区分地把当前目录所有东西都拷了进去。问题的根子清楚了:写好 Dockerfile 的工程量,全在"承认镜像是分层的、体积和缓存都被分层决定"之后——你不顺着分层来安排,镜像就只会又大又慢。先从第一层——基础镜像——说起。
二、基础镜像选型:一个 FROM 决定你的起跑线
镜像的第一层,就是 FROM 指定的基础镜像。这一层是你整个镜像的地基,它有多大,你的镜像就从多大起步。很多人镜像臃肿的第一个原因,就是 FROM 随手写了一个完整版。以 Node.js 为例,官方镜像就有好几个档次,体积天差地别:
# 同一个 Node.js 版本,不同基础镜像的体积对比
docker images | grep node
# node:20 ~1.1 GB 完整版:带编译器、Python、Git 等一大堆
# node:20-slim ~240 MB 精简版:去掉了大部分不必要的系统包
# node:20-alpine ~140 MB 基于 Alpine Linux,极致精简
# gcr.io/distroless ~170 MB distroless:连 shell 和包管理器都没有
同一个 Node 版本,完整版和 Alpine 版差了将近 1 个 G。把 FROM 从 node:20 换成 node:20-alpine,几乎不用改任何别的东西,镜像立刻瘦掉一大半:
# 换一个精简的基础镜像 —— 这一步几乎零成本,收益却最大
FROM node:20-alpine # 从 ~1.1 GB 的地基,换成 ~140 MB 的地基
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
# alpine 用 musl libc,极个别带 C 扩展的原生依赖可能要装编译工具,
# 大多数纯 JS 项目直接换过去就能跑。
这里的认知要点是:基础镜像的选型,是镜像优化里"性价比最高"的一步——它几乎不需要你改业务代码,只动一行 FROM,就能把镜像砍掉一大半。它的逻辑很朴素:你的服务真正运行时,需要的只是一个语言运行时和几个系统库;而完整版基础镜像,为了"什么场景都能用",预装了编译器、调试工具、各种语言环境——这些东西,你的服务跑起来一个都用不上,却要一直背着。选基础镜像的原则就一句话:够用就好,运行时需要什么就给什么,绝不为"可能用得上"买单。地基选对了,接下来就是把指令排好,让每次构建都尽量快——这就要说到分层缓存。
三、利用分层缓存:依赖和代码,必须分开 COPY
开头第二个问题——"改一行代码,依赖全部重装"——根子就在指令的排列顺序。前面说过,Docker 构建是逐层检查缓存的:某一层一旦失效,它下面的所有层缓存全部作废。而一个项目里,变动最频繁的是业务代码,变动最少的是依赖清单。第一版 Dockerfile 把它们用一个 COPY . . 混在了一起——代码一改,整个 COPY 层失效,后面的 npm install 跟着重跑。正确的做法是把它们拆开:先只拷依赖清单、装依赖,再拷其余代码:
# 关键技巧:把"几乎不变的依赖"和"频繁改动的代码"分层
FROM node:20-alpine
WORKDIR /app
# 第一步:只拷贝依赖清单这两个文件
COPY package.json package-lock.json ./
# 第二步:装依赖。只要上面两个文件没变,这一层就一直命中缓存
RUN npm ci
# 第三步:最后才拷贝其余的业务代码
COPY . .
CMD ["node", "server.js"]
# 现在改业务代码,只有最后的 COPY . . 这层失效;
# npm ci 那层因为 package.json 没变,直接复用缓存 —— 构建快得多。
这里还顺手做对了一件事:把 npm install 换成了 npm ci。npm ci 严格按 package-lock.json 安装,既更快,也保证每次装出来的依赖完全一致。下面这张图,把"改一行代码"时,两种 Dockerfile 的缓存命中差异画出来:
这里的认知要点是:分层缓存的精髓,是把 Dockerfile 的指令,按"变动频率"从低到高排列。变得最少的放最上面(基础镜像、系统依赖),变得最勤的放最下面(业务代码)。因为缓存是"自上而下、一断俱断"的,你把一个频繁变动的东西放在了高层,就等于亲手把它下面所有层的缓存,绑在了它的每一次改动上。依赖和代码分开 COPY,只是这个原则最典型的一个应用——它的本质,是不让"代码的改动",去牵连"依赖的安装"。缓存的事顺了,但还有一个大头没解决:构建时用到的那些编译工具,怎么才能不进最终镜像?
四、多阶段构建:把构建工具挡在最终镜像之外
很多项目构建时需要一套工具(编译器、打包器、各种 devDependencies),但运行时根本用不上它们。如果在一个镜像里又构建又运行,那些只在构建时用到的东西,就全留在最终镜像里了。解法是 多阶段构建(multi-stage build):在同一个 Dockerfile 里写多个 FROM,前一个阶段负责构建,后一个阶段只把构建产物拷过来,构建工具统统留在前一个阶段、不进最终镜像:
# 多阶段构建:第一阶段构建,第二阶段只拿构建产物
# ===== 阶段一:builder —— 负责安装全部依赖、编译打包 =====
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci # 装全部依赖,含 devDependencies
COPY . .
RUN npm run build # 编译/打包,产物输出到 dist/
# ===== 阶段二:运行镜像 —— 只要运行时需要的东西 =====
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev # 这次只装生产依赖,不要 devDependencies
COPY --from=builder /app/dist ./dist # 只从 builder 拷构建产物
CMD ["node", "dist/server.js"]
# 最终镜像里:没有 devDependencies、没有源码、没有编译过程的中间文件。
关键就在 COPY --from=builder 这一句:它只从前一个阶段挑出最终需要的产物,前一个阶段那一大堆构建依赖和中间文件,一概不带。如果是 Go、Rust 这类编译型语言,多阶段构建的效果更夸张——构建阶段要一整套编译工具链,运行阶段只需要一个编译好的二进制文件:
# 编译型语言的多阶段构建:最终镜像可以小到只有一个二进制
# ===== 阶段一:用完整的 Go 工具链编译 =====
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
# ===== 阶段二:一个几乎空的基础镜像,只放二进制 =====
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
CMD ["/server"]
# 最终镜像里没有 Go 编译器、没有源码 —— 可能只有十几 MB。
这里的认知要点是:多阶段构建,是把"构建期"和"运行期"这两个本质不同的环境,彻底切开。构建期需要的是一个"工具齐全的车间"——编译器、打包器、各种开发依赖;运行期需要的,只是一个"放成品的货架"——一个运行时,加上构建出来的产物。把这两者塞进同一个镜像,就等于把整个车间连同产品一起发了货。多阶段构建做的事,就是让你在车间里造完东西,只把成品搬到货架上,车间本身留在原地、绝不出厂。构建工具挡在外面了,但还有一类垃圾,是从一开始 COPY 就该被拦住的。
五、.dockerignore:别让垃圾文件混进构建
开头第四个问题——"镜像里塞满了 .git、测试文件、本机临时文件"——根子在于 COPY . . 这条指令:它不加区分地,把当前目录下的一切都拷进了镜像。解法是一个和 .gitignore 长得很像的文件——.dockerignore:把它放在项目根目录,列在里面的东西,构建时一概不会被 COPY 进去:
# .dockerignore —— 放在项目根目录,挡住不该进镜像的文件
node_modules # 依赖应在镜像内重新装,绝不从本机拷
.git # 版本历史,镜像运行时完全用不上
*.log # 各种日志文件
.env # 含密钥的环境变量文件,绝不能打进镜像
dist # 构建产物,应在镜像内构建,不从本机拷
coverage # 测试覆盖率报告
.vscode # 编辑器配置
Dockerfile # Dockerfile 本身也没必要进镜像
README.md # 文档
.dockerignore 有两重价值。一是让镜像更干净:.git、日志、测试报告这些运行时毫无用处的东西,不再占体积。二是更要命的安全:像 .env 这种含密钥、密码的文件,一旦被 COPY 进镜像,就等于把密钥随镜像一起分发出去了——任何拿到这个镜像的人,都能把它扒出来。还有一个额外的好处:node_modules 这类又大又多文件的目录被忽略后,Docker 构建时要传输的"构建上下文"变小了,构建本身也会更快。这里的认知要点是:.dockerignore 的作用,是给 COPY 这条"来者不拒"的指令,装上一道明确的过滤网。COPY . . 的语义是"把这里的一切都搬进去",而你的项目目录里,真正需要进镜像的,往往只是其中一小部分;剩下的——版本历史、依赖目录、日志、密钥、文档——要么是体积负担,要么是安全隐患。与其指望自己每次都把 COPY 写得很精确,不如用一个 .dockerignore 一次性地、声明式地划清边界:什么该进,什么永远别进。体积和构建都优化完了,最后是几个真正上生产才会撞见的工程坑。
六、工程坑:非 root 用户、版本固定与镜像扫描
五块设计之外,还有几个工程坑,不处理就会让你的镜像要么不安全、要么不稳定、要么你根本不知道它有没有漏洞。坑 1:别用 root 用户跑容器。容器里的进程默认是 root,一旦应用本身有漏洞被攻破,攻击者就在容器里拿到了 root 权限,风险会被放大。正确做法是在 Dockerfile 里创建一个普通用户,用它来跑应用:
# 安全:创建并切换到非 root 用户来运行应用
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
# 创建一个普通用户和用户组(alpine 用 addgroup/adduser)
RUN addgroup -S app && adduser -S app -G app
USER app # 此后的进程都以非 root 的 app 用户运行
CMD ["node", "server.js"]
坑 2:基础镜像的 tag 要尽量明确,别用 latest。FROM node:latest 看着省事,可latest 指向的镜像会变——今天构建和下个月构建,可能装出两个不同版本的 Node,埋下"在我机器上好好的"那类问题。要把版本写明确,对稳定性要求极高时,甚至可以用 digest 把镜像钉死:
# 不好:latest 是浮动的,不同时间构建可能装出不同版本
# FROM node:latest
# 好:明确到次版本号,构建结果可复现
FROM node:20.11-alpine
# 最严格:用 sha256 digest 把基础镜像精确钉死,完全不变
# FROM node:20.11-alpine@sha256:abc123...
坑 3:RUN 装完包要顺手清理缓存,并合并成一条指令。前面说过,某一层产生的体积,后面删也删不掉。所以安装和清理必须写在同一条 RUN 里——让它们在同一层内完成,清理才真正减小了这一层的体积:
# 反面:装和删分成两条 RUN —— 删除发生在新层,旧层体积仍在
# RUN apk add --no-cache python3 make g++
# RUN rm -rf /var/cache/apk/* # 这层删了,上一层照样占体积
# 正确:装、用、清理写在同一条 RUN 里,在同一层内完成
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
&& npm ci \
&& apk del .build-deps # 同一层内装上构建工具、用完、再删掉
# --no-cache 让 apk 不留包索引缓存;--virtual 把这批包归组,方便一次删净。
坑 4:镜像要做安全扫描。你用的基础镜像、装的依赖,都可能带着已知漏洞。别等出事,要用工具定期扫描镜像,把高危漏洞揪出来:
# 用 trivy 扫描镜像里的已知漏洞(CVE)
trivy image my-service:1.0.0
# 或用 Docker 自带的 scout
docker scout cves my-service:1.0.0
# 把扫描接进 CI:发现高危漏洞就让流水线失败,挡在上线之前
trivy image --exit-code 1 --severity HIGH,CRITICAL my-service:1.0.0
坑 5:用 docker history 看清体积到底花在哪。镜像大,别瞎猜。docker history 会把每一层的体积列出来,哪一层是大头一目了然,优化才有的放矢:
# 查看镜像每一层的体积 —— 优化前先看清大头在哪
docker history my-service:1.0.0
# 输出会逐层列出体积,例如:
# IMAGE CREATED BY SIZE
# abc... RUN npm ci 180MB <- 依赖层
# def... COPY . . 12MB
# ghi... FROM node:20-alpine 140MB <- 基础镜像
# —— 对着最大的那几层下手,才是有效的优化
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 镜像分层 | 镜像由多个只读层叠成,Dockerfile 每条指令基本生成一层 |
| 分层缓存 | 构建时逐层检查,某层失效则其下所有层缓存全部作废 |
| 基础镜像 | FROM 指定的第一层,选 alpine/slim 可比完整版小一个量级 |
| 多阶段构建 | 多个 FROM,构建阶段的工具不进最终运行镜像 |
| COPY --from | 从前一个构建阶段挑选产物拷入当前阶段 |
| .dockerignore | 声明哪些文件不进构建上下文,挡住垃圾与密钥 |
| npm ci | 严格按 lock 文件安装,比 npm install 更快更可复现 |
| 非 root 用户 | 用 USER 切换到普通用户运行,缩小被攻破后的危害 |
| 镜像扫描 | 用 trivy / docker scout 检出镜像里的已知 CVE 漏洞 |
| docker history | 逐层查看镜像体积,定位优化的大头 |
避坑清单
- 镜像是分层的,体积是各层累加,中间层的垃圾删了也仍占体积。
- 基础镜像别用完整版,优先 alpine/slim,一行 FROM 就能瘦一半。
- 依赖清单和业务代码分开 COPY,让依赖层稳定命中缓存。
- 把变动少的指令放上面、变动勤的放下面,顺着缓存的方向排。
- 构建工具用多阶段构建隔离,最终镜像只拷构建产物。
- 写 .dockerignore 挡住 .git、node_modules、日志、.env 等。
- 含密钥的 .env 绝不能被 COPY 进镜像,会随镜像泄露出去。
- 用 USER 切到非 root 用户跑应用,别让进程默认是 root。
- 基础镜像 tag 写明确,别用浮动的 latest,必要时钉 digest。
- 安装与清理写在同一条 RUN 里,并定期扫描镜像的已知漏洞。
总结
回头看那串"镜像一点几个 G、改一行就全部重装、推送拉取慢、镜像里全是垃圾"的问题,以及我后来在 Dockerfile 上接连踩的坑,最该记住的不是某一个优化技巧,而是我动手前那个想当然的判断——"Dockerfile 就是把代码拷进去、依赖装好、能跑起来就行"。这句话错在它把 Dockerfile 当成了一份"能跑就行"的命令流水账。我以为指令只要顺序合法、最后能 build 出一个能跑的镜像,就算写对了。可我忽略了一件事:Docker 镜像不是一个整体,它是一层一层叠起来的。你写下的每一条指令,都在往这摞层上再压一层——这一层有多大、它会不会让下面的缓存失效,你不管,这摞层可不会替你管。它能 build 出来,从来不等于它被叠得好。
所以做好 Docker 镜像优化,真正的工程量不在"把命令按顺序写进 Dockerfile"那几行上。那几行,谁都会写。真正的工程量,在于你要承认"镜像是分层的、体积和构建速度都被分层决定",并据此重新安排每一条指令:地基不能臃肿,你就把 FROM 换成 alpine 这样的精简基础镜像;缓存不能被代码改动牵连,你就把依赖和代码分开 COPY、让稳定的层待在上面;构建工具不能进最终镜像,你就用多阶段构建把车间和货架切开;垃圾和密钥不能被打进去,你就用 .dockerignore 划清边界;进程不能是 root,你就创建一个普通用户来跑它。这篇文章的几节,其实就是顺着这条线展开的:先想清楚"能 build 出来就行"为什么错,再讲基础镜像怎么选、分层缓存怎么用、多阶段构建怎么写、.dockerignore 怎么配,最后是非 root、版本固定、镜像扫描这几个把镜像守扎实的工程细节。
你会发现,Docker 镜像优化,和现实里"搬家时怎么打包行李"完全相通。你要从旧房子搬到新房子,镜像就是你打的那些箱子。一个不会打包的人会怎么做?他把整个房间——家具、垃圾、过期的食物、连墙上的灰——一股脑全塞进箱子(这就是 COPY . . 和臃肿的基础镜像);他把"常穿的衣服"和"压箱底的杂物"混在同一个箱子里,结果每次想拿件衣服,都得把整箱杂物翻一遍(这就是依赖和代码不分层、缓存反复失效);他把搬家用的梯子、纸箱、胶带,也一起搬进了新家,堆在客厅里占地方(这就是构建工具留在了最终镜像里)。而一个会打包的人怎么做?他先列一张"不带清单",垃圾、过期食物、旧报纸,根本不装箱(这就是 .dockerignore);他把常用的和不常用的分箱装,常用的单独一箱、随时能拿(这就是分层缓存:稳定的归稳定、易变的归易变);搬完之后,梯子、空纸箱这些工具,留在旧房子或直接还掉,绝不搬进新家(这就是多阶段构建)。同样的家当、同样要搬,可前者搬进一屋子又重又乱的箱子,后者轻装入住、东西还件件好找——差别不在东西多少,只在那一套"列不带清单、分类装箱、工具不进门"的打包章法。
最后想说,Docker 镜像写没写好,差距永远不会在"本地第一次 build、几分钟就出镜像"时暴露——本地第一次构建本就要跑全部层,你感受不到缓存;镜像大不大,本地也没人催你推送,你会觉得"能 build 出一个能跑的镜像"已经是全部。它只在真实的、要反复构建、反复推送拉取、还要扛安全审计的 CI/CD 环境里才显形。那时候它会用最磨人的方式给你结账:做不好,你的流水线会因为一个一点几个 G 的镜像,把大半时间耗在传输上,会因为依赖没分层,改一行代码就等几分钟构建,还可能因为一个 .env 被打进镜像,把密钥泄露出去;而做对了,你的镜像会小而干净:基础镜像是精简的,缓存大多数时候稳稳命中,构建工具一个都没留下,推送拉取又快又轻。所以别等"CI 慢得让人骂街、安全扫描报出一屏漏洞"找上门,在你写下那行 FROM 的时候就该想清楚:我叠的不是一个能跑就行的整体,而是一摞会被反复构建、反复传输、反复审计的层——它的地基瘦吗、它的缓存能命中吗、它的构建工具留下了吗、它的垃圾和密钥挡住了吗,这一道道工序,我是不是都替它走完了?这些问题有了答案,你写下的才不只是一个"能跑"的 Dockerfile,而是一份小、快、安全、经得起 CI/CD 反复折腾的可靠镜像构建。
—— 别看了 · 2026