花钱的年华

江南白衣,公众号:春天的旁边

唯品会的Service Mesh三年进化史

| Filed under 工作 技术

前言

今年在Goolge的推动,和K8S社区的惯性力量下,Service Mesh的风很大。蓦然回首,唯品会的服务化体系OSP(Open Service Platform) 在三年前就走上了ServiceMesh的路,一股淡淡的自豪,和对定下基调的前老板的佩服。

每种架构风格,由于各司不同的历史因果,现实状况,以及对采纳新架构的诉求和希望,都会有不同的实现路线。我们的SM在实战中一点点演化而来,与 Istio由谷歌IBM的超级架构师们画出来的架构也有不同之处。希望通过分享我们的演进过程,能给各司里同样在往SM演进中的架构师们一个参考。

 

SM演进史

一、标准服务化体系阶段

如果用两根手指将Local Proxy 和 Remote Proxy的框框按住,就是个标准的服务化框架。

传输与序列化层

当时还没有GRPC,自己拿Netty撸了一个TCP长连接,多路复用,异步IO的传输层,基于Thrift协议的序列化。

当然,这个组合从性能上现在看也不过时,自定义TCP不比HTTP2弱, Thrift 也比ProtocolBuffer略快,我们还支持基于Java类来定义Thrift接口,而不是原生Thrift,PB的有额外学习成本的中立语言,完美。

缺点嘛,就是跨语言时会略尴尬,虽然原生的Thrift也在某种程度上跨语言,但我们除了保留Thrift序列化协议外,其他从头到脚都重新实现了,也就不再能从它那借力。

服务注册中心

当时的大环境下,ZooKeeper也是比较流行的注册中心选择,不过最近在做去ZK化,改为自研的Http Api Server,数据用Redis或 Etcd存储。

API网关

当然要有入口网关,将外网的HTTP请求,经过认证,安全防刷, 再转换为OSP调用后端服务。

服务路由

服务的注册发现,负载均衡,路由定制,机房策略等路由策略,超时,重试,熔断等可用性策略,我们统称为路由逻辑。

我司的主力语言,前端是PHP 和 Java,后端是Java,在调用端有着硬性的跨语言需求。

SM第一步

上述的路由逻辑,如果在每种语言实现一次客户端,显然是不合算的。为了PHP,将这些逻辑抽取成独立的Proxy。

SM第二步

这个独立的Proxy,是部署成传统的集中式的Proxy集群,还是当时还不很著名的本地SideCar模式呢?

在公司的体量下,有着强烈的去中心化的愿望,所以我们选了SideCar模式。

SM第三步

PHP使用了Proxy,那Java呢,继续像dubbo一样在客户端里可以吗?

这个当时有比较大的争议,因为毕竟多经过了一层节点,虽然本地连接不经过网络,也很难说性能毫无影响。 为此我们还专门做了个基于Unix Domain Socket的版本,但后来觉得性能足够,一直没上线。

最后,老板力排众议,一是为了架构统一性,二是因为我们的Proxy功能还不成熟,而使用它的应用又特别多,如果不独立出来,很难推动快速升级。所以统一使用了Proxy模式。

SM第四步

SideCar模式虽好,但是存在单点的问题,如果SideCar在升级,或者挂掉了怎么办?

SideCar升级,可以联动同一台机上的应用先摘除流量。
SideCar挂掉,可以搞个脚本把它自动重新拉起。但重新拉起的间隔里还是会丢失的请求,如果重新拉起还是失败呢?

作为一个架构师,高可用的观念是深入到骨子里的,所以我们又在每个机房搭建了一个Remote Proxy集群,并在客户端里的SDK加入了如下的逻辑:

“如果本地Proxy不可用或宣称自己准备关闭,就将请求无损转发到Remote Proxy,再启动一个监视器观察本地Proxy什么时候重新可用。”

至此,一个封闭的服务化框架完备了,我们有了标准的服务化体系。能力上,也比当时久不更新的Dubbo,还有后来的Netflix,SpringCloud要强不少。

 

二,按不住的多语言的客户端接入

时间匆匆过, 一个公司里,总是按不住会有更多的语言出现,比如Node.js, C++, Go,像前面说的,再撸一次TCP/Thrift 感觉有点累了,这些也不是主流语言,花太多时间在上面不值得。 所以,我们在Proxy上额外支持了HTTP/JSON的传输层,再根据注册中心里的元数据,重新序列化成Thrift来调用后端服务就好了。

我们还发现,在PHP里用Thrift,还不如它内置的C写的HTTP/JSON库快。

