导语
改一行代码,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 的镜像到底贵在哪](#一、先算清这笔账:1.2GB 的镜像到底贵在哪)
- 二、瘦身的前提:镜像是一叠只读层,不是一整块文件
- 三、多阶段构建:把构建环境和运行环境彻底切开
- [四、三个能直接跑起来的完整示例(Go / Node.js / Python)](#四、三个能直接跑起来的完整示例(Go / Node.js / Python))
-
- [示例一:Go ------ golang:1.26 → scratch](#示例一:Go —— golang:1.26 → scratch)
- [示例二:Node.js ------ 三阶段](#示例二:Node.js —— 三阶段)
- [示例三:Python ------ 两阶段](#示例三:Python —— 两阶段)
- 三个栈的对照口径
- [五、基础镜像选型:alpine / slim / distroless / scratch 到底选哪个](#五、基础镜像选型:alpine / slim / distroless / scratch 到底选哪个)
-
- [alpine 的四个确切坑](#alpine 的四个确切坑)
- 版本要固定到什么程度
- [六、第一道闸:.dockerignore 与构建上下文](#六、第一道闸:.dockerignore 与构建上下文)
- [七、让缓存命中:指令顺序决定你是 5 秒还是 5 分钟](#七、让缓存命中:指令顺序决定你是 5 秒还是 5 分钟)
-
- [cache busting 用版本固定,不用 --no-cache](#cache busting 用版本固定,不用 --no-cache)
- [--pull 与 --no-cache 是两件事](#--pull 与 --no-cache 是两件事)
- [进阶:BuildKit cache mount](#进阶:BuildKit cache mount)
- [八、挤出最后 20%:合并 RUN、清理缓存、非 root](#八、挤出最后 20%:合并 RUN、清理缓存、非 root)
-
- [一个隐蔽的泄漏点:ENV 会在层里留痕](#一个隐蔽的泄漏点:ENV 会在层里留痕)
- [非 root](#非 root)
- 九、度量与验证:别靠感觉,用三件套把数字量出来
- [十、收尾:9 个最容易返工的误区](#十、收尾: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)。
这一点可以现场验证:
dockerfile
# 反例:跨层删除,100MB 依然躺在下面那层
FROM alpine:3.23
RUN dd if=/dev/zero of=/big.bin bs=1M count=100
RUN rm -f /big.bin
# 正例:同一层内产生并清除
# FROM alpine:3.23
# RUN dd if=/dev/zero of=/big.bin bs=1M count=100 && rm -f /big.bin
分别 build 后对比,前者仍比后者大约 100MB。docker history 会把这件事说得很直白:创建文件那层记着 100MB,删除那层只显示 0B------它只是加了个标记。
bash
docker build -f Dockerfile.bad -t demo:cross-layer .
docker build -f Dockerfile.good -t demo:same-layer .
docker images demo --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
docker history demo:cross-layer
层还有第二个身份:它是缓存与复用的最小单位。docker pull 只下载本地缺失的层,多个镜像共享同一基础层时磁盘上也只存一份。所以「合并层」与「保留可复用层」天然是一对矛盾,什么时候该合、什么时候不该合,见第七、八节。

三、多阶段构建:把构建环境和运行环境彻底切开
机制不复杂:一个 Dockerfile 里可以写多个 FROM,每个 FROM 开启一个新阶段,各阶段可使用完全不同的基础镜像;只有最后一个阶段(或 --target 指定的阶段)成为输出镜像,中间阶段的产物通过 COPY --from 按需搬运,没被搬的一律丢弃。
官方构建文档对它的定位是:在「构建镜像」与「最终输出」之间建立更清晰的隔离,让输出只包含运行应用所需的文件(only contains the files that are needed to run the application);同时多阶段还能让构建步骤并行执行。
最小可用骨架
dockerfile
# 阶段一:构建。镜像可以很重,用完就丢
FROM golang:1.26 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server ./cmd/server
# 阶段二:运行。只搬运产物
FROM scratch AS runtime
COPY --from=build /out/server /server
ENTRYPOINT ["/server"]
bash
# 构建前目录里需要有 go.mod 与 cmd/server/main.go
# runtime 是最后一个阶段,即默认阶段,不带 --target 就会产出它
# 若把 debug 阶段写在最后,就必须用 --target runtime 显式指定
docker build --target runtime -t myapp:1.0.0 .
docker images myapp --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
六个语法要点
FROM <image> AS <name>给阶段命名,COPY --from=<name>按名称拷贝。官方倾向命名而非索引,理由是「即便 Dockerfile 里的指令日后被重排,COPY也不会失效」。COPY --from=0按从 0 开始的整数索引引用未命名阶段。COPY --from=nginx: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
# Dockerfile.baseline:单阶段,整套 Go 工具链都留在最终镜像里
FROM golang:1.26
WORKDIR /app
COPY . .
RUN go build -o /app/server ./cmd/server
CMD ["/app/server"]
生产形态多了缓存分层、证书和调试阶段:
dockerfile
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux 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 --from=build /out/server /server
ENTRYPOINT ["/server"]
# runtime 放在最后:它是默认阶段,不指定 --target 就产出它
FROM scratch AS runtime
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/server /server
ENTRYPOINT ["/server"]
bash
# 默认产出 runtime(scratch 运行镜像)
docker build -t myapp:1.0.0 .
# 排障时才构建带 shell 的 debug 镜像
docker build --target debug -t myapp:debug .
关键点:
CGO_ENABLED=0不能省。它关掉 cgo 产出纯静态二进制;不加这一句,Go 默认动态链接 glibc,而 scratch 里没有动态加载器,容器根本起不来。这是换小镜像后翻车的头号原因。-ldflags="-s -w"去掉符号表与 DWARF 调试信息;后续还需要调试符号就别加。COPY go.mod go.sum ./单独成层、先跑go mod download:改业务代码时这层稳稳命中缓存(第七节详述)。- 证书从构建阶段的 Debian 系镜像里取是常见做法;scratch 里没有 CA 证书,程序发 HTTPS 请求会报 x509 证书错误。
- 默认产物是最后一个阶段
runtime:docker build -t myapp:1.0.0 .直接产出 scratch 运行镜像,排障才加--target debug;顺序写反(debug 在最后)会把调试镜像当生产镜像推上线。
scratch 的代价要说清楚:没有 shell,docker exec 进不去,也没有时区数据 。需要时区就把构建阶段的时区数据目录整个拷过去(COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo);需要进容器排查就用上面的 debug 阶段,或改用 distroless 类镜像(其 tag 随上游发布变化,用前请以 distroless 仓库 README 为准确认当前 tag)。
示例二:Node.js ------ 三阶段
dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
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_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
CMD ["node", "dist/main.js"]
最后那行入口按项目实际产物路径调整即可(dist/main.js 是占位示例)。
- 用
npm ci而非npm install:严格按 lockfile 安装,结果可复现,对 CI 也更友好。 --omit=dev是 npm 7+ 的写法,老的--only=production已废弃。deps 阶段只装生产依赖。USER node:官方 node 镜像自带 uid 1000 的 node 用户,不需要自建。- 最容易翻车的是最后两次拷贝 :必须精确到
dist和node_modules。写成COPY --from=build /app /app会把源码、构建缓存、devDependencies 一起搬回来,多阶段等于白做。
示例三:Python ------ 两阶段
dockerfile
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 --from=builder /install /usr/local
# 先建用户,再用 --chown 直接以目标属主拷贝
RUN groupadd -r app && useradd --no-log-init -r -g app app
WORKDIR /app
COPY --chown=app:app . .
USER app
CMD ["python", "app.py"]
pip install --prefix=/install把依赖装进独立前缀目录,runtime 阶段再COPY --from=builder /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 /app:overlay2 下改下层文件的属主会触发 copy-up,多一层且镜像更大。正解是先建用户、再COPY --chown=app:app . .以目标属主拷入;要 chown 的目录应与建目录动作放在同一条RUN(同层内产生即修改,不触发 copy-up)。 - 依赖里若有需要编译的 C 扩展,slim 里要额外装编译工具链;此时建议再加一个
build-deps阶段,编译完只把已装依赖搬进 runtime。
三个栈的对照口径
下表是量级参照而非承诺值------实际数字随依赖树浮动,请在本地 docker images 自行复现:
| 语言栈 | 单阶段镜像 | 多阶段运行镜像 | 典型落差 | 对照口径 |
|---|---|---|---|---|
| Go | 数百 MB ~ 接近 1GB(含完整工具链) | 十 MB 量级(单个静态二进制) | 接近两个数量级 | 对比 baseline 与 runtime 两个 tag |
| Node.js | GB 量级(含 devDependencies) | 百 MB 量级(生产依赖 + 产物) | 约一个数量级 | 对比含/不含 devDependencies 的 node_modules |
| Python | GB 量级(含编译产物与缓存) | 百 MB 量级(slim + 运行时依赖) | 约一个数量级 | 对比 builder 与 runtime 两个 tag |

五、基础镜像选型:alpine / slim / distroless / scratch 到底选哪个
第一性原则来自官方构建实践文档:选择一个满足需求的最小基础镜像,小基础镜像不只带来可移植性和更快的下载,还能减小体积、并把通过依赖引入的漏洞数量降到最低。同一份文档还建议用两类基础镜像------一套用于构建和单元测试,一套(通常更精简)用于生产。来源上优先选 Docker Official Images / Verified Publisher / Docker-Sponsored Open Source,认发布者徽标。
| 档位 | 体积量级 | libc | shell | 适用场景 | 主要坑 |
|---|---|---|---|---|---|
| 完整版(ubuntu / debian) | 百 MB 量级起 | glibc | bash + sh | 兼容性优先、需要大量系统包 | 无关包多,扫描项最多 |
| slim | 几十~百 MB | glibc | bash + sh | 需要 glibc 与预编译 wheel | 仍带包管理器与部分工具 |
| alpine | 约 5MB | musl | 仅 sh | 静态二进制、脚本类、能接受 musl | musl 兼容、DNS、无 bash、缺 tzdata/CA |
| distroless / 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 证书,按需安装:
dockerfile
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:
dockerfile
# 这串 0 是占位示例,使用前必须替换成真实 digest,否则 build 会失败
FROM alpine:3.23@sha256:0000000000000000000000000000000000000000000000000000000000000000
digest 这样取:
bash
docker pull alpine:3.23
docker images --digests alpine # 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 会失败。请按自己的构建方式调整。
text
# 版本控制与 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,又照抄模板排除了dist,COPY dist直接失败。判断标准:产物在容器内生成 → 可排除;在容器外生成后拷进来 → 不能排除。

七、让缓存命中:指令顺序决定你是 5 秒还是 5 分钟
构建时按顺序逐条执行指令,每条指令都先检查能否复用构建缓存。两类指令的失效规则完全不同:
RUN:只看指令字符串本身(外加父层是否命中),不看命令执行结果。COPY/ADD:按文件内容的校验和判断,任一文件变动即从该层起全部失效。
由此推出最经典的排序原则:先 COPY 依赖清单 → 再 RUN 安装依赖 → 最后 COPY 源码。
dockerfile
# 反例:改一行注释也会让依赖层全量重装
# 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 拆成两条。装包列表变了以后,第一条指令字符串没变、命中旧缓存不重跑,于是基于过期索引装包。
dockerfile
# 反例:拆成两条,索引会过期
# 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-foo=1.3.*)------它会强制构建去取指定版本,无论缓存里有什么。
--pull 与 --no-cache 是两件事
--pull:强制拉取更新的基础镜像。--no-cache:禁用构建缓存、重建所有层,作用是拿到包管理器里依赖的最新版本,不会去拉新的基础镜像。- 两件事都要做时才用
docker build --pull --no-cache。
所以 --no-cache 只该用于发版构建或缓存疑似被污染时排查,绝不是日常优化手段------日常用它等于每次全量重装。
进阶:BuildKit cache mount
需要 # syntax=docker/dockerfile:1 且启用 BuildKit。它把包管理器的缓存目录挂成缓存卷,既不进镜像层,又能跨构建复用,比依赖层缓存更稳------改了清单文件也还能命中下载缓存。
dockerfile
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# npm 写法
# RUN --mount=type=cache,target=/root/.npm npm ci
# apt 写法:两个目录都要挂,并加锁
# RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
# --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
# apt-get update && apt-get install -y --no-install-recommends curl
一个必须点破的细节:cache mount 与 pip install --no-cache-dir(或 PIP_NO_CACHE_DIR=1)互斥。后者明确禁止写缓存目录,配了 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,无需再 rm |
| pip | --no-cache-dir,或用 cache mount(二选一) |
| npm | npm ci 后 npm cache clean --force,或用 cache mount |
dockerfile
# 反例: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_USER="mark",后面 RUN unset ADMIN_USER,容器里 echo $ADMIN_USER 仍会打印 mark。
dockerfile
# 反例:unset 不掉,密钥留在层里
# ENV API_KEY=xxx
# RUN unset API_KEY
# 正例一:同一条 RUN 内 export → 使用 → unset
RUN export API_KEY=xxx \
&& some-build-command \
&& unset API_KEY
# 正例二(更稳):BuildKit secret mount,密钥不进任何层
# RUN --mount=type=secret,id=api_key \
# API_KEY=$(cat /run/secrets/api_key) some-build-command
secret mount 配套的构建命令(src 指向宿主机密钥文件,不打进上下文):
bash
docker build --secret id=api_key,src=./api_key.txt -t myapp .
非 root
官方的原则是:如果服务能在无特权下运行,就用 USER 切到非 root 用户。
不要在 COPY . . 之后再单独 RUN chown -R app:app /app :overlay2 下改下层文件的属主会触发 copy-up,镜像不减反增(第四节同理)。正确顺序是先建用户、再 COPY --chown ;只对 root 建的目录做 chown 时,把它和建目录放进同一条 RUN。
dockerfile
# 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 --chown=app: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 --chown=app:app . .
# USER app
# node 官方镜像自带 node 用户,直接切即可
# USER node
示例里的 chown 只作用于同一条 RUN 内刚建的 /app/data,源码靠 COPY --chown 一步到位。
三点补充:镜像里的用户/组 UID / GID 是非确定性 的,对权限有硬要求时应显式指定具体数值;不要在镜像里装或用 sudo(TTY 与信号转发行为不可预测),需要先 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。
bash
# 1. 总体积
docker images myapp --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
docker system df -v
# 2. 逐层定位(--human=false 输出字节数,便于排序)
docker history myapp:1.0.0
docker history --human=false --format "{{.Size}} {{.CreatedBy}}" myapp:1.0.0
# 3. 逐文件定位(第三方工具,需单独安装)
dive myapp:1.0.0
# 观察每一步的缓存命中情况(默认进度输出是折叠的)
docker build --progress=plain -t myapp:1.0.0 .
一套可复用的优化闭环:量基线 (docker images)→ 定位大层 (docker history)→ 定位大文件 (dive)→ 改 Dockerfile → 回到第一步复量。每次只改一处,否则说不清是哪一步奏效。
把度量接进 CI 也很直接:用 docker inspect --format '{``{.Size}}' 取出字节数,给镜像体积设一个上限,超了就让构建失败,防止体积在若干次迭代后悄悄回弹。
十、收尾:9 个最容易返工的误区
| # | 错误做法 | 正确做法 | 章节 |
|---|---|---|---|
| 1 | 在后面的 RUN 里 rm 前面层的文件 |
跨层删除只是加 whiteout;改为同层产生并清除,或让它压根不进层 | 二 |
| 2 | 把 --no-cache 当常规优化 |
日常构建用它等于每次全量重装;仅用于发版或缓存污染排查 | 七 |
| 3 | COPY . . 写在安装依赖之前 |
先 COPY 依赖清单再 RUN 安装,最后 COPY 源码 | 七 |
| 4 | COPY --from=builder /app /app 整目录搬运 |
精确到 dist / target / 单个二进制 |
四 |
| 5 | Go 不加 CGO_ENABLED=0 就进 scratch |
默认动态链接 glibc,scratch 里没有加载器 | 四 |
| 6 | 无脑换 alpine | musl 兼容、DNS resolver 差异、无 bash、缺 tzdata/CA;重 C 扩展依赖改选 slim | 五 |
| 7 | 以为 ENV 里的密钥 unset 掉就安全 |
ENV 在层里留痕,同 RUN 内 export+使用+unset,或用 secret mount | 八 |
| 8 | 为了少几层把所有 RUN 合成一条 |
牺牲依赖层可缓存性,日常反而更慢;只合并「产生即清除」的动作 | 八 |
| 9 | 只盯体积不盯来源与版本 | 至少固定到次版本,要求高的 pin digest 并配镜像扫描 | 五 |
误区 4 / 5 / 7 的写法对照:
dockerfile
# 误区 4:整目录搬运
COPY --from=builder /app /app # 错:源码、node_modules、中间产物一起回来
COPY --from=builder /app/dist /app/dist # 对:精确到产物
# 误区 5:忘记关 cgo
RUN go build -o /out/server ./cmd/server # 错:动态链接 glibc
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server ./cmd/server # 对:纯静态
# 误区 7:ENV 里的密钥
ENV API_KEY=xxx
RUN unset API_KEY # 错:值仍在层里
# RUN --mount=type=secret,id=api_key ... # 对:用 BuildKit secret mount

总结
全文收成一句话:别想着删,要想着别让它进来。
一条可执行的落地顺序,每一步收益都能单独验证:
- 加
.dockerignore; - 改成多阶段构建;
- 换更小的基础镜像(先确认 libc 兼容与排障需要);
- 调指令顺序,让依赖层命中缓存;
- 合并
RUN并清理包管理器缓存; - 切非 root;
- 用
docker images/docker history/dive复量。
按这个顺序走完,docker images 里的数字变化会自己说话。想继续深入容器镜像相关话题,可以看站内这个话题聚合:Docker 技术话题。
也欢迎在评论区留下你的语言栈和优化前后的数字,互相验证一下本文给出的量级口径。
参考资料
© 2026 | 转载请注明出处