SpringCloud 容器化

前言

容器化技术的出现标准化了服务的基础设施,统一了应用的打包分发、部署及操作系统相关类库等,解决了测试及生产部署时环境差异的问题,更方便分析排查问题。对运维来说,由于镜像的不可变性,更容易进行服务部署升级及回滚。另外利用诸如 Kubemetes 之类的容器管理平台,更容易实现一键部署、扩容、缩容等操作,更能将微服务架构、DevOps、不可变基础设施的思想落地下来。本文重点讲述 Spring Cloud 如何使用 Docker 实现容器化。

Java 服务 Docker 化

基础镜像选择

操作系统层面,可以选择传统的 Centos、Ubuntu 或者轻量级的 Alpine。其中 Ubuntu 16.04 版本的镜像大小约为 113M,压缩后大约 43M;Centos 7 版本的镜像大小约为 199M,压缩后大约为 73M;而 Alpine 3.7 版本镜像大小约为 4.15M,压缩后约为 2M。关于基础镜像的选择,一个是考虑镜像大小,一个是只提供最小的依赖包。关于第二点,不同的服务应用依赖包是不同的,这里不再展开,只从镜像大小角度考虑的话,Alpine 是首选,镜像小,远程推拉镜像的速度快,更为方便,这里建议釆用 Alpine 镜像作为基础镜像。从 Docker 镜像分层缓存的机制来考虑,如果选择了比较大的基础镜像,DockerFile 编写时可以适当分层,然后集中在几台镜像打包机上处理镜像打包及上传,这样可以充分利用打包机镜像分层缓存的机制,减少上传镜像的耗时。但是对于分布式服务的 Docker 部署,目标服务实例部署的机器比较多而且是随机的,就没办法利用这个机制来加快镜像下载速度。

DockerFile 编写

选择 Alpine 有个麻烦的地方就是 Alpine 采用的是 musl libc 的 C 标准库,而 Oracle JDK 或 OpenJDK 提供的版本则主要是以 glibc 为主,虽然 OpenJDK 在一些早期版本会放出使用 musl libc 编译好的版本,不过在正式发布的时候,并没有单独的 musl libc 编译版本可以下载,需要自己单独编译,稍微有些不便。因此可以考虑在 Alpine 里加上 glibc,然后添加 glibc 的 JDK 编译版本作为基础镜像。

Alpine + glibc

