一个 780 MB 的镜像,让每次发布多等三分钟
我们去年底把服务容器化了,当时赶时间,Dockerfile 写得非常粗糙:
FROM openjdk:8-jdk
WORKDIR /app
COPY target/order-service.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
build 出来 780 MB。CI 里推到公司内网 Harbor,千兆网络,每次 docker push 要 3 分 20 秒,K8s 每个节点第一次拉镜像还要再等。而且每次改一行代码,推的都是整个 780 MB。
2 月份我把这块重做了一遍,最终镜像 156 MB,推送 38 秒。过程记一下。
先看这 780 MB 都是什么
$ docker history order-service:v1.2.3 --human --format "{{.Size}}\t{{.CreatedBy}}"
431MB /bin/sh -c #(nop) COPY dir:... in target/order-service.jar
349MB /bin/sh -c set -ex; ... apt-get install ... openjdk-8-jdk ...
2.1kB /bin/sh -c #(nop) EXPOSE 8080
基础镜像 349 MB,我们的 jar 431 MB。两头都能砍。
一、基础镜像:jdk 换成 jre,再换 slim
运行时不需要 javac、不需要调试工具,jre 就够了:
FROM openjdk:8u242-jre-slim
这一改基础镜像从 349 MB 降到 154 MB。
slim 是 Debian 的精简版。我也试过 alpine(只有 83 MB),但不推荐给 Java 用:alpine 用的是 musl libc 而不是 glibc,JDK 里有些依赖 glibc 的行为会出问题。我们踩到的是 DNS 解析——alpine 上的 JVM 对 /etc/resolv.conf 的处理有差异,服务注册到 Nacos 时偶发 "UnknownHostException",排查了整整一天。最后放弃。
如果机器上装了字体相关的东西(比如验证码、Excel 导出),slim 版没有字体库,要补:
RUN apt-get update && apt-get install -y --no-install-recommends \
fontconfig fonts-dejavu \
&& rm -rf /var/lib/apt/lists/*
我们报表服务就加了这两行,多了 12 MB。
二、分层:让依赖层能复用
一个 Spring Boot fat jar 里面,BOOT-INF/lib 的依赖通常占 90% 以上,业务代码只占很小一块。而我们改一次代码就要重推整个 jar,非常亏。
Docker 镜像是分层的,只要某一层的内容没变,这一层就可以复用缓存、不用重传。所以正确的做法是把 jar 拆开,按变动频率分层:
# 构建阶段:解开 fat jar
FROM openjdk:8u242-jdk-slim AS builder
WORKDIR /app
COPY target/order-service.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
# 运行阶段
FROM openjdk:8u242-jre-slim
WORKDIR /app
# 顺序很重要:越不常变的越靠前
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
-Djarmode=layertools 是 Spring Boot 2.3 加的能力,会把 jar 拆成 dependencies、spring-boot-loader、snapshot-dependencies、application 四个目录。用这个需要先依赖 spring-boot-maven-plugin 并开启分层:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<layers>
<enabled>true</enabled>
</layers>
</configuration>
</plugin>
如果还在 2.2 上(我们大部分服务当时是),可以手动解压,效果差不多:
FROM openjdk:8u242-jdk-slim AS builder
WORKDIR /app
COPY target/order-service.jar app.jar
RUN mkdir -p extracted && cd extracted && unzip -q ../app.jar
FROM openjdk:8u242-jre-slim
WORKDIR /app
COPY --from=builder /app/extracted/BOOT-INF/lib ./BOOT-INF/lib
COPY --from=builder /app/extracted/META-INF ./META-INF
COPY --from=builder /app/extracted/org ./org
COPY --from=builder /app/extracted/BOOT-INF/classes ./BOOT-INF/classes
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
分层之后的推送数据,改一行业务代码:
| 层 | 大小 | 是否重传 |
|---|---|---|
| 基础镜像 jre-slim | 154 MB | 否(Harbor 上已有) |
| BOOT-INF/lib | 78 MB | 否,依赖没变 |
| META-INF + loader | 24 KB | 否 |
| BOOT-INF/classes | 1.9 MB | 是 |
实际推送 1.9 MB,38 秒里大部分时间是在跟 Harbor 协商。
三、JVM 容器感知,这个不配会出问题
这是最要命的一项,而且不配的话不会报错,只会莫名其妙 OOM。
JDK 8 的早期版本(8u131 之前)完全不认识 cgroup。你在 K8s 里给 Pod 限制 2 GB 内存,JVM 看到的却是宿主机的 64 GB,于是按 64 GB 的规则来设默认堆(1/4,也就是 16 GB)。等堆真的涨到 16 GB,Pod 早就被 OOMKilled 了。
8u191 之后默认开启了 UseContainerSupport,会读 cgroup 的限制。现在配法是这样:
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:InitialRAMPercentage=50.0", \
"-XX:+UseG1GC", \
"-XX:+HeapDumpOnOutOfMemoryError", \
"-XX:HeapDumpPath=/dumps", \
"-Xloggc:/logs/gc.log", \
"-Djava.security.egd=file:/dev/./urandom", \
"org.springframework.boot.loader.JarLauncher"]
几个参数的说明:
MaxRAMPercentage=75.0:堆最多占容器限制的 75%。留 25% 给元空间、线程栈、堆外内存(Netty 的 DirectBuffer、压缩类空间)。我们之前写死-Xmx2g配 2 GB 的 Pod 限制,被 kill 过好几次,就是因为堆外内存没算进去。InitialRAMPercentage=50.0:初始堆给一半,让 JVM 启动时不用现申请内存,能快个几百毫秒。配成 100 的话就没有弹性了。-Djava.security.egd:Tomcat 生成 Session ID 用的随机数源,默认是阻塞的/dev/random,容器里熵池小会导致启动卡住几十秒。这个坑很经典。
CPU 也有类似的坑。Runtime.availableProcessors() 在容器里会返回宿主机的核数,导致各类线程池(Tomcat 的、ForkJoinPool 的、GC 线程数)全都按宿主机核数开。8u191 之后 UseContainerSupport 会一起处理,但也可以用 -XX:ActiveProcessorCount=2 显式指定。
验证一下有没有生效:
$ kubectl exec -it order-service-7d9f8b-xxxx -- sh -c "java -XX:+PrintFlagsFinal -version | grep -iE 'MaxHeapSize|ActiveProcessor'"
size_t MaxHeapSize = 1610612736 {product} {ergonomic}
uintx ActiveProcessorCount = 2 {product} {command line}
Pod 限制 2 GB,MaxHeapSize 是 1.6 GB(75% 接近值,实际算法是按 2147483648 × 0.75 再往下取整到页),容器感知生效了。
另外几个必做项
时区。slim 镜像默认 UTC,我们日志时间全差 8 小时:
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
别用 root 跑。安全部门的要求:
RUN groupadd -g 1000 app && useradd -u 1000 -g app -s /sbin/nologin -M app
USER app
注意如果用了 USER app,挂载的 volume 目录权限要提前 chmod,否则写日志会 Permission denied。
.dockerignore。不加的话 COPY target/*.jar 会把整个 target 目录(含 4000 多个 class 文件)塞进构建上下文:
target/classes/
target/test-classes/
target/surefire-reports/
.git/
.idea/
*.log
加了之后构建上下文从 89 MB 降到 43 MB。
最终的 Dockerfile 和收效
FROM openjdk:8u242-jdk-slim AS builder
WORKDIR /app
COPY target/order-service.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM openjdk:8u242-jre-slim
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone \
&& groupadd -g 1000 app && useradd -u 1000 -g app -s /sbin/nologin -M app
WORKDIR /app
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
USER app
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", \
"-XX:InitialRAMPercentage=50.0", "-XX:+UseG1GC", \
"-XX:+HeapDumpOnOutOfMemoryError", "-XX:HeapDumpPath=/dumps", \
"-Djava.security.egd=file:/dev/./urandom", \
"org.springframework.boot.loader.JarLauncher"]
| 指标 | 改之前 | 改之后 |
|---|---|---|
| 镜像大小 | 780 MB | 156 MB |
| 改代码后推送耗时 | 3 分 20 秒 | 38 秒 |
| 节点首次拉取 | 约 2 分钟 | 约 25 秒 |
| 容器启动到 Ready | 21 秒 | 13 秒 |
小结
- 基础镜像用
jre-slim不用jdk,省 195 MB。alpine 体积小但有 musl libc 的坑,踩过 DNS 问题之后我不再用它。 - 分层的核心是把不常变的
BOOT-INF/lib放前面。Boot 2.3 有layertools,2.2 手动 unzip 也行。 - 必须配
UseContainerSupport加MaxRAMPercentage。写死-Xmx等于放弃了弹性,也容易算漏堆外内存被 OOMKilled。 -XX:HeapDumpPath指向挂载的 volume,容器挂掉之后 dump 文件才拿得出来。这个救过我两次。- 时区、非 root 用户、
.dockerignore这三项属于"不加迟早出事"的类别,一次性补上。
补充一句,镜像瘦身之后我才发现,原先 431 MB 的 jar 里有 3 个没用上的依赖(一个 netty-all 就 2.3 MB)。mvn dependency:analyze 能扫出来,值得一跑。