Docker镜像瘦身实战:从1G优化到50M的全过程

内容分享1小时前发布
0 0 0

上次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"
去掉调试信息用
scratch
空镜像替代alpine


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
替代
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.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%的问题。


有其他镜像优化技巧欢迎评论区分享~



© 版权声明

相关文章

暂无评论

none
暂无评论...