导语改一行代码CI 跑 6 分钟镜像 1.2GB扩容时 Pod 卡在 ContainerCreating安全扫描一次报几百个 CVE其中一半来自编译器和 shell。这三件事通常指向同一个原因镜像里装了运行期根本不需要的东西。更挫败的是很多人其实已经「优化」过了——末尾加了rm -rf、基础镜像换成 alpine、甚至抄了个多阶段构建然后docker images一瞅体积纹丝不动。问题不在动作在于不理解镜像是怎么叠起来的。本文围绕 Docker 镜像瘦身按「原理 → 手段 → 度量」推进先讲透为什么跨层删除删不掉再给 Go / Node.js / Python 三套能直接 build 的 Dockerfile最后给一套可复现的度量方法。声明本文基于个人使用体验非商业推广。摘要瘦身的关键不是「删文件」而是「别让文件进层」。本文讲透分层与 whiteout 机制给出 Go / Node.js / Python 三个可运行的多阶段构建示例配套 .dockerignore、指令顺序、cache mount、非 root 等做法结尾用 9 个高频误区做成速查清单。文章目录导语一、先算清这笔账1.2GB 的镜像到底贵在哪二、瘦身的前提镜像是一叠只读层不是一整块文件三、多阶段构建把构建环境和运行环境彻底切开最小可用骨架六个语法要点四、三个能直接跑起来的完整示例Go / Node.js / Python示例一Go —— golang:1.26 → scratch示例二Node.js —— 三阶段示例三Python —— 两阶段三个栈的对照口径五、基础镜像选型alpine / slim / distroless / scratch 到底选哪个alpine 的四个确切坑版本要固定到什么程度六、第一道闸.dockerignore 与构建上下文七、让缓存命中指令顺序决定你是 5 秒还是 5 分钟cache busting 用版本固定不用 --no-cache--pull 与 --no-cache 是两件事进阶BuildKit cache mount八、挤出最后 20%合并 RUN、清理缓存、非 root一个隐蔽的泄漏点ENV 会在层里留痕非 root九、度量与验证别靠感觉用三件套把数字量出来十、收尾9 个最容易返工的误区总结一、先算清这笔账1.2GB 的镜像到底贵在哪体积不是审美问题是四笔能算成钱的成本。成本项影响环节瘦身后的变化传输带宽推拉镜像、K8s 扩容与回滚、跨区部署每个 Pod 每次调度少传几百 MB节点就绪更快CI 耗时上下文打包上传、层推送依赖层命中缓存改代码只重跑最后几层存储镜像仓库、节点磁盘仓库占用下降共享基础层磁盘上仍只存一份攻击面CVE 扫描、合规审计编译器、包管理器、shell、源码不再进镜像扫描项明显减少第一笔最容易被忽略镜像体积不是「拉取一次」的成本而是每次调度都要付一次——节点扩容、版本回滚、跨区重调度每发生一次那 1.2GB 就重新走一遍网络。别用错预期镜像体积与运行时性能基本无关。收益全在交付链路上构建、传输、存储、安全不会让接口 QPS 变高。本文的目标很具体把「构建工具 源码 依赖 运行时」一锅端的单阶段镜像压成「只有运行时 产物」。能压到什么程度取决于语言栈——静态编译型如 Go可到十 MB 量级脚本型Python / Node.js通常在百 MB 量级。标题里的 90% 是这一量级的上限情形不是通用承诺下文每个示例都给出自己的对照口径请在自己的环境里用docker images复现。判断标准只有一条瘦身 让最终镜像只包含运行时真正需要的文件。二、瘦身的前提镜像是一叠只读层不是一整块文件Dockerfile 里每条会改动文件系统的指令FROM/RUN/COPY/ADD都会生成一个不可变层。多层通过联合文件系统overlay2叠加向上呈现为统一的文件视图容器启动时最上面再加一个可写容器层。两个机制值得记住写时复制copy-on-write修改下层文件 先把文件复制到容器层再改下层原文件岿然不动。删除 whiteout删除下层文件 在容器层写一个 whiteout 标记把下层那条路径「遮住」。运行时看到的删除是真的但被遮住的文件依然物理存在于镜像层里体积一分没少。由此推出全文最关键的一条推论在后面的RUN里rm -rf掉前面层产生的文件只能减少「新增量」不能减少「已有量」。想真正变小只有两条路让文件压根不进任何层多阶段构建、.dockerignore、cache mount或在同一个RUN里产生并清除合并 RUN。这一点可以现场验证# 反例跨层删除100MB 依然躺在下面那层 FROM alpine:3.23 RUN dd if/dev/zero of/big.bin bs1M count100 RUN rm -f /big.bin # 正例同一层内产生并清除 # FROM alpine:3.23 # RUN dd if/dev/zero of/big.bin bs1M count100 rm -f /big.bin分别 build 后对比前者仍比后者大约 100MB。docker history会把这件事说得很直白创建文件那层记着 100MB删除那层只显示 0B——它只是加了个标记。dockerbuild-fDockerfile.bad-tdemo:cross-layer.dockerbuild-fDockerfile.good-tdemo:same-layer.dockerimages demo--formattable {{.Repository}}:{{.Tag}}\t{{.Size}}dockerhistorydemo:cross-layer层还有第二个身份它是缓存与复用的最小单位。docker pull只下载本地缺失的层多个镜像共享同一基础层时磁盘上也只存一份。所以「合并层」与「保留可复用层」天然是一对矛盾什么时候该合、什么时候不该合见第七、八节。三、多阶段构建把构建环境和运行环境彻底切开机制不复杂一个 Dockerfile 里可以写多个FROM每个FROM开启一个新阶段各阶段可使用完全不同的基础镜像只有最后一个阶段或--target指定的阶段成为输出镜像中间阶段的产物通过COPY --from按需搬运没被搬的一律丢弃。官方构建文档对它的定位是在「构建镜像」与「最终输出」之间建立更清晰的隔离让输出只包含运行应用所需的文件only contains the files that are needed to run the application同时多阶段还能让构建步骤并行执行。最小可用骨架# 阶段一构建。镜像可以很重用完就丢 FROM golang:1.26 AS build WORKDIR /src COPY . . RUN CGO_ENABLED0 GOOSlinux go build -o /out/server ./cmd/server # 阶段二运行。只搬运产物 FROM scratch AS runtime COPY --frombuild /out/server /server ENTRYPOINT [/server]# 构建前目录里需要有 go.mod 与 cmd/server/main.go# runtime 是最后一个阶段即默认阶段不带 --target 就会产出它# 若把 debug 阶段写在最后就必须用 --target runtime 显式指定dockerbuild--targetruntime-tmyapp:1.0.0.dockerimages myapp--formattable {{.Repository}}:{{.Tag}}\t{{.Size}}六个语法要点FROM image AS name给阶段命名COPY --fromname按名称拷贝。官方倾向命名而非索引理由是「即便 Dockerfile 里的指令日后被重排COPY也不会失效」。COPY --from0按从 0 开始的整数索引引用未命名阶段。COPY --fromnginx:latest /etc/nginx/nginx.conf /nginx.conf可直接从外部镜像拷文件本地没有时会自动拉取。FROM builder AS build2可基于上一阶段继续派生适合多个变体共用一套已装好的依赖。docker build --target stage停在指定阶段。--target的典型组合是「带 shell 的 debug 阶段 精简的 production 阶段」日常产出后者排障时单独构建前者示例见下一节。注意构建器差异BuildKit 只构建--target所依赖的阶段Legacy builder 会把不相关的阶段也跑一遍。若docker build输出的是老式Step 1/6格式说明跑在 Legacy 上--target省下的时间会打折扣。四、三个能直接跑起来的完整示例Go / Node.js / Python示例一Go —— golang:1.26 → scratch先看不做优化的对照基线用于后面做docker images对比# Dockerfile.baseline单阶段整套 Go 工具链都留在最终镜像里 FROM golang:1.26 WORKDIR /app COPY . . RUN go build -o /app/server ./cmd/server CMD [/app/server]生产形态多了缓存分层、证书和调试阶段FROM golang:1.26 AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 GOOSlinux go build -ldflags-s -w -o /out/server ./cmd/server FROM alpine:3.23 AS debug RUN apk add --no-cache ca-certificates tzdata COPY --frombuild /out/server /server ENTRYPOINT [/server] # runtime 放在最后它是默认阶段不指定 --target 就产出它 FROM scratch AS runtime COPY --frombuild /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --frombuild /out/server /server ENTRYPOINT [/server]# 默认产出 runtimescratch 运行镜像dockerbuild-tmyapp:1.0.0.# 排障时才构建带 shell 的 debug 镜像dockerbuild--targetdebug-tmyapp:debug.关键点CGO_ENABLED0不能省。它关掉 cgo 产出纯静态二进制不加这一句Go 默认动态链接 glibc而 scratch 里没有动态加载器容器根本起不来。这是换小镜像后翻车的头号原因。-ldflags-s -w去掉符号表与 DWARF 调试信息后续还需要调试符号就别加。COPY go.mod go.sum ./单独成层、先跑go mod download改业务代码时这层稳稳命中缓存第七节详述。证书从构建阶段的 Debian 系镜像里取是常见做法scratch 里没有 CA 证书程序发 HTTPS 请求会报 x509 证书错误。默认产物是最后一个阶段runtimedocker build -t myapp:1.0.0 .直接产出 scratch 运行镜像排障才加--target debug顺序写反debug 在最后会把调试镜像当生产镜像推上线。scratch 的代价要说清楚没有 shelldocker exec进不去也没有时区数据。需要时区就把构建阶段的时区数据目录整个拷过去COPY --frombuild /usr/share/zoneinfo /usr/share/zoneinfo需要进容器排查就用上面的 debug 阶段或改用 distroless 类镜像其 tag 随上游发布变化用前请以 distroless 仓库 README 为准确认当前 tag。示例二Node.js —— 三阶段FROM node:22-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omitdev FROM node:22-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build FROM node:22-alpine AS runtime ENV NODE_ENVproduction WORKDIR /app COPY --fromdeps /app/node_modules ./node_modules COPY --frombuild /app/dist ./dist COPY package.json ./ USER node CMD [node, dist/main.js]最后那行入口按项目实际产物路径调整即可dist/main.js是占位示例。用npm ci而非npm install严格按 lockfile 安装结果可复现对 CI 也更友好。--omitdev是 npm 7 的写法老的--onlyproduction已废弃。deps 阶段只装生产依赖。USER node官方 node 镜像自带 uid 1000 的 node 用户不需要自建。最容易翻车的是最后两次拷贝必须精确到dist和node_modules。写成COPY --frombuild /app /app会把源码、构建缓存、devDependencies 一起搬回来多阶段等于白做。示例三Python —— 两阶段FROM python:3.12-slim AS builder WORKDIR /src COPY requirements.txt . RUN pip install --no-cache-dir --prefix/install -r requirements.txt FROM python:3.12-slim AS runtime COPY --frombuilder /install /usr/local # 先建用户再用 --chown 直接以目标属主拷贝 RUN groupadd -r app useradd --no-log-init -r -g app app WORKDIR /app COPY --chownapp:app . . USER app CMD [python, app.py]pip install --prefix/install把依赖装进独立前缀目录runtime 阶段再COPY --frombuilder /install /usr/local整体搬过去正好落进 site-packages 的正确位置。两个阶段的基础镜像次版本号必须一致这里都是 3.12。3.11 配 3.12 会让lib/python3.11/site-packages与3.12的路径对不上装进去的依赖找不到。--no-log-init是官方明确要求的Go 的 archive/tar 对稀疏文件处理存在未修复问题某些场景下建用户会把/var/log/faillog撑成巨大文件直接污染容器层。别写成COPY . . 单独RUN chown -R app:app /appoverlay2 下改下层文件的属主会触发 copy-up多一层且镜像更大。正解是先建用户、再COPY --chownapp:app . .以目标属主拷入要 chown 的目录应与建目录动作放在同一条RUN同层内产生即修改不触发 copy-up。依赖里若有需要编译的 C 扩展slim 里要额外装编译工具链此时建议再加一个build-deps阶段编译完只把已装依赖搬进 runtime。三个栈的对照口径下表是量级参照而非承诺值——实际数字随依赖树浮动请在本地docker images自行复现语言栈单阶段镜像多阶段运行镜像典型落差对照口径Go数百 MB ~ 接近 1GB含完整工具链十 MB 量级单个静态二进制接近两个数量级对比 baseline 与 runtime 两个 tagNode.jsGB 量级含 devDependencies百 MB 量级生产依赖 产物约一个数量级对比含/不含 devDependencies 的node_modulesPythonGB 量级含编译产物与缓存百 MB 量级slim 运行时依赖约一个数量级对比 builder 与 runtime 两个 tag五、基础镜像选型alpine / slim / distroless / scratch 到底选哪个第一性原则来自官方构建实践文档选择一个满足需求的最小基础镜像小基础镜像不只带来可移植性和更快的下载还能减小体积、并把通过依赖引入的漏洞数量降到最低。同一份文档还建议用两类基础镜像——一套用于构建和单元测试一套通常更精简用于生产。来源上优先选 Docker Official Images / Verified Publisher / Docker-Sponsored Open Source认发布者徽标。档位体积量级libcshell适用场景主要坑完整版ubuntu / debian百 MB 量级起glibcbash sh兼容性优先、需要大量系统包无关包多扫描项最多slim几十~百 MBglibcbash sh需要 glibc 与预编译 wheel仍带包管理器与部分工具alpine约 5MBmusl仅 sh静态二进制、脚本类、能接受 muslmusl 兼容、DNS、无 bash、缺 tzdata/CAdistroless / scratch最小视 tag / 无无完全静态链接的二进制无法 exec 进容器排查alpine 的四个确切坑换 alpine 之后线上出问题的基本都落在四条里musl libc 与 glibc 不兼容。glibc 系的 manylinux wheel 在 musl 上不可用自 PEP 600 起 pip 已支持 musllinux wheel但覆盖率低于 manylinux缺 wheel 的包会退化为源码编译往往还要额外装build-base。musl 的 DNS resolver 行为与 glibc 不同。在 K8s 的 search domain / ndots 场景下实践中常见解析变慢或失败。没有/bin/bash只有/bin/sh。脚本里用到 bash 特性的RUN会直接报错。缺 tzdata 与 CA 证书按需安装FROM alpine:3.23 RUN apk add --no-cache tzdata ca-certificates # 确实需要 glibc 行为时的兼容层按需先验证再上生产 # RUN apk add --no-cache gcompat更稳的判断是重 C 扩展或重预编译二进制依赖的技术栈科学计算类 Python 包、Puppeteer、Prisma、部分 Java agent直接选 slim别硬上 alpine。版本要固定到什么程度tag 是可变的——指定alpine:3.23时它解析到的是该次版本下的最新补丁版。所以至少固定到次版本对供应链有硬要求的 pin 到 digest# 这串 0 是占位示例使用前必须替换成真实 digest否则 build 会失败 FROM alpine:3.23sha256:0000000000000000000000000000000000000000000000000000000000000000digest 这样取dockerpull alpine:3.23dockerimages--digestsalpine# DIGEST 列的 sha256:... 整串粘到 FROM 后面次版本也有生命周期Alpine 每分支约维护两年3.21 的 EOL 是 2026-11-01。本文示例使用 3.23支持至 2027-11-01写本文时的当前最新次版本为 3.24请按你的环境替换固定 digest 换来的是可复现不等于可以一直不升级。六、第一道闸.dockerignore 与构建上下文docker build最后那个.是构建上下文整个目录会先被打包发送给守护进程再交给 Dockerfile 里的COPY挑选。也就是说.git、node_modules、本地日志、数据集这些从来没被 COPY 的文件也已经拖慢了每一次构建。官方的说法是用.dockerignore排除与构建无关的文件无需重构仓库结构语法与.gitignore类似。下面的模板假设产物dist / build / target在容器内生成本文 Node 示例即如此若你在宿主机生成后COPY进镜像请删掉对应行否则COPY会失败。请按自己的构建方式调整。# 版本控制与 CI .git .github .gitignore # 依赖与构建产物 node_modules __pycache__ *.pyc .venv venv target # dist产物在容器内构建时可排除若你在宿主机先 build 再 COPY 进镜像请删掉这一行 dist build # 本地环境与 IDE .idea .vscode *.log .env .env.* *.pem *.key # 文档 *.md两个容易忽略的点.dockerignore只在构建上下文的根目录生效。多 Dockerfile 场景下要确认它匹配的是docker build时传的那个 context 路径。别把运行时真正需要的文件也 ignore 掉。典型翻车是本地npm run build生成dist又照抄模板排除了distCOPY dist直接失败。判断标准产物在容器内生成 → 可排除在容器外生成后拷进来 → 不能排除。七、让缓存命中指令顺序决定你是 5 秒还是 5 分钟构建时按顺序逐条执行指令每条指令都先检查能否复用构建缓存。两类指令的失效规则完全不同RUN只看指令字符串本身外加父层是否命中不看命令执行结果。COPY/ADD按文件内容的校验和判断任一文件变动即从该层起全部失效。由此推出最经典的排序原则先 COPY 依赖清单 → 再 RUN 安装依赖 → 最后 COPY 源码。# 反例改一行注释也会让依赖层全量重装 # FROM node:22-alpine # WORKDIR /app # COPY . . # RUN npm ci # 正例改业务代码只重跑最后一层 FROM node:22-alpine WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . .同类问题还有一个官方列出的反面教材把apt-get update和apt-get install拆成两条。装包列表变了以后第一条指令字符串没变、命中旧缓存不重跑于是基于过期索引装包。# 反例拆成两条索引会过期 # RUN apt-get update # RUN apt-get install -y curl # 正例合并 不装 Recommends 级包 清索引 RUN apt-get update apt-get install -y --no-install-recommends \ ca-certificates \ curl \ rm -rf /var/lib/apt/lists/*清掉/var/lib/apt/lists能减小体积因为这个 apt 缓存本就不属于任何一层官方的 Debian / Ubuntu 镜像已自动执行apt-get clean不必再显式调用。装包一律带--no-install-recommends避免把 Recommends 级的无关包一起装进来。cache busting 用版本固定不用 --no-cache想强制拿某个依赖的新版本用版本固定package-foo1.3.*——它会强制构建去取指定版本无论缓存里有什么。–pull 与 --no-cache 是两件事--pull强制拉取更新的基础镜像。--no-cache禁用构建缓存、重建所有层作用是拿到包管理器里依赖的最新版本不会去拉新的基础镜像。两件事都要做时才用docker build --pull --no-cache。所以--no-cache只该用于发版构建或缓存疑似被污染时排查绝不是日常优化手段——日常用它等于每次全量重装。进阶BuildKit cache mount需要# syntaxdocker/dockerfile:1且启用 BuildKit。它把包管理器的缓存目录挂成缓存卷既不进镜像层又能跨构建复用比依赖层缓存更稳——改了清单文件也还能命中下载缓存。# syntaxdocker/dockerfile:1 FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN --mounttypecache,target/root/.cache/pip \ pip install -r requirements.txt # npm 写法 # RUN --mounttypecache,target/root/.npm npm ci # apt 写法两个目录都要挂并加锁 # RUN --mounttypecache,target/var/cache/apt,sharinglocked \ # --mounttypecache,target/var/lib/apt/lists,sharinglocked \ # apt-get update apt-get install -y --no-install-recommends curl一个必须点破的细节cache mount 与pip install --no-cache-dir或PIP_NO_CACHE_DIR1互斥。后者明确禁止写缓存目录配了 cache mount 也用不上两者选其一。CI 场景补充缓存要在 runner 之间延续得配合 buildx 的cache-from/cache-to纯本地docker build在全新 runner 上没有缓存可复用。八、挤出最后 20%合并 RUN、清理缓存、非 root合并RUN的原则很简单凡是「产生临时文件又在同一动作里清掉」的下载压缩包解压、装包后清索引、编译后清中间产物必须合并到一条RUN用串联、\折行。跨层删除无效第二节已经证明过了。包管理器清理方式apt / apt-get末尾rm -rf /var/lib/apt/lists/*apt-get clean官方镜像已自动执行apk直接用apk add --no-cache无需再rmpip--no-cache-dir或用 cache mount二选一npmnpm ci后npm cache clean --force或用 cache mount# 反例4 层且临时文件永远留在第 3 层 # RUN apt-get update # RUN apt-get install -y --no-install-recommends curl # RUN curl -fsSL https://example.com/app.tar.gz -o /tmp/app.tar.gz # RUN tar -xzf /tmp/app.tar.gz -C /opt rm /tmp/app.tar.gz # 正例1 层产生即清除 RUN apt-get update apt-get install -y --no-install-recommends \ ca-certificates \ curl \ curl -fsSL https://example.com/app.tar.gz -o /tmp/app.tar.gz \ tar -xzf /tmp/app.tar.gz -C /opt \ rm -f /tmp/app.tar.gz \ rm -rf /var/lib/apt/lists/*一个隐蔽的泄漏点ENV 会在层里留痕ENV也会生成中间层并且即便在后面的层里 unset它依然存在于那一层值可以被 dump 出来。官方给出的验证是先ENV ADMIN_USERmark后面RUN unset ADMIN_USER容器里echo $ADMIN_USER仍会打印mark。# 反例unset 不掉密钥留在层里 # ENV API_KEYxxx # RUN unset API_KEY # 正例一同一条 RUN 内 export → 使用 → unset RUN export API_KEYxxx \ some-build-command \ unset API_KEY # 正例二更稳BuildKit secret mount密钥不进任何层 # RUN --mounttypesecret,idapi_key \ # API_KEY$(cat /run/secrets/api_key) some-build-commandsecret mount 配套的构建命令src指向宿主机密钥文件不打进上下文dockerbuild--secretidapi_key,src./api_key.txt-tmyapp.非 root官方的原则是如果服务能在无特权下运行就用USER切到非 root 用户。不要在COPY . .之后再单独RUN chown -R app:app /appoverlay2 下改下层文件的属主会触发 copy-up镜像不减反增第四节同理。正确顺序是先建用户、再COPY --chown只对 root 建的目录做 chown 时把它和建目录放进同一条RUN。# Debian / slim 系先建用户再以目标属主拷贝 RUN groupadd -r app useradd --no-log-init -r -g app app \ mkdir -p /app/data \ chown app:app /app/data WORKDIR /app COPY --chownapp:app . . USER app # alpine 系addgroup / adduser 是 alpine 的写法 # RUN addgroup -S app adduser -S -G app app \ # mkdir -p /app/data \ # chown app:app /app/data # WORKDIR /app # COPY --chownapp:app . . # USER app # node 官方镜像自带 node 用户直接切即可 # USER node示例里的chown只作用于同一条RUN内刚建的/app/data源码靠COPY --chown一步到位。三点补充镜像里的用户/组 UID / GID 是非确定性的对权限有硬要求时应显式指定具体数值不要在镜像里装或用sudoTTY 与信号转发行为不可预测需要先 root 初始化再降权的场景用gosu配合exec让应用成为 PID 1尽量减少USER来回切换。非 root 用户需要写目录时也可在docker run/ K8s 里用 emptyDir 挂可写目录避免多产生一层。九、度量与验证别靠感觉用三件套把数字量出来docker images看总体积注意SIZE是各层展开后的大小之和比 Docker Hub 上显示的压缩体积要大后者才是网络传输口径同时由于多个镜像共享的基础层在磁盘上只存一份它也不等于实际磁盘占用——看磁盘用docker system df。docker history定位是哪一层大不装任何工具就能做的第一步。dive逐层看文件树第三方开源工具需单独安装不随 Docker 附带它回答的是docker history回答不了的问题——这一层里到底是哪个目录占了 300MB。# 1. 总体积dockerimages myapp--formattable {{.Repository}}\t{{.Tag}}\t{{.Size}}dockersystemdf-v# 2. 逐层定位--humanfalse 输出字节数便于排序dockerhistorymyapp:1.0.0dockerhistory--humanfalse--format{{.Size}} {{.CreatedBy}}myapp:1.0.0# 3. 逐文件定位第三方工具需单独安装dive myapp:1.0.0# 观察每一步的缓存命中情况默认进度输出是折叠的dockerbuild--progressplain-tmyapp:1.0.0.一套可复用的优化闭环量基线docker images→定位大层docker history→定位大文件dive→ 改 Dockerfile → 回到第一步复量。每次只改一处否则说不清是哪一步奏效。把度量接进 CI 也很直接用docker inspect --format {{.Size}}取出字节数给镜像体积设一个上限超了就让构建失败防止体积在若干次迭代后悄悄回弹。十、收尾9 个最容易返工的误区#错误做法正确做法章节1在后面的RUN里rm前面层的文件跨层删除只是加 whiteout改为同层产生并清除或让它压根不进层二2把--no-cache当常规优化日常构建用它等于每次全量重装仅用于发版或缓存污染排查七3COPY . .写在安装依赖之前先 COPY 依赖清单再 RUN 安装最后 COPY 源码七4COPY --frombuilder /app /app整目录搬运精确到dist/target/ 单个二进制四5Go 不加CGO_ENABLED0就进 scratch默认动态链接 glibcscratch 里没有加载器四6无脑换 alpinemusl 兼容、DNS resolver 差异、无 bash、缺 tzdata/CA重 C 扩展依赖改选 slim五7以为ENV里的密钥unset掉就安全ENV 在层里留痕同 RUN 内 export使用unset或用 secret mount八8为了少几层把所有RUN合成一条牺牲依赖层可缓存性日常反而更慢只合并「产生即清除」的动作八9只盯体积不盯来源与版本至少固定到次版本要求高的 pin digest 并配镜像扫描五误区 4 / 5 / 7 的写法对照# 误区 4整目录搬运 COPY --frombuilder /app /app # 错源码、node_modules、中间产物一起回来 COPY --frombuilder /app/dist /app/dist # 对精确到产物 # 误区 5忘记关 cgo RUN go build -o /out/server ./cmd/server # 错动态链接 glibc RUN CGO_ENABLED0 GOOSlinux go build -o /out/server ./cmd/server # 对纯静态 # 误区 7ENV 里的密钥 ENV API_KEYxxx RUN unset API_KEY # 错值仍在层里 # RUN --mounttypesecret,idapi_key ... # 对用 BuildKit secret mount总结全文收成一句话别想着删要想着别让它进来。一条可执行的落地顺序每一步收益都能单独验证加.dockerignore改成多阶段构建换更小的基础镜像先确认 libc 兼容与排障需要调指令顺序让依赖层命中缓存合并RUN并清理包管理器缓存切非 root用docker images/docker history/dive复量。按这个顺序走完docker images里的数字变化会自己说话。想继续深入容器镜像相关话题可以看站内这个话题聚合Docker 技术话题。也欢迎在评论区留下你的语言栈和优化前后的数字互相验证一下本文给出的量级口径。参考资料Dockerfile 构建实践建议https://docs.docker.com/build/building/best-practices/多阶段构建https://docs.docker.com/build/building/multi-stage/© 2026 | 转载请注明出处