⛏️

Minecraft サーバー用の VM の選び方

2024/03/13に公開

クラウドエースバックエンドエンジニアリング部の滝本です。

本記事では Google Cloud で Minecraft サーバーを立てる際に、どのような考え方でスペックを選ぶのか、また現時点で存在するマシンタイプからおすすめの VM を紹介します。

おすすめのマシンタイプ

「今すぐサーバー立てて友達と遊びたいんじゃ!!」という忙しい方向けに、まずいくつかおすすめのマシンタイプを紹介します。

Machine type vCPU RAM CPU Model / Base - Boost Freq. Comment
n2d-highcpu-4 4 4GB AMD EPYC 7B13 / 2.45 - 3.5GHz 迷ったらとりあえずこれ
n2-highcpu-4 4 4GB Xeon Gold 6268CL / 2.8 - 3.9GHz 安くて悪くない
n2d-highcpu-8 8 8GB AMD EPYC 7B13 / 2.45 - 3.5GHz 余裕がほしいときに
c2-standard-4 4 16GB Xeon Gold 6253CL / 3.1 - 3.9GHz メモリ多く必要なときに
現在東京もしくは大阪リージョンに存在しないが、Minecraft サーバーに適したマシンタイプ

Google Cloud の公式サイトに記載されていますが、東京もしくは大阪リージョンに提供されていない場合があります。以下 2 種類のマシンタイプはまだ検証していないが、スペック上 n2d 以上の性能を発揮してくれるはずなので、利用可能になったらぜひ試してください。

  • c3d-highcpu-4
  • c3d-highcpu-8

また、ARM ベースの t2a も、もしかしたら低コストサーバーとして有力候補になるかもしれません。今後に期待しましょう。

  • t2a-standard-4

Minecraft 用のサーバーを選ぶ際の考え方

クロック数が高いのは基本的に有利、コア数は多少ほしい

Minecraft は 2011 年発売のゲームで、当時の一般的なミドルレンジの CPU は 2 ~ 4 コアまででした。その Minecraft もマルチスレッドに特化した作りにはなっておらず、メインのゲームループはシングルコアで動作します。そのため基本的にはシングルスレッドのクロック数が高い CPU のほうが有利となりますが、一部の処理(JVM の GC や Minecraft 内の Chunk 生成など)は別スレッドで実行されますので、必ずしも「コア数が多いと無駄」というわけではありません。(詳細は検証パートを御覧ください)

もちろん、単純なクロック数の数字の比較だけでなく、CPU の世代も考慮する必要があります。クロック数が同じでも、マイクロ視点で改善され続けた新しい CPU のほうが速い場合が多く、これは Minecraft サーバーだけでなく PC 購入時にも気をつけましょう。

「CPU platforms」のドキュメントから逆算

残念なことに、Cloud Console から VM を作成する際に具体的なスペックを確認することができず、マシンタイプの一覧が記載されている Machine resource guideGeneral-purpose machines などのドキュメントにも「up to 3.9GHz」程度の情報しか記載されていません。

代わりに、以下のドキュメントには具体的な型番が記載されており、これをもとに、どのマシンタイプを選ぶべきかを逆算しましょう。ただし、例えば良さげなものがあったとしても東京もしくは大阪リージョンにまだ提供されていなかったり、最低でも 16 コアしか選択肢がなかったりする場合がありますので、多少根気がいる作業となります。
https://cloud.google.com/compute/docs/cpu-platforms

将来のプレイヤー数や規模に見越して多めに積む必要はない

「現在は 3 人しかいないけど、将来的に増えるかもしれないから強めのマシンを使おう」といった心配は不要です。月単位や年単位契約の VPS と異なり、「いつでもリソースを簡単に作り直せる」というのがクラウドのメリットとなります。VM に関しては一度停止しておけば、マシンタイプの変更は 5 分程度で完了しますので、プレイヤー数や MOD の増減に合わせてマシンタイプを変更することをおすすめします。

メモリを減らして節約しょう

制約上、vCPU の数を増やすとおまけにメモリもついてきます。例えば 8 vCPU + 4GB RAM といった構成にすることはできず、場合によっては無駄にコストがかかってしまうことになります。Vanilla サーバーのみ動かす場合は 4GB(JVM のヒープに 2 〜 3GB)で十分なので、highcpu や custom を選択してメモリを減らしましょう。

