低版本Java和Docker不是天然的兼容,具体表现在 Docker可以设置内存限制和CPU限制,而低版本Java却不能自动检测到。
docker run -m 1024M --cpuset-cpus= "1,3"
对于上容器大家都是非常积极的态度,在战略上藐视敌人,在战术上重视敌人。在部门内开展了Docker的学习以及模拟容器环境下可能出现的问题,充分做到兵马未动,粮草先行。
Java8u_131之前,需要在容器中通过设置 -Xmx来限制堆大小避免容器内kill掉进程,并同时显示设定并行GC的线程数和JIT编译并行数:
-Xmx=2G -XX:ParallelGCThreads=cpus -XX:CICompilerCount=cpus
Java8u_131已经可以正确识别Docker设定的CPU资源限制,不用再显式设定ParallelGCThreads和 CICompilerCount,而内存方面的识别还需要以下参数:
-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap
Java8u_191为适配Docker容器新增三个参数InitialRAMPercentage、 MinRAMPercentage、MaxRAMPercentage分别代表初始JVM初始内存占比、最小和最大内存占比。这样做的好处是显而易见的,当容器资源发生内存和CPU扩容的时候,不需要修改启动JVM的脚本。
Java11开始可以使用 -XX:UseContainerSupport 来保证JVM可以获取到正确的CPU数量和内存限制。并且这个参数是默认开启的,如果要关闭可以通过 -XX:-UseContainerSupport选项禁用。
综合看来Java11是真香,自带ZGC,并且是长期支持版本。由于一些历史原因,电商依然有80%的项目的运行在Java8的131版本之上,5%运行在Java8的065版本之上,15%的项目是运行在Java7之上。考虑盲目升级Java大版本可能会出现一些大的改动,这不是我们所想遇见的,最终决定了决定还是用Java8最新的191版本。
系统日志
程序日志
COPY nxlog.conf /etc/
CMD /usr/bin/nxlog && java $JAVA_OPTS -jar /app.jar
之家云平台的JVM的监控体系还在起步阶段。根据墨菲定律,如果你担心什么事会发生,那么它在将来一定会发生。在Java应用运行的生命周期内一定会出现各种异常情况,这时候就需要登录容器内部排查问题。便捷性和完善性将是考虑的核心。distroless镜像不支持bash,所以无法使用top,jstat等jvm工具查看Java进程的运行状态,也无法dump。
鹰眼日志收集平台使用的收集工具nxlog在Alpine镜像上无法很好的运行。
考虑现在是微服务的链路监控系统skywalking插装使用的jar包以及Java性能诊断工具Arthas的jar包可以放在Runtime层,这样就避免每个工程项目都自带一份jar。
FROM xxxx.autohome.com.cn/project/centos7-jdk8:full-nxlog
ADD skywalking-agent/6.4.0/ /skywalking-agent/
ADD arthas-3.1.4/ /arthas/
RUN echo 'Asia/Shanghai' > /etc/timezone && chown -R root:root "/var/run/nxlog" && chown -R root:root "/var/spool/nxlog" && mkdir -p /usr/local/nxlog-config/data
MAINTAINER fangli@autohome.com.cn
docker build -t xxxx.autohome.com.cn/project/centos7-jdk8:v1 <Dockerfile Path>
docker push xxx.autohome.com.cn/project/centos7-jdk8:v1
FROM xxxx.autohome.com.cn/project/centos7-jdk8:v1
FROM maven:3.5-jdk-8 AS build
COPY src /usr/src/app/src
COPY pom.xml /usr/src/app
RUN mvn -f /usr/src/app/pom.xml clean package
FROM openjdk:8-jre
ARG DEPENDENCY=/usr/src/app/target/dependency
COPY --from=build ${DEPENDENCY}/BOOT-INF/lib /app/lib
COPY --from=build ${DEPENDENCY}/META-INF /app/META-INF
COPY --from=build ${DEPENDENCY}/BOOT-INF/classes /app
ENTRYPOINT ["java","-cp","app:app/lib/*","hello.Application"]
编译命令 mvn clean package在Dockerfile里。在之家云平台上都是不同的环境都是对应不同的流水线,每个流水线的编译节点的脚本才会带上不同的环境变量。云平台制作镜像的时候默认选择的是项目根目录下的Dockerfile进行编译。虽然可以通过建立多个profile下不同的Dockerfile,然后在编译阶段将不同环境的Dockerfile 拷贝到根目录。但是这种方式会产生Dockerfile冗余。
多阶段构建镜像每次在构建的时候都会在mvn执行的时候会从nexus服务下载项目依赖的jar包,需快的项目编译1-2分钟可以搞定,对于一些大型项目,编译时间超过10分钟都是有可能的,这就导致了在一些紧急情况需要上线修复的情况下,无法满足快速交付的需要。
依赖项:它们占了很大的一部分比例,但很少发生改动。大多数时候我们只会修改我们的代码,很少会去修改依赖项。但依赖项每次都会被拷贝到发布版本中。
资源文件:这个问题跟依赖项差不多。虽然资源文件(HTML、CSS、图像、配置文件等等)比依赖项更经常发生改动,但比起代码还是相对少一些。它们也会被拷贝到发布版本中。
代码:代码只占 JAR 包很小的一部分(通常 300KB 到 2MB),但会经常发生改动。
经常发生改动的代码只有几 MB,但每次都需要拷贝所有的依赖项和资源文件,这是对存储空间、带宽和时间的一种浪费。Jib利用 Docker 镜像的分层机制,就像已经分好的 OS层 和 Runtime 层那样。更进一步引入了依赖项层、资源文件层和代码层,按照改动的频繁程度来安排这些层的次序。
使用jib制作的镜像和Dockerfile方式的镜像大小对比:
使用jib可以显著的减少镜像的体积,但是在实际生产中却是不可用的:
从jib构建流程的图里也可以看到,jib是直接推送到镜像服务器里的,这样就会导致研发可能会在本地开发环境推送不可用的镜像到服务器里,会极大的浪费了服务器存储资源。
直接推送到镜像服务器里,与云平台的流水线的CI、CD概念违背。
CMD java $JAVA_OPTS -jar /app.jar
在云原生的架构下,应用程序运行在K8S的Pod实例的容器里,Kubernetes提供了两种探针来检查容器的状态,存活探针Liveness和就绪探针Readiness。
Liveness探针是为了查看容器是否正在运行,目的是让Kubernetes知道你的应用是否活着。定时检查运行是否正常,如果你的应用还活着,那么Kubernetes就让它继续存在。如果你的应用程序已经死了,Kubernetes将重启容器。Liveness探针职责是在容器运行期。
Readiness探针是为了查看容器是否准备好接受HTTP请求。就绪探针旨在让Pod实例知道你的容器是否准备好为请求提供服务。只有在就绪探针通过才会通过负载均衡将流量转发到容器。如果就绪探针检测失败,Kubernetes将停止向该容器发送流量,直到它通过。Readiness探针的职责则是在应用服务运行之前。除了初始决定加入负载列表外,Readiness也会持续探测,在检测失败次数达到条件时会将容器从负载列表暂时摘除,摘除出也会持续探测达到条件重新加回来。
健康检测机制其实是在依赖倒置原则下设立的,虽然集群根据具体容器的探测结果来负责重启容器/上下负载,但是最终决定容器生死和服务状态的其实还是容器应用自身。容器应用还是应该仔细规划两个探针返回健康状态的逻辑,以达到利用健康检查机制执行自动化运维的目的。对于上容器的应用我们建议设置独立的健康检查:
若因依赖方、调用方问题导致的业务不可用但还在不断重试且可恢复, 只需要将【就绪检测】置失败状态;
若进入了不可恢复的状态, 还是需要设置【存活检测】为失败以通知集群重启容器;
若应用已直接判断出容器重启即可解决问题,应用也可直接退出运行的方式让集群重新拉起容器。
另外之家云容器平台也有规划,将当前的健康检查拆分为就绪检测和存活检测两套接口,且提供更丰富的机制与应用进行交互以及方便故障定位等。
就具体实现而言,如果RPC框架使用的是Dubbo,Dubbo在解析到<dubbo:service />时就会打开端口对外提供服务,有些服务需要一定的预热时间,比如初始化缓存,等待相关资源就位等,如果此时请求进来,则会报错。在Dubbo-2.6.5 之前版本 可以通过在提供的服务的配置文件上增加 delay="-1",将暴露服务延迟到Spring初始化之后;或者 delay="3000" 等spring初始化之后再延长3s。Dubbo-2.6.5 及以后版本,默认所有的服务都在Spring完成初始之后对外暴露。
对于一些特定的Readiness检查项目,可以使用蚂蚁金服的SofaStack框架提供的healthcheck相关包来实现:
<dependency>
<groupId>com.alipay.sofa</groupId>
<artifactId>healthcheck-sofa-boot</artifactId>
<version>3.2.0</version>
</dependency>
扩展 HealthChecker 接口实现自己的检查项目,通过/actuator/readiness路径来获取应用的 Readiness Check 的状况。
程序安全退出
K8S为容器生命周期提供了preStop钩子,也就是在准备关闭pod时会调用的。这个接口调用是至少一次,有可能调用多次,需要做好幂等处理。之家云平台主要通过http接口的方式进行preStop的处理。
Dubbo 是通过 JDK 的 Runtime的ShutdownHook 来完成优雅停机的。SpringBoot 1.x 版本的spring-boot-starter-actuator 模块提供了一个 restful 接口,用于优雅停机。添加配置:
endpoints.shutdown.enabled=true #启用shutdown endpoint的HTTP访问
endpoints.shutdown.sensitive=false #不需要验证
生产中请注意该端口需要设置权限,如配合 spring-security 使用。执行
curl -X POST host:port/shutdown
指令,关闭成功便可以获得如下的返回:
{"message":"Shutting down, bye..."}
SpringBoot 2.x的版本没有提供默认的优雅停机的方案,开发者需要自己实现,核心思想是关闭应用服务器的接收请求的线程池。
需要注意事项:
用粗暴延时方式执行preStop会延长应用的结束时间,会延缓升级进程。
preStop返回的任何结果,集群并不参考。集群只根据preStop调用是否已完成来决定,preStop调用一旦完成,即会发送结束信号给容器。
preStop只有在teminated时才会被调用,就是集群主动杀容器时。如果容器因为各种原因自己退出运行以及其它难以处理的异常时是不会激活此调用的,所以不可将工作完全寄托于此机制。参见 https://github.com/kubernetes/kubernetes/issues/55807
另外preStop也不严格保证只调用一次。
如果没有配置preStop的接口,就一定要保证程序以pid=1的方式运行。
乱码问题
ENV LANG="zh_CN.UTF-8"
RUN echo 'Asia/Shanghai' > /etc/timezone
SLF4J: Class path contains multiple SLF4J bindings.
SLF4J: Found binding in [jar:file:/app/tomcat/webapps/ROOT/WEB-INF/lib/log4j-slf4j-impl-2.8.2.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: Found binding in [jar:file:/app/tomcat/webapps/ROOT/WEB-INF/lib/slf4j-log4j12-1.6.4.jar!/org/slf4j/impl/StaticLoggerBinder.class]
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
</exclusion>
</exclusions>
容器内启动了tomcat/nginx/flask等服务,直接把文件放在这些服务器的服务目录内不就可以下载了。
在容器外能控制的服务器上提供个能上传的ftp/http服务,在容器里使用curl进行上传操作。
# 使用curl进行ftp上传操作示例
curl -T project.hprof ftp://username:password@ftp.server.com/dump/project.hprof
# 使用curl进行http form 上传示例
curl -F ‘data=@path/to/local/file’ UPLOAD_ADDRESS
Docker本身提供了两种终止容器运行的方式,即docker stop与docker kill。docker kill命令不会给容器中的应用程序有任何gracefully shutdown的机会,它会直接发出SIGKILL的系统信号,以强行终止容器中程序的运行,类似虚拟机的kill -9 。在docker stop命令执行的时候,会先向容器中PID为1的进程发送系统信号SIGTERM,然后等待容器中的应用程序终止执行,如果等待时间达到设定的超时时间,或者默认的10秒,会继续发送SIGKILL的系统信号强行kill掉进程。在容器中的应用程序,可以选择忽略和不处理SIGTERM信号,不过一旦达到超时时间,程序就会被系统强行kill掉,因为SIGKILL信号是直接发往系统内核的,应用程序没有机会去处理它。因此我们可以想办法让 JVM 应用以 PID 1 运行即可。
启动命令前的脚本:
CMD java $JAVA_OPTS -jar /app.jar && /usr/bin/nxlog
CMD ["java","$JAVA_OPTS","-jar","/app.jar"]