上周有个同事跑过来问我,说他打包的Java服务镜像有1.2GB,推到仓库慢得要命,部署拉镜像也得等半天。我一看他的Dockerfile,好家伙,直接FROM openjdk:17,把整个JDK和Debian系统全打进去了。
镜像太大这事,说小了影响CI/CD速度,说大了就是安全和成本问题。这篇就聊聊怎么把镜像从1GB压到100MB以内。
镜像为什么这么大
先搞清楚体积都花在哪了。跑一下docker history myapp,你会看到每一层的大小。大部分情况下的罪魁祸首就这几个:
用了一个臃肿的基础镜像(比如完整的ubuntu、debian)。构建工具和编译器留在了最终镜像里。依赖缓存没清理,比如apt的/var/cache、npm的node_modules。源码、测试文件、文档全拷进去了。
说白了,你的镜像里有一半东西在运行时根本用不到。
选对基础镜像
这是瘦身的第一步,也是最立竿见影的一步。现在主流的轻量镜像有三选:Alpine、distroless、scratch。
Alpine大概5MB左右,基于musl libc和busybox,自带包管理器apk,有shell。调试方便,但有些C语言依赖在musl上会踩坑,比如glibc特有的DNS解析行为。
distroless 更激进一些,只有你的应用运行时和最底层的共享库,没有shell、没有包管理器。大小在2到20MB之间,自带SSL证书。安全性好,但调试起来比较费劲------你没法docker exec进去到处看。好在distroless提供了:debug标签,带了个busybox shell,开发环境可以用。
scratch是终极方案,0MB,完全空的文件系统。适合Go、Rust这种编译成静态二进制的语言。你的镜像里就只有编译好的可执行文件,别的什么都没有。但SSL证书得自己往里拷,调试也基本别想。
实际项目里怎么选?Go应用我倾向scratch或distroless,Java应用用distroless的Java运行时,Node.js用Alpine。别用完整的openjdk:17,换成eclipse-temurin:17-jre-alpine或者distroless的Java镜像,体积直接砍掉一半。
多阶段构建是关键
这是瘦身最核心的手段。思路很简单:构建环境和运行环境分开,构建阶段该装的编译器、构建工具一个不留到最终镜像。
拿一个Spring Boot项目举例:
bash
# 构建阶段
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:resolve
COPY src ./src
RUN mvn package -DskipTests
# 运行阶段
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
第一个阶段里,Maven、JDK、源码、.m2缓存全都在,可能有800MB。但最终镜像只从里面拷了那个jar文件,基础镜像换成了只有JRE的Alpine版本。一下从1.2GB降到了180MB左右。
Go项目更夸张,多阶段构建后用scratch,最终镜像可能就十几MB:
sql
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o myapp -ldflags="-s -w" .
FROM scratch
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]
-ldflags="-s -w"把调试信息去掉了,又能省不少。CGO_ENABLED=0保证静态编译,这样scratch里没有glibc也能跑。
.dockerignore别忘了
很多人Dockerfile写得挺好,结果.dockerignore是空的。COPY . .的时候把.git目录、node_modules、测试文件、IDE配置全拷进去了。这些不仅让镜像变大,还可能导致缓存频繁失效。
一个基本的.dockerignore至少该有这些:
bash
.git
node_modules
*.md
.env
Dockerfile
docker-compose.yml
__pycache__
*.pyc
.idea
.vscode
target
dist
写好了再build,你会发现构建速度也快了不少,因为传给daemon的context小了。
层缓存怎么优化
Docker每条指令就是一层,缓存命中的前提是指令和上下文没变。最常见的坑就是把COPY . .写在RUN npm install前面,导致每次代码改动,依赖都得重装。
正确的顺序是先拷依赖文件,再拷源码:
sql
COPY package*.json ./
RUN npm ci
COPY . .
这样只有package.json变化时才会重新安装依赖,日常改代码构建秒级完成。Maven项目同理,先COPY pom.xml再RUN mvn dependency:resolve,最后拷源码。
还有一点,RUN指令能合并就合并,别一个apt-get install拆成好几行。每条RUN都是一层,合在一起用&&连接,最后清掉缓存:
sql
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
--no-install-recommends不装推荐包,rm -rf清理apt缓存,每一步都在省空间。
镜像扫描和安全
镜像小了还不够,得确保里面没有已知漏洞。用Trivy扫一下就知道:
arduino
trivy image myapp:latest
输出会告诉你哪些层有CVE漏洞,哪些依赖该升级了。Alpine虽然小,但因为用musl libc,有些安全补丁的跟进速度不如glibc发行版。distroless在安全方面更靠谱,因为攻击面小------没有shell意味着攻击者就算拿到了RCE也没法轻松exec进去。
生产环境建议把镜像扫描集成到CI流水线里,有高危漏洞直接卡住不让发版。
一个真实的瘦身案例
之前接手一个Node.js项目,初始镜像1.1GB。Dockerfile大概长这样:
sql
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]
问题一眼就能看出来:用的完整node镜像、没分层、源码和node_modules全在、没.dockerignore。
优化后的版本:
sql
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
最终镜像98MB。从1.1GB到98MB,降了91%。
整个过程就三板斧:换基础镜像、多阶段构建、依赖分层缓存。没有什么黑科技,都是Docker文档里写的基本操作,但很多人就是懒得做。
镜像优化这事,前期花个把小时调整Dockerfile,后面每次构建、推送、部署省的时间加起来远不止这些。特别是CI/CD流水线里镜像体积直接影响部署速度,值得认真对待。