前言
容器化技术的出现标准化了服务的基础设施,统一了应用的打包分发、部署及操作系统相关类库等,解决了测试及生产部署时环境差异的问题,更方便分析排查问题。对运维来说,由于镜像的不可变性,更容易进行服务部署升级及回滚。另外利用诸如 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
| # docker pull registry.cn-hangzhou.aliyuncs.com/springcloud-cn/java:8u172-jre-alpine
# docker pull registry.cn-hangzhou.aliyuncs.com/springcloud-cn/java:openjdk-9u181-alpine
# docker pull registry.cn-hangzhou.aliyuncs.com/springcloud-cn/java:openjdk-10.0.1-alpine
# docker pull registry.cn-hangzhou.aliyuncs.com/springcloud-cn/java:openjdk-11-ea19-alpine
|
Maven 构建与发布镜像
构建镜像的 Maven 插件
主流的几款 Docker 的 Maven 插件:
这里以 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 的 buildArg
为 JAR_FILE
,其值为 $(project.build.finalName) .jar
。username
与 password
标签是指访问私有仓库的用户名和密码,若不需要身份认证,则可以注释这两个标签。点击下载完整的示例代码。
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> <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
| $ mvn dockerfile: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 工具
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 的文件夹路径
Jlink 使用案例
创建对应 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
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
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
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
# du -sh /opt/jdk-10.0.1/ 53.5M /opt/jdk-10.0.1/
# du -sh /opt/app.jar 22.2M /opt/app.jar
|