我们有一个 Go 微服务,二进制本身只有 18MB,但 Docker 镜像出来居然是 1.2GB,部署到 K8s 集群之后,每次滚动更新都要等三分钟拉镜像,十几个节点轮流拉,网络带宽被打满,影响在线业务。这种"二进制小但镜像巨大"的现象,几乎每个 DevOps 团队都遇到过,根本原因是 Dockerfile 写得太朴素,把构建工具链和运行环境捆绑在一起。这篇把这次镜像瘦身的完整过程,从 1.2GB 一步一步压到 80MB 再到 12MB(用 distroless 加 scratch)讲完,顺便给一份不同语言、不同场景下的 Dockerfile 最佳实践模板。
镜像膨胀的现场
事故的起因是一次大版本发布,运维同学发现集群带宽利用率突然飙到百分之九十,排查发现是 Docker 镜像拉取占用的。我们的 Go 微服务镜像从一年前的 800MB 涨到了现在的 1.2GB,在十二个节点上滚动更新,加上每天若干次扩缩容,镜像拉取每天能占用几百 GB 流量。除了带宽,还有一个隐性成本是镜像仓库的存储,我们用的是私有 Harbor,存储成本随着镜像版本数量线性增长。
| 阶段 | 镜像大小 | 拉取时间 | 层数 |
|---|---|---|---|
| 原始版本 | 1.2GB | 180s | 15 |
| 第一轮优化(合并 RUN) | 980MB | 140s | 8 |
| 第二轮(切 Alpine) | 320MB | 50s | 8 |
| 第三轮(多阶段构建) | 80MB | 15s | 5 |
| 第四轮(distroless) | 32MB | 8s | 3 |
| 第五轮(scratch) | 12MB | 3s | 2 |
第一版 Dockerfile:典型的反面教材
# 原始 Dockerfile, 反面教材
FROM golang:1.21
WORKDIR /app
ADD . /app
RUN apt-get update
RUN apt-get install -y git make gcc
RUN go mod download
RUN go build -o server ./cmd/main.go
RUN chmod +x server
EXPOSE 8080
CMD ["./server"]
这个 Dockerfile 几乎踩了所有典型坑。第一,基础镜像用了完整的 golang 镜像,自带 800MB 的工具链。第二,RUN 指令没合并,每个 RUN 都会产生一个 layer,layer 多了之后镜像膨胀。第三,apt-get update 之后没清理 apt 缓存,几十 MB 缓存留在镜像里。第四,没有 .dockerignore,把整个项目目录(包括 .git、node_modules、tmp)都打进了镜像。第五,运行时镜像直接用了构建镜像,不需要的 git、make、gcc 全都留在里面。
优化一:合并 RUN + 清理缓存
# 第一轮优化
FROM golang:1.21
WORKDIR /app
ADD . /app
RUN apt-get update && \
apt-get install -y --no-install-recommends git make gcc && \
rm -rf /var/lib/apt/lists/* && \
go mod download && \
go build -o server ./cmd/main.go && \
chmod +x server
EXPOSE 8080
CMD ["./server"]
关键改动是把多个 RUN 合并成一个,清理 apt 缓存。合并 RUN 的好处是减少 layer 数量,清理缓存避免无用文件留在镜像里。这一步把镜像从 1.2GB 压到 980MB,效果有限但成本最低。
优化二:换 Alpine 基础镜像
# 第二轮优化, Alpine 基础镜像
FROM golang:1.21-alpine
WORKDIR /app
RUN apk add --no-cache git make gcc musl-dev
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server ./cmd/main.go
EXPOSE 8080
CMD ["./server"]
Alpine 镜像只有 5MB 左右,比 Debian 系列小一个数量级。换成 Alpine 之后镜像直接降到 320MB,主要节省的是基础系统的体积。这里还做了一个小优化,把 COPY 拆成两步,先拷 go.mod 和 go.sum 下载依赖,再拷源码编译。这样在源码改了但依赖没变时,可以利用 Docker 的 layer 缓存,加速构建。
但 Alpine 也有坑,它用的 libc 是 musl 而不是 glibc,某些依赖 glibc 的二进制在 Alpine 上跑不起来。我们后来遇到过一次,某个 C 扩展依赖 glibc 的特殊符号,在 Alpine 上直接段错误。修法是要么换回 Debian 系列,要么用 Alpine 的 gcompat 包补 glibc 兼容层,前者更稳但镜像大,后者镜像小但有不确定性。
优化三:多阶段构建
# 第三轮优化, 多阶段构建
# 阶段一: 构建
FROM golang:1.21-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-s -w' -o server ./cmd/main.go
# 阶段二: 运行
FROM alpine:3.18
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]
多阶段构建是 Docker 17.05 引入的特性,允许在一个 Dockerfile 里定义多个 FROM,只把最后一个阶段打成最终镜像。前面的阶段只用于构建,最终镜像只包含运行需要的二进制和依赖。这是 Docker 镜像优化里最重要的技术,直接把镜像从 320MB 压到 80MB。
这里有几个值得注意的细节。CGO_ENABLED=0 是禁用 CGO,让 Go 编译出纯静态二进制,不依赖 libc。-ldflags='-s -w' 是去掉调试信息和符号表,二进制可以再瘦一半。如果你的服务需要做 TLS,在 HTTP 之上加一层 TLS 加密,防止中间人窃听和篡改。">HTTPS 调用,记得在运行阶段安装 ca-certificates,否则 TLS 握手会失败。tzdata 是时区数据,如果业务里用了 time.LoadLocation 也需要装,不然时间显示会错。
优化四:用 distroless 替代 Alpine
# 第四轮优化, distroless
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-s -w' -o server ./cmd/main.go
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
USER nonroot:nonroot
CMD ["./server"]
distroless 是 Google 维护的极简基础镜像,只包含运行二进制必需的最小依赖,连 shell 都没有。这意味着攻击者即使拿到镜像也没法 exec sh 进去查看,安全性大幅提升。distroless/static 镜像只有 2MB,比 Alpine 的 5MB 还小,而且自带 ca-certificates、tzdata、用户 nonroot。
distroless 的缺点是没法 docker exec 进去 debug,因为没有 shell。这在排查问题时是个不便,但 Google 提供了一个 :debug 标签的镜像,临时切到 debug 标签可以拿到 busybox。我们的实践是生产环境用 distroless,排查问题时拉同版本的 debug 标签起一个临时容器看。
优化五:scratch 极限瘦身
# 第五轮优化, scratch
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-s -w -extldflags "-static"' -o server ./cmd/main.go
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
scratch 是 Docker 的特殊空镜像,真的什么都没有。最终镜像只包含你 COPY 进去的文件,大小就是你二进制的大小加上额外文件的大小。这种方式只适合纯静态二进制,任何动态链接、shell 调用、文件系统操作都跑不起来。Go 编译的纯静态二进制是 scratch 的理想用户,绝大部分 Go 服务都可以跑在 scratch 上。
scratch 的进一步极致是连 ca-certificates 都不放,只复制最小必需的文件。但通常没必要这么极端,80MB 和 12MB 的差距在拉取时间上其实不大,运维和安全的便利性更值得追求。我们最终选定的是 distroless,既享受了瘦身收益,又保留了 Google 提供的安全更新和兼容性保证。
常见的 Dockerfile 反模式
除了上面提到的合并 RUN、清理缓存之外,还有几个常见反模式值得专门讲一下。第一个是ADD 和 COPY 不分。ADD 有两个特殊行为,会自动解压 tar 文件、支持 URL 下载。这两个行为在大多数场景下不需要,反而容易引发意外行为。Docker 官方推荐用 COPY,只在需要解压时才用 ADD。我们团队的规矩是除非明确需要解压,否则一律用 COPY。
第二个反模式是把环境变量塞进 ENV 而不是构建参数。某些秘密信息比如 API 密钥不应该放在 ENV 里,因为它会被打进镜像的元数据,任何人都能 docker inspect 看到。秘密应该通过运行时挂载或者 secrets 管理服务注入,而不是固化在镜像里。这种安全意识在前期是繁琐的,但出过一次"密钥被泄露"事故之后,所有人都会主动遵守。
第三个反模式是用 latest 标签。无论是基础镜像还是自己的应用镜像,都不应该依赖 latest 标签。latest 会随着上游更新而变化,可能引入意外的 break。所有镜像引用都应该锁定具体版本,推荐用语义版本加 commit hash,这样既能追溯历史,又能避免意外升级。我们的 CI 构建脚本里强制检查,任何引用 latest 的 Dockerfile 都会构建失败。
第四个反模式是不写 HEALTHCHECK。HEALTHCHECK 让 Docker 能感知容器的真实健康状态,比如服务起来但内部死锁了,只看进程是否运行就漏判了。HEALTHCHECK 的实现可以是简单的 curl localhost 检查,也可以是更复杂的业务级检测,根据业务需要选择。生产环境强烈建议每个服务都有 HEALTHCHECK,这是容器编排正常工作的前提。
构建缓存的正确利用
Docker 镜像构建是按 layer 缓存的,如果某一层没变,后续构建会复用缓存,大幅加速。但缓存命中需要写 Dockerfile 时的特殊考虑,层的顺序非常重要。原则是把变化少的放前面,变化多的放后面。比如安装系统依赖应该放在 COPY 源码之前,因为系统依赖很少变化,源码每次提交都变。
# 利用缓存的典型写法
FROM node:18-alpine AS builder
WORKDIR /app
# 1. 先拷依赖描述文件
COPY package.json package-lock.json ./
RUN npm ci # 这一层只在 package.json 变化时重建
# 2. 再拷源码
COPY . .
RUN npm run build # 源码变化会触发, 但 npm ci 缓存仍然命中
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
这种写法在前端项目里特别重要。npm install 通常要几分钟,如果每次源码改动都重新下载,CI 时间会非常长。把 package.json 单独拷贝,可以让依赖下载只在 package.json 变化时重新执行,平时构建几乎瞬间完成。Python、Ruby、Java 等语言也有类似优化,核心思想都是依赖和源码分离。
不同语言的最佳实践模板
| 语言 | 构建镜像 | 运行镜像 | 典型大小 |
|---|---|---|---|
| Go | golang:1.21-alpine | distroless/static / scratch | 10-30MB |
| Rust | rust:1.75-alpine | distroless/cc / scratch | 8-20MB |
| Java | maven:3-eclipse-temurin-17 | eclipse-temurin:17-jre-alpine | 200-300MB |
| Node.js | node:18-alpine | node:18-alpine | 150-250MB |
| Python | python:3.11-slim | python:3.11-slim | 100-200MB |
| 静态前端 | node:18-alpine | nginx:alpine | 30-50MB |
从表格可以看出,Go 和 Rust 这种能编译纯静态二进制的语言,最终镜像可以做到极致小。Java 因为 JRE 本身就大,优化空间有限,但用 GraalVM Native Image 可以编译成原生二进制,镜像也能压到几十 MB。Node 和 Python 因为是解释型语言,镜像必须带运行时,瘦身空间不如编译型语言。但 Node.js 也有 pkg 工具,Python 有 PyInstaller,可以打包成单文件二进制,运行在 distroless 上。
.dockerignore 的重要性
很多人写 Dockerfile 时忘了写 .dockerignore,结果整个项目目录全被打进 build context,包括 .git、node_modules、tmp、test 数据等等。这不仅让镜像变大,还可能泄露敏感信息(.env 文件、密钥)。每个项目都应该有一个完善的 .dockerignore,排除不需要的文件。
# 典型的 .dockerignore
.git
.gitignore
.dockerignore
Dockerfile*
README.md
docs/
test/
*.test
*.log
tmp/
.env*
.vscode/
.idea/
node_modules/
__pycache__/
*.pyc
.pytest_cache/
target/
build/
dist/
coverage/
.dockerignore 的语法跟 .gitignore 类似,放在 Dockerfile 同级目录。Docker 在构建时会把 build context 上传给 daemon,如果项目目录很大,这个过程也会很慢。一个好的 .dockerignore 能让构建快几十倍,镜像小几倍,值得每个项目都精心维护。
镜像安全扫描
镜像瘦身之后还有一个重要环节是安全扫描。基础镜像可能包含已知漏洞,运行时依赖可能有 CVE,这些都应该在 CI 阶段就扫出来,而不是上线之后被攻击者利用。我们用的是 Trivy,开源、快速、覆盖广,可以扫 OS 层和应用依赖层的漏洞。
# Trivy 扫描镜像
trivy image --severity HIGH,CRITICAL myapp:v1.0
# CI 集成: 有 HIGH 漏洞就失败
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:v1.0
# 扫描 Dockerfile
trivy config Dockerfile
# 生成报告
trivy image -f json -o report.json myapp:v1.0
扫描出漏洞之后,根据严重程度决定是否阻断发布。CRITICAL 级别必须修复,HIGH 级别评估影响范围,MEDIUM 和 LOW 可以容忍但要记录。distroless 镜像在这方面有先天优势,因为依赖少,漏洞数量也少,我们切到 distroless 之后扫描出来的 CVE 数量下降了百分之九十。
团队立的几条规矩
- 所有 Dockerfile 必须用多阶段构建,禁止把构建工具留在运行镜像里。
- 基础镜像优先选 distroless,其次 alpine,最后才考虑 debian-slim。
- 所有镜像必须有 .dockerignore,且不能为空。
- 所有镜像必须 HEALTHCHECK,符合业务真实健康检测语义。
- 禁止用 latest 标签,所有版本必须显式指定。
- CI 集成 Trivy 扫描,CRITICAL 漏洞自动阻断发布。
- 镜像大小超过 200MB 必须经过架构组评审。
- 秘密信息禁止写进 Dockerfile 或镜像,必须运行时注入。
排查镜像膨胀的标准流程
遇到镜像变大的情况,标准流程是先用 docker history 命令看每一层的大小,定位到最大的那几层,再针对性优化。docker history 会按时间顺序列出所有层,每层对应 Dockerfile 里的一条指令,大小和创建时间一目了然。如果某一层异常大,基本能直接对应到某条 RUN 或 COPY 指令,后续就是分析那条指令到底引入了什么。我们当时的 1.2GB 镜像,光是 golang:1.21 基础镜像就占了 800MB,RUN apt-get install 又加了 200MB,源码加构建产物再加 200MB,总计 1.2GB,层层都有膨胀空间。
除了 docker history,还有几个工具可以深度分析镜像。dive 是一个开源的镜像分析工具,可以交互式地浏览每一层的文件变化,看到哪些文件是冗余的、哪些文件可以删除。dive 的界面比 docker history 直观得多,适合做深度优化。slim 是 docker-slim 的简称,可以自动分析镜像并生成一个最小化的版本,理论上能把镜像压到原来的几十分之一。slim 的原理是动态分析容器运行时实际访问的文件,把没访问的全部删掉。这个工具在某些场景下效果惊人,但对动态加载文件的应用可能漏删,需要充分测试。
另一个常被忽略的工具是 syft,可以列出镜像里所有的软件包和版本号,配合 grype 可以扫描漏洞。这两个工具来自 Anchore 公司,在 CI 里集成可以做到自动化的镜像审计。我们的实践是 Trivy 用于发布前的强阻断,syft+grype 用于每日的镜像清单巡检,两套工具互相补充,覆盖面更广。镜像安全是个长期工作,不能一劳永逸,需要持续投入。
瘦身之后的真实收益
这次优化全部完成之后,我们做了一个量化的收益统计。镜像大小从 1.2GB 降到 32MB,降幅百分之九十七。集群滚动更新时间从三分钟缩到二十秒,节点扩容速度提升十倍。镜像拉取每天的带宽消耗从几百 GB 降到几十 GB,CDN 成本节省可观。镜像仓库存储从 500GB 降到 80GB,可以多保留几倍的历史版本。
除了直接的资源收益,还有几个间接收益。第一个是开发体验改善,本地构建从两分钟降到二十秒,大家更愿意频繁迭代。第二个是安全性提升,distroless 镜像没有 shell、没有包管理器、没有不必要的二进制,攻击面大幅缩小。第三个是故障恢复速度,出问题时拉新镜像更快,回滚也更快,平均故障恢复时间(MTTR)有明显改善。
这些收益叠加起来,投入产出比非常高。整个优化过程花了大约两周时间,但带来的长期收益是持续的,每天都在节省时间和成本。这就是基础设施优化的魅力,一次投入,长期收益,值得团队认真对待。
镜像优化对 CI/CD 流程的连锁影响
镜像变小不仅改善生产环境,对 CI/CD 流程也有显著影响。第一个影响是构建速度。CI 里的 docker build 时间跟镜像大小成正比,因为每一层都要被处理、压缩、推送。我们的 CI 构建时间从优化前的八分钟降到优化后的两分钟,降幅明显。这意味着开发者提交代码后,等待 CI 的时间大幅缩短,迭代速度加快。短反馈循环对开发体验的提升是巨大的,八分钟和两分钟的差距,一天累积下来能让团队多完成几次代码迭代。
第二个影响是镜像推送速度。CI 构建完镜像之后要推送到镜像仓库,推送时间跟镜像大小直接相关。1.2GB 镜像推送到 Harbor 需要约一分钟,12MB 镜像几秒钟就推完。在频繁部署的场景下,这个时间节省非常可观,特别是对于 GitLab CI 或 GitHub Actions 这种按运行时间计费的平台,直接降低成本。
第三个影响是构建缓存的命中率。优化之后的 Dockerfile 层数变少了,但每一层的稳定性提升了。原来的 Dockerfile 经常因为某个 RUN 里包含 apt-get update,每次构建都重新下载,缓存失效。优化之后的多阶段 Dockerfile,基础镜像层、依赖下载层、源码编译层分得清清楚楚,只在对应内容变化时重建,缓存命中率从百分之三十提升到百分之八十。这种隐性收益往往被忽略,但累积下来比镜像大小本身的收益还要大。
第四个影响是多架构镜像的构建。如果要支持 amd64 和 arm64 双架构,镜像大小直接乘以二。小镜像在多架构场景下的优势更明显,因为每个架构都要单独构建、单独推送、单独存储,大小翻倍带来的成本也翻倍。我们的 ARM 集群上线之后,镜像优化的收益又翻了一倍,这是当初优化时没想到的额外好处。
团队推广优化时遇到的真实阻力
这次镜像优化想推广到整个公司,实际执行时遇到了一些阻力,主要来自三个方面。第一个阻力来自历史项目的维护团队。他们的 Dockerfile 已经跑了很多年,稳定可靠,改造意味着风险。他们的担心很合理,毕竟"能跑就别动"是运维的金科玉律。我们的解决方案是先在新项目强制执行新规范,老项目自愿改造,有问题随时回滚。半年下来,老项目里大概一半主动改造完成,因为看到了新项目的明显收益,愿意跟进。
第二个阻力来自对 distroless 不熟悉的开发者。distroless 没有 shell,调试时不能 docker exec 进去,这让很多开发者很不习惯。我们专门写了内部文档,讲怎么用 :debug 标签做临时调试,怎么通过日志和指标观察服务状态。培训了几次之后,大部分人接受了 distroless 的限制,反而觉得这种约束让他们更注重可观测性的建设,长期看是好事。
第三个阻力来自跨团队的协调。基础镜像统一是镜像优化的重要一环,但每个团队可能有自己偏好的基础镜像。我们花了几个月时间,组织了几次架构组评审,最终统一了几套标准的基础镜像,所有团队都基于这些标准镜像构建。这种统一带来的好处是巨大的,镜像缓存可以跨团队复用,安全更新可以集中管理,问题排查也方便。但前期协调成本不低,需要有耐心和沟通技巧,纯靠技术手段是推不动的。
给同行的几条建议
第一条建议是不要等到镜像变大才想起优化。镜像膨胀是个渐进过程,每次小改动可能只多几十 MB,但积累起来就是上 GB。养成定期检查镜像大小的习惯,任何意外膨胀都要及时排查。我们现在有个看板,展示所有服务的镜像大小变化趋势,任何突变都有告警。
第二条建议是多阶段构建是必须的,不是可选的。任何编译型语言的项目都应该用多阶段构建,这不是性能优化而是基本规范。把构建工具链留在运行镜像里没有任何好处,只有坏处。
第三条建议是distroless 是生产环境的最佳实践。即使你不在乎安全,也应该在乎攻击面缩小带来的可维护性提升。distroless 的限制(没 shell、不能 exec)实际上是一种约束,让你不得不通过更规范的方式(日志、指标、追踪)来观察服务,这反而是工程化的进步。
第四条建议是把镜像优化纳入定期工作。每个季度都做一次镜像审计,看看有没有可以优化的地方。Docker 生态在持续演进,新的工具、新的基础镜像、新的最佳实践不断出现,持续跟进才能保持优势。
跟其他容器化方案的横向对比
除了 Docker,还有几个容器运行时和镜像格式值得了解。Podman 是 Red Hat 推的容器引擎,跟 Docker 兼容但去掉了 daemon,更安全也更容易在 root-less 模式下运行。Podman 用的也是 OCI 镜像格式,可以无缝使用 Docker 镜像。我们部分团队迁移到了 Podman,主要看中的是它的安全性和与 Kubernetes 更好的集成,但镜像优化的方法跟 Docker 完全一致,这篇里的所有建议都适用。
BuildKit 是 Docker 推出的下一代构建引擎,相比传统 docker build 有几个显著优势:并行构建多个阶段、更智能的缓存复用、支持 secret 挂载(避免密钥进镜像)、支持 SSH 转发(私有仓库依赖下载)。启用 BuildKit 只需要设置 DOCKER_BUILDKIT=1 环境变量,改造成本极低,但构建速度可以快百分之三十以上。我们项目全部启用了 BuildKit,在 CI 里效果特别明显,推荐所有团队都开。
Buildpacks 是 Heroku 开源的容器化方案,理念是"零配置容器化",开发者不需要写 Dockerfile,Buildpacks 自动检测语言、安装依赖、构建镜像。这种方案适合标准化的应用,可以省下大量 Dockerfile 维护成本,但对需要深度定制的场景灵活性不够。我们试过几个 Buildpacks 项目,简单应用确实方便,但稍微复杂一点就需要写自定义 Buildpack,反而比直接写 Dockerfile 麻烦。
Nix 也是一个值得关注的方向,可以构建出极致最小化的镜像,而且具有完美的可复现性,同样的输入永远产出同样的输出。Nix 的学习曲线陡峭,但在追求确定性和最小化的场景下,几乎没有对手。一些金融和安全敏感行业开始用 Nix 构建生产镜像,效果非常好。如果你的团队有 Nix 经验,值得在合适的项目里尝试。
镜像优化的几个常见认知误区
第一个误区是"镜像越小越好"。绝对小不是目标,合理小才是目标。把镜像压到 12MB 看似酷炫,但失去了 shell 和调试工具,某些场景下排查问题代价很高。distroless 的 32MB 是个更平衡的选择,既享受了瘦身收益,又保留了 ca-certificates 等关键依赖。盲目追求极致瘦身,可能会牺牲可运维性,得不偿失。
第二个误区是"Alpine 一定比 Debian 好"。Alpine 的 musl libc 跟 glibc 不完全兼容,某些依赖 glibc 的库在 Alpine 上跑不起来。Python 在 Alpine 上的兼容性问题特别多,因为很多 Python 包带 C 扩展。如果遇到 Alpine 上的奇怪问题,不要硬怼,换 debian-slim 可能就好了,镜像稍大一点但稳定可靠。
第三个误区是"只看构建镜像大小"。多阶段构建之后,构建镜像和运行镜像是分离的,只需要关注运行镜像大小。但有些团队习惯用同一个镜像做开发和生产,这种方式既不安全也不高效。开发镜像可以保留所有工具方便调试,生产镜像必须最小化,两者应该分别维护。
事故复盘的几点反思
第一点反思是基础设施的隐性成本经常被忽视。镜像大一点看起来无关紧要,但累积到上百个服务、每天上千次部署,带宽、存储、时间成本都会成倍放大。我们这次优化之前从来没认真核算过镜像相关的总成本,真核算之后才发现每年浪费的资源相当可观,远远超过两周的优化投入。基础设施的优化往往这样,单看一处不显眼,整体优化收益巨大。
第二点反思是团队需要有人主动推动基础工程。镜像优化、CI 流程改进、监控完善这些工作不像新功能那样能直接产生业务价值,容易被推到一边。需要有人主动承担、长期投入,才能慢慢看到收益。我们公司专门设了一个基础设施组,负责这类工作,半年下来整体研发效率提升明显,业务团队反而能更专注于业务创新。这种角色分工值得借鉴。
总结
Docker 镜像优化是 DevOps 工程师的基本功,看似简单实则有很多细节。从 1.2GB 压到 12MB,核心技术是多阶段构建 + 极简基础镜像。多阶段构建把构建工具链和运行环境彻底分离,distroless 或 scratch 提供最小的运行时,两者结合可以让镜像极致瘦身。配合 .dockerignore、构建缓存、Trivy 扫描、HEALTHCHECK 等实践,可以打造既小又安全又可维护的镜像。
这次优化让我意识到,基础设施的细节决定了运维的天花板。镜像大小看似只是个数字,但它影响了部署速度、扩容能力、网络成本、存储成本、安全风险等多个维度。把这些细节做好,运维工作会从被动救火变成主动建设,团队的整体效率和稳定性都会提升一个台阶。希望这篇文章能给你的镜像优化提供一些参考,如果你的项目里还有 GB 级的镜像,今天就动手优化吧,收益绝对超出你的预期。
—— 别看了 · 2026