性能検証

本章では「おすすめのマシンタイプ」の章で紹介した VM を実際試した結果を紹介します。

測定方法

Minecraft のサーバーとしての性能を定量化することは難しいです。大量の全自動農場やトラップタワーといった高負荷のものが建てられたり、逆にワールドが整地されてモブが一切わかなくなかったりなど、サーバーにかかる負荷はプレイヤーのプレイスタイルによって大きく変わります。

本記事では、簡易なスクリプトを作成して、以下のような実際のゲームプレイにもありそうな行為をシミュレートし、サーバー側の game tick time の推移や CPU 利用率等を確認します。(もし定番の Minecraft サーバーの性能を測定するツールが存在している場合、ぜひコメントで教えてください)

# 実施内容 対応するゲームプレイ
1 一秒ごとに以下のコマンドを実施
execute as @p at @s run tp ~30 128 0
Elytra による高速移動(マップ未生成)
2 1 を実施後、もう開始地点に戻り再度 1 を実行 Elytra による高速移動(マップ生成済)
3 指定の箇所に Chicken を 500 匹用意した上で、
高所から Creeper を一定間隔で落とす
なんらかの農場やトラップタワーを再現

各種バージョン・設定情報

項目 バージョン・設定
Minecraft Client 1.20.4 (Vanilla)
Minecraft Server Fabric
Java OpenJDK 17.0.10
Operating System Debian 12
JVM Options (4GB RAM) -Xmx3G -Xms3G
JVM Options (8GB RAM) -Xmx6G -Xms6G
seed cloud-ace
rendering distance 10
time 6000 (doDaylightCycle = false)

ベンチマーク1:高速移動による未探索マップ生成

まず Minecraft のマップ生成について簡単に説明します。プレイヤーが移動する際、サーバーはプレイヤーの周りの Chunk 情報(16 * 16 * 384 の塊の集合)を返します。対象の Chunk が初めてロードされる場合、Chunk の生成処理が走ります。

本ベンチマークは、ゲームの終盤で手に入る「Elytra」という空を飛べるアイテムを使った高速移動をシミュレートして、サーバーの状況やプレイヤーの画面をみていきます。内容としては、まずはスタート地点に少し待機した上で、毎秒 30 ブロックの速度で直線移動します。

まず今回使った最も強力な n2d-highcpu-8 で実行してみました。

この時点で判明した情報として:

  • Chunk の生成はマルチスレッドで行われる
  • 4 スレッドまでは使ってくれそうなので、他の処理を考慮して 8 コアにする価値はある
  • CPU 利用率は高いが、Chunk 生成以外の負荷はそんなにないときの tick time が低い
    • つまり、tick time だけで「サーバーが重い」という判断はできない

引き続き、他のマシンもやっていきます。


Chunk の生成速度は n2d-highcpu-8 > n2d-highcpu-4 > c2-standard-4 > n2-highcpu-4 でした。Zenn の画像サイズ制限的に映すことができませんでしたが、数秒後に左下がほぼ完全に青画面になります。サバイバルで Elytra を飛ばしていたら何かしらに衝突しそうで危険ですね。

引き続きグラフも見ていきましょう。

青が tick time で、赤が Minecraft サーバーのプロセスだけの CPU 利用率となります。右上以外は 4 vCPU で、マップ生成時の CPU 利用率が 300% 前後となります。右上の n2d-highcpu-8 は 8 vCPU ありますが、恐らく Vanilla か Fabric Server の制約でマップの生成が 4 スレッドによって行われました。余ったリソースは、ゲームロジックの部分や OS 側に使われるので、逆に 8 vCPU がちょうどいいかもしれません。

そして本テストでは最終地点到達後に 30 秒待機しており、つまりプレイヤー半径 10 chunk の生成が完了するまでの時間もついでに計測しました。左下の n2-highcpu-4 ではギリギリ計測終了の直前で CPU が戻ったに対して、右上の n2d-highcpu-8 は終点到達後のすぐに CPU 利用率が落ち着きました。

ベンチマーク2:同じルートで高速移動する

