初次接触使用 Docker 和尝试打包 Minecraft 的踩坑记录: Play Server 的性能测试

2021-01-20

说是性能测试,实际上是在尽可能的省内存...

解决在 docker 中没有 ps free 等命令

apt update && apt install -y procps

使用 jdk 版本容器自带这些命令(jre 没有)

增强版 top

apt update && apt install -y htop

如何获取 & 后的 pid

$!

& 后的命令写在一行

在后台运行的 & 写在一行时,不用加分号。

Java 内存使用(基础)

Java 的内存是由堆内存和非堆内存两部分组成的,其中 Java 的 -Xmx-Xms 指的是堆内存。

-Xms 的误区

-Xms 代表起始内存(不是最小,可能更小)。

Java 堆内存设置与 Docker 内存限制

现在 Java 好像会从 cgroups 里读取内存大小,并且将其 1/4 用于设置 Java 堆大小。而如果 docker 设置的内存限制太小,会导致 Java 设置的堆内存更小,进而导致程序启动崩溃。

最好手动设置 Java 运行时的堆内存大小(-XmxXXXXm -XmsXXXXm)

ps/top/htop 中的 rss

ps/top/htop 里显示的 rss/res (常驻内存)为程序真实占用的物理内存。除此之外,还有一部分被换到磁盘里的swap,这也是程序正在使用的内存,两者和小于程序需要的内存会导致程序崩溃(内存不足)。

ps/top/htop 里显示的 virt 内存是程序申请的地址空间,并不是 swap 使用的内存,除非在 32 位环境下工作,否则该值没有太大意义。

free 中的 free 与 available

Linux 会使用一部分空闲内存作为磁盘加速(可被释放),这部分容量被包含在 available,并不在 free 中。

Java 内存使用

使用 jvmtop 查看内存使用和 gc 情况

Java 的内存是由堆内存和非堆内存两个部分组成的。可以使用 jvmtop 来查看程序运行时内存使用情况。

jvmtop 的一些坑

jvmtop 需要 jdk,在设置有 -XX:+PerfDisableSharedMem 的情况时,jvmtop 无法显示该进程的内存使用情况(不显示该进程)。

故在测试的时候,需要设置 Dockerfile 的 From 为 FROM openjdk:8-jdk-buster ,以及将 start.sh 中的 -XX:+PerfDisableSharedMem 参数删掉。

看起来 jvmtop 好像也不能与 gosu 一起使用(jvmtop 会提示连接失败)

jvmtop 的 non-heap 内存坑

该数字取自 MemoryMXBean.getNonHeapMemoryUsage ,看起来好像并不是 实际使用的内存(RSS)-heap 使用的内存:

MemoryMXBean counts the following JVM memory pools as "Non-heap":

  • Code Cache (or Code Heap) - the area for compiled methods and other dynamically generated code;
  • Metaspace and Compressed Class Space - the areas for class metadata.

How to understand java.lang.management.MemoryMXBean and -Xms?

jvmtop 测试脚本

docker cp test mc:/ && docker exec -ti mc bash
cd /test/ && mkdir jvmtop-0.8.0 && tar xvf jvmtop-0.8.0.tar.gz -C jvmtop-0.8.0 && cd jvmtop-0.8.0 && bash jvmtop.sh

使用 jemalloc 来分析内存使用情况

jemalloc 配合一系列工具,可以可视化分析非堆内存泄露问题。

apt install -y libjemalloc-dev

libjemalloc.so 在 /usr/lib/x86_64-linux-gnu/libjemalloc.so

apt install -y binutils 解决找不到 objdump 命令的问题

apt install -y graphviz 解决找不到 dot 命令的问题,该库用于生成 gif

具体使用参见 Using jemalloc to get to the bottom of a memory leak

Andrei Pangin 的视频里也有实际使用的例子。

使用 async-profiler 来结合 Java 调用分析内存使用情况

jemalloc 只能显示 native 函数调用,无法追溯到 java 这个黑箱子里。async-profiler 可以可视化的显示调用栈。

最后的 {pid} 可以直接写 jps

Andrei Pangin 的视频里有实际使用的例子。

使用 Java Native Memory Tracking 来分析 Java 程序内存使用情况

Java 提供给我们一个很好的工具来分析 Java 运行时内存。

在启动时增加 -XX:NativeMemoryTracking=summary 参数,

然后启动 jcmd VM.native_memory

NativeMemoryTracking 显示的内存相关

reserved 代表的是 Java 向系统申请使用的地址空间,即为 ps 中 virt 那一部分,意义不大。