下述的 DockerFile 中,选择 Alpine 3.7 版本,glibc 釆用 Sgerrand 开源的 glibc 安装包,版本为 2.27-r0,该镜像可以作为后面的 JDK 镜像 的基础镜像。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
FROM alpine:3.7
MAINTAINER example <example@gmail.com>
RUN apk add --no-cache ca-certificates curl openssl binutils xz tzdata \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \
&& GLIBC_VER="2.27-r0" \
&& ALPINE_GLIBC_REPO="https://github.com/sgerrand/alpine-pkg-glibc/releases/download" \
&& curl -Ls ${ALPINE_GLIBC_REPO}/${GLIBC_VER}/glibc-${GLIBC_VER}.apk > /tmp/${GLIBC_VER}.apk \
&& apk add --allow-untrusted /tmp/${GLIBC_VER}.apk \
&& curl -Ls https://www.archlinux.org/packages/core/x86_64/gcc-libs/download > /tmp/gcc-libs.tar.xz \
&& mkdir /tmp/gcc \
&& tar -xf /tmp/gcc-libs.tar.xz -C /tmp/gcc \
&& mv /tmp/gcc/usr/lib/libgcc* /tmp/gcc/usr/lib/libstdc++* /usr/glibc-compat/lib \
&& strip /usr/glibc-compat/lib/libgcc_s.so.* /usr/glibc-compat/lib/libstdc++.so* \
&& curl -Ls https://www.archlinux.org/packages/core/x86_64/zlib/download > /tmp/libz.tar.xz \
&& mkdir /tmp/libz \
&& tar -xf /tmp/libz.tar.xz -C /tmp/libz \
&& mv /tmp/libz/usr/lib/libz.so* /usr/glibc-compat/lib \
&& apk del binutils \
&& rm -rf /tmp/${GLIBC_VER}.apk /tmp/gcc /tmp/gcc-libs.tar.xz /tmp/libz /tmp/libz.tar.xz /var/cache/apk/*

这里有几点需要注意:

  • 由于 Docker 镜像采用的是分层机制,因此安全类库或软件的命令最好在同一行命令中,减少分层,以降低最后镜像的大小
  • 命令中间安装了类库或软件包,需要在同一行命令中删除 apk 的 cache,这样才能有效删除 apk,以减少镜像大小
  • 这里安装了 openssl、curl、xz、tzdata 库,同时把 timezone 改为了 Asia/Shanghai
  • 构建镜像的命令为:docker build -f /usr/local/DockerFile-Alpine-Glibc -t alpine-3.7:glibc-2.27-r0 .,其中 /usr/local/DockerFile-Alpine-Glibc 是 DockerFile 的文件路径
  • 由于构建镜像的过程比较慢,这里给出阿里云上已构建好的镜像(alpine + glibc),可以直接拉取到本地来使用,命令如下:
1
2
# 拉取镜像
# docker pull registry.cn-hangzhou.aliyuncs.com/springcloud-cn/alpine-3.7:glibc-2.27-r0

Alpine + glibc + JDK8

对于 JDK 版本的选择,有 Oracle 的 Hotspot JDK,也有 OpenJDK。对于 Oracle 的 JDK,个人使用及非商业使用是免费的,而对于商业使用来说,需进行企业订阅,在 2019 年 1 月之后才能继续获得 Java SE8 更新。Oracle 已经建议选择不订阅或不继续订阅的公司在订阅结束之前,把 JDK 版本迁移到 OpenJDK,以确保相关应用程序不受影响。下述的 JDK 8 版本釆用 Oracle 的 server-jre-8ul72 版本,而对于 JDK 9、10 及 11 版本,则釆取 OpenJDK 来构建。附上 OpenJDK 的官方下载地址

1
2
3
4
5
6
FROM registry.cn-hangzhou.aliyuncs.com/springcloud-cn/alpine-3.7:glibc-2.27-r0
MAINTAINER example <example@gmail.com>
ADD server-jre-8u172-linux-x64.tar.gz /opt/
RUN chmod +x /opt/jdk1.8.0_172
ENV JAVA_HOME=/opt/jdk1.8.0_172
ENV PATH="$JAVA_HOME/bin:${PATH}"

Alpine + glibc + JDK9

1
2
3
4
5
6
FROM registry.cn-hangzhou.aliyuncs.com/springcloud-cn/alpine-3.7:glibc-2.27-r0
MAINTAINER example <example@gmail.com>
ADD openjdk-9u181_linux-x64_bin.tar.gz /opt/
RUN chmod +x /opt/jdk-9
ENV JAVA_HOME=/opt/jdk-9
ENV PATH="$JAVA_HOME/bin:${PATH}"

Alpine + glibc + JDK10

1
2
3
4
5
6
FROM registry.cn-hangzhou.aliyuncs.com/springcloud-cn/alpine-3.7:glibc-2.27-r0
MAINTAINER example <example@gmail.com>
ADD openjdk-10.0.1_linux-x64_bin.tar.gz /opt/
RUN chmod +x /opt/jdk-10.0.1
ENV JAVA_HOME=/opt/jdk-10.0.1
ENV PATH="$JAVA_HOME/bin:${PATH}"

Alpine + glibc + JDK11

1
2
3
4
5
6
FROM registry.cn-hangzhou.aliyuncs.com/springcloud-cn/alpine-3.7:glibc-2.27-r0
MAINTAINER example <example@gmail.com>
ADD openjdk-11+28_linux-x64_bin.tar.gz /opt/
RUN chmod +x /opt/jdk-11
ENV JAVA_HOME=/opt/jdk-11
ENV PATH="$JAVA_HOME/bin:${PATH}"

阿里云上有已构建好的不同版本的 JDK 镜像,拉取到本地就可以直接使用:

1
2
3
4
5
6
7
8
9
10
11
# 基于 Oracle JDK 8 构建的镜像
# docker pull registry.cn-hangzhou.aliyuncs.com/springcloud-cn/java:8u172-jre-alpine

# 基于 OpenJDK 9 构建的镜像
# docker pull registry.cn-hangzhou.aliyuncs.com/springcloud-cn/java:openjdk-9u181-alpine

# 基于 OpenJDK 10 构建的镜像
# docker pull registry.cn-hangzhou.aliyuncs.com/springcloud-cn/java:openjdk-10.0.1-alpine

# 基于 OpenJDK 11 构建的镜像
# docker pull registry.cn-hangzhou.aliyuncs.com/springcloud-cn/java:openjdk-11-ea19-alpine

Maven 构建与发布镜像

构建镜像的 Maven 插件

主流的几款 Docker 的 Maven 插件:

maven-docker-plugin

这里以 Maven 构建为例,选用的是 com.spotify 的插件,其 Maven 的 POM 配置如下。使用 spring-boot-maven-plugin 的 1.4.3 版本,另外设置的镜像前缀为 registry.cn-hangzhou.aliyuncs.com/springcloud-cn,tag 为 $(project.version), repository(私有仓库地址)为 ${docker.image.prefix}/${project.artifactId},另外这里还传递了一个 Docker 的 buildArgJAR_FILE,其值为 $(project.build.finalName) .jarusernamepassword 标签是指访问私有仓库的用户名和密码,若不需要身份认证,则可以注释这两个标签。点击下载完整的示例代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<dockerfile.maven.version>1.4.3</dockerfile.maven.version>
<docker.image.prefix>registry.cn-hangzhou.aliyuncs.com/springcloud-cn</docker.image.prefix>
</properties>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.3.RELEASE</version>
</parent>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Finchley.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/libs-milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>com.spotify</groupId>
<artifactId>dockerfile-maven-plugin</artifactId>
<version>${dockerfile.maven.version}</version>
<executions>
<execution>
<id>default</id>
<goals>
<goal>build</goal>
<goal>push</goal>
</goals>
</execution>
</executions>
<configuration>
<skipPush>true</skipPush>
<!-- <username>admin</username> -->
<!-- <password>123456</password> -->
<repository>${docker.image.prefix}/${project.artifactId}</repository>
<tag>${project.version}</tag>
<buildArgs>
<JAR_FILE>${project.build.finalName}.jar</JAR_FILE>
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>

Maven 构建镜像的 DockFile

Maven 项目的 DockerFile 内容如下,特别注意,DockerFile 需要放在 IDEA 里的某个应用(模块)的根目录下。例如 gateway-server 模块需要打包,并发布构建到 Docker 镜像里,那么 DockerFile 此时应该放在 gateway-server 模块的根目录下,不同的应用(模块)可以拥有自己的 DockerFile。下面的 registry.cn-hangzhou.aliyuncs.com/springcloud-cn/java:8u172-jre-alpine 是指私有仓库里已构建好的 JDK 镜像。

1
2
3
4
5
6
FROM registry.cn-hangzhou.aliyuncs.com/springcloud-cn/java:8u172-jre-alpine
ARG JAR_FILE
ENV PROFILE default
ADD target/${JAR_FILE} /opt/app.jar
EXPOSE 8080
ENTRYPOINT java ${JAVA_OPTS} -Djava.security.egd=file:/dev/./urandom -Duser.timezone=Asia/Shanghai -Dfile.encoding=UTF-8 -Dspring.profiles.active=${PROFILE} -jar /opt/app.jar

Maven 打包构建镜像

执行下述的 Maven 打包构建命令(跳过单元测试),成功后会在本地构建生成新的 Docker 镜像,如果上面的 POM 配置了 <skipPush>false</skipPush>,会自动将新的镜像 Push 到私有仓库。

1
$ mvn clean package -Dmaven.test.skip=true

Maven Push 镜像

Maven 手动 Push 镜像到 私有仓库:

1
2
3
4
5
# 第一种方式:不使用身份认证或者使用POM配置里的私有仓库账号进行Push
$ mvn dockerfile:push

# 第二种方式:使用指定的私有仓库账号进行Push
$ mvn dockerfile:push -Ddockerfile.username=xxx -Ddockerfile.password=xxx

Maven 运行镜像

执行以下命令运行镜像,实际项目中可以根据项目需要调整对应的 JVM 参数:

1
2
3
4
# docker run -p 8080:8080 --rm \
-e JAVA_OPTS='-server -Xmx1g -Xms1g -XX:MetaspaceSize=64m -verbose:gc -verbose:sizes -XX:+UseG1GC -XX:MaxGCPauseMillis=50 -XX:+UnlockDiagnosticVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/ -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -Xloggc:/opt/gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=20M -Djava.io.tmpdir=/tmp' \
-e PROFILE='default' \
registry.cn-hangzhou.aliyuncs.com/springcloud-cn/gateway:1.0-SNAPSHOT

JDK 8+ 的 Docker 资源限制支持

JDK 8 && JDK 9

Java 8ul31 及以上版本开始支持了 Docker 的 CPU 和 Memory 限制。对于 CPU 的限制,如果 JVM 没有显式指定 -XX: ParalllelGCThreads 或者 -XX: CICompilerCount,那么 JVM 会使用 Docker 的 CPU 限制。如果 Docker 有指定 CPU Limit,JVM 参数也有指定 -XX: ParalllelGCThreads 或者 -XX: CICompilerCount,那么最终以指定的 JVM 参数为准。对于 Memory 限制,需要加上 -XX: +UnlockExperimentalVMOptions-XX: +UseCGroupMemoryLimitForHeap 才能使得 Xmx 感知 Docker 的 Memory Limit。

JDK 10

JDK 10 版本废弃了 UseCGroupMemoryLimitForHeap,同时新引入了新配置 ActiveProcessorCount,可以用来强制指定 CPU 的个数。

JDK 11

JDK 11 正式移除 UseCGroupMemoryLimitForHeap,同时新引入 UseContainerSupport 配置,默认为 ture,即默认支持 Docker 的 CPU 及 Memory 限制,也可以设置为 false 来禁用容器支持。

JDK 9+ 镜像优化

JDK9 及以上的版本与之前的版本有一个比较大的变动,就是 JDK9 及以上的版本支持模块系统 JPMS,同时 JDK 自身也模块化了,里面的 Modular Run-Time Images 功能特性以及 jlink 工具对于镜像的优化非常有帮助,可根据所需模块来精简 JDK。

Jlink 工具可以用来将已有的 JDK 按所需模块进行优化,并重新组装成一个自定义的 runtime image,其基本语法如下:

jlink [options] --module-path modulepath --add-modules module [,module...]

其中 module-path 参数用于指定需要 Jlink 的 JDK 的 jmods 路径,options 的部分参数说明如下:

  • add-mobules,用来指定所需要的模块名称,比如 java.xml
  • compress,用来指定压缩级别,0 为不压缩,1 为常量字符串共享,2 为 Zip 压缩
  • no-hreader-files,表示排除掉 header 文件
  • output,指定输出精简后的 JDK 的文件夹路径

创建对应 Dockerfile,配置内容如下,其中指定了需要依赖的 JDK 模块,目的是通过 Jlink 生成精简的 JDK,点击下载完整的示例代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
FROM registry.cn-hangzhou.aliyuncs.com/springcloud-cn/java:openjdk-10.0.1-alpine as packager

# jlink tool
RUN /opt/jdk-10.0.1/bin/jlink \
--module-path /opt/jdk-10.0.1/jmods \
--verbose \
--add-modules java.base,java.logging,java.xml,jdk.unsupported,java.sql,java.desktop,java.management,java.naming,java.instrument,jdk.jstatd,jdk.jcmd,jdk.management \
--compress 2 \
--no-header-files \
--output /opt/jdk-10-jlinked

# copy jdk after jlink
FROM registry.cn-hangzhou.aliyuncs.com/springcloud-cn/alpine-3.7:glibc-2.27-r0
COPY --from=packager /opt/jdk-10-jlinked /opt/jdk-10.0.1
ENV JAVA_HOME=/opt/jdk-10.0.1
ENV PATH=$JAVA_HOME/bin:$PATH

# add application jar
ARG JAR_FILE
ENV PROFILE default
ADD target/${JAR_FILE} /opt/app.jar
EXPOSE 8080
ENTRYPOINT java ${JAVA_OPTS} -Djava.security.egd=file:/dev/./urandom -Duser.timezone=Asia/Shanghai -Dfile.encoding=UTF-8 -Dspring.profiles.active=${PROFILE} -jar /opt/app.jar

通过 Maven 打包构建镜像:

1
$ mvn clean package -Dmaven.test.skip=true

查看镜像的大小,可以发现精简后的 JDK 包括 app.jar,总大小在 100M 以内:

1
2
# docker images |grep gatewaydocker images |grep gateway
registry.cn-hangzhou.aliyuncs.com/springcloud-cn/gateway 1.0-SNAPSHOT 8f0c327e65a4 2 minutes ago 96MB

运行镜像:

1
2
3
4
# docker run -p 8080:8080 --rm \
-e JAVA_OPTS='-server -XX:+UseG1GC -XX:MaxGCPauseMillis=50 -XX:+UnlockDiagnosticVMOptions -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XX:ActiveProcessorCount=1 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/ -Xlog:age*,gc*=info:file=gc-%p-%t.log:time,tid,tags:filecount=5,filesize=10m -Djava.io.tmpdir=/tmp' \
-e PROFILE='default' \
registry.cn-hangzhou.aliyuncs.com/springcloud-cn/gateway:1.0-SNAPSHOT

查看精简后的 JDK 大小:

1
2
3
4
5
6
7
8
9
10
# 连接容器
# docker exec -it dreamy_golick /bin/sh

# 查看精简后的JDK大小
# du -sh /opt/jdk-10.0.1/
53.5M /opt/jdk-10.0.1/

# 查看应用Jar包的大小
# du -sh /opt/app.jar
22.2M /opt/app.jar