ベンチマーク 1 だけを見ると、滑らかな飛行を体験したければ月 3 万円程度の出費が発生する結論になってしまいますが、ディスクサイズを犠牲にして一度広範囲のマップを予め生成しておく手法があります(chunk pregeneration)。本ベンチマーク 2 では、ベンチマーク 1 の開始時点に戻り、全く同じルートで走行するテストを行います。

そこまで絶望的ではないですね。引き続きグラフも見ていきましょう。

どうやら、一回速いスピードで移動しただけでは、経路上の周辺の chunk は完全に生成しきれていないようですね。ベンチマーク 1 の最後に、終了地点で 30 秒ほど待機していたため、そのエリアに近付くと一気に CPU 使用率が下がることを確認できました。Chunk の事前生成で CPU 使用率を下げたい場合は、人力で飛ぶよりツールを利用しましょう。

ベンチマーク3:大量の Entity で負荷をかける

最後は Entity を大量に生成して、よくある農場の負荷を擬似的に再現します。内容はまず一箇所に Chicken を 500 匹設置して(maxEntityCramming を予め 0 に設定する)から、隣に 3000 匹の Creeper を高所から落とします。Creeper が落とした gunpowder は回収せずにそのまま置いておきます。一般的な農場より数が多いと思いますが、tick time に有意の差を作り出すために多めにしています。


4 VM ともに、CPU 使用率が 100% 張り付きという状態になり、これは Minecraft のメインゲームループがシングルスレッドであることを意味します。つまり誰も新しい場所を探索せず、サーバー内に他のツールなども走らせていない場合、余ったスレッド数が宝の持ち腐れとなってしまいます。もしかしたら、world border を設置してその範囲内のマップさえ生成すれば、あとは強力な 2 コアに任せる、というような構成も考えられるかもしれません。

そして tick time ですが、ざっくり言うと上 2 つが 60ms 弱で下 2 つが 60ms 強となります。標準の 20 TPS を維持するためには 1 game tick が 50ms 以内に終わらす必要がありますので、どの VM も満たしていないんですが、シングルスレッドの性能差を見出すための Chicken 500 匹なので通常のプレイでは問題ないでしょう。

おわりに

いかがでしたでしょうか?今後新しいマシンタイプが出ても、Minecraft サーバーに適しているかを同様な手法で判断できると思います。そして「◯◯というマシンタイプを使っていて快適ですよー」などがあればぜひコメントで教えてください。

付録

最後に、参考用に検証に利用したツールやスクリプト等を公開します。

付録:サーバー構築用スクリプト

VM 作成後、以下のようにバケットにある予め用意したスクリプトを実行し、Minecraft サーバーの初期設定を行なっています。

gsutil cp gs://作業用バケット/setup.sh - | bash
#!/bin/bash

sudo apt update
sudo apt install openjdk-17-jre tmux

mkdir minecraft
cd minecraft
wget https://meta.fabricmc.net/v2/versions/loader/1.20.4/0.15.7/1.0.0/server/jar
mv jar server.jar
echo eula=true > eula.txt
gsutil cp gs://minecraft-initial-scripts/server.properties .

mkdir mods
cd mods
wget https://mediafilez.forgecdn.net/files/5004/388/fabric_tps-1.20.4-1.4.2.jar
wget https://mediafilez.forgecdn.net/files/5135/435/fabric-api-0.96.4%2B1.20.4.jar
cd ..

java -jar -Xmx3G -Xms3G server.jar nogui

付録:server.properties

すべてのベンチマークでは以下の server.properties を利用しています。先頭の数行はベンチマークとして重要な設定を記載し、後ろの部分はベンチマークと関連のない値、もしくはデフォルト値となります。

server.properties
# Important settings related to the benchmark
level-seed=cloud-ace
difficulty=hard
gamemode=creative
generate-structures=true
view-distance=10
simulation-distance=10
enable-rcon=true
rcon.port=25575
rcon.password=cloud-ace

