Java Metaspace 造成的full GC

老司机 Kyle 又一次问我有没有兴趣查一个问题, 直接扔过来一个app的监控 dashboard, 又是 GC overhead 100%. 不过这次有点曲折.


监控数据如下:
dashboard.png

解读:

  1. GC overhead 100%, 也就是Java 进程占用的CPU 全部用来做GC 了;
  2. JVM CPU usage 从20% 升到了35%, 也就是Java 进程使用的CPU 量增加了很多;
  3. OOM 的数量从0变成了每秒几个;
  4. 有意思的是 Java 的可用heap 从2G 突然变成了5.1G.

背景相关:
这个app 设置的Java heap 是5.5G.

从上面信息大致可以猜测, 可能遇到如下问题:

  1. 大量调用 System.gc() 或 Runtime.gc();
  2. Metaspace (或Java8之前永久代) 用尽;
  3. 或者 Native 内存被用尽;

于是使用 jstat 命令查看 gccause:

$ jdk/bin/jstat -gccause 1075  2000 5
  S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT    LGCC                 GCC
  0.00 100.00   0.00   4.14  69.08  32.72  48945 2441.067  1848  806.736 3247.803 Metadata GC Threshold Metadata GC Threshold
  0.00   0.00   0.00   4.55  69.08  32.72  48947 2441.076  1853  808.914 3249.990 Metadata GC Threshold Last ditch collection
  0.00 100.00   0.00   5.07  69.08  32.72  48950 2441.092  1858  811.101 3252.193 Metadata GC Threshold Metadata GC Threshold
  0.00 100.00   0.00   4.82  69.08  32.72  48952 2441.102  1862  812.887 3253.989 Metadata GC Threshold Metadata GC Threshold
  0.00   0.00   0.00   3.93  69.08  32.72  48954 2441.111  1867  815.072 3256.183 Metadata GC Threshold Last ditch collection

上面各列名的解释:
S0: Survive 0; S1: Survive 1; E: Eden; O: Old; M: Metaspace; CCS: Compressed class; YGC: Young GC Count; YGCT: Young GC Time; FGC: Full GC Count; FGCT: Full GC Time; GCT: GC Time; LGCC: Last GC Cause; GCC: GC Cause this time.

从上面统计数据可以看到: Full GC(FGC) 的数量在持续的10s 内持续增加, 造成的原因就是: Metadata GC Threshold 和 Last ditch collection.

在解释上面2个原因之前, 我们可以看看 Metaspace 的使用策略. Metaspace 是用来替换之前的永久代的, 并且从Native 内存申请, 用来存放类的元数据. 从操作系统申请的native 内存被分成 chunks, 每个chunk 被分配给每个 ClassLoader, 这个chunk里面的class 如果全部被回收的时候, 这个 chunk 也会被回收. 另外这些Native 内存使用mmap分配, 不是使用malloc.

从网上找来一张好图:
concepts.png

上面图上有几个概念:

  1. reserved: Java 应用虚拟内存里面保留的大小, 这个这是虚拟内存申请量, 和实际使用量没关系;
  2. committed: 从操作系统(OS)里面申请的 native 内存的大小, 被分为若干个不同大小的 chunks;
  3. capacity: 被分配给 ClassLoader 的所有 chunk 内存之和, 不包含没被分配的 free chunk;
  4. chunk: 分成2种: 被分配给classLoader的和未被分配的;
  5. used: 所有被分配的 chunk 里面的使用量之和. 一个被分配的chunk 也是慢慢被使用完的.
    上面图里面没有涉及到的概念:
  6. Metaspace occupied threshold(high watermark), 它一般是committed 的一个百分比, 由 -XX:MaxMetaspaceFreeRatio 和 -XX:MinMetaspaceFreeRatio 这个2个参数决定.

当 ClassLoader 要分配一个新的class空间的时候, 它检查自己拥有的chunk, 如果当前拥有的chunk内部空间不足的时候, 就会去free chunk 里申请一个新的chunk. JVM 内部维护一个metaspace 已经被占用的 threshold (high watermark), 当已经占用的量(capacity) 触及这个 threshold 的时候, 就会触发一个 Full GC (Metadata GC Threshold) GC. 当这个Full GC 还不能回收到足够的空间时, 就会看到 Full GC (Last ditch collection).

verbose GC log 里面的日志:

