通过 JMXMP 实现对容器内 Java 应用的监控

通过 JMXMP 实现对容器内 Java 应用的监控

如何远程管理和监控 Java 应用

做 Java 开发的同学肯定有过使用 jconsole 或者 visualvm(JDK 自带的版本叫做 jvisualvm)等管理应用远程剖析 Java 应用服务的经历。仅需要在启动 Java 应用前简单的配置几个 JMX 参数即可启动相关功能,然后就可以根据配置通过前述的管理应用进行远程连接并进行剖析了。

相比 jconsole,我个人偏好使用 visualvm,它提供了丰富的插件,比如通过安装 Visual GC 插件可视化的观察应用的垃圾回收情况,通过 Threads Inspector 打印目标线程的栈等等。

如果是在裸机(bare mental)上运行 Java 应用,开启相关的 JMX 配置是很简单的,忽略安全相关的配置,在你的启动脚本中增加下面三个示例选项:

jmxremote.port=18082
jmxremote.rmi.port=18083
jmxremote.rmi.server.hostname=$(hostname -i)

就能通过 visualvm 远程连接到应用并进行管理了。

但是如果在基于 Kubernetes 管理的容器中运行 Java 应用,上述配置就不起作用了。

为什么呢?

接下来,我们先解释下通过 JMX 远程管理 Java 应用的原理,然后通过几个试验来分析为什么无法通过常规配置进行远程连接和管理,最后给出一个基于 jmxmp 协议的解决方案。

什么是 JMX

JMX(Java Management Extentions)技术提供了一系列工具,使用这些工具我们可以构建用于管理和监控设备与应用的解决方案。JMX 从 Java5 开始包含在 JDK 里发布。

JMX 是基于 MBean(Managed Bean)实现的。MBean 是一种 Java 对象,它描述了你要进行管理和监控的资源。MBean 注册到 MBean Server 里面,后者是一个运行在支持 Java 语言的设备上的代理。

一个 MBean Server(其上注册了一堆 MBeans) 和一组用于处理 MBeans 的服务就构成了一个 JMX agent。JMX agent 可以直接控制资源并且让这些资源可以被远程管理应用(如 jconsole 或则 visualvm)操控。

JMX 技术定义了一些标准的 connectors(叫做 JMX connectors),后者可以让开发者从远程管理应用(如 jconsole 后者 visualvm)中访问 JMX agents。JMX connectors 通过不同的通信协议实现了同样的管理接口,因此远程管理应用可以透明地管理资源,不管底层用的是哪种通信协议。如果你愿意,也可以实现自己的私有协议,只要这个协议实现了 Connector 相关的接口。幸运地是,JVM 已经基于 RMI 实现了一个标准的 connector,开箱即用。

下面用一张维基百科上的图1来总结上面描述的内容:

The JMX 3-level architecture

JMX 整个架构分为三层:
- Remote Mangement Level:由各种 Connectors 和 Adaptors 构成
- Agent Level:由 MBeanServer 以及注册到其上的各种 MBeans 构成
- Probe Level:由管理各类资源的 MBeans 构成

作为普通用户,我们只需关注 Remote Mangement Level,尤其跟 Connector 相关的部分。

为什么通过常规配置无法远程连接容器中的 Java 应用

下面将会通过四种情况的测试来解释为何常规配置无法远程连接和剖析容器中的 Java 应用。

下面试验均通过 visualvm 进行。

JMX 相关的三个配置的用途

为容器内应用启动脚本添加下述配置:

jmxremote.port=18082
jmxremote.rmi.port=18083
jmxremote.rmi.server.hostname=$(hostname -i)

在客户端机器运行 wireshark 抓包,然后通过 visualvm 针对 service:jmx:rmi:///jndi/rmi://10.1.135.24:18082/jmxrmi 发起连接。

test1

从上图可以看到,visualvm 连接上 rmi registry 端口 18082 后,该端口返回的 stub 所要连接的 endpoint 是 10.101.77.2:18083,此 ip 是应用在容器内部通过 hostname -i 获取到的 kubernetes 分配的集群 ip,而不是物理机 ip。容器内网络独立于物理机网络并且不通,当然连不上了。

本轮测试结论:rmi server 的 hostname 很关键,因为它会包含在被 rmi registry 返回的 stub 中,如果 visualvm 所在机器与该 hostname 不通,就无法连上,这是很显然地。还有一点需要注意,这个 hostname 只被用于 rmi server,也就是供返回给客户端的 stub 在后续调用,与 registry 没有任何关系。registry 的 port 只要预先在 Java 应用相关的 kubernetes pod yaml 中 export 给物理机,通过 [物理机 ip:registry port], visualvm 就可以连接上 registry。

hostname 配置的重要性

在测试 1 基础上将 hostname 改为物理机 ip:

jmxremote.port=18082
jmxremote.rmi.port=18083
jmxremote.rmi.server.hostname=10.1.135.24

此时使用 visualvm 针对 service:jmx:rmi://[ip:port]/jndi/rmi://10.1.135.24:18082/jmxrmi 发起连接时,是完全没问题的。注意,连接里面 [ip:port] 部分可以随便填写,因为根本就不会用,后面的 registry 相关的 ip 和 port 写对即可,具体 rmi 调用完全取决于 registry 端点返回的 stub 里面包含的 ip 和 port,而根据我们配置,stub 里面包含的 ip 就是 jmxremote.rmi.server.hostname 指定的 ip,port 就是 jmxremote.rmi.port 指定的 port。