# Default properties
enable-jmx-monitoring=false
enable-command-block=false
enable-query=false
generator-settings={}
enforce-secure-profile=true
level-name=world
motd=Benchmark
query.port=25565
pvp=true
max-chained-neighbor-updates=1000000
network-compression-threshold=256
max-tick-time=60000
require-resource-pack=false
use-native-transport=true
max-players=20
online-mode=true
enable-status=true
allow-flight=false
initial-disabled-packs=
broadcast-rcon-to-ops=true
server-ip=
resource-pack-prompt=
allow-nether=true
server-port=25565
sync-chunk-writes=true
resource-pack-id=
op-permission-level=4
prevent-proxy-connections=false
hide-online-players=false
resource-pack=
entity-broadcast-range-percentage=100
player-idle-timeout=0
force-gamemode=false
rate-limit=0
hardcore=false
white-list=false
broadcast-console-to-ops=true
spawn-npcs=true
spawn-animals=true
log-ips=true
function-permission-level=2
initial-enabled-packs=vanilla
level-type=minecraft\:normal
text-filtering-config=
spawn-monsters=true
enforce-whitelist=false
spawn-protection=16
resource-pack-sha1=
max-world-size=29999984

付録:テスト用ツール

各種ツールも置いておきます。kotlin のバージョンは 1.9.22 を利用しています。

毎秒 30 ブロック移動
elytra.kt
fun main(array: Array<String>) {
    start("elytra", array) {
        log("elytra: goto origin")
        it.send("execute as @p at @s run tp 0 128 0")
        it.countDown(5, "standby")
        log("elytra: start main test")
        for (i in 0..<50) {
            it.send("execute as @p at @s run tp ~30 128 0")
            it.send("tp ")
            Thread.sleep(1000)
        }
        it.countDown(30, "standby")
        System.exit(0)
    }
}
Chicken / Creeper および容器生成
entity.kt
fun main(array: Array<String>) {
    start("entity", array) {
        it.send("fill 10 80 10 12 120 12 minecraft:glass")
        it.send("fill 11 81 11 11 120 11 minecraft:air")
        it.send("kill @e[type=!player]")
        it.send("kill @e[type=!player]")

        it.send("fill 5 80 10 7 85 12 minecraft:glass")
        it.send("fill 6 81 11 6 85 11 minecraft:air")
        it.countDown(10, "standby")
        val start = System.currentTimeMillis()
        for (i in 0..<500) {
            it.send("summon minecraft:chicken 6 83 11")
        }
        for (i in 0..<3000) {
            it.send("summon minecraft:creeper 11 110 11")
        }
        log("used ${System.currentTimeMillis() - start} ms.")
        it.countDown(30, "standby")
        System.exit(0)
    }
}
テストツールの共通事前処理
entry.kt
import java.net.*
import java.net.http.*
import java.net.http.HttpResponse.BodyHandlers
import java.nio.file.*
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Scanner

val FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")

fun start(name: String, array: Array<String>, callback: (IO) -> Unit) {
    val address = array.getOrElse(0) { "192.168.3.100" }
    val socket = Socket(address, 25575)
    Files.createDirectories(Path.of("output"))
    val formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
    val timestamp = LocalDateTime.now().format(formatter)
    val io = IO(socket.getInputStream(), socket.getOutputStream())
    io.auth()
    io.initializeGameRules()

    val client = HttpClient.newHttpClient()
    startProfiling(io, "output/${name}_${timestamp}_ticks.log") {
        HttpRequest.newBuilder(URI.create("http://$address:25566")).build().let {
            client.send(it, BodyHandlers.ofString()).body().trim().toDouble()
        }
    }

    val benchmarkThread = Thread {
        io.countDown(5, "\nstart")
        try {
            callback(io)
        } catch (e: InterruptedException) {
            log("stopped benchmark thread")
        }
    }.also { it.start() }

    log("press enter to stop")
    Scanner(System.`in`).nextLine()
    if (benchmarkThread.isAlive) {
        benchmarkThread.interrupt()
    }
    log("waiting the tps counter to stop")
    io.countDown(30, "end")
    System.exit(0)
}

fun IO.countDown(seconds: Int, message: String) {
    (seconds downTo 1).forEach {
        print("$it... ")
        send("say $it")
        Thread.sleep(1000)
    }
    log("\n" + message)
}

fun IO.initializeGameRules() {
    send("gamerule doDaylightCycle false")
    send("gamerule maxEntityCramming 0")
    send("time set 6000")
    send("op CrazyWinds")
}

fun log(message: String) {
    val timestamp = LocalDateTime.now().format(FORMATTER)
    println("[$timestamp] $message")
}
定期的に TPS を取得するツール
tick.kt
import java.nio.file.*
import java.nio.file.StandardOpenOption.*
import java.time.LocalDateTime