2022-10-01T18:11:27.974-0700: 407174.877: [Full GC (Metadata GC Threshold) 2022-10-01T18:11:27.974-0700: 407174.877: [GC concurrent-root-region-scan-end, 0.0001638 secs]
2022-10-01T18:11:27.974-0700: 407174.877: [GC concurrent-mark-start]
 189M->189M(5120M), 0.4585377 secs]
   [Eden: 0.0B(390.0M)->0.0B(390.0M) Survivors: 2048.0K->0.0B Heap: 189.4M(5120.0M)->189.4M(5120.0M)], [Metaspace: 362167K->362167K(1525760K)]
Heap after GC invocations=50066 (full 1356):
 garbage-first heap   total 5242880K, used 193982K [0x00000006a0800000, 0x00000006a0a05000, 0x00000007e0800000)
  region size 2048K, 0 young (0K), 0 survivors (0K)
 Metaspace       used 362167K, capacity 367544K, committed 524288K, reserved 1525760K
  class space    used 15775K, capacity 16721K, committed 48228K, reserved 1048576K
}
 [Times: user=0.65 sys=0.01, real=0.46 secs]
{Heap before GC invocations=50066 (full 1356):
 garbage-first heap   total 5242880K, used 193982K [0x00000006a0800000, 0x00000006a0a05000, 0x00000007e0800000)
  region size 2048K, 0 young (0K), 0 survivors (0K)
 Metaspace       used 362167K, capacity 367544K, committed 524288K, reserved 1525760K
  class space    used 15775K, capacity 16721K, committed 48228K, reserved 1048576K
2022-10-01T18:11:28.433-0700: 407175.336: [Full GC (Last ditch collection)  189M->189M(5120M), 0.4538734 secs]

-- 未完待续

mongo express MongoError: command listCollections requires authentication

为了连接一个MongoDB server 省事, 不想装本地app, 于是想使用docker 装一个 Web 版本的 Mongo express. 在启动的时候, 总是报这个错: MongoError: command listCollections requires authentication

我的连接URL是: mongodb://user1:[email protected]:27017/test_db. 可是根据官方的说明, 不论怎么写 docker command 都不行.

$docker run --rm -e ME_CONFIG_MONGODB_SERVER=mymongo.tianxioahui.com \
                 -e ME_CONFIG_BASICAUTH_USERNAME=user1 \
                 -e ME_CONFIG_BASICAUTH_PASSWORD=pwd1 \
                 -e ME_CONFIG_MONGODB_ENABLE_ADMIN=false \
                 -e ME_CONFIG_MONGODB_AUTH_DATABASE=test_db
                 -p 8083:8081 --name myMongo  mongo-express

(node:6) UnhandledPromiseRejectionWarning: MongoError: command listCollections requires authentication
    at Connection.<anonymous> (/node_modules/mongodb/lib/core/connection/pool.js:453:61)
    at Connection.emit (events.js:314:20)
    at processMessage (/node_modules/mongodb/lib/core/connection/connection.js:456:10)
    at Socket.<anonymous> (/node_modules/mongodb/lib/core/connection/connection.js:625:15)
    at Socket.emit (events.js:314:20)
    at addChunk (_stream_readable.js:297:12)
    at readableAddChunk (_stream_readable.js:272:9)
    at Socket.Readable.push (_stream_readable.js:213:10)
    at TCP.onStreamRead (internal/stream_base_commons.js:188:23)

可是不论怎么调可用的参数, 总是报这个错. Google 了一下, 发现2021年6月就有人报这个错: https://github.com/mongo-express/mongo-express/issues/720

解决方式也很简单, 直接用一个连接URL 替换其他环境变量:

sudo docker run --rm -e ME_CONFIG_MONGODB_URL=mongodb://user1:[email protected]:27017/test_db  -p 8083:8081 --name myMongo mongo-express

可是, 可是, 这个环境变量ME_CONFIG_MONGODB_URL在 https://hub.docker.com/_/mongo-express 竟然没有, 可是能用, 还很管用.

解密 Java 的 https 流量

最近有个同事发现生产环境中有些服务比较慢, 检查一番后, 发现一个服务调用在tcp 3次握手和 ssl client hello 包之间竟然有200ms 左右的延迟. 接下来, 该同事就想弄清楚: 为什么在3次握手和 client hello之间有200ms 延迟. 他知道有些语言是直接调用的 OpenSSL 的库, 于是问我 Java 里面是不是也是使用 OpenSSL的封装?

我之前写过2篇 Windows 上解密 chrome 的 https 流量 & MAC 上解密 chrome 的 https 流量, 当时就想: 是不是Java 也可以自动把 ssl key 劫持下来, 然后后边自动揭秘. 当时就找到了这个已经持续好多年的项目: jsslkeylog, 这个项目就是通过 Java Agent的方式, 注入相关的代码, 把ssl key 劫持下来.

所以, 当同事问我是不是 OpenSSL 封装的时候, 我立马想到这个项目. 在这个项目里面, 它通过注入的方式把 ssl key 记录下来, 那么肯定是Java 自己原生写的 SSL 的代码处理.

一些核心的代码类: https://github.com/jsslkeylog/jsslkeylog/tree/master/src/main/java/net/sf/jsslkeylog

关于 NSS(Network Security Services)

NSS (Network Security Services) 是由 Mozilla 项目开发的一个模块化、可复用的网络安全服务库,它提供了一系列的安全协议、密钥管理、证书管理、加密算法等安全功能的实现。NSS 被广泛地应用于多种网络应用程序中,包括 Web 浏览器、电子邮件客户端、虚拟专用网络 (VPN) 和移动设备等。

NSS 的功能涵盖了多种协议和技术,包括 SSL、TLS、PKCS#11、S/MIME、X.509 证书、公钥基础设施 (PKI)、安全套接字层 (SSL) 和传输层安全 (TLS) 等。NSS 在这些领域是具有广泛应用的库之一,是开源社区中信任和可靠的安全库。

NSS 还为应用程序提供了一些基本的加密功能,包括随机数生成器、哈希函数、密钥交换、数字签名、加密和解密、证书签名和认证等。这些功能被设计成易于使用和可扩展的,帮助应用程序开发者更快速、更容易地构建安全的网络应用程序。

需要注意的是,NSS 是一个独立的项目,并不仅限于 Mozilla 项目中。它在其他开源项目中也被广泛使用,例如 Red Hat、Fedora、OpenSSL 和 OpenSSH 等。
项目地址: https://github.com/nss-dev/nss
wiki: https://en.wikipedia.org/wiki/Network_Security_Services

关于 NSS 的 key log 格式

官方文档已经找不到了, 这是一个缓存: https://udn.realityripple.com/docs/Mozilla/Projects/NSS/Key_Log_Format
你可以认为这是 NSS 暴露出的一个接口, 这样使用 Wireshark 就能够解密加密传输.
NSS_Key_Log_Format.png

key log 的最新格式

这是 Mozilla 提交给 IEEE 的最新格式, 可以认为之前都不是经过认证, 只是 NSS 和 wireshark 之间商量的一个格式.
https://datatracker.ietf.org/doc/draft-thomson-tls-keylogfile/

wireshark 支持的格式

https://github.com/boundary/wireshark/blob/d029f48e4fd74b09848fc309630e5dfdc5d602f2/epan/dissectors/packet-ssl-utils.c#L4164-L4182
https://github.com/boundary/wireshark/blob/07eade8124fd1d5386161591b52e177ee6ea849f/epan/dissectors/packet-ssl-utils.c#L4204-L4207

两个揭秘 Java TLS 的工具

https://github.com/neykov/extract-tls-secrets/
https://github.com/jsslkeylog/jsslkeylog/tree/master/src/main/java/net/sf/jsslkeylog

使用 -Djavax.net.debug=all 生成的日志直接提取key的工具(TLS 1.3不适用):
https://dimosr.github.io/decrypting-tls-traffic-with-wireshark/
https://gist.github.com/tsaarni/14cc3341d0996e25671f5ca894842ec9

JDK 关于 javax.net.debug 的说明

https://docs.oracle.com/javase/8/docs/technotes/guides/security/jsse/ReadDebug.html

JDK 使用到的关于TLS 版本的启动参数

  1. -Djdk.tls.client.protocols=TLSv1.2,TLSv1.3
    jdk.tls.client.protocols 是一个 JDK (Java Development Kit) 的系统属性,这个属性可以用来指定与客户端建立安全连接时所支持的 TLS 版本的范围。具体来讲,这个属性可以用来限制客户端连接到服务端时可用的 TLS 协议版本。jdk.tls.client.protocols 属性的值是一个以逗号分隔的 TLS 协议版本列表.
  2. -Dhttps.protocols=TLSv1.2,TLSv1.3
    https.protocols 是一个 Java 系统属性, 可以用来指定在执行 HTTPS 请求时支持的协议版本。特别是当我们使用 Java 编写的程序向使用 HTTPS 存储在远程服务器上的资源发出请求时,这个属性就非常有用了。参数的值表示 Java 程序只会使用 TLS 1.2 和 TLS 1.3 协议版本来发起 HTTPS 请求。如果远程服务器不支持这些协议,则连接将失败。
  3. -Djavax.net.debug=ssl,keygen,handshake

exec user process caused: exec format error

今天开发的app 做了一个新的docker image, 发布到K8S之后, 就报下面的错:

standard_init_linux.go:228: exec user process caused: exec format error

还以为自己写的代码配置出错了, 想登上去看看, 发现进程压根就没起来.

Google 一把, 有人说是entrypoint的shell 文件的 shebang 不对, 我这里没有这个问题.

后来看到有人说是可执行格式的错误, 发现还真是.
首先, 我在本地Mac Pro (ARM) 上去执行这个image, 很正常的执行起来了, 然后我在另外一个Ubuntu 上面执行这个image, 就给我报下面的错误了:

WARNING: The requested image's platform (linux/arm64/v8) does not match the detected host platform (linux/amd64) and no specific platform was requested

所以, 很明显, 这个image 是给 ARM64/v8 用的. 用 docker image inspect <img>去查看, 能看到这个image 是给什么平台机构的.
arch.png

我的 docker build 环境是 MAC ARM 机器, 之前一直没有错, 今天为啥出错了, 原因很有可能是我今天重启了 Docker Desktop.

去看 docker 官方文档: https://docs.docker.com/engine/reference/commandline/build/ 发现他们最近(20220914) 对于 docker build 新加了一个参数 (--platform)
platform.png

到它 change 去看, 发现最新的 v141
https://docs.docker.com/engine/api/version-history/#v140-api-changes

所以, 要在 Mac Pro ARM 芯片上做 x86_64/amd64 的image, 要给一个新参数 --platform linux/amd64

WebSocket 协议

最近有个项目要做一个Slack Robot, 就是根据用户发到Slack 某个Channel的内容, 给出智能回复.

与Slack的交互方式有2种: HTTP连接的方式和WebSocket的方式. HTTP 连接的方式又有2种实现方式:

  1. 我们提供一个公开的endpoint, 当Slack的Channel 有消息的时候, Slack 以webhook的方式及时通知我们, 我们的app 给出回复.
  2. 我们不提供endpoint, 我们连接Slack的endpoint, 以固定时间间隔的方式去poll 消息, 如果有新消息, 我们的app 给出回复.

以上这2种方式都不是很好的方式, 对于第一种, 我们要提供一个公开的endpoint, 在我们的生产环境基本不可能, 对于第二种方式, 不是基于事件的, 不管有没有消息, 都要固定的去poll, 对于一个app 里面有多个实例的情况, 还要控制那个实例去poll.

所以 WebSocket 的方式是最好的, 不过当时就有开发人员提出了意见: 我们的生产环境时不能连外网的, 要连只能通过http 代理, 而我们的生产环境的代理只支持 http 代理, 不支持sock 方式, 所以这种 WebSocket 行不通. 是真的吗?

在我们看看 WebSocket 到底是什么之前, 先做一个 WebSocket 的例子: 使用Node.js 开一个WebSocket 服务, 然后在浏览器开一个 WebSocket 客户端.

WebSocket 服务端

  1. 新建一个文件夹

    mkdir wsServer
    cd wsServer
  2. 新建一个 package.json

    vim package.json

    输入如下代码

    {
     "name": "wsServer",
     "version": "0.0.1",
     "type": "module",
     "dependencies": {
       "ws":"8.8.1"
     }
    }
  3. 新建一个服务端代码文件

    vim server.js

    输入如下代码:

    var WebSocketServer = require('ws').Server
    var fs = require('fs')
    
    const wss = new WebSocketServer({ port: 8080 });
    
    wss.on('connection', function connection(ws) {
     ws.on('message', function message(data) {
     console.log('received: %s', data);
     ws.send(data + " -> server ack");
     });
    
     fs.watch('/tmp/', (eventType, fileName) => {
     console.log('Get event on file ' + fileName + ', type: ' + eventType);
     ws.send('file -> ' + fileName + ' -> ' + eventType);
     })
    
     ws.send('welcome to WebSocket world!');
    });

    上面这段代码做3件事情:

  4. 启动 WebSocket 服务器在8080 端口上, 当有人来连接的时候, 发送欢迎消息;
  5. 当收到客户端消息的时候, 打印收到的消息, 并且发送给客户端 ack 消息;
  6. 监听本地 /tmp 文件夹的文件变动事件, 打印日志, 并推送给客户端;

启动服务端代码:

node server.js

WebSocket 客户端

在 Chrome 浏览器打开任意页面的控制台, 输入如下JavaScript 代码

const ws = new WebSocket('ws://10.249.64.103:8080');

// Listen for messages
ws.addEventListener('message', (event) => {
    console.log('Message from server: ', event.data);
});

document.addEventListener('click', (event) => {
    console.log("just clicked");
    ws.send('click on (' + event.x + ', ' + event.y + ')');
});

上面的代码做下面的事情:

  1. 连接服务端 WebSocket;
  2. 然后当收到服务器端消息的时候, 打印收到的消息;
  3. 当页面上收到点击事件的时候, 推送给服务端点击事件的坐标;

运行效果

客户端效果:
client.png

服务器端效果:
server.png

使用代理

因为客户端使用的是浏览器, 可以设置在chrome 设置对于这个IP 启用代理, 使用http 代理, 依然能联通server 端, 正常运转.

使用curl

使用curl 只能被动的接受服务器传来的消息, 不能发送任何消息. (这个 Sec-WebSocket-Key 是从浏览器刚才发送的历史中复制过来的)

curl \
    --include \
    --no-buffer \
    --header "Connection: Upgrade" \
    --header "Upgrade: websocket" \
    --header "Host: 10.249.64.103:8080" \
    --header "Origin: http://10.249.64.103:8080" \
    --header "Sec-WebSocket-Key: 1TFTcjPQ7iG2XvsZ83WgZg==" \
    --header "Sec-WebSocket-Version: 13" \
    http://10.249.64.103:8080

效果(curl 对回应消息只能拼接, 不换行):
server.png

JavaScript WebSocket API 文档

官方文档在这里: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket
这个 WebSocket 类很简单:

  1. 构造函数给出服务器的地址, 包括ws协议, host 加 port;
  2. 有几个字段: 比如 url, readyState, protocol, bufferedAcmount, binaryType等, 有些是只读的;
  3. 客户端只有发送 send() 和 close() 方法;
  4. event handler:

    1. message: 当收到消息时;
    2. open: 当连接建立时;
    3. error: 当发送错误时;
    4. close: 当关闭连接时;

Node.js 的 WebSocket 实现库 ws 的文档

官方文档: https://www.npmjs.com/package/ws#sending-and-receiving-text-data
它不仅仅包含一个server端的API实现, 还包含一个做为 Node.js 客户端端代码实现

WebSocket 协议

官方文档: https://www.rfc-editor.org/rfc/rfc6455
当我们看过上面的 WebSocket 的例子之后, 再来看这个RFC 文档, 就不是那么难了.

为什么需要 WebSocket 协议

在 web 通信等某些场景下, 如果服务端发生了某些事件, 需要实时推送给客户端. 在传统的基于web的技术下, 需要客户端不断的去poll消息, 不管服务端到底有没有事件更新, 每次poll 都需要客户端发送一个http request, 并且如果发送的频率过低, 可能不能及时收到服务端事件更新, 如果频率过高, 又会对网络和服务端造成一些压力.

所以, 如果建立一个连接的情况下, 服务端事件变更主动推送客户端, 客户端只要等待就好了, 就完美解决了这个问题, 于是就有了 WebSocket 协议.

WebSocket 基本介绍

  1. 它可以使客户端和服务端在一个连接里面双向不间断通信;
  2. 通信过程分2阶段, 先是使用http协议握手连接, 然后以数据帧的方式双向发送数据;
  3. 广泛使用在游戏, 股票等需要实时消息通讯等软件中;
  4. WebSocket 可以使用现有web的 proxy和认证等成熟的机制;
  5. 通常开在80或443, 可以通过防火墙;

通常的 连接握手协议:

The handshake from the client looks as follows:

        GET /chat HTTP/1.1
        Host: server.example.com
        Upgrade: websocket
        Connection: Upgrade
        Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
        Origin: http://example.com
        Sec-WebSocket-Protocol: chat, superchat
        Sec-WebSocket-Version: 13

   The handshake from the server looks as follows:

        HTTP/1.1 101 Switching Protocols
        Upgrade: websocket
        Connection: Upgrade
        Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
        Sec-WebSocket-Protocol: chat