工作里最常见的一幕某同事把服务往测试环境推构建跑了九分钟docker images一看镜像 1.3G容器起来还秒退。他甩过来一个 Dockerfile问我能不能“顺手优化一下”。这类活儿我接过太多次了八成问题都出在那十几行指令的写法上——不是环境问题不是网络问题是 Dockerfile 本身没有按构建规则去组织。Dockerfile 说白了就是一份“从零搭出一个可运行文件系统”的施工图它决定了镜像有多大、构建有多快、能不能被别人安全地拿去部署。这篇东西写给三类人刚接触容器、只会docker build .的新手写过几个 Dockerfile 但总觉得构建慢、镜像胖的中级使用者以及需要把构建流程固化进流水线、被 CI 时长折磨过的老手。下面全部是能直接照着抄的写法和踩过的坑不讲虚的。1. 从一次构建说起Dockerfile 到底在解决什么问题1.1 手工打镜像的老路为什么走不通早些年做镜像有一种很土但很常见的办法先起一个基础容器进去apt install一堆东西改配置装依赖最后docker commit成一个新镜像。这条路上手极快几乎零学习成本但它有三个致命毛病。第一是不可复现。你今天commit出来的镜像明天换个网络环境、换个镜像源、某些包升了个小版本再commit一次结果就不一样了。出了问题想回滚到“上周那个能跑的版本”你会发现根本没有版本可言只有一堆哈希值不同的镜像躺在本地。第二是不可审计。新人接手想知道这个镜像里到底装了什么、改过哪些文件只能一层层docker history去看而且 commit 出来的层往往巨大且语义混乱里面混着临时下载的安装包、编辑器缓存、甚至.bash_history。第三是无法自动化。手工操作意味着每次发版都要有人重复一遍人一多、机器一多就必然出错。Dockerfile 就是为了治这三个病。它把“构建镜像”这件事从操作行为变成了声明式文本环境怎么来、依赖怎么装、代码怎么放、进程怎么起全部写死在文件里。同一份 Dockerfile 加同一份构建上下文在任何一台装了构建引擎的机器上产出的镜像内容是一致的除非基础镜像的 tag 被覆盖。这就把镜像纳入了版本管理的范畴也把构建纳入了流水线。我自己判断一个团队的容器化成熟度不看他们用了多少编排工具就看两件事Dockerfile 是否进代码仓库、是否有 code review。能做到这两点镜像的可维护性基本就过关了。1.2 一条指令一层的代价与好处理解 Dockerfile 绕不开“层”layer。构建镜像时每条会改变文件系统的指令RUN、COPY、ADD都会在当前层之上再叠一层形成只读的增量层镜像就是这些层的叠加加上一份描述启动方式的元数据。这么设计的原因很实在层是可以被复用的。多个镜像如果共享同一段基础层本地磁盘只存一份推送和拉取时也只传缺失的层。这也是为什么官方基础镜像那么受欢迎——大家都从它开始底层全都能命中缓存。但代价同样明显有三条必须记牢层只增不减。你在某层删掉一个文件文件在当前层“看不见”了但它仍然存在于下层镜像体积不会变小。所以RUN apt-get install xxx之后再单独写一条RUN rm -rf /var/lib/apt/lists/*等于白删。层数过多会拖慢构建和分发。老版本的文件系统驱动对层数有上限历史上是 127 层现在虽然放宽了但几十层依然会让pull变慢。层的顺序决定了缓存的价值。一旦某个层失效它之后的所有层全部作废不管内容有没有变。这三点直接推导出 Dockerfile 写作的两条核心法则能合并的清理动作合并到同一条RUN里不常变的东西放前面、常变的东西放后面。后面第 4 节会把缓存的判定规则拆开讲。1.3 构建上下文和 Dockerfile 是两回事新手最容易混淆的概念就在这里。docker build -t myapp .里那个.不是 Dockerfile 的路径是构建上下文build context——一个目录。构建引擎会把这个目录里的内容默认排除.dockerignore里声明的部分准备好供COPY、ADD指令取用。这意味着两件事一COPY的源路径只能写上下文内的相对路径COPY /etc/nginx/nginx.conf这种绝对路径是拿不到宿主机文件的二上下文越大构建启动越慢。我见过有人在项目根目录直接构建上下文里带着node_modules、.git、几个 G 的测试数据集光传输就花两分钟。这种问题跟 Dockerfile 写得好不好没关系纯粹是上下文没管住。2. 核心指令逐个拆解把坑提前填掉2.1 FROM 选基镜像是第一道分水岭FROM决定了后面所有指令的起点选错了后面怎么优化都是事倍功半。常见选择有这么几档基础镜像类型典型体积适用场景主要代价完整发行版如debian、ubuntu70~120MB依赖系统库较多、需要调试工具体积大攻击面大-slim变体30~80MB生产环境跑编译型程序或纯 Python 服务缺部分常用工具装东西要联网Alpine5~8MB追求极致体积、能接受 musl部分预编译二进制不兼容DNS 行为有差异distroless / scratch0~2MB静态编译的 Go/Rust 服务没有 shell排查困难关于 Alpine我得说句公道话它不是万能解药。Alpine 用的是 musl libc某些依赖 glibc 的预编译包尤其是一些 Python 的 wheel、部分 Java 相关的本地库跑上去会报not found或者诡异的段错误。我踩过一次某个图像处理库在 Alpine 上编译出来运行时崩溃换回slim就好了折腾了整整一个下午。所以我的默认策略是Go/Rust 这类能静态链接的用 Alpine 或 scratchPython/Node 用官方-slim确实需要系统工具链的才用完整镜像。另外一个细节FROM后面的 tag 不要用latest。用latest意味着某天上游一推新版本你的构建结果就变了而且没人知道变了什么。至少写主版本号比如golang:1.22-alpine更稳的做法是锁到小版本甚至摘要digest。2.2 RUN 的写法决定镜像胖瘦RUN是构建过程中唯一真正在“执行命令”的指令也是最容易把镜像搞胖的地方。三个高频问题第一清理和安装不在同一层。前面说过分两条写等于没删。正确姿势是合并RUN apt-get update \ apt-get install -y --no-install-recommends \ ca-certificates \ curl \ tzdata \ rm -rf /var/lib/apt/lists/*这里--no-install-recommends能省掉大量“推荐但非必需”的包实测在 Debian 上装同一个软件包加不加这个参数体积能差 30%~50%。rm -rf /var/lib/apt/lists/*必须跟在同一个RUN里才会让这一层的最终内容里不含索引缓存。第二交互式命令卡死。apt-get install在遇到需要确认的配置项时非交互环境下会直接挂住构建表现为“卡在某一步没动静”。加-y还不够保险环境变量DEBIAN_FRONTENDnoninteractive才是根治手段。可以写成ARG DEBIAN_FRONTENDnoninteractive只在这一层生效也不会污染运行时的环境变量。第三命令行过长。一条RUN塞十几行用连起来可读性极差改一次要重新对齐半天。我的习惯是超过五行就换行加反斜杠并且保持每行以开头改的时候不容易漏符号。逻辑上仍然是一条RUN一层不影响体积。Python 项目还有个专属优化pip install --no-cache-dir。这个参数会阻止 pip 在镜像里留下载缓存一个中等规模的项目能省一两百 MB。2.3 COPY 与 ADD能用前者就别用后者这两条指令功能有重叠但定位完全不同。COPY只做一件事把上下文里的文件复制到镜像里。行为可预测是我 99% 场景下的选择。ADD多了两个能力能解压本地 tar 包能从 URL 下载文件。这两个能力听着方便实际上都是坑源。从 URL 下载不会校验内容也无法利用构建缓存每次构建都会重新拉而且下载下来的文件权限是 600经常导致后面启动失败。tar 自动解压则让“复制”这个动作变得不透明——看到ADD app.tar.gz /app/你得想一下到底放进去的是压缩包还是解压后的目录。所以我的规则很干脆需要下载就用RUN curl -fsSL -o /tmp/x.tar.gz 地址 tar -xzf /tmp/x.tar.gz -C /opt rm /tmp/x.tar.gz分步清晰还能清理需要复制就用COPY。COPY本身有几个细节值得记住支持--chown比如COPY --chown10001:10001 . /app。这个很重要后面USER切非 root 时如果文件属主是 root 而进程是非 root读写就会失败。COPY a b c /dst/多源复制时目标必须是以/结尾的目录。COPY的缓存判定基于文件内容的校验和内容没变就命中缓存跟文件修改时间无关。这点比某些构建工具友好。2.4 CMD 与 ENTRYPOINT 的真实分工这两条指令的组合规则我面过的人里至少一半说不清楚。用一句话概括ENTRYPOINT是“这个镜像就是用来干这个的”CMD是“默认参数可以被覆盖”。镜像中的组合docker run img实际执行docker run img foo实际执行只有 CMD[a,b]a bfoo只有 ENTRYPOINT[a]aa fooENTRYPOINT[a] CMD[b]a ba foo从表里能看出关键差异docker run后面跟的参数会替换 CMD但如果存在 ENTRYPOINT参数是追加到 ENTRYPOINT 后面。所以做基础镜像、做 CLI 工具镜像时用 ENTRYPOINT 更合适做通用服务镜像时用 ENTRYPOINT 加 CMD 给默认参数别人用起来最灵活。还有一个比组合规则更容易出事的地方shell 形式和 exec 形式的区别。CMD python app.py是 shell 形式实际会被包成/bin/sh -c python app.py主进程变成 shpython 是它的子进程。后果是容器收到停止信号时sh 不一定会转发给 python服务没法优雅退出只能等超时被强杀。而CMD [python, app.py]是 exec 形式python 直接作为 1 号进程信号能收到。这个坑的表现特别有迷惑性本地docker stop每次都要等 10 秒才停日志里没有任何优雅关闭的记录。第一次遇到根本想不到是 CMD 写法的问题。结论就一句一律用 exec 形式JSON 数组除非你确实需要 shell 做变量展开。2.5 ARG 与 ENV 的作用域差别这两个都是定义变量的但生命周期完全不同维度ARGENV生效阶段仅构建期构建期 运行期传值方式--build-arg KEYVAL-e KEYVAL/ compose 的 environment是否进入最终镜像配置一般不进入但默认值会残留在 history 里进入docker inspect可见能否用于FROM之前可以不可以几个实践要点构建期变量用 ARG运行期配置用 ENV。比如代理地址、包版本号、编译参数用 ARG服务的监听端口、时区、日志级别用 ENV。别把密钥写成 ARG 或 ENV——两者都能被docker history或docker inspect看到等于明文公开。真要传敏感信息用构建时的 secret 挂载机制或者干脆在运行时注入。ARG 在FROM前后是两套。想用变量控制基础镜像版本得这么写ARG GO_VERSION1.22 FROM golang:${GO_VERSION}-alpine AS builder ARG GO_VERSION第二行的ARG不是重复是重新声明——FROM之前的 ARG 出了FROM就失效了后面的指令读不到。这个规则不看文档绝对想不到。ARG 的缓存有个隐藏陷阱。传统构建模式下RUN echo $FOO这条指令的缓存 key 只看指令字符串本身如果两次构建指令文本一样但--build-arg FOO不同可能错误地命中缓存拿到旧结果。稳妥的做法是把变量值直接展开写进指令里或者换用新版构建引擎它对变量参与缓存的计算更严谨。3. 实操把一个“能跑”的 Dockerfile 改成“敢上线”的3.1 需求与目录结构假设我们有个内部服务叫agent负责采集本机指标然后上报Go 写的编译出来是单个二进制监听 8080。目录大概这样agent/ ├── cmd/agent/main.go ├── internal/... ├── go.mod ├── go.sum ├── configs/agent.yaml └── Dockerfile需求很朴素镜像要能跑、体积要小、构建要快、别用 root 跑。下面按三个版本演进每一版都能实际构建方便对照。3.2 第一版能跑但 800MB新手最常见的写法长这样FROM golang:1.22 WORKDIR /app COPY . . RUN go build -o agent ./cmd/agent CMD ./agent这一版有四个问题逐个说基础镜像golang:1.22是完整 Debian 加全套 Go 工具链光它自己就 800MB 上下。最终镜像里塞着一整个编译环境运行时根本用不上。COPY . .放在go build之前意味着任何一个源码文件的改动都会让依赖下载和编译全部重来。改一行注释构建时间从 20 秒变 90 秒。CMD ./agent是 shell 形式前面讲过信号收不到。默认 root 运行容器里被拿到 shell 就是 root 权限。实测体积约 830MB。构建耗时有缓存约 8 秒改动源码后约 75 秒。3.3 第二版多阶段构建拆开编译与运行多阶段构建的核心思想是编译需要的东西和运行需要的东西压根不是一套。用AS给阶段命名最后一个阶段就是最终镜像前面的阶段只作为“中间产物提供者”。# 阶段一编译 FROM golang:1.22-alpine AS builder WORKDIR /src ENV CGO_ENABLED0 GOOSlinux COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -trimpath -ldflags-s -w -o /out/agent ./cmd/agent # 阶段二运行 FROM alpine:3.19 RUN apk add --no-cache ca-certificates tzdata \ addgroup -g 10001 app \ adduser -D -u 10001 -G app app WORKDIR /app COPY --frombuilder /out/agent /app/agent COPY configs/agent.yaml /app/configs/agent.yaml USER 10001 EXPOSE 8080 ENTRYPOINT [/app/agent]几处关键决策的理由CGO_ENABLED0关掉 cgo编译出纯静态二进制。这样即使运行阶段换成 scratch 也能跑不依赖目标系统的 libc。代价是某些依赖 cgo 的库比如某些 DNS 解析、SQLite 驱动用不了得提前确认。-trimpath去掉编译路径信息-ldflags-s -w去掉符号表和调试信息。两个加起来二进制体积通常能减 25%~30%副作用是panic时的堆栈里看不到完整路径排查问题会稍微麻烦一点——我的做法是线上镜像剥符号排查时用带符号的构建产物配合。COPY go.mod go.sum ./单独一行然后go mod download再COPY . .。这是为了解决缓存问题只要依赖声明没变go mod download这一层就命中缓存无论业务代码怎么改。这是整个优化里性价比最高的一招。运行阶段装ca-certificates和tzdata。前者是 HTTPS 请求必需不装的话访问带证书的接口会报x509: certificate signed by unknown authority而且报错信息很难联想到是镜像缺证书后者是时区数据不装的话TZAsia/Shanghai设了也没用日志时间全是 UTC排查问题时对时间戳能对到怀疑人生。adduser -D -u 10001 app建了个非 root 用户USER 10001切换。注意这里用的是数字 UID比用户名更稳——如果基础镜像里用户表有差异用名字可能解析失败。实测体积约 22MB。构建耗时全冷约 65 秒改动源码后约 12 秒。3.4 第三版缓存挂载与更激进的瘦身第二版已经能上生产了。如果想再压一压有两个方向。方向一给编译器加持久缓存。新版构建引擎支持把某次构建的缓存目录跨构建保留RUN --mounttypecache,target/root/.cache/go-build \ --mounttypecache,target/go/pkg/mod \ go build -trimpath -ldflags-s -w -o /out/agent ./cmd/agentgo-build目录缓存编译中间产物go/pkg/mod缓存模块下载。效果在 CI 上特别明显无缓存的流水线每次都要重新编译所有包加了之后增量编译能把构建从 60 秒压到 15 秒以内。这里要提醒一句缓存目录不会被写进镜像它是挂在构建期的临时目录所以不会让镜像变大。方向二换 scratch 或 distroless。如果二进制是纯静态的运行阶段可以直接FROM scratch COPY --frombuilder /out/agent /agent COPY --frombuilder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ USER 10001 ENTRYPOINT [/agent]体积能压到 15MB 以内。但代价要说清楚scratch 里没有 shelldocker exec -it xxx sh直接失败出了问题只能靠日志和外部工具排查。我的经验是——核心服务、排查频率高的用 Alpine 留个 shell 更实际边缘采集组件、逻辑简单、日志完善的用 scratch 省资源。3.5 三版对比与一个反直觉的结论版本镜像体积冷构建改源码后构建运行用户信号处理第一版约 830MB约 90s约 75sroot不优雅第二版约 22MB约 65s约 12s10001正常第三版缓存挂载约 15MB约 60s约 8s10001正常看这张表能发现一个反直觉的事多阶段构建并没有让冷构建变快甚至可能更慢一点因为要起两个阶段。它真正解决的是镜像体积和运行安全。构建速度的提升来自缓存组织方式和缓存挂载这是两件独立的事别混为一谈。很多人以为上了多阶段构建就自动变快结果发现没变化然后开始怀疑人生。另外补一句关于构建顺序的通用原则按“变化频率”从低到高排列指令。系统依赖很少变→ 语言运行时的包管理清单偶尔变→ 依赖安装偶尔变→ 业务代码天天变→ 启动命令几乎不变。这条原则比任何技巧都重要违反了它后面所有优化都是事倍功半。4. 构建缓存与构建上下文构建变慢的两个真凶4.1 缓存命中规则我自己的理解缓存这件事用一句话描述就是构建引擎按顺序走每一条指令对每条指令计算一个“指纹”如果这个指纹和历史上某次构建的某条指令一致并且它前面所有层的指纹也一致就直接复用那层的结果跳过执行。指纹怎么算取决于指令类型RUN主要看指令文本。文本一模一样就可能命中。这就是为什么在RUN里写apt-get update单独一行是有风险的——文本没变但上游仓库内容可能已经变了你会拿到一个几周前的包索引。COPY/ADD看被复制文件的内容校验和加上目标路径。内容没变命中。ENV、WORKDIR、USER等元数据指令看键值。关键点在于“前面所有层都一致”这个前提。缓存是链式的第 5 条指令的缓存失效第 6 到第 20 条全部重算哪怕它们的内容一个字都没改。所以优化缓存的核心动作只有一个把最不常变的指令往前挪把天天变的往后挪。我排查构建慢的固定套路是这样的看构建输出里第一个出现CACHED消失的位置那就是失效点往前一条就是罪魁祸首。如果是COPY导致的检查是不是把整个目录都复制进去了把依赖清单和业务代码拆开。如果是RUN导致的检查它前面是不是有COPY . .把缓存打断了。全都没问题但还是慢就上缓存挂载或者检查是不是加了--no-cache。还有一种情况镜像拉取慢导致的“假慢”。构建第一步是拉基础镜像如果本地没有且网络一般光拉golang:1.22-alpine就可能花几十秒。这种情况看构建输出的第一行就能分辨别误判成自己的 Dockerfile 有问题。4.2 .dockerignore 到底省了什么.dockerignore放在构建上下文根目录语法跟.gitignore类似支持!做例外。它的作用是在把上下文传给构建引擎之前就剔掉文件。它省的是两笔账第一笔是传输时间。上下文里的每个文件都要参与“发送给构建引擎”这个动作新版引擎有增量优化但依然要扫描。一个带node_modules的前端项目轻松几万个文件光扫描加打包就十几秒。第二笔是缓存稳定性。这条更隐蔽。如果你的COPY . .把日志文件、临时文件、编辑器生成的.swp都复制进去了那这些文件天天变COPY那一层的缓存天天失效后面全崩。把它们排除掉缓存立刻稳定。我的项目里几乎都会放这么一份.git .gitignore node_modules vendor dist build *.log *.tmp .DS_Store .idea .vscode Dockerfile .dockerignore README.md几个说明Dockerfile和.dockerignore本身没必要进镜像.git目录经常有几个 G 的历史必须排vendor要不要排取决于构建策略——如果依赖靠go mod download拉就排掉如果要在无网环境构建、依赖 vendor 目录那必须保留。这里有个高频困惑COPY . .到底复制了什么如果.dockerignore写错了比如把internal目录排掉了构建会直接报file not found或者更糟——构建成功但运行时报配置缺失。我的习惯是配完之后用一行命令验证docker build --no-cache -t ctx-test --target builder . 21 | head -50或者更直接的办法写个临时RUN ls -la /app打出来看。花三十秒确认比上线后排查省三小时。5. 常见问题与排查技巧实录5.1 报错定位的基本套路Dockerfile 的问题分两大类构建期和运行期。这两类的排查入口完全不同先分清能省一半时间。构建期的问题看构建输出的最后 20 行基本就有答案。构建引擎会明确指出是哪条指令、什么错误码。这里有个技巧在可疑的指令后面临时加RUN echo --- step ok ---能快速定位到底卡在哪一步尤其适合那种“报错信息指向很模糊”的场景。运行期的问题第一手资料永远是日志docker logs --tail 100 容器。容器起不来、起来就退九成能从日志里看到原因。如果日志是空的说明进程压根没启动问题出在ENTRYPOINT/CMD上这时候docker inspect 镜像 | grep -A5 Entrypoint直接看配置最快。还有一条很少人用的命令docker history 镜像 --no-trunc --human。它能列出每一层的大小和对应指令专门用来抓“哪一行把镜像搞胖了”。如果发现某层特别大但指令看着人畜无害多半是那层里下载的临时文件没清理。5.2 问题速查表下面这些是我和身边人真实踩过、并且重复踩过的按症状整理症状大概率原因处理方式COPY failed: no source files were specified源路径被.dockerignore排除或用了上下文外的绝对路径检查.dockerignore确认路径相对上下文根构建卡住无输出apt-get等交互式命令等待输入加-y和DEBIAN_FRONTENDnoninteractive镜像比预期大很多清理没和安装写在同一个RUN里合并成一条用连接运行时报not found但文件明明存在架构不匹配arm64/amd64或 Alpine 缺 glibc用--platform指定架构或换-slim基础镜像HTTPS 请求报证书错误精简镜像里没装 CA 证书安装ca-certificates日志时间全是 UTC缺时区数据安装tzdata并设ENV TZdocker stop要等 10 秒CMD/ENTRYPOINT 是 shell 形式信号没转发改成 JSON 数组的 exec 形式容器启动秒退退出码 0主进程是后台运行或脚本执行完就结束确保主进程前台运行不要加或nohup退出码 137内存超限被强制终止调内存限制或排查内存泄漏非 root 用户启动报权限拒绝文件属主是 root进程无读权限COPY --chown或在RUN里改属主挂载配置后容器起不来宿主机目录挂载覆盖了镜像内路径导致文件“消失”检查挂载点避免覆盖应用目录构建时好时坏取决于机器基础镜像 tag 浮动如latest锁定具体版本或摘要表里第十一条我想多说两句因为它特别反直觉。现象是镜像单独跑完全正常一上编排环境就报配置文件找不到。原因是编排配置里把宿主机的某个目录挂到了/app宿主机那个目录是空的一挂载就把镜像里辛苦COPY进去的配置文件全遮住了。这个坑我踩过一次排查了两个多小时最后靠docker run时去掉挂载对比才定位到。之后我养成了习惯配置目录和二进制目录分开配置走/etc/app/挂载也只挂配置目录绝不挂应用根目录。5.3 几条压箱底的操作习惯第一本地一定开新版构建引擎。老引擎和新引擎在多阶段构建、缓存挂载上的能力差距很大用老引擎调试完推到流水线结果行为不一致排查起来非常痛苦。可以在本地固定一个环境变量开关或者干脆在 Dockerfile 首行加# syntaxdocker/dockerfile:1显式声明语法版本保证本地和 CI 用的是同一套解析规则。第二给每个阶段起名字。AS builder不只是为了COPY --frombuilder更是一个文档。半年后回来的你看到builder和runtime两个阶段名立刻就懂这段构建的组织逻辑。第三构建时带上--target做分段调试。docker build --target builder -t debug-env .能直接进到编译阶段里面工具链齐全可以手动跑命令试错比反复改 Dockerfile 快得多。这个技巧在排查“为什么编译在容器里失败、在本地成功”这类问题时特别管用。第四镜像里加HEALTHCHECK值得但要克制。一行HEALTHCHECK --interval30s --timeout3s --start-period10s --retries3 CMD [/app/agent, -health]能让编排系统知道服务到底活没活。--start-period是关键参数给启动慢的服务留出宽限避免还没起来就被判死。但要注意如果你的服务本身就是个短周期任务比如定时任务加健康检查反而是灾难它会不断被重启。第五不要迷信“层数越少越好”。早期有个说法是“能合并的都合并成一条 RUN”这在层数有硬限制的年代是必要的。现在这个限制基本消失为了减少层数把不相干的命令塞进一条RUN代价是可读性崩掉、缓存粒度变粗——改一个依赖要重装全部。合理的做法是按“变化频率”分组同一频率的合并不同频率的坚决拆开。第六把 Dockerfile 当成代码 review。我见过太多团队应用代码 review 三轮Dockerfile 直接 merge。结果就是里面塞着明文密码、latesttag、root 用户、chmod 777。这几样随便一个带出去都是隐患。评审清单其实很短有没有锁版本、有没有非 root、有没有不必要的ADD、有没有把敏感信息写进ARG或ENV、清理有没有做在同层。五分钟能过完收益极大。6. 一个容易被忽略的收尾动作镜像清理与复用构建镜像这件事有个隐性成本很多人不觉得但跑久了必然爆发构建缓存越积越多。本地磁盘动辄被吃掉几十 Gdocker system df一看Build Cache那一项吓人。我的习惯是每周清一次用docker builder prune --filter until168h只清一周以前的正在用的缓存不受影响不会打断当天的工作节奏。真正激进的做法是docker builder prune -a清掉全部缓存下次构建全冷启动。这个只在磁盘真的告急时用平时不要因为重建一次缓存的代价远大于省下的那点空间。还有一件小事值得坚持给镜像打 tag 的时候别只打latest。至少同时打一个日期版本agent:20240612和一个环境版本agent:test。出问题时能立刻回滚到上一个确定可用的版本而不是去翻本地那一堆哈希值猜哪个是好的。这个动作在流水线里很容易自动化成本几乎为零收益是在真正需要回滚那天不至于手忙脚乱。最后分享一个我自己踩过、印象最深的坑。有段时间一个服务的镜像体积一直是 400MB 下不来多阶段构建也用了、清理也做了就是瘦不下去。后来用docker history一层层看发现运行阶段有一层的体积异常追到指令发现是早期为了“以防万一”装了curl和vim理由是“容器出问题可以进去看看”。这个理由听起来很合理但实际运行半年这个容器一次都没进去过。把这两行删掉镜像直接降到 90MB。这件事让我形成了一个判断标准任何进镜像的东西都要问一句“线上运行真的需要它吗”如果答案是“可能以后需要”那就先别放。真需要的时候用临时排障容器挂同一个网络进去看能力更强还不用承担体积和攻击面。