镜像优化不只是追求一个更小的数字。镜像会进入构建缓存、制品仓库、节点本地存储和运行时,每多一份无用数据,都可能增加传输、解压、扫描与回收成本。优化的目标应同时覆盖可重复构建、缓存命中、运行安全和排障能力。
镜像分层如何工作
Dockerfile 中多数会改变文件系统的指令都会产生只读层。构建器按顺序计算指令及其输入的缓存标识;前一层变化后,依赖它的后续层通常都要重建。容器启动时只在镜像只读层之上增加一个可写层,不会修改原镜像。
一个常见错误是先复制全部源码,再安装依赖:
COPY . .
RUN npm ci
RUN npm run build
任何 README 或业务代码变化都会让 COPY 缓存失效,依赖安装也随之重跑。锁文件比源码稳定,应把依赖描述单独复制:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
缓存命中必须以正确性为前提。若依赖安装还受运行时版本、系统库或构建参数影响,这些输入也要体现在基础镜像和指令中。不要为了命中缓存而省略锁文件校验,也不要在受保护构建中复用来源不明的远程缓存。
合并 RUN 的真实意义
每个 RUN 通常形成一层。在旧层中下载文件、下一层再删除,下载内容仍保留在历史层里,镜像体积不会真正回收。因此安装系统包时应在同一条指令中清理索引:
RUN apt-get update
&& apt-get install -y --no-install-recommends ca-certificates tzdata
&& rm -rf /var/lib/apt/lists/*
合并 RUN 能减少层数量,更重要的是让临时数据在提交该层前被删除。它不意味着要把所有操作拼成一条无法维护的超长命令。变化频率不同、能够独立缓存的步骤仍可拆开;优化应围绕数据是否进入层以及缓存边界,而不是机械追求最少指令数。
包管理器缓存、编译缓存可使用 BuildKit 的缓存挂载,使其加速构建但不进入最终层:
RUN --mount=type=cache,target=/root/.cache/go-build
--mount=type=cache,target=/go/pkg/mod
go build -trimpath -o /out/server ./cmd/server
多阶段构建隔离构建环境
编译器、头文件、测试工具和源码只服务于构建,不应跟随程序进入运行镜像。多阶段构建允许从构建阶段只复制必要产物:
# syntax=docker/dockerfile:1.7
FROM golang:1.24-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w"
-o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
USER 65532:65532
ENTRYPOINT ["/server"]
运行阶段没有编译器与源代码,镜像更小,运行时可用工具也更少。故障排查不要临时把 shell 装进生产容器,可使用临时调试容器、应用诊断端点或单独维护的调试镜像。若程序依赖动态库、证书、时区数据或字体,必须显式复制或选择包含这些组件的运行基础镜像;“本地能运行、精简镜像启动失败”常来自遗漏运行时依赖。
基础镜像的选择权衡
基础镜像越小,通常拉取更快、预装软件更少,潜在漏洞面也可能更窄,但兼容性和可诊断性会下降。极简镜像适合依赖明确的静态二进制;需要原生扩展、动态链接库或 shell 脚本的应用,使用精简版通用发行版更稳妥。基于 musl 的镜像与依赖 glibc 的预编译组件可能不兼容,不能仅凭体积替换。
基础镜像应固定版本,生产制品还可固定摘要,避免同一标签更新造成构建漂移。固定摘要不会自动获得安全修复,需要依赖更新任务定期发现新摘要、重新构建和验证。镜像内包数量少不等于没有漏洞,应用依赖、静态链接库与复制进来的工具同样需要扫描。
控制构建上下文
构建命令会把上下文发送给构建器。若目录包含 .git、测试报告、本地依赖、密钥文件或大型数据,既拖慢传输,也可能被误复制进镜像。.dockerignore 应与 Dockerfile 一起评审:
.git
node_modules
coverage
*.log
.env*
secrets/
Dockerfile*
忽略规则过宽会导致构建阶段缺文件,例如误排除迁移脚本或静态资源。可以在干净工作目录执行构建,避免开发机上的未跟踪文件让镜像内容不可重复。COPY . . 即使配合忽略文件,审计性仍弱;运行阶段更适合逐项复制明确产物。
非 root 与只读根文件系统
镜像内创建专用用户并切换 USER,可降低进程被利用后的权限。运行平台还应禁止提权、丢弃不需要的 Linux capabilities,并尽量启用只读根文件系统:
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir:
sizeLimit: 128Mi
只读根文件系统启用后,应用若写日志、缓存、PID 文件或临时文件会失败。日志应写 stdout/stderr;确需写入的路径用受限临时卷显式挂载并设置容量。非 root 进程监听低端口、读取证书或访问挂载卷时也可能遇到权限问题,应在预发布环境用与生产一致的安全上下文验证。
体积、拉取与流水线门禁
镜像体积会影响新节点拉取、突发扩容、跨区域同步和节点磁盘回收。节点已缓存镜像时启动可能很快,换到空节点后才暴露长拉取路径,因此演练需要覆盖冷缓存。分层设计也影响传输:稳定的大层可复用,频繁变化的应用层应靠后且尽量小。
镜像扫描应位于镜像构建之后、推送到正式仓库或部署之前。扫描结果要关联镜像摘要,不能只记录可移动标签。策略可阻断新增的高严重度且存在可修复版本的问题,对已批准例外设置责任人与过期时间。扫描数据库更新后,同一镜像的结果可能变化,仓库中的在用镜像还需周期性重扫。
优化效果可通过分层清单、构建耗时、冷拉取耗时和运行依赖验证来观察。若为了缩小体积删除证书或诊断所需数据,导致 TLS 连接失败或故障无法定位,就偏离了目标;可控、可更新、能稳定运行的镜像比单纯追求极限体积更重要。
用分层数据定位膨胀
体积突然增加时,应先查看镜像历史和每层大小,而不是直接更换基础镜像:
docker image history --no-trunc app:build
docker image inspect app:build --format '{{json .RootFS.Layers}}'
若某层包含包管理器索引、测试产物或源码,回到生成该层的指令修复;在后续层删除只能让容器视图中看不见,旧层数据仍会随镜像传输。多架构镜像还要分别检查各平台清单,不同架构的基础层和依赖可能具有不同体积。
构建验证应在无本地缓存条件下至少执行一次,确认所有输入都来自声明内容;再重复构建检查缓存命中是否符合预期。产物可记录 SBOM、镜像摘要和构建来源,但这些元数据必须与摘要绑定。发布前以生产安全上下文启动容器,执行健康检查、只读文件系统测试和最小业务冒烟,能较早发现精简过程中丢失动态库、写目录或证书的问题。
Comments (4)
照着敲了一遍,第 4 步报错,看了下是版本差异,改个参数就好了。
有点意思,之前完全没从这个角度想过。
写得实在,不吹不黑。
有个疑问:这个做法对老系统改造友好吗?
Please Sign in to join the discussion.