Java

Java

云原生之下的Java

尼古拉斯 发表了文章 • 0 个评论 • 185 次浏览 • 2019-05-30 10:22 • 来自相关话题

自从公司的运行平台全线迁入了 Kubenetes 之后总是觉得 DevOps 变成了一个比以前更困难的事情,反思了一下,这一切的困境居然是从现在所使用的 Java 编程语言而来,那我们先聊聊云原生。 Cloud Native 在我的理 ...查看全部
自从公司的运行平台全线迁入了 Kubenetes 之后总是觉得 DevOps 变成了一个比以前更困难的事情,反思了一下,这一切的困境居然是从现在所使用的 Java 编程语言而来,那我们先聊聊云原生。

Cloud Native 在我的理解是,虚拟化之后企业上云,现在的企业几乎底层设施都已经云化之后,对应用的一种倒逼,Cloud Native 是一个筐,什么都可以往里面扔,但是有些基础是被大家共识的,首先云原生当然和编程语言无关,说的是一个应用如何被创建/部署,后续的就引申出了比如 DevOps 之类的新的理念,但是回到问题的本身,Cloud Native 提出的一个很重要的要求,应用如何部署 这个问题从以前由应用决定,现在变成了,基础设施 决定 应用应该如何部署。如果你想和更多Kubernetes技术专家交流,可以加我微信liyingjiese,备注『加群』。群里每周都有全球各大公司的最佳实践以及行业最新动态

让我们回到一切的开始,首先云原生亦或者是 DevOps 都有一个基础的要求,当前版本的代码能够在任何一个环境运行,看起来是不是一个很简单的需求,但是这个需求有一个隐喻所有的环境的基础设施是一样的,显然不能你的开发环境是 Windows 测试环境 Debian 生产环境又是 CentOS 那怎么解决呢,从这一环,我们需要一个工具箱然后往这个工具箱里面扔我们需要的工具了。首先我们需要的就是 Cloud Native 工具箱中最为明显的产品 Docker/Continar,经常有 Java 开发者问我,Docker 有什么用,我的回答是,Docker 对 Java 不是必须的,但是对于其他的语言往往是如果伊甸园中的苹果一样的诱人,打个比方,一个随系统打包的二进制发行版本,可以在任何地方运行,是不是让人很激动,对于大部分的 Java 开发者可能无感,对于 C 语言项目的编写者,那些只要不是基于虚拟机的语言,他们都需要系统提供运行环境,而系统千变万化,当然开发者不愿意为了不同的系统进行适配,在以前我们需要交叉编译,现在我们把这个复杂的事情交给了 Docker,让 Docker 如同 Java 一样,一次编写处处运行,这样的事情简直就像是端了 Java 的饭碗,以前我们交付一个复杂的系统,往往连着操作系统一起交付,而客户可能买了一些商业系统,为了适配有可能还要改代码,现在你有了Docker,开发者喜大普奔,而这里的代价呢?C&C++&GO 他们失去的是枷锁,获得全世界,而 Java 如同被革命一般,失去了 Once Code,Everywhere Run,获得的是更大的 Docker Image Size,获得被人诟病的 Big Size Runtime。

当我们从代码构建完成了镜像,Cloud Navtive 的故事才刚刚开始,当你的 Team Leader 要求你的系统架构是 MicroServices 的,你把原来的项目进行拆分了,或者是开发的就拆分的足够小的时候,你发现因为代码拆分开了,出现了一点点的代码的重复,有适合也避免不了的,你的依赖库也变的 xN,隔壁 Go 程序员想了想,不行我们就搞个 .so 共享一部分代码吧,然后看了构建出来的二进制文件才 15MB,运维大手一挥,这点大小有啥要共享的,Java 程序员望了望了自己的 Jar 包,60MB 还行吧,维护镜像仓库的运维同事这个时候跑出来,你的镜像怎么有 150MB 了, 你看看你们把磁盘都塞满了,只能苦笑,运维小哥坑次坑次的给打包机加了一块硬盘,顺便问你马上部署了,你需要多大的配额,你说道 2C4G,运维一脸嫌弃的问你,为什么隔壁 Go 项目组的同事才需要 0.5C512MB。你当然也不用告诉他,SpringBoot 依赖的了 XXX,YYY,ZZZ 的库,虽然一半的功能你都没用到。

部署到线上,刚刚准备喘口气,突然发现新的需求又来了,虽然是一个很小的功能,但是和现在的系统内的任何一个服务都没有什么直接关联性,你提出再新写一个服务,运维主管抱怨道,现在的服务器资源还是很紧张,你尝试着用现在最流行的 Vertx 开发一个简单的 Web 服务,你对构建出来的 jar 只有 10MB 很满意,可是镜像加起来还是有 60 MB,也算一种进步,你找到 QA 主管,准备 Show 一下你用了 Java 社区最酷的框架,最强的性能,QA 主管找了一个台 1C2G 的服务让你压测一下,你发现你怎么也拼不过别人 Go 系统,你研究之后发现,原来协程模型在这样的少核心的情况下性能要更好,你找运维希望能升级下配置,你走到运维门口的时候,你停了下来,醒醒吧,不是你错了,而是时代变了。

云原生压根不是为了 Java 存在的,云原生的时代已经不是 90 年代,那时候的软件是一个技术活,每一个系统都需要精心设计,一个系统数个月才会更新一个版本,每一个功能都需要进行完整的测试,软件也跑在了企业内部的服务器上,软件是IT部分的宝贝,给他最好的环境,而在 9012 年,软件是什么?软件早就爆炸了,IT 从业者已经到达一个峰值,还有源源不断的人输入进来,市场的竞争也变的激烈,软件公司的竞争力也早就不是质量高,而是如何更快的应对市场的变化,Java 就如同一个身披无数荣光的二战将军,你让他去打21世纪的信息战,哪里还跟着上时代。

云原生需要的是,More Fast & More Fast 的交付系统,一个系统开发很快的系统,那天生就和精心设计是违背的,一个精心设计又能很快开发完的系统实在少见,所以我们从 Spring Boot 上直接堆砌业务代码,最多按照 MVC 进行一个简单的分层,那些优秀的 OOP 理念都活在哪里,那些底层框架,而你突然有一天对 Go 来了兴趣,你按照学 juc 的包的姿势,想要学习下 Go 的优雅源码,你发现,天呐,那些底层库原来可以设计的如此简单,Cache 只需要使用简单的 Map 加上一个 Lock 就可以获得很好的性能了,你开始怀疑了,随着你了解的越深入,你发现 Go 这个语言真是充满了各种各样的缺点,但是足够简单这个优势简直让你羡慕到不行,你回想起来,Executors 的用法你学了好几天,看了好多文章,才把自己的姿势学完,你发现 go func(){} 就解决你的需求了,你顺手删掉了 JDK,走上了真香之路。虽然你还会怀念 SpringBoot 的方便,你发现 Go 也足够满足你 80% 的需求了,剩下俩的一点点就捏着鼻子就好了。你老婆也不怪你没时间陪孩子了,你的工资也涨了点,偶尔翻开自己充满设计模式的 Old Style 代码,再也没有什么兴趣了。

原文链接:http://blog.yannxia.top/2019/05/29/fxxk-java-in-cloud-native/

Jib 1.0.0迎来通用版本——以前所未有的低门槛构建Java Docker镜像

大卫 发表了文章 • 0 个评论 • 1518 次浏览 • 2019-02-12 18:22 • 来自相关话题

去年,我们开始着手帮助开发人员更轻松地实现Java应用程序的容器化转换。我们注意到,开发人员们在使用现有工具时往往面临诸多困难——例如构建速度太慢,Dockerfiles混合不堪,以及容器体积过大等等。 为了改变上述状况,我们开发出了 ...查看全部
去年,我们开始着手帮助开发人员更轻松地实现Java应用程序的容器化转换。我们注意到,开发人员们在使用现有工具时往往面临诸多困难——例如构建速度太慢,Dockerfiles混合不堪,以及容器体积过大等等。

为了改变上述状况,我们开发出了Jib。Jib是一款开源工具,能够非常轻松地与您的Java应用程序实现集成——您无需安装Docker、无需运行Docker守护程序,甚至不需要编写Dockerfile。只需要在Maven或者Gradle build当中使用这款插件并运行构建过程,一切即可迎刃而解。Jib能够利用既有构建信息快速且高效地自动与您的应用程序完成适配。在Jib的帮助下,构建Java容器如今就像打包JAR文件一样简单。

我们于去年公布了Jib的beta测试版本,从那时开始,我们陆续收到了来自社区的诸多反馈与贡献,这也帮助我们更好地实现了其容器化体验。今天,我们高兴地宣布Jib 1.0.0通用版本的正式来临,其已经做好充分的准备,能够满足生产环境对于稳定性的严格要求。

我们将在这篇文章当中对版本中的主要变更做出说明,具体包括对WAR项目的支持、与Skaffold的集成以及面向Java的全新容器构建库Jib Core。
#Jib 1.0版本中包含哪些重点?
##Docker化WAR项目
Java编写的Web应用程序通常会被打包成WAR文件。如今,Jib已经能够对WAR项目进行容器化,且完全无需额外配置。您只需要直接运行以下命令:

Maven:
$ mvn package jib:build

Gradle:
$ gradle jib

该容器中的默认应用服务器为Jetty,但您也可以对基础镜像以及appRoot进行配置调整,从而使用Tomcat等其它服务器选项:

Maven(pom.xml):


tomcat:8.5-jre8-alpine


gcr.io/my-project/my-war-image


/usr/local/tomcat/webapps/my-webapp


Gradle(build.gradle):
jib {
from.image = 'tomcat:8.5-jre8-alpine'
to.image = 'gcr.io/my-project/my-war-image'
container.appRoot = '/usr/local/tomcat/webapps/my-webapp'
}

感兴趣的朋友请参阅Docker化Maven WAR项目Docker化Gradle WAR项目的相关说明。
#在Kubernetes开发当中与Skaffold for Java相集成
Skaffold是一款用于在Kubernetes上实现持续开发的命令行工具。我们将Skaffold与Jib加以集成,旨在实现Kubernetes之上的无缝化开发体验。Jib现在已经可以作为Skaffold当中的builder选项。

要在您的Java项目当中开始使用Skaffold,您首先需要安装Skaffold并向项目当中添加skaffold.yaml文件:
skaffold.yaml:

apiVersion: skaffold/v1beta4
kind: Config
build:
artifacts:
- image: gcr.io/my-project/my-java-image
# Use this for a Maven project:
jibMaven: {}
# Use this for a Gradle project:
jibGradle: {}

请确保您已经把Kubernetes清单存放在k8s/目录当中,且Container规范中的镜像引用匹配至gcr.io/my-project/my-java-image位置。请查阅Skaffold库作为参考

接下来,您可以使用以下命令启动Skaffold的持续开发功能:
$ skaffold dev --trigger notify

Skaffold能够帮助您消除在进行每一项变更之后,对应用程序进行重新构建与重新部署所带来的一系列繁琐步骤。Skaffold会利用Jib对您的应用程序进行容器化转换,而后在检测到变更时将其部署至您的Kubernetes集群当中。现在,您将能够把精力集中到真正重要的工作——编写代码身上。
##Jib Core:在Java中构建Docker镜像
Jib运行在我们自己用于构建容器镜像的通用库之上,我们将这套库以Jib Core的形式进行发布,同时进行了一系列API改进。现在,您可以将Jib作为Maven以及Gradle插件,从而在无需Docker守护程序的前提下面向任意应用程序利用Java进行容器构建。

要使用Jib Core,您需要在项目当中添加以下文件:

Maven(pom.xml):

com.google.cloud.tools
jib-core
0.1.1

Gradle(build.gradle):
dependencies {
implementation 'com.google.cloud.tools:jib-core:0.1.1'
}

以下是构建一套简单Docker镜像的操作示例。其将以基础镜像为起点,添加单一层、设置入口点,而后使用几行代码将镜像推送至远端注册表当中:
Jib.from("busybox")
.addLayer(Arrays.asList(Paths.get("helloworld.sh")), AbsoluteUnixPath.get("/"))
.setEntrypoint("sh", "/helloworld.sh")
.containerize(
Containerizer.to(RegistryImage.named("gcr.io/my-project/hello-from-jib")
.addCredential("myusername", "mypassword")));

我们也鼓励大家利用Jib Core构建属于自己的自定义容器化解决方案。欢迎您在我们的Gitter频道上共享利用Jib Core构建的一切项目。另外,您也可以参考我们发布的Jib Core其它使用示例,例如Gradle构建脚本
#丰富的功能,加上仍然简单易行的窗口化操作体验
利用Jib对Java应用程序进行容器化转换仍然与以往一样简单易行。如果您使用的是Maven,只需要将这款插件添加至pom.xml当中:

com.google.cloud.tools
jib-maven-plugin
1.0.0


gcr.io/my-project/my-java-image



要构建一套镜像并将其推送至容器注册表,您可使用以下命令:
$ mvn compile jib:build

或者使用以下命令面向Docker守护程序进行构建:
$ mvn compile jib:dockerBuild

您现在甚至可以在无需修改pom.xml文件的前提下实现应用程序容器化,具体操作如下:
$ mvn compile com.google.cloud.tools:jib-maven-plugin:1.0.0:build -Dimage=gcr.io/my-project/my-java-image

若需了解更多细节信息,请参阅Jib Maven快速入门。
当配合Gradle使用Jib时,您需要将该插件添加至build.gradle当中:
plugins {
id 'com.google.cloud.tools.jib' version '1.0.0'
}

jib.to.image = 'gcr.io/my-project/my-java-image'

在此之后,您可以利用以下命令将应用程序容器化至目标容器注册表:
$ gradle jib

或者使用以下命令将其容器化至Docker守护程序:
$ gradle jibDockerBuild

若需了解更多细节信息,请参阅Jib Gradle快速入门
##立即开始使用
我们希望Jib能够帮助每一位朋友简化并加快自己的Java开发进程。要开始使用Jib,请参阅我们的示例;此外,您也可使用Codelabs将Spring Boot应用程序或者Micronaut应用程序部署至Kubernetes当中。Jib能够与大多数Docker注册表提供程序以及托管注册表相兼容;请尽情尝试,并通过github.com/GoogleContainerTools/jib与我们分享您的心得体会。感谢!

原文链接:Jib 1.0.0 is GA—building Java Docker images has never been easier

容器中的JVM资源该如何被安全的限制?

尼古拉斯 发表了文章 • 0 个评论 • 1411 次浏览 • 2019-02-09 11:44 • 来自相关话题

#前言 Java与Docker的结合,虽然更好的解决了application的封装问题。但也存在着不兼容,比如Java并不能自动的发现Docker设置的内存限制,CPU限制。 这将导致JVM不能稳定服务业务!容器会杀死你J ...查看全部
#前言
Java与Docker的结合,虽然更好的解决了application的封装问题。但也存在着不兼容,比如Java并不能自动的发现Docker设置的内存限制,CPU限制。

这将导致JVM不能稳定服务业务!容器会杀死你JVM进程,而健康检查又将拉起你的JVM进程,进而导致你监控你的Pod一天重启次数甚至能达到几百次。

我们希望当Java进程运行在容器中时,Java能够自动识别到容器限制,获取到正确的内存和CPU信息,而不用每次都需要在kubernetes的yaml描述文件中显示的配置完容器,还需要配置JVM参数。

使用JVM MaxRAM参数或者解锁实验特性的JVM参数,升级JDK到10+,我们可以解决这个问题(也许吧~.~)。

首先Docker容器本质是是宿主机上的一个进程,它与宿主机共享一个/proc目录,也就是说我们在容器内看到的/proc/meminfo,/proc/cpuinfo与直接在宿主机上看到的一致,如下。

Host:
cat /proc/meminfo 
MemTotal: 197869260KB
MemFree: 3698100KB
MemAvailable: 62230260KB

容器:
docker run -it --rm alpine cat /proc/meminfo
MemTotal: 197869260KB
MemFree: 3677800KB
MemAvailable: 62210088KB

那么Java是如何获取到Host的内存信息的呢?没错就是通过/proc/meminfo来获取到的。

默认情况下,JVM的Max Heap Size是系统内存的1/4,假如我们系统是8G,那么JVM将的默认Heap≈2G。

Docker通过CGroups完成的是对内存的限制,而/proc目录是已只读形式挂载到容器中的,由于默认情况下Java压根就看不见CGroups的限制的内存大小,而默认使用/proc/meminfo中的信息作为内存信息进行启动,
这种不兼容情况会导致,如果容器分配的内存小于JVM的内存,JVM进程会被理解杀死。
#内存限制不兼容
我们首先来看一组测试,这里我们采用一台内存为188G的物理机。
#free -g
total used free shared buff/cache available
Mem: 188 122 1 0 64 64

以下的测试中,我们将包含OpenJDK的hotspot虚拟机,IBM的OpenJ9虚拟机。

以下测试中,我们把正确识别到限制的JDK,称之为安全(即不会超出容器限制不会被kill),反之称之为危险。
##测试用例1(OpenJDK)
这一组测试我们使用最新的OpenJDK8-12,给容器限制内存为4G,看JDK默认参数下的最大堆为多少?看看我们默认参数下多少版本的JDK是安全的

命令如下,如果你也想试试看,可以用一下命令。
docker run -m 4GB --rm  openjdk:8-jre-slim java  -XshowSettings:vm  -version
docker run -m 4GB --rm openjdk:9-jre-slim java -XshowSettings:vm -version
docker run -m 4GB --rm openjdk:10-jre-slim java -XshowSettings:vm -version
docker run -m 4GB --rm openjdk:11-jre-slim java -XshowSettings:vm -version
docker run -m 4GB --rm openjdk:12 java -XshowSettings:vm -version

OpenJDK8(并没有识别容器限制,26.67G) 危险。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:8-jre-slim java  -XshowSettings:vm  -version

VM settings:
Max. Heap Size (Estimated): 26.67G
Ergonomics Machine Class: server
Using VM: OpenJDK 64-Bit Server VM

openjdk version "1.8.0_181"
OpenJDK Runtime Environment (build 1.8.0_181-8u181-b13-2~deb9u1-b13)
OpenJDK 64-Bit Server VM (build 25.181-b13, mixed mode)

OpenJDK8 -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap (正确的识别容器限制,910.50M)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:8-jre-slim java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 910.50M
Ergonomics Machine Class: server
Using VM: OpenJDK 64-Bit Server VM

openjdk version "1.8.0_181"
OpenJDK Runtime Environment (build 1.8.0_181-8u181-b13-2~deb9u1-b13)
OpenJDK 64-Bit Server VM (build 25.181-b13, mixed mode)

OpenJDK 9(并没有识别容器限制,26.67G)危险。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:9-jre-slim java  -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 29.97G
Using VM: OpenJDK 64-Bit Server VM

openjdk version "9.0.4"
OpenJDK Runtime Environment (build 9.0.4+12-Debian-4)
OpenJDK 64-Bit Server VM (build 9.0.4+12-Debian-4, mixed mode)

OpenJDK 9 -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap(正确的识别容器限制,1G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:9-jre-slim java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 1.00G
Using VM: OpenJDK 64-Bit Server VM

openjdk version "9.0.4"
OpenJDK Runtime Environment (build 9.0.4+12-Debian-4)
OpenJDK 64-Bit Server VM (build 9.0.4+12-Debian-4, mixed mode)

OpenJDK 10(正确的识别容器限制,1G)安全。
[root@xiaoke-test ~]# docker run -m 32GB --rm  openjdk:10-jre-slim java -XshowSettings:vm -XX:MaxRAMFraction=1  -version
VM settings:
Max. Heap Size (Estimated): 1.00G
Using VM: OpenJDK 64-Bit Server VM

openjdk version "10.0.2" 2018-07-17
OpenJDK Runtime Environment (build 10.0.2+13-Debian-2)
OpenJDK 64-Bit Server VM (build 10.0.2+13-Debian-2, mixed mode)

OpenJDK 11(正确的识别容器限制,1G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:11-jre-slim java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 1.00G
Using VM: OpenJDK 64-Bit Server VM

openjdk version "11.0.1" 2018-10-16
OpenJDK Runtime Environment (build 11.0.1+13-Debian-3)
OpenJDK 64-Bit Server VM (build 11.0.1+13-Debian-3, mixed mode, sharing)

OpenJDK 12(正确的识别容器限制,1G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:12 java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 1.00G
Using VM: OpenJDK 64-Bit Server VM

openjdk version "12-ea" 2019-03-19
OpenJDK Runtime Environment (build 12-ea+23)
OpenJDK 64-Bit Server VM (build 12-ea+23, mixed mode, sharing)

##测试用例2(IBM OpenJ9)
docker run -m 4GB --rm  adoptopenjdk/openjdk8-openj9:alpine-slim  java -XshowSettings:vm  -version
docker run -m 4GB --rm adoptopenjdk/openjdk9-openj9:alpine-slim java -XshowSettings:vm -version
docker run -m 4GB --rm adoptopenjdk/openjdk10-openj9:alpine-slim java -XshowSettings:vm -version
docker run -m 4GB --rm adoptopenjdk/openjdk11-openj9:alpine-slim java -XshowSettings:vm -version

OpenJDK8-OpenJ9(正确的识别容器限制,3G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  adoptopenjdk/openjdk8-openj9:alpine-slim  java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 3.00G
Ergonomics Machine Class: server
Using VM: Eclipse OpenJ9 VM

openjdk version "1.8.0_192"
OpenJDK Runtime Environment (build 1.8.0_192-b12_openj9)
Eclipse OpenJ9 VM (build openj9-0.11.0, JRE 1.8.0 Linux amd64-64-Bit Compressed References 20181107_95 (JIT enabled, AOT enabled)
OpenJ9 - 090ff9dcd
OMR - ea548a66
JCL - b5a3affe73 based on jdk8u192-b12)

OpenJDK9-OpenJ9 (正确的识别容器限制,3G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  adoptopenjdk/openjdk9-openj9:alpine-slim  java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 3.00G
Using VM: Eclipse OpenJ9 VM

openjdk version "9.0.4-adoptopenjdk"
OpenJDK Runtime Environment (build 9.0.4-adoptopenjdk+12)
Eclipse OpenJ9 VM (build openj9-0.9.0, JRE 9 Linux amd64-64-Bit Compressed References 20180814_248 (JIT enabled, AOT enabled)
OpenJ9 - 24e53631
OMR - fad6bf6e
JCL - feec4d2ae based on jdk-9.0.4+12)

OpenJDK10-OpenJ9 (正确的识别容器限制,3G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  adoptopenjdk/openjdk10-openj9:alpine-slim  java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 3.00G
Using VM: Eclipse OpenJ9 VM

openjdk version "10.0.2-adoptopenjdk" 2018-07-17
OpenJDK Runtime Environment (build 10.0.2-adoptopenjdk+13)
Eclipse OpenJ9 VM (build openj9-0.9.0, JRE 10 Linux amd64-64-Bit Compressed References 20180813_102 (JIT enabled, AOT enabled)
OpenJ9 - 24e53631
OMR - fad6bf6e
JCL - 7db90eda56 based on jdk-10.0.2+13)

OpenJDK11-OpenJ9(正确的识别容器限制,3G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  adoptopenjdk/openjdk11-openj9:alpine-slim  java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 3.00G
Using VM: Eclipse OpenJ9 VM

openjdk version "11.0.1" 2018-10-16
OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.1+13)
Eclipse OpenJ9 VM AdoptOpenJDK (build openj9-0.11.0, JRE 11 Linux amd64-64-Bit Compressed References 20181020_70 (JIT enabled, AOT enabled)
OpenJ9 - 090ff9dc
OMR - ea548a66
JCL - f62696f378 based on jdk-11.0.1+13)

##分析
分析之前我们先了解这么一个情况:
JavaMemory (MaxRAM) = 元数据+线程+代码缓存+OffHeap+Heap...

一般我们都只配置Heap即使用-Xmx来指定JVM可使用的最大堆。而JVM默认会使用它获取到的最大内存的1/4作为堆的原因也是如此。

安全性(即不会超过容器限制被容器kill)

OpenJDK:

OpenJdk8-12,都能保证这个安全性的特点(8和9需要特殊参数,-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap)。

OpenJ9:

2.IbmOpenJ9所有的版本都能识别到容器限制。

资源利用率

OpenJDK:

自动识别到容器限制后,OpenJDK把最大堆设置为了大概容器内存的1/4,对内存的浪费不可谓不大。

当然可以配合另一个JVM参数来配置最大堆。-XX:MaxRAMFraction=int。下面是我整理的一个常见内存设置的表格,从中我们可以看到似乎JVM默认的最大堆的取值为MaxRAMFraction=4,随着内存的增加,堆的闲置空间越来越大,在16G容器内存时,Java堆只有不到4G。

MaxRAMFraction取值	堆占比	容器内存=1G	容器内存=2G	容器内存=4G   容器内存=8G	容器内存=16G
1 ≈90% 910.50M 1.78G 3.56G 7.11G 14.22G
2 ≈50% 455.50M 910.50M 1.78G 3.56G 7.11G
3 ≈33% 304.00M 608.00M 1.19G 2.37G 4.74G
4 ≈25% 228.00M 455.50M 910.50M 1.78G 3.56G

OpenJ9:

关于OpenJ9的的详细介绍你可以从这里了解更多。

对于内存利用率OpenJ9的策略是优于OpenJDK的。以下是OpenJ9的策略表格.

容器内存	最大Java堆大小
小于1GB 50%
1GB-2GB -512MB
大于2GB 大于2GB

##结论
注意:这里我们说的是容器内存限制,和物理机内存不同。

自动档

如果你想要的是,不显示的指定-Xmx,让Java进程自动的发现容器限制。

如果你想要的是JVM进程在容器中安全稳定的运行,不被容器kiil,并且你的JDK版本小于10(大于等于JDK10的版本不需要设置,参考前面的测试)。

你需要额外设置JVM参数-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap,即可保证你的Java进程不会因为内存问题被容器Kill。

当然这个方式使用起来简单,可靠,缺点也很明显,资源利用率过低(参考前面的表格MaxRAMFraction=4)。

如果想在基础上我还想提高一些内存资源利用率,并且容器内存为1GB - 4GB,我建议你设置-XX:MaxRAMFraction=2,在大于8G的可以尝试设置-XX:MaxRAMFraction=1(参考上表格)。

手动挡

如果你想要的是手动挡的体验,更加进一步的利用内存资源,那么你可能需要回到手动配置时代-Xmx。

手动挡部分,请可以完全忽略上面我的BB。

上面我们说到了自动挡的配置,用起来很简单很舒服,自动发现容器限制,无需担心和思考去配置-Xmx。

比如你有内存1G那么我建议你的-Xmx750M,2G建议配置-Xmx1700M,4G建议配置-Xmx3500-3700M,8G建议设置-Xmx7500-7600M,总之就是至少保留300M以上的内存留给JVM的其他内存。如果堆特别大,可以预留到1G甚至2G。

手动挡用起来就没有那么舒服了,当然资源利用率相对而言就更高了。

原文链接:https://qingmu.io/2018/12/17/How-to-securely-limit-JVM-resources-in-a-container/

Java线程池ThreadPoolExecutor实现原理剖析

Andy_Lee 发表了文章 • 0 个评论 • 1671 次浏览 • 2018-10-13 17:05 • 来自相关话题

【编者的话】在Java中,使用线程池来异步执行一些耗时任务是非常常见的操作。最初我们一般都是直接使用new Thread().start的方式,但我们知道,线程的创建和销毁都会耗费大量的资源,关于线程可以参考之前的一篇博客《Java线程那点事儿》,因此我们需要 ...查看全部
【编者的话】在Java中,使用线程池来异步执行一些耗时任务是非常常见的操作。最初我们一般都是直接使用new Thread().start的方式,但我们知道,线程的创建和销毁都会耗费大量的资源,关于线程可以参考之前的一篇博客《Java线程那点事儿》,因此我们需要重用线程资源。

当然也有其他待解决方案,比如说coroutine,目前Kotlin已经支持了,JDK也已经有了相关的提案:Project Loom,目前的实现方式和Kotlin有点类似,都是基于ForkJoinPool,当然目前还有很多限制以及问题没解决,比如synchronized还是锁住当前线程等。
##继承结构
1.png

继承结构看起来很清晰,最顶层的Executor只提供了一个最简单的void execute(Runnable command)方法,然后是ExecutorService,ExecutorService提供了一些管理相关的方法,例如关闭、判断当前线程池的状态等,另外不同于Executor#execute,ExecutorService提供了一系列方法,可以将任务包装成一个Future,从而使得任务提交方可以跟踪任务的状态。而父类AbstractExecutorService则提供了一些默认的实现。
#构造器
ThreadPoolExecutor的构造器提供了非常多的参数,每一个参数都非常的重要,一不小心就容易踩坑,因此设置的时候,你必须要知道自己在干什么。
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.acc = System.getSecurityManager() == null ?
null :
AccessController.getContext();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}


  1. corePoolSize、 maximumPoolSize。线程池会自动根据corePoolSize和maximumPoolSize去调整当前线程池的大小。当你通过submit或者execute方法提交任务的时候,如果当前线程池的线程数小于corePoolSize,那么线程池就会创建一个新的线程处理任务, 即使其他的core线程是空闲的。如果当前线程数大于corePoolSize并且小于maximumPoolSize,那么只有在队列"满"的时候才会创建新的线程。因此这里会有很多的坑,比如你的core和max线程数设置的不一样,希望请求积压在队列的时候能够实时的扩容,但如果制定了一个无界队列,那么就不会扩容了,因为队列不存在满的概念。

  1. keepAliveTime。如果当前线程池中的线程数超过了corePoolSize,那么如果在keepAliveTime时间内都没有新的任务需要处理,那么超过corePoolSize的这部分线程就会被销毁。默认情况下是不会回收core线程的,可以通过设置allowCoreThreadTimeOut改变这一行为。

  1. workQueue。即实际用于存储任务的队列,这个可以说是最核心的一个参数了,直接决定了线程池的行为,比如说传入一个有界队列,那么队列满的时候,线程池就会根据core和max参数的设置情况决定是否需要扩容,如果传入了一个SynchronousQueue,这个队列只有在另一个线程在同步remove的时候才可以put成功,对应到线程池中,简单来说就是如果有线程池任务处理完了,调用poll或者take方法获取新的任务的时候,新提交的任务才会put成功,否则如果当前的线程都在忙着处理任务,那么就会put失败,也就会走扩容的逻辑,如果传入了一个DelayedWorkQueue,顾名思义,任务就会根据过期时间来决定什么时候弹出,即为ScheduledThreadPoolExecutor的机制。

  1. threadFactory。创建线程都是通过ThreadFactory来实现的,如果没指定的话,默认会使用Executors.defaultThreadFactory(),一般来说,我们会在这里对线程设置名称、异常处理器等。

  1. handler。即当任务提交失败的时候,会调用这个处理器,ThreadPoolExecutor内置了多个实现,比如抛异常、直接抛弃等。这里也需要根据业务场景进行设置,比如说当队列积压的时候,针对性的对线程池扩容或者发送告警等策略。

看完这几个参数的含义,我们看一下Executors提供的一些工具方法,只要是为了方便使用,但是我建议最好少用这个类,而是直接用ThreadPoolExecutor的构造函数,多了解一下这几个参数到底是什么意思,自己的业务场景是什么样的,比如线程池需不需要扩容、用不用回收空闲的线程等。
public class Executors {

/*
* 提供一个固定大小的线程池,并且线程不会回收,由于传入的是一个无界队列,相当于队列永远不会满
* 也就不会扩容,因此需要特别注意任务积压在队列中导致内存爆掉的问题
*/
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue());
}


/*
* 这个线程池会一直扩容,由于SynchronousQueue的特性,如果当前所有的线程都在处理任务,那么
* 新的请求过来,就会导致创建一个新的线程处理任务。如果线程一分钟没有新任务处理,就会被回
* 收掉。特别注意,如果每一个任务都比较耗时,并发又比较高,那么可能每次任务过来都会创建一个线
* 程
*/
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue());
}
}

##源码分析
既然是个线程池,那就必然有其生命周期:运行中、关闭、停止等。ThreadPoolExecutor是用一个AtomicInteger去的前三位表示这个状态的,另外又重用了低29位用于表示线程数,可以支持最大大概5亿多,绝逼够用了,如果以后硬件真的发展到能够启动这么多线程,改成AtomicLong就可以了。

状态这里主要分为下面几种:

  1. RUNNING:表示当前线程池正在运行中,可以接受新任务以及处理队列中的任务
  2. SHUTDOWN:不再接受新的任务,但会继续处理队列中的任务
  3. STOP:不再接受新的任务,也不处理队列中的任务了,并且会中断正在进行中的任务
  4. TIDYING:所有任务都已经处理完毕,线程数为0,转为为TIDYING状态之后,会调用terminated()回调
  5. TERMINATED:terminated()已经执行完毕

同时我们可以看到所有的状态都是用二进制位表示的,并且依次递增,从而方便进行比较,比如想获取当前状态是否至少为SHUTDOWN等,同时状态之前有几种转换:

  1. RUNNING -> SHUTDOWN。调用了shutdown()之后,或者执行了finalize()
  2. (RUNNING 或者 SHUTDOWN) -> STOP。调用了shutdownNow()之后会转换这个状态
  3. SHUTDOWN -> TIDYING。当线程池和队列都为空的时候
  4. STOP -> TIDYING。当线程池为空的时候
  5. IDYING -> TERMINATED。执行完terminated()回调之后会转换为这个状态

    private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY = (1 << COUNT_BITS) - 1;

private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;

//由于前三位表示状态,因此将CAPACITY取反,和进行与操作即可
private static int runStateOf(int c) { return c & ~CAPACITY; }
private static int workerCountOf(int c) { return c & CAPACITY; }

//高三位+第三位进行或操作即可
private static int ctlOf(int rs, int wc) { return rs | wc; }

private static boolean runStateLessThan(int c, int s) {
return c < s;
}

private static boolean runStateAtLeast(int c, int s) {
return c >= s;
}

private static boolean isRunning(int c) {
return c < SHUTDOWN;
}

//下面三个方法,通过CAS修改worker的数目
private boolean compareAndIncrementWorkerCount(int expect) {
return ctl.compareAndSet(expect, expect + 1);
}

//只尝试一次,失败了则返回,是否重试由调用方决定
private boolean compareAndDecrementWorkerCount(int expect) {
return ctl.compareAndSet(expect, expect - 1);
}

//跟上一个不一样,会一直重试
private void decrementWorkerCount() {
do {} while (! compareAndDecrementWorkerCount(ctl.get()));
}

下面是比较核心的字段,这里workers采用的是非线程安全的HashSet,而不是线程安全的版本,主要是因为这里有些复合的操作,比如说将worker添加到workers后,我们还需要判断是否需要更新largestPoolSize等,workers只在获取到mainLock的情况下才会进行读写,另外这里的mainLock也用于在中断线程的时候串行执行,否则如果不加锁的话,可能会造成并发去中断线程,引起不必要的中断风暴。
private final ReentrantLock mainLock = new ReentrantLock();

private final HashSet workers = new HashSet();

private final Condition termination = mainLock.newCondition();

private int largestPoolSize;

private long completedTaskCount;

##核心方法
拿到一个线程池之后,我们就可以开始提交任务,让它去执行了,那么我们看一下submit方法是如何实现的。
    public Future submit(Runnable task) {
if (task == null) throw new NullPointerException();
RunnableFuture ftask = newTaskFor(task, null);
execute(ftask);
return ftask;
}

public Future submit(Callable task) {
if (task == null) throw new NullPointerException();
RunnableFuture ftask = newTaskFor(task);
execute(ftask);
return ftask;
}

这两个方法都很简单,首先将提交过来的任务(有两种形式:Callable、Runnable)都包装成统一的RunnableFuture,然后调用execute方法,execute可以说是线程池最核心的一个方法。
    public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
/*
获取当前worker的数目,如果小于corePoolSize那么就扩容,
这里不会判断是否已经有core线程,而是只要小于corePoolSize就会直接增加worker
*/
if (workerCountOf(c) < corePoolSize) {
/*
调用addWorker(Runnable firstTask, boolean core)方法扩容
firstTask表示为该worker启动之后要执行的第一个任务,core表示要增加的为core线程
*/
if (addWorker(command, true))
return;
//如果增加失败了那么重新获取ctl的快照,比如可能线程池在这期间关闭了
c = ctl.get();
}
/*
如果当前线程池正在运行中,并且将任务丢到队列中成功了,
那么就会进行一次double check,看下在这期间线程池是否关闭了,
如果关闭了,比如处于SHUTDOWN状态,如上文所讲的,SHUTDOWN状态的时候,
不再接受新任务,remove成功后调用拒绝处理器。而如果仍然处于运行中的状态,
那么这里就double check下当前的worker数,如果为0,有可能在上述逻辑的执行
过程中,有worker销毁了,比如说任务抛出了未捕获异常等,那么就会进行一次扩容,
但不同于扩容core线程,这里由于任务已经丢到队列中去了,因此就不需要再传递firstTask了,
同时要注意,这里扩容的是非core线程
*/
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
/*
如果在上一步中,将任务丢到队列中失败了,那么就进行一次扩容,
这里会将任务传递到firstTask参数中,并且扩容的是非core线程,
如果扩容失败了,那么就执行拒绝策略。
*/
reject(command);
}

这里要特别注意下防止队列失败的逻辑,不同的队列丢任务的逻辑也不一样,例如说无界队列,那么就永远不会put失败,也就是说扩容也永远不会执行,如果是有界队列,那么当队列满的时候,会扩容非core线程,如果是SynchronousQueue,这个队列比较特殊,当有另外一个线程正在同步获取任务的时候,你才能put成功,因此如果当前线程池中所有的worker都忙着处理任务的时候,那么后续的每次新任务都会导致扩容,当然如果worker没有任务处理了,阻塞在获取任务这一步的时候,新任务的提交就会直接丢到队列中去,而不会扩容。

上文中多次提到了扩容,那么我们下面看一下线程池具体是如何进行扩容的:
    private boolean addWorker(Runnable firstTask, boolean core) {
retry:
for (;;) {
int c = ctl.get();
//获取当前线程池的状态
int rs = runStateOf(c);

/*
如果状态为大于SHUTDOWN, 比如说STOP,STOP上文说过队列中的任务不处理了,也不接受新任务,
因此可以直接返回false不扩容了,如果状态为SHUTDOWN并且firstTask为null,同时队列非空,
那么就可以扩容
*/
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;

for (;;) {
int wc = workerCountOf(c);
/*
若worker的数目大于CAPACITY则直接返回,
然后根据要扩容的是core线程还是非core线程,进行判断worker数目
是否超过设置的值,超过则返回
*/
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
/*
通过CAS的方式自增worker的数目,成功了则直接跳出循环
*/
if (compareAndIncrementWorkerCount(c))
break retry;
//重新读取状态变量,如果状态改变了,比如线程池关闭了,那么就跳到最外层的for循环,
//注意这里跳出的是retry。
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}

boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
//创建Worker
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
/*
获取锁,并判断线程池是否已经关闭
*/
int rs = runStateOf(ctl.get());

if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // 若线程已经启动了,比如说已经调用了start()方法,那么就抛异常,
throw new IllegalThreadStateException();
//添加到workers中
workers.add(w);
int s = workers.size();
if (s > largestPoolSize) //更新largestPoolSize
largestPoolSize = s;
workerAdded = true;
}
} finally {
mainLock.unlock();
}
if (workerAdded) {
//若Worker创建成功,则启动线程,这么时候worker就会开始执行任务了
t.start();
workerStarted = true;
}
}
} finally {
if (! workerStarted)
//添加失败
addWorkerFailed(w);
}
return workerStarted;
}

private void addWorkerFailed(Worker w) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
if (w != null)
workers.remove(w);
decrementWorkerCount();
//每次减少worker或者从队列中移除任务的时候都需要调用这个方法
tryTerminate();
} finally {
mainLock.unlock();
}
}

这里有个貌似不太起眼的方法tryTerminate,这个方法会在所有可能导致线程池终结的地方调用,比如说减少worker的数目等,如果满足条件的话,那么将线程池转换为TERMINATED状态。另外这个方法没有用private修饰,因为ScheduledThreadPoolExecutor继承自ThreadPoolExecutor,而ScheduledThreadPoolExecutor也会调用这个方法。
    final void tryTerminate() {
for (;;) {
int c = ctl.get();
/*
如果当前线程处于运行中、TIDYING、TERMINATED状态则直接返回,运行中的没
什么好说的,后面两种状态可以说线程池已经正在终结了,另外如果处于SHUTDOWN状态,
并且workQueue非空,表明还有任务需要处理,也直接返回
*/
if (isRunning(c) ||
runStateAtLeast(c, TIDYING) ||
(runStateOf(c) == SHUTDOWN && ! workQueue.isEmpty()))
return;
//可以退出,但是线程数非0,那么就中断一个线程,从而使得关闭的信号能够传递下去,
//中断worker后,worker捕获异常后,会尝试退出,并在这里继续执行tryTerminate()方法,
//从而使得信号传递下去
if (workerCountOf(c) != 0) {
interruptIdleWorkers(ONLY_ONE);
return;
}

final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//尝试转换成TIDYING状态,执行完terminated回调之后
//会转换为TERMINATED状态,这个时候线程池已经完整关闭了,
//通过signalAll方法,唤醒所有阻塞在awaitTermination上的线程
if (ctl.compareAndSet(c, ctlOf(TIDYING, 0))) {
try {
terminated();
} finally {
ctl.set(ctlOf(TERMINATED, 0));
termination.signalAll();
}
return;
}
} finally {
mainLock.unlock();
}
// else retry on failed CAS
}
}

/**
* 中断空闲的线程
* @param onlyOne
*/
private void interruptIdleWorkers(boolean onlyOne) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
for (Worker w : workers) {
//遍历所有worker,若之前没有被中断过,
//并且获取锁成功,那么就尝试中断。
//锁能够获取成功,那么表明当前worker没有在执行任务,而是在
//获取任务,因此也就达到了只中断空闲线程的目的。
Thread t = w.thread;
if (!t.isInterrupted() && w.tryLock()) {
try {
t.interrupt();
} catch (SecurityException ignore) {
} finally {
w.unlock();
}
}
if (onlyOne)
break;
}
} finally {
mainLock.unlock();
}
}

2.png

##Worker
下面看一下Worker类,也就是这个类实际负责执行任务,Worker类继承自AbstractQueuedSynchronizer,AQS可以理解为一个同步框架,提供了一些通用的机制,利用模板方法模式,让你能够原子的管理同步状态、blocking和unblocking线程、以及队列,具体的内容之后有时间会再写,还是比较复杂的。这里Worker对AQS的使用相对比较简单,使用了状态变量state表示是否获得锁,0表示解锁、1表示已获得锁,同时通过exclusiveOwnerThread存储当前持有锁的线程。另外再简单提一下,比如说CountDownLatch, 也是基于AQS框架实现的,countdown方法递减state,await阻塞等待state为0。
private final class Worker
extends AbstractQueuedSynchronizer
implements Runnable
{

/*[i] Thread this worker is running in. Null if factory fails. [/i]/
final Thread thread;

/*[i] Initial task to run. Possibly null. [/i]/
Runnable firstTask;

/*[i] Per-thread task counter [/i]/
volatile long completedTasks;

Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
this.thread = getThreadFactory().newThread(this);
}

/*[i] Delegates main run loop to outer runWorker [/i]/
public void run() {
runWorker(this);
}
protected boolean isHeldExclusively() {
return getState() != 0;
}

protected boolean tryAcquire(int unused) {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}

protected boolean tryRelease(int unused) {
setExclusiveOwnerThread(null);
setState(0);
return true;
}

public void lock() { acquire(1); }
public boolean tryLock() { return tryAcquire(1); }
public void unlock() { release(1); }
public boolean isLocked() { return isHeldExclusively(); }

void interruptIfStarted() {
Thread t;
if (getState() >= 0 && (t = thread) != null && !t.isInterrupted()) {
try {
t.interrupt();
} catch (SecurityException ignore) {
}
}
}
}

注意这里Worker初始化的时候,会通过setState(-1)将state设置为-1,并在runWorker()方法中置为0,上文说过Worker是利用state这个变量来表示锁的状态,那么加锁的操作就是通过CAS将state从0改成1,那么初始化的时候改成-1,也就是表示在Worker启动之前,都不允许加锁操作,我们再看interruptIfStarted()以及interruptIdleWorkers()方法,这两个方法在尝试中断Worker之前,都会先加锁或者判断state是否大于0,因此这里的将state设置为-1,就是为了禁止中断操作,并在runWorker中置为0,也就是说只能在Worker启动之后才能够中断Worker。

另外线程启动之后,其实就是调用了runWorker方法,下面我们看一下具体是如何实现的。
   final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // 调用unlock()方法,将state置为0,表示其他操作可以获得锁或者中断worker
boolean completedAbruptly = true;
try {
/*
首先尝试执行firstTask,若没有的话,则调用getTask()从队列中获取任务
*/
while (task != null || (task = getTask()) != null) {
w.lock();
/*
如果线程池正在关闭,那么中断线程。
*/
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();
try {
//执行beforeExecute回调
beforeExecute(wt, task);
Throwable thrown = null;
try {
//实际开始执行任务
task.run();
} catch (RuntimeException x) {
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
//执行afterExecute回调
afterExecute(task, thrown);
}
} finally {
task = null;
//这里加了锁,因此没有线程安全的问题,volatile修饰保证其他线程的可见性
w.completedTasks++;
w.unlock();//解锁
}
}
completedAbruptly = false;
} finally {
//抛异常了,或者当前队列中已没有任务需要处理等
processWorkerExit(w, completedAbruptly);
}
}

private void processWorkerExit(Worker w, boolean completedAbruptly) {
//如果是异常终止的,那么减少worker的数目
if (completedAbruptly) // If abrupt, then workerCount wasn't adjusted
decrementWorkerCount();

final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//将当前worker中workers中删除掉,并累加当前worker已执行的任务到completedTaskCount中
completedTaskCount += w.completedTasks;
workers.remove(w);
} finally {
mainLock.unlock();
}

//上文说过,减少worker的操作都需要调用这个方法
tryTerminate();

/*
如果当前线程池仍然是运行中的状态,那么就看一下是否需要新增另外一个worker替换此worker
*/
int c = ctl.get();
if (runStateLessThan(c, STOP)) {
/*
如果是异常结束的则直接扩容,否则的话则为正常退出,比如当前队列中已经没有任务需要处理,
如果允许core线程超时的话,那么看一下当前队列是否为空,空的话则不用扩容。否则话看一下
是否少于corePoolSize个worker在运行。
*/
if (!completedAbruptly) {
int min = allowCoreThreadTimeOut ? 0 : corePoolSize;
if (min == 0 && ! workQueue.isEmpty())
min = 1;
if (workerCountOf(c) >= min)
return; // replacement not needed
}
addWorker(null, false);
}
}

private Runnable getTask() {
boolean timedOut = false; // 上一次poll()是否超时了

for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

// 若线程池关闭了(状态大于STOP)
// 或者线程池处于SHUTDOWN状态,但是队列为空,那么返回null
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}

int wc = workerCountOf(c);

/*
如果允许core线程超时 或者 不允许core线程超时但当前worker的数目大于core线程数,
那么下面的poll()则超时调用
*/
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

/*
获取任务超时了并且(当前线程池中还有不止一个worker 或者 队列中已经没有任务了),那么就尝试
减少worker的数目,若失败了则重试
*/
if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}

try {
//从队列中抓取任务
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
//走到这里表明,poll调用超时了
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}

##关闭线程池
关闭线程池一般有两种形式,shutdown()和shutdownNow()。
    public void shutdown() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
//通过CAS将状态更改为SHUTDOWN,这个时候线程池不接受新任务,但会继续处理队列中的任务
advanceRunState(SHUTDOWN);
//中断所有空闲的worker,也就是说除了正在处理任务的worker,其他阻塞在getTask()上的worker
//都会被中断
interruptIdleWorkers();
//执行回调
onShutdown(); // hook for ScheduledThreadPoolExecutor
} finally {
mainLock.unlock();
}
tryTerminate();
//这个方法不会等待所有的任务处理完成才返回
}
public List shutdownNow() {
List tasks;
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
/*
不同于shutdown(),会转换为STOP状态,不再处理新任务,队列中的任务也不处理,
而且会中断所有的worker,而不只是空闲的worker
*/
advanceRunState(STOP);
interruptWorkers();
tasks = drainQueue();//将所有的任务从队列中弹出
} finally {
mainLock.unlock();
}
tryTerminate();
return tasks;
}

private List drainQueue() {
BlockingQueue q = workQueue;
ArrayList taskList = new ArrayList();
/*
将队列中所有的任务remove掉,并添加到taskList中,
但是有些队列比较特殊,比如说DelayQueue,如果第一个任务还没到过期时间,则不会弹出,
因此这里通过调用toArray方法,然后再一个一个的remove掉
*/
q.drainTo(taskList);
if (!q.isEmpty()) {
for (Runnable r : q.toArray(new Runnable[0])) {
if (q.remove(r))
taskList.add(r);
}
}
return taskList;
}

从上文中可以看到,调用了shutdown()方法后,不会等待所有的任务处理完毕才返回,因此需要调用awaitTermination()来实现。
    public boolean awaitTermination(long timeout, TimeUnit unit)
throws InterruptedException {
long nanos = unit.toNanos(timeout);
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
for (;;) {
//线程池若已经终结了,那么就返回
if (runStateAtLeast(ctl.get(), TERMINATED))
return true;
//若超时了,也返回掉
if (nanos <= 0)
return false;
//阻塞在信号量上,等待线程池终结,但是要注意这个方法可能会因为一些未知原因随时唤醒当前线程,
//因此需要重试,在tryTerminate()方法中,执行完terminated()回调后,表明线程池已经终结了,
//然后会通过termination.signalAll()唤醒当前线程
nanos = termination.awaitNanos(nanos);
}
} finally {
mainLock.unlock();
}
}
一些统计相关的方法
public int getPoolSize() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//若线程已终结则直接返回0,否则计算works中的数目
//想一下为什么不用workerCount呢?
return runStateAtLeast(ctl.get(), TIDYING) ? 0
: workers.size();
} finally {
mainLock.unlock();
}
}

public int getActiveCount() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
int n = 0;
for (Worker w : workers)
if (w.isLocked())//上锁的表明worker当前正在处理任务,也就是活跃的worker
++n;
return n;
} finally {
mainLock.unlock();
}
}


public int getLargestPoolSize() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
return largestPoolSize;
} finally {
mainLock.unlock();
}
}

//获取任务的总数,这个方法慎用,若是个无解队列,或者队列挤压比较严重,会很蛋疼
public long getTaskCount() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
long n = completedTaskCount;//比如有些worker被销毁后,其处理完成的任务就会叠加到这里
for (Worker w : workers) {
n += w.completedTasks;//叠加历史处理完成的任务
if (w.isLocked())//上锁表明正在处理任务,也算一个
++n;
}
return n + workQueue.size();//获取队列中的数目
} finally {
mainLock.unlock();
}
}


public long getCompletedTaskCount() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
long n = completedTaskCount;
for (Worker w : workers)
n += w.completedTasks;
return n;
} finally {
mainLock.unlock();
}
}

#总结
这篇博客基本上覆盖了线程池的方方面面,但仍然有非常多的细节可以深究,比如说异常的处理,可以参照之前的一篇博客:《深度解析Java线程池的异常处理机制》,另外还有AQS、unsafe等可以之后再单独总结。

原文链接:https://github.com/aCoder2013/blog/issues/28

Apache Dubbo已不再局限于Java语言

大卫 发表了文章 • 0 个评论 • 1260 次浏览 • 2018-07-11 22:26 • 来自相关话题

2017 年 9 月 7 日,在沉寂了4年之后,Dubbo 悄悄的在 GitHub 发布了 2.5.4 版本。随后又迅速发布了 2.5.5、2.5.6、2.5.7 等release。在 2017年 10 月举行的云栖大会上,阿里宣布 Dubbo 被列入集团重点 ...查看全部
2017 年 9 月 7 日,在沉寂了4年之后,Dubbo 悄悄的在 GitHub 发布了 2.5.4 版本。随后又迅速发布了 2.5.5、2.5.6、2.5.7 等release。在 2017年 10 月举行的云栖大会上,阿里宣布 Dubbo 被列入集团重点维护开源项目,这也就意味着 Dubbo 重启,开始重新进入新征程。Dubbo 进入 Apache 孵化器,如果毕业后,项目移出 incubator,成为正式开源项目,在这期间还是有很多工作要做。
1.jpg

2.jpg

3.jpg

近来进入dubbo官网,发现又改版升级了,很清爽简洁,打开速率比之前更快了。
4.jpg

5.jpg

6.jpg

有几个亮点,可从上图生态中发现:
##不局限于Java
Dubbo已不在局限在Java语言范围内,开始支持Node.js,Python。具体使用过程Dubbo的社区生态中找到对应方法。
##支持SpringBoot
Dubbo支持通过API方式启动方式中已经融合SpringBoot,从github的incubator-dubbo-spring-boot-project项目中可以看到,已经迭代3个版本,支持最新的SpringBoot 2.0,2018-6-21日发布的两个发个release新版本中可以看到。
##支持Rest
Dubbo在重启维护后,dubbo-2.6.0版本中奖当当团队维护的DubboX合并近来(2018-01-08)。基于标准的Java REST API——JAX-RS 2.0(Java API for RESTful Web Services的简写)实现的REST调用支持。
7.jpg

##高性能序列化框架
在DubboX的分支合并中,kryo, FST的serialization framework,提升接口数据的交互效率。

Github上接近2W个Star,相信随着周边生态不断的完善,Dubbo会进入到更多的企业中,发挥更大的效用。

原文链接:https://mp.weixin.qq.com/s/1sGu2KOKBtLZN4l3r2OFGw

为多个PHP-FPM容器量身打造单一Nginx镜像

kelvinji2009 发表了文章 • 2 个评论 • 2226 次浏览 • 2018-06-09 16:31 • 来自相关话题

【译者的话】这篇博客主要讲述了如何创建一个可以关联Docker环境变量与Nginx配置文件的Nginx镜像,供你所有的`PHP-FPM`容器应用。 最近我一直在努力部署一套使用Docker容器的PHP微服务。其中一个问题是我们的P ...查看全部
【译者的话】这篇博客主要讲述了如何创建一个可以关联Docker环境变量与Nginx配置文件的Nginx镜像,供你所有的`PHP-FPM`容器应用。

最近我一直在努力部署一套使用Docker容器的PHP微服务。其中一个问题是我们的PHP应用程序被设置为与`PHP-FPM`和`Nginx`一起工作(而不是这里所说的简单的Apache/PHP设置),因此每个PHP微服务需要两个容器(也就是相当于两个Docker镜像):

* PHP-FPM容器
* Nginx容器

假设一个应用运行超过六个PHP微服务,算上你的dev和prod环境,那么最终差不多会产生接近30个容器。我决定构建一个单独的Nginx Docker镜像,将`PHP-FPM`主机名作为环境变量映射到这个镜像里面独特的配置文件中,而不是为每个`PHP-FPM`微服务的镜像构建独特的Nginx镜像。
XoCNwnk.jpg

在这篇博客文章中,我将概述我从上述方法1到方法2的过程,最后用介绍如何使用新定制Nginx Docker镜像的解决方案来结束这篇博客。

我已经将这个镜像开源GitHub,所以如果这刚好是您经常遇到的问题,请随时查看。
# 为什么是Nginx?

`PHP-FPM`和Nginx一起使用可以产生更好的PHP应用程序性能,但缺点是PHP-FPM Docker镜像默认没有像`PHP Apache`镜像那样与Nginx捆绑在一起。

如果您想将Nginx容器连接到PHP-FPM后端,则需要将该后端的DNS记录添加到您的Nginx配置中。

例如,如果PHP-FPM容器作为名为`php-fpm-api`的容器运行,那么您的Nginx配置文件应该这样写:
nginx
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
# This line passes requests through to the PHP-FPM container
fastcgi_pass php-fpm-api:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}

如果你只服务一个PHP-FPM容器应用,在你的Nginx容器的配置文件中硬编码对应的名字是可以的。但是,如我上面提到的,每个PHP服务都需要一个对应的Nginx容器,我们就需要运行多个Nginx容器。创建一个新的Nginx镜像(我们后面必须维护和升级)将是一件痛苦的事情,因为即使管理一堆不同的卷,对于更改单个变量名称似乎也有很多工作要做。
# 第一个解决方案:使用Docker文档里提到的方法`envsubst`


起初,我认为这很容易。在Docker文档中关于如何使用`envsubst`有一个很好的小章节,但不幸的是,这不适用于我的Nginx配置文件:

vhost.conf
nginx
server {
listen 80;
index index.php index.html;
root /var/www/public;
client_max_body_size 32M;

location / {
try_files $uri /index.php?$args;
}

location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass ${NGINX_HOST}:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
}

我的`vhost.conf`文件用到了好几个Nginx内置的环境变量,结果当我运行Docker文档里提到的如下命令行时,提示错误:`$uri`和`fastcgi_script_name`未定义。
shell
/bin/bash -c "envsubst < /etc/nginx/conf.d/mysite.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"

这些变量通常由Nginx本身传入,所以不容易搞清楚他们是什么和怎么进行参数传递的,而且这会影响容器的动态可配置性。
# 另一个差点成功的Docker镜像

接下来,我开始搜索不同的Nginx的基础镜像。找到了两个,但是这两个都是两年没有更新了。我从martin/nginx开始,尝试看看能不能得到一个可以工作的原型。

Martin的镜像有点不太一样,因为它要求特定的文件目录结构。我先在`Dockerfile`中添加了:
FROM martin/nginx

接下来,我添加了`app/`空目录,只包含一个`vhost.conf`文件的`conf/`目录。

vhost.conf
nginx
server {
listen 80;
index index.php index.html;
root /var/www/public;
client_max_body_size 32M;

location / {
try_files $uri /index.php?$args;
}

location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass $ENV{"NGINX_HOST"}:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
}

这个跟我原始的配置文件差不多,只修改了一行:`fastcgi_pass $ENV{"NGINX_HOST"}:9000;`。现在当我想要启动一个Nginx容器和一个叫`php-fpm-api`的PHP容器的时候,我可以先编译一个新的镜像,然后在它运行的时候传递给它对应的环境变量:
shell
docker build -t shiphp/nginx-env:test .
docker run -it --rm -e NGINX_HOST=php-fpm-api shiphp/nginx-env:test

成功了!但是,这个方法有两个问题困扰着我:

  1. 基础镜像版本陈旧,两年多没更新了。这可能会造成安全和性能风险。
  2. 要求一个`app`的空目录似乎没啥必要,再加上我的文件放在不同的目录。

# 最终解决方案

我觉得Martin的镜像是个不错的自定义方案选择。所以,我`fork`了他的仓库并构建了一个新的并解决了以上两个问题的Nginx基础镜像。现在,如果你想运行一个伴随着nginx容器的动态命名后端应用,你只需要简单地这么做:
shell
# Pull down the latest from Docker Hub
docker pull shiphp/nginx-env:latest

# Run a PHP container named "php-fpm-api"
docker run --name php-fpm-api -v $(pwd):/var/www php:fpm

# Start this NGinx container linked to the PHP-FPM container
docker run --link php-fpm-api -e NGINX_HOST=php-fpm-api shiphp/nginx-env

如果你想自定义这个镜像,添加你自己的文件或者Nginx配置文件,只需要像下面这样扩展你的`Dockerfile`:
FROM shiphp/nginx-env

ONBUILD ADD /etc/nginx/conf.d/

现在我所有的PHP-FPM容器都使用单个Nginx镜像的实例,当我需要升级Nginx、修改权限或者配置一些东西的时候,这让我的生活变得简单多了。

所有的代码都放在GitHub上面了。如果您发现任何问题或想要提出改进建议,请随时创建`issue`。如果您对这个问题或Docker相关的任何问题,可以在Twitter上找我一起讨论。

原文链接:Building a Single NGinx Docker Image For All My PHP-FPM Containers(翻译:kelvinji

Java和Docker限制的那些事儿

kelvinji2009 发表了文章 • 0 个评论 • 4813 次浏览 • 2018-06-04 15:06 • 来自相关话题

【编者的话】Java和Docker不是天然的朋友。 Docker可以设置内存和CPU限制,而Java不能自动检测到。使用Java的Xmx标识(繁琐/重复)或新的实验性JVM标识,我们可以解决这个问题。 加强Docker容器与Java1 ...查看全部
【编者的话】Java和Docker不是天然的朋友。 Docker可以设置内存和CPU限制,而Java不能自动检测到。使用Java的Xmx标识(繁琐/重复)或新的实验性JVM标识,我们可以解决这个问题。

加强Docker容器与Java10集成 - Docker官方博客在最新版本的Java的OpenJ9和OpenJDK10中彻底解决了这个问题。
# 虚拟化中的不匹配
Java和Docker的结合并不是完美匹配的,最初的时候离完美匹配有相当大的距离。对于初学者来说,JVM的全部设想就是,虚拟机可以让程序与底层硬件无关。

那么,把我们的Java应用打包到JVM中,然后整个再塞进Docker容器中,能给我们带来什么好处呢?大多数情况下,你只是在复制JVMs和Linux容器,除了浪费更多的内存,没任何好处。感觉这样子挺傻的。

不过,Docker可以把你的程序,设置,特定的JDK,Linux设置和应用服务器,还有其他工具打包在一起,当做一个东西。站在DevOps/Cloud的角度来看,这样一个完整的容器有着更高层次的封装。
## 问题一:内存
时至今日,绝大多数产品级应用仍然在使用Java 8(或者更旧的版本),而这可能会带来问题。Java 8(update 131之前的版本)跟Docker无法很好地一起工作。问题是在你的机器上,JVM的可用内存和CPU数量并不是Docker允许你使用的可用内存和CPU数量。

比如,如果你限制了你的Docker容器只能使用100MB内存,但是呢,旧版本的Java并不能识别这个限制。Java看不到这个限制。JVM会要求更多内存,而且远超这个限制。如果使用太多内存,Docker将采取行动并杀死容器内的进程!JAVA进程被干掉了,很明显,这并不是我们想要的。

为了解决这个问题,你需要给Java指定一个最大内存限制。在旧版本的Java(8u131之前),你需要在容器中通过设置`-Xmx`来限制堆大小。这感觉不太对,你可不想定义这些限制两次,也不太想在你的容器中来定义。

幸运的是我们现在有了更好的方式来解决这个问题。从Java 9之后(8u131+),JVM增加了如下标志:
-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap

这些标志强制JVM检查Linux的`cgroup`配置,Docker是通过`cgroup`来实现最大内存设置的。现在,如果你的应用到达了Docker设置的限制(比如500MB),JVM是可以看到这个限制的。JVM将会尝试GC操作。如果仍然超过内存限制,JVM就会做它该做的事情,抛出`OutOfMemoryException`。也就是说,JVM能够看到Docker的这些设置。

从Java 10之后(参考下面的测试),这些体验标志位是默认开启的,也可以使用`-XX:+UseContainerSupport`来使能(你可以通过设置`-XX:-UseContainerSupport`来禁止这些行为)。
## 问题二:CPU
第二个问题是类似的,但它与CPU有关。简而言之,JVM将查看硬件并检测CPU的数量。它会优化你的runtime以使用这些CPUs。但是同样的情况,这里还有另一个不匹配,Docker可能不允许你使用所有这些CPUs。可惜的是,这在Java 8或Java 9中并没有修复,但是在Java 10中得到了解决。

从Java 10开始,可用的CPUs的计算将采用以不同的方式(默认情况下)解决此问题(同样是通过`UseContainerSupport`)。
# Java和Docker的内存处理测试
作为一个有趣的练习,让我们验证并测试Docker如何使用几个不同的JVM版本/标志甚至不同的JVM来处理内存不足。

首先,我们创建一个测试应用程序,它只是简单地“吃”内存并且不释放它。
java
import java.util.ArrayList;
import java.util.List;

public class MemEat {
public static void main(String[] args) {
List l = new ArrayList<>();
while (true) {
byte b[] = new byte[1048576];
l.add(b);
Runtime rt = Runtime.getRuntime();
System.out.println( "free memory: " + rt.freeMemory() );
}
}
}

我们可以启动Docker容器并运行这个应用程序来查看会发生什么。
## 测试一:Java 8u111
首先,我们将从具有旧版本Java 8的容器开始(update 111)。
shell
docker run -m 100m -it java:openjdk-8u111 /bin/bash

我们编译并运行`MemEat.java`文件:
shell
javac MemEat.java

java MemEat
...
free memory: 67194416
free memory: 66145824
free memory: 65097232
Killed

正如所料,Docker已经杀死了我们的Java进程。不是我们想要的(!)。你也可以看到输出,Java认为它仍然有大量的内存需要分配。

我们可以通过使用-Xmx标志为Java提供最大内存来解决此问题:
shell
javac MemEat.java

java -Xmx100m MemEat
...
free memory: 1155664
free memory: 1679936
free memory: 2204208
free memory: 1315752
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at MemEat.main(MemEat.java:8)

在提供了我们自己的内存限制之后,进程正常停止,JVM理解它正在运行的限制。然而,问题在于你现在将这些内存限制设置了两次,Docker一次,JVM一次。
## 测试二:Java 8u144
如前所述,随着增加新标志来修复问题,JVM现在可以遵循Docker所提供的设置。我们可以使用版本新一点的JVM来测试它。
shell
docker run -m 100m -it adoptopenjdk/openjdk8 /bin/bash

(在撰写本文时,此OpenJDK Java镜像的版本是Java 8u144)

接下来,我们再次编译并运行`MemEat.java`文件,不带任何标志:
shell
javac MemEat.java

java MemEat
...
free memory: 67194416
free memory: 66145824
free memory: 65097232
Killed

依然存在同样的问题。但是我们现在可以提供上面提到的实验性标志来试试看:
shell
javac MemEat.java
java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap MemEat
...
free memory: 1679936
free memory: 2204208
free memory: 1155616
free memory: 1155600
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at MemEat.main(MemEat.java:8)

这一次我们没有告诉JVM限制的是什么,我们只是告诉JVM去检查正确的限制设置!现在感觉好多了。
## 测试三:Java 10u23
有些人在评论和Reddit上提到Java 10通过使实验标志成为新的默认值来解决所有问题。这种行为可以通过禁用此标志来关闭:`-XX:-UseContainerSupport`。

当我测试它时,它最初不起作用。在撰写本文时,AdoptAJDK OpenJDK10镜像与`jdk-10+23`一起打包。这个JVM显然还是不理解`UseContainerSupport`标志,该进程仍然被Docker杀死。
shell
docker run -m 100m -it adoptopenjdk/openjdk10 /bin/bash

测试了代码(甚至手动提供需要的标志):
shell
javac MemEat.java

java MemEat
...
free memory: 96262112
free memory: 94164960
free memory: 92067808
free memory: 89970656
Killed

java -XX:+UseContainerSupport MemEat

Unrecognized VM option 'UseContainerSupport'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.

## 测试四:Java 10u46(Nightly)
我决定尝试AdoptAJDK OpenJDK 10的最新`nightly`构建。它包含的版本是Java 10+46,而不是Java 10+23。
shell
docker run -m 100m -it adoptopenjdk/openjdk10:nightly /bin/bash

然而,在这个`ngithly`构建中有一个问题,导出的PATH指向旧的Java 10+23目录,而不是10+46,我们需要修复这个问题。
shell
export PATH=$PATH:/opt/java/openjdk/jdk-10+46/bin/

javac MemEat.java

java MemEat
...
free memory: 3566824
free memory: 2796008
free memory: 1480320
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at MemEat.main(MemEat.java:8)

成功!不提供任何标志,Java 10依然可以正确检测到Dockers内存限制。
## 测试五:OpenJ9
我最近也在试用OpenJ9,这个免费的替代JVM已经从IBM J9开源,现在由Eclipse维护。

请在我的下一篇博文中阅读关于OpenJ9的更多信息。

它运行速度快,内存管理非常好,性能卓越,经常可以为我们的微服务节省多达30-50%的内存。这几乎可以将Spring Boot应用程序定义为'micro'了,其运行时间只有100-200mb,而不是300mb+。我打算尽快就此写一篇关于这方面的文章。

但令我惊讶的是,OpenJ9还没有类似于Java 8/9/10+中针对`cgroup`内存限制的标志(backported)的选项。如果我们将以前的测试用例应用到最新的AdoptAJDK OpenJDK 9 + OpenJ9 build:
shell
docker run -m 100m -it adoptopenjdk/openjdk9-openj9 /bin/bash

我们添加OpenJDK标志(OpenJ9会忽略的标志):
shell
java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap MemEat
...
free memory: 83988984
free memory: 82940400
free memory: 81891816
Killed

Oops,JVM再次被Docker杀死。

我真的希望类似的选项将很快添加到OpenJ9中,因为我希望在生产环境中运行这个选项,而不必指定最大内存两次。 Eclipse/IBM正在努力修复这个问题,已经提了issues,甚至已经针对issues提交了PR。
## 更新:(不推荐Hack)
一个稍微丑陋/hacky的方式来解决这个问题是使用下面的组合标志:
shell
java -Xmx`cat /sys/fs/cgroup/memory/memory.limit_in_bytes` MemEat
...
free memory: 3171536
free memory: 2127048
free memory: 2397632
free memory: 1344952
JVMDUMP039I Processing dump event "systhrow", detail "java/lang/OutOfMemoryError" at 2018/05/15 14:04:26 - please wait.
JVMDUMP032I JVM requested System dump using '//core.20180515.140426.125.0001.dmp' in response to an event
JVMDUMP010I System dump written to //core.20180515.140426.125.0001.dmp
JVMDUMP032I JVM requested Heap dump using '//heapdump.20180515.140426.125.0002.phd' in response to an event
JVMDUMP010I Heap dump written to //heapdump.20180515.140426.125.0002.phd
JVMDUMP032I JVM requested Java dump using '//javacore.20180515.140426.125.0003.txt' in response to an event
JVMDUMP010I Java dump written to //javacore.20180515.140426.125.0003.txt
JVMDUMP032I JVM requested Snap dump using '//Snap.20180515.140426.125.0004.trc' in response to an event
JVMDUMP010I Snap dump written to //Snap.20180515.140426.125.0004.trc
JVMDUMP013I Processed dump event "systhrow", detail "java/lang/OutOfMemoryError".
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at MemEat.main(MemEat.java:8)

在这种情况下,堆大小受限于分配给Docker实例的内存,这适用于较旧的JVM和OpenJ9。这当然是错误的,因为容器本身和堆外的JVM的其他部分也使用内存。但它似乎工作,显然Docker在这种情况下是宽松的。也许某些bash大神会做出更好的版本,从其他进程的字节中减去一部分。

无论如何,不要这样做,它可能无法正常工作。
## 测试六:OpenJ9(Nightly)
有人建议使用OpenJ9的最新`nightly`版本。
shell
docker run -m 100m -it adoptopenjdk/openjdk9-openj9:nightly /bin/bash

最新的OpenJ9夜间版本,它有两个东西:

  1. 另一个有问题的PATH参数,需要先解决这个问题
  2. JVM支持新标志UseContainerSupport(就像Java 10一样)

shell
export PATH=$PATH:/opt/java/openjdk/jdk-9.0.4+12/bin/

javac MemEat.java

java -XX:+UseContainerSupport MemEat
...
free memory: 5864464
free memory: 4815880
free memory: 3443712
free memory: 2391032
JVMDUMP039I Processing dump event "systhrow", detail "java/lang/OutOfMemoryError" at 2018/05/15 21:32:07 - please wait.
JVMDUMP032I JVM requested System dump using '//core.20180515.213207.62.0001.dmp' in response to an event
JVMDUMP010I System dump written to //core.20180515.213207.62.0001.dmp
JVMDUMP032I JVM requested Heap dump using '//heapdump.20180515.213207.62.0002.phd' in response to an event
JVMDUMP010I Heap dump written to //heapdump.20180515.213207.62.0002.phd
JVMDUMP032I JVM requested Java dump using '//javacore.20180515.213207.62.0003.txt' in response to an event
JVMDUMP010I Java dump written to //javacore.20180515.213207.62.0003.txt
JVMDUMP032I JVM requested Snap dump using '//Snap.20180515.213207.62.0004.trc' in response to an event
JVMDUMP010I Snap dump written to //Snap.20180515.213207.62.0004.trc
JVMDUMP013I Processed dump event "systhrow", detail "java/lang/OutOfMemoryError".
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

TADAAA,正在修复中!

奇怪的是,这个标志在OpenJ9中默认没有启用,就像它在Java 10中一样。再说一次:确保你测试了这是你想在一个Docker容器中运行Java。
# 结论
简言之:注意资源限制的不匹配。测试你的内存设置和JVM标志,不要假设任何东西。

如果您在Docker容器中运行Java,请确保你设置了Docker内存限制和在JVM中也做了限制,或者你的JVM能够理解这些限制。

如果您无法升级您的Java版本,请使用`-Xmx`设置您自己的限制。


对于Java 8和Java 9,请更新到最新版本并使用:
-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap

对于Java 10,确保它支持'UseContainerSupport'(更新到最新版本)。

对于OpenJ9(我强烈建议使用,可以在生产环境中有效减少内存占用量),现在使用`-Xmx`设置限制,但很快会出现一个支持`UseContainerSupport`标志的版本。

原文链接:Java and Docker, the limitations(翻译:kelvinji

Java 10发布后,Docker容器管理能力得到显著增强

kelvinji2009 发表了文章 • 0 个评论 • 2280 次浏览 • 2018-06-04 15:05 • 来自相关话题

Apache Spark、Kafka等运行在JVM中的传统企业应用,实际上都可以运行在容器环境之内。然而,在容器内运行JVM的方案近期却遇到了麻烦——由于内存与CPU资源及利用率受限,致使其性能表现无法令人满意。究其原因,这是因为Java无法意识到自身正运行在 ...查看全部
Apache Spark、Kafka等运行在JVM中的传统企业应用,实际上都可以运行在容器环境之内。然而,在容器内运行JVM的方案近期却遇到了麻烦——由于内存与CPU资源及利用率受限,致使其性能表现无法令人满意。究其原因,这是因为Java无法意识到自身正运行在容器当中。

随着Java 10的发布,JVM终于可以识别出由容器控制组(cgroups)提出的限制集合。这意味着内存与CPU限制条件皆可用于直接在容器内实现对Java应用的管理,具体包括:


* 在容器内部设置内存限制
* 在容器内部设置可用CPU个数
* 在容器内设置CPU限制


Java 10的这些优化成果可在Docker for mac/windows和Docker企业版中正常起效。


#容器内存限制

一直到Java 9版本,JVM仍然无法通过在容器中使用标识来识别内存或CPU限制。在Java 10中,内存限制可以被自动识别,而且这一特性将默认启用。

Java对服务器进行分级定义,如果一台服务器有双CPU加2 GB内存,那么默认的堆大小将为物理内存的1/4。这里假定有一台安装Docker企业版四CPU 加2 GB内存的服务器,下面我们来比较分别运行有Java 8和Java 10的容器的具体区别。先来看Java 8:

docker container run -it -m512 --entrypoint bash openjdk:latest

$ docker-java-home/bin/java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
uintx MaxHeapSize := 524288000 {product}
openjdk version "1.8.0_162"


我们可以看到最大的堆大小是512 MB,刚好等于(1/4)x 2 GB,但这是通过Docker EE自动设置的,而而通过设置容器实现。作为比较,我们再来看Java 10环境下运行同样的命令是什么结果。

docker container run -it -m512M --entrypoint bash openjdk:10-jdk

$ docker-java-home/bin/java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
size_t MaxHeapSize = 134217728 {product} {ergonomic}
openjdk version "10" 2018-03-20


上面的结果显示,容器里的内存限制非常接近我们期望的128 MB。

#设置可用CPU个数
默认情况下,各容器所能获取的主机CPU时钟周期不受限制。通过额外设置,我们可以指定某一特定容器所能获得的主机CPU时钟周期。

Java 10能够识别这些限制:

docker container run -it --cpus 2 openjdk:10-jdk
jshell> Runtime.getRuntime().availableProcessors()
$1 ==> 2


所有分配给Docker EE的CPU均获得同样比例的CPU时钟周期。这一比例可以通过调整CPU共享比重(相对于其他所有运行的容器的比重)来进行修改。

这一比重只在运行CPU敏感型进程时才会生效。当某一容器中的任务处于空闲状态,那么其它容器可以使用剩余的全部CPU时钟周期。真实CPU时间量在很大程度上取决于运行在该系统之上的容器数量。这些在Java 10中都可以设置:

docker container run -it --cpu-shares 2048 openjdk:10-jdk
jshell> Runtime.getRuntime().availableProcessors()
$1 ==> 2


Java 10中也可以设置CPU集合限制(允许哪些CPU执行)。

docker run -it --cpuset-cpus="1,2,3" openjdk:10-jdk
jshell> Runtime.getRuntime().availableProcessors()
$1 ==> 3

#分配内存与CPU
利用Java 10,我们可以使用容器设置来估算部署应用程序所需的内存和CPU配额。我们假设已经确定了容器中运行的每个进程的内存堆和CPU要求,并设置了JAVA_OPTS。例如,如果您有一个跨十个节点分布的应用程序,其中五个节点需要512 Mb的内存,且各自分配得1024个CPU比重;另外五个节点需要256 Mb,且各自分配得512个CPU比重。请注意,1块CPU的整体计算能力将被拆分为1024个比重单位。



对于内存,应用程序需要至少分配5 Gb容量。



512 Mb x 5 = 2.56 Gb




256Mb x 5 = 1.28 Gb

该应用程序需要8块CPU才能高效运行。

1024 x 5 = 5 CPU

512 x 5 = 3 CPU



最佳实践建议用户对应用程序进行分析,以确定运行在JVM每个进程的内存和CPU分配需求。但凭借着对容器需求的识别能力,Java 10使我们得以准确估算工作负载的CPU及内存资源占用情况,从而防止容器内运行的Java应用程序遭遇内存或CPU不足的问题。



原文链接:IMPROVED DOCKER CONTAINER INTEGRATION WITH JAVA 10(翻译:kelvinji)

基于 Docker 的微服务架构实践

老李 发表了文章 • 2 个评论 • 7049 次浏览 • 2018-04-11 15:26 • 来自相关话题

前言 基于 Docker 的容器技术是在2015年的时候开始接触的,两年多的时间,作为一名 Docker 的 DevOps,也见证了 Docker 的技术体系的快速发展。本文主要是结合在公司搭建的微服务架构的实践过程,做一个简单的总结 ...查看全部
前言

基于 Docker 的容器技术是在2015年的时候开始接触的,两年多的时间,作为一名 Docker 的 DevOps,也见证了 Docker 的技术体系的快速发展。本文主要是结合在公司搭建的微服务架构的实践过程,做一个简单的总结。希望给在创业初期探索如何布局服务架构体系的 DevOps,或者想初步了解企业级架构的同学们一些参考。

Microservice 和 Docker

对于创业公司的技术布局,很多声音基本上是,创业公司就是要快速上线快速试错。用单应用或者前后台应用分离的方式快速集成,快速开发,快速发布。但其实这种结果造成的隐性成本会更高。

当业务发展起来,开发人员多了之后,就会面临庞大系统的部署效率,开发协同效率问题。然后通过服务的拆分,数据的读写分离、分库分表等方式重新架构,而且这种方式如果要做的彻底,需要花费大量人力物力。

个人建议,DevOps 结合自己对于业务目前以及长期的发展判断,能够在项目初期使用微服务架构,多为后人谋福。

随着 Docker 周围开源社区的发展,让微服务架构的概念能有更好的一个落地实施的方案。并且在每一个微服务应用内部,都可以使用 DDD(Domain-Drive Design)的六边形架构来进行服务内的设计。关于 DDD 的一些概念也可以参考之前写的几篇文章:领域驱动设计整理——概念&架构、领域驱动设计整理——实体和值对象设计、领域服务、领域事件。

清晰的微服务的领域划分,服务内部有架构层次的优雅的实现,服务间通过 RPC 或者事件驱动完成必要的 IPC,使用 API gateway 进行所有微服务的请求转发,非阻塞的请求结果合并。本文下面会具体介绍,如何在分布式环境下,也可以快速搭建起来具有以上几点特征的,微服务架构 with Docker。

服务发现模式

如果使用 Docker 技术来架构微服务体系,服务发现就是一个必然的课题。目前主流的服务发现模式有两种:客户端发现模式,以及服务端发现模式。

客户端发现模式

客户端发现模式的架构图如下:

A1.png




客户端发现模式的典型实现是Netflix体系技术。客户端从一个服务注册服务中心查询所有可用服务实例。客户端使用负载均衡算法从多个可用的服务实例中选择出一个,然后发出请求。比较典型的一个开源实现就是 Netflix 的 Eureka。

Netflix-Eureka

Eureka 的客户端是采用自注册的模式,客户端需要负责处理服务实例的注册和注销,发送心跳。

在使用 SpringBoot 集成一个微服务时,结合 SpringCloud 项目可以很方便得实现自动注册。在服务启动类上添加@EnableEurekaClient即可在服务实例启动时,向配置好的 Eureka 服务端注册服务,并且定时发送以心跳。客户端的负载均衡由 Netflix Ribbon 实现。服务网关使用 Netflix Zuul,熔断器使用 Netflix Hystrix。

除了服务发现的配套框架,SpringCloud 的 Netflix-Feign,提供了声明式的接口来处理服务的 Rest 请求。当然,除了使用 FeignClient,也可以使用 Spring RestTemplate。项目中如果使用@FeignClient可以使代码的可阅读性更好,Rest API 也一目了然。

服务实例的注册管理、查询,都是通过应用内调用 Eureka 提供的 REST API 接口(当然使用 SpringCloud-Eureka 不需要编写这部分代码)。由于服务注册、注销是通过客户端自身发出请求的,所以这种模式的一个主要问题是对于不同的编程语言会注册不同服务,需要为每种开发语言单独开发服务发现逻辑。另外,使用 Eureka 时需要显式配置健康检查支持。

服务端发现模式

服务端发现模式的架构图如下:


A2.png



客户端向负载均衡器发出请求,负载均衡器向服务注册表发出请求,将请求转发到注册表中可用的服务实例。服务实例也是在注册表中注册,注销的。负载均衡可以使用可以使用 Haproxy 或者 Nginx。服务端发现模式目前基于 Docker 的主流方案主要是 Consul、Etcd 以及 Zookeeper。

Consul

Consul 提供了一个 API 允许客户端注册和发现服务。其一致性上基于RAFT算法。通过 WAN 的 Gossip 协议,管理成员和广播消息,以完成跨数据中心的同步,且支持 ACL 访问控制。Consul 还提供了健康检查机制,支持 kv 存储服务(Eureka 不支持)。Consul 的一些更详细的介绍可以参考之前写的一篇:Docker 容器部署 Consul 集群。

Etcd

Etcd 都是强一致的(满足 CAP 的 CP),高可用的。Etcd 也是基于 RAFT 算法实现强一致性的 KV 数据同步。Kubernetes 中使用 Etcd 的 KV 结构存储所有对象的生命周期。

关于 Etcd 的一些内部原理可以看下etcd v3原理分析

Zookeeper

ZK 最早应用于 Hadoop,其体系已经非常成熟,常被用于大公司。如果已经有自己的 ZK 集群,那么可以考虑用 ZK 来做自己的服务注册中心。

Zookeeper 同 Etcd 一样,强一致性,高可用性。一致性算法是基于 Paxos 的。对于微服务架构的初始阶段,没有必要用比较繁重的 ZK 来做服务发现。

服务注册

服务注册表是服务发现中的一个重要组件。除了 Kubernetes、Marathon 其服务发现是内置的模块之外。服务都是需要注册到注册表上。上文介绍的 Eureka、consul、etcd 以及 ZK 都是服务注册表的例子。

微服务如何注册到注册表也是有两种比较典型的注册方式:自注册模式,第三方注册模式。

自注册模式 Self-registration pattern

上文中的 Netflix-Eureka 客户端就是一个典型的自注册模式的例子。也即每个微服务的实例本身,需要负责注册以及注销服务。Eureka 还提供了心跳机制,来保证注册信息的准确,具体的心跳的发送间隔时间可以在微服务的 SpringBoot 中进行配置。

如下,就是使用 Eureka 做注册表时,在微服务(SpringBoot 应用)启动时会有一条服务注册的信息:

com.netflix.discovery.DiscoveryClient : DiscoveryClient_SERVICE-USER/{your_ip}:service-user:{port}:cc9f93c54a0820c7a845422f9ecc73fb: registering service...

同样,在应用停用时,服务实例需要主动注销本实例信息:

2018-01-04 20:41:37.290 INFO 49244 --- [ Thread-8] c.n.e.EurekaDiscoveryClientConfiguration : Unregistering application service-user with eureka with status DOWN
2018-01-04 20:41:37.340 INFO 49244 --- [ Thread-8] com.netflix.discovery.DiscoveryClient : Shutting down DiscoveryClient ...
2018-01-04 20:41:37.381 INFO 49244 --- [ Thread-8] com.netflix.discovery.DiscoveryClient : Unregistering ...
2018-01-04 20:41:37.559 INFO 49244 --- [ Thread-8] com.netflix.discovery.DiscoveryClient : DiscoveryClient_SERVICE-USER/{your_ip}:service-user:{port}:cc9f93c54a0820c7a845422f9ecc73fb - deregister status: 200

自注册方式是比较简单的服务注册方式,不需要额外的设施或代理,由微服务实例本身来管理服务注册。但是缺点也很明显,比如 Eureka 目前只提供了 Java 客户端,所以不方便多语言的微服务扩展。因为需要微服务自己去管理服务注册逻辑,所以微服务实现也耦合了服务注册和心跳机制。跨语言性比较差。

第三方注册模式 Third party registration pattern

第三方注册,也即服务注册的管理(注册、注销服务)通过一个专门的服务管理器(Registar)来负责。Registrator 就是一个开源的服务管理器的实现。Registrator 提供了对于 Etcd 以及 Consul 的注册表服务支持。

Registrator 作为一个代理服务,需要部署、运行在微服务所在的服务器或者虚拟机中。比较简单的安装方式就是通过 Docker,以容器的方式来运行。三方注册模式的架构图如下:


A3.png



通过添加一个服务管理器,微服务实例不再直接向注册中心注册,注销。由服务管理器(Registar)通过订阅服务,跟踪心跳,来发现可用的服务实例,并向注册中心(consul、etcd 等)注册,注销实例,以及发送心跳。这样就可以实现服务发现组件和微服务架构的解耦。

Registrator 配合 Consul,以及 Consul Template 搭建服务发现中心,可以参考: Scalable Architecture DR CoN: Docker, Registrator, Consul, Consul Template and Nginx 。此文示例了 Nginx 来做负载均衡,在具体的实施过程中也可以用 Haproxy 或其他方案进行替代。

小结

除了以上几种做服务发现的技术,Kubernetes 自带了服务发现模块,负责处理服务实例的注册和注销。Kubernetes 也在每个集群节点上运行代理,来实现服务端发现路由器的功能。如果编排技术使用的 k8n,可以用 k8n 的一整套 Docker 微服务方案,对 k8n 感兴趣的可以阅读下Kubernetes 架构设计与核心原理。

在实际的技术选型中,最主要还是要结合业务、系统的未来发展的特征进行合理判断。

在 CAP 理论中。Eureka 满足了 AP,Consul 是 CA,ZK 和 Etcd 是 CP。 在分布式场景下 Eureka 和 Consul 都能保证可用性。而搭建 Eureka 服务会相对更快速,因为不需要搭建额外的高可用服务注册中心,在小规模服务器实例时,使用 Eureka 可以节省一定成本。
Eureka、Consul 都提供了可以查看服务注册数据的 WebUI 组件。Consul 还提供了 KV 存储,支持支持 http 和 dns 接口。对于创业公司最开始搭建微服务,比较推荐这两者。
在多数据中心方面,Consul 自带数据中心的 WAN 方案。ZK 和 Etcd 均不提供多数据中心功能的支持,需要额外的开发。
跨语言性上,Zookeeper 需要使用其提供的客户端 api,跨语言支持较弱。Etcd、Eureka 都支持 http,Etcd 还支持 grpc。Consul 除了 http 之外还提供了 DNS 的支持。
安全方面,Consul,Zookeeper 支持 ACL,另外 Consul、Etcd 支持安全通道 Https。
SpringCloud 目前对于 Eureka、Consul、Etcd、ZK 都有相应的支持。
Consul 和 Docker 一样,都是用 Go 语言实现,基于 Go 语言的微服务应用可以优先考虑用 Consul。

服务间的 IPC 机制

按照微服务的架构体系,解决了服务发现的问题之后。就需要选择合适的服务间通信的机制。如果是在 SpringBoot 应用中,使用基于 Http 协议的 REST API 是一种同步的解决方案。而且 Restful 风格的 API 可以使每个微服务应用更加趋于资源化,使用轻量级的协议也是微服务一直提倡的。

如果每个微服务是使用 DDD(Domain-Driven Design)思想的话,那么需要每个微服务尽量不使用同步的 RPC 机制。异步的基于消息的方式比如 AMQP 或者 STOMP,来松耦合微服务间的依赖会是很好的选择。目前基于消息的点对点的 pub/sub 的框架选择也比较多。下面具体介绍下两种 IPC 的一些方案。

同步

对于同步的请求/响应模式的通信方式。可以选择基于 Restful 风格的 Http 协议进行服务间通信,或者跨语言性很好的 Thrift 协议。如果是使用纯 Java 语言的微服务,也可以使用 Dubbo。如果是 SpringBoot 集成的微服务架构体系,建议选择跨语言性好、Spring 社区支持比较好的 RPC。

Dubbo

Dubbo是由阿里巴巴开发的开源的 Java 客户端的 RPC 框架。Dubbo 基于 TCP 协议的长连接进行数据传输。传输格式是使用 Hessian 二进制序列化。服务注册中心可以通过 Zookeeper 实现。

ApacheThrift

ApacheThrift 是由 Facebook 开发的 RPC 框架。其代码生成引擎可以在多种语言中,如 C++、 Java、Python、PHP、Ruby、Erlang、Perl 等创建高效的服务。传输数据采用二进制格式,其数据包要比使用 Json 或者 XML 格式的 HTTP 协议小。高并发,大数据场景下更有优势。

Rest

Rest 基于 HTTP 协议,HTTP 协议本身具有语义的丰富性。随着 Springboot 被广泛使用,越来越多的基于 Restful 风格的 API 流行起来。REST 是基于 HTTP 协议的,并且大多数开发者也是熟知 HTTP 的。

这里另外提一点,很多公司或者团队也是使用Springboot的,也在说自己是基于 Restful 风格的。但是事实其实往往是实施得并不到位。对于你的 Restful 是否是真的 Restful,可以参考这篇文章,对于 Restful 风格 API 的成熟度进行了四个层次的分析: Richardson Maturity Model steps toward the glory of REST。

如果使用Springboot的话,无论使用什么服务发现机制,都可以通过 Spring 的RestTemplate来做基础的Http请求封装。

如果使用的前文提到的Netflix-Eureka的话,可以使用Netflix-Feign。Feign是一个声明式 Web Service 客户端。客户端的负载均衡使用 Netflix-Ribbon。

异步

在微服务架构中,排除纯粹的“事件驱动架构”,使用消息队列的场景一般是为了进行微服务之间的解耦。服务之间不需要了解是由哪个服务实例来消费或者发布消息。

只要处理好自己领域范围的逻辑,然后通过消息通道来发布,或者订阅自己关注的消息就可以。目前开源的消息队列技术也很多。比如 Apache Kafka,RabbitMQ,Apache ActiveMQ 以及阿里巴巴的 RocketMQ 目前已经成为 Apache 项目之一。消息队列的模型中,主要的三个组成就是:

Producer:生产消息,将消息写入 channel。
Message Broker:消息代理,将写入 channel 的消息按队列的结构进行管理。负责存储/转发消息。Broker 一般是需要单独搭建、配置的集群,而且必须是高可用的。
Consumer:消息的消费者。目前大多数的消息队列都是保证消息至少被消费一次。所以根据使用的消息队列设施不同,消费者要做好幂等。

不同的消息队列的实现,消息模型不同。各个框架的特性也不同:

RabbitMQ

RabbitMQ 是基于 AMQP 协议的开源实现,由以高性能、可伸缩性出名的 Erlang 写成。目前客户端支持 Java、.Net/C# 和 Erlang。在 AMQP(Advanced Message Queuing Protocol)的组件中,Broker 中可以包含多个Exchange(交换机)组件。Exchange 可以绑定多个 Queue 以及其他 Exchange。

消息会按照 Exchange 中设置的 Routing 规则,发送到相应的 Message Queue。在 Consumer 消费了这个消息之后,会跟 Broker 建立连接。发送消费消息的通知。则 Message Queue 才会将这个消息移除。

Kafka

Kafka 是一个高性能的基于发布/订阅的跨语言分布式消息系统。Kafka 的开发语言为 Scala。其比较重要的特性是:

以时间复杂度为O(1)的方式快速消息持久化;
高吞吐率;
支持服务间的消息分区,及分布式消费,同时保证消息顺序传输;
支持在线水平扩展,自带负载均衡;
支持只消费且仅消费一次(Exactly Once)模式等等。
说个缺点: 管理界面是个比较鸡肋了点,可以使用开源的kafka-manager

其高吞吐的特性,除了可以作为微服务之间的消息队列,也可以用于日志收集, 离线分析, 实时分析等。

Kafka 官方提供了 Java 版本的客户端 API,Kafka 社区目前也支持多种语言,包括 PHP、Python、Go、C/C++、Ruby、NodeJS 等。

ActiveMQ

ActiveMQ 是基于 JMS(Java Messaging Service)实现的 JMSProvider。JMS主要提供了两种类型的消息:点对点(Point-to-Point)以及发布/订阅(Publish/Subscribe)。目前客户端支持 Java、C、C++、 C#、Ruby、Perl、Python、PHP。而且 ActiveMQ 支持多种协议:Stomp、AMQP、MQTT 以及 OpenWire。

RocketMQ/ONS

RocketMQ 是由阿里巴巴研发开源的高可用分布式消息队列。ONS是提供商业版的高可用集群。ONS 支持 pull/push。可支持主动推送,百亿级别消息堆积。ONS 支持全局的顺序消息,以及有友好的管理页面,可以很好的监控消息队列的消费情况,并且支持手动触发消息多次重发。

小结

通过上篇的微服务的服务发现机制,加上 Restful API,可以解决微服务间的同步方式的进程间通信。当然,既然使用了微服务,就希望所有的微服务能有合理的限界上下文(系统边界)。

微服务之间的同步通信应尽量避免,以防止服务间的领域模型互相侵入。为了避免这种情况,就可以在微服务的架构中使用一层API gateway(会在下文介绍)。所有的微服务通过API gateway进行统一的请求的转发,合并。并且API gateway也需要支持同步请求,以及NIO的异步的请求(可以提高请求合并的效率以及性能)。

消息队列可以用于微服务间的解耦。在基于Docker的微服务的服务集群环境下,网络环境会比一般的分布式集群复杂。选择一种高可用的分布式消息队列实现即可。如果自己搭建诸如Kafka、RabbitMQ集群环境的话,那对于Broker设施的高可用性会要求很高。

基于Springboot的微服务的话,比较推荐使用Kafka 或者ONS。虽然ONS是商用的,但是易于管理以及稳定性高,尤其对于必要场景才依赖于消息队列进行通信的微服务架构来说,会更适合。如果考虑到会存在日志收集,实时分析等场景,也可以搭建Kafka集群。目前阿里云也有了基于Kafka的商用集群设施。

使用 API Gateway 处理微服务请求转发、合并

前面主要介绍了如何解决微服务的服务发现和通信问题。在微服务的架构体系中,使用DDD思想划分服务间的限界上下文的时候,会尽量减少微服务之间的调用。为了解耦微服务,便有了基于API Gateway方式的优化方案。

解耦微服务的调用

比如,下面一个常见的需求场景——“用户订单列表”的一个聚合页面。需要请求”用户服务“获取基础用户信息,以及”订单服务“获取订单信息,再通过请求“商品服务”获取订单列表中的商品图片、标题等信息。如下图所示的场景 :


A4.png



如果让客户端(比如H5、Android、iOS)发出多个请求来解决多个信息聚合,则会增加客户端的复杂度。比较合理的方式就是增加API Gateway层。API Gateway跟微服务一样,也可以部署、运行在Docker容器中,也是一个Springboot应用。如下,通过Gateway API进行转发后:


A5.png



所有的请求的信息,由Gateway进行聚合,Gateway也是进入系统的唯一节点。并且Gateway和所有微服务,以及提供给客户端的也是Restful风格API。Gateway层的引入可以很好的解决信息的聚合问题。而且可以更好得适配不同的客户端的请求,比如H5的页面不需要展示用户信息,而iOS客户端需要展示用户信息,则只需要添加一个Gateway API请求资源即可,微服务层的资源不需要进行变更。

API Gateway 的特点

API gateway除了可以进行请求的合并、转发。还需要有其他的特点,才能成为一个完整的Gateway。

响应式编程

Gateway是所有客户端请求的入口。类似Facade模式。为了提高请求的性能,最好选择一套非阻塞I/O的框架。在一些需要请求多个微服务的场景下,对于每个微服务的请求不一定需要同步。前文举例的“用户订单列表”的例子中,获取用户信息,以及获取订单列表,就是两个独立请求。

只有获取订单的商品信息,需要等订单信息返回之后,根据订单的商品id列表再去请求商品微服务。为了减少整个请求的响应时间,需要Gateway能够并发处理相互独立的请求。一种解决方案就是采用响应式编程。

目前使用Java技术栈的响应式编程方式有,Java8的CompletableFuture,以及ReactiveX提供的基于JVM的实现-RxJava。

ReactiveX是一个使用可观察数据流进行异步编程的编程接口,ReactiveX结合了观察者模式、迭代器模式和函数式编程的精华。除了RxJava还有RxJS,RX.NET等多语言的实现。

对于Gateway来说,RxJava提供的Observable可以很好的解决并行的独立I/O请求,并且如果微服务项目中使用Java8,团队成员会对RxJava的函数学习吸收会更快。同样基于Lambda风格的响应式编程,可以使代码更加简洁。关于RxJava的详细介绍可以可以阅读RxJava文档和教程。

通过响应式编程的Observable模式,可以很简洁、方便得创建事件流、数据流,以及用简洁的函数进行数据的组合和转换,同时可以订阅任何可观察的数据流并执行操作。

通过使用RxJava,“用户订单列表”的资源请求时序图:


A6.png



响应式编程可以更好的处理各种线程同步、并发请求,通过Observables和Schedulers提供了透明的数据流、事件流的线程处理。在敏捷开发模式下,响应式编程使代码更加简洁,更好维护。

鉴权

Gateway作为系统的唯一入口,基于微服务的所有鉴权,都可以围绕Gateway去做。在Springboot工程中,基础的授权可以使用spring-boot-starter-security以及Spring Security(Spring Security也可以集成在Spring MVC项目中)。

Spring Security主要使用AOP,对资源请求进行拦截,内部维护了一个角色的Filter Chain。因为微服务都是通过Gateway请求的,所以微服务的@Secured可以根据Gateway中不同的资源的角色级别进行设置。

Spring Security提供了基础的角色的校验接口规范。但客户端请求的Token信息的加密、存储以及验证,需要应用自己完成。对于Token加密信息的存储可以使用Redis。

这里再多提一点,为了保证一些加密信息的可变性,最好在一开始设计Token模块的时候就考虑到支持多个版本密钥,以防止万一内部密钥被泄露(之前听一个朋友说其公司的Token加密代码被员工公布出去)。

至于加密算法,以及具体的实现在此就不再展开。 在Gateway鉴权通过之后,解析后的token信息可以直接传递给需要继续请求的微服务层。

如果应用需要授权(对资源请求需要管理不同的角色、权限),也只要在Gateway的Rest API基础上基于AOP思想来做即可。统一管理鉴权和授权,这也是使用类似Facade模式的Gateway API的好处之一。

负载均衡

API Gateway跟Microservice一样,作为Springboot应用,提供Rest api。所以同样运行在Docker容器中。Gateway和微服务之间的服务发现还是可以采用前文所述的客户端发现模式,或者服务端发现模式。

在集群环境下,API Gateway 可以暴露统一的端口,其实例会运行在不同IP的服务器上。因为我们是使用阿里云的ECS作为容器的基础设施,所以在集群环境的负载均衡也是使用阿里云的负载均衡SLB,域名解析也使用AliyunDNS。下图是一个简单的网络请求的示意:


A7.png



在实践中,为了不暴露服务的端口和资源地址,也可以在服务集群中再部署Nginx服务作为反向代理,外部的负载均衡设施比如SLB可以将请求转发到Nginx服务器,请求通过Nginx再转发给Gateway端口。如果是自建机房的集群,就需要搭建高可用的负载均衡中心。为了应对跨机器请求,最好使用Consul,Consul(Consul Template)+Registor+Haproxy来做服务发现和负载均衡中心。

缓存

对于一些高QPS的请求,可以在API Gateway做多级缓存。分布式的缓存可以使用Redis,Memcached等。如果是一些对实时性要求不高的,变化频率不高但是高QPS的页面级请求,也可以在Gateway层做本地缓存。而且Gateway可以让缓存方案更灵活和通用。

API Gateway的错误处理

在Gateway的具体实现过程中,错误处理也是一个很重要的事情。对于Gateway的错误处理,可以使用Hystrix来处理请求的熔断。并且RxJava自带的onErrorReturn回调也可以方便得处理错误信息的返回。对于熔断机制,需要处理以下几个方面:

服务请求的容错处理

作为一个合理的Gateway,其应该只负责处理数据流、事件流,而不应该处理业务逻辑。在处理多个微服务的请求时,会出现微服务请求的超时、不可用的情况。在一些特定的场景下, 需要能够合理得处理部分失败。比如上例中的“用户订单列表”,当“User”微服务出现错误时,不应该影响“Order”数据的请求。

最好的处理方式就是给当时错误的用户信息请求返回一个默认的数据,比如显示一个默认头像,默认用户昵称。然后对于请求正常的订单,以及商品信息给与正确的数据返回。

如果是一个关键的微服务请求异常,比如当“Order”领域的微服务异常时,则应该给客户端一个错误码,以及合理的错误提示信息。这样的处理可以尽量在部分系统不可用时提升用户体验。使用RxJava时,具体的实现方式就是针对不同的客户端请求的情况,写好onErrorReturn,做好错误数据兼容即可。

异常的捕捉和记录

Gateway主要是做请求的转发、合并。为了能清楚得排查问题,定位到具体哪个服务、甚至是哪个Docker容器的问题,需要Gateway能对不同类型的异常、业务错误进行捕捉和记录。

如果使用FeignClient来请求微服务资源,可以通过对ErrorDecoder接口的实现,来针对Response结果进行进一步的过滤处理,以及在日志中记录下所有请求信息。如果是使用Spring Rest Template,则可以通过定义一个定制化的RestTempate,并对返回的ResponseEntity进行解析。在返回序列化之后的结果对象之前,对错误信息进行日志记录。

超时机制

Gateway线程中大多是IO线程,为了防止因为某一微服务请求阻塞,导致Gateway过多的等待线程,耗尽线程池、队列等系统资源。需要Gateway中提供超时机制,对超时接口能进行优雅的服务降级。

在SpringCloud的Feign项目中集成了Hystrix。Hystrix提供了比较全面的超时处理的熔断机制。默认情况下,超时机制是开启的。除了可以配置超时相关的参数,Netflix还提供了基于Hytrix的实时监控Netflix -Dashboard,并且集群服务只需再附加部署Netflix-Turbine。通用的Hytrix的配置项可以参考Hystrix-Configuration。

如果是使用RxJava的Observable的响应式编程,想对不同的请求设置不同的超时时间,可以直接在Observable的timeout()方法的参数进行设置回调的方法以及超时时间等。

重试机制

对于一些关键的业务,在请求超时时,为了保证正确的数据返回,需要Gateway能提供重试机制。如果使用SpringCloudFeign,则其内置的Ribbon,会提供的默认的重试配置,可以通过设置spring.cloud.loadbalancer.retry.enabled=false将其关闭。

Ribbon提供的重试机制会在请求超时或者socket read timeout触发,除了设置重试,也可以定制重试的时间阀值以及重试次数等。

对于除了使用Feign,也使用Spring RestTemplate的应用,可以通过自定义的RestTemplate,对于返回的ResponseEntity对象进行结果解析,如果请求需要重试(比如某个固定格式的error-code的方式识别重试策略),则通过Interceptor进行请求拦截,以及回调的方式invoke多次请求。

小结

对于微服务的架构,通过一个独立的API Gateway,可以进行统一的请求转发、合并以及协议转换。可以更灵活得适配不同客户端的请求数据。而且对于不同客户端(比如H5和iOS的展示数据不同)、不同版本兼容的请求,可以很好地在Gateway进行屏蔽,让微服务更加纯粹。微服务只要关注内部的领域服务的设计,事件的处理。

API gateway还可以对微服务的请求进行一定的容错、服务降级。使用响应式编程来实现API gateway可以使线程同步、并发的代码更简洁,更易于维护。在对于微服务的请求可以统一通过FeignClint。代码也会很有层次。如下图,是一个示例的请求的类层次。


A8.png



Clint负责集成服务发现(对于使用Eureka自注册方式)、负载均衡以及发出请求,并获取ResponseEntity对象。
Translator将ResponseEntity转换成Observable对象,以及对异常进行统一日志采集,类似于DDD中防腐层的概念。
Adapter调用各个Translator,使用Observable函数,对请求的数据流进行合并。如果存在多个数据的组装,可以增加一层Assembler专门处理DTO对象到Model的转换。
Controller,提供Restful资源的管理,每个Controller只请求唯一的一个Adapter方法。

微服务的持续集成部署

前文主要介绍了微服务的服务发现、服务通信以及API Gateway。整体的微服务架构的模型初见。在实际的开发、测试以及生产环境中。使用Docker实现微服务,集群的网络环境会更加复杂。

微服务架构本身就意味着需要对若干个容器服务进行治理,每个微服务都应可以独立部署、扩容、监控。下面会继续介绍如何进行Docker微服务的持续集成部署(CI/CD)。

镜像仓库

用Docker来部署微服务,需要将微服务打包成Docker镜像,就如同部署在Web server打包成war文件一样。只不过Docker镜像运行在Docker容器中。

如果是Springboot服务,则会直接将包含Apache Tomcat server的Springboot,以及包含Java运行库的编译后的Java应用打包成Docker镜像。

为了能统一管理打包以及分发(pull/push)镜像。企业一般需要建立自己的镜像私库。实现方式也很简单。可以在服务器上直接部署Docker hub的镜像仓库的容器版Registry2。目前最新的版本是V2。

代码仓库

代码的提交、回滚等管理,也是项目持续集成的一环。一般也是需要建立企业的代码仓库的私库。可以使用SVN,GIT等代码版本管理工具。

目前公司使用的是Gitlab,通过Git的Docker镜像安装、部署操作也很便捷。具体步骤可以参考docker gitlab install。为了能快速构建、打包,也可将Git和Registry部署在同一台服务器上。

项目构建

在Springboot项目中,构建工具可以用Maven,或者Gradle。Gradle相比Maven更加灵活,而且Springboot应用本身去配置化的特点,用基于Groovy的Gradle会更加适合,DSL本身也比XML更加简洁高效。

因为Gradle支持自定义task。所以微服务的Dockerfile写好之后,就可以用Gradle的task脚本来进行构建打包成Docker Image。

目前也有一些开源的Gradle构建Docker镜像的工具,比如Transmode-Gradlew插件。其除了可以对子项目(单个微服务)进行构建Docker镜像,也可以支持同时上传镜像到远程镜像仓库。在生产环境中的build机器上,可以通过一个命令直接执行项目的build,Docker Image的打包,以及镜像的push。

容器编排技术

Docker镜像构建之后,因为每个容器运行着不同的微服务实例,容器之间也是隔离部署服务的。通过编排技术,可以使DevOps轻量化管理容器的部署以及监控,以提高容器管理的效率。

目前一些通用的编排工具比如Ansible、Chef、Puppet,也可以做容器的编排。但他们都不是专门针对容器的编排工具,所以使用时需要自己编写一些脚本,结合Docker的命令。比如Ansible,确实可以实现很便利的集群的容器的部署和管理。目前Ansible针对其团队自己研发的容器技术提供了集成方案:Ansible Container。

集群管理系统将主机作为资源池,根据每个容器对资源的需求,决定将容器调度到哪个主机上。

目前,围绕Docker容器的调度、编排,比较成熟的技术有Google的Kubernetes(下文会简写k8s),Mesos结合Marathon管理Docker集群,以及在Docker 1.12.0版本以上官方提供的Docker Swarm。编排技术是容器技术的重点之一。选择一个适合自己团队的容器编排技术也可以使运维更高效、更自动化。

Docker Compose

Docker Compose是一个简单的Docker容器的编排工具,通过YAML文件配置需要运行的应用,然后通过compose up命令启动多个服务对应的容器实例。Docker中没有集成Compose,需要另外安装。

Compose可以用于微服务项目的持续集成,但其不适合大型集群的容器管理,大集群中,可以Compose结合Ansible做集群资源管理,以及服务治理。

对于集群中服务器不多的情况,可以使用Compose,其使用步骤主要是:

结合微服务运行环境,定义好服务的Dockerfile
根据服务镜像、端口、运行变量等编写docker-compose.yml文件,以使服务可以一起部署,运行
运行docker-compose up 命令启动并且进入容器实例,如果需要使用后台进程方式运行,使用docker-compose up -d即可。

Docker Swarm

在16年,Docker的1.12版本出来之后,使用新版本的Docker,就自带Docker swarm mode了。不需要额外安装任何插件工具。可以看出去年开始Docker团队也开始重视服务编排技术,通过内置Swarm mode,也要抢占一部分服务编排市场。想要了解更多微服务架构知识点的,可以加群:650385180,群里有系统的学习方案供大家免费下载。

如果团队开始使用新版本的Docker,可以选择Docker swarm mode来进行集群化的容器调度和管理。Swarm还支持滚动更新、节点间传输层安全加密、负载均衡等。

DockerSwarm的使用示例可以参考之前写的一篇:使用docker-swarm搭建持续集成集群服务。

Kubernetes

Kubernetes是Google开源的容器集群管理系统,使用Go语言实现,其提供应用部署、维护、 扩展机制等功能。目前可以在GCE、vShpere、CoreOS、OpenShift、Azure等平台使用k8s。

国内目前Aliyun也提供了基于k8s的服务治理平台。如果是基于物理机、虚拟机搭建的Docker集群的话,也可以直接部署、运行k8s。在微服务的集群环境下,Kubernetes可以很方便管理跨机器的微服务容器实例。

目前k8s基本是公认的最强大开源服务治理技术之一。其主要提供以下功能:

自动化对基于Docker对服务实例进行部署和复制
以集群的方式运行,可以管理跨机器的容器,以及滚动升级、存储编排。
内置了基于Docker的服务发现和负载均衡模块
K8s提供了强大的自我修复机制,会对崩溃的容器进行替换(对用户,甚至开发团队都无感知),并可随时扩容、缩容。让容器管理更加弹性化。

k8s主要通过以下几个重要的组件完成弹性容器集群的管理的:

Pod是Kubernetes的最小的管理元素,一个或多个容器运行在pod中。pod的生命周期很短暂,会随着调度失败,节点崩溃,或者其他资源回收时消亡。
Label是key/value存储结构的,可以关联pod,主要用来标记pod,给服务分组。微服务之间通过label选择器(Selectors)来识别Pod。
Replication Controller是k8s Master节点的核心组件。用来确保任何时候Kubernetes集群中有指定数量的pod副本(replicas)运行。即提供了自我修复机制的功能,并且对缩容扩容、滚动升级也很有用。
Service是对一组Pod的策略的抽象。也是k8s管理的基本元素之一。Service通过Label识别一组Pod。创建时也会创建一个本地集群的DNS(存储Service对应的Pod的服务地址)。所以在客户端请求通过请求DNS来获取一组当前可用的Pods的ip地址。之后通过每个Node中运行的kube-proxy将请求转发给其中一个Pod。这层负载均衡是透明的,但是目前的k8s的负载均衡策略还不是很完善,默认是随机的方式。

小结

微服务架构体系中,一个合适的持续集成的工具,可以很好得提升团队的运维、开发效率。目前类似Jenkins也有针对Docker的持续集成的插件,但是还是存在很多不完善。所以建议还是选择专门应对Docker容器编排技术的Swarm,k8s,Mesos。或者多个技术结合起来,比如Jenkins做CI+k8s做CD。

Swarm,k8s,Mesos各有各的特性,他们对于容器的持续部署、管理以及监控都提供了支持。Mesos还支持数据中心的管理。Docker swarm mode扩展了现有的Docker API,通过Docker Remote API的调用和扩展,可以调度容器运行到指定的节点。

Kubernetes则是目前市场规模最大的编排技术,目前很多大公司也都加入到了k8s家族,k8s应对集群应用的扩展、维护和管理更加灵活,但是负载均衡策略比较粗糙。而Mesos更专注于通用调度,提供了多种调度器。

对于服务编排,还是要选择最适合自己团队的,如果初期机器数量很少,集群环境不复杂也可以用Ansible+Docker Compose,再加上Gitlab CI来做持续集成。

服务集群的解决方案

企业在实践使用Docker部署、运行微服务应用的时候,无论是一开始就布局微服务架构,或者从传统的单应用架构进行微服务化迁移。都需要能够处理复杂的集群中的服务调度、编排、监控等问题。下面主要为介绍在分布式的服务集群下,如何更安全、高效得使用Docker,以及在架构设计上,需要考虑的方方面面。

负载均衡

这里说的是集群中的负载均衡,如果是纯服务端API的话就是指Gateway API的负载均衡,如果使用了Nginx的话,则是指Nginx的负载均衡。我们目前使用的是阿里云的负载均衡服务SLB。

其中一个主要原因是可以跟DNS域名服务进行绑定。对于刚开始进行创业的公司来说,可以通过Web界面来设置负载均衡的权重,比较便于部分发布、测试验证,以及健康检查监控等等。从效率和节约运维成本上来说都是个比较适合的选择。

如果自己搭建七层负载均衡如使用Nginx或Haproxy的话,也需要保证负责负载均衡的集群也是高可用的,以及提供便捷的集群监控,蓝绿部署等功能。

持久化及缓存

关系型数据库(RDBMS)

对于微服务来说,使用的存储技术主要是根据企业的需要。为了节约成本的话,一般都是选用Mysql,在Mysql的引擎选择的话建议选择InnoDB引擎(5.5版本之前默认MyISAM)。

InnoDB在处理并发时更高效,其查询性能的差距也可以通过缓存、搜索等方案进行弥补。InnoDB处理数据拷贝、备份的免费方案有binlog,mysqldump。不过要做到自动化的备份恢复、可监控的数据中心还是需要DBA或者运维团队。

相对花费的成本也较高。如果初创企业,也可以考虑依托一些国内外比较大型的云计算平台提供的PaaS服务。

微服务一般按照业务领域进行边界划分,所以微服务最好是一开始就进行分库设计。是否需要进行分表需要根据每个微服务具体的业务领域的发展以及数据规模进行具体分析。但建议对于比较核心的领域的模型,比如“订单”提前做好分表字段的设计和预留。

KV模型数据库(Key-Value-stores)

Redis是开源的Key-Value结构的数据库。其基于内存,具有高效缓存的性能,同时也支持持久化。Redis主要有两种持久化方式。一种是RDB,通过指定时间间隔生成数据集的时间点快照,从内存写入磁盘进行持久化。

RDB方式会引起一定程度的数据丢失,但性能好。另外一种是AOF,其写入机制,有点类似InnoDB的binlog,AOF的文件的命令都是以Redis协议格式保存。这两种持久化是可以同时存在的,在Redis重启时,AOF文件会被优先用于恢复数据。因为持久化是可选项,所以也可以禁用Redis持久化。

在实际的场景中,建议保留持久化。比如目前比较流行的解决短信验证码的验证,就可使用Redis。在微服务架构体系中,也可以用Redis处理一些KV数据结构的场景。轻量级的数据存储方案,也很适合本身强调轻量级方案的微服务思想。

我们在实践中,是对Redis进行了缓存、持久化,两个功能特征进行分库的。

在集成Springboot项目中会使用到spring-boot-starter-data-redis来进行Redis的数据库连接以及基础配置、以及spring-data-redis提供的丰富的数据APIOperations。

另外,如果是要求高吞吐量的应用,可以考虑用Memcached来专门做简单的KV数据结构的缓存。其比较适合大数据量的读取,但支持的数据结构类型比较单一。

图形数据库(Graph Database)

涉及到社交相关的模型数据的存储,图形数据库是一种相交关系型数据库更高效、更灵活的选择。图形数据库也是Nosql的一种。其和KV不同,存储的数据主要是数据节点(node),具有指向性的关系(Relationship)以及节点和关系上的属性(Property)。

如果用Java作为微服务的主开发语言,最好选择Neo4j。Neo4j是一种基于Java实现的支持ACID的图形数据库。其提供了丰富的JavaAPI。在性能方面,图形数据库的局部性使遍历的速度非常快,尤其是大规模深度遍历。这个是关系型数据库的多表关联无法企及的。

下图是使用Neo4j的WebUI工具展示的一个官方Getting started数据模型示例。示例中的语句MATCH p=()-[r:DIRECTED]->() RETURN p LIMIT 25是Neo4j提供的查询语言——Cypher。


A9.png



在项目使用时可以集成SpringData的项目Spring Data Neo4j。以及SpringBootStartersspring-boot-starter-data-neo4j

文档数据库(Document database)

目前应用的比较广泛的开源的面向文档的数据库可以用Mongodb。Mongo具有高可用、高可伸缩性以及灵活的数据结构存储,尤其是对于Json数据结构的存储。比较适合博客、评论等模型的存储。

搜索技术

在开发的过程中,有时候经常会看到有人写了很长很绕、很难维护的多表查询SQL,或者是各种多表关联的子查询语句。对于某一领域模型,当这种场景多的时候,就该考虑接入一套搜索方案了。不要什么都用SQL去解决,尤其是查询的场景。慢查询语句的问题有时候甚至会拖垮DB,如果DB的监控体系做的不到位,可能问题也很难排查。

Elasticsearch是一个基于Apache Lucene实现的开源的实时分布式搜索和分析引擎。Springboot的项目也提供了集成方式: spring-boot-starter-data-elasticsearch以及spring-data-elasticsearch。

对于搜索集群的搭建,可以使用Docker。具体搭建方法可以参考用Docker搭建Elasticsearch集群,对于Springboot项目的集成可以参考在Springboot微服务中集成搜索服务。至今,最新版本的SpringDataElasticsearch已经支持到了5.x版本的ES,可以解决很多2.x版本的痛点了。

如果是小规模的搜索集群,可以用三台低配置的机器,然后用ES的Docker进项进行搭建。也可以使用一些商业版的PaaS服务。如何选择还是要根据团队和业务的规模、场景来看。想要了解更多微服务架构知识点的,可以加群:650385180,群里有系统的学习方案供大家免费下载。

目前除了ES,使用比较广泛的开源搜索引擎还有Solr,Solr也基于Lucene,且专注在文本搜索。而ES的文本搜索确实不如Solr,ES主要专注于对分布式的支持,并且内置了服务发现组件Zen来维护集群状态,相对Solr(需要借助类似Zookeeper实现分布式)部署也更加轻量级。ES除了分析查询,还可以集成日志收集以及做分析处理。

消息队列

消息队列如前篇所述,可以作为很好的微服务解耦通信方式。在分布式集群的场景下,对于分布式下的最终一致性也可以提供技术基础保障。并且消息队列也可以用来处理流量削锋。

消息队列的对比在此不再赘述。目前公司使用的是阿里云的ONS。因为使用消息队列还是考虑用在对高可用以及易于管理、监控上的要求,所以选择了安全可靠的消息队列平台。

安全技术

安全性是做架构需要考虑的基础。互联网的环境复杂,保护好服务的安全,也是对用户的基本承诺。安全技术涉及到的范围比较广,本文选几个常见问题以及常用方式来简单介绍下。

服务实例安全

分布式集群本身就是对于服务实例安全的一种保障。一台服务器或者某一个服务实例出现问题的时候,负载均衡可以将请求转发到其他可用的服务实例。但很多企业是自建机房,而且是单机房的,这种布局其实比较危险。

因为服务器的备份容灾也得不到完整的保障。最怕的就是数据库也是在同一机房,主备全都在一起。不单是安全性得不到很高的保障,平常的运维花销也会比较大。而且需要注意配置防火墙安全策略。

如果可以,尽量使用一些高可用、高可伸缩的稳定性IaaS平台。

网络安全

  1. 预防网络攻击

目前主要的网络攻击有一下几种:

SQL注入:根据不同的持久层框架,应对策略不同。如果使用JPA,则只要遵循JPA的规范,基本不用担心。
XSS攻击:做好参数的转义处理和校验。具体参考XSS预防
CSRF攻击:做好Http的Header信息的Token、Refer验证。具体参考CSRF预防
DDOS攻击:大流量的DDoS攻击,一般是采用高防IP。也可以接入一些云计算平台的高防IP。

以上只是列举了几种常见的攻击,想要深入了解的可以多看看REST安全防范表。在网络安全领域,一般很容易被初创企业忽视,如果没有一个运维安全团队,最好使用类似阿里云-云盾之类的产品。省心省成本。

  1. 使用安全协议

这个不用多说,无论是对于使用Restful API的微服务通信,还是使用的CDN或者使用的DNS服务。涉及到Http协议的,建议都统一使用Https。无论是什么规模的应用,都要防范流量劫持,否则将会给用户带来很不好的使用体验。

  1. 鉴权

关于微服务的鉴权前面API Gateway已经有介绍。除了微服务本身之外,我们使用的一些如Mysql,Redis,Elasticsearch,Eureka等服务,也需要设置好鉴权,并且尽量通过内网访问。不要对外暴露过多的端口。对于微服务的API Gateway,除了鉴权,最好前端通过Nginx反向代理来请求API层。

日志采集、监控

基于容器技术的微服务的监控体系面临着更复杂的网络、服务环境。日志采集、监控如何能对微服务减少侵入性、对开发者更透明,一直是很多微服务的DevOps在不断思考和实践的。

  1. 微服务日志的采集

微服务的API层的监控,需要从API Gateway到每个微服务的调用路径的跟踪,采集以及分析。使用Rest API的话,为了对所有请求进行采集,可以使用Spring Web的OncePerRequestFilter对所有请求进行拦截,在采集日志的时候,也最好对请求的rt进行记录。

除了记录access,request等信息,还需要对API调用进行请求跟踪。如果单纯记录每个服务以及Gateway的日志,那么当Gateway Log出现异常的时候,就不知道其具体是微服务的哪个容器实例出现了问题。如果容器达到一定数量,也不可能排查所有容器以及服务实例的日志。比较简单的解决方式就是对log信息都append一段含有容器信息的、唯一可标识的Trace串。

日志采集之后,还需要对其进行分析。如果使用E.L.K的技术体系,就可以灵活运用Elasticsearch的实时分布式特性。Logstash可以进行日志进行收集、分析,并将数据同步到Elasticsearch。Kibana结合Logstash和ElasticSearch,提供良好的便于日志分析的WebUI,增强日志数据的可视化管理。

对于数据量大的日志的采集,为了提升采集性能,需要使用上文提到的消息队列。优化后的架构如下:


A10.png



  1. 基础服务的调用日志采集

通过对微服务的所有Rest API的日志采集、分析可以监控请求信息。

在服务内部,对于中间件、基础设施(包括Redis,Mysql,Elasticsearch等)调用的性能的日志采集和分析也是必要的。

对于中间件服务的日志采集,我们目前可以通过动态代理的方式,对于服务调用的如cache、repository(包括搜索和DB)的基础方法,进行拦截及回调日志记录方法。

具体的实现方式可以采用字节码生成框架ASM,关于方法的逻辑注入,可以参考之前写的一篇ASM(四) 利用Method 组件动态注入方法逻辑,如果觉得ASM代码不太好维护,也可以使用相对API友好的Cglib。

架构五要素:

最后,结合架构核心的五要素来回顾下我们在搭建Docker微服务架构使用的技术体系:

高性能
消息队列、RxJava异步并发、分布式缓存、本地缓存、Http的Etag缓存、使用Elasticsearch优化查询、CDN等等。
可用性
容器服务集群、RxJava的熔断处理、服务降级、消息的幂等处理、超时机制、重试机制、分布式最终一致性等等。
伸缩性
服务器集群的伸缩、容器编排Kubernetes、数据库分库分表、Nosql的线性伸缩、搜索集群的可伸缩等等。
扩展性
基于Docker的微服务本身就是为了扩展性而生!
安全性
JPA/Hibernate,SpringSecurity、高防IP、日志监控、Https、Nginx反向代理、HTTP/2.0等等。

小结

对于服务集群的解决方案,其实无论是微服务架构或者SOA架构,都是比较通用的。只是对于一些中间件集群的搭建,可以使用Docker。一句Docker ps就可以很方便查询运行的服务信息,并且升级基础服务也很方便。

对于优秀的集群架构设计的追求是永无止境的。在跟很多创业公司的技术朋友们接触下来,大家都是比较偏向于快速搭建以及开发、发布服务。然而一方面也顾虑微服务的架构会比较复杂,杀鸡用牛刀。但是微服务本身就是一种敏捷模式的优秀实践。这些朋友往往会在业务飞速发展的时候面临一个困扰,就是服务拆分,数据库的分库分表、通过消息去解耦像面条一样的同步代码,想要优化性能但是无从下手的尴尬。

相关文档

Apache Thrift
使用Mesos和Marathon管理Docker集群
基于docker-swarm搭建持续集成集群服务
Kubernetes中文文档

后记

本文主要是对于Docker的微服务实践进行技术方案选型以及介绍。不同的业务、团队可能会适合不通过的架构体系和技术方案。

作为架构师应该结合公司近期、长期的战略规划,进行长远的布局。最起码基础的架构也是需要能支撑3年发展,期间可以不断引入新的技术并进行服务升级和持续的代码层重构。

也许一个架构师从0开始搭建一整套体系并不需要花费多久时间,最需要其进行的就是不断在团队推行Domain-Driven Design。并且使团队一起遵循Clean Code,进行敏捷开发OvO

追求极简:Docker镜像构建演化史

老李 发表了文章 • 0 个评论 • 1941 次浏览 • 2018-04-08 19:46 • 来自相关话题

自从2013年dotCloud公司(现已改名为Docker Inc)发布Docker容器技术以来,到目前为止已经有四年多的时间了。这期间Docker技术飞速发展,并催生出一个生机勃勃的、以轻量级容器技术为基础的庞大的容器平台生态圈。作为Docker三大核心技术 ...查看全部
自从2013年dotCloud公司(现已改名为Docker Inc)发布Docker容器技术以来,到目前为止已经有四年多的时间了。这期间Docker技术飞速发展,并催生出一个生机勃勃的、以轻量级容器技术为基础的庞大的容器平台生态圈。作为Docker三大核心技术之一的镜像技术在Docker的快速发展之路上可谓功不可没:镜像让容器真正插上了翅膀,实现了容器自身的重用和标准化传播,使得开发、交付、运维流水线上的各个角色真正围绕同一交付物,“test what you write, ship what you test”成为现实。

E1.png



对于已经接纳和使用Docker技术在日常开发工作中的开发者而言,构建Docker镜像已经是家常便饭。但如何更高效地构建以及构建出Size更小的镜像却是很多Docker技术初学者心中常见的疑问,甚至是一些老手都未曾细致考量过的问题。本文将从一个Docker用户角度来阐述Docker镜像构建的演化史,希望能起到一定的解惑作用。

一、镜像:继承中的创新

谈镜像构建之前,我们先来简要说下镜像。

Docker技术本质上并不是新技术,而是将已有技术进行了更好地整合和包装。内核容器技术以一种完整形态最早出现在Sun公司的Solaris操作系统上,Solaris是当时最先进的服务器操作系统。2005年Sun发布了Solaris Container技术,从此开启了内核容器之门。

2008年,以Google公司开发人员为主导实现的Linux Container(即LXC)功能在被merge到Linux内核中。LXC是一种内核级虚拟化技术,主要基于Namespaces和Cgroups技术,实现共享一个操作系统内核前提下的进程资源隔离,为进程提供独立的虚拟执行环境,这样的一个虚拟的执行环境就是一个容器。本质上说,LXC容器与现在的Docker所提供容器是一样的。Docker也是基于Namespaces和Cgroups技术之上实现的,Docker的创新之处在于其基于Union File System技术定义了一套容器打包规范,真正将容器中的应用及其运行的所有依赖都封装到一种特定格式的文件中去,而这种文件就被称为镜像(即image),原理见下图(引自Docker官网):

W1.png


图1:Docker镜像原理

镜像是容器的“序列化”标准,这一创新为容器的存储、重用和传输奠定了基础。并且“坐上了巨轮”的容器镜像可以传播到世界每一个角落,这无疑助力了容器技术的飞速发展。

与Solaris Container、LXC等早期内核容器技术不同,Docker为开发者提供了开发者体验良好的工具集,这其中就包括了用于镜像构建的Dockerfile以及一种用于编写Dockerfile领域特定语言。采用Dockerfile方式构建成为镜像构建的标准方法,其可重复、可自动化、可维护以及分层精确控制等特点是采用传统采用docker commit命令提交的镜像所不能比拟的。

二、“镜像是个筐”:初学者的认知

“镜像是个筐,什么都往里面装” – 这句俏皮话可能是大部分Docker初学者对镜像最初认知的真实写照。这里我们用一个例子来生动地展示一下。我们将httpserver.go这个源文件编译为httpd程序并通过镜像发布,考虑到被编译的源码并非本文重点,这里使用了一个极简的demo代码:

//httpserver.go

package main

import (
"fmt"
"net/http"
)

func main() {
fmt.Println("http daemon start")
fmt.Println(" -> listen on port:8080")
http.ListenAndServe(":8080", nil)
}

接下来,我们来编写一个用于构建目标image的Dockerfile:

From ubuntu:14.04

RUN apt-get update \
&& apt-get install -y software-properties-common \
&& add-apt-repository ppa:gophers/archive \
&& apt-get update \
&& apt-get install -y golang-1.9-go \
git \
&& rm -rf /var/lib/apt/lists/*

ENV GOPATH /root/go
ENV GOROOT /usr/lib/go-1.9
ENV PATH="/usr/lib/go-1.9/bin:${PATH}"

COPY ./httpserver.go /root/httpserver.go
RUN go build -o /root/httpd /root/httpserver.go \
&& chmod +x /root/httpd

WORKDIR /root
ENTRYPOINT ["/root/httpd"]
构建这个Image:

# docker build -t repodemo/httpd:latest .
//...构建输出这里省略...

# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
repodemo/httpd latest 183dbef8eba6 2 minutes ago 550MB
ubuntu 14.04 dea1945146b9 2 months ago 188MB
整个镜像的构建过程因环境而定。如果您的网络速度一般,这个构建过程可能会花费你10多分钟甚至更多。最终如我们所愿,基于repodemo/httpd:latest这个镜像的容器可以正常运行:

# docker run repodemo/httpd
http daemon start
-> listen on port:8080

一个Dockerfile最终生产出一个镜像。Dockerfile由若干Command组成,每个Command执行结果都会单独形成一个layer。我们来探索一下构建出来的镜像:

# docker history 183dbef8eba6
IMAGE CREATED CREATED BY SIZE COMMENT
183dbef8eba6 21 minutes ago /bin/sh -c #(nop) ENTRYPOINT ["/root/httpd"] 0B
27aa721c6f6b 21 minutes ago /bin/sh -c #(nop) WORKDIR /root 0B
a9d968c704f7 21 minutes ago /bin/sh -c go build -o /root/httpd /root/h... 6.14MB
... ...
aef7700a9036 30 minutes ago /bin/sh -c apt-get update && apt-get... 356MB
.... ...
2 months ago /bin/sh -c #(nop) ADD file:8f997234193c2f5... 188MB

我们去除掉那些Size为0或很小的layer,我们看到三个size占比较大的layer,见下图:

W2.png


图2:Docker镜像分层探索

虽然Docker引擎利用r缓存机制可以让同主机下非首次的镜像构建执行得很快,但是在Docker技术热情催化下的这种构建思路让docker镜像在存储和传输方面的优势荡然无存,要知道一个ubuntu-server 16.04的虚拟机ISO文件的大小也就不过600多MB而已。
三、”理性的回归”:builder模式的崛起

Docker使用者在新技术接触初期的热情“冷却”之后迎来了“理性的回归”。根据上面分层镜像的图示,我们发现最终镜像中包含构建环境是多余的,我们只需要在最终镜像中包含足够支撑httpd运行的运行环境即可,而base image自身就可以满足。于是我们应该去除不必要的中间层:


W3.png


图3:去除不必要的分层

现在问题来了!如果不在同一镜像中完成应用构建,那么在哪里、由谁来构建应用呢?至少有两种方法:

在本地构建并COPY到镜像中;
借助构建者镜像(builder image)构建。
不过方法1本地构建有很多局限性,比如:本地环境无法复用、无法很好融入持续集成/持续交付流水线等。借助builder image进行构建已经成为Docker社区的一个最佳实践,Docker官方为此也推出了各种主流编程语言的官方base image,比如:go、java、node、python以及ruby等。借助builder image进行镜像构建的流程原理如下图:


W4.png


图4:借助builder image进行镜像构建的流程图

通过原理图,我们可以看到整个目标镜像的构建被分为了两个阶段:

第一阶段:构建负责编译源码的构建者镜像;
第二阶段:将第一阶段的输出作为输入,构建出最终的目标镜像。
我们选择golang:1.9.2作为builder base image,构建者镜像的Dockerfile.build如下:

// Dockerfile.build

FROM golang:1.9.2

WORKDIR /go/src
COPY ./httpserver.go .

RUN go build -o httpd ./httpserver.go
执行构建:

# docker build -t repodemo/httpd-builder:latest -f Dockerfile.build .
构建好的应用程序httpd放在了镜像repodemo/httpd-builder中的/go/src目录下,我们需要一些“胶水”命令来连接两个构建阶段,这些命令将httpd从构建者镜像中取出并作为下一阶段构建的输入:

# docker create --name extract-httpserver repodemo/httpd-builder
# docker cp extract-httpserver:/go/src/httpd ./httpd
# docker rm -f extract-httpserver
# docker rmi repodemo/httpd-builder
通过上面的命令,我们将编译好的httpd程序拷贝到了本地。下面是目标镜像的Dockerfile:

//Dockerfile.target
From ubuntu:14.04

COPY ./httpd /root/httpd
RUN chmod +x /root/httpd

WORKDIR /root
ENTRYPOINT ["/root/httpd"]
接下来我们来构建目标镜像:

# docker build -t repodemo/httpd:latest -f Dockerfile.target .
我们来看看这个镜像的“体格”:

# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
repodemo/httpd latest e3d009d6e919 12 seconds ago 200MB
200MB!目标镜像的Size降为原来的 1/2 还多。

四、“像赛车那样减去所有不必要的东西”:追求最小镜像

前面我们构建出的镜像的Size已经缩小到200MB,但这还不够。200MB的“体格”在我们的网络环境下缓存和传输仍然很难令人满意。我们要为镜像进一步减重,减到尽可能的小,就像赛车那样,为了能减轻重量将所有不必要的东西都拆除掉:我们仅保留能支撑我们的应用运行的必要库、命令,其余的一律不纳入目标镜像。当然不仅仅是Size上的原因,小镜像还有额外的好处,比如:内存占用小,启动速度快,更加高效;不会因其他不必要的工具、库的漏洞而被攻击,减少了“攻击面”,更加安全。


W5.png


图5:目标镜像还能更小些吗?

一般应用开发者不会从scratch镜像从头构建自己的base image以及目标镜像的,开发者会挑选适合的base image。一些“蝇量级”甚至是“草量级”的官方base image的出现为这种情况提供了条件。


W6.png


图6:一些base image的Size比较(来自imagelayers.io截图)

从图中看,我们有两个选择:busybox和alpine。

单从image的size上来说,busybox更小。不过busybox默认的libc实现是uClibc,而我们通常运行环境使用的libc实现都是glibc,因此我们要么选择静态编译程序,要么使用busybox:glibc镜像作为base image。

而 alpine image 是另外一种蝇量级 base image,它使用了比 glibc 更小更安全的 musl libc 库。 不过和 busybox image 相比,alpine image 体积还是略大。除了因为 musl比uClibc 大一些之外,alpine还在镜像中添加了自己的包管理系统apk,开发者可以使用apk在基于alpine的镜像中添 加需要的包或工具。因此,对于普通开发者而言,alpine image显然是更佳的选择。不过alpine使用的libc实现为musl,与基于glibc上编译出来的应用程序不兼容。如果直接将前面构建出的httpd应用塞入alpine,在容器启动时会遇到下面错误,因为加载器找不到glibc这个动态共享库文件:

standard_init_linux.go:185: exec user process caused "no such file or directory"
对于Go应用来说,我们可以采用静态编译的程序,但一旦采用静态编译,也就意味着我们将失去一些libc提供的原生能力,比如:在linux上,你无法使用系统提供的DNS解析能力,只能使用Go自实现的DNS解析器。

我们还可以采用基于alpine的builder image,golang base image就提供了alpine 版本。 我们就用这种方式构建出一个基于alpine base image的极小目标镜像。


W7.png


图7:借助 alpine builder image 进行镜像构建的流程图

我们新建两个用于 alpine 版本目标镜像构建的 Dockerfile:Dockerfile.build.alpine 和Dockerfile.target.alpine:

//Dockerfile.build.alpine
FROM golang:alpine

WORKDIR /go/src
COPY ./httpserver.go .

RUN go build -o httpd ./httpserver.go

// Dockerfile.target.alpine
From alpine

COPY ./httpd /root/httpd
RUN chmod +x /root/httpd

WORKDIR /root
ENTRYPOINT ["/root/httpd"]

构建builder镜像:

# docker build -t repodemo/httpd-alpine-builder:latest -f Dockerfile.build.alpine .

# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
repodemo/httpd-alpine-builder latest d5b5f8813d77 About a minute ago 275MB
执行“胶水”命令:

# docker create --name extract-httpserver repodemo/httpd-alpine-builder
# docker cp extract-httpserver:/go/src/httpd ./httpd
# docker rm -f extract-httpserver
# docker rmi repodemo/httpd-alpine-builder
构建目标镜像:

# docker build -t repodemo/httpd-alpine -f Dockerfile.target.alpine .

# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
repodemo/httpd-alpine latest 895de7f785dd 13 seconds ago 16.2MB
16.2MB!目标镜像的Size降为不到原来的十分之一。我们得到了预期的结果。

五、“要有光,于是便有了光”:对多阶段构建的支持

至此,虽然我们实现了目标Image的最小化,但是整个构建过程却是十分繁琐,我们需要准备两个Dockerfile、需要准备“胶水”命令、需要清理中间产物等。作为Docker用户,我们希望用一个Dockerfile就能解决所有问题,于是就有了Docker引擎对多阶段构建(multi-stage build)的支持。注意:这个特性非常新,只有Docker 17.05.0-ce及以后的版本才能支持。

现在我们就按照“多阶段构建”的语法将上面的Dockerfile.build.alpine和Dockerfile.target.alpine合并到一个Dockerfile中:

//Dockerfile

FROM golang:alpine as builder

WORKDIR /go/src
COPY httpserver.go .

RUN go build -o httpd ./httpserver.go

From alpine:latest

WORKDIR /root/
COPY --from=builder /go/src/httpd .
RUN chmod +x /root/httpd

ENTRYPOINT ["/root/httpd"]
Dockerfile的语法还是很简明和易理解的。即使是你第一次看到这个语法也能大致猜出六成含义。与之前Dockefile最大的不同在于在支持多阶段构建的Dockerfile中我们可以写多个“From baseimage”的语句了,每个From语句开启一个构建阶段,并且可以通过“as”语法为此阶段构建命名(比如这里的builder)。我们还可以通过COPY命令在两个阶段构建产物之间传递数据,比如这里传递的httpd应用,这个工作之前我们是使用“胶水”代码完成的。

构建目标镜像:

# docker build -t repodemo/httpd-multi-stage .

# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
repodemo/httpd-multi-stage latest 35e494aa5c6f 2 minutes ago 16.2MB
我们看到通过多阶段构建特性构建的Docker Image与我们之前通过builder模式构建的镜像在效果上是等价的。

六、来到现实

沿着时间的轨迹,Docker 镜像构建走到了今天。追求又快又小的镜像已成为了 Docker 社区 的共识。社区在自创 builder 镜像构建的最佳实践后终于迎来了多阶段构建这柄利器,从此构建 出极简的镜像将不再困难。

云原生之下的Java

尼古拉斯 发表了文章 • 0 个评论 • 185 次浏览 • 2019-05-30 10:22 • 来自相关话题

自从公司的运行平台全线迁入了 Kubenetes 之后总是觉得 DevOps 变成了一个比以前更困难的事情,反思了一下,这一切的困境居然是从现在所使用的 Java 编程语言而来,那我们先聊聊云原生。 Cloud Native 在我的理 ...查看全部
自从公司的运行平台全线迁入了 Kubenetes 之后总是觉得 DevOps 变成了一个比以前更困难的事情,反思了一下,这一切的困境居然是从现在所使用的 Java 编程语言而来,那我们先聊聊云原生。

Cloud Native 在我的理解是,虚拟化之后企业上云,现在的企业几乎底层设施都已经云化之后,对应用的一种倒逼,Cloud Native 是一个筐,什么都可以往里面扔,但是有些基础是被大家共识的,首先云原生当然和编程语言无关,说的是一个应用如何被创建/部署,后续的就引申出了比如 DevOps 之类的新的理念,但是回到问题的本身,Cloud Native 提出的一个很重要的要求,应用如何部署 这个问题从以前由应用决定,现在变成了,基础设施 决定 应用应该如何部署。如果你想和更多Kubernetes技术专家交流,可以加我微信liyingjiese,备注『加群』。群里每周都有全球各大公司的最佳实践以及行业最新动态

让我们回到一切的开始,首先云原生亦或者是 DevOps 都有一个基础的要求,当前版本的代码能够在任何一个环境运行,看起来是不是一个很简单的需求,但是这个需求有一个隐喻所有的环境的基础设施是一样的,显然不能你的开发环境是 Windows 测试环境 Debian 生产环境又是 CentOS 那怎么解决呢,从这一环,我们需要一个工具箱然后往这个工具箱里面扔我们需要的工具了。首先我们需要的就是 Cloud Native 工具箱中最为明显的产品 Docker/Continar,经常有 Java 开发者问我,Docker 有什么用,我的回答是,Docker 对 Java 不是必须的,但是对于其他的语言往往是如果伊甸园中的苹果一样的诱人,打个比方,一个随系统打包的二进制发行版本,可以在任何地方运行,是不是让人很激动,对于大部分的 Java 开发者可能无感,对于 C 语言项目的编写者,那些只要不是基于虚拟机的语言,他们都需要系统提供运行环境,而系统千变万化,当然开发者不愿意为了不同的系统进行适配,在以前我们需要交叉编译,现在我们把这个复杂的事情交给了 Docker,让 Docker 如同 Java 一样,一次编写处处运行,这样的事情简直就像是端了 Java 的饭碗,以前我们交付一个复杂的系统,往往连着操作系统一起交付,而客户可能买了一些商业系统,为了适配有可能还要改代码,现在你有了Docker,开发者喜大普奔,而这里的代价呢?C&C++&GO 他们失去的是枷锁,获得全世界,而 Java 如同被革命一般,失去了 Once Code,Everywhere Run,获得的是更大的 Docker Image Size,获得被人诟病的 Big Size Runtime。

当我们从代码构建完成了镜像,Cloud Navtive 的故事才刚刚开始,当你的 Team Leader 要求你的系统架构是 MicroServices 的,你把原来的项目进行拆分了,或者是开发的就拆分的足够小的时候,你发现因为代码拆分开了,出现了一点点的代码的重复,有适合也避免不了的,你的依赖库也变的 xN,隔壁 Go 程序员想了想,不行我们就搞个 .so 共享一部分代码吧,然后看了构建出来的二进制文件才 15MB,运维大手一挥,这点大小有啥要共享的,Java 程序员望了望了自己的 Jar 包,60MB 还行吧,维护镜像仓库的运维同事这个时候跑出来,你的镜像怎么有 150MB 了, 你看看你们把磁盘都塞满了,只能苦笑,运维小哥坑次坑次的给打包机加了一块硬盘,顺便问你马上部署了,你需要多大的配额,你说道 2C4G,运维一脸嫌弃的问你,为什么隔壁 Go 项目组的同事才需要 0.5C512MB。你当然也不用告诉他,SpringBoot 依赖的了 XXX,YYY,ZZZ 的库,虽然一半的功能你都没用到。

部署到线上,刚刚准备喘口气,突然发现新的需求又来了,虽然是一个很小的功能,但是和现在的系统内的任何一个服务都没有什么直接关联性,你提出再新写一个服务,运维主管抱怨道,现在的服务器资源还是很紧张,你尝试着用现在最流行的 Vertx 开发一个简单的 Web 服务,你对构建出来的 jar 只有 10MB 很满意,可是镜像加起来还是有 60 MB,也算一种进步,你找到 QA 主管,准备 Show 一下你用了 Java 社区最酷的框架,最强的性能,QA 主管找了一个台 1C2G 的服务让你压测一下,你发现你怎么也拼不过别人 Go 系统,你研究之后发现,原来协程模型在这样的少核心的情况下性能要更好,你找运维希望能升级下配置,你走到运维门口的时候,你停了下来,醒醒吧,不是你错了,而是时代变了。

云原生压根不是为了 Java 存在的,云原生的时代已经不是 90 年代,那时候的软件是一个技术活,每一个系统都需要精心设计,一个系统数个月才会更新一个版本,每一个功能都需要进行完整的测试,软件也跑在了企业内部的服务器上,软件是IT部分的宝贝,给他最好的环境,而在 9012 年,软件是什么?软件早就爆炸了,IT 从业者已经到达一个峰值,还有源源不断的人输入进来,市场的竞争也变的激烈,软件公司的竞争力也早就不是质量高,而是如何更快的应对市场的变化,Java 就如同一个身披无数荣光的二战将军,你让他去打21世纪的信息战,哪里还跟着上时代。

云原生需要的是,More Fast & More Fast 的交付系统,一个系统开发很快的系统,那天生就和精心设计是违背的,一个精心设计又能很快开发完的系统实在少见,所以我们从 Spring Boot 上直接堆砌业务代码,最多按照 MVC 进行一个简单的分层,那些优秀的 OOP 理念都活在哪里,那些底层框架,而你突然有一天对 Go 来了兴趣,你按照学 juc 的包的姿势,想要学习下 Go 的优雅源码,你发现,天呐,那些底层库原来可以设计的如此简单,Cache 只需要使用简单的 Map 加上一个 Lock 就可以获得很好的性能了,你开始怀疑了,随着你了解的越深入,你发现 Go 这个语言真是充满了各种各样的缺点,但是足够简单这个优势简直让你羡慕到不行,你回想起来,Executors 的用法你学了好几天,看了好多文章,才把自己的姿势学完,你发现 go func(){} 就解决你的需求了,你顺手删掉了 JDK,走上了真香之路。虽然你还会怀念 SpringBoot 的方便,你发现 Go 也足够满足你 80% 的需求了,剩下俩的一点点就捏着鼻子就好了。你老婆也不怪你没时间陪孩子了,你的工资也涨了点,偶尔翻开自己充满设计模式的 Old Style 代码,再也没有什么兴趣了。

原文链接:http://blog.yannxia.top/2019/05/29/fxxk-java-in-cloud-native/

Jib 1.0.0迎来通用版本——以前所未有的低门槛构建Java Docker镜像

大卫 发表了文章 • 0 个评论 • 1518 次浏览 • 2019-02-12 18:22 • 来自相关话题

去年,我们开始着手帮助开发人员更轻松地实现Java应用程序的容器化转换。我们注意到,开发人员们在使用现有工具时往往面临诸多困难——例如构建速度太慢,Dockerfiles混合不堪,以及容器体积过大等等。 为了改变上述状况,我们开发出了 ...查看全部
去年,我们开始着手帮助开发人员更轻松地实现Java应用程序的容器化转换。我们注意到,开发人员们在使用现有工具时往往面临诸多困难——例如构建速度太慢,Dockerfiles混合不堪,以及容器体积过大等等。

为了改变上述状况,我们开发出了Jib。Jib是一款开源工具,能够非常轻松地与您的Java应用程序实现集成——您无需安装Docker、无需运行Docker守护程序,甚至不需要编写Dockerfile。只需要在Maven或者Gradle build当中使用这款插件并运行构建过程,一切即可迎刃而解。Jib能够利用既有构建信息快速且高效地自动与您的应用程序完成适配。在Jib的帮助下,构建Java容器如今就像打包JAR文件一样简单。

我们于去年公布了Jib的beta测试版本,从那时开始,我们陆续收到了来自社区的诸多反馈与贡献,这也帮助我们更好地实现了其容器化体验。今天,我们高兴地宣布Jib 1.0.0通用版本的正式来临,其已经做好充分的准备,能够满足生产环境对于稳定性的严格要求。

我们将在这篇文章当中对版本中的主要变更做出说明,具体包括对WAR项目的支持、与Skaffold的集成以及面向Java的全新容器构建库Jib Core。
#Jib 1.0版本中包含哪些重点?
##Docker化WAR项目
Java编写的Web应用程序通常会被打包成WAR文件。如今,Jib已经能够对WAR项目进行容器化,且完全无需额外配置。您只需要直接运行以下命令:

Maven:
$ mvn package jib:build

Gradle:
$ gradle jib

该容器中的默认应用服务器为Jetty,但您也可以对基础镜像以及appRoot进行配置调整,从而使用Tomcat等其它服务器选项:

Maven(pom.xml):


tomcat:8.5-jre8-alpine


gcr.io/my-project/my-war-image


/usr/local/tomcat/webapps/my-webapp


Gradle(build.gradle):
jib {
from.image = 'tomcat:8.5-jre8-alpine'
to.image = 'gcr.io/my-project/my-war-image'
container.appRoot = '/usr/local/tomcat/webapps/my-webapp'
}

感兴趣的朋友请参阅Docker化Maven WAR项目Docker化Gradle WAR项目的相关说明。
#在Kubernetes开发当中与Skaffold for Java相集成
Skaffold是一款用于在Kubernetes上实现持续开发的命令行工具。我们将Skaffold与Jib加以集成,旨在实现Kubernetes之上的无缝化开发体验。Jib现在已经可以作为Skaffold当中的builder选项。

要在您的Java项目当中开始使用Skaffold,您首先需要安装Skaffold并向项目当中添加skaffold.yaml文件:
skaffold.yaml:

apiVersion: skaffold/v1beta4
kind: Config
build:
artifacts:
- image: gcr.io/my-project/my-java-image
# Use this for a Maven project:
jibMaven: {}
# Use this for a Gradle project:
jibGradle: {}

请确保您已经把Kubernetes清单存放在k8s/目录当中,且Container规范中的镜像引用匹配至gcr.io/my-project/my-java-image位置。请查阅Skaffold库作为参考

接下来,您可以使用以下命令启动Skaffold的持续开发功能:
$ skaffold dev --trigger notify

Skaffold能够帮助您消除在进行每一项变更之后,对应用程序进行重新构建与重新部署所带来的一系列繁琐步骤。Skaffold会利用Jib对您的应用程序进行容器化转换,而后在检测到变更时将其部署至您的Kubernetes集群当中。现在,您将能够把精力集中到真正重要的工作——编写代码身上。
##Jib Core:在Java中构建Docker镜像
Jib运行在我们自己用于构建容器镜像的通用库之上,我们将这套库以Jib Core的形式进行发布,同时进行了一系列API改进。现在,您可以将Jib作为Maven以及Gradle插件,从而在无需Docker守护程序的前提下面向任意应用程序利用Java进行容器构建。

要使用Jib Core,您需要在项目当中添加以下文件:

Maven(pom.xml):

com.google.cloud.tools
jib-core
0.1.1

Gradle(build.gradle):
dependencies {
implementation 'com.google.cloud.tools:jib-core:0.1.1'
}

以下是构建一套简单Docker镜像的操作示例。其将以基础镜像为起点,添加单一层、设置入口点,而后使用几行代码将镜像推送至远端注册表当中:
Jib.from("busybox")
.addLayer(Arrays.asList(Paths.get("helloworld.sh")), AbsoluteUnixPath.get("/"))
.setEntrypoint("sh", "/helloworld.sh")
.containerize(
Containerizer.to(RegistryImage.named("gcr.io/my-project/hello-from-jib")
.addCredential("myusername", "mypassword")));

我们也鼓励大家利用Jib Core构建属于自己的自定义容器化解决方案。欢迎您在我们的Gitter频道上共享利用Jib Core构建的一切项目。另外,您也可以参考我们发布的Jib Core其它使用示例,例如Gradle构建脚本
#丰富的功能,加上仍然简单易行的窗口化操作体验
利用Jib对Java应用程序进行容器化转换仍然与以往一样简单易行。如果您使用的是Maven,只需要将这款插件添加至pom.xml当中:

com.google.cloud.tools
jib-maven-plugin
1.0.0


gcr.io/my-project/my-java-image



要构建一套镜像并将其推送至容器注册表,您可使用以下命令:
$ mvn compile jib:build

或者使用以下命令面向Docker守护程序进行构建:
$ mvn compile jib:dockerBuild

您现在甚至可以在无需修改pom.xml文件的前提下实现应用程序容器化,具体操作如下:
$ mvn compile com.google.cloud.tools:jib-maven-plugin:1.0.0:build -Dimage=gcr.io/my-project/my-java-image

若需了解更多细节信息,请参阅Jib Maven快速入门。
当配合Gradle使用Jib时,您需要将该插件添加至build.gradle当中:
plugins {
id 'com.google.cloud.tools.jib' version '1.0.0'
}

jib.to.image = 'gcr.io/my-project/my-java-image'

在此之后,您可以利用以下命令将应用程序容器化至目标容器注册表:
$ gradle jib

或者使用以下命令将其容器化至Docker守护程序:
$ gradle jibDockerBuild

若需了解更多细节信息,请参阅Jib Gradle快速入门
##立即开始使用
我们希望Jib能够帮助每一位朋友简化并加快自己的Java开发进程。要开始使用Jib,请参阅我们的示例;此外,您也可使用Codelabs将Spring Boot应用程序或者Micronaut应用程序部署至Kubernetes当中。Jib能够与大多数Docker注册表提供程序以及托管注册表相兼容;请尽情尝试,并通过github.com/GoogleContainerTools/jib与我们分享您的心得体会。感谢!

原文链接:Jib 1.0.0 is GA—building Java Docker images has never been easier

容器中的JVM资源该如何被安全的限制?

尼古拉斯 发表了文章 • 0 个评论 • 1411 次浏览 • 2019-02-09 11:44 • 来自相关话题

#前言 Java与Docker的结合,虽然更好的解决了application的封装问题。但也存在着不兼容,比如Java并不能自动的发现Docker设置的内存限制,CPU限制。 这将导致JVM不能稳定服务业务!容器会杀死你J ...查看全部
#前言
Java与Docker的结合,虽然更好的解决了application的封装问题。但也存在着不兼容,比如Java并不能自动的发现Docker设置的内存限制,CPU限制。

这将导致JVM不能稳定服务业务!容器会杀死你JVM进程,而健康检查又将拉起你的JVM进程,进而导致你监控你的Pod一天重启次数甚至能达到几百次。

我们希望当Java进程运行在容器中时,Java能够自动识别到容器限制,获取到正确的内存和CPU信息,而不用每次都需要在kubernetes的yaml描述文件中显示的配置完容器,还需要配置JVM参数。

使用JVM MaxRAM参数或者解锁实验特性的JVM参数,升级JDK到10+,我们可以解决这个问题(也许吧~.~)。

首先Docker容器本质是是宿主机上的一个进程,它与宿主机共享一个/proc目录,也就是说我们在容器内看到的/proc/meminfo,/proc/cpuinfo与直接在宿主机上看到的一致,如下。

Host:
cat /proc/meminfo 
MemTotal: 197869260KB
MemFree: 3698100KB
MemAvailable: 62230260KB

容器:
docker run -it --rm alpine cat /proc/meminfo
MemTotal: 197869260KB
MemFree: 3677800KB
MemAvailable: 62210088KB

那么Java是如何获取到Host的内存信息的呢?没错就是通过/proc/meminfo来获取到的。

默认情况下,JVM的Max Heap Size是系统内存的1/4,假如我们系统是8G,那么JVM将的默认Heap≈2G。

Docker通过CGroups完成的是对内存的限制,而/proc目录是已只读形式挂载到容器中的,由于默认情况下Java压根就看不见CGroups的限制的内存大小,而默认使用/proc/meminfo中的信息作为内存信息进行启动,
这种不兼容情况会导致,如果容器分配的内存小于JVM的内存,JVM进程会被理解杀死。
#内存限制不兼容
我们首先来看一组测试,这里我们采用一台内存为188G的物理机。
#free -g
total used free shared buff/cache available
Mem: 188 122 1 0 64 64

以下的测试中,我们将包含OpenJDK的hotspot虚拟机,IBM的OpenJ9虚拟机。

以下测试中,我们把正确识别到限制的JDK,称之为安全(即不会超出容器限制不会被kill),反之称之为危险。
##测试用例1(OpenJDK)
这一组测试我们使用最新的OpenJDK8-12,给容器限制内存为4G,看JDK默认参数下的最大堆为多少?看看我们默认参数下多少版本的JDK是安全的

命令如下,如果你也想试试看,可以用一下命令。
docker run -m 4GB --rm  openjdk:8-jre-slim java  -XshowSettings:vm  -version
docker run -m 4GB --rm openjdk:9-jre-slim java -XshowSettings:vm -version
docker run -m 4GB --rm openjdk:10-jre-slim java -XshowSettings:vm -version
docker run -m 4GB --rm openjdk:11-jre-slim java -XshowSettings:vm -version
docker run -m 4GB --rm openjdk:12 java -XshowSettings:vm -version

OpenJDK8(并没有识别容器限制,26.67G) 危险。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:8-jre-slim java  -XshowSettings:vm  -version

VM settings:
Max. Heap Size (Estimated): 26.67G
Ergonomics Machine Class: server
Using VM: OpenJDK 64-Bit Server VM

openjdk version "1.8.0_181"
OpenJDK Runtime Environment (build 1.8.0_181-8u181-b13-2~deb9u1-b13)
OpenJDK 64-Bit Server VM (build 25.181-b13, mixed mode)

OpenJDK8 -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap (正确的识别容器限制,910.50M)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:8-jre-slim java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 910.50M
Ergonomics Machine Class: server
Using VM: OpenJDK 64-Bit Server VM

openjdk version "1.8.0_181"
OpenJDK Runtime Environment (build 1.8.0_181-8u181-b13-2~deb9u1-b13)
OpenJDK 64-Bit Server VM (build 25.181-b13, mixed mode)

OpenJDK 9(并没有识别容器限制,26.67G)危险。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:9-jre-slim java  -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 29.97G
Using VM: OpenJDK 64-Bit Server VM

openjdk version "9.0.4"
OpenJDK Runtime Environment (build 9.0.4+12-Debian-4)
OpenJDK 64-Bit Server VM (build 9.0.4+12-Debian-4, mixed mode)

OpenJDK 9 -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap(正确的识别容器限制,1G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:9-jre-slim java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 1.00G
Using VM: OpenJDK 64-Bit Server VM

openjdk version "9.0.4"
OpenJDK Runtime Environment (build 9.0.4+12-Debian-4)
OpenJDK 64-Bit Server VM (build 9.0.4+12-Debian-4, mixed mode)

OpenJDK 10(正确的识别容器限制,1G)安全。
[root@xiaoke-test ~]# docker run -m 32GB --rm  openjdk:10-jre-slim java -XshowSettings:vm -XX:MaxRAMFraction=1  -version
VM settings:
Max. Heap Size (Estimated): 1.00G
Using VM: OpenJDK 64-Bit Server VM

openjdk version "10.0.2" 2018-07-17
OpenJDK Runtime Environment (build 10.0.2+13-Debian-2)
OpenJDK 64-Bit Server VM (build 10.0.2+13-Debian-2, mixed mode)

OpenJDK 11(正确的识别容器限制,1G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:11-jre-slim java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 1.00G
Using VM: OpenJDK 64-Bit Server VM

openjdk version "11.0.1" 2018-10-16
OpenJDK Runtime Environment (build 11.0.1+13-Debian-3)
OpenJDK 64-Bit Server VM (build 11.0.1+13-Debian-3, mixed mode, sharing)

OpenJDK 12(正确的识别容器限制,1G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:12 java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 1.00G
Using VM: OpenJDK 64-Bit Server VM

openjdk version "12-ea" 2019-03-19
OpenJDK Runtime Environment (build 12-ea+23)
OpenJDK 64-Bit Server VM (build 12-ea+23, mixed mode, sharing)

##测试用例2(IBM OpenJ9)
docker run -m 4GB --rm  adoptopenjdk/openjdk8-openj9:alpine-slim  java -XshowSettings:vm  -version
docker run -m 4GB --rm adoptopenjdk/openjdk9-openj9:alpine-slim java -XshowSettings:vm -version
docker run -m 4GB --rm adoptopenjdk/openjdk10-openj9:alpine-slim java -XshowSettings:vm -version
docker run -m 4GB --rm adoptopenjdk/openjdk11-openj9:alpine-slim java -XshowSettings:vm -version

OpenJDK8-OpenJ9(正确的识别容器限制,3G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  adoptopenjdk/openjdk8-openj9:alpine-slim  java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 3.00G
Ergonomics Machine Class: server
Using VM: Eclipse OpenJ9 VM

openjdk version "1.8.0_192"
OpenJDK Runtime Environment (build 1.8.0_192-b12_openj9)
Eclipse OpenJ9 VM (build openj9-0.11.0, JRE 1.8.0 Linux amd64-64-Bit Compressed References 20181107_95 (JIT enabled, AOT enabled)
OpenJ9 - 090ff9dcd
OMR - ea548a66
JCL - b5a3affe73 based on jdk8u192-b12)

OpenJDK9-OpenJ9 (正确的识别容器限制,3G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  adoptopenjdk/openjdk9-openj9:alpine-slim  java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 3.00G
Using VM: Eclipse OpenJ9 VM

openjdk version "9.0.4-adoptopenjdk"
OpenJDK Runtime Environment (build 9.0.4-adoptopenjdk+12)
Eclipse OpenJ9 VM (build openj9-0.9.0, JRE 9 Linux amd64-64-Bit Compressed References 20180814_248 (JIT enabled, AOT enabled)
OpenJ9 - 24e53631
OMR - fad6bf6e
JCL - feec4d2ae based on jdk-9.0.4+12)

OpenJDK10-OpenJ9 (正确的识别容器限制,3G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  adoptopenjdk/openjdk10-openj9:alpine-slim  java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 3.00G
Using VM: Eclipse OpenJ9 VM

openjdk version "10.0.2-adoptopenjdk" 2018-07-17
OpenJDK Runtime Environment (build 10.0.2-adoptopenjdk+13)
Eclipse OpenJ9 VM (build openj9-0.9.0, JRE 10 Linux amd64-64-Bit Compressed References 20180813_102 (JIT enabled, AOT enabled)
OpenJ9 - 24e53631
OMR - fad6bf6e
JCL - 7db90eda56 based on jdk-10.0.2+13)

OpenJDK11-OpenJ9(正确的识别容器限制,3G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  adoptopenjdk/openjdk11-openj9:alpine-slim  java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 3.00G
Using VM: Eclipse OpenJ9 VM

openjdk version "11.0.1" 2018-10-16
OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.1+13)
Eclipse OpenJ9 VM AdoptOpenJDK (build openj9-0.11.0, JRE 11 Linux amd64-64-Bit Compressed References 20181020_70 (JIT enabled, AOT enabled)
OpenJ9 - 090ff9dc
OMR - ea548a66
JCL - f62696f378 based on jdk-11.0.1+13)

##分析
分析之前我们先了解这么一个情况:
JavaMemory (MaxRAM) = 元数据+线程+代码缓存+OffHeap+Heap...

一般我们都只配置Heap即使用-Xmx来指定JVM可使用的最大堆。而JVM默认会使用它获取到的最大内存的1/4作为堆的原因也是如此。

安全性(即不会超过容器限制被容器kill)

OpenJDK:

OpenJdk8-12,都能保证这个安全性的特点(8和9需要特殊参数,-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap)。

OpenJ9:

2.IbmOpenJ9所有的版本都能识别到容器限制。

资源利用率

OpenJDK:

自动识别到容器限制后,OpenJDK把最大堆设置为了大概容器内存的1/4,对内存的浪费不可谓不大。

当然可以配合另一个JVM参数来配置最大堆。-XX:MaxRAMFraction=int。下面是我整理的一个常见内存设置的表格,从中我们可以看到似乎JVM默认的最大堆的取值为MaxRAMFraction=4,随着内存的增加,堆的闲置空间越来越大,在16G容器内存时,Java堆只有不到4G。

MaxRAMFraction取值	堆占比	容器内存=1G	容器内存=2G	容器内存=4G   容器内存=8G	容器内存=16G
1 ≈90% 910.50M 1.78G 3.56G 7.11G 14.22G
2 ≈50% 455.50M 910.50M 1.78G 3.56G 7.11G
3 ≈33% 304.00M 608.00M 1.19G 2.37G 4.74G
4 ≈25% 228.00M 455.50M 910.50M 1.78G 3.56G

OpenJ9:

关于OpenJ9的的详细介绍你可以从这里了解更多。

对于内存利用率OpenJ9的策略是优于OpenJDK的。以下是OpenJ9的策略表格.

容器内存	最大Java堆大小
小于1GB 50%
1GB-2GB -512MB
大于2GB 大于2GB

##结论
注意:这里我们说的是容器内存限制,和物理机内存不同。

自动档

如果你想要的是,不显示的指定-Xmx,让Java进程自动的发现容器限制。

如果你想要的是JVM进程在容器中安全稳定的运行,不被容器kiil,并且你的JDK版本小于10(大于等于JDK10的版本不需要设置,参考前面的测试)。

你需要额外设置JVM参数-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap,即可保证你的Java进程不会因为内存问题被容器Kill。

当然这个方式使用起来简单,可靠,缺点也很明显,资源利用率过低(参考前面的表格MaxRAMFraction=4)。

如果想在基础上我还想提高一些内存资源利用率,并且容器内存为1GB - 4GB,我建议你设置-XX:MaxRAMFraction=2,在大于8G的可以尝试设置-XX:MaxRAMFraction=1(参考上表格)。

手动挡

如果你想要的是手动挡的体验,更加进一步的利用内存资源,那么你可能需要回到手动配置时代-Xmx。

手动挡部分,请可以完全忽略上面我的BB。

上面我们说到了自动挡的配置,用起来很简单很舒服,自动发现容器限制,无需担心和思考去配置-Xmx。

比如你有内存1G那么我建议你的-Xmx750M,2G建议配置-Xmx1700M,4G建议配置-Xmx3500-3700M,8G建议设置-Xmx7500-7600M,总之就是至少保留300M以上的内存留给JVM的其他内存。如果堆特别大,可以预留到1G甚至2G。

手动挡用起来就没有那么舒服了,当然资源利用率相对而言就更高了。

原文链接:https://qingmu.io/2018/12/17/How-to-securely-limit-JVM-resources-in-a-container/

Java线程池ThreadPoolExecutor实现原理剖析

Andy_Lee 发表了文章 • 0 个评论 • 1671 次浏览 • 2018-10-13 17:05 • 来自相关话题

【编者的话】在Java中,使用线程池来异步执行一些耗时任务是非常常见的操作。最初我们一般都是直接使用new Thread().start的方式,但我们知道,线程的创建和销毁都会耗费大量的资源,关于线程可以参考之前的一篇博客《Java线程那点事儿》,因此我们需要 ...查看全部
【编者的话】在Java中,使用线程池来异步执行一些耗时任务是非常常见的操作。最初我们一般都是直接使用new Thread().start的方式,但我们知道,线程的创建和销毁都会耗费大量的资源,关于线程可以参考之前的一篇博客《Java线程那点事儿》,因此我们需要重用线程资源。

当然也有其他待解决方案,比如说coroutine,目前Kotlin已经支持了,JDK也已经有了相关的提案:Project Loom,目前的实现方式和Kotlin有点类似,都是基于ForkJoinPool,当然目前还有很多限制以及问题没解决,比如synchronized还是锁住当前线程等。
##继承结构
1.png

继承结构看起来很清晰,最顶层的Executor只提供了一个最简单的void execute(Runnable command)方法,然后是ExecutorService,ExecutorService提供了一些管理相关的方法,例如关闭、判断当前线程池的状态等,另外不同于Executor#execute,ExecutorService提供了一系列方法,可以将任务包装成一个Future,从而使得任务提交方可以跟踪任务的状态。而父类AbstractExecutorService则提供了一些默认的实现。
#构造器
ThreadPoolExecutor的构造器提供了非常多的参数,每一个参数都非常的重要,一不小心就容易踩坑,因此设置的时候,你必须要知道自己在干什么。
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.acc = System.getSecurityManager() == null ?
null :
AccessController.getContext();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}


  1. corePoolSize、 maximumPoolSize。线程池会自动根据corePoolSize和maximumPoolSize去调整当前线程池的大小。当你通过submit或者execute方法提交任务的时候,如果当前线程池的线程数小于corePoolSize,那么线程池就会创建一个新的线程处理任务, 即使其他的core线程是空闲的。如果当前线程数大于corePoolSize并且小于maximumPoolSize,那么只有在队列"满"的时候才会创建新的线程。因此这里会有很多的坑,比如你的core和max线程数设置的不一样,希望请求积压在队列的时候能够实时的扩容,但如果制定了一个无界队列,那么就不会扩容了,因为队列不存在满的概念。

  1. keepAliveTime。如果当前线程池中的线程数超过了corePoolSize,那么如果在keepAliveTime时间内都没有新的任务需要处理,那么超过corePoolSize的这部分线程就会被销毁。默认情况下是不会回收core线程的,可以通过设置allowCoreThreadTimeOut改变这一行为。

  1. workQueue。即实际用于存储任务的队列,这个可以说是最核心的一个参数了,直接决定了线程池的行为,比如说传入一个有界队列,那么队列满的时候,线程池就会根据core和max参数的设置情况决定是否需要扩容,如果传入了一个SynchronousQueue,这个队列只有在另一个线程在同步remove的时候才可以put成功,对应到线程池中,简单来说就是如果有线程池任务处理完了,调用poll或者take方法获取新的任务的时候,新提交的任务才会put成功,否则如果当前的线程都在忙着处理任务,那么就会put失败,也就会走扩容的逻辑,如果传入了一个DelayedWorkQueue,顾名思义,任务就会根据过期时间来决定什么时候弹出,即为ScheduledThreadPoolExecutor的机制。

  1. threadFactory。创建线程都是通过ThreadFactory来实现的,如果没指定的话,默认会使用Executors.defaultThreadFactory(),一般来说,我们会在这里对线程设置名称、异常处理器等。

  1. handler。即当任务提交失败的时候,会调用这个处理器,ThreadPoolExecutor内置了多个实现,比如抛异常、直接抛弃等。这里也需要根据业务场景进行设置,比如说当队列积压的时候,针对性的对线程池扩容或者发送告警等策略。

看完这几个参数的含义,我们看一下Executors提供的一些工具方法,只要是为了方便使用,但是我建议最好少用这个类,而是直接用ThreadPoolExecutor的构造函数,多了解一下这几个参数到底是什么意思,自己的业务场景是什么样的,比如线程池需不需要扩容、用不用回收空闲的线程等。
public class Executors {

/*
* 提供一个固定大小的线程池,并且线程不会回收,由于传入的是一个无界队列,相当于队列永远不会满
* 也就不会扩容,因此需要特别注意任务积压在队列中导致内存爆掉的问题
*/
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue());
}


/*
* 这个线程池会一直扩容,由于SynchronousQueue的特性,如果当前所有的线程都在处理任务,那么
* 新的请求过来,就会导致创建一个新的线程处理任务。如果线程一分钟没有新任务处理,就会被回
* 收掉。特别注意,如果每一个任务都比较耗时,并发又比较高,那么可能每次任务过来都会创建一个线
* 程
*/
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue());
}
}

##源码分析
既然是个线程池,那就必然有其生命周期:运行中、关闭、停止等。ThreadPoolExecutor是用一个AtomicInteger去的前三位表示这个状态的,另外又重用了低29位用于表示线程数,可以支持最大大概5亿多,绝逼够用了,如果以后硬件真的发展到能够启动这么多线程,改成AtomicLong就可以了。

状态这里主要分为下面几种:

  1. RUNNING:表示当前线程池正在运行中,可以接受新任务以及处理队列中的任务
  2. SHUTDOWN:不再接受新的任务,但会继续处理队列中的任务
  3. STOP:不再接受新的任务,也不处理队列中的任务了,并且会中断正在进行中的任务
  4. TIDYING:所有任务都已经处理完毕,线程数为0,转为为TIDYING状态之后,会调用terminated()回调
  5. TERMINATED:terminated()已经执行完毕

同时我们可以看到所有的状态都是用二进制位表示的,并且依次递增,从而方便进行比较,比如想获取当前状态是否至少为SHUTDOWN等,同时状态之前有几种转换:

  1. RUNNING -> SHUTDOWN。调用了shutdown()之后,或者执行了finalize()
  2. (RUNNING 或者 SHUTDOWN) -> STOP。调用了shutdownNow()之后会转换这个状态
  3. SHUTDOWN -> TIDYING。当线程池和队列都为空的时候
  4. STOP -> TIDYING。当线程池为空的时候
  5. IDYING -> TERMINATED。执行完terminated()回调之后会转换为这个状态

    private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY = (1 << COUNT_BITS) - 1;

private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;

//由于前三位表示状态,因此将CAPACITY取反,和进行与操作即可
private static int runStateOf(int c) { return c & ~CAPACITY; }
private static int workerCountOf(int c) { return c & CAPACITY; }

//高三位+第三位进行或操作即可
private static int ctlOf(int rs, int wc) { return rs | wc; }

private static boolean runStateLessThan(int c, int s) {
return c < s;
}

private static boolean runStateAtLeast(int c, int s) {
return c >= s;
}

private static boolean isRunning(int c) {
return c < SHUTDOWN;
}

//下面三个方法,通过CAS修改worker的数目
private boolean compareAndIncrementWorkerCount(int expect) {
return ctl.compareAndSet(expect, expect + 1);
}

//只尝试一次,失败了则返回,是否重试由调用方决定
private boolean compareAndDecrementWorkerCount(int expect) {
return ctl.compareAndSet(expect, expect - 1);
}

//跟上一个不一样,会一直重试
private void decrementWorkerCount() {
do {} while (! compareAndDecrementWorkerCount(ctl.get()));
}

下面是比较核心的字段,这里workers采用的是非线程安全的HashSet,而不是线程安全的版本,主要是因为这里有些复合的操作,比如说将worker添加到workers后,我们还需要判断是否需要更新largestPoolSize等,workers只在获取到mainLock的情况下才会进行读写,另外这里的mainLock也用于在中断线程的时候串行执行,否则如果不加锁的话,可能会造成并发去中断线程,引起不必要的中断风暴。
private final ReentrantLock mainLock = new ReentrantLock();

private final HashSet workers = new HashSet();

private final Condition termination = mainLock.newCondition();

private int largestPoolSize;

private long completedTaskCount;

##核心方法
拿到一个线程池之后,我们就可以开始提交任务,让它去执行了,那么我们看一下submit方法是如何实现的。
    public Future submit(Runnable task) {
if (task == null) throw new NullPointerException();
RunnableFuture ftask = newTaskFor(task, null);
execute(ftask);
return ftask;
}

public Future submit(Callable task) {
if (task == null) throw new NullPointerException();
RunnableFuture ftask = newTaskFor(task);
execute(ftask);
return ftask;
}

这两个方法都很简单,首先将提交过来的任务(有两种形式:Callable、Runnable)都包装成统一的RunnableFuture,然后调用execute方法,execute可以说是线程池最核心的一个方法。
    public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
/*
获取当前worker的数目,如果小于corePoolSize那么就扩容,
这里不会判断是否已经有core线程,而是只要小于corePoolSize就会直接增加worker
*/
if (workerCountOf(c) < corePoolSize) {
/*
调用addWorker(Runnable firstTask, boolean core)方法扩容
firstTask表示为该worker启动之后要执行的第一个任务,core表示要增加的为core线程
*/
if (addWorker(command, true))
return;
//如果增加失败了那么重新获取ctl的快照,比如可能线程池在这期间关闭了
c = ctl.get();
}
/*
如果当前线程池正在运行中,并且将任务丢到队列中成功了,
那么就会进行一次double check,看下在这期间线程池是否关闭了,
如果关闭了,比如处于SHUTDOWN状态,如上文所讲的,SHUTDOWN状态的时候,
不再接受新任务,remove成功后调用拒绝处理器。而如果仍然处于运行中的状态,
那么这里就double check下当前的worker数,如果为0,有可能在上述逻辑的执行
过程中,有worker销毁了,比如说任务抛出了未捕获异常等,那么就会进行一次扩容,
但不同于扩容core线程,这里由于任务已经丢到队列中去了,因此就不需要再传递firstTask了,
同时要注意,这里扩容的是非core线程
*/
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
/*
如果在上一步中,将任务丢到队列中失败了,那么就进行一次扩容,
这里会将任务传递到firstTask参数中,并且扩容的是非core线程,
如果扩容失败了,那么就执行拒绝策略。
*/
reject(command);
}

这里要特别注意下防止队列失败的逻辑,不同的队列丢任务的逻辑也不一样,例如说无界队列,那么就永远不会put失败,也就是说扩容也永远不会执行,如果是有界队列,那么当队列满的时候,会扩容非core线程,如果是SynchronousQueue,这个队列比较特殊,当有另外一个线程正在同步获取任务的时候,你才能put成功,因此如果当前线程池中所有的worker都忙着处理任务的时候,那么后续的每次新任务都会导致扩容,当然如果worker没有任务处理了,阻塞在获取任务这一步的时候,新任务的提交就会直接丢到队列中去,而不会扩容。

上文中多次提到了扩容,那么我们下面看一下线程池具体是如何进行扩容的:
    private boolean addWorker(Runnable firstTask, boolean core) {
retry:
for (;;) {
int c = ctl.get();
//获取当前线程池的状态
int rs = runStateOf(c);

/*
如果状态为大于SHUTDOWN, 比如说STOP,STOP上文说过队列中的任务不处理了,也不接受新任务,
因此可以直接返回false不扩容了,如果状态为SHUTDOWN并且firstTask为null,同时队列非空,
那么就可以扩容
*/
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;

for (;;) {
int wc = workerCountOf(c);
/*
若worker的数目大于CAPACITY则直接返回,
然后根据要扩容的是core线程还是非core线程,进行判断worker数目
是否超过设置的值,超过则返回
*/
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
/*
通过CAS的方式自增worker的数目,成功了则直接跳出循环
*/
if (compareAndIncrementWorkerCount(c))
break retry;
//重新读取状态变量,如果状态改变了,比如线程池关闭了,那么就跳到最外层的for循环,
//注意这里跳出的是retry。
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}

boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
//创建Worker
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
/*
获取锁,并判断线程池是否已经关闭
*/
int rs = runStateOf(ctl.get());

if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // 若线程已经启动了,比如说已经调用了start()方法,那么就抛异常,
throw new IllegalThreadStateException();
//添加到workers中
workers.add(w);
int s = workers.size();
if (s > largestPoolSize) //更新largestPoolSize
largestPoolSize = s;
workerAdded = true;
}
} finally {
mainLock.unlock();
}
if (workerAdded) {
//若Worker创建成功,则启动线程,这么时候worker就会开始执行任务了
t.start();
workerStarted = true;
}
}
} finally {
if (! workerStarted)
//添加失败
addWorkerFailed(w);
}
return workerStarted;
}

private void addWorkerFailed(Worker w) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
if (w != null)
workers.remove(w);
decrementWorkerCount();
//每次减少worker或者从队列中移除任务的时候都需要调用这个方法
tryTerminate();
} finally {
mainLock.unlock();
}
}

这里有个貌似不太起眼的方法tryTerminate,这个方法会在所有可能导致线程池终结的地方调用,比如说减少worker的数目等,如果满足条件的话,那么将线程池转换为TERMINATED状态。另外这个方法没有用private修饰,因为ScheduledThreadPoolExecutor继承自ThreadPoolExecutor,而ScheduledThreadPoolExecutor也会调用这个方法。
    final void tryTerminate() {
for (;;) {
int c = ctl.get();
/*
如果当前线程处于运行中、TIDYING、TERMINATED状态则直接返回,运行中的没
什么好说的,后面两种状态可以说线程池已经正在终结了,另外如果处于SHUTDOWN状态,
并且workQueue非空,表明还有任务需要处理,也直接返回
*/
if (isRunning(c) ||
runStateAtLeast(c, TIDYING) ||
(runStateOf(c) == SHUTDOWN && ! workQueue.isEmpty()))
return;
//可以退出,但是线程数非0,那么就中断一个线程,从而使得关闭的信号能够传递下去,
//中断worker后,worker捕获异常后,会尝试退出,并在这里继续执行tryTerminate()方法,
//从而使得信号传递下去
if (workerCountOf(c) != 0) {
interruptIdleWorkers(ONLY_ONE);
return;
}

final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//尝试转换成TIDYING状态,执行完terminated回调之后
//会转换为TERMINATED状态,这个时候线程池已经完整关闭了,
//通过signalAll方法,唤醒所有阻塞在awaitTermination上的线程
if (ctl.compareAndSet(c, ctlOf(TIDYING, 0))) {
try {
terminated();
} finally {
ctl.set(ctlOf(TERMINATED, 0));
termination.signalAll();
}
return;
}
} finally {
mainLock.unlock();
}
// else retry on failed CAS
}
}

/**
* 中断空闲的线程
* @param onlyOne
*/
private void interruptIdleWorkers(boolean onlyOne) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
for (Worker w : workers) {
//遍历所有worker,若之前没有被中断过,
//并且获取锁成功,那么就尝试中断。
//锁能够获取成功,那么表明当前worker没有在执行任务,而是在
//获取任务,因此也就达到了只中断空闲线程的目的。
Thread t = w.thread;
if (!t.isInterrupted() && w.tryLock()) {
try {
t.interrupt();
} catch (SecurityException ignore) {
} finally {
w.unlock();
}
}
if (onlyOne)
break;
}
} finally {
mainLock.unlock();
}
}

2.png

##Worker
下面看一下Worker类,也就是这个类实际负责执行任务,Worker类继承自AbstractQueuedSynchronizer,AQS可以理解为一个同步框架,提供了一些通用的机制,利用模板方法模式,让你能够原子的管理同步状态、blocking和unblocking线程、以及队列,具体的内容之后有时间会再写,还是比较复杂的。这里Worker对AQS的使用相对比较简单,使用了状态变量state表示是否获得锁,0表示解锁、1表示已获得锁,同时通过exclusiveOwnerThread存储当前持有锁的线程。另外再简单提一下,比如说CountDownLatch, 也是基于AQS框架实现的,countdown方法递减state,await阻塞等待state为0。
private final class Worker
extends AbstractQueuedSynchronizer
implements Runnable
{

/*[i] Thread this worker is running in. Null if factory fails. [/i]/
final Thread thread;

/*[i] Initial task to run. Possibly null. [/i]/
Runnable firstTask;

/*[i] Per-thread task counter [/i]/
volatile long completedTasks;

Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
this.thread = getThreadFactory().newThread(this);
}

/*[i] Delegates main run loop to outer runWorker [/i]/
public void run() {
runWorker(this);
}
protected boolean isHeldExclusively() {
return getState() != 0;
}

protected boolean tryAcquire(int unused) {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}

protected boolean tryRelease(int unused) {
setExclusiveOwnerThread(null);
setState(0);
return true;
}

public void lock() { acquire(1); }
public boolean tryLock() { return tryAcquire(1); }
public void unlock() { release(1); }
public boolean isLocked() { return isHeldExclusively(); }

void interruptIfStarted() {
Thread t;
if (getState() >= 0 && (t = thread) != null && !t.isInterrupted()) {
try {
t.interrupt();
} catch (SecurityException ignore) {
}
}
}
}

注意这里Worker初始化的时候,会通过setState(-1)将state设置为-1,并在runWorker()方法中置为0,上文说过Worker是利用state这个变量来表示锁的状态,那么加锁的操作就是通过CAS将state从0改成1,那么初始化的时候改成-1,也就是表示在Worker启动之前,都不允许加锁操作,我们再看interruptIfStarted()以及interruptIdleWorkers()方法,这两个方法在尝试中断Worker之前,都会先加锁或者判断state是否大于0,因此这里的将state设置为-1,就是为了禁止中断操作,并在runWorker中置为0,也就是说只能在Worker启动之后才能够中断Worker。

另外线程启动之后,其实就是调用了runWorker方法,下面我们看一下具体是如何实现的。
   final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // 调用unlock()方法,将state置为0,表示其他操作可以获得锁或者中断worker
boolean completedAbruptly = true;
try {
/*
首先尝试执行firstTask,若没有的话,则调用getTask()从队列中获取任务
*/
while (task != null || (task = getTask()) != null) {
w.lock();
/*
如果线程池正在关闭,那么中断线程。
*/
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();
try {
//执行beforeExecute回调
beforeExecute(wt, task);
Throwable thrown = null;
try {
//实际开始执行任务
task.run();
} catch (RuntimeException x) {
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
//执行afterExecute回调
afterExecute(task, thrown);
}
} finally {
task = null;
//这里加了锁,因此没有线程安全的问题,volatile修饰保证其他线程的可见性
w.completedTasks++;
w.unlock();//解锁
}
}
completedAbruptly = false;
} finally {
//抛异常了,或者当前队列中已没有任务需要处理等
processWorkerExit(w, completedAbruptly);
}
}

private void processWorkerExit(Worker w, boolean completedAbruptly) {
//如果是异常终止的,那么减少worker的数目
if (completedAbruptly) // If abrupt, then workerCount wasn't adjusted
decrementWorkerCount();

final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//将当前worker中workers中删除掉,并累加当前worker已执行的任务到completedTaskCount中
completedTaskCount += w.completedTasks;
workers.remove(w);
} finally {
mainLock.unlock();
}

//上文说过,减少worker的操作都需要调用这个方法
tryTerminate();

/*
如果当前线程池仍然是运行中的状态,那么就看一下是否需要新增另外一个worker替换此worker
*/
int c = ctl.get();
if (runStateLessThan(c, STOP)) {
/*
如果是异常结束的则直接扩容,否则的话则为正常退出,比如当前队列中已经没有任务需要处理,
如果允许core线程超时的话,那么看一下当前队列是否为空,空的话则不用扩容。否则话看一下
是否少于corePoolSize个worker在运行。
*/
if (!completedAbruptly) {
int min = allowCoreThreadTimeOut ? 0 : corePoolSize;
if (min == 0 && ! workQueue.isEmpty())
min = 1;
if (workerCountOf(c) >= min)
return; // replacement not needed
}
addWorker(null, false);
}
}

private Runnable getTask() {
boolean timedOut = false; // 上一次poll()是否超时了

for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

// 若线程池关闭了(状态大于STOP)
// 或者线程池处于SHUTDOWN状态,但是队列为空,那么返回null
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}

int wc = workerCountOf(c);

/*
如果允许core线程超时 或者 不允许core线程超时但当前worker的数目大于core线程数,
那么下面的poll()则超时调用
*/
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

/*
获取任务超时了并且(当前线程池中还有不止一个worker 或者 队列中已经没有任务了),那么就尝试
减少worker的数目,若失败了则重试
*/
if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}

try {
//从队列中抓取任务
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
//走到这里表明,poll调用超时了
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}

##关闭线程池
关闭线程池一般有两种形式,shutdown()和shutdownNow()。
    public void shutdown() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
//通过CAS将状态更改为SHUTDOWN,这个时候线程池不接受新任务,但会继续处理队列中的任务
advanceRunState(SHUTDOWN);
//中断所有空闲的worker,也就是说除了正在处理任务的worker,其他阻塞在getTask()上的worker
//都会被中断
interruptIdleWorkers();
//执行回调
onShutdown(); // hook for ScheduledThreadPoolExecutor
} finally {
mainLock.unlock();
}
tryTerminate();
//这个方法不会等待所有的任务处理完成才返回
}
public List shutdownNow() {
List tasks;
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
/*
不同于shutdown(),会转换为STOP状态,不再处理新任务,队列中的任务也不处理,
而且会中断所有的worker,而不只是空闲的worker
*/
advanceRunState(STOP);
interruptWorkers();
tasks = drainQueue();//将所有的任务从队列中弹出
} finally {
mainLock.unlock();
}
tryTerminate();
return tasks;
}

private List drainQueue() {
BlockingQueue q = workQueue;
ArrayList taskList = new ArrayList();
/*
将队列中所有的任务remove掉,并添加到taskList中,
但是有些队列比较特殊,比如说DelayQueue,如果第一个任务还没到过期时间,则不会弹出,
因此这里通过调用toArray方法,然后再一个一个的remove掉
*/
q.drainTo(taskList);
if (!q.isEmpty()) {
for (Runnable r : q.toArray(new Runnable[0])) {
if (q.remove(r))
taskList.add(r);
}
}
return taskList;
}

从上文中可以看到,调用了shutdown()方法后,不会等待所有的任务处理完毕才返回,因此需要调用awaitTermination()来实现。
    public boolean awaitTermination(long timeout, TimeUnit unit)
throws InterruptedException {
long nanos = unit.toNanos(timeout);
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
for (;;) {
//线程池若已经终结了,那么就返回
if (runStateAtLeast(ctl.get(), TERMINATED))
return true;
//若超时了,也返回掉
if (nanos <= 0)
return false;
//阻塞在信号量上,等待线程池终结,但是要注意这个方法可能会因为一些未知原因随时唤醒当前线程,
//因此需要重试,在tryTerminate()方法中,执行完terminated()回调后,表明线程池已经终结了,
//然后会通过termination.signalAll()唤醒当前线程
nanos = termination.awaitNanos(nanos);
}
} finally {
mainLock.unlock();
}
}
一些统计相关的方法
public int getPoolSize() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//若线程已终结则直接返回0,否则计算works中的数目
//想一下为什么不用workerCount呢?
return runStateAtLeast(ctl.get(), TIDYING) ? 0
: workers.size();
} finally {
mainLock.unlock();
}
}

public int getActiveCount() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
int n = 0;
for (Worker w : workers)
if (w.isLocked())//上锁的表明worker当前正在处理任务,也就是活跃的worker
++n;
return n;
} finally {
mainLock.unlock();
}
}


public int getLargestPoolSize() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
return largestPoolSize;
} finally {
mainLock.unlock();
}
}

//获取任务的总数,这个方法慎用,若是个无解队列,或者队列挤压比较严重,会很蛋疼
public long getTaskCount() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
long n = completedTaskCount;//比如有些worker被销毁后,其处理完成的任务就会叠加到这里
for (Worker w : workers) {
n += w.completedTasks;//叠加历史处理完成的任务
if (w.isLocked())//上锁表明正在处理任务,也算一个
++n;
}
return n + workQueue.size();//获取队列中的数目
} finally {
mainLock.unlock();
}
}


public long getCompletedTaskCount() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
long n = completedTaskCount;
for (Worker w : workers)
n += w.completedTasks;
return n;
} finally {
mainLock.unlock();
}
}

#总结
这篇博客基本上覆盖了线程池的方方面面,但仍然有非常多的细节可以深究,比如说异常的处理,可以参照之前的一篇博客:《深度解析Java线程池的异常处理机制》,另外还有AQS、unsafe等可以之后再单独总结。

原文链接:https://github.com/aCoder2013/blog/issues/28

Apache Dubbo已不再局限于Java语言

大卫 发表了文章 • 0 个评论 • 1260 次浏览 • 2018-07-11 22:26 • 来自相关话题

2017 年 9 月 7 日,在沉寂了4年之后,Dubbo 悄悄的在 GitHub 发布了 2.5.4 版本。随后又迅速发布了 2.5.5、2.5.6、2.5.7 等release。在 2017年 10 月举行的云栖大会上,阿里宣布 Dubbo 被列入集团重点 ...查看全部
2017 年 9 月 7 日,在沉寂了4年之后,Dubbo 悄悄的在 GitHub 发布了 2.5.4 版本。随后又迅速发布了 2.5.5、2.5.6、2.5.7 等release。在 2017年 10 月举行的云栖大会上,阿里宣布 Dubbo 被列入集团重点维护开源项目,这也就意味着 Dubbo 重启,开始重新进入新征程。Dubbo 进入 Apache 孵化器,如果毕业后,项目移出 incubator,成为正式开源项目,在这期间还是有很多工作要做。
1.jpg

2.jpg

3.jpg

近来进入dubbo官网,发现又改版升级了,很清爽简洁,打开速率比之前更快了。
4.jpg

5.jpg

6.jpg

有几个亮点,可从上图生态中发现:
##不局限于Java
Dubbo已不在局限在Java语言范围内,开始支持Node.js,Python。具体使用过程Dubbo的社区生态中找到对应方法。
##支持SpringBoot
Dubbo支持通过API方式启动方式中已经融合SpringBoot,从github的incubator-dubbo-spring-boot-project项目中可以看到,已经迭代3个版本,支持最新的SpringBoot 2.0,2018-6-21日发布的两个发个release新版本中可以看到。
##支持Rest
Dubbo在重启维护后,dubbo-2.6.0版本中奖当当团队维护的DubboX合并近来(2018-01-08)。基于标准的Java REST API——JAX-RS 2.0(Java API for RESTful Web Services的简写)实现的REST调用支持。
7.jpg

##高性能序列化框架
在DubboX的分支合并中,kryo, FST的serialization framework,提升接口数据的交互效率。

Github上接近2W个Star,相信随着周边生态不断的完善,Dubbo会进入到更多的企业中,发挥更大的效用。

原文链接:https://mp.weixin.qq.com/s/1sGu2KOKBtLZN4l3r2OFGw

为多个PHP-FPM容器量身打造单一Nginx镜像

kelvinji2009 发表了文章 • 2 个评论 • 2226 次浏览 • 2018-06-09 16:31 • 来自相关话题

【译者的话】这篇博客主要讲述了如何创建一个可以关联Docker环境变量与Nginx配置文件的Nginx镜像,供你所有的`PHP-FPM`容器应用。 最近我一直在努力部署一套使用Docker容器的PHP微服务。其中一个问题是我们的P ...查看全部
【译者的话】这篇博客主要讲述了如何创建一个可以关联Docker环境变量与Nginx配置文件的Nginx镜像,供你所有的`PHP-FPM`容器应用。

最近我一直在努力部署一套使用Docker容器的PHP微服务。其中一个问题是我们的PHP应用程序被设置为与`PHP-FPM`和`Nginx`一起工作(而不是这里所说的简单的Apache/PHP设置),因此每个PHP微服务需要两个容器(也就是相当于两个Docker镜像):

* PHP-FPM容器
* Nginx容器

假设一个应用运行超过六个PHP微服务,算上你的dev和prod环境,那么最终差不多会产生接近30个容器。我决定构建一个单独的Nginx Docker镜像,将`PHP-FPM`主机名作为环境变量映射到这个镜像里面独特的配置文件中,而不是为每个`PHP-FPM`微服务的镜像构建独特的Nginx镜像。
XoCNwnk.jpg

在这篇博客文章中,我将概述我从上述方法1到方法2的过程,最后用介绍如何使用新定制Nginx Docker镜像的解决方案来结束这篇博客。

我已经将这个镜像开源GitHub,所以如果这刚好是您经常遇到的问题,请随时查看。
# 为什么是Nginx?

`PHP-FPM`和Nginx一起使用可以产生更好的PHP应用程序性能,但缺点是PHP-FPM Docker镜像默认没有像`PHP Apache`镜像那样与Nginx捆绑在一起。

如果您想将Nginx容器连接到PHP-FPM后端,则需要将该后端的DNS记录添加到您的Nginx配置中。

例如,如果PHP-FPM容器作为名为`php-fpm-api`的容器运行,那么您的Nginx配置文件应该这样写:
nginx
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
# This line passes requests through to the PHP-FPM container
fastcgi_pass php-fpm-api:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}

如果你只服务一个PHP-FPM容器应用,在你的Nginx容器的配置文件中硬编码对应的名字是可以的。但是,如我上面提到的,每个PHP服务都需要一个对应的Nginx容器,我们就需要运行多个Nginx容器。创建一个新的Nginx镜像(我们后面必须维护和升级)将是一件痛苦的事情,因为即使管理一堆不同的卷,对于更改单个变量名称似乎也有很多工作要做。
# 第一个解决方案:使用Docker文档里提到的方法`envsubst`


起初,我认为这很容易。在Docker文档中关于如何使用`envsubst`有一个很好的小章节,但不幸的是,这不适用于我的Nginx配置文件:

vhost.conf
nginx
server {
listen 80;
index index.php index.html;
root /var/www/public;
client_max_body_size 32M;

location / {
try_files $uri /index.php?$args;
}

location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass ${NGINX_HOST}:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
}

我的`vhost.conf`文件用到了好几个Nginx内置的环境变量,结果当我运行Docker文档里提到的如下命令行时,提示错误:`$uri`和`fastcgi_script_name`未定义。
shell
/bin/bash -c "envsubst < /etc/nginx/conf.d/mysite.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"

这些变量通常由Nginx本身传入,所以不容易搞清楚他们是什么和怎么进行参数传递的,而且这会影响容器的动态可配置性。
# 另一个差点成功的Docker镜像

接下来,我开始搜索不同的Nginx的基础镜像。找到了两个,但是这两个都是两年没有更新了。我从martin/nginx开始,尝试看看能不能得到一个可以工作的原型。

Martin的镜像有点不太一样,因为它要求特定的文件目录结构。我先在`Dockerfile`中添加了:
FROM martin/nginx

接下来,我添加了`app/`空目录,只包含一个`vhost.conf`文件的`conf/`目录。

vhost.conf
nginx
server {
listen 80;
index index.php index.html;
root /var/www/public;
client_max_body_size 32M;

location / {
try_files $uri /index.php?$args;
}

location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass $ENV{"NGINX_HOST"}:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
}

这个跟我原始的配置文件差不多,只修改了一行:`fastcgi_pass $ENV{"NGINX_HOST"}:9000;`。现在当我想要启动一个Nginx容器和一个叫`php-fpm-api`的PHP容器的时候,我可以先编译一个新的镜像,然后在它运行的时候传递给它对应的环境变量:
shell
docker build -t shiphp/nginx-env:test .
docker run -it --rm -e NGINX_HOST=php-fpm-api shiphp/nginx-env:test

成功了!但是,这个方法有两个问题困扰着我:

  1. 基础镜像版本陈旧,两年多没更新了。这可能会造成安全和性能风险。
  2. 要求一个`app`的空目录似乎没啥必要,再加上我的文件放在不同的目录。

# 最终解决方案

我觉得Martin的镜像是个不错的自定义方案选择。所以,我`fork`了他的仓库并构建了一个新的并解决了以上两个问题的Nginx基础镜像。现在,如果你想运行一个伴随着nginx容器的动态命名后端应用,你只需要简单地这么做:
shell
# Pull down the latest from Docker Hub
docker pull shiphp/nginx-env:latest

# Run a PHP container named "php-fpm-api"
docker run --name php-fpm-api -v $(pwd):/var/www php:fpm

# Start this NGinx container linked to the PHP-FPM container
docker run --link php-fpm-api -e NGINX_HOST=php-fpm-api shiphp/nginx-env

如果你想自定义这个镜像,添加你自己的文件或者Nginx配置文件,只需要像下面这样扩展你的`Dockerfile`:
FROM shiphp/nginx-env

ONBUILD ADD /etc/nginx/conf.d/

现在我所有的PHP-FPM容器都使用单个Nginx镜像的实例,当我需要升级Nginx、修改权限或者配置一些东西的时候,这让我的生活变得简单多了。

所有的代码都放在GitHub上面了。如果您发现任何问题或想要提出改进建议,请随时创建`issue`。如果您对这个问题或Docker相关的任何问题,可以在Twitter上找我一起讨论。

原文链接:Building a Single NGinx Docker Image For All My PHP-FPM Containers(翻译:kelvinji

Java和Docker限制的那些事儿

kelvinji2009 发表了文章 • 0 个评论 • 4813 次浏览 • 2018-06-04 15:06 • 来自相关话题

【编者的话】Java和Docker不是天然的朋友。 Docker可以设置内存和CPU限制,而Java不能自动检测到。使用Java的Xmx标识(繁琐/重复)或新的实验性JVM标识,我们可以解决这个问题。 加强Docker容器与Java1 ...查看全部
【编者的话】Java和Docker不是天然的朋友。 Docker可以设置内存和CPU限制,而Java不能自动检测到。使用Java的Xmx标识(繁琐/重复)或新的实验性JVM标识,我们可以解决这个问题。

加强Docker容器与Java10集成 - Docker官方博客在最新版本的Java的OpenJ9和OpenJDK10中彻底解决了这个问题。
# 虚拟化中的不匹配
Java和Docker的结合并不是完美匹配的,最初的时候离完美匹配有相当大的距离。对于初学者来说,JVM的全部设想就是,虚拟机可以让程序与底层硬件无关。

那么,把我们的Java应用打包到JVM中,然后整个再塞进Docker容器中,能给我们带来什么好处呢?大多数情况下,你只是在复制JVMs和Linux容器,除了浪费更多的内存,没任何好处。感觉这样子挺傻的。

不过,Docker可以把你的程序,设置,特定的JDK,Linux设置和应用服务器,还有其他工具打包在一起,当做一个东西。站在DevOps/Cloud的角度来看,这样一个完整的容器有着更高层次的封装。
## 问题一:内存
时至今日,绝大多数产品级应用仍然在使用Java 8(或者更旧的版本),而这可能会带来问题。Java 8(update 131之前的版本)跟Docker无法很好地一起工作。问题是在你的机器上,JVM的可用内存和CPU数量并不是Docker允许你使用的可用内存和CPU数量。

比如,如果你限制了你的Docker容器只能使用100MB内存,但是呢,旧版本的Java并不能识别这个限制。Java看不到这个限制。JVM会要求更多内存,而且远超这个限制。如果使用太多内存,Docker将采取行动并杀死容器内的进程!JAVA进程被干掉了,很明显,这并不是我们想要的。

为了解决这个问题,你需要给Java指定一个最大内存限制。在旧版本的Java(8u131之前),你需要在容器中通过设置`-Xmx`来限制堆大小。这感觉不太对,你可不想定义这些限制两次,也不太想在你的容器中来定义。

幸运的是我们现在有了更好的方式来解决这个问题。从Java 9之后(8u131+),JVM增加了如下标志:
-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap

这些标志强制JVM检查Linux的`cgroup`配置,Docker是通过`cgroup`来实现最大内存设置的。现在,如果你的应用到达了Docker设置的限制(比如500MB),JVM是可以看到这个限制的。JVM将会尝试GC操作。如果仍然超过内存限制,JVM就会做它该做的事情,抛出`OutOfMemoryException`。也就是说,JVM能够看到Docker的这些设置。

从Java 10之后(参考下面的测试),这些体验标志位是默认开启的,也可以使用`-XX:+UseContainerSupport`来使能(你可以通过设置`-XX:-UseContainerSupport`来禁止这些行为)。
## 问题二:CPU
第二个问题是类似的,但它与CPU有关。简而言之,JVM将查看硬件并检测CPU的数量。它会优化你的runtime以使用这些CPUs。但是同样的情况,这里还有另一个不匹配,Docker可能不允许你使用所有这些CPUs。可惜的是,这在Java 8或Java 9中并没有修复,但是在Java 10中得到了解决。

从Java 10开始,可用的CPUs的计算将采用以不同的方式(默认情况下)解决此问题(同样是通过`UseContainerSupport`)。
# Java和Docker的内存处理测试
作为一个有趣的练习,让我们验证并测试Docker如何使用几个不同的JVM版本/标志甚至不同的JVM来处理内存不足。

首先,我们创建一个测试应用程序,它只是简单地“吃”内存并且不释放它。
java
import java.util.ArrayList;
import java.util.List;

public class MemEat {
public static void main(String[] args) {
List l = new ArrayList<>();
while (true) {
byte b[] = new byte[1048576];
l.add(b);
Runtime rt = Runtime.getRuntime();
System.out.println( "free memory: " + rt.freeMemory() );
}
}
}

我们可以启动Docker容器并运行这个应用程序来查看会发生什么。
## 测试一:Java 8u111
首先,我们将从具有旧版本Java 8的容器开始(update 111)。
shell
docker run -m 100m -it java:openjdk-8u111 /bin/bash

我们编译并运行`MemEat.java`文件:
shell
javac MemEat.java

java MemEat
...
free memory: 67194416
free memory: 66145824
free memory: 65097232
Killed

正如所料,Docker已经杀死了我们的Java进程。不是我们想要的(!)。你也可以看到输出,Java认为它仍然有大量的内存需要分配。

我们可以通过使用-Xmx标志为Java提供最大内存来解决此问题:
shell
javac MemEat.java

java -Xmx100m MemEat
...
free memory: 1155664
free memory: 1679936
free memory: 2204208
free memory: 1315752
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at MemEat.main(MemEat.java:8)

在提供了我们自己的内存限制之后,进程正常停止,JVM理解它正在运行的限制。然而,问题在于你现在将这些内存限制设置了两次,Docker一次,JVM一次。
## 测试二:Java 8u144
如前所述,随着增加新标志来修复问题,JVM现在可以遵循Docker所提供的设置。我们可以使用版本新一点的JVM来测试它。
shell
docker run -m 100m -it adoptopenjdk/openjdk8 /bin/bash

(在撰写本文时,此OpenJDK Java镜像的版本是Java 8u144)

接下来,我们再次编译并运行`MemEat.java`文件,不带任何标志:
shell
javac MemEat.java

java MemEat
...
free memory: 67194416
free memory: 66145824
free memory: 65097232
Killed

依然存在同样的问题。但是我们现在可以提供上面提到的实验性标志来试试看:
shell
javac MemEat.java
java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap MemEat
...
free memory: 1679936
free memory: 2204208
free memory: 1155616
free memory: 1155600
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at MemEat.main(MemEat.java:8)

这一次我们没有告诉JVM限制的是什么,我们只是告诉JVM去检查正确的限制设置!现在感觉好多了。
## 测试三:Java 10u23
有些人在评论和Reddit上提到Java 10通过使实验标志成为新的默认值来解决所有问题。这种行为可以通过禁用此标志来关闭:`-XX:-UseContainerSupport`。

当我测试它时,它最初不起作用。在撰写本文时,AdoptAJDK OpenJDK10镜像与`jdk-10+23`一起打包。这个JVM显然还是不理解`UseContainerSupport`标志,该进程仍然被Docker杀死。
shell
docker run -m 100m -it adoptopenjdk/openjdk10 /bin/bash

测试了代码(甚至手动提供需要的标志):
shell
javac MemEat.java

java MemEat
...
free memory: 96262112
free memory: 94164960
free memory: 92067808
free memory: 89970656
Killed

java -XX:+UseContainerSupport MemEat

Unrecognized VM option 'UseContainerSupport'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.

## 测试四:Java 10u46(Nightly)
我决定尝试AdoptAJDK OpenJDK 10的最新`nightly`构建。它包含的版本是Java 10+46,而不是Java 10+23。
shell
docker run -m 100m -it adoptopenjdk/openjdk10:nightly /bin/bash

然而,在这个`ngithly`构建中有一个问题,导出的PATH指向旧的Java 10+23目录,而不是10+46,我们需要修复这个问题。
shell
export PATH=$PATH:/opt/java/openjdk/jdk-10+46/bin/

javac MemEat.java

java MemEat
...
free memory: 3566824
free memory: 2796008
free memory: 1480320
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at MemEat.main(MemEat.java:8)

成功!不提供任何标志,Java 10依然可以正确检测到Dockers内存限制。
## 测试五:OpenJ9
我最近也在试用OpenJ9,这个免费的替代JVM已经从IBM J9开源,现在由Eclipse维护。

请在我的下一篇博文中阅读关于OpenJ9的更多信息。

它运行速度快,内存管理非常好,性能卓越,经常可以为我们的微服务节省多达30-50%的内存。这几乎可以将Spring Boot应用程序定义为'micro'了,其运行时间只有100-200mb,而不是300mb+。我打算尽快就此写一篇关于这方面的文章。

但令我惊讶的是,OpenJ9还没有类似于Java 8/9/10+中针对`cgroup`内存限制的标志(backported)的选项。如果我们将以前的测试用例应用到最新的AdoptAJDK OpenJDK 9 + OpenJ9 build:
shell
docker run -m 100m -it adoptopenjdk/openjdk9-openj9 /bin/bash

我们添加OpenJDK标志(OpenJ9会忽略的标志):
shell
java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap MemEat
...
free memory: 83988984
free memory: 82940400
free memory: 81891816
Killed

Oops,JVM再次被Docker杀死。

我真的希望类似的选项将很快添加到OpenJ9中,因为我希望在生产环境中运行这个选项,而不必指定最大内存两次。 Eclipse/IBM正在努力修复这个问题,已经提了issues,甚至已经针对issues提交了PR。
## 更新:(不推荐Hack)
一个稍微丑陋/hacky的方式来解决这个问题是使用下面的组合标志:
shell
java -Xmx`cat /sys/fs/cgroup/memory/memory.limit_in_bytes` MemEat
...
free memory: 3171536
free memory: 2127048
free memory: 2397632
free memory: 1344952
JVMDUMP039I Processing dump event "systhrow", detail "java/lang/OutOfMemoryError" at 2018/05/15 14:04:26 - please wait.
JVMDUMP032I JVM requested System dump using '//core.20180515.140426.125.0001.dmp' in response to an event
JVMDUMP010I System dump written to //core.20180515.140426.125.0001.dmp
JVMDUMP032I JVM requested Heap dump using '//heapdump.20180515.140426.125.0002.phd' in response to an event
JVMDUMP010I Heap dump written to //heapdump.20180515.140426.125.0002.phd
JVMDUMP032I JVM requested Java dump using '//javacore.20180515.140426.125.0003.txt' in response to an event
JVMDUMP010I Java dump written to //javacore.20180515.140426.125.0003.txt
JVMDUMP032I JVM requested Snap dump using '//Snap.20180515.140426.125.0004.trc' in response to an event
JVMDUMP010I Snap dump written to //Snap.20180515.140426.125.0004.trc
JVMDUMP013I Processed dump event "systhrow", detail "java/lang/OutOfMemoryError".
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at MemEat.main(MemEat.java:8)

在这种情况下,堆大小受限于分配给Docker实例的内存,这适用于较旧的JVM和OpenJ9。这当然是错误的,因为容器本身和堆外的JVM的其他部分也使用内存。但它似乎工作,显然Docker在这种情况下是宽松的。也许某些bash大神会做出更好的版本,从其他进程的字节中减去一部分。

无论如何,不要这样做,它可能无法正常工作。
## 测试六:OpenJ9(Nightly)
有人建议使用OpenJ9的最新`nightly`版本。
shell
docker run -m 100m -it adoptopenjdk/openjdk9-openj9:nightly /bin/bash

最新的OpenJ9夜间版本,它有两个东西:

  1. 另一个有问题的PATH参数,需要先解决这个问题
  2. JVM支持新标志UseContainerSupport(就像Java 10一样)

shell
export PATH=$PATH:/opt/java/openjdk/jdk-9.0.4+12/bin/

javac MemEat.java

java -XX:+UseContainerSupport MemEat
...
free memory: 5864464
free memory: 4815880
free memory: 3443712
free memory: 2391032
JVMDUMP039I Processing dump event "systhrow", detail "java/lang/OutOfMemoryError" at 2018/05/15 21:32:07 - please wait.
JVMDUMP032I JVM requested System dump using '//core.20180515.213207.62.0001.dmp' in response to an event
JVMDUMP010I System dump written to //core.20180515.213207.62.0001.dmp
JVMDUMP032I JVM requested Heap dump using '//heapdump.20180515.213207.62.0002.phd' in response to an event
JVMDUMP010I Heap dump written to //heapdump.20180515.213207.62.0002.phd
JVMDUMP032I JVM requested Java dump using '//javacore.20180515.213207.62.0003.txt' in response to an event
JVMDUMP010I Java dump written to //javacore.20180515.213207.62.0003.txt
JVMDUMP032I JVM requested Snap dump using '//Snap.20180515.213207.62.0004.trc' in response to an event
JVMDUMP010I Snap dump written to //Snap.20180515.213207.62.0004.trc
JVMDUMP013I Processed dump event "systhrow", detail "java/lang/OutOfMemoryError".
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

TADAAA,正在修复中!

奇怪的是,这个标志在OpenJ9中默认没有启用,就像它在Java 10中一样。再说一次:确保你测试了这是你想在一个Docker容器中运行Java。
# 结论
简言之:注意资源限制的不匹配。测试你的内存设置和JVM标志,不要假设任何东西。

如果您在Docker容器中运行Java,请确保你设置了Docker内存限制和在JVM中也做了限制,或者你的JVM能够理解这些限制。

如果您无法升级您的Java版本,请使用`-Xmx`设置您自己的限制。


对于Java 8和Java 9,请更新到最新版本并使用:
-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap

对于Java 10,确保它支持'UseContainerSupport'(更新到最新版本)。

对于OpenJ9(我强烈建议使用,可以在生产环境中有效减少内存占用量),现在使用`-Xmx`设置限制,但很快会出现一个支持`UseContainerSupport`标志的版本。

原文链接:Java and Docker, the limitations(翻译:kelvinji

Java 10发布后,Docker容器管理能力得到显著增强

kelvinji2009 发表了文章 • 0 个评论 • 2280 次浏览 • 2018-06-04 15:05 • 来自相关话题

Apache Spark、Kafka等运行在JVM中的传统企业应用,实际上都可以运行在容器环境之内。然而,在容器内运行JVM的方案近期却遇到了麻烦——由于内存与CPU资源及利用率受限,致使其性能表现无法令人满意。究其原因,这是因为Java无法意识到自身正运行在 ...查看全部
Apache Spark、Kafka等运行在JVM中的传统企业应用,实际上都可以运行在容器环境之内。然而,在容器内运行JVM的方案近期却遇到了麻烦——由于内存与CPU资源及利用率受限,致使其性能表现无法令人满意。究其原因,这是因为Java无法意识到自身正运行在容器当中。

随着Java 10的发布,JVM终于可以识别出由容器控制组(cgroups)提出的限制集合。这意味着内存与CPU限制条件皆可用于直接在容器内实现对Java应用的管理,具体包括:


* 在容器内部设置内存限制
* 在容器内部设置可用CPU个数
* 在容器内设置CPU限制


Java 10的这些优化成果可在Docker for mac/windows和Docker企业版中正常起效。


#容器内存限制

一直到Java 9版本,JVM仍然无法通过在容器中使用标识来识别内存或CPU限制。在Java 10中,内存限制可以被自动识别,而且这一特性将默认启用。

Java对服务器进行分级定义,如果一台服务器有双CPU加2 GB内存,那么默认的堆大小将为物理内存的1/4。这里假定有一台安装Docker企业版四CPU 加2 GB内存的服务器,下面我们来比较分别运行有Java 8和Java 10的容器的具体区别。先来看Java 8:

docker container run -it -m512 --entrypoint bash openjdk:latest

$ docker-java-home/bin/java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
uintx MaxHeapSize := 524288000 {product}
openjdk version "1.8.0_162"


我们可以看到最大的堆大小是512 MB,刚好等于(1/4)x 2 GB,但这是通过Docker EE自动设置的,而而通过设置容器实现。作为比较,我们再来看Java 10环境下运行同样的命令是什么结果。

docker container run -it -m512M --entrypoint bash openjdk:10-jdk

$ docker-java-home/bin/java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
size_t MaxHeapSize = 134217728 {product} {ergonomic}
openjdk version "10" 2018-03-20


上面的结果显示,容器里的内存限制非常接近我们期望的128 MB。

#设置可用CPU个数
默认情况下,各容器所能获取的主机CPU时钟周期不受限制。通过额外设置,我们可以指定某一特定容器所能获得的主机CPU时钟周期。

Java 10能够识别这些限制:

docker container run -it --cpus 2 openjdk:10-jdk
jshell> Runtime.getRuntime().availableProcessors()
$1 ==> 2


所有分配给Docker EE的CPU均获得同样比例的CPU时钟周期。这一比例可以通过调整CPU共享比重(相对于其他所有运行的容器的比重)来进行修改。

这一比重只在运行CPU敏感型进程时才会生效。当某一容器中的任务处于空闲状态,那么其它容器可以使用剩余的全部CPU时钟周期。真实CPU时间量在很大程度上取决于运行在该系统之上的容器数量。这些在Java 10中都可以设置:

docker container run -it --cpu-shares 2048 openjdk:10-jdk
jshell> Runtime.getRuntime().availableProcessors()
$1 ==> 2


Java 10中也可以设置CPU集合限制(允许哪些CPU执行)。

docker run -it --cpuset-cpus="1,2,3" openjdk:10-jdk
jshell> Runtime.getRuntime().availableProcessors()
$1 ==> 3

#分配内存与CPU
利用Java 10,我们可以使用容器设置来估算部署应用程序所需的内存和CPU配额。我们假设已经确定了容器中运行的每个进程的内存堆和CPU要求,并设置了JAVA_OPTS。例如,如果您有一个跨十个节点分布的应用程序,其中五个节点需要512 Mb的内存,且各自分配得1024个CPU比重;另外五个节点需要256 Mb,且各自分配得512个CPU比重。请注意,1块CPU的整体计算能力将被拆分为1024个比重单位。



对于内存,应用程序需要至少分配5 Gb容量。



512 Mb x 5 = 2.56 Gb




256Mb x 5 = 1.28 Gb

该应用程序需要8块CPU才能高效运行。

1024 x 5 = 5 CPU

512 x 5 = 3 CPU



最佳实践建议用户对应用程序进行分析,以确定运行在JVM每个进程的内存和CPU分配需求。但凭借着对容器需求的识别能力,Java 10使我们得以准确估算工作负载的CPU及内存资源占用情况,从而防止容器内运行的Java应用程序遭遇内存或CPU不足的问题。



原文链接:IMPROVED DOCKER CONTAINER INTEGRATION WITH JAVA 10(翻译:kelvinji)

赶紧升级!Java 10为Docker做了特殊优化

ds_sky2008 发表了文章 • 0 个评论 • 3310 次浏览 • 2018-03-22 13:26 • 来自相关话题

【编者的话】在过去的几年中,Docker一直是非常受欢迎的容器技术,而原因也很简单。将基于JVM的应用程序容器化部署,可以为应用程序提供一致的开发、部署环境以及零耦合的环境隔离。但不幸的是,目前的JVM在Linux容器内运行事务并不那么简单。因此,为了优化一些 ...查看全部
【编者的话】在过去的几年中,Docker一直是非常受欢迎的容器技术,而原因也很简单。将基于JVM的应用程序容器化部署,可以为应用程序提供一致的开发、部署环境以及零耦合的环境隔离。但不幸的是,目前的JVM在Linux容器内运行事务并不那么简单。因此,为了优化一些问题,Java 9和10做了很多非常必要的改进,这里我们重点说三点。
# 堆(Heap)大小
默认情况下,在64位的服务器中,JVM通常将最大堆大小设定为物理内存的1/4。而在容器化环境中,这确实没有什么意义,因为你通常拥有很多可以运行多个JVM的大内存的服务器。如果你在不同的容器中运行10个JVM,并且每个JVM最终都使用了1/4的RAM,那么你将面临过度使用机器RAM的窘境,并且有可能最终导致虚拟内存耗尽——结局就是用户将离你而去。

这还会抵消容器的另一项重要优势,即构建及测试的容器镜像必然能够在生产环境中拥有同样的运行效果。在较小的物理主机上的镜像环境中,一个容器可以很容易地正常工作,但在生产环境较大的主机上可能会因为超出容器的任何内存限制而被内核杀死。

对此有各种解决方法,例如包括一个JAVA_OPTIONS环境变量,可以从容器外部设置堆大小(或-XX:MaxRam)。但是,这会让事情变得混乱,因为你需要多次复制关于容器限制的信息——一次在容器中,一次为JVM。当然,你也可以编写JVM启动脚本,从proc文件系统中提取正确的内存限制。但都不会让你优雅的解决问题。

在Linux上隔离容器的主要机制是通过控制组(CGroups),这些机制允许(除其他外)限制资源到一组进程。使用Java 10,JVM将读取容器CGroup中的内存限制和使用情况,并使用它来初始化最大内存,从而消除对这些变通办法中的任何一种的需求。
# 可用的CPU
默认情况下,Docker容器可以无限制地访问系统上的所有CPU。将利用率限制在一定比例的CPU time(使用CPU份额)或系统的各个CPU范围(使用cpusets)是可能的也是常见的

不幸的是,与堆大小一样,Java 8中的JVM大多不知道用于限制容器内CPU利用率的各种机制。这可能会导致在具有多个内核的大型物理主机上出现问题,因为在容器内运行的所有JVM都会假定它们可以访问比实际更多的CPU。这样做的结果是,JVM的许多部分将根据可用的处理器进行自适应大小调整,例如具有并行性和并发性的JIT编译器线程和ForkJoin池,其大小将错误调整,从而产生的线程数量大于他们所期望的数量并且这可能会导致过多的上下文切换以及生产中糟糕的性能。许多第三方实用程序,库和应用程序也使用Runtime.availableProcessors()方法来调整自己的线程池或展现类似的行为。

从Java 8u131和Java 9开始,JVM可以理解和利用cpusets来确定可用处理器的大小,而Java 10则支持CPU共享
# 从host连接
Attach API允许从另一个JVM程式访问JVM。它对于读取目标JVM的环境状态非常有用,并且在JVM代理中动态加载可以执行额外的监控,分析或诊断任务。由于连接机制与进程名称空间的交互方式,目前无法将主机上的JVM附加到在Docker容器内运行的JVM。

主流操作系统上的所有进程都有唯一的标识符PID。Linux还具有PID命名空间的概念,其中不同命名空间中的两个进程可以共享相同的PID。命名空间也可以嵌套,这个功能用来隔离容器内的进程。

连接机制的复杂性在于容器内部的JVM当前没有在容器外的PID概念。Java 10通过容器内的JVM在根名称空间中找到它的PID并使用它来监视JVM,据此来修复此问题

原文链接:Java on Docker will no longer suck: improvements coming in Java 10(翻译:dssky2008)

在容器中使用Java RAM:五种不丢失内存的方法

herryliq 发表了文章 • 0 个评论 • 4141 次浏览 • 2017-05-27 13:45 • 来自相关话题

【编者的话】在这篇文章中,我们想分享一些看起来不那么明显的关于在容器内部中Java内存管理和弹性扩展的细节。 您将看到在即将发布的JDK版本中需要注意的问题和重要更新的列表,以及核心难点的现有解决方法。 我们收集了可以提高Java应用程序的资源使用效率的五个最 ...查看全部
【编者的话】在这篇文章中,我们想分享一些看起来不那么明显的关于在容器内部中Java内存管理和弹性扩展的细节。 您将看到在即将发布的JDK版本中需要注意的问题和重要更新的列表,以及核心难点的现有解决方法。 我们收集了可以提高Java应用程序的资源使用效率的五个最有趣和最有用的问题点。

【3 天烧脑式 Docker 训练营 | 上海站】随着Docker技术被越来越多的人所认可,其应用的范围也越来越广泛。本次培训我们理论结合实践,从Docker应该场景、持续部署与交付、如何提升测试效率、存储、网络、监控、安全等角度进行。

#在Docker中对Java Heap内存的限制
最近,社区中正在讨论在Docker容器中运行Java程序时内存限制错误的问题。
screen.png

JVM的内存使用超出Docker容器的cgroups限制时被内核杀死。

解决这个问题,其中的一个改善对策已经在OpenJDK 9中实现:

“在OpenJDK 9中已经添加了一个实验性的变更,JVM能够感知到Java程序正运行在一个容器中,因此可以调整内存的限制。” 可以查看文章 Java 9 Will Adjust Memory Limits if Running with Docker 。



一个新的JVM参数(-XX:+UseCGroupMemoryLimitForHeap)将根据cgroup中的内存限制自动的为Java进程设置 Xmx参数。

在Java 9正式发布前,Xmx限制作为JVM的一个具体明确的启动参数,这将作为一个变通的方案来解决这个问题。在official OpenJDK repo中已经有一个pull请求 “a script to set better default Xmx values according to the docker memory limits” 。

Jelastic通过使用与Docker镜像相结合的增强型系统容器虚拟化层来忽略错误的内存限制。在之前我们已经在文章《Java and Memory Limits in Containers: LXC, Docker and OpenVZ》中解释过它是如何工作的。
#跟踪不在堆中的原生内存使用
当在云中运行Java应用时,注意Java进程的原生内存使用情况同样非常重要,被称为off-heap内存。它可以被用做以下不同目的:

垃圾回收器和JIT优化的Object Graphs在原生内存中被跟踪和储存数据。此外,从JDK8开始,类的名称和字段,方法的字节流,常量池等已经被分配到Metaspace中,也是在JVM堆之外的内存区域中。

因此,为了追求高性能的Java应用可以在原生内存区中分配内存。使用java.nio.ByteBuffer或者第三方的库可以使得应用保存大量的在系统原生I/O操作层面被管理的缓冲区。

默认的情况下,Metaspace的分配只受到原生系统内存的限制。在Docker容器的混合体中使用不正确的内存限制,将会增加应用不确定的风险。限制metadata的大小是重要的,尤其是当你存在OOM的问题时。可以使用参数 -XX:MaxMetaspaceSize来进行限制。

将所有对象都存储在普通的垃圾回收堆内存之外,对于Java应用程序的内存占用空间有何影响并不明显。有一篇很好的文章详细解释了这个问题,并提供了一些如何分析本地内存使用的指导原则:

“几周之前,我在尝试分析一个运行在Docker中的Java应用(Spring Boot + Infinispan)的内存使用情况时,遇到了一个有趣的问题。Xmx参数被设置为256m,但是在Docker的监控工具中至少有两次使用了更多的内存。” ——Analyzing java memory usage in a Docker container



在文章的结尾作者较为风趣的说明:

“我可以说什么作为结论? 好吧,从来没有在同一句话中说“java”和“micro”,我在开玩笑 - 只要记住,在java的中处理内存,Linux和Docker要比看起来更棘手一些。“



为了跟踪原生内存的分配,可以使用(-XX:NativeMemoryTracking=summary)的JVM参数。请注意如果设置了这个参数将使你获得5%~10%的性能提升。
#在运行时缩减JVM使用的内存
在Java应用中其它有效降低JVM中内存消耗的方法是在运行时调整可管理的参数,从JDK7u60和JDK8u20开始,MinHeapFreeRatio和MaxHeapFreeRatio参数已经变得更加容易的被管理。这意味着我们可以在运行时改天它们的值,而无需重新启动Java进程。

在文章《Runtime Committed Heap Resizing》中,作者介绍了如何减少内存使用来调整这些可管理的选项:

“……多次调整内存使用的大小,堆容量从159MB增加到444MB。 描述堆容量值的85%应该是已经被释放的,并且指出JVM调整堆的大小可以获得最多15%的使用率。“



这种方法可以为变量加载带来显着的资源使用优化。 而改进JVM内存大小调整的下一步可以允许在运行时模式下更改Xmx,而不需要重新启动Java进程。
#提高内存的压缩率
在多数情况下,客户希望最大程度地减少Java应用程序中使用的内存量,从而可以更频繁的GC。例如,这样可以帮助在开发、测试和编译环境中更高效的利用资源来节省资金,同样在生产环境中也适用。然而,根据官方的增强版本说明,当前的GC算法需要多次完整的GC周期来释放全部未被使用的内存。

作为改进,JDK9中增加了一个新可以控制GC算法的的JVM参数(-XX:+ShrinkHeapInSteps)。这个设置将屏蔽4轮完整的GC循环。这样将更加迅速的释放未被使用的内存同时也可以使应用中的变量加载可以最小的使用Java的堆空间。
#减少内存使用速度以便更快速的流动
实时迁移消耗大量内存的Java应用往往需要大量的时间。为了降低总的迁移时间和资源的消耗,迁移引擎通常使用小规模的数据量在服务器之间进行传输。 可以在实时传输前通过压缩RAM来帮助全部GC循环。对于各种应用来说,这种方法可以更具成本效益,以克服在GC循环期间的性能下降,而不是使用未压缩的RAM进行迁移。

我们发现了与此主题相关的伟大研究工作: GC-assisted JVM Live Migration for Java Server Applications。作者将JVM与CRIU(检查点/用户空间中的恢复点)集成,并引入了一个新的GC逻辑,以减少Java应用程序从一个主机迁移到另一个主机的时间。所提供的方法允许在执行Java进程状态的快照之前启用迁移感知垃圾收集,然后通过在磁盘上检查它来冻结运行的容器,然后从冻结的角度恢复容器。

此外,Docker社区将CRIU整合为主流。目前这个功能还处于试验阶段。

两者(Java和CRIU)的结合可以释放尚未发现的性能和部署优化的机会,从而改进云中的Java应用程序托管。您可以在文章“Containers Live Migration: Behind the Scenes”中找到有关容器在云中如何运行的更多细节。

Java是伟大的,已经在云中很好地运行,特别是在容器中,但是我们认为它可以更好。因此,在本文中,我们介绍了一系列当前可能已经改进的问题,以便顺利,高效地运行Java应用程序。

在Jelastic,我们在全球数百个数据中心运行了数千个Java容器。良好的内存管理对我们至关重要。这就是为什么我们不断将Java内存使用的提高纳入我们的平台,所以开发人员不必明确地处理这些问题。在Jelastic增强平台上运行Java容器的实验

原文链接:Java RAM Usage in Containers: Top 5 Tips Not to Lose Your Memory(翻译:李强)

Docker容器内存分配问题

回复

吴锦晟 回复了问题 • 4 人关注 • 3 个回复 • 9301 次浏览 • 2017-12-04 17:02 • 来自相关话题

一个spring cloud的java容器限制多大的内存比较好

回复

请叫我小路飞 发起了问题 • 1 人关注 • 0 个回复 • 3133 次浏览 • 2017-05-23 19:48 • 来自相关话题

Docker安装ZeroMQ

回复

豪杰春香 发起了问题 • 2 人关注 • 0 个回复 • 2656 次浏览 • 2015-12-28 16:51 • 来自相关话题

构建的java镜像为什么很大!

回复

oilbeater 回复了问题 • 3 人关注 • 2 个回复 • 2881 次浏览 • 2015-12-12 19:58 • 来自相关话题

启动tomcat容器来跑JAVA项目有问题

回复

lioncui 发起了问题 • 2 人关注 • 0 个回复 • 3283 次浏览 • 2015-09-28 16:16 • 来自相关话题

Docker运行Java,路径问题

回复

bnuhero 回复了问题 • 4 人关注 • 2 个回复 • 5534 次浏览 • 2015-04-25 18:35 • 来自相关话题

云原生之下的Java

尼古拉斯 发表了文章 • 0 个评论 • 185 次浏览 • 2019-05-30 10:22 • 来自相关话题

自从公司的运行平台全线迁入了 Kubenetes 之后总是觉得 DevOps 变成了一个比以前更困难的事情,反思了一下,这一切的困境居然是从现在所使用的 Java 编程语言而来,那我们先聊聊云原生。 Cloud Native 在我的理 ...查看全部
自从公司的运行平台全线迁入了 Kubenetes 之后总是觉得 DevOps 变成了一个比以前更困难的事情,反思了一下,这一切的困境居然是从现在所使用的 Java 编程语言而来,那我们先聊聊云原生。

Cloud Native 在我的理解是,虚拟化之后企业上云,现在的企业几乎底层设施都已经云化之后,对应用的一种倒逼,Cloud Native 是一个筐,什么都可以往里面扔,但是有些基础是被大家共识的,首先云原生当然和编程语言无关,说的是一个应用如何被创建/部署,后续的就引申出了比如 DevOps 之类的新的理念,但是回到问题的本身,Cloud Native 提出的一个很重要的要求,应用如何部署 这个问题从以前由应用决定,现在变成了,基础设施 决定 应用应该如何部署。如果你想和更多Kubernetes技术专家交流,可以加我微信liyingjiese,备注『加群』。群里每周都有全球各大公司的最佳实践以及行业最新动态

让我们回到一切的开始,首先云原生亦或者是 DevOps 都有一个基础的要求,当前版本的代码能够在任何一个环境运行,看起来是不是一个很简单的需求,但是这个需求有一个隐喻所有的环境的基础设施是一样的,显然不能你的开发环境是 Windows 测试环境 Debian 生产环境又是 CentOS 那怎么解决呢,从这一环,我们需要一个工具箱然后往这个工具箱里面扔我们需要的工具了。首先我们需要的就是 Cloud Native 工具箱中最为明显的产品 Docker/Continar,经常有 Java 开发者问我,Docker 有什么用,我的回答是,Docker 对 Java 不是必须的,但是对于其他的语言往往是如果伊甸园中的苹果一样的诱人,打个比方,一个随系统打包的二进制发行版本,可以在任何地方运行,是不是让人很激动,对于大部分的 Java 开发者可能无感,对于 C 语言项目的编写者,那些只要不是基于虚拟机的语言,他们都需要系统提供运行环境,而系统千变万化,当然开发者不愿意为了不同的系统进行适配,在以前我们需要交叉编译,现在我们把这个复杂的事情交给了 Docker,让 Docker 如同 Java 一样,一次编写处处运行,这样的事情简直就像是端了 Java 的饭碗,以前我们交付一个复杂的系统,往往连着操作系统一起交付,而客户可能买了一些商业系统,为了适配有可能还要改代码,现在你有了Docker,开发者喜大普奔,而这里的代价呢?C&C++&GO 他们失去的是枷锁,获得全世界,而 Java 如同被革命一般,失去了 Once Code,Everywhere Run,获得的是更大的 Docker Image Size,获得被人诟病的 Big Size Runtime。

当我们从代码构建完成了镜像,Cloud Navtive 的故事才刚刚开始,当你的 Team Leader 要求你的系统架构是 MicroServices 的,你把原来的项目进行拆分了,或者是开发的就拆分的足够小的时候,你发现因为代码拆分开了,出现了一点点的代码的重复,有适合也避免不了的,你的依赖库也变的 xN,隔壁 Go 程序员想了想,不行我们就搞个 .so 共享一部分代码吧,然后看了构建出来的二进制文件才 15MB,运维大手一挥,这点大小有啥要共享的,Java 程序员望了望了自己的 Jar 包,60MB 还行吧,维护镜像仓库的运维同事这个时候跑出来,你的镜像怎么有 150MB 了, 你看看你们把磁盘都塞满了,只能苦笑,运维小哥坑次坑次的给打包机加了一块硬盘,顺便问你马上部署了,你需要多大的配额,你说道 2C4G,运维一脸嫌弃的问你,为什么隔壁 Go 项目组的同事才需要 0.5C512MB。你当然也不用告诉他,SpringBoot 依赖的了 XXX,YYY,ZZZ 的库,虽然一半的功能你都没用到。

部署到线上,刚刚准备喘口气,突然发现新的需求又来了,虽然是一个很小的功能,但是和现在的系统内的任何一个服务都没有什么直接关联性,你提出再新写一个服务,运维主管抱怨道,现在的服务器资源还是很紧张,你尝试着用现在最流行的 Vertx 开发一个简单的 Web 服务,你对构建出来的 jar 只有 10MB 很满意,可是镜像加起来还是有 60 MB,也算一种进步,你找到 QA 主管,准备 Show 一下你用了 Java 社区最酷的框架,最强的性能,QA 主管找了一个台 1C2G 的服务让你压测一下,你发现你怎么也拼不过别人 Go 系统,你研究之后发现,原来协程模型在这样的少核心的情况下性能要更好,你找运维希望能升级下配置,你走到运维门口的时候,你停了下来,醒醒吧,不是你错了,而是时代变了。

云原生压根不是为了 Java 存在的,云原生的时代已经不是 90 年代,那时候的软件是一个技术活,每一个系统都需要精心设计,一个系统数个月才会更新一个版本,每一个功能都需要进行完整的测试,软件也跑在了企业内部的服务器上,软件是IT部分的宝贝,给他最好的环境,而在 9012 年,软件是什么?软件早就爆炸了,IT 从业者已经到达一个峰值,还有源源不断的人输入进来,市场的竞争也变的激烈,软件公司的竞争力也早就不是质量高,而是如何更快的应对市场的变化,Java 就如同一个身披无数荣光的二战将军,你让他去打21世纪的信息战,哪里还跟着上时代。

云原生需要的是,More Fast & More Fast 的交付系统,一个系统开发很快的系统,那天生就和精心设计是违背的,一个精心设计又能很快开发完的系统实在少见,所以我们从 Spring Boot 上直接堆砌业务代码,最多按照 MVC 进行一个简单的分层,那些优秀的 OOP 理念都活在哪里,那些底层框架,而你突然有一天对 Go 来了兴趣,你按照学 juc 的包的姿势,想要学习下 Go 的优雅源码,你发现,天呐,那些底层库原来可以设计的如此简单,Cache 只需要使用简单的 Map 加上一个 Lock 就可以获得很好的性能了,你开始怀疑了,随着你了解的越深入,你发现 Go 这个语言真是充满了各种各样的缺点,但是足够简单这个优势简直让你羡慕到不行,你回想起来,Executors 的用法你学了好几天,看了好多文章,才把自己的姿势学完,你发现 go func(){} 就解决你的需求了,你顺手删掉了 JDK,走上了真香之路。虽然你还会怀念 SpringBoot 的方便,你发现 Go 也足够满足你 80% 的需求了,剩下俩的一点点就捏着鼻子就好了。你老婆也不怪你没时间陪孩子了,你的工资也涨了点,偶尔翻开自己充满设计模式的 Old Style 代码,再也没有什么兴趣了。

原文链接:http://blog.yannxia.top/2019/05/29/fxxk-java-in-cloud-native/

Jib 1.0.0迎来通用版本——以前所未有的低门槛构建Java Docker镜像

大卫 发表了文章 • 0 个评论 • 1518 次浏览 • 2019-02-12 18:22 • 来自相关话题

去年,我们开始着手帮助开发人员更轻松地实现Java应用程序的容器化转换。我们注意到,开发人员们在使用现有工具时往往面临诸多困难——例如构建速度太慢,Dockerfiles混合不堪,以及容器体积过大等等。 为了改变上述状况,我们开发出了 ...查看全部
去年,我们开始着手帮助开发人员更轻松地实现Java应用程序的容器化转换。我们注意到,开发人员们在使用现有工具时往往面临诸多困难——例如构建速度太慢,Dockerfiles混合不堪,以及容器体积过大等等。

为了改变上述状况,我们开发出了Jib。Jib是一款开源工具,能够非常轻松地与您的Java应用程序实现集成——您无需安装Docker、无需运行Docker守护程序,甚至不需要编写Dockerfile。只需要在Maven或者Gradle build当中使用这款插件并运行构建过程,一切即可迎刃而解。Jib能够利用既有构建信息快速且高效地自动与您的应用程序完成适配。在Jib的帮助下,构建Java容器如今就像打包JAR文件一样简单。

我们于去年公布了Jib的beta测试版本,从那时开始,我们陆续收到了来自社区的诸多反馈与贡献,这也帮助我们更好地实现了其容器化体验。今天,我们高兴地宣布Jib 1.0.0通用版本的正式来临,其已经做好充分的准备,能够满足生产环境对于稳定性的严格要求。

我们将在这篇文章当中对版本中的主要变更做出说明,具体包括对WAR项目的支持、与Skaffold的集成以及面向Java的全新容器构建库Jib Core。
#Jib 1.0版本中包含哪些重点?
##Docker化WAR项目
Java编写的Web应用程序通常会被打包成WAR文件。如今,Jib已经能够对WAR项目进行容器化,且完全无需额外配置。您只需要直接运行以下命令:

Maven:
$ mvn package jib:build

Gradle:
$ gradle jib

该容器中的默认应用服务器为Jetty,但您也可以对基础镜像以及appRoot进行配置调整,从而使用Tomcat等其它服务器选项:

Maven(pom.xml):


tomcat:8.5-jre8-alpine


gcr.io/my-project/my-war-image


/usr/local/tomcat/webapps/my-webapp


Gradle(build.gradle):
jib {
from.image = 'tomcat:8.5-jre8-alpine'
to.image = 'gcr.io/my-project/my-war-image'
container.appRoot = '/usr/local/tomcat/webapps/my-webapp'
}

感兴趣的朋友请参阅Docker化Maven WAR项目Docker化Gradle WAR项目的相关说明。
#在Kubernetes开发当中与Skaffold for Java相集成
Skaffold是一款用于在Kubernetes上实现持续开发的命令行工具。我们将Skaffold与Jib加以集成,旨在实现Kubernetes之上的无缝化开发体验。Jib现在已经可以作为Skaffold当中的builder选项。

要在您的Java项目当中开始使用Skaffold,您首先需要安装Skaffold并向项目当中添加skaffold.yaml文件:
skaffold.yaml:

apiVersion: skaffold/v1beta4
kind: Config
build:
artifacts:
- image: gcr.io/my-project/my-java-image
# Use this for a Maven project:
jibMaven: {}
# Use this for a Gradle project:
jibGradle: {}

请确保您已经把Kubernetes清单存放在k8s/目录当中,且Container规范中的镜像引用匹配至gcr.io/my-project/my-java-image位置。请查阅Skaffold库作为参考

接下来,您可以使用以下命令启动Skaffold的持续开发功能:
$ skaffold dev --trigger notify

Skaffold能够帮助您消除在进行每一项变更之后,对应用程序进行重新构建与重新部署所带来的一系列繁琐步骤。Skaffold会利用Jib对您的应用程序进行容器化转换,而后在检测到变更时将其部署至您的Kubernetes集群当中。现在,您将能够把精力集中到真正重要的工作——编写代码身上。
##Jib Core:在Java中构建Docker镜像
Jib运行在我们自己用于构建容器镜像的通用库之上,我们将这套库以Jib Core的形式进行发布,同时进行了一系列API改进。现在,您可以将Jib作为Maven以及Gradle插件,从而在无需Docker守护程序的前提下面向任意应用程序利用Java进行容器构建。

要使用Jib Core,您需要在项目当中添加以下文件:

Maven(pom.xml):

com.google.cloud.tools
jib-core
0.1.1

Gradle(build.gradle):
dependencies {
implementation 'com.google.cloud.tools:jib-core:0.1.1'
}

以下是构建一套简单Docker镜像的操作示例。其将以基础镜像为起点,添加单一层、设置入口点,而后使用几行代码将镜像推送至远端注册表当中:
Jib.from("busybox")
.addLayer(Arrays.asList(Paths.get("helloworld.sh")), AbsoluteUnixPath.get("/"))
.setEntrypoint("sh", "/helloworld.sh")
.containerize(
Containerizer.to(RegistryImage.named("gcr.io/my-project/hello-from-jib")
.addCredential("myusername", "mypassword")));

我们也鼓励大家利用Jib Core构建属于自己的自定义容器化解决方案。欢迎您在我们的Gitter频道上共享利用Jib Core构建的一切项目。另外,您也可以参考我们发布的Jib Core其它使用示例,例如Gradle构建脚本
#丰富的功能,加上仍然简单易行的窗口化操作体验
利用Jib对Java应用程序进行容器化转换仍然与以往一样简单易行。如果您使用的是Maven,只需要将这款插件添加至pom.xml当中:

com.google.cloud.tools
jib-maven-plugin
1.0.0


gcr.io/my-project/my-java-image



要构建一套镜像并将其推送至容器注册表,您可使用以下命令:
$ mvn compile jib:build

或者使用以下命令面向Docker守护程序进行构建:
$ mvn compile jib:dockerBuild

您现在甚至可以在无需修改pom.xml文件的前提下实现应用程序容器化,具体操作如下:
$ mvn compile com.google.cloud.tools:jib-maven-plugin:1.0.0:build -Dimage=gcr.io/my-project/my-java-image

若需了解更多细节信息,请参阅Jib Maven快速入门。
当配合Gradle使用Jib时,您需要将该插件添加至build.gradle当中:
plugins {
id 'com.google.cloud.tools.jib' version '1.0.0'
}

jib.to.image = 'gcr.io/my-project/my-java-image'

在此之后,您可以利用以下命令将应用程序容器化至目标容器注册表:
$ gradle jib

或者使用以下命令将其容器化至Docker守护程序:
$ gradle jibDockerBuild

若需了解更多细节信息,请参阅Jib Gradle快速入门
##立即开始使用
我们希望Jib能够帮助每一位朋友简化并加快自己的Java开发进程。要开始使用Jib,请参阅我们的示例;此外,您也可使用Codelabs将Spring Boot应用程序或者Micronaut应用程序部署至Kubernetes当中。Jib能够与大多数Docker注册表提供程序以及托管注册表相兼容;请尽情尝试,并通过github.com/GoogleContainerTools/jib与我们分享您的心得体会。感谢!

原文链接:Jib 1.0.0 is GA—building Java Docker images has never been easier

容器中的JVM资源该如何被安全的限制?

尼古拉斯 发表了文章 • 0 个评论 • 1411 次浏览 • 2019-02-09 11:44 • 来自相关话题

#前言 Java与Docker的结合,虽然更好的解决了application的封装问题。但也存在着不兼容,比如Java并不能自动的发现Docker设置的内存限制,CPU限制。 这将导致JVM不能稳定服务业务!容器会杀死你J ...查看全部
#前言
Java与Docker的结合,虽然更好的解决了application的封装问题。但也存在着不兼容,比如Java并不能自动的发现Docker设置的内存限制,CPU限制。

这将导致JVM不能稳定服务业务!容器会杀死你JVM进程,而健康检查又将拉起你的JVM进程,进而导致你监控你的Pod一天重启次数甚至能达到几百次。

我们希望当Java进程运行在容器中时,Java能够自动识别到容器限制,获取到正确的内存和CPU信息,而不用每次都需要在kubernetes的yaml描述文件中显示的配置完容器,还需要配置JVM参数。

使用JVM MaxRAM参数或者解锁实验特性的JVM参数,升级JDK到10+,我们可以解决这个问题(也许吧~.~)。

首先Docker容器本质是是宿主机上的一个进程,它与宿主机共享一个/proc目录,也就是说我们在容器内看到的/proc/meminfo,/proc/cpuinfo与直接在宿主机上看到的一致,如下。

Host:
cat /proc/meminfo 
MemTotal: 197869260KB
MemFree: 3698100KB
MemAvailable: 62230260KB

容器:
docker run -it --rm alpine cat /proc/meminfo
MemTotal: 197869260KB
MemFree: 3677800KB
MemAvailable: 62210088KB

那么Java是如何获取到Host的内存信息的呢?没错就是通过/proc/meminfo来获取到的。

默认情况下,JVM的Max Heap Size是系统内存的1/4,假如我们系统是8G,那么JVM将的默认Heap≈2G。

Docker通过CGroups完成的是对内存的限制,而/proc目录是已只读形式挂载到容器中的,由于默认情况下Java压根就看不见CGroups的限制的内存大小,而默认使用/proc/meminfo中的信息作为内存信息进行启动,
这种不兼容情况会导致,如果容器分配的内存小于JVM的内存,JVM进程会被理解杀死。
#内存限制不兼容
我们首先来看一组测试,这里我们采用一台内存为188G的物理机。
#free -g
total used free shared buff/cache available
Mem: 188 122 1 0 64 64

以下的测试中,我们将包含OpenJDK的hotspot虚拟机,IBM的OpenJ9虚拟机。

以下测试中,我们把正确识别到限制的JDK,称之为安全(即不会超出容器限制不会被kill),反之称之为危险。
##测试用例1(OpenJDK)
这一组测试我们使用最新的OpenJDK8-12,给容器限制内存为4G,看JDK默认参数下的最大堆为多少?看看我们默认参数下多少版本的JDK是安全的

命令如下,如果你也想试试看,可以用一下命令。
docker run -m 4GB --rm  openjdk:8-jre-slim java  -XshowSettings:vm  -version
docker run -m 4GB --rm openjdk:9-jre-slim java -XshowSettings:vm -version
docker run -m 4GB --rm openjdk:10-jre-slim java -XshowSettings:vm -version
docker run -m 4GB --rm openjdk:11-jre-slim java -XshowSettings:vm -version
docker run -m 4GB --rm openjdk:12 java -XshowSettings:vm -version

OpenJDK8(并没有识别容器限制,26.67G) 危险。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:8-jre-slim java  -XshowSettings:vm  -version

VM settings:
Max. Heap Size (Estimated): 26.67G
Ergonomics Machine Class: server
Using VM: OpenJDK 64-Bit Server VM

openjdk version "1.8.0_181"
OpenJDK Runtime Environment (build 1.8.0_181-8u181-b13-2~deb9u1-b13)
OpenJDK 64-Bit Server VM (build 25.181-b13, mixed mode)

OpenJDK8 -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap (正确的识别容器限制,910.50M)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:8-jre-slim java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 910.50M
Ergonomics Machine Class: server
Using VM: OpenJDK 64-Bit Server VM

openjdk version "1.8.0_181"
OpenJDK Runtime Environment (build 1.8.0_181-8u181-b13-2~deb9u1-b13)
OpenJDK 64-Bit Server VM (build 25.181-b13, mixed mode)

OpenJDK 9(并没有识别容器限制,26.67G)危险。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:9-jre-slim java  -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 29.97G
Using VM: OpenJDK 64-Bit Server VM

openjdk version "9.0.4"
OpenJDK Runtime Environment (build 9.0.4+12-Debian-4)
OpenJDK 64-Bit Server VM (build 9.0.4+12-Debian-4, mixed mode)

OpenJDK 9 -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap(正确的识别容器限制,1G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:9-jre-slim java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 1.00G
Using VM: OpenJDK 64-Bit Server VM

openjdk version "9.0.4"
OpenJDK Runtime Environment (build 9.0.4+12-Debian-4)
OpenJDK 64-Bit Server VM (build 9.0.4+12-Debian-4, mixed mode)

OpenJDK 10(正确的识别容器限制,1G)安全。
[root@xiaoke-test ~]# docker run -m 32GB --rm  openjdk:10-jre-slim java -XshowSettings:vm -XX:MaxRAMFraction=1  -version
VM settings:
Max. Heap Size (Estimated): 1.00G
Using VM: OpenJDK 64-Bit Server VM

openjdk version "10.0.2" 2018-07-17
OpenJDK Runtime Environment (build 10.0.2+13-Debian-2)
OpenJDK 64-Bit Server VM (build 10.0.2+13-Debian-2, mixed mode)

OpenJDK 11(正确的识别容器限制,1G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:11-jre-slim java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 1.00G
Using VM: OpenJDK 64-Bit Server VM

openjdk version "11.0.1" 2018-10-16
OpenJDK Runtime Environment (build 11.0.1+13-Debian-3)
OpenJDK 64-Bit Server VM (build 11.0.1+13-Debian-3, mixed mode, sharing)

OpenJDK 12(正确的识别容器限制,1G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  openjdk:12 java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 1.00G
Using VM: OpenJDK 64-Bit Server VM

openjdk version "12-ea" 2019-03-19
OpenJDK Runtime Environment (build 12-ea+23)
OpenJDK 64-Bit Server VM (build 12-ea+23, mixed mode, sharing)

##测试用例2(IBM OpenJ9)
docker run -m 4GB --rm  adoptopenjdk/openjdk8-openj9:alpine-slim  java -XshowSettings:vm  -version
docker run -m 4GB --rm adoptopenjdk/openjdk9-openj9:alpine-slim java -XshowSettings:vm -version
docker run -m 4GB --rm adoptopenjdk/openjdk10-openj9:alpine-slim java -XshowSettings:vm -version
docker run -m 4GB --rm adoptopenjdk/openjdk11-openj9:alpine-slim java -XshowSettings:vm -version

OpenJDK8-OpenJ9(正确的识别容器限制,3G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  adoptopenjdk/openjdk8-openj9:alpine-slim  java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 3.00G
Ergonomics Machine Class: server
Using VM: Eclipse OpenJ9 VM

openjdk version "1.8.0_192"
OpenJDK Runtime Environment (build 1.8.0_192-b12_openj9)
Eclipse OpenJ9 VM (build openj9-0.11.0, JRE 1.8.0 Linux amd64-64-Bit Compressed References 20181107_95 (JIT enabled, AOT enabled)
OpenJ9 - 090ff9dcd
OMR - ea548a66
JCL - b5a3affe73 based on jdk8u192-b12)

OpenJDK9-OpenJ9 (正确的识别容器限制,3G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  adoptopenjdk/openjdk9-openj9:alpine-slim  java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 3.00G
Using VM: Eclipse OpenJ9 VM

openjdk version "9.0.4-adoptopenjdk"
OpenJDK Runtime Environment (build 9.0.4-adoptopenjdk+12)
Eclipse OpenJ9 VM (build openj9-0.9.0, JRE 9 Linux amd64-64-Bit Compressed References 20180814_248 (JIT enabled, AOT enabled)
OpenJ9 - 24e53631
OMR - fad6bf6e
JCL - feec4d2ae based on jdk-9.0.4+12)

OpenJDK10-OpenJ9 (正确的识别容器限制,3G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  adoptopenjdk/openjdk10-openj9:alpine-slim  java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 3.00G
Using VM: Eclipse OpenJ9 VM

openjdk version "10.0.2-adoptopenjdk" 2018-07-17
OpenJDK Runtime Environment (build 10.0.2-adoptopenjdk+13)
Eclipse OpenJ9 VM (build openj9-0.9.0, JRE 10 Linux amd64-64-Bit Compressed References 20180813_102 (JIT enabled, AOT enabled)
OpenJ9 - 24e53631
OMR - fad6bf6e
JCL - 7db90eda56 based on jdk-10.0.2+13)

OpenJDK11-OpenJ9(正确的识别容器限制,3G)安全。
[root@xiaoke-test ~]# docker run -m 4GB --rm  adoptopenjdk/openjdk11-openj9:alpine-slim  java -XshowSettings:vm  -version
VM settings:
Max. Heap Size (Estimated): 3.00G
Using VM: Eclipse OpenJ9 VM

openjdk version "11.0.1" 2018-10-16
OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.1+13)
Eclipse OpenJ9 VM AdoptOpenJDK (build openj9-0.11.0, JRE 11 Linux amd64-64-Bit Compressed References 20181020_70 (JIT enabled, AOT enabled)
OpenJ9 - 090ff9dc
OMR - ea548a66
JCL - f62696f378 based on jdk-11.0.1+13)

##分析
分析之前我们先了解这么一个情况:
JavaMemory (MaxRAM) = 元数据+线程+代码缓存+OffHeap+Heap...

一般我们都只配置Heap即使用-Xmx来指定JVM可使用的最大堆。而JVM默认会使用它获取到的最大内存的1/4作为堆的原因也是如此。

安全性(即不会超过容器限制被容器kill)

OpenJDK:

OpenJdk8-12,都能保证这个安全性的特点(8和9需要特殊参数,-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap)。

OpenJ9:

2.IbmOpenJ9所有的版本都能识别到容器限制。

资源利用率

OpenJDK:

自动识别到容器限制后,OpenJDK把最大堆设置为了大概容器内存的1/4,对内存的浪费不可谓不大。

当然可以配合另一个JVM参数来配置最大堆。-XX:MaxRAMFraction=int。下面是我整理的一个常见内存设置的表格,从中我们可以看到似乎JVM默认的最大堆的取值为MaxRAMFraction=4,随着内存的增加,堆的闲置空间越来越大,在16G容器内存时,Java堆只有不到4G。

MaxRAMFraction取值	堆占比	容器内存=1G	容器内存=2G	容器内存=4G   容器内存=8G	容器内存=16G
1 ≈90% 910.50M 1.78G 3.56G 7.11G 14.22G
2 ≈50% 455.50M 910.50M 1.78G 3.56G 7.11G
3 ≈33% 304.00M 608.00M 1.19G 2.37G 4.74G
4 ≈25% 228.00M 455.50M 910.50M 1.78G 3.56G

OpenJ9:

关于OpenJ9的的详细介绍你可以从这里了解更多。

对于内存利用率OpenJ9的策略是优于OpenJDK的。以下是OpenJ9的策略表格.

容器内存	最大Java堆大小
小于1GB 50%
1GB-2GB -512MB
大于2GB 大于2GB

##结论
注意:这里我们说的是容器内存限制,和物理机内存不同。

自动档

如果你想要的是,不显示的指定-Xmx,让Java进程自动的发现容器限制。

如果你想要的是JVM进程在容器中安全稳定的运行,不被容器kiil,并且你的JDK版本小于10(大于等于JDK10的版本不需要设置,参考前面的测试)。

你需要额外设置JVM参数-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap,即可保证你的Java进程不会因为内存问题被容器Kill。

当然这个方式使用起来简单,可靠,缺点也很明显,资源利用率过低(参考前面的表格MaxRAMFraction=4)。

如果想在基础上我还想提高一些内存资源利用率,并且容器内存为1GB - 4GB,我建议你设置-XX:MaxRAMFraction=2,在大于8G的可以尝试设置-XX:MaxRAMFraction=1(参考上表格)。

手动挡

如果你想要的是手动挡的体验,更加进一步的利用内存资源,那么你可能需要回到手动配置时代-Xmx。

手动挡部分,请可以完全忽略上面我的BB。

上面我们说到了自动挡的配置,用起来很简单很舒服,自动发现容器限制,无需担心和思考去配置-Xmx。

比如你有内存1G那么我建议你的-Xmx750M,2G建议配置-Xmx1700M,4G建议配置-Xmx3500-3700M,8G建议设置-Xmx7500-7600M,总之就是至少保留300M以上的内存留给JVM的其他内存。如果堆特别大,可以预留到1G甚至2G。

手动挡用起来就没有那么舒服了,当然资源利用率相对而言就更高了。

原文链接:https://qingmu.io/2018/12/17/How-to-securely-limit-JVM-resources-in-a-container/

Java线程池ThreadPoolExecutor实现原理剖析

Andy_Lee 发表了文章 • 0 个评论 • 1671 次浏览 • 2018-10-13 17:05 • 来自相关话题

【编者的话】在Java中,使用线程池来异步执行一些耗时任务是非常常见的操作。最初我们一般都是直接使用new Thread().start的方式,但我们知道,线程的创建和销毁都会耗费大量的资源,关于线程可以参考之前的一篇博客《Java线程那点事儿》,因此我们需要 ...查看全部
【编者的话】在Java中,使用线程池来异步执行一些耗时任务是非常常见的操作。最初我们一般都是直接使用new Thread().start的方式,但我们知道,线程的创建和销毁都会耗费大量的资源,关于线程可以参考之前的一篇博客《Java线程那点事儿》,因此我们需要重用线程资源。

当然也有其他待解决方案,比如说coroutine,目前Kotlin已经支持了,JDK也已经有了相关的提案:Project Loom,目前的实现方式和Kotlin有点类似,都是基于ForkJoinPool,当然目前还有很多限制以及问题没解决,比如synchronized还是锁住当前线程等。
##继承结构
1.png

继承结构看起来很清晰,最顶层的Executor只提供了一个最简单的void execute(Runnable command)方法,然后是ExecutorService,ExecutorService提供了一些管理相关的方法,例如关闭、判断当前线程池的状态等,另外不同于Executor#execute,ExecutorService提供了一系列方法,可以将任务包装成一个Future,从而使得任务提交方可以跟踪任务的状态。而父类AbstractExecutorService则提供了一些默认的实现。
#构造器
ThreadPoolExecutor的构造器提供了非常多的参数,每一个参数都非常的重要,一不小心就容易踩坑,因此设置的时候,你必须要知道自己在干什么。
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.acc = System.getSecurityManager() == null ?
null :
AccessController.getContext();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}


  1. corePoolSize、 maximumPoolSize。线程池会自动根据corePoolSize和maximumPoolSize去调整当前线程池的大小。当你通过submit或者execute方法提交任务的时候,如果当前线程池的线程数小于corePoolSize,那么线程池就会创建一个新的线程处理任务, 即使其他的core线程是空闲的。如果当前线程数大于corePoolSize并且小于maximumPoolSize,那么只有在队列"满"的时候才会创建新的线程。因此这里会有很多的坑,比如你的core和max线程数设置的不一样,希望请求积压在队列的时候能够实时的扩容,但如果制定了一个无界队列,那么就不会扩容了,因为队列不存在满的概念。

  1. keepAliveTime。如果当前线程池中的线程数超过了corePoolSize,那么如果在keepAliveTime时间内都没有新的任务需要处理,那么超过corePoolSize的这部分线程就会被销毁。默认情况下是不会回收core线程的,可以通过设置allowCoreThreadTimeOut改变这一行为。

  1. workQueue。即实际用于存储任务的队列,这个可以说是最核心的一个参数了,直接决定了线程池的行为,比如说传入一个有界队列,那么队列满的时候,线程池就会根据core和max参数的设置情况决定是否需要扩容,如果传入了一个SynchronousQueue,这个队列只有在另一个线程在同步remove的时候才可以put成功,对应到线程池中,简单来说就是如果有线程池任务处理完了,调用poll或者take方法获取新的任务的时候,新提交的任务才会put成功,否则如果当前的线程都在忙着处理任务,那么就会put失败,也就会走扩容的逻辑,如果传入了一个DelayedWorkQueue,顾名思义,任务就会根据过期时间来决定什么时候弹出,即为ScheduledThreadPoolExecutor的机制。

  1. threadFactory。创建线程都是通过ThreadFactory来实现的,如果没指定的话,默认会使用Executors.defaultThreadFactory(),一般来说,我们会在这里对线程设置名称、异常处理器等。

  1. handler。即当任务提交失败的时候,会调用这个处理器,ThreadPoolExecutor内置了多个实现,比如抛异常、直接抛弃等。这里也需要根据业务场景进行设置,比如说当队列积压的时候,针对性的对线程池扩容或者发送告警等策略。

看完这几个参数的含义,我们看一下Executors提供的一些工具方法,只要是为了方便使用,但是我建议最好少用这个类,而是直接用ThreadPoolExecutor的构造函数,多了解一下这几个参数到底是什么意思,自己的业务场景是什么样的,比如线程池需不需要扩容、用不用回收空闲的线程等。
public class Executors {

/*
* 提供一个固定大小的线程池,并且线程不会回收,由于传入的是一个无界队列,相当于队列永远不会满
* 也就不会扩容,因此需要特别注意任务积压在队列中导致内存爆掉的问题
*/
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue());
}


/*
* 这个线程池会一直扩容,由于SynchronousQueue的特性,如果当前所有的线程都在处理任务,那么
* 新的请求过来,就会导致创建一个新的线程处理任务。如果线程一分钟没有新任务处理,就会被回
* 收掉。特别注意,如果每一个任务都比较耗时,并发又比较高,那么可能每次任务过来都会创建一个线
* 程
*/
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue());
}
}

##源码分析
既然是个线程池,那就必然有其生命周期:运行中、关闭、停止等。ThreadPoolExecutor是用一个AtomicInteger去的前三位表示这个状态的,另外又重用了低29位用于表示线程数,可以支持最大大概5亿多,绝逼够用了,如果以后硬件真的发展到能够启动这么多线程,改成AtomicLong就可以了。

状态这里主要分为下面几种:

  1. RUNNING:表示当前线程池正在运行中,可以接受新任务以及处理队列中的任务
  2. SHUTDOWN:不再接受新的任务,但会继续处理队列中的任务
  3. STOP:不再接受新的任务,也不处理队列中的任务了,并且会中断正在进行中的任务
  4. TIDYING:所有任务都已经处理完毕,线程数为0,转为为TIDYING状态之后,会调用terminated()回调
  5. TERMINATED:terminated()已经执行完毕

同时我们可以看到所有的状态都是用二进制位表示的,并且依次递增,从而方便进行比较,比如想获取当前状态是否至少为SHUTDOWN等,同时状态之前有几种转换:

  1. RUNNING -> SHUTDOWN。调用了shutdown()之后,或者执行了finalize()
  2. (RUNNING 或者 SHUTDOWN) -> STOP。调用了shutdownNow()之后会转换这个状态
  3. SHUTDOWN -> TIDYING。当线程池和队列都为空的时候
  4. STOP -> TIDYING。当线程池为空的时候
  5. IDYING -> TERMINATED。执行完terminated()回调之后会转换为这个状态

    private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY = (1 << COUNT_BITS) - 1;

private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;

//由于前三位表示状态,因此将CAPACITY取反,和进行与操作即可
private static int runStateOf(int c) { return c & ~CAPACITY; }
private static int workerCountOf(int c) { return c & CAPACITY; }

//高三位+第三位进行或操作即可
private static int ctlOf(int rs, int wc) { return rs | wc; }

private static boolean runStateLessThan(int c, int s) {
return c < s;
}

private static boolean runStateAtLeast(int c, int s) {
return c >= s;
}

private static boolean isRunning(int c) {
return c < SHUTDOWN;
}

//下面三个方法,通过CAS修改worker的数目
private boolean compareAndIncrementWorkerCount(int expect) {
return ctl.compareAndSet(expect, expect + 1);
}

//只尝试一次,失败了则返回,是否重试由调用方决定
private boolean compareAndDecrementWorkerCount(int expect) {
return ctl.compareAndSet(expect, expect - 1);
}

//跟上一个不一样,会一直重试
private void decrementWorkerCount() {
do {} while (! compareAndDecrementWorkerCount(ctl.get()));
}

下面是比较核心的字段,这里workers采用的是非线程安全的HashSet,而不是线程安全的版本,主要是因为这里有些复合的操作,比如说将worker添加到workers后,我们还需要判断是否需要更新largestPoolSize等,workers只在获取到mainLock的情况下才会进行读写,另外这里的mainLock也用于在中断线程的时候串行执行,否则如果不加锁的话,可能会造成并发去中断线程,引起不必要的中断风暴。
private final ReentrantLock mainLock = new ReentrantLock();

private final HashSet workers = new HashSet();

private final Condition termination = mainLock.newCondition();

private int largestPoolSize;

private long completedTaskCount;

##核心方法
拿到一个线程池之后,我们就可以开始提交任务,让它去执行了,那么我们看一下submit方法是如何实现的。
    public Future submit(Runnable task) {
if (task == null) throw new NullPointerException();
RunnableFuture ftask = newTaskFor(task, null);
execute(ftask);
return ftask;
}

public Future submit(Callable task) {
if (task == null) throw new NullPointerException();
RunnableFuture ftask = newTaskFor(task);
execute(ftask);
return ftask;
}

这两个方法都很简单,首先将提交过来的任务(有两种形式:Callable、Runnable)都包装成统一的RunnableFuture,然后调用execute方法,execute可以说是线程池最核心的一个方法。
    public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
/*
获取当前worker的数目,如果小于corePoolSize那么就扩容,
这里不会判断是否已经有core线程,而是只要小于corePoolSize就会直接增加worker
*/
if (workerCountOf(c) < corePoolSize) {
/*
调用addWorker(Runnable firstTask, boolean core)方法扩容
firstTask表示为该worker启动之后要执行的第一个任务,core表示要增加的为core线程
*/
if (addWorker(command, true))
return;
//如果增加失败了那么重新获取ctl的快照,比如可能线程池在这期间关闭了
c = ctl.get();
}
/*
如果当前线程池正在运行中,并且将任务丢到队列中成功了,
那么就会进行一次double check,看下在这期间线程池是否关闭了,
如果关闭了,比如处于SHUTDOWN状态,如上文所讲的,SHUTDOWN状态的时候,
不再接受新任务,remove成功后调用拒绝处理器。而如果仍然处于运行中的状态,
那么这里就double check下当前的worker数,如果为0,有可能在上述逻辑的执行
过程中,有worker销毁了,比如说任务抛出了未捕获异常等,那么就会进行一次扩容,
但不同于扩容core线程,这里由于任务已经丢到队列中去了,因此就不需要再传递firstTask了,
同时要注意,这里扩容的是非core线程
*/
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
/*
如果在上一步中,将任务丢到队列中失败了,那么就进行一次扩容,
这里会将任务传递到firstTask参数中,并且扩容的是非core线程,
如果扩容失败了,那么就执行拒绝策略。
*/
reject(command);
}

这里要特别注意下防止队列失败的逻辑,不同的队列丢任务的逻辑也不一样,例如说无界队列,那么就永远不会put失败,也就是说扩容也永远不会执行,如果是有界队列,那么当队列满的时候,会扩容非core线程,如果是SynchronousQueue,这个队列比较特殊,当有另外一个线程正在同步获取任务的时候,你才能put成功,因此如果当前线程池中所有的worker都忙着处理任务的时候,那么后续的每次新任务都会导致扩容,当然如果worker没有任务处理了,阻塞在获取任务这一步的时候,新任务的提交就会直接丢到队列中去,而不会扩容。

上文中多次提到了扩容,那么我们下面看一下线程池具体是如何进行扩容的:
    private boolean addWorker(Runnable firstTask, boolean core) {
retry:
for (;;) {
int c = ctl.get();
//获取当前线程池的状态
int rs = runStateOf(c);

/*
如果状态为大于SHUTDOWN, 比如说STOP,STOP上文说过队列中的任务不处理了,也不接受新任务,
因此可以直接返回false不扩容了,如果状态为SHUTDOWN并且firstTask为null,同时队列非空,
那么就可以扩容
*/
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;

for (;;) {
int wc = workerCountOf(c);
/*
若worker的数目大于CAPACITY则直接返回,
然后根据要扩容的是core线程还是非core线程,进行判断worker数目
是否超过设置的值,超过则返回
*/
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
/*
通过CAS的方式自增worker的数目,成功了则直接跳出循环
*/
if (compareAndIncrementWorkerCount(c))
break retry;
//重新读取状态变量,如果状态改变了,比如线程池关闭了,那么就跳到最外层的for循环,
//注意这里跳出的是retry。
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}

boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
//创建Worker
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
/*
获取锁,并判断线程池是否已经关闭
*/
int rs = runStateOf(ctl.get());

if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // 若线程已经启动了,比如说已经调用了start()方法,那么就抛异常,
throw new IllegalThreadStateException();
//添加到workers中
workers.add(w);
int s = workers.size();
if (s > largestPoolSize) //更新largestPoolSize
largestPoolSize = s;
workerAdded = true;
}
} finally {
mainLock.unlock();
}
if (workerAdded) {
//若Worker创建成功,则启动线程,这么时候worker就会开始执行任务了
t.start();
workerStarted = true;
}
}
} finally {
if (! workerStarted)
//添加失败
addWorkerFailed(w);
}
return workerStarted;
}

private void addWorkerFailed(Worker w) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
if (w != null)
workers.remove(w);
decrementWorkerCount();
//每次减少worker或者从队列中移除任务的时候都需要调用这个方法
tryTerminate();
} finally {
mainLock.unlock();
}
}

这里有个貌似不太起眼的方法tryTerminate,这个方法会在所有可能导致线程池终结的地方调用,比如说减少worker的数目等,如果满足条件的话,那么将线程池转换为TERMINATED状态。另外这个方法没有用private修饰,因为ScheduledThreadPoolExecutor继承自ThreadPoolExecutor,而ScheduledThreadPoolExecutor也会调用这个方法。
    final void tryTerminate() {
for (;;) {
int c = ctl.get();
/*
如果当前线程处于运行中、TIDYING、TERMINATED状态则直接返回,运行中的没
什么好说的,后面两种状态可以说线程池已经正在终结了,另外如果处于SHUTDOWN状态,
并且workQueue非空,表明还有任务需要处理,也直接返回
*/
if (isRunning(c) ||
runStateAtLeast(c, TIDYING) ||
(runStateOf(c) == SHUTDOWN && ! workQueue.isEmpty()))
return;
//可以退出,但是线程数非0,那么就中断一个线程,从而使得关闭的信号能够传递下去,
//中断worker后,worker捕获异常后,会尝试退出,并在这里继续执行tryTerminate()方法,
//从而使得信号传递下去
if (workerCountOf(c) != 0) {
interruptIdleWorkers(ONLY_ONE);
return;
}

final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//尝试转换成TIDYING状态,执行完terminated回调之后
//会转换为TERMINATED状态,这个时候线程池已经完整关闭了,
//通过signalAll方法,唤醒所有阻塞在awaitTermination上的线程
if (ctl.compareAndSet(c, ctlOf(TIDYING, 0))) {
try {
terminated();
} finally {
ctl.set(ctlOf(TERMINATED, 0));
termination.signalAll();
}
return;
}
} finally {
mainLock.unlock();
}
// else retry on failed CAS
}
}

/**
* 中断空闲的线程
* @param onlyOne
*/
private void interruptIdleWorkers(boolean onlyOne) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
for (Worker w : workers) {
//遍历所有worker,若之前没有被中断过,
//并且获取锁成功,那么就尝试中断。
//锁能够获取成功,那么表明当前worker没有在执行任务,而是在
//获取任务,因此也就达到了只中断空闲线程的目的。
Thread t = w.thread;
if (!t.isInterrupted() && w.tryLock()) {
try {
t.interrupt();
} catch (SecurityException ignore) {
} finally {
w.unlock();
}
}
if (onlyOne)
break;
}
} finally {
mainLock.unlock();
}
}

2.png

##Worker
下面看一下Worker类,也就是这个类实际负责执行任务,Worker类继承自AbstractQueuedSynchronizer,AQS可以理解为一个同步框架,提供了一些通用的机制,利用模板方法模式,让你能够原子的管理同步状态、blocking和unblocking线程、以及队列,具体的内容之后有时间会再写,还是比较复杂的。这里Worker对AQS的使用相对比较简单,使用了状态变量state表示是否获得锁,0表示解锁、1表示已获得锁,同时通过exclusiveOwnerThread存储当前持有锁的线程。另外再简单提一下,比如说CountDownLatch, 也是基于AQS框架实现的,countdown方法递减state,await阻塞等待state为0。
private final class Worker
extends AbstractQueuedSynchronizer
implements Runnable
{

/*[i] Thread this worker is running in. Null if factory fails. [/i]/
final Thread thread;

/*[i] Initial task to run. Possibly null. [/i]/
Runnable firstTask;

/*[i] Per-thread task counter [/i]/
volatile long completedTasks;

Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
this.thread = getThreadFactory().newThread(this);
}

/*[i] Delegates main run loop to outer runWorker [/i]/
public void run() {
runWorker(this);
}
protected boolean isHeldExclusively() {
return getState() != 0;
}

protected boolean tryAcquire(int unused) {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}

protected boolean tryRelease(int unused) {
setExclusiveOwnerThread(null);
setState(0);
return true;
}

public void lock() { acquire(1); }
public boolean tryLock() { return tryAcquire(1); }
public void unlock() { release(1); }
public boolean isLocked() { return isHeldExclusively(); }

void interruptIfStarted() {
Thread t;
if (getState() >= 0 && (t = thread) != null && !t.isInterrupted()) {
try {
t.interrupt();
} catch (SecurityException ignore) {
}
}
}
}

注意这里Worker初始化的时候,会通过setState(-1)将state设置为-1,并在runWorker()方法中置为0,上文说过Worker是利用state这个变量来表示锁的状态,那么加锁的操作就是通过CAS将state从0改成1,那么初始化的时候改成-1,也就是表示在Worker启动之前,都不允许加锁操作,我们再看interruptIfStarted()以及interruptIdleWorkers()方法,这两个方法在尝试中断Worker之前,都会先加锁或者判断state是否大于0,因此这里的将state设置为-1,就是为了禁止中断操作,并在runWorker中置为0,也就是说只能在Worker启动之后才能够中断Worker。

另外线程启动之后,其实就是调用了runWorker方法,下面我们看一下具体是如何实现的。
   final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // 调用unlock()方法,将state置为0,表示其他操作可以获得锁或者中断worker
boolean completedAbruptly = true;
try {
/*
首先尝试执行firstTask,若没有的话,则调用getTask()从队列中获取任务
*/
while (task != null || (task = getTask()) != null) {
w.lock();
/*
如果线程池正在关闭,那么中断线程。
*/
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();
try {
//执行beforeExecute回调
beforeExecute(wt, task);
Throwable thrown = null;
try {
//实际开始执行任务
task.run();
} catch (RuntimeException x) {
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
//执行afterExecute回调
afterExecute(task, thrown);
}
} finally {
task = null;
//这里加了锁,因此没有线程安全的问题,volatile修饰保证其他线程的可见性
w.completedTasks++;
w.unlock();//解锁
}
}
completedAbruptly = false;
} finally {
//抛异常了,或者当前队列中已没有任务需要处理等
processWorkerExit(w, completedAbruptly);
}
}

private void processWorkerExit(Worker w, boolean completedAbruptly) {
//如果是异常终止的,那么减少worker的数目
if (completedAbruptly) // If abrupt, then workerCount wasn't adjusted
decrementWorkerCount();

final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//将当前worker中workers中删除掉,并累加当前worker已执行的任务到completedTaskCount中
completedTaskCount += w.completedTasks;
workers.remove(w);
} finally {
mainLock.unlock();
}

//上文说过,减少worker的操作都需要调用这个方法
tryTerminate();

/*
如果当前线程池仍然是运行中的状态,那么就看一下是否需要新增另外一个worker替换此worker
*/
int c = ctl.get();
if (runStateLessThan(c, STOP)) {
/*
如果是异常结束的则直接扩容,否则的话则为正常退出,比如当前队列中已经没有任务需要处理,
如果允许core线程超时的话,那么看一下当前队列是否为空,空的话则不用扩容。否则话看一下
是否少于corePoolSize个worker在运行。
*/
if (!completedAbruptly) {
int min = allowCoreThreadTimeOut ? 0 : corePoolSize;
if (min == 0 && ! workQueue.isEmpty())
min = 1;
if (workerCountOf(c) >= min)
return; // replacement not needed
}
addWorker(null, false);
}
}

private Runnable getTask() {
boolean timedOut = false; // 上一次poll()是否超时了

for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

// 若线程池关闭了(状态大于STOP)
// 或者线程池处于SHUTDOWN状态,但是队列为空,那么返回null
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}

int wc = workerCountOf(c);

/*
如果允许core线程超时 或者 不允许core线程超时但当前worker的数目大于core线程数,
那么下面的poll()则超时调用
*/
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

/*
获取任务超时了并且(当前线程池中还有不止一个worker 或者 队列中已经没有任务了),那么就尝试
减少worker的数目,若失败了则重试
*/
if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}

try {
//从队列中抓取任务
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
//走到这里表明,poll调用超时了
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}

##关闭线程池
关闭线程池一般有两种形式,shutdown()和shutdownNow()。
    public void shutdown() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
//通过CAS将状态更改为SHUTDOWN,这个时候线程池不接受新任务,但会继续处理队列中的任务
advanceRunState(SHUTDOWN);
//中断所有空闲的worker,也就是说除了正在处理任务的worker,其他阻塞在getTask()上的worker
//都会被中断
interruptIdleWorkers();
//执行回调
onShutdown(); // hook for ScheduledThreadPoolExecutor
} finally {
mainLock.unlock();
}
tryTerminate();
//这个方法不会等待所有的任务处理完成才返回
}
public List shutdownNow() {
List tasks;
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
/*
不同于shutdown(),会转换为STOP状态,不再处理新任务,队列中的任务也不处理,
而且会中断所有的worker,而不只是空闲的worker
*/
advanceRunState(STOP);
interruptWorkers();
tasks = drainQueue();//将所有的任务从队列中弹出
} finally {
mainLock.unlock();
}
tryTerminate();
return tasks;
}

private List drainQueue() {
BlockingQueue q = workQueue;
ArrayList taskList = new ArrayList();
/*
将队列中所有的任务remove掉,并添加到taskList中,
但是有些队列比较特殊,比如说DelayQueue,如果第一个任务还没到过期时间,则不会弹出,
因此这里通过调用toArray方法,然后再一个一个的remove掉
*/
q.drainTo(taskList);
if (!q.isEmpty()) {
for (Runnable r : q.toArray(new Runnable[0])) {
if (q.remove(r))
taskList.add(r);
}
}
return taskList;
}

从上文中可以看到,调用了shutdown()方法后,不会等待所有的任务处理完毕才返回,因此需要调用awaitTermination()来实现。
    public boolean awaitTermination(long timeout, TimeUnit unit)
throws InterruptedException {
long nanos = unit.toNanos(timeout);
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
for (;;) {
//线程池若已经终结了,那么就返回
if (runStateAtLeast(ctl.get(), TERMINATED))
return true;
//若超时了,也返回掉
if (nanos <= 0)
return false;
//阻塞在信号量上,等待线程池终结,但是要注意这个方法可能会因为一些未知原因随时唤醒当前线程,
//因此需要重试,在tryTerminate()方法中,执行完terminated()回调后,表明线程池已经终结了,
//然后会通过termination.signalAll()唤醒当前线程
nanos = termination.awaitNanos(nanos);
}
} finally {
mainLock.unlock();
}
}
一些统计相关的方法
public int getPoolSize() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//若线程已终结则直接返回0,否则计算works中的数目
//想一下为什么不用workerCount呢?
return runStateAtLeast(ctl.get(), TIDYING) ? 0
: workers.size();
} finally {
mainLock.unlock();
}
}

public int getActiveCount() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
int n = 0;
for (Worker w : workers)
if (w.isLocked())//上锁的表明worker当前正在处理任务,也就是活跃的worker
++n;
return n;
} finally {
mainLock.unlock();
}
}


public int getLargestPoolSize() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
return largestPoolSize;
} finally {
mainLock.unlock();
}
}

//获取任务的总数,这个方法慎用,若是个无解队列,或者队列挤压比较严重,会很蛋疼
public long getTaskCount() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
long n = completedTaskCount;//比如有些worker被销毁后,其处理完成的任务就会叠加到这里
for (Worker w : workers) {
n += w.completedTasks;//叠加历史处理完成的任务
if (w.isLocked())//上锁表明正在处理任务,也算一个
++n;
}
return n + workQueue.size();//获取队列中的数目
} finally {
mainLock.unlock();
}
}


public long getCompletedTaskCount() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
long n = completedTaskCount;
for (Worker w : workers)
n += w.completedTasks;
return n;
} finally {
mainLock.unlock();
}
}

#总结
这篇博客基本上覆盖了线程池的方方面面,但仍然有非常多的细节可以深究,比如说异常的处理,可以参照之前的一篇博客:《深度解析Java线程池的异常处理机制》,另外还有AQS、unsafe等可以之后再单独总结。

原文链接:https://github.com/aCoder2013/blog/issues/28

Apache Dubbo已不再局限于Java语言

大卫 发表了文章 • 0 个评论 • 1260 次浏览 • 2018-07-11 22:26 • 来自相关话题

2017 年 9 月 7 日,在沉寂了4年之后,Dubbo 悄悄的在 GitHub 发布了 2.5.4 版本。随后又迅速发布了 2.5.5、2.5.6、2.5.7 等release。在 2017年 10 月举行的云栖大会上,阿里宣布 Dubbo 被列入集团重点 ...查看全部
2017 年 9 月 7 日,在沉寂了4年之后,Dubbo 悄悄的在 GitHub 发布了 2.5.4 版本。随后又迅速发布了 2.5.5、2.5.6、2.5.7 等release。在 2017年 10 月举行的云栖大会上,阿里宣布 Dubbo 被列入集团重点维护开源项目,这也就意味着 Dubbo 重启,开始重新进入新征程。Dubbo 进入 Apache 孵化器,如果毕业后,项目移出 incubator,成为正式开源项目,在这期间还是有很多工作要做。
1.jpg

2.jpg

3.jpg

近来进入dubbo官网,发现又改版升级了,很清爽简洁,打开速率比之前更快了。
4.jpg

5.jpg

6.jpg

有几个亮点,可从上图生态中发现:
##不局限于Java
Dubbo已不在局限在Java语言范围内,开始支持Node.js,Python。具体使用过程Dubbo的社区生态中找到对应方法。
##支持SpringBoot
Dubbo支持通过API方式启动方式中已经融合SpringBoot,从github的incubator-dubbo-spring-boot-project项目中可以看到,已经迭代3个版本,支持最新的SpringBoot 2.0,2018-6-21日发布的两个发个release新版本中可以看到。
##支持Rest
Dubbo在重启维护后,dubbo-2.6.0版本中奖当当团队维护的DubboX合并近来(2018-01-08)。基于标准的Java REST API——JAX-RS 2.0(Java API for RESTful Web Services的简写)实现的REST调用支持。
7.jpg

##高性能序列化框架
在DubboX的分支合并中,kryo, FST的serialization framework,提升接口数据的交互效率。

Github上接近2W个Star,相信随着周边生态不断的完善,Dubbo会进入到更多的企业中,发挥更大的效用。

原文链接:https://mp.weixin.qq.com/s/1sGu2KOKBtLZN4l3r2OFGw

Java和Docker限制的那些事儿

kelvinji2009 发表了文章 • 0 个评论 • 4813 次浏览 • 2018-06-04 15:06 • 来自相关话题

【编者的话】Java和Docker不是天然的朋友。 Docker可以设置内存和CPU限制,而Java不能自动检测到。使用Java的Xmx标识(繁琐/重复)或新的实验性JVM标识,我们可以解决这个问题。 加强Docker容器与Java1 ...查看全部
【编者的话】Java和Docker不是天然的朋友。 Docker可以设置内存和CPU限制,而Java不能自动检测到。使用Java的Xmx标识(繁琐/重复)或新的实验性JVM标识,我们可以解决这个问题。

加强Docker容器与Java10集成 - Docker官方博客在最新版本的Java的OpenJ9和OpenJDK10中彻底解决了这个问题。
# 虚拟化中的不匹配
Java和Docker的结合并不是完美匹配的,最初的时候离完美匹配有相当大的距离。对于初学者来说,JVM的全部设想就是,虚拟机可以让程序与底层硬件无关。

那么,把我们的Java应用打包到JVM中,然后整个再塞进Docker容器中,能给我们带来什么好处呢?大多数情况下,你只是在复制JVMs和Linux容器,除了浪费更多的内存,没任何好处。感觉这样子挺傻的。

不过,Docker可以把你的程序,设置,特定的JDK,Linux设置和应用服务器,还有其他工具打包在一起,当做一个东西。站在DevOps/Cloud的角度来看,这样一个完整的容器有着更高层次的封装。
## 问题一:内存
时至今日,绝大多数产品级应用仍然在使用Java 8(或者更旧的版本),而这可能会带来问题。Java 8(update 131之前的版本)跟Docker无法很好地一起工作。问题是在你的机器上,JVM的可用内存和CPU数量并不是Docker允许你使用的可用内存和CPU数量。

比如,如果你限制了你的Docker容器只能使用100MB内存,但是呢,旧版本的Java并不能识别这个限制。Java看不到这个限制。JVM会要求更多内存,而且远超这个限制。如果使用太多内存,Docker将采取行动并杀死容器内的进程!JAVA进程被干掉了,很明显,这并不是我们想要的。

为了解决这个问题,你需要给Java指定一个最大内存限制。在旧版本的Java(8u131之前),你需要在容器中通过设置`-Xmx`来限制堆大小。这感觉不太对,你可不想定义这些限制两次,也不太想在你的容器中来定义。

幸运的是我们现在有了更好的方式来解决这个问题。从Java 9之后(8u131+),JVM增加了如下标志:
-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap

这些标志强制JVM检查Linux的`cgroup`配置,Docker是通过`cgroup`来实现最大内存设置的。现在,如果你的应用到达了Docker设置的限制(比如500MB),JVM是可以看到这个限制的。JVM将会尝试GC操作。如果仍然超过内存限制,JVM就会做它该做的事情,抛出`OutOfMemoryException`。也就是说,JVM能够看到Docker的这些设置。

从Java 10之后(参考下面的测试),这些体验标志位是默认开启的,也可以使用`-XX:+UseContainerSupport`来使能(你可以通过设置`-XX:-UseContainerSupport`来禁止这些行为)。
## 问题二:CPU
第二个问题是类似的,但它与CPU有关。简而言之,JVM将查看硬件并检测CPU的数量。它会优化你的runtime以使用这些CPUs。但是同样的情况,这里还有另一个不匹配,Docker可能不允许你使用所有这些CPUs。可惜的是,这在Java 8或Java 9中并没有修复,但是在Java 10中得到了解决。

从Java 10开始,可用的CPUs的计算将采用以不同的方式(默认情况下)解决此问题(同样是通过`UseContainerSupport`)。
# Java和Docker的内存处理测试
作为一个有趣的练习,让我们验证并测试Docker如何使用几个不同的JVM版本/标志甚至不同的JVM来处理内存不足。

首先,我们创建一个测试应用程序,它只是简单地“吃”内存并且不释放它。
java
import java.util.ArrayList;
import java.util.List;

public class MemEat {
public static void main(String[] args) {
List l = new ArrayList<>();
while (true) {
byte b[] = new byte[1048576];
l.add(b);
Runtime rt = Runtime.getRuntime();
System.out.println( "free memory: " + rt.freeMemory() );
}
}
}

我们可以启动Docker容器并运行这个应用程序来查看会发生什么。
## 测试一:Java 8u111
首先,我们将从具有旧版本Java 8的容器开始(update 111)。
shell
docker run -m 100m -it java:openjdk-8u111 /bin/bash

我们编译并运行`MemEat.java`文件:
shell
javac MemEat.java

java MemEat
...
free memory: 67194416
free memory: 66145824
free memory: 65097232
Killed

正如所料,Docker已经杀死了我们的Java进程。不是我们想要的(!)。你也可以看到输出,Java认为它仍然有大量的内存需要分配。

我们可以通过使用-Xmx标志为Java提供最大内存来解决此问题:
shell
javac MemEat.java

java -Xmx100m MemEat
...
free memory: 1155664
free memory: 1679936
free memory: 2204208
free memory: 1315752
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at MemEat.main(MemEat.java:8)

在提供了我们自己的内存限制之后,进程正常停止,JVM理解它正在运行的限制。然而,问题在于你现在将这些内存限制设置了两次,Docker一次,JVM一次。
## 测试二:Java 8u144
如前所述,随着增加新标志来修复问题,JVM现在可以遵循Docker所提供的设置。我们可以使用版本新一点的JVM来测试它。
shell
docker run -m 100m -it adoptopenjdk/openjdk8 /bin/bash

(在撰写本文时,此OpenJDK Java镜像的版本是Java 8u144)

接下来,我们再次编译并运行`MemEat.java`文件,不带任何标志:
shell
javac MemEat.java

java MemEat
...
free memory: 67194416
free memory: 66145824
free memory: 65097232
Killed

依然存在同样的问题。但是我们现在可以提供上面提到的实验性标志来试试看:
shell
javac MemEat.java
java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap MemEat
...
free memory: 1679936
free memory: 2204208
free memory: 1155616
free memory: 1155600
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at MemEat.main(MemEat.java:8)

这一次我们没有告诉JVM限制的是什么,我们只是告诉JVM去检查正确的限制设置!现在感觉好多了。
## 测试三:Java 10u23
有些人在评论和Reddit上提到Java 10通过使实验标志成为新的默认值来解决所有问题。这种行为可以通过禁用此标志来关闭:`-XX:-UseContainerSupport`。

当我测试它时,它最初不起作用。在撰写本文时,AdoptAJDK OpenJDK10镜像与`jdk-10+23`一起打包。这个JVM显然还是不理解`UseContainerSupport`标志,该进程仍然被Docker杀死。
shell
docker run -m 100m -it adoptopenjdk/openjdk10 /bin/bash

测试了代码(甚至手动提供需要的标志):
shell
javac MemEat.java

java MemEat
...
free memory: 96262112
free memory: 94164960
free memory: 92067808
free memory: 89970656
Killed

java -XX:+UseContainerSupport MemEat

Unrecognized VM option 'UseContainerSupport'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.

## 测试四:Java 10u46(Nightly)
我决定尝试AdoptAJDK OpenJDK 10的最新`nightly`构建。它包含的版本是Java 10+46,而不是Java 10+23。
shell
docker run -m 100m -it adoptopenjdk/openjdk10:nightly /bin/bash

然而,在这个`ngithly`构建中有一个问题,导出的PATH指向旧的Java 10+23目录,而不是10+46,我们需要修复这个问题。
shell
export PATH=$PATH:/opt/java/openjdk/jdk-10+46/bin/

javac MemEat.java

java MemEat
...
free memory: 3566824
free memory: 2796008
free memory: 1480320
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at MemEat.main(MemEat.java:8)

成功!不提供任何标志,Java 10依然可以正确检测到Dockers内存限制。
## 测试五:OpenJ9
我最近也在试用OpenJ9,这个免费的替代JVM已经从IBM J9开源,现在由Eclipse维护。

请在我的下一篇博文中阅读关于OpenJ9的更多信息。

它运行速度快,内存管理非常好,性能卓越,经常可以为我们的微服务节省多达30-50%的内存。这几乎可以将Spring Boot应用程序定义为'micro'了,其运行时间只有100-200mb,而不是300mb+。我打算尽快就此写一篇关于这方面的文章。

但令我惊讶的是,OpenJ9还没有类似于Java 8/9/10+中针对`cgroup`内存限制的标志(backported)的选项。如果我们将以前的测试用例应用到最新的AdoptAJDK OpenJDK 9 + OpenJ9 build:
shell
docker run -m 100m -it adoptopenjdk/openjdk9-openj9 /bin/bash

我们添加OpenJDK标志(OpenJ9会忽略的标志):
shell
java -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap MemEat
...
free memory: 83988984
free memory: 82940400
free memory: 81891816
Killed

Oops,JVM再次被Docker杀死。

我真的希望类似的选项将很快添加到OpenJ9中,因为我希望在生产环境中运行这个选项,而不必指定最大内存两次。 Eclipse/IBM正在努力修复这个问题,已经提了issues,甚至已经针对issues提交了PR。
## 更新:(不推荐Hack)
一个稍微丑陋/hacky的方式来解决这个问题是使用下面的组合标志:
shell
java -Xmx`cat /sys/fs/cgroup/memory/memory.limit_in_bytes` MemEat
...
free memory: 3171536
free memory: 2127048
free memory: 2397632
free memory: 1344952
JVMDUMP039I Processing dump event "systhrow", detail "java/lang/OutOfMemoryError" at 2018/05/15 14:04:26 - please wait.
JVMDUMP032I JVM requested System dump using '//core.20180515.140426.125.0001.dmp' in response to an event
JVMDUMP010I System dump written to //core.20180515.140426.125.0001.dmp
JVMDUMP032I JVM requested Heap dump using '//heapdump.20180515.140426.125.0002.phd' in response to an event
JVMDUMP010I Heap dump written to //heapdump.20180515.140426.125.0002.phd
JVMDUMP032I JVM requested Java dump using '//javacore.20180515.140426.125.0003.txt' in response to an event
JVMDUMP010I Java dump written to //javacore.20180515.140426.125.0003.txt
JVMDUMP032I JVM requested Snap dump using '//Snap.20180515.140426.125.0004.trc' in response to an event
JVMDUMP010I Snap dump written to //Snap.20180515.140426.125.0004.trc
JVMDUMP013I Processed dump event "systhrow", detail "java/lang/OutOfMemoryError".
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at MemEat.main(MemEat.java:8)

在这种情况下,堆大小受限于分配给Docker实例的内存,这适用于较旧的JVM和OpenJ9。这当然是错误的,因为容器本身和堆外的JVM的其他部分也使用内存。但它似乎工作,显然Docker在这种情况下是宽松的。也许某些bash大神会做出更好的版本,从其他进程的字节中减去一部分。

无论如何,不要这样做,它可能无法正常工作。
## 测试六:OpenJ9(Nightly)
有人建议使用OpenJ9的最新`nightly`版本。
shell
docker run -m 100m -it adoptopenjdk/openjdk9-openj9:nightly /bin/bash

最新的OpenJ9夜间版本,它有两个东西:

  1. 另一个有问题的PATH参数,需要先解决这个问题
  2. JVM支持新标志UseContainerSupport(就像Java 10一样)

shell
export PATH=$PATH:/opt/java/openjdk/jdk-9.0.4+12/bin/

javac MemEat.java

java -XX:+UseContainerSupport MemEat
...
free memory: 5864464
free memory: 4815880
free memory: 3443712
free memory: 2391032
JVMDUMP039I Processing dump event "systhrow", detail "java/lang/OutOfMemoryError" at 2018/05/15 21:32:07 - please wait.
JVMDUMP032I JVM requested System dump using '//core.20180515.213207.62.0001.dmp' in response to an event
JVMDUMP010I System dump written to //core.20180515.213207.62.0001.dmp
JVMDUMP032I JVM requested Heap dump using '//heapdump.20180515.213207.62.0002.phd' in response to an event
JVMDUMP010I Heap dump written to //heapdump.20180515.213207.62.0002.phd
JVMDUMP032I JVM requested Java dump using '//javacore.20180515.213207.62.0003.txt' in response to an event
JVMDUMP010I Java dump written to //javacore.20180515.213207.62.0003.txt
JVMDUMP032I JVM requested Snap dump using '//Snap.20180515.213207.62.0004.trc' in response to an event
JVMDUMP010I Snap dump written to //Snap.20180515.213207.62.0004.trc
JVMDUMP013I Processed dump event "systhrow", detail "java/lang/OutOfMemoryError".
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

TADAAA,正在修复中!

奇怪的是,这个标志在OpenJ9中默认没有启用,就像它在Java 10中一样。再说一次:确保你测试了这是你想在一个Docker容器中运行Java。
# 结论
简言之:注意资源限制的不匹配。测试你的内存设置和JVM标志,不要假设任何东西。

如果您在Docker容器中运行Java,请确保你设置了Docker内存限制和在JVM中也做了限制,或者你的JVM能够理解这些限制。

如果您无法升级您的Java版本,请使用`-Xmx`设置您自己的限制。


对于Java 8和Java 9,请更新到最新版本并使用:
-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap

对于Java 10,确保它支持'UseContainerSupport'(更新到最新版本)。

对于OpenJ9(我强烈建议使用,可以在生产环境中有效减少内存占用量),现在使用`-Xmx`设置限制,但很快会出现一个支持`UseContainerSupport`标志的版本。

原文链接:Java and Docker, the limitations(翻译:kelvinji

Java 10发布后,Docker容器管理能力得到显著增强

kelvinji2009 发表了文章 • 0 个评论 • 2280 次浏览 • 2018-06-04 15:05 • 来自相关话题

Apache Spark、Kafka等运行在JVM中的传统企业应用,实际上都可以运行在容器环境之内。然而,在容器内运行JVM的方案近期却遇到了麻烦——由于内存与CPU资源及利用率受限,致使其性能表现无法令人满意。究其原因,这是因为Java无法意识到自身正运行在 ...查看全部
Apache Spark、Kafka等运行在JVM中的传统企业应用,实际上都可以运行在容器环境之内。然而,在容器内运行JVM的方案近期却遇到了麻烦——由于内存与CPU资源及利用率受限,致使其性能表现无法令人满意。究其原因,这是因为Java无法意识到自身正运行在容器当中。

随着Java 10的发布,JVM终于可以识别出由容器控制组(cgroups)提出的限制集合。这意味着内存与CPU限制条件皆可用于直接在容器内实现对Java应用的管理,具体包括:


* 在容器内部设置内存限制
* 在容器内部设置可用CPU个数
* 在容器内设置CPU限制


Java 10的这些优化成果可在Docker for mac/windows和Docker企业版中正常起效。


#容器内存限制

一直到Java 9版本,JVM仍然无法通过在容器中使用标识来识别内存或CPU限制。在Java 10中,内存限制可以被自动识别,而且这一特性将默认启用。

Java对服务器进行分级定义,如果一台服务器有双CPU加2 GB内存,那么默认的堆大小将为物理内存的1/4。这里假定有一台安装Docker企业版四CPU 加2 GB内存的服务器,下面我们来比较分别运行有Java 8和Java 10的容器的具体区别。先来看Java 8:

docker container run -it -m512 --entrypoint bash openjdk:latest

$ docker-java-home/bin/java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
uintx MaxHeapSize := 524288000 {product}
openjdk version "1.8.0_162"


我们可以看到最大的堆大小是512 MB,刚好等于(1/4)x 2 GB,但这是通过Docker EE自动设置的,而而通过设置容器实现。作为比较,我们再来看Java 10环境下运行同样的命令是什么结果。

docker container run -it -m512M --entrypoint bash openjdk:10-jdk

$ docker-java-home/bin/java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
size_t MaxHeapSize = 134217728 {product} {ergonomic}
openjdk version "10" 2018-03-20


上面的结果显示,容器里的内存限制非常接近我们期望的128 MB。

#设置可用CPU个数
默认情况下,各容器所能获取的主机CPU时钟周期不受限制。通过额外设置,我们可以指定某一特定容器所能获得的主机CPU时钟周期。

Java 10能够识别这些限制:

docker container run -it --cpus 2 openjdk:10-jdk
jshell> Runtime.getRuntime().availableProcessors()
$1 ==> 2


所有分配给Docker EE的CPU均获得同样比例的CPU时钟周期。这一比例可以通过调整CPU共享比重(相对于其他所有运行的容器的比重)来进行修改。

这一比重只在运行CPU敏感型进程时才会生效。当某一容器中的任务处于空闲状态,那么其它容器可以使用剩余的全部CPU时钟周期。真实CPU时间量在很大程度上取决于运行在该系统之上的容器数量。这些在Java 10中都可以设置:

docker container run -it --cpu-shares 2048 openjdk:10-jdk
jshell> Runtime.getRuntime().availableProcessors()
$1 ==> 2


Java 10中也可以设置CPU集合限制(允许哪些CPU执行)。

docker run -it --cpuset-cpus="1,2,3" openjdk:10-jdk
jshell> Runtime.getRuntime().availableProcessors()
$1 ==> 3

#分配内存与CPU
利用Java 10,我们可以使用容器设置来估算部署应用程序所需的内存和CPU配额。我们假设已经确定了容器中运行的每个进程的内存堆和CPU要求,并设置了JAVA_OPTS。例如,如果您有一个跨十个节点分布的应用程序,其中五个节点需要512 Mb的内存,且各自分配得1024个CPU比重;另外五个节点需要256 Mb,且各自分配得512个CPU比重。请注意,1块CPU的整体计算能力将被拆分为1024个比重单位。



对于内存,应用程序需要至少分配5 Gb容量。



512 Mb x 5 = 2.56 Gb




256Mb x 5 = 1.28 Gb

该应用程序需要8块CPU才能高效运行。

1024 x 5 = 5 CPU

512 x 5 = 3 CPU



最佳实践建议用户对应用程序进行分析,以确定运行在JVM每个进程的内存和CPU分配需求。但凭借着对容器需求的识别能力,Java 10使我们得以准确估算工作负载的CPU及内存资源占用情况,从而防止容器内运行的Java应用程序遭遇内存或CPU不足的问题。



原文链接:IMPROVED DOCKER CONTAINER INTEGRATION WITH JAVA 10(翻译:kelvinji)

基于 Docker 的微服务架构实践

老李 发表了文章 • 2 个评论 • 7049 次浏览 • 2018-04-11 15:26 • 来自相关话题

前言 基于 Docker 的容器技术是在2015年的时候开始接触的,两年多的时间,作为一名 Docker 的 DevOps,也见证了 Docker 的技术体系的快速发展。本文主要是结合在公司搭建的微服务架构的实践过程,做一个简单的总结 ...查看全部
前言

基于 Docker 的容器技术是在2015年的时候开始接触的,两年多的时间,作为一名 Docker 的 DevOps,也见证了 Docker 的技术体系的快速发展。本文主要是结合在公司搭建的微服务架构的实践过程,做一个简单的总结。希望给在创业初期探索如何布局服务架构体系的 DevOps,或者想初步了解企业级架构的同学们一些参考。

Microservice 和 Docker

对于创业公司的技术布局,很多声音基本上是,创业公司就是要快速上线快速试错。用单应用或者前后台应用分离的方式快速集成,快速开发,快速发布。但其实这种结果造成的隐性成本会更高。

当业务发展起来,开发人员多了之后,就会面临庞大系统的部署效率,开发协同效率问题。然后通过服务的拆分,数据的读写分离、分库分表等方式重新架构,而且这种方式如果要做的彻底,需要花费大量人力物力。

个人建议,DevOps 结合自己对于业务目前以及长期的发展判断,能够在项目初期使用微服务架构,多为后人谋福。

随着 Docker 周围开源社区的发展,让微服务架构的概念能有更好的一个落地实施的方案。并且在每一个微服务应用内部,都可以使用 DDD(Domain-Drive Design)的六边形架构来进行服务内的设计。关于 DDD 的一些概念也可以参考之前写的几篇文章:领域驱动设计整理——概念&架构、领域驱动设计整理——实体和值对象设计、领域服务、领域事件。

清晰的微服务的领域划分,服务内部有架构层次的优雅的实现,服务间通过 RPC 或者事件驱动完成必要的 IPC,使用 API gateway 进行所有微服务的请求转发,非阻塞的请求结果合并。本文下面会具体介绍,如何在分布式环境下,也可以快速搭建起来具有以上几点特征的,微服务架构 with Docker。

服务发现模式

如果使用 Docker 技术来架构微服务体系,服务发现就是一个必然的课题。目前主流的服务发现模式有两种:客户端发现模式,以及服务端发现模式。

客户端发现模式

客户端发现模式的架构图如下:

A1.png




客户端发现模式的典型实现是Netflix体系技术。客户端从一个服务注册服务中心查询所有可用服务实例。客户端使用负载均衡算法从多个可用的服务实例中选择出一个,然后发出请求。比较典型的一个开源实现就是 Netflix 的 Eureka。

Netflix-Eureka

Eureka 的客户端是采用自注册的模式,客户端需要负责处理服务实例的注册和注销,发送心跳。

在使用 SpringBoot 集成一个微服务时,结合 SpringCloud 项目可以很方便得实现自动注册。在服务启动类上添加@EnableEurekaClient即可在服务实例启动时,向配置好的 Eureka 服务端注册服务,并且定时发送以心跳。客户端的负载均衡由 Netflix Ribbon 实现。服务网关使用 Netflix Zuul,熔断器使用 Netflix Hystrix。

除了服务发现的配套框架,SpringCloud 的 Netflix-Feign,提供了声明式的接口来处理服务的 Rest 请求。当然,除了使用 FeignClient,也可以使用 Spring RestTemplate。项目中如果使用@FeignClient可以使代码的可阅读性更好,Rest API 也一目了然。

服务实例的注册管理、查询,都是通过应用内调用 Eureka 提供的 REST API 接口(当然使用 SpringCloud-Eureka 不需要编写这部分代码)。由于服务注册、注销是通过客户端自身发出请求的,所以这种模式的一个主要问题是对于不同的编程语言会注册不同服务,需要为每种开发语言单独开发服务发现逻辑。另外,使用 Eureka 时需要显式配置健康检查支持。

服务端发现模式

服务端发现模式的架构图如下:


A2.png



客户端向负载均衡器发出请求,负载均衡器向服务注册表发出请求,将请求转发到注册表中可用的服务实例。服务实例也是在注册表中注册,注销的。负载均衡可以使用可以使用 Haproxy 或者 Nginx。服务端发现模式目前基于 Docker 的主流方案主要是 Consul、Etcd 以及 Zookeeper。

Consul

Consul 提供了一个 API 允许客户端注册和发现服务。其一致性上基于RAFT算法。通过 WAN 的 Gossip 协议,管理成员和广播消息,以完成跨数据中心的同步,且支持 ACL 访问控制。Consul 还提供了健康检查机制,支持 kv 存储服务(Eureka 不支持)。Consul 的一些更详细的介绍可以参考之前写的一篇:Docker 容器部署 Consul 集群。

Etcd

Etcd 都是强一致的(满足 CAP 的 CP),高可用的。Etcd 也是基于 RAFT 算法实现强一致性的 KV 数据同步。Kubernetes 中使用 Etcd 的 KV 结构存储所有对象的生命周期。

关于 Etcd 的一些内部原理可以看下etcd v3原理分析

Zookeeper

ZK 最早应用于 Hadoop,其体系已经非常成熟,常被用于大公司。如果已经有自己的 ZK 集群,那么可以考虑用 ZK 来做自己的服务注册中心。

Zookeeper 同 Etcd 一样,强一致性,高可用性。一致性算法是基于 Paxos 的。对于微服务架构的初始阶段,没有必要用比较繁重的 ZK 来做服务发现。

服务注册

服务注册表是服务发现中的一个重要组件。除了 Kubernetes、Marathon 其服务发现是内置的模块之外。服务都是需要注册到注册表上。上文介绍的 Eureka、consul、etcd 以及 ZK 都是服务注册表的例子。

微服务如何注册到注册表也是有两种比较典型的注册方式:自注册模式,第三方注册模式。

自注册模式 Self-registration pattern

上文中的 Netflix-Eureka 客户端就是一个典型的自注册模式的例子。也即每个微服务的实例本身,需要负责注册以及注销服务。Eureka 还提供了心跳机制,来保证注册信息的准确,具体的心跳的发送间隔时间可以在微服务的 SpringBoot 中进行配置。

如下,就是使用 Eureka 做注册表时,在微服务(SpringBoot 应用)启动时会有一条服务注册的信息:

com.netflix.discovery.DiscoveryClient : DiscoveryClient_SERVICE-USER/{your_ip}:service-user:{port}:cc9f93c54a0820c7a845422f9ecc73fb: registering service...

同样,在应用停用时,服务实例需要主动注销本实例信息:

2018-01-04 20:41:37.290 INFO 49244 --- [ Thread-8] c.n.e.EurekaDiscoveryClientConfiguration : Unregistering application service-user with eureka with status DOWN
2018-01-04 20:41:37.340 INFO 49244 --- [ Thread-8] com.netflix.discovery.DiscoveryClient : Shutting down DiscoveryClient ...
2018-01-04 20:41:37.381 INFO 49244 --- [ Thread-8] com.netflix.discovery.DiscoveryClient : Unregistering ...
2018-01-04 20:41:37.559 INFO 49244 --- [ Thread-8] com.netflix.discovery.DiscoveryClient : DiscoveryClient_SERVICE-USER/{your_ip}:service-user:{port}:cc9f93c54a0820c7a845422f9ecc73fb - deregister status: 200

自注册方式是比较简单的服务注册方式,不需要额外的设施或代理,由微服务实例本身来管理服务注册。但是缺点也很明显,比如 Eureka 目前只提供了 Java 客户端,所以不方便多语言的微服务扩展。因为需要微服务自己去管理服务注册逻辑,所以微服务实现也耦合了服务注册和心跳机制。跨语言性比较差。

第三方注册模式 Third party registration pattern

第三方注册,也即服务注册的管理(注册、注销服务)通过一个专门的服务管理器(Registar)来负责。Registrator 就是一个开源的服务管理器的实现。Registrator 提供了对于 Etcd 以及 Consul 的注册表服务支持。

Registrator 作为一个代理服务,需要部署、运行在微服务所在的服务器或者虚拟机中。比较简单的安装方式就是通过 Docker,以容器的方式来运行。三方注册模式的架构图如下:


A3.png



通过添加一个服务管理器,微服务实例不再直接向注册中心注册,注销。由服务管理器(Registar)通过订阅服务,跟踪心跳,来发现可用的服务实例,并向注册中心(consul、etcd 等)注册,注销实例,以及发送心跳。这样就可以实现服务发现组件和微服务架构的解耦。

Registrator 配合 Consul,以及 Consul Template 搭建服务发现中心,可以参考: Scalable Architecture DR CoN: Docker, Registrator, Consul, Consul Template and Nginx 。此文示例了 Nginx 来做负载均衡,在具体的实施过程中也可以用 Haproxy 或其他方案进行替代。

小结

除了以上几种做服务发现的技术,Kubernetes 自带了服务发现模块,负责处理服务实例的注册和注销。Kubernetes 也在每个集群节点上运行代理,来实现服务端发现路由器的功能。如果编排技术使用的 k8n,可以用 k8n 的一整套 Docker 微服务方案,对 k8n 感兴趣的可以阅读下Kubernetes 架构设计与核心原理。

在实际的技术选型中,最主要还是要结合业务、系统的未来发展的特征进行合理判断。

在 CAP 理论中。Eureka 满足了 AP,Consul 是 CA,ZK 和 Etcd 是 CP。 在分布式场景下 Eureka 和 Consul 都能保证可用性。而搭建 Eureka 服务会相对更快速,因为不需要搭建额外的高可用服务注册中心,在小规模服务器实例时,使用 Eureka 可以节省一定成本。
Eureka、Consul 都提供了可以查看服务注册数据的 WebUI 组件。Consul 还提供了 KV 存储,支持支持 http 和 dns 接口。对于创业公司最开始搭建微服务,比较推荐这两者。
在多数据中心方面,Consul 自带数据中心的 WAN 方案。ZK 和 Etcd 均不提供多数据中心功能的支持,需要额外的开发。
跨语言性上,Zookeeper 需要使用其提供的客户端 api,跨语言支持较弱。Etcd、Eureka 都支持 http,Etcd 还支持 grpc。Consul 除了 http 之外还提供了 DNS 的支持。
安全方面,Consul,Zookeeper 支持 ACL,另外 Consul、Etcd 支持安全通道 Https。
SpringCloud 目前对于 Eureka、Consul、Etcd、ZK 都有相应的支持。
Consul 和 Docker 一样,都是用 Go 语言实现,基于 Go 语言的微服务应用可以优先考虑用 Consul。

服务间的 IPC 机制

按照微服务的架构体系,解决了服务发现的问题之后。就需要选择合适的服务间通信的机制。如果是在 SpringBoot 应用中,使用基于 Http 协议的 REST API 是一种同步的解决方案。而且 Restful 风格的 API 可以使每个微服务应用更加趋于资源化,使用轻量级的协议也是微服务一直提倡的。

如果每个微服务是使用 DDD(Domain-Driven Design)思想的话,那么需要每个微服务尽量不使用同步的 RPC 机制。异步的基于消息的方式比如 AMQP 或者 STOMP,来松耦合微服务间的依赖会是很好的选择。目前基于消息的点对点的 pub/sub 的框架选择也比较多。下面具体介绍下两种 IPC 的一些方案。

同步

对于同步的请求/响应模式的通信方式。可以选择基于 Restful 风格的 Http 协议进行服务间通信,或者跨语言性很好的 Thrift 协议。如果是使用纯 Java 语言的微服务,也可以使用 Dubbo。如果是 SpringBoot 集成的微服务架构体系,建议选择跨语言性好、Spring 社区支持比较好的 RPC。

Dubbo

Dubbo是由阿里巴巴开发的开源的 Java 客户端的 RPC 框架。Dubbo 基于 TCP 协议的长连接进行数据传输。传输格式是使用 Hessian 二进制序列化。服务注册中心可以通过 Zookeeper 实现。

ApacheThrift

ApacheThrift 是由 Facebook 开发的 RPC 框架。其代码生成引擎可以在多种语言中,如 C++、 Java、Python、PHP、Ruby、Erlang、Perl 等创建高效的服务。传输数据采用二进制格式,其数据包要比使用 Json 或者 XML 格式的 HTTP 协议小。高并发,大数据场景下更有优势。

Rest

Rest 基于 HTTP 协议,HTTP 协议本身具有语义的丰富性。随着 Springboot 被广泛使用,越来越多的基于 Restful 风格的 API 流行起来。REST 是基于 HTTP 协议的,并且大多数开发者也是熟知 HTTP 的。

这里另外提一点,很多公司或者团队也是使用Springboot的,也在说自己是基于 Restful 风格的。但是事实其实往往是实施得并不到位。对于你的 Restful 是否是真的 Restful,可以参考这篇文章,对于 Restful 风格 API 的成熟度进行了四个层次的分析: Richardson Maturity Model steps toward the glory of REST。

如果使用Springboot的话,无论使用什么服务发现机制,都可以通过 Spring 的RestTemplate来做基础的Http请求封装。

如果使用的前文提到的Netflix-Eureka的话,可以使用Netflix-Feign。Feign是一个声明式 Web Service 客户端。客户端的负载均衡使用 Netflix-Ribbon。

异步

在微服务架构中,排除纯粹的“事件驱动架构”,使用消息队列的场景一般是为了进行微服务之间的解耦。服务之间不需要了解是由哪个服务实例来消费或者发布消息。

只要处理好自己领域范围的逻辑,然后通过消息通道来发布,或者订阅自己关注的消息就可以。目前开源的消息队列技术也很多。比如 Apache Kafka,RabbitMQ,Apache ActiveMQ 以及阿里巴巴的 RocketMQ 目前已经成为 Apache 项目之一。消息队列的模型中,主要的三个组成就是:

Producer:生产消息,将消息写入 channel。
Message Broker:消息代理,将写入 channel 的消息按队列的结构进行管理。负责存储/转发消息。Broker 一般是需要单独搭建、配置的集群,而且必须是高可用的。
Consumer:消息的消费者。目前大多数的消息队列都是保证消息至少被消费一次。所以根据使用的消息队列设施不同,消费者要做好幂等。

不同的消息队列的实现,消息模型不同。各个框架的特性也不同:

RabbitMQ

RabbitMQ 是基于 AMQP 协议的开源实现,由以高性能、可伸缩性出名的 Erlang 写成。目前客户端支持 Java、.Net/C# 和 Erlang。在 AMQP(Advanced Message Queuing Protocol)的组件中,Broker 中可以包含多个Exchange(交换机)组件。Exchange 可以绑定多个 Queue 以及其他 Exchange。

消息会按照 Exchange 中设置的 Routing 规则,发送到相应的 Message Queue。在 Consumer 消费了这个消息之后,会跟 Broker 建立连接。发送消费消息的通知。则 Message Queue 才会将这个消息移除。

Kafka

Kafka 是一个高性能的基于发布/订阅的跨语言分布式消息系统。Kafka 的开发语言为 Scala。其比较重要的特性是:

以时间复杂度为O(1)的方式快速消息持久化;
高吞吐率;
支持服务间的消息分区,及分布式消费,同时保证消息顺序传输;
支持在线水平扩展,自带负载均衡;
支持只消费且仅消费一次(Exactly Once)模式等等。
说个缺点: 管理界面是个比较鸡肋了点,可以使用开源的kafka-manager

其高吞吐的特性,除了可以作为微服务之间的消息队列,也可以用于日志收集, 离线分析, 实时分析等。

Kafka 官方提供了 Java 版本的客户端 API,Kafka 社区目前也支持多种语言,包括 PHP、Python、Go、C/C++、Ruby、NodeJS 等。

ActiveMQ

ActiveMQ 是基于 JMS(Java Messaging Service)实现的 JMSProvider。JMS主要提供了两种类型的消息:点对点(Point-to-Point)以及发布/订阅(Publish/Subscribe)。目前客户端支持 Java、C、C++、 C#、Ruby、Perl、Python、PHP。而且 ActiveMQ 支持多种协议:Stomp、AMQP、MQTT 以及 OpenWire。

RocketMQ/ONS

RocketMQ 是由阿里巴巴研发开源的高可用分布式消息队列。ONS是提供商业版的高可用集群。ONS 支持 pull/push。可支持主动推送,百亿级别消息堆积。ONS 支持全局的顺序消息,以及有友好的管理页面,可以很好的监控消息队列的消费情况,并且支持手动触发消息多次重发。

小结

通过上篇的微服务的服务发现机制,加上 Restful API,可以解决微服务间的同步方式的进程间通信。当然,既然使用了微服务,就希望所有的微服务能有合理的限界上下文(系统边界)。

微服务之间的同步通信应尽量避免,以防止服务间的领域模型互相侵入。为了避免这种情况,就可以在微服务的架构中使用一层API gateway(会在下文介绍)。所有的微服务通过API gateway进行统一的请求的转发,合并。并且API gateway也需要支持同步请求,以及NIO的异步的请求(可以提高请求合并的效率以及性能)。

消息队列可以用于微服务间的解耦。在基于Docker的微服务的服务集群环境下,网络环境会比一般的分布式集群复杂。选择一种高可用的分布式消息队列实现即可。如果自己搭建诸如Kafka、RabbitMQ集群环境的话,那对于Broker设施的高可用性会要求很高。

基于Springboot的微服务的话,比较推荐使用Kafka 或者ONS。虽然ONS是商用的,但是易于管理以及稳定性高,尤其对于必要场景才依赖于消息队列进行通信的微服务架构来说,会更适合。如果考虑到会存在日志收集,实时分析等场景,也可以搭建Kafka集群。目前阿里云也有了基于Kafka的商用集群设施。

使用 API Gateway 处理微服务请求转发、合并

前面主要介绍了如何解决微服务的服务发现和通信问题。在微服务的架构体系中,使用DDD思想划分服务间的限界上下文的时候,会尽量减少微服务之间的调用。为了解耦微服务,便有了基于API Gateway方式的优化方案。

解耦微服务的调用

比如,下面一个常见的需求场景——“用户订单列表”的一个聚合页面。需要请求”用户服务“获取基础用户信息,以及”订单服务“获取订单信息,再通过请求“商品服务”获取订单列表中的商品图片、标题等信息。如下图所示的场景 :


A4.png



如果让客户端(比如H5、Android、iOS)发出多个请求来解决多个信息聚合,则会增加客户端的复杂度。比较合理的方式就是增加API Gateway层。API Gateway跟微服务一样,也可以部署、运行在Docker容器中,也是一个Springboot应用。如下,通过Gateway API进行转发后:


A5.png



所有的请求的信息,由Gateway进行聚合,Gateway也是进入系统的唯一节点。并且Gateway和所有微服务,以及提供给客户端的也是Restful风格API。Gateway层的引入可以很好的解决信息的聚合问题。而且可以更好得适配不同的客户端的请求,比如H5的页面不需要展示用户信息,而iOS客户端需要展示用户信息,则只需要添加一个Gateway API请求资源即可,微服务层的资源不需要进行变更。

API Gateway 的特点

API gateway除了可以进行请求的合并、转发。还需要有其他的特点,才能成为一个完整的Gateway。

响应式编程

Gateway是所有客户端请求的入口。类似Facade模式。为了提高请求的性能,最好选择一套非阻塞I/O的框架。在一些需要请求多个微服务的场景下,对于每个微服务的请求不一定需要同步。前文举例的“用户订单列表”的例子中,获取用户信息,以及获取订单列表,就是两个独立请求。

只有获取订单的商品信息,需要等订单信息返回之后,根据订单的商品id列表再去请求商品微服务。为了减少整个请求的响应时间,需要Gateway能够并发处理相互独立的请求。一种解决方案就是采用响应式编程。

目前使用Java技术栈的响应式编程方式有,Java8的CompletableFuture,以及ReactiveX提供的基于JVM的实现-RxJava。

ReactiveX是一个使用可观察数据流进行异步编程的编程接口,ReactiveX结合了观察者模式、迭代器模式和函数式编程的精华。除了RxJava还有RxJS,RX.NET等多语言的实现。

对于Gateway来说,RxJava提供的Observable可以很好的解决并行的独立I/O请求,并且如果微服务项目中使用Java8,团队成员会对RxJava的函数学习吸收会更快。同样基于Lambda风格的响应式编程,可以使代码更加简洁。关于RxJava的详细介绍可以可以阅读RxJava文档和教程。

通过响应式编程的Observable模式,可以很简洁、方便得创建事件流、数据流,以及用简洁的函数进行数据的组合和转换,同时可以订阅任何可观察的数据流并执行操作。

通过使用RxJava,“用户订单列表”的资源请求时序图:


A6.png



响应式编程可以更好的处理各种线程同步、并发请求,通过Observables和Schedulers提供了透明的数据流、事件流的线程处理。在敏捷开发模式下,响应式编程使代码更加简洁,更好维护。

鉴权

Gateway作为系统的唯一入口,基于微服务的所有鉴权,都可以围绕Gateway去做。在Springboot工程中,基础的授权可以使用spring-boot-starter-security以及Spring Security(Spring Security也可以集成在Spring MVC项目中)。

Spring Security主要使用AOP,对资源请求进行拦截,内部维护了一个角色的Filter Chain。因为微服务都是通过Gateway请求的,所以微服务的@Secured可以根据Gateway中不同的资源的角色级别进行设置。

Spring Security提供了基础的角色的校验接口规范。但客户端请求的Token信息的加密、存储以及验证,需要应用自己完成。对于Token加密信息的存储可以使用Redis。

这里再多提一点,为了保证一些加密信息的可变性,最好在一开始设计Token模块的时候就考虑到支持多个版本密钥,以防止万一内部密钥被泄露(之前听一个朋友说其公司的Token加密代码被员工公布出去)。

至于加密算法,以及具体的实现在此就不再展开。 在Gateway鉴权通过之后,解析后的token信息可以直接传递给需要继续请求的微服务层。

如果应用需要授权(对资源请求需要管理不同的角色、权限),也只要在Gateway的Rest API基础上基于AOP思想来做即可。统一管理鉴权和授权,这也是使用类似Facade模式的Gateway API的好处之一。

负载均衡

API Gateway跟Microservice一样,作为Springboot应用,提供Rest api。所以同样运行在Docker容器中。Gateway和微服务之间的服务发现还是可以采用前文所述的客户端发现模式,或者服务端发现模式。

在集群环境下,API Gateway 可以暴露统一的端口,其实例会运行在不同IP的服务器上。因为我们是使用阿里云的ECS作为容器的基础设施,所以在集群环境的负载均衡也是使用阿里云的负载均衡SLB,域名解析也使用AliyunDNS。下图是一个简单的网络请求的示意:


A7.png



在实践中,为了不暴露服务的端口和资源地址,也可以在服务集群中再部署Nginx服务作为反向代理,外部的负载均衡设施比如SLB可以将请求转发到Nginx服务器,请求通过Nginx再转发给Gateway端口。如果是自建机房的集群,就需要搭建高可用的负载均衡中心。为了应对跨机器请求,最好使用Consul,Consul(Consul Template)+Registor+Haproxy来做服务发现和负载均衡中心。

缓存

对于一些高QPS的请求,可以在API Gateway做多级缓存。分布式的缓存可以使用Redis,Memcached等。如果是一些对实时性要求不高的,变化频率不高但是高QPS的页面级请求,也可以在Gateway层做本地缓存。而且Gateway可以让缓存方案更灵活和通用。

API Gateway的错误处理

在Gateway的具体实现过程中,错误处理也是一个很重要的事情。对于Gateway的错误处理,可以使用Hystrix来处理请求的熔断。并且RxJava自带的onErrorReturn回调也可以方便得处理错误信息的返回。对于熔断机制,需要处理以下几个方面:

服务请求的容错处理

作为一个合理的Gateway,其应该只负责处理数据流、事件流,而不应该处理业务逻辑。在处理多个微服务的请求时,会出现微服务请求的超时、不可用的情况。在一些特定的场景下, 需要能够合理得处理部分失败。比如上例中的“用户订单列表”,当“User”微服务出现错误时,不应该影响“Order”数据的请求。

最好的处理方式就是给当时错误的用户信息请求返回一个默认的数据,比如显示一个默认头像,默认用户昵称。然后对于请求正常的订单,以及商品信息给与正确的数据返回。

如果是一个关键的微服务请求异常,比如当“Order”领域的微服务异常时,则应该给客户端一个错误码,以及合理的错误提示信息。这样的处理可以尽量在部分系统不可用时提升用户体验。使用RxJava时,具体的实现方式就是针对不同的客户端请求的情况,写好onErrorReturn,做好错误数据兼容即可。

异常的捕捉和记录

Gateway主要是做请求的转发、合并。为了能清楚得排查问题,定位到具体哪个服务、甚至是哪个Docker容器的问题,需要Gateway能对不同类型的异常、业务错误进行捕捉和记录。

如果使用FeignClient来请求微服务资源,可以通过对ErrorDecoder接口的实现,来针对Response结果进行进一步的过滤处理,以及在日志中记录下所有请求信息。如果是使用Spring Rest Template,则可以通过定义一个定制化的RestTempate,并对返回的ResponseEntity进行解析。在返回序列化之后的结果对象之前,对错误信息进行日志记录。

超时机制

Gateway线程中大多是IO线程,为了防止因为某一微服务请求阻塞,导致Gateway过多的等待线程,耗尽线程池、队列等系统资源。需要Gateway中提供超时机制,对超时接口能进行优雅的服务降级。

在SpringCloud的Feign项目中集成了Hystrix。Hystrix提供了比较全面的超时处理的熔断机制。默认情况下,超时机制是开启的。除了可以配置超时相关的参数,Netflix还提供了基于Hytrix的实时监控Netflix -Dashboard,并且集群服务只需再附加部署Netflix-Turbine。通用的Hytrix的配置项可以参考Hystrix-Configuration。

如果是使用RxJava的Observable的响应式编程,想对不同的请求设置不同的超时时间,可以直接在Observable的timeout()方法的参数进行设置回调的方法以及超时时间等。

重试机制

对于一些关键的业务,在请求超时时,为了保证正确的数据返回,需要Gateway能提供重试机制。如果使用SpringCloudFeign,则其内置的Ribbon,会提供的默认的重试配置,可以通过设置spring.cloud.loadbalancer.retry.enabled=false将其关闭。

Ribbon提供的重试机制会在请求超时或者socket read timeout触发,除了设置重试,也可以定制重试的时间阀值以及重试次数等。

对于除了使用Feign,也使用Spring RestTemplate的应用,可以通过自定义的RestTemplate,对于返回的ResponseEntity对象进行结果解析,如果请求需要重试(比如某个固定格式的error-code的方式识别重试策略),则通过Interceptor进行请求拦截,以及回调的方式invoke多次请求。

小结

对于微服务的架构,通过一个独立的API Gateway,可以进行统一的请求转发、合并以及协议转换。可以更灵活得适配不同客户端的请求数据。而且对于不同客户端(比如H5和iOS的展示数据不同)、不同版本兼容的请求,可以很好地在Gateway进行屏蔽,让微服务更加纯粹。微服务只要关注内部的领域服务的设计,事件的处理。

API gateway还可以对微服务的请求进行一定的容错、服务降级。使用响应式编程来实现API gateway可以使线程同步、并发的代码更简洁,更易于维护。在对于微服务的请求可以统一通过FeignClint。代码也会很有层次。如下图,是一个示例的请求的类层次。


A8.png



Clint负责集成服务发现(对于使用Eureka自注册方式)、负载均衡以及发出请求,并获取ResponseEntity对象。
Translator将ResponseEntity转换成Observable对象,以及对异常进行统一日志采集,类似于DDD中防腐层的概念。
Adapter调用各个Translator,使用Observable函数,对请求的数据流进行合并。如果存在多个数据的组装,可以增加一层Assembler专门处理DTO对象到Model的转换。
Controller,提供Restful资源的管理,每个Controller只请求唯一的一个Adapter方法。

微服务的持续集成部署

前文主要介绍了微服务的服务发现、服务通信以及API Gateway。整体的微服务架构的模型初见。在实际的开发、测试以及生产环境中。使用Docker实现微服务,集群的网络环境会更加复杂。

微服务架构本身就意味着需要对若干个容器服务进行治理,每个微服务都应可以独立部署、扩容、监控。下面会继续介绍如何进行Docker微服务的持续集成部署(CI/CD)。

镜像仓库

用Docker来部署微服务,需要将微服务打包成Docker镜像,就如同部署在Web server打包成war文件一样。只不过Docker镜像运行在Docker容器中。

如果是Springboot服务,则会直接将包含Apache Tomcat server的Springboot,以及包含Java运行库的编译后的Java应用打包成Docker镜像。

为了能统一管理打包以及分发(pull/push)镜像。企业一般需要建立自己的镜像私库。实现方式也很简单。可以在服务器上直接部署Docker hub的镜像仓库的容器版Registry2。目前最新的版本是V2。

代码仓库

代码的提交、回滚等管理,也是项目持续集成的一环。一般也是需要建立企业的代码仓库的私库。可以使用SVN,GIT等代码版本管理工具。

目前公司使用的是Gitlab,通过Git的Docker镜像安装、部署操作也很便捷。具体步骤可以参考docker gitlab install。为了能快速构建、打包,也可将Git和Registry部署在同一台服务器上。

项目构建

在Springboot项目中,构建工具可以用Maven,或者Gradle。Gradle相比Maven更加灵活,而且Springboot应用本身去配置化的特点,用基于Groovy的Gradle会更加适合,DSL本身也比XML更加简洁高效。

因为Gradle支持自定义task。所以微服务的Dockerfile写好之后,就可以用Gradle的task脚本来进行构建打包成Docker Image。

目前也有一些开源的Gradle构建Docker镜像的工具,比如Transmode-Gradlew插件。其除了可以对子项目(单个微服务)进行构建Docker镜像,也可以支持同时上传镜像到远程镜像仓库。在生产环境中的build机器上,可以通过一个命令直接执行项目的build,Docker Image的打包,以及镜像的push。

容器编排技术

Docker镜像构建之后,因为每个容器运行着不同的微服务实例,容器之间也是隔离部署服务的。通过编排技术,可以使DevOps轻量化管理容器的部署以及监控,以提高容器管理的效率。

目前一些通用的编排工具比如Ansible、Chef、Puppet,也可以做容器的编排。但他们都不是专门针对容器的编排工具,所以使用时需要自己编写一些脚本,结合Docker的命令。比如Ansible,确实可以实现很便利的集群的容器的部署和管理。目前Ansible针对其团队自己研发的容器技术提供了集成方案:Ansible Container。

集群管理系统将主机作为资源池,根据每个容器对资源的需求,决定将容器调度到哪个主机上。

目前,围绕Docker容器的调度、编排,比较成熟的技术有Google的Kubernetes(下文会简写k8s),Mesos结合Marathon管理Docker集群,以及在Docker 1.12.0版本以上官方提供的Docker Swarm。编排技术是容器技术的重点之一。选择一个适合自己团队的容器编排技术也可以使运维更高效、更自动化。

Docker Compose

Docker Compose是一个简单的Docker容器的编排工具,通过YAML文件配置需要运行的应用,然后通过compose up命令启动多个服务对应的容器实例。Docker中没有集成Compose,需要另外安装。

Compose可以用于微服务项目的持续集成,但其不适合大型集群的容器管理,大集群中,可以Compose结合Ansible做集群资源管理,以及服务治理。

对于集群中服务器不多的情况,可以使用Compose,其使用步骤主要是:

结合微服务运行环境,定义好服务的Dockerfile
根据服务镜像、端口、运行变量等编写docker-compose.yml文件,以使服务可以一起部署,运行
运行docker-compose up 命令启动并且进入容器实例,如果需要使用后台进程方式运行,使用docker-compose up -d即可。

Docker Swarm

在16年,Docker的1.12版本出来之后,使用新版本的Docker,就自带Docker swarm mode了。不需要额外安装任何插件工具。可以看出去年开始Docker团队也开始重视服务编排技术,通过内置Swarm mode,也要抢占一部分服务编排市场。想要了解更多微服务架构知识点的,可以加群:650385180,群里有系统的学习方案供大家免费下载。

如果团队开始使用新版本的Docker,可以选择Docker swarm mode来进行集群化的容器调度和管理。Swarm还支持滚动更新、节点间传输层安全加密、负载均衡等。

DockerSwarm的使用示例可以参考之前写的一篇:使用docker-swarm搭建持续集成集群服务。

Kubernetes

Kubernetes是Google开源的容器集群管理系统,使用Go语言实现,其提供应用部署、维护、 扩展机制等功能。目前可以在GCE、vShpere、CoreOS、OpenShift、Azure等平台使用k8s。

国内目前Aliyun也提供了基于k8s的服务治理平台。如果是基于物理机、虚拟机搭建的Docker集群的话,也可以直接部署、运行k8s。在微服务的集群环境下,Kubernetes可以很方便管理跨机器的微服务容器实例。

目前k8s基本是公认的最强大开源服务治理技术之一。其主要提供以下功能:

自动化对基于Docker对服务实例进行部署和复制
以集群的方式运行,可以管理跨机器的容器,以及滚动升级、存储编排。
内置了基于Docker的服务发现和负载均衡模块
K8s提供了强大的自我修复机制,会对崩溃的容器进行替换(对用户,甚至开发团队都无感知),并可随时扩容、缩容。让容器管理更加弹性化。

k8s主要通过以下几个重要的组件完成弹性容器集群的管理的:

Pod是Kubernetes的最小的管理元素,一个或多个容器运行在pod中。pod的生命周期很短暂,会随着调度失败,节点崩溃,或者其他资源回收时消亡。
Label是key/value存储结构的,可以关联pod,主要用来标记pod,给服务分组。微服务之间通过label选择器(Selectors)来识别Pod。
Replication Controller是k8s Master节点的核心组件。用来确保任何时候Kubernetes集群中有指定数量的pod副本(replicas)运行。即提供了自我修复机制的功能,并且对缩容扩容、滚动升级也很有用。
Service是对一组Pod的策略的抽象。也是k8s管理的基本元素之一。Service通过Label识别一组Pod。创建时也会创建一个本地集群的DNS(存储Service对应的Pod的服务地址)。所以在客户端请求通过请求DNS来获取一组当前可用的Pods的ip地址。之后通过每个Node中运行的kube-proxy将请求转发给其中一个Pod。这层负载均衡是透明的,但是目前的k8s的负载均衡策略还不是很完善,默认是随机的方式。

小结

微服务架构体系中,一个合适的持续集成的工具,可以很好得提升团队的运维、开发效率。目前类似Jenkins也有针对Docker的持续集成的插件,但是还是存在很多不完善。所以建议还是选择专门应对Docker容器编排技术的Swarm,k8s,Mesos。或者多个技术结合起来,比如Jenkins做CI+k8s做CD。

Swarm,k8s,Mesos各有各的特性,他们对于容器的持续部署、管理以及监控都提供了支持。Mesos还支持数据中心的管理。Docker swarm mode扩展了现有的Docker API,通过Docker Remote API的调用和扩展,可以调度容器运行到指定的节点。

Kubernetes则是目前市场规模最大的编排技术,目前很多大公司也都加入到了k8s家族,k8s应对集群应用的扩展、维护和管理更加灵活,但是负载均衡策略比较粗糙。而Mesos更专注于通用调度,提供了多种调度器。

对于服务编排,还是要选择最适合自己团队的,如果初期机器数量很少,集群环境不复杂也可以用Ansible+Docker Compose,再加上Gitlab CI来做持续集成。

服务集群的解决方案

企业在实践使用Docker部署、运行微服务应用的时候,无论是一开始就布局微服务架构,或者从传统的单应用架构进行微服务化迁移。都需要能够处理复杂的集群中的服务调度、编排、监控等问题。下面主要为介绍在分布式的服务集群下,如何更安全、高效得使用Docker,以及在架构设计上,需要考虑的方方面面。

负载均衡

这里说的是集群中的负载均衡,如果是纯服务端API的话就是指Gateway API的负载均衡,如果使用了Nginx的话,则是指Nginx的负载均衡。我们目前使用的是阿里云的负载均衡服务SLB。

其中一个主要原因是可以跟DNS域名服务进行绑定。对于刚开始进行创业的公司来说,可以通过Web界面来设置负载均衡的权重,比较便于部分发布、测试验证,以及健康检查监控等等。从效率和节约运维成本上来说都是个比较适合的选择。

如果自己搭建七层负载均衡如使用Nginx或Haproxy的话,也需要保证负责负载均衡的集群也是高可用的,以及提供便捷的集群监控,蓝绿部署等功能。

持久化及缓存

关系型数据库(RDBMS)

对于微服务来说,使用的存储技术主要是根据企业的需要。为了节约成本的话,一般都是选用Mysql,在Mysql的引擎选择的话建议选择InnoDB引擎(5.5版本之前默认MyISAM)。

InnoDB在处理并发时更高效,其查询性能的差距也可以通过缓存、搜索等方案进行弥补。InnoDB处理数据拷贝、备份的免费方案有binlog,mysqldump。不过要做到自动化的备份恢复、可监控的数据中心还是需要DBA或者运维团队。

相对花费的成本也较高。如果初创企业,也可以考虑依托一些国内外比较大型的云计算平台提供的PaaS服务。

微服务一般按照业务领域进行边界划分,所以微服务最好是一开始就进行分库设计。是否需要进行分表需要根据每个微服务具体的业务领域的发展以及数据规模进行具体分析。但建议对于比较核心的领域的模型,比如“订单”提前做好分表字段的设计和预留。

KV模型数据库(Key-Value-stores)

Redis是开源的Key-Value结构的数据库。其基于内存,具有高效缓存的性能,同时也支持持久化。Redis主要有两种持久化方式。一种是RDB,通过指定时间间隔生成数据集的时间点快照,从内存写入磁盘进行持久化。

RDB方式会引起一定程度的数据丢失,但性能好。另外一种是AOF,其写入机制,有点类似InnoDB的binlog,AOF的文件的命令都是以Redis协议格式保存。这两种持久化是可以同时存在的,在Redis重启时,AOF文件会被优先用于恢复数据。因为持久化是可选项,所以也可以禁用Redis持久化。

在实际的场景中,建议保留持久化。比如目前比较流行的解决短信验证码的验证,就可使用Redis。在微服务架构体系中,也可以用Redis处理一些KV数据结构的场景。轻量级的数据存储方案,也很适合本身强调轻量级方案的微服务思想。

我们在实践中,是对Redis进行了缓存、持久化,两个功能特征进行分库的。

在集成Springboot项目中会使用到spring-boot-starter-data-redis来进行Redis的数据库连接以及基础配置、以及spring-data-redis提供的丰富的数据APIOperations。

另外,如果是要求高吞吐量的应用,可以考虑用Memcached来专门做简单的KV数据结构的缓存。其比较适合大数据量的读取,但支持的数据结构类型比较单一。

图形数据库(Graph Database)

涉及到社交相关的模型数据的存储,图形数据库是一种相交关系型数据库更高效、更灵活的选择。图形数据库也是Nosql的一种。其和KV不同,存储的数据主要是数据节点(node),具有指向性的关系(Relationship)以及节点和关系上的属性(Property)。

如果用Java作为微服务的主开发语言,最好选择Neo4j。Neo4j是一种基于Java实现的支持ACID的图形数据库。其提供了丰富的JavaAPI。在性能方面,图形数据库的局部性使遍历的速度非常快,尤其是大规模深度遍历。这个是关系型数据库的多表关联无法企及的。

下图是使用Neo4j的WebUI工具展示的一个官方Getting started数据模型示例。示例中的语句MATCH p=()-[r:DIRECTED]->() RETURN p LIMIT 25是Neo4j提供的查询语言——Cypher。


A9.png



在项目使用时可以集成SpringData的项目Spring Data Neo4j。以及SpringBootStartersspring-boot-starter-data-neo4j

文档数据库(Document database)

目前应用的比较广泛的开源的面向文档的数据库可以用Mongodb。Mongo具有高可用、高可伸缩性以及灵活的数据结构存储,尤其是对于Json数据结构的存储。比较适合博客、评论等模型的存储。

搜索技术

在开发的过程中,有时候经常会看到有人写了很长很绕、很难维护的多表查询SQL,或者是各种多表关联的子查询语句。对于某一领域模型,当这种场景多的时候,就该考虑接入一套搜索方案了。不要什么都用SQL去解决,尤其是查询的场景。慢查询语句的问题有时候甚至会拖垮DB,如果DB的监控体系做的不到位,可能问题也很难排查。

Elasticsearch是一个基于Apache Lucene实现的开源的实时分布式搜索和分析引擎。Springboot的项目也提供了集成方式: spring-boot-starter-data-elasticsearch以及spring-data-elasticsearch。

对于搜索集群的搭建,可以使用Docker。具体搭建方法可以参考用Docker搭建Elasticsearch集群,对于Springboot项目的集成可以参考在Springboot微服务中集成搜索服务。至今,最新版本的SpringDataElasticsearch已经支持到了5.x版本的ES,可以解决很多2.x版本的痛点了。

如果是小规模的搜索集群,可以用三台低配置的机器,然后用ES的Docker进项进行搭建。也可以使用一些商业版的PaaS服务。如何选择还是要根据团队和业务的规模、场景来看。想要了解更多微服务架构知识点的,可以加群:650385180,群里有系统的学习方案供大家免费下载。

目前除了ES,使用比较广泛的开源搜索引擎还有Solr,Solr也基于Lucene,且专注在文本搜索。而ES的文本搜索确实不如Solr,ES主要专注于对分布式的支持,并且内置了服务发现组件Zen来维护集群状态,相对Solr(需要借助类似Zookeeper实现分布式)部署也更加轻量级。ES除了分析查询,还可以集成日志收集以及做分析处理。

消息队列

消息队列如前篇所述,可以作为很好的微服务解耦通信方式。在分布式集群的场景下,对于分布式下的最终一致性也可以提供技术基础保障。并且消息队列也可以用来处理流量削锋。

消息队列的对比在此不再赘述。目前公司使用的是阿里云的ONS。因为使用消息队列还是考虑用在对高可用以及易于管理、监控上的要求,所以选择了安全可靠的消息队列平台。

安全技术

安全性是做架构需要考虑的基础。互联网的环境复杂,保护好服务的安全,也是对用户的基本承诺。安全技术涉及到的范围比较广,本文选几个常见问题以及常用方式来简单介绍下。

服务实例安全

分布式集群本身就是对于服务实例安全的一种保障。一台服务器或者某一个服务实例出现问题的时候,负载均衡可以将请求转发到其他可用的服务实例。但很多企业是自建机房,而且是单机房的,这种布局其实比较危险。

因为服务器的备份容灾也得不到完整的保障。最怕的就是数据库也是在同一机房,主备全都在一起。不单是安全性得不到很高的保障,平常的运维花销也会比较大。而且需要注意配置防火墙安全策略。

如果可以,尽量使用一些高可用、高可伸缩的稳定性IaaS平台。

网络安全

  1. 预防网络攻击

目前主要的网络攻击有一下几种:

SQL注入:根据不同的持久层框架,应对策略不同。如果使用JPA,则只要遵循JPA的规范,基本不用担心。
XSS攻击:做好参数的转义处理和校验。具体参考XSS预防
CSRF攻击:做好Http的Header信息的Token、Refer验证。具体参考CSRF预防
DDOS攻击:大流量的DDoS攻击,一般是采用高防IP。也可以接入一些云计算平台的高防IP。

以上只是列举了几种常见的攻击,想要深入了解的可以多看看REST安全防范表。在网络安全领域,一般很容易被初创企业忽视,如果没有一个运维安全团队,最好使用类似阿里云-云盾之类的产品。省心省成本。

  1. 使用安全协议

这个不用多说,无论是对于使用Restful API的微服务通信,还是使用的CDN或者使用的DNS服务。涉及到Http协议的,建议都统一使用Https。无论是什么规模的应用,都要防范流量劫持,否则将会给用户带来很不好的使用体验。

  1. 鉴权

关于微服务的鉴权前面API Gateway已经有介绍。除了微服务本身之外,我们使用的一些如Mysql,Redis,Elasticsearch,Eureka等服务,也需要设置好鉴权,并且尽量通过内网访问。不要对外暴露过多的端口。对于微服务的API Gateway,除了鉴权,最好前端通过Nginx反向代理来请求API层。

日志采集、监控

基于容器技术的微服务的监控体系面临着更复杂的网络、服务环境。日志采集、监控如何能对微服务减少侵入性、对开发者更透明,一直是很多微服务的DevOps在不断思考和实践的。

  1. 微服务日志的采集

微服务的API层的监控,需要从API Gateway到每个微服务的调用路径的跟踪,采集以及分析。使用Rest API的话,为了对所有请求进行采集,可以使用Spring Web的OncePerRequestFilter对所有请求进行拦截,在采集日志的时候,也最好对请求的rt进行记录。

除了记录access,request等信息,还需要对API调用进行请求跟踪。如果单纯记录每个服务以及Gateway的日志,那么当Gateway Log出现异常的时候,就不知道其具体是微服务的哪个容器实例出现了问题。如果容器达到一定数量,也不可能排查所有容器以及服务实例的日志。比较简单的解决方式就是对log信息都append一段含有容器信息的、唯一可标识的Trace串。

日志采集之后,还需要对其进行分析。如果使用E.L.K的技术体系,就可以灵活运用Elasticsearch的实时分布式特性。Logstash可以进行日志进行收集、分析,并将数据同步到Elasticsearch。Kibana结合Logstash和ElasticSearch,提供良好的便于日志分析的WebUI,增强日志数据的可视化管理。

对于数据量大的日志的采集,为了提升采集性能,需要使用上文提到的消息队列。优化后的架构如下:


A10.png



  1. 基础服务的调用日志采集

通过对微服务的所有Rest API的日志采集、分析可以监控请求信息。

在服务内部,对于中间件、基础设施(包括Redis,Mysql,Elasticsearch等)调用的性能的日志采集和分析也是必要的。

对于中间件服务的日志采集,我们目前可以通过动态代理的方式,对于服务调用的如cache、repository(包括搜索和DB)的基础方法,进行拦截及回调日志记录方法。

具体的实现方式可以采用字节码生成框架ASM,关于方法的逻辑注入,可以参考之前写的一篇ASM(四) 利用Method 组件动态注入方法逻辑,如果觉得ASM代码不太好维护,也可以使用相对API友好的Cglib。

架构五要素:

最后,结合架构核心的五要素来回顾下我们在搭建Docker微服务架构使用的技术体系:

高性能
消息队列、RxJava异步并发、分布式缓存、本地缓存、Http的Etag缓存、使用Elasticsearch优化查询、CDN等等。
可用性
容器服务集群、RxJava的熔断处理、服务降级、消息的幂等处理、超时机制、重试机制、分布式最终一致性等等。
伸缩性
服务器集群的伸缩、容器编排Kubernetes、数据库分库分表、Nosql的线性伸缩、搜索集群的可伸缩等等。
扩展性
基于Docker的微服务本身就是为了扩展性而生!
安全性
JPA/Hibernate,SpringSecurity、高防IP、日志监控、Https、Nginx反向代理、HTTP/2.0等等。

小结

对于服务集群的解决方案,其实无论是微服务架构或者SOA架构,都是比较通用的。只是对于一些中间件集群的搭建,可以使用Docker。一句Docker ps就可以很方便查询运行的服务信息,并且升级基础服务也很方便。

对于优秀的集群架构设计的追求是永无止境的。在跟很多创业公司的技术朋友们接触下来,大家都是比较偏向于快速搭建以及开发、发布服务。然而一方面也顾虑微服务的架构会比较复杂,杀鸡用牛刀。但是微服务本身就是一种敏捷模式的优秀实践。这些朋友往往会在业务飞速发展的时候面临一个困扰,就是服务拆分,数据库的分库分表、通过消息去解耦像面条一样的同步代码,想要优化性能但是无从下手的尴尬。

相关文档

Apache Thrift
使用Mesos和Marathon管理Docker集群
基于docker-swarm搭建持续集成集群服务
Kubernetes中文文档

后记

本文主要是对于Docker的微服务实践进行技术方案选型以及介绍。不同的业务、团队可能会适合不通过的架构体系和技术方案。

作为架构师应该结合公司近期、长期的战略规划,进行长远的布局。最起码基础的架构也是需要能支撑3年发展,期间可以不断引入新的技术并进行服务升级和持续的代码层重构。

也许一个架构师从0开始搭建一整套体系并不需要花费多久时间,最需要其进行的就是不断在团队推行Domain-Driven Design。并且使团队一起遵循Clean Code,进行敏捷开发OvO

追求极简:Docker镜像构建演化史

老李 发表了文章 • 0 个评论 • 1941 次浏览 • 2018-04-08 19:46 • 来自相关话题

自从2013年dotCloud公司(现已改名为Docker Inc)发布Docker容器技术以来,到目前为止已经有四年多的时间了。这期间Docker技术飞速发展,并催生出一个生机勃勃的、以轻量级容器技术为基础的庞大的容器平台生态圈。作为Docker三大核心技术 ...查看全部
自从2013年dotCloud公司(现已改名为Docker Inc)发布Docker容器技术以来,到目前为止已经有四年多的时间了。这期间Docker技术飞速发展,并催生出一个生机勃勃的、以轻量级容器技术为基础的庞大的容器平台生态圈。作为Docker三大核心技术之一的镜像技术在Docker的快速发展之路上可谓功不可没:镜像让容器真正插上了翅膀,实现了容器自身的重用和标准化传播,使得开发、交付、运维流水线上的各个角色真正围绕同一交付物,“test what you write, ship what you test”成为现实。

E1.png



对于已经接纳和使用Docker技术在日常开发工作中的开发者而言,构建Docker镜像已经是家常便饭。但如何更高效地构建以及构建出Size更小的镜像却是很多Docker技术初学者心中常见的疑问,甚至是一些老手都未曾细致考量过的问题。本文将从一个Docker用户角度来阐述Docker镜像构建的演化史,希望能起到一定的解惑作用。

一、镜像:继承中的创新

谈镜像构建之前,我们先来简要说下镜像。

Docker技术本质上并不是新技术,而是将已有技术进行了更好地整合和包装。内核容器技术以一种完整形态最早出现在Sun公司的Solaris操作系统上,Solaris是当时最先进的服务器操作系统。2005年Sun发布了Solaris Container技术,从此开启了内核容器之门。

2008年,以Google公司开发人员为主导实现的Linux Container(即LXC)功能在被merge到Linux内核中。LXC是一种内核级虚拟化技术,主要基于Namespaces和Cgroups技术,实现共享一个操作系统内核前提下的进程资源隔离,为进程提供独立的虚拟执行环境,这样的一个虚拟的执行环境就是一个容器。本质上说,LXC容器与现在的Docker所提供容器是一样的。Docker也是基于Namespaces和Cgroups技术之上实现的,Docker的创新之处在于其基于Union File System技术定义了一套容器打包规范,真正将容器中的应用及其运行的所有依赖都封装到一种特定格式的文件中去,而这种文件就被称为镜像(即image),原理见下图(引自Docker官网):

W1.png


图1:Docker镜像原理

镜像是容器的“序列化”标准,这一创新为容器的存储、重用和传输奠定了基础。并且“坐上了巨轮”的容器镜像可以传播到世界每一个角落,这无疑助力了容器技术的飞速发展。

与Solaris Container、LXC等早期内核容器技术不同,Docker为开发者提供了开发者体验良好的工具集,这其中就包括了用于镜像构建的Dockerfile以及一种用于编写Dockerfile领域特定语言。采用Dockerfile方式构建成为镜像构建的标准方法,其可重复、可自动化、可维护以及分层精确控制等特点是采用传统采用docker commit命令提交的镜像所不能比拟的。

二、“镜像是个筐”:初学者的认知

“镜像是个筐,什么都往里面装” – 这句俏皮话可能是大部分Docker初学者对镜像最初认知的真实写照。这里我们用一个例子来生动地展示一下。我们将httpserver.go这个源文件编译为httpd程序并通过镜像发布,考虑到被编译的源码并非本文重点,这里使用了一个极简的demo代码:

//httpserver.go

package main

import (
"fmt"
"net/http"
)

func main() {
fmt.Println("http daemon start")
fmt.Println(" -> listen on port:8080")
http.ListenAndServe(":8080", nil)
}

接下来,我们来编写一个用于构建目标image的Dockerfile:

From ubuntu:14.04

RUN apt-get update \
&& apt-get install -y software-properties-common \
&& add-apt-repository ppa:gophers/archive \
&& apt-get update \
&& apt-get install -y golang-1.9-go \
git \
&& rm -rf /var/lib/apt/lists/*

ENV GOPATH /root/go
ENV GOROOT /usr/lib/go-1.9
ENV PATH="/usr/lib/go-1.9/bin:${PATH}"

COPY ./httpserver.go /root/httpserver.go
RUN go build -o /root/httpd /root/httpserver.go \
&& chmod +x /root/httpd

WORKDIR /root
ENTRYPOINT ["/root/httpd"]
构建这个Image:

# docker build -t repodemo/httpd:latest .
//...构建输出这里省略...

# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
repodemo/httpd latest 183dbef8eba6 2 minutes ago 550MB
ubuntu 14.04 dea1945146b9 2 months ago 188MB
整个镜像的构建过程因环境而定。如果您的网络速度一般,这个构建过程可能会花费你10多分钟甚至更多。最终如我们所愿,基于repodemo/httpd:latest这个镜像的容器可以正常运行:

# docker run repodemo/httpd
http daemon start
-> listen on port:8080

一个Dockerfile最终生产出一个镜像。Dockerfile由若干Command组成,每个Command执行结果都会单独形成一个layer。我们来探索一下构建出来的镜像:

# docker history 183dbef8eba6
IMAGE CREATED CREATED BY SIZE COMMENT
183dbef8eba6 21 minutes ago /bin/sh -c #(nop) ENTRYPOINT ["/root/httpd"] 0B
27aa721c6f6b 21 minutes ago /bin/sh -c #(nop) WORKDIR /root 0B
a9d968c704f7 21 minutes ago /bin/sh -c go build -o /root/httpd /root/h... 6.14MB
... ...
aef7700a9036 30 minutes ago /bin/sh -c apt-get update && apt-get... 356MB
.... ...
2 months ago /bin/sh -c #(nop) ADD file:8f997234193c2f5... 188MB

我们去除掉那些Size为0或很小的layer,我们看到三个size占比较大的layer,见下图:

W2.png


图2:Docker镜像分层探索

虽然Docker引擎利用r缓存机制可以让同主机下非首次的镜像构建执行得很快,但是在Docker技术热情催化下的这种构建思路让docker镜像在存储和传输方面的优势荡然无存,要知道一个ubuntu-server 16.04的虚拟机ISO文件的大小也就不过600多MB而已。
三、”理性的回归”:builder模式的崛起

Docker使用者在新技术接触初期的热情“冷却”之后迎来了“理性的回归”。根据上面分层镜像的图示,我们发现最终镜像中包含构建环境是多余的,我们只需要在最终镜像中包含足够支撑httpd运行的运行环境即可,而base image自身就可以满足。于是我们应该去除不必要的中间层:


W3.png


图3:去除不必要的分层

现在问题来了!如果不在同一镜像中完成应用构建,那么在哪里、由谁来构建应用呢?至少有两种方法:

在本地构建并COPY到镜像中;
借助构建者镜像(builder image)构建。
不过方法1本地构建有很多局限性,比如:本地环境无法复用、无法很好融入持续集成/持续交付流水线等。借助builder image进行构建已经成为Docker社区的一个最佳实践,Docker官方为此也推出了各种主流编程语言的官方base image,比如:go、java、node、python以及ruby等。借助builder image进行镜像构建的流程原理如下图:


W4.png


图4:借助builder image进行镜像构建的流程图

通过原理图,我们可以看到整个目标镜像的构建被分为了两个阶段:

第一阶段:构建负责编译源码的构建者镜像;
第二阶段:将第一阶段的输出作为输入,构建出最终的目标镜像。
我们选择golang:1.9.2作为builder base image,构建者镜像的Dockerfile.build如下:

// Dockerfile.build

FROM golang:1.9.2

WORKDIR /go/src
COPY ./httpserver.go .

RUN go build -o httpd ./httpserver.go
执行构建:

# docker build -t repodemo/httpd-builder:latest -f Dockerfile.build .
构建好的应用程序httpd放在了镜像repodemo/httpd-builder中的/go/src目录下,我们需要一些“胶水”命令来连接两个构建阶段,这些命令将httpd从构建者镜像中取出并作为下一阶段构建的输入:

# docker create --name extract-httpserver repodemo/httpd-builder
# docker cp extract-httpserver:/go/src/httpd ./httpd
# docker rm -f extract-httpserver
# docker rmi repodemo/httpd-builder
通过上面的命令,我们将编译好的httpd程序拷贝到了本地。下面是目标镜像的Dockerfile:

//Dockerfile.target
From ubuntu:14.04

COPY ./httpd /root/httpd
RUN chmod +x /root/httpd

WORKDIR /root
ENTRYPOINT ["/root/httpd"]
接下来我们来构建目标镜像:

# docker build -t repodemo/httpd:latest -f Dockerfile.target .
我们来看看这个镜像的“体格”:

# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
repodemo/httpd latest e3d009d6e919 12 seconds ago 200MB
200MB!目标镜像的Size降为原来的 1/2 还多。

四、“像赛车那样减去所有不必要的东西”:追求最小镜像

前面我们构建出的镜像的Size已经缩小到200MB,但这还不够。200MB的“体格”在我们的网络环境下缓存和传输仍然很难令人满意。我们要为镜像进一步减重,减到尽可能的小,就像赛车那样,为了能减轻重量将所有不必要的东西都拆除掉:我们仅保留能支撑我们的应用运行的必要库、命令,其余的一律不纳入目标镜像。当然不仅仅是Size上的原因,小镜像还有额外的好处,比如:内存占用小,启动速度快,更加高效;不会因其他不必要的工具、库的漏洞而被攻击,减少了“攻击面”,更加安全。


W5.png


图5:目标镜像还能更小些吗?

一般应用开发者不会从scratch镜像从头构建自己的base image以及目标镜像的,开发者会挑选适合的base image。一些“蝇量级”甚至是“草量级”的官方base image的出现为这种情况提供了条件。


W6.png


图6:一些base image的Size比较(来自imagelayers.io截图)

从图中看,我们有两个选择:busybox和alpine。

单从image的size上来说,busybox更小。不过busybox默认的libc实现是uClibc,而我们通常运行环境使用的libc实现都是glibc,因此我们要么选择静态编译程序,要么使用busybox:glibc镜像作为base image。

而 alpine image 是另外一种蝇量级 base image,它使用了比 glibc 更小更安全的 musl libc 库。 不过和 busybox image 相比,alpine image 体积还是略大。除了因为 musl比uClibc 大一些之外,alpine还在镜像中添加了自己的包管理系统apk,开发者可以使用apk在基于alpine的镜像中添 加需要的包或工具。因此,对于普通开发者而言,alpine image显然是更佳的选择。不过alpine使用的libc实现为musl,与基于glibc上编译出来的应用程序不兼容。如果直接将前面构建出的httpd应用塞入alpine,在容器启动时会遇到下面错误,因为加载器找不到glibc这个动态共享库文件:

standard_init_linux.go:185: exec user process caused "no such file or directory"
对于Go应用来说,我们可以采用静态编译的程序,但一旦采用静态编译,也就意味着我们将失去一些libc提供的原生能力,比如:在linux上,你无法使用系统提供的DNS解析能力,只能使用Go自实现的DNS解析器。

我们还可以采用基于alpine的builder image,golang base image就提供了alpine 版本。 我们就用这种方式构建出一个基于alpine base image的极小目标镜像。


W7.png


图7:借助 alpine builder image 进行镜像构建的流程图

我们新建两个用于 alpine 版本目标镜像构建的 Dockerfile:Dockerfile.build.alpine 和Dockerfile.target.alpine:

//Dockerfile.build.alpine
FROM golang:alpine

WORKDIR /go/src
COPY ./httpserver.go .

RUN go build -o httpd ./httpserver.go

// Dockerfile.target.alpine
From alpine

COPY ./httpd /root/httpd
RUN chmod +x /root/httpd

WORKDIR /root
ENTRYPOINT ["/root/httpd"]

构建builder镜像:

# docker build -t repodemo/httpd-alpine-builder:latest -f Dockerfile.build.alpine .

# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
repodemo/httpd-alpine-builder latest d5b5f8813d77 About a minute ago 275MB
执行“胶水”命令:

# docker create --name extract-httpserver repodemo/httpd-alpine-builder
# docker cp extract-httpserver:/go/src/httpd ./httpd
# docker rm -f extract-httpserver
# docker rmi repodemo/httpd-alpine-builder
构建目标镜像:

# docker build -t repodemo/httpd-alpine -f Dockerfile.target.alpine .

# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
repodemo/httpd-alpine latest 895de7f785dd 13 seconds ago 16.2MB
16.2MB!目标镜像的Size降为不到原来的十分之一。我们得到了预期的结果。

五、“要有光,于是便有了光”:对多阶段构建的支持

至此,虽然我们实现了目标Image的最小化,但是整个构建过程却是十分繁琐,我们需要准备两个Dockerfile、需要准备“胶水”命令、需要清理中间产物等。作为Docker用户,我们希望用一个Dockerfile就能解决所有问题,于是就有了Docker引擎对多阶段构建(multi-stage build)的支持。注意:这个特性非常新,只有Docker 17.05.0-ce及以后的版本才能支持。

现在我们就按照“多阶段构建”的语法将上面的Dockerfile.build.alpine和Dockerfile.target.alpine合并到一个Dockerfile中:

//Dockerfile

FROM golang:alpine as builder

WORKDIR /go/src
COPY httpserver.go .

RUN go build -o httpd ./httpserver.go

From alpine:latest

WORKDIR /root/
COPY --from=builder /go/src/httpd .
RUN chmod +x /root/httpd

ENTRYPOINT ["/root/httpd"]
Dockerfile的语法还是很简明和易理解的。即使是你第一次看到这个语法也能大致猜出六成含义。与之前Dockefile最大的不同在于在支持多阶段构建的Dockerfile中我们可以写多个“From baseimage”的语句了,每个From语句开启一个构建阶段,并且可以通过“as”语法为此阶段构建命名(比如这里的builder)。我们还可以通过COPY命令在两个阶段构建产物之间传递数据,比如这里传递的httpd应用,这个工作之前我们是使用“胶水”代码完成的。

构建目标镜像:

# docker build -t repodemo/httpd-multi-stage .

# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
repodemo/httpd-multi-stage latest 35e494aa5c6f 2 minutes ago 16.2MB
我们看到通过多阶段构建特性构建的Docker Image与我们之前通过builder模式构建的镜像在效果上是等价的。

六、来到现实

沿着时间的轨迹,Docker 镜像构建走到了今天。追求又快又小的镜像已成为了 Docker 社区 的共识。社区在自创 builder 镜像构建的最佳实践后终于迎来了多阶段构建这柄利器,从此构建 出极简的镜像将不再困难。

“金三”之2018一线互联网公司Java高级面试题总结

老李 发表了文章 • 0 个评论 • 1217 次浏览 • 2018-03-31 20:40 • 来自相关话题

1、hashcode相等两个类一定相等吗?equals呢?相反呢? 2、介绍一下集合框架? 3、hashmap hastable 底层实现什么区别?hashtable和concurrenthashtable呢 ...查看全部
1、hashcode相等两个类一定相等吗?equals呢?相反呢?

2、介绍一下集合框架?

3、hashmap hastable 底层实现什么区别?hashtable和concurrenthashtable呢?

4、hashmap和treemap什么区别?低层数据结构是什么?

5、线程池用过吗都有什么参数?底层如何实现的?

6、sychnized和Lock什么区别?sychnize 什么情况情况是对象锁? 什么时候是全局锁为什么?

7、ThreadLocal 是什么底层如何实现?写一个例子呗?

8、volitile的工作原理?

9、cas知道吗如何实现的?

10、请用至少四种写法写一个单例模式?

JVM

1、请介绍一下JVM内存模型??用过什么垃圾回收器都说说呗

2、线上发送频繁full gc如何处理? CPU 使用率过高怎么办?

如何定位问题?如何解决说一下解决思路和处理方法

3、知道字节码吗?字节码都有哪些?Integer x =5,int y =5,比较x =y 都经过哪些步骤?

4、讲讲类加载机制呗都有哪些类加载器,这些类加载器都加载哪些文件?

手写一下类加载Demo

5、知道osgi吗? 他是如何实现的???

6、请问你做过哪些JVM优化?使用什么方法达到什么效果???

7、classforName("java.lang.String")和String classgetClassLoader() LoadClass("java.lang.String") 什么区别啊??


A1.png




Spring

1、spring都有哪些机制啊AOP底层如何实现的啊IOC呢??

2、cgLib知道吗?他和jdk动态代理什么区别?手写一个jdk动态代理呗?

数据库

1、使用mysq1索引都有哪些原则? ?索引什么数据结构? 3+tree 和B tree 什么区别?

2、mysq1有哪些存储引擎啊?都有啥区别? 要详细!

3、设计高并发系统数据库层面该怎么设计??数据库锁有哪些类型?如何实现呀?

4、数据库事务有哪些?

分库分表

1、如何设计可以动态扩容缩容的分库分表方案?

2、用过哪些分库分表中间件,有啥优点和缺点?讲一下你了解的分库分表中间件的底层实现原理?

3、我现在有一个未分库分表的系统,以后系统需分库分表,如何设计,让未分库分表的系统动态切换到分库分表的系统上???TCC? 那若出现网络原因,网络连不通怎么办啊???

4、分布式事务知道吗? 你们怎么解决的?

5、为什么要分库分表啊???

6、分布式寻址方式都有哪些算法知道一致性hash吗?手写一下java实现代码??你若userId取摸分片,那我要查一段连续时间里的数据怎么办???

7、如何解决分库分表主键问题有什么实现方案??

分布式缓存

1、redis和memcheched 什么区别为什么单线程的redis比多线程的memched效率要高啊?

2、redis有什么数据类型都在哪些场景下使用啊?

3、reids的主从复制是怎么实现的redis的集群模式是如何实现的呢redis的key是如何寻址的啊?

4、使用redis如何设计分布式锁?使用zk可以吗?如何实现啊这两种哪个效率更高啊??

5、知道redis的持久化吗都有什么缺点优点啊? ?具体底层实现呢?

6、redis过期策略都有哪些LRU 写一下java版本的代码吧??

分布式服务框架

1、说一下dubbo的实现过程注册中心挂了可以继续通信吗??

2、zk原理知道吗zk都可以干什么Paxos算法知道吗?说一下原理和实现??

3、dubbo支持哪些序列化协议?hessian 说一下hessian的数据结构PB知道吗为啥PB效率是最高的啊??

4、知道netty吗'netty可以干嘛呀NIO,BIO,AIO 都是什么啊有什么区别啊?

5、dubbo复制均衡策略和高可用策略都有哪些啊动态代理策略呢?

6、为什么要进行系统拆分啊拆分不用dubbo可以吗'dubbo和thrift什么区别啊?

分布式消息队列

1、为什么使用消息队列啊消息队列有什么优点和缺点啊?

2、如何保证消息队列的高可用啊如何保证消息不被重复消费啊

3、kafka ,activemq,rabbitmq ,rocketmq都有什么优点,缺点啊???

4、如果让你写一个消息队列,该如何进行架构设计啊?说一下你的思路

分布式搜索引擎

1、es的工作过程实现是如何的?如何实现分布式的啊

2、es在数据量很大的情况下( 数十亿级别)如何提高查询效率啊?

3、es的查询是一个怎么的工作过程?底层的lucence介绍一下呗倒排索引知道吗?es和mongdb什么区别啊都在什么场景下使用啊?

高并发高可用架构设计

1、如何设计一个高并发高可用系统

2、如何限流?工程中怎么做的,说一下具体实现

3、缓存如何使用的缓存使用不当会造成什么后果?

4、如何熔断啊?熔断框架都有哪些?具体实现原理知道吗?

5、如何降级如何进行系统拆分,如何数据库拆分????


A2.png



通信协议

1、说一下TCP 'IP四层?

2、http的工作流程?? ?http1.0 http1.1http2.0 具体哪些区别啊?

3、TCP三次握手,四层分手的工作流程画一下流程图为什么不是四次五次或者二次啊?

4、画一下https的工作流程?具体如何实现啊?如何防止被抓包啊??

算法

1、比较简单,我一个文件,有45亿个阿拉伯数字,如何进行去重啊如何找出最大的那个数啊?

数据结构

1、二叉树和红黑树等。

源码中所用到的经典设计思想及常用设计模式

A3.png
Java是一种简单的,面向对象的,分布式的,解释型的,健壮安全的,结构中立的,可移植的,性能优异、多线程的动态语言。