不过,我们也不想在这些客户端再实现一次本地SideCar与Remote Proxy的切换逻辑了,反正这些非主流语言的客户端的调用量不会很大,就通通去调用Remote Proxy集群好了。哪天撑不住时,可能会改用前面说的不那么完美的方案。

至此,多语言的客户端完全咩有接入限制了。

 

三, 普通Web应用也想零成本变身服务化

时间又匆匆过,又有大量原来的Web应用,不想改造成OSP应用,又想作为一个服务,或者接入API网关,或者加入服务化大家庭,享受各种服务治理的能力。

为此,我们开发了一个很轻量的注册器,也是以sidecar的形式运行,不断的对Web应用的做健康检查,与注册中心进行注册,心跳,和反注册。

当然,免费的午餐肯定没那么好吃,比如没有了基于IDL生成SDK的与客户端的契约化编程,没有了高效的传输层序列化层,没有了限流,自动隔离线程池,ClassLoader预热,闲时主动GC等等细微的Runtime加强。

至此,多语言的服务端也完全咩有问题了。

 

四、容器化了,容器化了

为了容器化,sidecar跑在哪里,又成了问题。 如果跑在每一个Pod里,一来呢,身为Java应用,堆内堆外内存吃得有点多;二来呢,升级Proxy时,又要重新发布每一个应用;

所以,我们选择了DaemonSet的形式,每台宿主机上只运行一个Proxy,Proxy启动时把自己的IP写在一个共享文件里,这个文件也Mount进各个容器里面,各个客户端会监瑞脑消金兽听这个文件。

为了隔离性,Proxy加了个来源IP的限流,效果就是单个容器的调用高于2万QPS时,第二万零一个请求开始就把它临时重定向到Remote Proxy集群。 十秒钟后再重试本地Proxy,如果还是高,又继续打发过去。

另一个改动,我们之前基于IP来定义路由规则( 比如把一些消耗较大的接口,都发送到隔离的三台机器上)。容器里IP不固定了怎么办?

我们引入了一个部署池的概念,比如一个应用有两个部署池,一个3台机器,另一个20台机器,这个部署池的名字,会注册为服务实例的元信息。在服务路由里就用这个池的名字来定义规则。

Istio 的区别

1. Server端无入口Agent

Server端前面也摆一个Agent,全部流量都流经它,有点重啊。当初在Client前摆一个Agent都争半天了,真的要再来一个?

如果已经是OSP应用,当然没必要摆这个Agent了。如果是一个希望零成本变身的Web应用,那我们回顾一下服务端要做的事情:

一是服务注册和心跳。 我们在应用旁边摆了个轻量级的注册与心跳器来实现。

二是分布式调用链记录。分布式调用链监控原本就支持Web应用呀,不需要额外的Agent。

三是服务端授权,服务端限流之类必须在服务端进行的服务治理,相对没那么重要与常用,真的需要要时同样以Servlet WebFilter 或多语言SDK包方式提供。

所以,这个偏重的Agent暂时没有太大必要。

 

2. Client端不基于IPTable劫持

为了客户端零改造成本,Istio里基于IPTable,将Client发出的所有请求劫持到Proxy上。但IPTable的性能一般,尤其是一个环境里有很多应用时,性能更加跌得很厉害。所以我们就不节约客户端的一点点改造成本了。

基于SDK的访问,支持Local Proxy与Remote Proxy的完美切换,比IPTable更加的高可用。而如果某种语不想写这种复杂SDK,那要么全部打到本地Proxy(等于IPTable),要么全部直接访问 Remote Proxy集群。

 

3. 没有画大饼用的Mixer

为了Proxy实现的可替换性,Istio里将与基础设施相关的部分都提取到中央的Mixer里,连分布式调用链跟踪都分离出来太那啥了,每个请求发生前发生后都可能要调用一下这个Mixer,一个是性能,一个是中央化的容量瓶颈。

虽然理解Google架构师们为了画大饼的无奈,但自用体系,不需要考虑Proxy的可替换性时,还是把该下沉到Proxy的基础设施埋点给下沉下去。
 

4. SideCar以DaemonSet形式运行

因为是Java,堆内堆外要3G左右,所以每台宿主机只运行一个Proxy。 当然也为了升级Proxy方便,不用每次升级Proxy将全部应用的容器重新发布一遍。

 

5. 不依赖K8S和容器技术

这么一路走来,当然不会依赖于K8S。目前Istio们为了节约开发精力而完全依赖K8S,其实也大大局限了它们的适用范围,起码一个公司里如果混合物理机与容器双向调用的就难搞了。

 

6. 路由和路由是不一样的,熔断和熔断是不一样的

