2024 年我们的 CI/CD 流水线:80 个微服务,每天构建 600+ 次,某次梳理发现镜像构建成了瓶颈 — 单个 Java 服务镜像 1.8GB,构建 8 分钟,推送 3 分钟,K8s 拉取启动慢,节点磁盘频繁告警。投了两周做镜像治理,镜像体积 1.8GB → 180MB,构建 8min → 90s,整条流水线提速 60%。本文复盘 Docker 镜像构建优化的完整实战,覆盖多阶段构建、层缓存、基础镜像、体积瘦身、安全扫描。
问题背景
CI/CD:GitLab CI + Kaniko 构建
服务:80 个微服务(Java 50 / Go 20 / Node 10)
构建频率:600+ 次/天
问题:
- Java 服务镜像:1.8GB(openjdk 全量 + 依赖 + 源码)
- Go 服务镜像:900MB(golang 全镜像直接跑)
- Node 服务镜像:1.2GB(node_modules + 源码)
- 构建时长:Java 8min / Go 5min / Node 6min
- 推送 registry:2-3min
- K8s 节点磁盘:镜像层占 80%+,频繁清理
- Pod 拉镜像启动:首次 90s+
镜像层分析(dive 工具):
$ dive myapp:latest
Total Image size: 1.8 GB
Potential wasted space: 680 MB ← 浪费近 700MB
- 多次 COPY 同样文件
- apt cache 没清理
- 构建工具留在最终镜像
Image efficiency score: 62%
修复 1:多阶段构建(Java)
# 不好:单阶段,构建工具 + 源码全留在镜像
# FROM maven:3.9-openjdk-17
# COPY . .
# RUN mvn package
# CMD ["java", "-jar", "target/app.jar"]
# → 1.8GB(含 maven / JDK / .m2 / 源码)
# 好:多阶段构建
# === 阶段 1:构建 ===
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
# 先拷 pom.xml 单独下依赖(利用层缓存)
COPY pom.xml .
RUN mvn dependency:go-offline -B
# 再拷源码构建(源码变了不会重下依赖)
COPY src ./src
RUN mvn package -DskipTests -B
# === 阶段 2:运行 ===
FROM eclipse-temurin:17-jre-jammy AS runtime
WORKDIR /app
# 只拷构建产物
COPY --from=builder /build/target/app.jar app.jar
# 非 root 用户运行(安全)
RUN groupadd -r app && useradd -r -g app app
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
# 结果:JRE 而非 JDK,无 maven,无源码 → 350MB
# === 进阶:用 jlink 裁剪 JRE ===
FROM eclipse-temurin:17-jdk-jammy AS jre-builder
RUN jlink \
--add-modules java.base,java.logging,java.sql,java.naming,java.management,java.security.jgss,java.instrument,jdk.unsupported \
--strip-debug --no-man-pages --no-header-files \
--compress=2 --output /custom-jre
FROM debian:12-slim AS runtime
COPY --from=jre-builder /custom-jre /opt/jre
ENV PATH="/opt/jre/bin:$PATH"
COPY --from=builder /build/target/app.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
# 裁剪 JRE → 最终镜像 180MB
修复 2:多阶段构建(Go)
# Go 镜像优化:静态编译 + scratch/distroless
# === 阶段 1:构建 ===
FROM golang:1.22-alpine AS builder
WORKDIR /build
# 先拷 go.mod 下依赖(层缓存)
COPY go.mod go.sum ./
RUN go mod download
# 拷源码编译
COPY . .
# CGO_ENABLED=0 静态编译,-ldflags 去符号表
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -extldflags '-static'" \
-trimpath \
-o /app ./cmd/server
# === 阶段 2:最小运行镜像 ===
# 方案 A:scratch(空镜像,最小)
FROM scratch
# 需要手动拷 ca 证书 + 时区
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 /app
ENTRYPOINT ["/app"]
# → 12MB
# 方案 B:distroless(谷歌官方,带基础但无 shell)
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
# → 14MB,比 scratch 多了 ca/tz/nonroot 用户,推荐
# 对比:
# golang:1.22 全镜像跑 → 900MB
# alpine + 二进制 → 25MB
# distroless → 14MB
# scratch → 12MB
修复 3:Node.js 镜像优化
# === 阶段 1:依赖安装 ===
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# 只装生产依赖
RUN npm ci --omit=dev && npm cache clean --force
# === 阶段 2:构建 ===
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci # 含 devDependencies
COPY . .
RUN npm run build # 产出 dist/
# === 阶段 3:运行 ===
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
# 只拷生产依赖 + 构建产物
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
# 非 root
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
# .dockerignore(关键,减少构建上下文)
# node_modules
# dist
# .git
# *.log
# coverage
# .env*
# README.md
# tests
# 结果:1.2GB → 220MB
# 进一步:用 distroless/nodejs → 180MB
修复 4:层缓存优化
# 原则:变化频率低的指令放前面,高的放后面
# Docker 层缓存:某层变了,后面所有层缓存失效
# 不好:源码一变,依赖重装
FROM node:20-alpine
WORKDIR /app
COPY . . # 源码 + package.json 一起拷
RUN npm ci # 任何文件改动都重装依赖
# 好:依赖和源码分开
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # 只拷依赖描述
RUN npm ci # 依赖没变就命中缓存
COPY . . # 源码单独拷
RUN npm run build
# === RUN 指令合并(减少层数 + 清理缓存)===
# 不好:apt cache 留在镜像里
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y vim
# 好:一条 RUN,装完即清理
RUN apt-get update && \
apt-get install -y --no-install-recommends curl vim && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# === BuildKit 缓存挂载(依赖缓存跨构建复用)===
# syntax=docker/dockerfile:1.4
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
# --mount=type=cache:.m2 缓存持久化,不进镜像层
RUN --mount=type=cache,target=/root/.m2 \
mvn dependency:go-offline -B
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
mvn package -DskipTests -B
# Go 同理
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /app ./cmd/server
# CI 中启用 BuildKit
# DOCKER_BUILDKIT=1 docker build ...
# 或 buildx:docker buildx build --cache-from --cache-to
修复 5:CI 构建加速
# GitLab CI:用 buildx + registry 缓存
build:
stage: build
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_BUILDKIT: "1"
script:
- docker buildx create --use
# --cache-from / --cache-to 用 registry 做缓存层
- docker buildx build
--cache-from type=registry,ref=$CI_REGISTRY_IMAGE:buildcache
--cache-to type=registry,ref=$CI_REGISTRY_IMAGE:buildcache,mode=max
--tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
--push .
# Kaniko 构建(K8s 内无 docker daemon)
build-kaniko:
image:
name: gcr.io/kaniko-project/executor:debug
script:
- /kaniko/executor
--context $CI_PROJECT_DIR
--dockerfile $CI_PROJECT_DIR/Dockerfile
--destination $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
--cache=true
--cache-repo=$CI_REGISTRY_IMAGE/cache
--snapshot-mode=redo
--use-new-run
# 构建矩阵并行(80 服务分批)
# 用 GitLab parallel:matrix 或 needs DAG
# 镜像分层标签策略
# - :latest 最新
# - :$CI_COMMIT_SHA 精确版本(部署用这个)
# - :$BRANCH 分支最新
# - :buildcache 缓存专用
# 效果:
# - 全 cache 命中:90s 完成
# - 依赖变更:3min
# - 完全冷构建:5min
修复 6:镜像安全扫描
# 1. Trivy 扫描(CVE 漏洞 + 配置)
$ trivy image --severity HIGH,CRITICAL myapp:latest
# 输出
myapp:latest (debian 12.4)
Total: 3 (HIGH: 2, CRITICAL: 1)
┌──────────────┬────────────────┬──────────┬───────────────────┐
│ Library │ Vulnerability │ Severity │ Fixed Version │
├──────────────┼────────────────┼──────────┼───────────────────┤
│ libssl3 │ CVE-2024-xxxx │ CRITICAL │ 3.0.13-1~deb12u1 │
└──────────────┴────────────────┴──────────┴───────────────────┘
# 2. CI 中集成(发现 CRITICAL 就 fail)
scan:
stage: test
image: aquasec/trivy:latest
script:
- trivy image --exit-code 1 --severity CRITICAL
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
allow_failure: false
# 3. 减少攻击面的措施
# - 用 distroless / slim 基础镜像(无 shell / 无包管理器)
# - 非 root 用户运行
# - 只装必要的包(--no-install-recommends)
# - 定期更新基础镜像
# - 不在镜像里放密钥(用 K8s Secret)
# 4. 镜像签名(供应链安全)
$ cosign sign --key cosign.key myapp:latest
$ cosign verify --key cosign.pub myapp:latest
# 5. dive 分析镜像效率
$ dive myapp:latest --ci --highestUserWastedPercent 0.1
# 浪费空间超阈值则 CI 失败
# 6. SBOM 生成(软件物料清单)
$ trivy image --format cyclonedx --output sbom.json myapp:latest
$ syft myapp:latest -o spdx-json
优化效果
指标 优化前 优化后
=========================================================
Java 镜像体积 1.8GB 180MB(jlink JRE)
Go 镜像体积 900MB 14MB(distroless)
Node 镜像体积 1.2GB 180MB
构建时长(冷) 8min 5min
构建时长(缓存命中) 8min 90s
推送 registry 2-3min 10-20s
K8s 首次拉取启动 90s 12s
镜像效率分(dive) 62% 96%
节点磁盘镜像占用 80%+ 25%
整体收益:
- CI 流水线:平均 25min → 10min
- registry 存储:8TB → 1.5TB
- K8s 节点磁盘告警:消失
- 部署速度快 5x,回滚也快
安全:
- CVE 扫描入 CI,CRITICAL 阻断发布
- distroless 无 shell,攻击面大幅缩小
- 镜像签名 + SBOM,供应链可追溯
避坑清单
- 多阶段构建:builder 编译,runtime 只拷产物
- 基础镜像选最小:JRE 而非 JDK,distroless/scratch 而非全镜像
- 层缓存:依赖描述文件(pom/go.mod/package.json)单独 COPY 在前
- RUN 指令合并 + 清理缓存(apt clean / rm -rf lists)
- BuildKit cache mount:依赖缓存不进镜像层
- .dockerignore 必写,减小构建上下文
- 非 root 用户运行,安全基线
- CI 用 registry 缓存(--cache-from/--cache-to)
- Trivy 扫描入 CI,CRITICAL 漏洞阻断发布
- 用 dive 分析镜像效率,浪费空间纳入 CI 检查
总结
Docker 镜像优化是个性价比极高的工程,几行 Dockerfile 改动就能换来 10 倍体积缩减和构建提速。最大的认知改变:镜像里不该有构建工具,maven / JDK / node_modules 的 devDependencies / Go 编译器,这些只在构建阶段需要,多阶段构建让 runtime 镜像只保留运行所需的最小集合 —— 一个 Go 服务的最终镜像可以只有 14MB。最被低估的是层缓存的指令顺序,把 COPY package.json 和 RUN npm ci 放在 COPY 源码之前,源码每次改动都不会触发依赖重装,这一个顺序调整就能让 90% 的构建走缓存、90 秒完成。最容易踩的坑是 RUN 指令不清理缓存,apt-get install 后不 clean,几百 MB 的包索引就永久留在镜像层里,而且因为分层机制,后面的 RUN 删除也救不回来 —— 必须在同一条 RUN 里装完即清。最后,distroless 不只是为了瘦身,它没有 shell、没有包管理器,攻击者即使进了容器也无从下手,体积和安全一举两得,是 2024 年生产镜像的首选基础。
—— 别看了 · 2026