1. 前言
最近因某些原因需要对公司项目进行一次全面的安全扫描。经扫描发现,部分项目使用基于 CentOS 系统的 JDK HotSpot Docker 镜像存在高危漏洞,为此需要进行一次镜像的升级换代。经过一番筛选,最终选定了华为 openEuler 官方镜像(也考虑到逐步实现信创体系转型而作准备)。
但公司之前并没有使用 openEuler 操作系统的相关经验,贸然将项目迁移到 openEuler 镜像中是存在一定风险的,为此才有本次的适应性测试。
2. 更换 JDK
openEuler 系统提供了华为 bisheng(毕昇) JDK,这个 JDK 对 ARM 架构做了特殊优化。虽然毕昇也有基于服务器的 x64 架构版本,但根据公司项目的实际情况,还是选择了 x64 架构的 IBM Semeru Runtimes(因项目用到了部分 OpenJ9 的特性,不得已而为之)。
3. 编写简单的 Springboot 压测 Demo
公司项目大多基于 Docker 的 Springboot 项目,为了贴合真实场景编写了一个简单的 Springboot Demo 通过 JMeter 对其进行高并发调用。
3.1 测试代码
java
@SpringBootApplication
public class ServerFitness {
public static void main(String[] args) {
SpringApplication.run(ServerFitness.class, args);
}
}
java
@RestController
public class RestHttpController {
private static final Logger LOGGER = LogManager.getLogger(RestHttpController.class);
// 线程安全随机数
private static final SecureRandom SEC_RANDOM = new SecureRandom();
// 最短响应值
private static final int MIN_RESPONSE_VAL = 100;
// 最长响应值
private static final int MAX_RESPONSE_VAL = 1500;
@PostMapping("/setup")
public Map<String, String> setup() {
long start = System.currentTimeMillis();
Map<String, String> map = new HashMap<>();
// 处理请求(这里使用线程安全随机数来设定线程等待时间)
try {
int randomASCIICode =
SEC_RANDOM.nextInt(MAX_RESPONSE_VAL) % (MAX_RESPONSE_VAL - MIN_RESPONSE_VAL + 1) + MIN_RESPONSE_VAL;
Thread.sleep(randomASCIICode);
// 获取服务器性能指标
String performanceMetrics = getPerformanceMetrics();
// 不要介意这种简单的返回格式
map.put("retcode", "1");
map.put("retdata", "This is the information returned from the server!");
map.put("retmsg", performanceMetrics);
} catch (InterruptedException e) {
map.put("retcode", "0");
}
LOGGER.info("total request time: " + (System.currentTimeMillis() - start) + " ms.");
return map;
}
/**
*
* @MethodName: getPerformanceMetrics
* @Description: 在原生 java 的情况下获取服务器性能指标
* @author yuanzhenhui
* @return String
* @date 2023-07-24 05:10:24
*/
private static String getPerformanceMetrics() {
// 获取操作系统的性能指标
OperatingSystemMXBean osMxBean = ManagementFactory.getOperatingSystemMXBean();
double cpuUsage = osMxBean.getSystemLoadAverage() * 100.0;
// 获取线程的性能指标
ThreadMXBean threadMxBean = ManagementFactory.getThreadMXBean();
int threadCount = threadMxBean.getThreadCount();
int peakThreadCount = threadMxBean.getPeakThreadCount();
long cpuTime = threadMxBean.getCurrentThreadCpuTime();
long userTime = threadMxBean.getCurrentThreadUserTime();
long totalThreadCount = threadMxBean.getTotalStartedThreadCount();
// 构建性能指标字符串
StringBuilder metrics = new StringBuilder();
metrics.append("CPU usage: ").append(cpuUsage).append("%\n");
metrics.append("current number of active threads: ").append(threadCount).append("\n");
metrics.append("peak thread count: ").append(peakThreadCount).append("\n");
metrics.append("The total number of threads the program has run: ").append(totalThreadCount).append("\n");
metrics.append("CPU time (nanoseconds) of the current thread: ").append(cpuTime).append("\n");
metrics.append("User-mode CPU time of the current thread (nanoseconds): ").append(userTime).append("\n");
return metrics.toString();
}
}
由上图得知,代码并没有调用第三方应用,只采取 Thread.sleep 模拟线程阻塞。本次压测的主要目的是对镜像进行适应性测试并调整部分镜像内系统参数,调用第三方应用时会掺杂网络消耗、数据刷新耗时等问题,从而会对结果产生混淆影响判断。
4. 压测硬件配置
4.1 客户端配置
操作系统 | 硬件配置 | 压测资源 |
---|---|---|
macOS | CPU:Intel Core i5 2GHz RAM:32GB 3733 MHz LPDDR4X | 压测软件:Apache JMeter JDK:AdoptOpenJDK-11.0.11+9 (build 11.0.11+9) |
4.2 服务端配置
服务器 | 操作系统 | 软件资源 |
---|---|---|
node21 | openEuler(22.03 LTS) | CPU:Intel® Xeon® Silver 4314 CPU @ 2.40GHz 内存: 4GB 硬盘: 40GB Docker:18.09.0 |
5. JMeter 压测
5.1 第一次压测参数
5.1.1 JMeter 压测参数
参数名称 | 数值 |
---|---|
线程数 | 300 |
启动时长(秒) | 10 |
持续时长(分钟) | 5 |
5.1.2 压测结果
Label | # 样本 | 平均值 | 中位数 | 90% 百分位 | 95% 百分位 | 99% 百分位 | 最小值 | 最大值 | 异常 % | 吞吐量 | 接收 KB/sec | 发送 KB/sec |
---|---|---|---|---|---|---|---|---|---|---|---|---|
调用 http 接口 | 825 | 108804 | 107818 | 190300 | 190350 | 190519 | 473 | 190530 | 0.00% | 2.49422 | 1.17 | 0.51 |
TOTAL | 825 | 108804 | 107818 | 190300 | 190350 | 190519 | 473 | 190530 | 0.00% | 2.49422 | 1.17 | 0.51 |
第一轮压测是在没有任何调整的情况下进行的,从上表可以看出吞吐量并不理想只有 2.4 tps 每秒。
5.2 第二次压测参数
5.2.1 JMeter 压测参数
参数名称 | 数值 |
---|---|
线程数 | 300 |
启动时长(秒) | 10 |
持续时长(分钟) | 5 |
5.2.2 压测结果
Label | # 样本 | 平均值 | 中位数 | 90% 百分位 | 95% 百分位 | 99% 百分位 | 最小值 | 最大值 | 异常 % | 吞吐量 | 接收 KB/sec | 发送 KB/sec |
---|---|---|---|---|---|---|---|---|---|---|---|---|
调用 http 接口 | 1031 | 106150 | 108575 | 179947 | 199325 | 200439 | 645 | 350708 | 8.63% | 1.95845 | 1.25 | 0.37 |
TOTAL | 1031 | 106150 | 108575 | 179947 | 199325 | 200439 | 645 | 350708 | 8.63% | 1.95845 | 1.25 | 0.37 |
第二轮压测结果跟第一轮相似,本次是对构建使用的 Dockerfile 中启动参数进行了调整,增加了部分 JVM 启动参数,换写成 shell 脚本将如下所示:
shell
java -jar fitness-tool-1.0.0.jar
-XX:MaxGCPauseMillis=200 \
-Xloggc:/tmp/fitness-tool/gc.log \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-XX:+UnlockExperimentalVMOptions \
-XX:+UseG1GC \
-Djava.security.egd=file:/dev/./urandom \
-XX:+UseCGroupMemoryLimitForHeap \
-XX:MaxRAMFraction=1 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp/fitness-tool/dump/
以上配置我就不再多说了,就结果而言效果并不明显。通过观察程序输出时发现 XNIO 线程组在高并发下 IO 线程只使用了两个。并且从第一、第二次压测结果中可以看出在 90%百分位的处理时间徘徊在 179947 至 190300 之间,中位数在 107818 到 108575 之间,明显线程跟不上并发速度导致出现"连接超时中断"的情况。
5.3 第三次压测参数
5.3.1 JMeter 压测参数
参数名称 | 数值 |
---|---|
线程数 | 300 |
启动时长(秒) | 10 |
重复次数 | 10 |
经过对代码、压测用例进行了一次复盘后发现,压测对象只有一个接口,在不涉及到"多流程通测"的情况下只需使用"重复次数"即可。并且使用"重复次数"能有效管理 JMeter 客户端线程,不会出现因"时间到"而临时终止线程导致的"异常"指数飙升。
5.3.2 压测结果
Label | # 样本 | 平均值 | 中位数 | 90% 百分位 | 95% 百分位 | 99% 百分位 | 最小值 | 最大值 | 异常 % | 吞吐量 | 接收 KB/sec | 发送 KB/sec |
---|---|---|---|---|---|---|---|---|---|---|---|---|
测试用压测接口 | 3000 | 47929 | 43537 | 78715 | 101584 | 156177 | 130 | 156191 | 5.73% | 5.44389 | 3.17 | 1.06 |
TOTAL | 3000 | 47929 | 43537 | 78715 | 101584 | 156177 | 130 | 156191 | 5.73% | 5.44389 | 3.17 | 1.06 |
通过查阅 Undertow 容器源码得知,Undertow 是根据服务器 CPU 核心进行线程数的自动设置的,如下图:
因此,若需要提升性能则必须重置 ioThreads 和 workerThreads 变量(但不能按照网上描述的来配置,这个跟公司最近升级了内部使用的 licorice-framework 框架有关,目前已对框架进行修复和配置同步,这个配置跟之前应用在基于 CentOS 的 JDK 镜像的配置有所出入,目前已经做了兼容处理)。
重置后,使用四线程(通过上面硬件配置得知服务端只有 2 个 CPU,因此进行了 ×2 处理)能使吞吐量有所提升,但也只有 5.44 tps 每秒且出现 5.7% 的异常信息,这些异常信息都是连接超时重置引起的,如下图:
java
java.net.SocketException: Connection reset
at java.base/java.net.SocketInputStream.read(SocketInputStream.java:186)
at java.base/java.net.SocketInputStream.read(SocketInputStream.java:140)
at org.apache.http.impl.io.SessionInputBufferImpl.streamRead(SessionInputBufferImpl.java:137)
at org.apache.http.impl.io.SessionInputBufferImpl.fillBuffer(SessionInputBufferImpl.java:153)
at org.apache.http.impl.io.SessionInputBufferImpl.readLine(SessionInputBufferImpl.java:280)
at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:138)
at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:56)
at org.apache.http.impl.io.AbstractMessageParser.parse(AbstractMessageParser.java:259)
at org.apache.http.impl.DefaultBHttpClientConnection.receiveResponseHeader(DefaultBHttpClientConnection.java:163)
...
虽然四线程还不足以承受来自 300 线程的并发,但基本上突破口已经确定,只需要加大线程数量就可以了。
5.4 第四、五、六次压测参数
5.4.1 JMeter 压测参数
参数名称 | 数值 |
---|---|
线程数 | 300(四)/3000(五)/2500(六) |
启动时长(秒) | 10 |
重复次数 | 10 |
5.4.2 压测结果
Label | # 样本 | 平均值 | 中位数 | 90% 百分位 | 95% 百分位 | 99% 百分位 | 最小值 | 最大值 | 异常 % | 吞吐量 | 接收 KB/sec | 发送 KB/sec |
---|---|---|---|---|---|---|---|---|---|---|---|---|
测试用压测接口 | 3000 | 3258 | 2843 | 6179 | 7490 | 10448 | 110 | 14966 | 0.00% | 40.99257 | 19.16 | 8.45 |
TOTAL | 3000 | 3258 | 2843 | 6179 | 7490 | 10448 | 110 | 14966 | 0.00% | 40.99257 | 19.16 | 8.45 |
第四次压测前做了两件事,第一件事是对 openEuler 镜像内/etc/sysctl.conf 配置进行了一次参数的调整,如下图:
shell
# 增加最大连接数
net.core.somaxconn = 65535
# 提高系统文件描述符限制
fs.file-max = 65535
# 提高进程打开文件数限制
fs.nr_open = 65535
# 系统最大线程数
kernel.threads-max = 65535
除了以上的配置外,那些"net.ipv4"开头的网络配置是没有配置到镜像里面。这是因为 Docker 容器会继承来自宿主机的 sysctl 参数,因此关于网络配置的参数就可以不在镜像里配置了。
此外,在程序中将 ioThreads 变量调整为 64, workerThreads 变量调整为 512( 通过 Undertow 源码得知 workerThreads = ioThreads x 8),吞吐量直线上升达到了 40 tps 每秒。
为了压测阈限值,后面基于 3000 线程又做了一次压测,结果如下:
Label | # 样本 | 平均值 | 中位数 | 90% 百分位 | 95% 百分位 | 99% 百分位 | 最小值 | 最大值 | 异常 % | 吞吐量 | 接收 KB/sec | 发送 KB/sec |
---|---|---|---|---|---|---|---|---|---|---|---|---|
测试用压测接口 | 30000 | 31303 | 29818 | 53237 | 61328 | 77689 | 109 | 106395 | 0.41% | 80.18796 | 38.31 | 16.46 |
TOTAL | 30000 | 31303 | 29818 | 53237 | 61328 | 77689 | 109 | 106395 | 0.41% | 80.18796 | 38.31 | 16.46 |
压测参数不变的情况下,在线程提高到 3000 时吞吐量可以上升到 80 tps 每秒,但出现了 0.41%的连接中断异常。虽然有异常,但按照已知数据推算,单应用线程并发数 2000 应该是没有问题的。于是,最后使用 2000 线程做最后一次压测,结果如下:
Label | # 样本 | 平均值 | 中位数 | 90% 百分位 | 95% 百分位 | 99% 百分位 | 最小值 | 最大值 | 异常 % | 吞吐量 | 接收 KB/sec | 发送 KB/sec |
---|---|---|---|---|---|---|---|---|---|---|---|---|
测试用压测接口 | 20000 | 20299 | 18395 | 35146 | 44323 | 62804 | 115 | 89586 | 0.00% | 77.82737 | 36.56 | 16.04 |
TOTAL | 20000 | 20299 | 18395 | 35146 | 44323 | 62804 | 115 | 89586 | 0.00% | 77.82737 | 36.56 | 16.04 |
2000 线程并发下,吞吐量能达到到 77 tps 每秒。
至此,openEuler 镜像适应性测试结束。