一个词能涵盖完全不同级别的实现。大家都说路由,但我觉得比如Dubbo,比如OSP,才算是真正的规则路由。

其他负载均衡,熔断,重试等等,都一个名字也可以做得差别极大。

by calvin | tags : | 2

快速,低成本,低扰动地运行一段Java代码

| Filed under 技术

JVM是个运行服务端应用的好VM,但如果你只是想频繁地运行一段Java写的脚本,或者在跑一些辅助性的Java程序比如监控,比如日志收集,这时候的诉求就和平日里的应用不一样了:

一、启动快速,动静小。
二、低成本,节约CPU、内存和线程。
三、低扰动,不干扰主应用的运行。

1. 从失败的取经开始

第一时间,看看jmap,jstack们用了什么参数,结果发现通通只有一个-Xms8m (在它们运行时,跑jps -v 可见,源码级确认JDK7见Makefile.launcher,JDK8见CompileLaunchers.gmk)。

另外,传说中的-client,在多核的Linux服务器上的也是无效的。

下面开始自己的折腾,首先给跑的脚本配上"-Xloggc:gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCApplicationStoppedTime", 在gc.log 里就会有实际启动参数,GC日志,以及结束时打印内存各代的占用。

其次,长期跑一个 pidstat -l 1| grep XXX 监控进程的CPU消耗

最后,jstack一下进程,看看里面跑着多少线程,一个默认的JVM,线程数多得吓人。

 

2. 类的加载和编译

2.1 -Xverify:none

来自优化Eclipse启动速度的经验,说关闭Java类加载验证可以加快10% -15%的启动速度。

2.2 设定编译级别

JIT编译之后的代码比解释执行字节码更快,更省CPU,但编译本身就需要CPU,也需要额外的编译线程。临时给启动脚本配上 “-XX:+PrintCompilation”,观察编译情况。

如果脚本的代码只简单的跑一次,比如vjtools里的vjmxcli,建议就不要进行JIT编译了,编译完了也用不上,直接解释执行就好。禁止它:-Djava.compiler=NONE

如果脚本会用于循环计算,比如vjtools里的vjmap,则建议打开多层编译,一开始就对运行到的方法进行静态编译,不用等方法被调用1万次。多层编译在JDK8默认打开,显式打开:-XX:+TieredCompilation。

但打开多层编译也会导致程序运行初期有较多的编译任务,吃比较多的CPU,可以显式关掉多层编译 -XX:-TieredCompilation来对比一下,综合其带来的性能提升,脚本的生命周期长短,以及额外的CPU支出来综合评价。

2.3 编译线程的设定

在24核服务器上,默认有4条C1编译线程,8条C2编译线程(多层编译下),考虑到低成本原则,建议把它设到最小的-XX:CICompilerCount=2。

2.4 未来黑科技-AOT

JIT不够酷,预先把代码编译(Ahead-of-Time,AOT) 更好。 JDK9里有一个Hotspot编译器组搞的试验性的jaotc,另一个选择是GraalVM全家桶里带的SubstrateVM,这个支持JDK8。两者都是基于Graal,看各位大大炫,但我还没玩过。

 

3. GC 设置

这些脚本,辅助进程一般不介意GC延时,建议使用吞度量最的串行收集算法 -XX:+UseSerialGC,大大减少了其他GC算法所产生的大量GC线程,保证自己GC时也不会影响到主应用。

如果依然想使用并行算法,就一定要设置GC线程数,如果按默认值,在24核机器上YGC和CMS GC的线程数分别是18和5,一旦发生GC,将占用大量的CPU核,直接对主应用产生巨大的影响。可设为 -XX :P arallelGCThreads=2 -XX:ConcGCThreads=1

 

4. 内存设置

JVM的堆内存扩张并没有想象中那么智能,当-Xms 与 -Xmx 不等,又没有指定新生代比例时,新生代大小更是混乱。建议根据运行后的实际占用及GC日志,完整设置一个最合适的值。

快速运行一次的脚本,老生代几乎没用,新生代可以设大些。

-Xms96m -Xmx96m -Xmn64m

长期运行的,则一般设为1:1。

-Xms256m -Xmx256m -XX:NewRatio=1

每条线程的内存从默认1M回到256k: -xss256k

永久代也可以一设,可惜JDK7/8的参数不兼容,用java -version 拿版本号太贵了,所以如果明确了JDK版本的话可以设置,否则就算了。

 

5. 其他

这些脚本,免不了和数字打交道,如果不保证没有int ->Integer的autoboxing,就还是打开-XX:AutoBoxCacheMax=20000

如果有其他更多黑科技,请大家补充。