本轮测试结论:rmi server 的 hostname 很关键,因为会包含在被 rmi registry 返回的 stub 中,如果 visualvm 所在机器与该 hostname 不通,就无法连上。将 rmi server hostname 改为物理机 ip,同时对应的 rmi port 被事先 export 给物理机,则可以在远程成功连接。

不配置 rmi.port 是否可以

在测试 2 基础上去掉了 rmi.port:

jmxremote.port=18082
jmxremote.rmi.server.hostname=10.1.135.24

抓包过滤器设置为 host 10.1.135.24 and port not 22,可以看到 stub ip 是对的,但是 port 38141 不是我们事先设置的而是随机分配的,而且该端口并没有事先被 export 给物理机(因为是随机的,我们事先也无法知道),所以无法通过 visualvm 远程进行连接,抓包见下图:

test3-1

我们进到容器中执行 netstat 可以确认 38141 端口确实是 rmi 相关的:

test3-2

接下来,我们尝试修改物理机的 iptables 来让这个端口暴露出来,在物理机上执行下面命令:

iptables -t nat -A DOCKER ! -i docker0 -p tcp -m tcp --dport 38141 -j DNAT --to-destination 10.101.77.2:38141

再次用 visualvm 发起连接会发现可以连接成功,同时用 wireshark 在本地抓包也可以看到三次握手成功,如下图:

test3-3

本轮测试结论:如果不指定容器的 rmi.port,Java 应用会随机生成一个,但是这个随机端口无法事先 export 给物理机(因为无法预知),此时就需要手动修改 iptables 来使其暴露,然后就可以远程连接成功了。

rmi.port 是否能与 registry port 使用同一个端口

在测试 3 基础上增加 rmi.port,而且端口与 registry port 一致:

jmxremote.port=18082
jmxremote.rmi.port=18082
jmxremote.rmi.server.hostname=10.1.135.24

在本机通过 visualvm 进行连接,发现可以成功连接。

结论:rmi.port 与 registry port 可以一样,这样在 Java 应用相关的 kubernetes pod yaml 中只 export 一个端口就可以了,这样配置维护起来更加简单。

四轮测试总结

通过上述四轮测试,我们可以得出以下结论:

只要让容器外部的远程管理应用如 visualvm 能够感知到容器内 Java 应用的 rmi registry port 以及 rmi registry 返回的 stub 中包含的 rmi server port 和 rmi server hostname 就可以了。

而且,针对两个 port, 我们可以使用同一个端口号,并针对不通的 Java 应用事先约定好,然后在物理机的 sysctl.conf 中进行保护。

现在 port 相关配置不是问题了,关键是如何让容器发现自己所在 node 的物理机 ip 以在应用启动前在 jmx.jmxremote.rmi.hostname 中进行配置。

由于容器都是由 kubernetes 进行弹性调度的,在成百上千台机器上去挨个判断 node 的 ip 然后修改 Java 应用的 jmx.jmxremote.rmi.hostname 配置或者挨个机器去修改 iptables 都是不现实的,后续扩容也不方便。

为了解决 rmi.server.hostanme 的感知问题,有两个解决办法:
1. 修改 Java 应用相关的 pod yaml,获取 node ip 并将其写入到容器内(通过全局变量或者 configmap 甚至配置文件等等),然后修改 Java 应用启动脚本,去获取前述配置并用其修改启动脚本的 jmxremote.rmi.server.hostname 赋值部分。
2. 使用 jmxmp 协议来替代默认的 RMI 协议实现远程管理和监控。

第一个方案修改已有的启动脚本有点麻烦,下面着重讲一下第二个方案。

通过 JMXMP 实现 JMX 远程管理和监控

JMXMP 全称为 JMX Messaging Protocol,它的安全设施比 RMI 更加高级。请回顾下前文引用的 JMX 三层架构图,Remote Management Level 中的 RMI 是可以替换为 JMXMP 协议的。

在 JMXMP connector 里面,server 和 client 之间的通信依赖一条单独的 TCP 连接,每一条消息都是一个序列化的 Java 对象。而且,server 和 client 之间的通信分为两个流,每个流负责一个方向,这允许我们在任意时刻在这条连接上发送多个并发的请求给 server。

由于 JMXMP 不是 JDK 的标准部分,所以如果在 Java 应用服务端使用需要在 pom 中增加对应的依赖:

<!-- https://mvnrepository.com/artifact/org.jvnet.opendmk/jmxremote_optional -->
<dependency>
    <groupId>org.jvnet.opendmk</groupId>
    <artifactId>jmxremote_optional</artifactId>
    <version>1.0_01-ea</version>
</dependency>

然后编写对应的服务端代码:

JMXServiceURL url = new JMXServiceURL("jmxmp", JMXMP_SERVER_HOST, JMXMP_SERVER_PORT);
JMXConnectorServer cs = JMXConnectorServerFactory.newJMXConnectorServer(url, null, ManagementFactory.getPlatformMBeanServer());
cs.start();

其中的 JMXMP_SERVER_HOSTJMXMP_SERVER_PORT 可以在 Java 应用 pod yaml 里面通过全局变量或 configmap 或写文件的方式传给容器。Java 应用在启动时进行读取并生成 JMXServiceURL

为了在客户端使用 JMXMP,启动 visualvm 时需要手动指定jmxremote_optional jar 包,比如像下面这样:

visualvm -cp:a "C:\Program Files\Java\jdk1.8.0_171\lib\jmxremote_optional.jar"

然后连接 service:jmx:jmxmp://JMXMP_SERVER_HOST:JMXMP_SERVER_PORT 既可以连接成功,如下图所示:

connect-via-jmxmp

(end)

添加新评论

我们会加密处理您的邮箱保证您的隐私. 标有星号的为必填信息 *