committed 内存代表 Java 向系统申请使用的内存,即为 ps 中 rss + swap 中那一部分,跟硬件条件相关。

committed 部分的内存可能会比 rss+swap 大,这是因为 linux 系统的懒分配机制,内存只有在第一次访问的时候才会被分配给 java ,为此堆内存可以使用 -XX:+AlwaysPreTouch 来确保 committed 部分全部体现在 rss+swap 中。Why does a JVM report more committed memory than the linux process resident set size?

此外 committed 并不是真正被 used 的,jvmtop 里可以体现 used 的堆内存,一些内存可能从系统中获取,但是并不真正用在表示 java 对象上(空闲)。(好乱啊)

需要禁用 swap 。swap 内存好像没有好使的工具来查看,关闭 swap 可以让所有被真正使用的内存体现在 rss 里。

在确保堆内存全部被系统分配后,rss+swap 可能会比 committed 大(即,Java 中一些内存在 top/htop/ps 上的 rss/res (常驻内存)可以体现,而在 Java Native Memory Tracking 中无法体现),因为 c1/c2 编译器、标准库调用的 native 库内存(如加载 jar 包所用内存)、malloc 碎片内存等,并不会被 NativeMemoryTracking 统计到:

c1/c2 编译器 jvm 外内存 ~80m

It's also possible that there is a native memory leak. A common problem is native memory leaks caused by not closing a ZipInputStream/GZIPInputStream.

A typical way that a ZipInputStream is opened is by a call to Class.getResource/ClassLoader.getResource and calling openConnection().getInputStream() on the java.net.URL instance or by calling Class.getResourceAsStream/ClassLoader.getResourceAsStream. One must ensure that these streams always get closed.

It almost seems that the JVM is asking the OS for memory, which is allocated within the container, and the JVM is freeing memory as its GC runs, but the container doesn't release the memory back to the main OS. So... memory leak.

加载的JNI代码System.loadLibrary可以分配所需的尽可能多的堆外内存,而无需JVM进行控制。这也涉及标准的Java类库。特别是,未关闭的Java资源可能会成为本机内存泄漏的来源。典型示例为ZipInputStream或DirectoryStream。

...

进程通常直接从OS(通过mmap系统调用)或通过使用malloc标准libc分配器来请求本机内存。依次malloc使用mmap,从OS请求大块内存,然后根据其自己的分配算法管理这些大块。问题是-该算法可能导致碎片和过多的虚拟内存使用。

由于多种原因,NativeMemoryTracking报告的承诺内存可能少于进程的实际驻留集大小(RSS)。

NMT仅计算某些JVM结构。它不计算内存映射文件(包括已加载的.jar文件),也不计算以外的库分配的内存libjvm。libjavaNMT报告中甚至不显示由标准类库(即)分配的本机内存。

当某物使用标准系统分配器(malloc)分配内存然后释放它时,该内存并不总是返回到OS。系统分配器可以将释放的内存保留在池中以备将来重用,但是从OS角度来看,该内存被视为已使用(因此包含在RSS中)。

java 本机库 和 malloc

尽可能的降低 Java 内存使用

一些方法并不推荐!

使用 Aikar 的内存调优参数以优化内存使用

-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 -XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch -XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=40 -XX:G1HeapRegionSize=8M -XX:G1ReservePercent=20 -XX:G1HeapWastePercent=5 -XX:G1MixedGCCountTarget=4 -XX:InitiatingHeapOccupancyPercent=15 -XX:G1MixedGCLiveThresholdPercent=90 -XX:G1RSetUpdatingPauseTimePercent=5 -XX:SurvivorRatio=32 -XX:+PerfDisableSharedMem -XX:MaxTenuringThreshold=1

使用 DisableExplicitGC 参数可能会增加堆外内存使用