6. 小结

谢谢你看到这里,这篇的题材比较偏门,幽僻处可有人行。

一段快速执行,关闭了JIT的脚本,启动命令大概长这个样子:

-Xms96m -Xmx96m -Xmn64m -Xss256k -XX:+UseSerialGC -Djava.compiler=NONE -Xverify:none -XX:AutoBoxCacheMax=20000

一段辅助程序,启动命令大概长这个样子

-Xms256m -Xmx256m -XX:NewRatio=1 -Xss256k -XX:+UseSerialGC -XX:-TieredCompilation -XX:CICompilerCount=2 -Xverify:none -XX:AutoBoxCacheMax=20000

VJTools几个工具的启动脚本,都按这个标准重写了一下。

入门科普,围绕JVM的各种外挂技术

| Filed under 技术

jstat, jmap, btrace, jprofiler, vjtools都基于什么实现? 对围绕JVM的各种工具的外挂技术,运用大整理术,让大家从茫然,到轻摇纸扇,知道分子。

归拢一下,就是C 和 Java两种Agent,SA 和 VirtualMachine 两种 Attach,JMX和PerfData两种Data,两两之间很是混淆,网上好像少了篇简明扼要的科普,所以自己操刀补了一篇,作为上周的直播《VJTools如何利用佛性技术完全JVM》的续集。

 

1. 两种Agent

1.1 Native Agent

以 C/C++代码编写的Agent,用强大的JVMTI(JVM Tool Interface)接口与JVM进行通讯,订阅感兴趣的JVM事件(比如方法出入、线程始末等等),当这些事件发生时,会回调Agent的代码。 JVMTI 同时提供了众多的功能函数,查询和控制 Java 应用的运行状态,包括内存控制和对象获取,线程与锁等等,简直无所不能。

使用者,包括各种Profile工具,如Yourkit,JProfiler,Aysnc-Profiler, 还有动态Reload Class而不重启应用的JRebel。

使用方式 ,可以在启动命令里加入 -agentlib: 或 -agentpath: /path/to/agent.so,也可以用后面讲的VituralMachine.attach()动态加载。

 

1.2 Java Agent

Java Agent的底层也是JVMTI ,但后门能力就只剩一个AOP 代码植入了:在加载class文件之前做拦截并对字节码做修改。比如AspectJ,单元测试覆盖率的Jacoco,动态重载Class的Spring-Loaded。

典型代码如下:

public class MyAgent {
public static void premain(String args, Instrumentation instrumentation){
ClassFileTransformer transformer = new MyClassWeaving();
instrumentation.addTransformer(transformer);
}
}

另一种逍遥的用法,就是为了随时让 “一段代码” 与 “主应用” 在同一JVM中运行,而不用修改主应用的代码去显式调用。

比如JMX的agent,启动一条TCP侦听线程响应JMX 请求。
比如jolokia的agent,启动一条Http侦听线程,响应Restful版的JMX请求。
比如btrace,启动一条TCP线程与btrace client通信,接收client发过来的脚本字节码,进行加载并输出结果。

它有两种启动方式:

一种是在启动时命令行加入 -javaagent:/path/to/agent.jar,根据agent.jar中的MANIFEST.MF文件中的Premain-Class定义,JVM找到相应的MyAgent类,调用其premain函数。

一种是通过后面讲的VM.attach()技术,在任意时刻由外部程序来灵活加载,调用其agentmain函数。

 

2.两种Attach

两种截然不同方式实现的Attach,本质上都是在跟踪程序与目标JVM之间建立一个沟通的管道,然后在跟踪程序使用特定的API去操作目标JVM。

2.1 Vitural Machine.attach()

跟踪程序通过Unix Domain Socket 与目标JVM的Attach Listener线程进行交互。 Socket 文件为/tmp/.java_pid$PID。

API 接口是com.sun.tools.attach.VirtualMachine 及其子类sun.tools.attach.HotSpotVirtualMachine,在tools.jar中,运行时需要依赖,可以做下面的事情:

● dumpHeap: jmap -dump 效果
● heapHisto: jmap -histo效果
● threadDump: jstack效果
● dataDump: kill-3 效果(jstack + jmap -heap)
● loadAgent: 动态加载C/Java Agent
● agentProperties: 获得已加载Agent的属性
● sytemProperties: 获得System Properties
● setFlag: 动态设置可写的JVM参数(但没几个是可写的)
● printFlag: 打印JVM 参数的值
● jcmd: 执行jcmd命令,具体能干啥见jcmd $PID help

可见, jmap, jmap,jcmd 们默认就是基于这个机制来做事情的。