fun startProfiling(io: IO, path: String, getCpuUsage: () -> Double) {
    Thread {
        while (true) {
            val response = io.send("fabric tps")
            if (response.code != 0)
                throw RuntimeException("Non 0 when calling fabric tps")
            val ticks = parseTickInfo(response.text)
            val timestamp = LocalDateTime.now().format(FORMATTER)
            val usage = getCpuUsage()
            val string = "$timestamp,${ticks.joinToString(",")},$usage\n"
            Files.writeString(Path.of(path), string, APPEND, CREATE)
            io.send("say $string")
            Thread.sleep(500)
        }
    }.start()
}

fun parseTickInfo(log: String): List<Double> {
    val regex = Regex("Mean tick time: ([0-9.]+) ms. Mean TPS: ([0-9.]+)")
    val result = regex.findAll(log)
    return result.map {
        listOf(
            it.groups[1]!!.value.toDouble(),
            it.groups[2]!!.value.toDouble()
        )
    }.drop(3).flatMap { it.toList() }.toList()
}
RCON プロトコルの簡易実装
rcon.kt
import java.io.*
import java.lang.RuntimeException
import java.nio.*
import java.nio.ByteOrder.LITTLE_ENDIAN
import java.util.Random

fun IO.auth() {
    write(output, 3, "cloud-ace")
    with(read(input).code) {
        if (this != 0) throw RuntimeException("Auth failed ($this)")
    }
}

@Synchronized
fun IO.send(payload: String): Response {
    write(output, 2, payload)
    return read(input)
}

fun write(stream: OutputStream, type: Int, payload: String) {
    val bytes = payload.toByteArray()
    with(ByteBuffer.allocate(bytes.size + 14).order(LITTLE_ENDIAN)) {
        putInt(bytes.size + 10)
        putInt(Random().nextInt())
        putInt(type)
        put(bytes)
        put(0x00)
        put(0x00)
        stream.write(array())
    }
}

fun read(stream: InputStream): Response {
    val buffer = ByteBuffer
        .wrap(stream.readNBytes(4))
        .order(LITTLE_ENDIAN)
        .getInt()
        .let { stream.readNBytes(it) }
    val body = ByteArray(buffer.size - 10)
    if (body.isEmpty()) return Response(0, "")
    val code: Int
    ByteBuffer
        .wrap(buffer)
        .order(LITTLE_ENDIAN)
        .also { it.getInt() }
        .also { code = it.getInt() }
        .also { it.get(body, 0, body.size) }
    return Response(code, String(body))
}

data class Response(val code: Int, val text: String)
data class IO(val input: InputStream, val output: OutputStream)
特定プロセスの CPU 利用率を取得するためのツール(top コマンドの Wrapper)
monitor.kt
import com.sun.net.httpserver.HttpServer
import java.net.InetSocketAddress
import java.time.LocalDateTime

fun main(vararg pid: String) {
    if (pid.size != 1) {
        println("provide the pid of the process")
        System.exit(-1)
    }
    println(readCpu(pid[0]))
    HttpServer.create(InetSocketAddress(25566), 0).also {
        it.createContext("/") {
            val response = readCpu(pid[0]).toByteArray()
            it.sendResponseHeaders(200, response.size.toLong())
            it.responseBody.write(response)
            it.close()
        }
        it.start()
    }
}

fun readCpu(pid: String): String {
    val process = ProcessBuilder("top", "-p", pid, "-b", "-n", "2", "-d", "0.5").start()
    process.waitFor()
    return String(process.inputStream.readAllBytes())
        .split("\n")
        .dropLast(1)
        .last()
        .split(Regex("\\s+"))[9]
        .also { println("[${LocalDateTime.now().format(FORMATTER)}] $it") }
}
Makefile(ビルド後に作業用バケットに必要なツールを転送)
Makefile
main:
	kotlinc -include-runtime -d monitor.jar monitor.kt entry.kt rcon.kt tick.kt
	kotlinc -include-runtime -d elytra.jar elytra.kt entry.kt rcon.kt tick.kt
	kotlinc -include-runtime -d entity.jar entity.kt entry.kt rcon.kt tick.kt
	gsutil cp *.jar gs://作業用バケット/

Discussion