使用 DisableExplicitGC 参数(来自 Aikar's flags)在关闭不想要的 GC 同时,也会造成一定程度上堆外内存使用增加。

使用 ExplicitGCInvokesConcurrent 来优化这一点。

(并未更改,还是跟着大部队走~这里仅做一个记录)

该论点来自 Andrei Pangin 的视频。

jit 编译器触发

好像如果程序不复杂,并不会触发 jit 编译器,观察到 jvmtop 内存使用并不是特别高,感觉不像有 80m 编译器堆外内存的样子

java 不大爱用 swap

尽管 swappiness 设置的为 100,好像 java 仍然不愿意使用 swap,只有在物理内存不足时,才会使用 swap 。

Docker 中的 swap

只要程序的使用的内存小于 --memory-swap (即物理内存加被换到swap里的内存),程序就能跑起来。

故在确定容器所使用的内存大小时,应该将 swap 内存设置为 0,避免 swap 的干扰,方法就是将 -m 参数设置的与 --memory-swap 一致。

swap 大小与 swappiness

关于 swap 大小:

  • <2g swap 大小一般为 两倍 物理内存 大小
  • 2-8g swap 大小一般为 物理内存 大小
  • >8g swap 大小一般为 8g

关于 swappiness :

好像操作系统默认都是 60 。该值(0-100)越高,程序越愿意使用 swap 内存。

阿里云与 swap

阿里云的 swap 需要手动设置,阿里云不仅仅没有提供 swap 内存,同时也将 swappiness 设置为 0。

理由也很简单,一是阿里云的硬盘是网络盘,性能低(吞吐低,IOPS 小),二是高内存机型价格贵啊!

按照阿里云 ECS 块存储性能 文档提供的数据,40g 的 SSD 云盘吞吐量为 140MB/s ,IOPS 为 3000/s 。

(其实这个数据 swap 感觉还好吧...)

测定 Java 程序需要的最低内存

Java 程序运行的内存使用时非常复杂的一件事情,jvm 的许多部分都需要使用到内存,故确定 Java 所需最低内存的方法是实际运行 Java 程序。

将容器的 Java 环境设置为 jdk 环境(提供必要的 top/ps 命令和一些其他的 jvm 运行调试工具)。

最小堆内存

先不对容器设置内存限制,关注 jvmtop 中 GC 所占用的 cpu 比率的同时,尝试不断减小 -Xmx ,直到 gc cpu 比率过高或者程序因为 heap 内存不足退出。

java 程序运行时,使用的内存和申请来的内存可能会差很大(可能为2倍关系(数据来自 Andrei Pangin 的演讲)),该步骤的目的旨在挤掉水分(最大化堆内存使用率)。

最小非堆内存

禁用容器的 swap,设置 -Xms -Xmx 相等(即堆内存),同时设置 -XX:+AlwaysPreTouch ,确保堆内存都体现在 rss 中,之后运行程序,观察 rss 内存,减去设置的堆内存大小,所得就是非堆内存的真实大小。该内存的大小一般不随堆内存大小变化而变化(是个定值),即使变化,变化量也很小(gc所需要的内存随堆大小变化)。

关闭容器的 swap 的目的在于,关闭 swap 的情况下,rss 即为程序所需要的内存;让 -Xms 和 -Xmx 相等的目的在于,让 heap 内存固定。

总量

最小堆内存+最小非堆内存即为所需的最小内存。留一部分余量,即可设置 Docker 的内存限制。

添加模组的流程

Dockerfile 中 jre 改为 jdk

禁用 swap 启动容器。

尝试出最小堆内存(jvmtop)。

在最小堆内存的情况下,查看非堆内存(ps)。

测试启动。

内存压力测试脚本

docker run --rm -it --cap-add SYS_ADMIN -m 8g --memory-swap 8g -e 'AUTH_SERVER=http://192.168.0.2:1234/api/yggdrasil' -p 25565:25565 -p 25565:25565/udp --entrypoint bash --name mc wonld-orbis-server

JAVA_OPTS='-Xmx1700m -Xms1700m -XX:NativeMemoryTracking=summary'
java \
-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200 -XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC -XX:+AlwaysPreTouch -XX:G1NewSizePercent=30 -XX:G1MaxNewSizePercent=40 -XX:G1HeapRegionSize=8M -XX:G1ReservePercent=20 -XX:G1HeapWastePercent=5 -XX:G1MixedGCCountTarget=4 -XX:InitiatingHeapOccupancyPercent=15 -XX:G1MixedGCLiveThresholdPercent=90 -XX:G1RSetUpdatingPauseTimePercent=5 -XX:SurvivorRatio=32 -XX:MaxTenuringThreshold=1 \
-javaagent:authlib-injector-1.1.29.jar=$AUTH_SERVER \
$JAVA_OPTS \
-jar forge-1.12.2-14.23.5.2838-universal.jar \
"[email protected]"

docker exec -it mc bash
ps aux
jcmd 6 VM.native_memory summary
pmap -x 6

内存压力测试结果

当前模组安装情况下,空白游戏目录(需要第一次生成配置和世界)启动时(无玩家进入),各个内存 jvmtop 上的表现:

-e JAVA_OPTS='-Xmx1500m'   gc 一般为 0 ,能够启动
-e JAVA_OPTS='-Xmx1200m'   gc 会出现 4% 情况,能够启动
-e JAVA_OPTS='-Xmx1024m'   bc 加载结束加载 ic2 (好像)的时候,会出现一段时间 5% ,能够启动
-e JAVA_OPTS='-Xmx900m'    有时会 5% bc 加载结束加载 ic2 (好像)的时候,长时间(内存使用一直接近最大值) 6%,但是仍然启动了!启动时间相对上者变化不大。
-e JAVA_OPTS='-Xmx800m'    gc 一般会在 0.1% 偶尔3% bc 加载结束加载 ic2 (好像)的时候长时间 6%, 启动失败,原因 ic2 java heap 不足。

以上测试过程中,最后稳定下来的 jvmtop 显示的 non-heap 内存(而且最大)基本没有变化 ~300m (但是这个 non-heap 并不是真正的 non-heap)。

故最低 java 堆内存为 900m 。

swap = 0 的重要性,另外一些测试结果

如果不设置 swap 内存为空的话,测试的结果会有问题。

比如这个测试:

-m 1300m --memory-swap 1300m -e JAVA_OPTS='-Xmx900m' 在 ic2 recepi 时被 kill了
-m 1400m --memory-swap 1400m -e JAVA_OPTS='-Xmx900m' 在生成地图的时候也被 kill 了
-m 1500m --memory-swap 1500m -e JAVA_OPTS='-Xmx900m' 地图生成完成后 AromaBackup 备份时,启动失败, heap 溢出 
-m 2g -e JAVA_OPTS='-Xmx1700m'                       ok
-m 2g --memory-swap 2g -e JAVA_OPTS='-Xmx1600m'      killed
-m 1600m --memory-swap 1600m -e JAVA_OPTS='-Xmx900m' ok
-m 1600m --memory-swap 4g -e JAVA_OPTS='-Xmx900m'    ok
-m 800m --memory-swap 4g -e JAVA_OPTS='-Xmx900m'     卡在 ic2 加载,手动结束了测试

800m 内存 就

另外一些测试结果

在 server-profiling 目录里。(swap 已关)

rss-heap 在不同堆大小的情况下,差别不大,最大为 685m 。

最小启动内存

故最小堆内存为 900m,最小非堆内存为 685m ,共计 1600m 内存。

该结果仅仅为第一次启动(创建配置、存档)所需最低内存。真实情况下内存使用会比这个大。例如 900m 堆内存是无法启动最开始玩的存档的。

一些启动时间相关的测试

从全新容器启动,直到服务器提示 Can't keep up 。

测试平台 i7-4790k 16g win10 docker wsl2。

没有屏蔽网络(但是网络应该是通畅的),没有固定种子,结果可能会稍微有点偶然性,但是能代表趋势。

命令:docker run --cap-add SYS_ADMIN -dit {limitation} -e 'AUTH_SERVER=http://192.168.0.2:1234/api/yggdrasil' -p 25565:25565 -p 25565:25565/udp --name mc wonld-orbis-server

无限制 132s
-m 1600m --memory-swap 1600m -e JAVA_OPTS='-Xmx900m' 195s
--cpus 1 -m 1600m --memory-swap 1600m -e JAVA_OPTS='-Xmx900m' 367s
--cpus 2 -m 1600m --memory-swap 1600m -e JAVA_OPTS='-Xmx900m' 252s
--cpus 2 -m 2g --memory-swap 2g -e JAVA_OPTS='-Xmx1200m' 158s
j1900 启动之前的游戏存档 --cpus 2 -m 2g --memory-swap 4g -e JAVA_OPTS='-Xmx1500m' 550s

测试结果上来看,最后一个限制,相对于无限制,时间开销为 1.2 倍,在节省处理器、内存资源和保证启动速度达到了一个很好的平衡。

看起来给 mc 服务器设置 2-3 个核心建议还是蛮实用的,除了少数时间加载 Mod 的时候 cpu 使用率会升到 400% ,甚至 500 ,绝大多数时期,包括地图生成的时候,处理器使用率一般也就 200% ,idle 的时候大概也就 5% 。

Minecraft is still Single-Threaded? | Sponge Forums

维护网站需要一定的开销,如果您认可这篇文章,烦请关闭广告屏蔽器浏览一下广告,谢谢!
加载中...

(。・∀・)ノ゙嗨,欢迎来到 lookas 的小站!

这里是 lookas 记录一些事情的地方,可能不时会有 lookas 的一些神奇的脑洞或是一些不靠谱的想法。

总之多来看看啦。