Administrator
发布于 2020-02-15 / 568 阅读
4

Docker 打包 Spring Boot 镜像的最佳实践

一个 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 拆成 dependenciesspring-boot-loadersnapshot-dependenciesapplication 四个目录。用这个需要先依赖 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-slim154 MB否(Harbor 上已有)
BOOT-INF/lib78 MB否,依赖没变
META-INF + loader24 KB
BOOT-INF/classes1.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 MB156 MB
改代码后推送耗时3 分 20 秒38 秒
节点首次拉取约 2 分钟约 25 秒
容器启动到 Ready21 秒13 秒

小结

  • 基础镜像用 jre-slim 不用 jdk,省 195 MB。alpine 体积小但有 musl libc 的坑,踩过 DNS 问题之后我不再用它。
  • 分层的核心是把不常变的 BOOT-INF/lib 放前面。Boot 2.3 有 layertools,2.2 手动 unzip 也行。
  • 必须配 UseContainerSupportMaxRAMPercentage。写死 -Xmx 等于放弃了弹性,也容易算漏堆外内存被 OOMKilled。
  • -XX:HeapDumpPath 指向挂载的 volume,容器挂掉之后 dump 文件才拿得出来。这个救过我两次。
  • 时区、非 root 用户、.dockerignore 这三项属于"不加迟早出事"的类别,一次性补上。

补充一句,镜像瘦身之后我才发现,原先 431 MB 的 jar 里有 3 个没用上的依赖(一个 netty-all 就 2.3 MB)。mvn dependency:analyze 能扫出来,值得一跑。

参考