上次review同事的Dockerfile,一个Go服务的镜像打出来1.2G。
“这也太大了吧?”
“能跑就行呗。”
能跑是能跑,但每次部署拉镜像就要好几分钟,磁盘空间也吃不消。
花了半天时间优化,最后压到47M,记录一下过程。
问题分析
先看看原来的Dockerfile:
FROM golang:1.21 WORKDIR /app COPY . . RUN go mod download RUN go build -o main . EXPOSE 8080 CMD ["./main"]看起来没毛病,但问题就出在这里。
镜像分析
# 查看镜像大小docker images|grepmyapp myapp latest abc1231.24GB# 用dive分析镜像层dive myapp:latest分析结果:
- golang:1.21基础镜像就有800MB
- 加上源码、依赖、编译产物,妥妥过1G
优化方案
阶段一:多阶段构建
最立竿见影的优化:编译和运行分开。
# 阶段1:编译 FROM golang:1.21 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o main . # 阶段2:运行 FROM alpine:3.18 WORKDIR /app COPY --from=builder /app/main . EXPOSE 8080 CMD ["./main"]效果:
docker images|grepmyapp myapp latest def456 28MB直接从1.2G降到28MB,降了97%。
原理很简单:
- 编译阶段用完整的golang镜像
- 运行阶段只拷贝编译好的二进制文件
- 用alpine替代完整系统,本身才5MB
阶段二:进一步压缩
28MB还能更小吗?可以。
# 阶段1:编译 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 main . # 阶段2:运行 FROM scratch COPY --from=builder /app/main /main EXPOSE 8080 ENTRYPOINT ["/main"]改进点:
- 编译阶段也用alpine,加快构建
- 加上
-ldflags="-s -w"去掉调试信息 - 用
scratch空镜像替代alpine
docker images|grepmyapp myapp latest ghi789 12MB从28MB又降到12MB。
阶段三:UPX压缩(可选)
如果想更极致:
FROM golang:1.21-alpine AS builder # 安装upx RUN apk add --no-cache upx WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o main . RUN upx --best --lzma main FROM scratch COPY --from=builder /app/main /main EXPOSE 8080 ENTRYPOINT ["/main"]docker images|grepmyapp myapp latest jkl0124.7MB从12MB降到4.7MB。
但UPX有个问题:程序启动时需要解压,会增加启动时间。适合对镜像大小极度敏感但对启动速度不敏感的场景。
不同语言的优化策略
Java项目
Java比较麻烦,因为需要JVM。
# 阶段1:编译 FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests # 阶段2:运行 FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY --from=builder /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]关键点:
- 用jre-alpine替代完整JDK
- 分离依赖下载和代码编译(利用缓存)
Java还可以用jlink自定义运行时:
FROM eclipse-temurin:17 AS jre-builder RUN jlink \ --add-modules java.base,java.logging,java.sql,java.naming,java.management \ --strip-debug \ --no-man-pages \ --no-header-files \ --compress=2 \ --output /javaruntime FROM alpine:3.18 COPY --from=jre-builder /javaruntime /opt/java COPY --from=builder /app/target/*.jar /app/app.jar ENV PATH="/opt/java/bin:${PATH}" ENTRYPOINT ["java", "-jar", "/app/app.jar"]自定义的JRE只有几十MB,比完整JRE小很多。
Node.js项目
# 阶段1:构建 FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . RUN npm run build # 阶段2:运行 FROM node:20-alpine WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules COPY package*.json ./ USER node EXPOSE 3000 CMD ["node", "dist/index.js"]Node项目主要是node_modules太大,优化方向:
- 只装生产依赖
- 用
npm ci替代npm install - 考虑用esbuild打包成单文件
Python项目
# 阶段1:构建 FROM python:3.11-alpine AS builder RUN apk add --no-cache gcc musl-dev WORKDIR /app COPY requirements.txt . RUN pip install --user --no-cache-dir -r requirements.txt # 阶段2:运行 FROM python:3.11-alpine WORKDIR /app COPY --from=builder /root/.local /root/.local COPY . . ENV PATH=/root/.local/bin:$PATH EXPOSE 8000 CMD ["python", "app.py"]缓存优化
镜像大小优化完了,顺便说说构建速度。
利用层缓存
# 好的写法:先复制依赖文件 COPY go.mod go.sum ./ RUN go mod download # 再复制源码 COPY . . RUN go build -o main .# 差的写法:一起复制 COPY . . RUN go mod download RUN go build -o main .好的写法只要依赖不变,go mod download这层就会走缓存。
.dockerignore
别忘了加.dockerignore:
.git .gitignore *.md .idea .vscode node_modules vendor *.log Dockerfile docker-compose.yml不然COPY .会把一堆没用的东西复制进去。
安全优化
镜像瘦身的同时,顺便做一下安全加固。
非root用户
FROM alpine:3.18 RUN addgroup -S appgroup && adduser -S appuser -G appgroup WORKDIR /app COPY --from=builder /app/main . RUN chown -R appuser:appgroup /app USER appuser EXPOSE 8080 CMD ["./main"]只读文件系统
# docker-compose.ymlservices:app:image:myappread_only:truetmpfs:-/tmp扫描漏洞
# 用trivy扫描trivy image myapp:latest选择维护良好的基础镜像,及时更新。
实际效果对比
| 优化阶段 | 镜像大小 | 构建时间 |
|---|---|---|
| 原始版本 | 1.24GB | 45s |
| 多阶段+alpine | 28MB | 38s |
| scratch+ldflags | 12MB | 35s |
| UPX压缩 | 4.7MB | 52s |
推荐停在"scratch+ldflags"这个阶段,性价比最高。
部署效率提升
镜像从1.2G降到12MB后:
- 推送到仓库:从3分钟变成5秒
- 拉取镜像:从2分钟变成2秒
- 磁盘占用:一台机器能放更多版本
特别是跨区域部署的时候,镜像小就是快。我们有几个异地节点,之前用星空组网把节点连起来后,小镜像部署基本感觉不到延迟。
常见问题
Q1:scratch镜像没有shell怎么调试?
# 需要调试就用alpine FROM alpine:3.18 # 或者用busybox FROM busybox:latestQ2:CGO_ENABLED=0是什么意思?
禁用CGO,编译成纯静态二进制。不依赖glibc,才能在scratch里跑。
如果你的代码用了CGO(比如用了sqlite3),就不能这样玩。
Q3:alpine里程序跑不起来?
可能是glibc的问题。alpine用的是musl。
解决方案:
- 编译时用alpine对应的golang镜像
- 或者静态编译
总结
Docker镜像瘦身的核心技巧:
| 技巧 | 适用场景 | 效果 |
|---|---|---|
| 多阶段构建 | 所有项目 | 立竿见影 |
| 小基础镜像 | 大多数项目 | 很明显 |
| ldflags去调试信息 | Go项目 | 减少30-50% |
| UPX压缩 | 对大小极端敏感 | 减少60-70% |
| .dockerignore | 所有项目 | 加快构建 |
一句话总结:多阶段构建 + 合适的基础镜像,就能解决90%的问题。
有其他镜像优化技巧欢迎评论区分享~