VJTools的vjmxcli的特色之一,如果应用的启动脚本忘了设定启动JMX,可以根据PID attach到应用里,动态的把JMX Agent启动起来,然后通Agent属性获得本地连接地址,嗯, jconsole也是这么干的。

 

2.2 SA.attach()

著名的SA(Serviceability Agent),用于分析JVM运行时进程的Snapshot数据。Snapshot的意思,就是当SA 开始分析时,整个目标JVM是停顿下来不工作的,让SA可以从容读取进程内存中的数据,直到断开后才会恢复。所以在生产上使用这类工具时,必须先摘除流量。

这个神奇的操作,主要是通过系统调用ptrace实现。ptrace会使内核暂停目标进程并将控制权交给跟踪进程,使跟踪进程得以察看目标进程的内存,详见ptrace的man,所以在容器环境下,需要打开ptrace的安全权限。

API的接口,一个是 sun.jvm.hotspot.HotSpotAgent 负责attach, sun.jvm.hotspot.runtime.VM负责操作,在sa-jdi.jar中。

VM类从内存二进制信息中,提取出JVM内部数据结构,包括:

● 内存的getObjectHeap()/getUniverse()
● 处线程的getThreads()
● 永久代内容的getSymbolTable(),getStringTable(), getSystemDictionary()
● 还有很厉害的读内部Native对象值的getTypeDataBase()

jstack,jmap 们默认用前面的VM.attach()模式,与目标JVM的Attach Listener线程通信, 但如果目标JVM已经半死不活,Attach Listener线程无力响应请求时,就可以增加-F 参数,转而使用SA.attach 模式,用ptrace去暴力接管进程, 详细代码见sun.tools.jmap.JMap。看代码你还会发现,因为AttachListener支持的命令有限,所以jmap -heap 打印heap的总结信息时,也是以SA模式进去。

VJTools的vjmap是jmap的一个增强,能够独立各个分代中对象的数量大小统计,用以排除新生代对象的干扰,查找内存缓慢泄漏的问题,比如更直接找出Survior区里age>2的对象,里面就使用了VM类的观察者模式回调,和直接内存访问 两种方法来遍历。

SA模式比VM模式做相同事情时要慢一截,非必需时不要用它。还有,如果跟踪程序被kill-9 非正常退出,没有执行中断SA,目标JVM就会一直暂停在那里,Linux下可以执行kill -18 $PID 发送SIGCONT信号重新激活目标进程。

 

3. 两种Data

3.1 JMX

文章已太多,不再啰嗦。其中vjtools的vjtop,如何不停顿JVM的获得线程的CPU、内存信息,获得某条繁忙进程的StackTrace看看它在忙些什么,值得一看。

3.2 PerfData

很多人不知道的一个机制,JVM其实每秒都会将自己的大量统计数据,写入到 /tmp/hsperfdata_$username/$pid 文件中。

用下面指令可以感受下:

jcmd $PID PerfCounter.print

内容包括jvm的基本信息,内存,GC,线程数等等,还有一些JMX中没有暴露的数据,比如包含JVM中所有的停顿的SafePoint信息。

//线程的情况
java.threads.daemon=6
java.threads.live=7
...
// younggen的情况
sun.gc.generation.0.capacity=44695552
sun.gc.generation.0.maxCapacity=715784192
sun.gc.generation.0.minCapacity=44695552
....

jps,其实就是读取/tmp/hsperfdata_$username/ 目录下所有的文件。

jstat,同样是读取这个神秘的文件。一个很大的好处,就是它只默默读取文件,而不会像JMX那样要与应用程序交互,打扰应用程序的工作。

自己写代码也简单,使用 sun.management.counter.perf.PerfInstrumentation类即可,VJTools里的vjtop 从JMX,PerfData和/proc/pid 三处地方,综合打印JVM的概况和繁忙线程的情况,里面的PerfData类就是其使用的展示。

PerfData文件是mmap到内存中的,读写都很快,但每次写完还要更新磁盘上的文件元数据比如last modified time,如果遇上磁盘高IO,还是有概率造成JVM被锁定一段时间。所以我们以前通过-XX:+PerfDisableSharedMem禁止了perfdata的写入,不过现在又有点摇摆。

注意,perfdata 和 vm.attach 都需要在/tmp 目录读写文件,如果目标JVM的启动参数重新指定了临时目录,而跟踪程序依然去读取/tmp 目录,也会导致这些机制失效。

 

4. 小结

VJTools (https://github.com/vipshop/vjtools) 的三个工具,是上面的两种Attach, 两种Data的使用样本,大家可以直接阅读源码来加深印象,再顺手点个Star。