上次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 | grep myapp
myapp latest abc123 1.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 | grep myapp
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"空镜像替代alpine
scratch
docker images | grep myapp
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 | grep myapp
myapp latest jkl012 4.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考虑用esbuild打包成单文件
npm install
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.yml
services:
app:
image: myapp
read_only: true
tmpfs:
- /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:latest
Q2: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%的问题。
有其他镜像优化技巧欢迎评论区分享~

