GCPでなるべく安くMinecraft Mod鯖を運用してみた話

# 概要

友人間でMinecraftを遊ぶ流れになりましたが、
バニラは飽きちゃってたのでModを導入しました。

最初はおうちPCでMod鯖を運用してたのですが、長期休暇で家を離れることになったので...
クラウド上になるべく安い構成のMod鯖を構築してみたので覚書。

## 規模

- 5人前後
- 昼は全く稼働しないけど夜は早朝まで稼働
- 南は九州、北は北海道なのでネットワーク品質は気になるところ
- 少々鯖が不安定でも気にしない
- 1～2か月くらい遊ぶ想定

# 方針

- Modの導入はModPackを利用して導入負荷軽減
- SpotVMのGCEインスタンスを利用してコスト削減
- 遊んでる人がいる時だけ鯖を立ち上げコスト削減
  - 接続者が居なくなったら自動でGCEインスタンスをSuspend
  - Discord botを用意してGCEインスタンスの立ち上げは手動で行う

# 構築

## Mod導入

https://www.feed-the-beast.com/modpacks/119-ftb-presents-direwolf20-120

かなり盛り盛りのModPackなのでサーバー処理負荷は高いです。

- クライアント
  - FTB AppをインストールしてそこからModPackを選ぶだけ
- サーバー
  - 導入方法ページ
    - https://docs.feed-the-beast.com/docs/support/Guides/Server/
  - ModPackページ -> Versionsタブ -> Server FilesのLinuxを選択してインストーラ取得
    - これを以下GCEインスタンスで実行するだけ

## GCEインスタンス

なるべく安くしたいので少々重くても体感で許容できるちょうどいいマシンを選びます。

### インスタンス選定

- リージョン
  - Oregon (us-west1)
    - 安いが...試しにデプロイしたところチャンク読み込み時にネットワークタイムアウトが発生
    - 大量のModブロックが動作しているチャンク読み込みでもかなりロールバックが起こり流石に耐えれなかった 
  - Taiwan (asia-east1)
    - かなりお安めだが北海道ユーザーにてロールバックが頻発したため不採用
  - Tokyo (asia-northeast1)
    - ★採用★
    - 少々お高めだがネットワーク品質は文句なし
      - むしろおうち鯖で運用してた時より安定
- スペック
  - 共有コア(e2-micro, e2-small, e2-medium)
    - MOD鯖のためメモリ8GBは必要...メモリ不足でまともに動作しなかった
  - e2-standard-2
    - vCPU: 2
    - Memory: 8GB
    - 最初こちらで運用していたがチェンク生成がかなり時間がかかる...
      - 最終的にはn2を採用したのだが、e2はどうやら定期的にCPUスパイクが発生し得るワークロードには不向きかもしれない
  - n2-standard-2
    - ★採用★
    - vCPU: 2
    - Memory: 8GB

asia-northeast1/n2-standard-2を利用する方針としました。

### インスタンス作成
リージョンやスペックは上記参照。

- ストレージ
  - 標準永続ディスク: 20～40GB
    - ディスク負荷は比較的低いのでバランス永続ディスクやSSD永続ディスクでなくてOK
      - MODワールドは体感1ワールド5GBは使うので20GBだと途中で拡張作業が必要かも
    - ただし定期的にフルバックアップを取るMODが入っているので容量はこちらのMODを調整しないと20GBは簡単に使い潰してしまうため注意
      - https://www.curseforge.com/minecraft/mc-mods/ftb-backups-forge
- 可用性ポリシー
  - ★一番大事な項目★
  - VM プロビジョニング モデルを `スポット` とすること
  - https://cloud.google.com/compute/docs/instances/spot?hl=ja
    - スポットインスタンスにすることでコストを1/3にすることができる
    - ただしGCPが高負荷になった際にプリエンプト(VM中断)が発生する可能性がある
      - 半年近く運用したが実際にプリエンプトされたのは１回だけだった
      - 身内で遊ぶくらいなら最適な選択

### インスタンスセットアップ

#### MODサーバー導入

1. ModPackのインストーラーを取得
   1. (例) `curl -L https://api.feed-the-beast.com/v1/modpacks/public/modpack/119/12252/server/linux > serverinstall_119_12252`
1. インストーラーに実行権限を付与し実行
   1. `chmod +x serverinstall_119_12252`
   1. `./serverinstall_119_12252`
1. サーバー起動確認
   1. `./start.sh`

GCEのファイアーウォールの調整、接続確認などは省きます

#### tmux導入/systemdからの自動起動

こちら参考にさせてもらいました。

https://0sn.net/posts/20220821/mcserver-systemd/

## GCEインスタンスの自動Suspend

Minecraftサーバーへの接続者を30分毎にチェックし、
30分間接続者が居なかったらインスタンスをSuspendさせる簡単なScriptを組みます。

```/root/autoSuspend.bash
#!/bin/bash
WORKDIR=$(pwd)
cd "$(dirname "$0")"

BEFORE=$(cat BEFORE.txt)
CURRENT=$(netstat -a | grep -G .*【MinecraftサーバーPort番号】.*ESTABLISHED | wc -l)
echo $CURRENT > BEFORE.txt
if [ $BEFORE -eq 0 ] && [ $CURRENT -eq 0 ]; then
        gcloud compute instances suspend 【GCEインスタンス名】 --zone 【GCEインスタンスZone】
fi
```

これをcron登録しておきます。
```bash
# crontab -e

15,45 * * * * cd /root; bash autoSuspend.bash
```

毎時15, 45分でサーバー接続者をチェックし接続人数をテキスト出力、
前回も今回もチェック時に接続者0人なら自身のインスタンスをSuspendさせます。

（厳密に言うとチェックタイミングに偶然誰も接続してなかったらSuspendしちゃうけど身内用なので気にしない）
（毎時0, 30分はftb-backups-forgeのバックアップ処理のタイミングなので15, 45分にずらしてます）


### サービスアカウントの作成

上記スクリプトや後述のDiscord botではGCEインスタンスからGCEインスタンスのStart/Suspendを行わせたいので専用のサービスアカウントを作成します。

1. GCPコンソールを開く
1. IAM と管理 -> ロール
1. ロール作成
    1. 名前 `instanceMng`
    1. 権限
        1. ```
            compute.instances.get
            compute.instances.reset
            compute.instances.resume
            compute.instances.start
            compute.instances.stop
            compute.instances.suspend
            ```
1. IAM と管理 -> サービスアカウント
1. サービスアカウント作成
    1. 名前 `instanceMng`
1. IAM と管理 -> IAM
    1. 作成したサービスアカウント
       `instancemng@【GCPプロジェクト名】.iam.gserviceaccount.com` に、
       ロール `instanceMng` を付与

作成後、前途のminecraftサーバーのGCEインスタンスについて、
instancemng@～のサービスアカウントを割り当てるよう変更が必要です。

## Discord botの導入

前途のサービスアカウント作成にて付与した以下権限の操作が可能なDiscord botを作ります。
- compute.instances.get
  - インスタンス状態取得
- compute.instances.reset
  - インスタンス再起動
- compute.instances.stop
  - インスタンス停止
- compute.instances.start
  - インスタンス起動
- compute.instances.suspend
  - インスタンス一時停止
- compute.instances.resume
  - インスタンス再開（Suspend後）

新規GCEインスタンスを作成しますが、GCPの無料枠に収まるよう調整します。

https://cloud.google.com/free/docs/free-cloud-features?hl=ja#compute

### インスタンスセットアップ

- zone: `us-west1`
- インスタンスタイプ: `e2-micro`
- ディスク: 標準永続ディスク20GB
- サービスアカウント: `instancemng@～`

無料枠の利用目的なので可用性ポリシーは標準のままでよさそうです。

### コード

こちらを参考にさせていただきました。

https://qiita.com/nahiro_tus/items/6b90699ab5aba8dbbede

```
import discord
import os
import subprocess
import time
import random


### define
# Discord Bot アクセストークン
TOKEN = '★★★'
# インスタンス名
INSTANCE = '★★★'
# インスタンスのzone
ZONE = '★★★'

### OSコマンド実行
def exec_subprocess(cmd: str, raise_error=True):
    child = subprocess.Popen(cmd, shell=True,
                             stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    stdout, stderr = child.communicate()
    rt = child.returncode
    if rt != 0 and raise_error:
        raise Exception(f"command return code is not 0. got {rt}. stderr = {stderr}")

    return stdout, stderr, rt

intents = discord.Intents.default()
client = discord.Client(intents=intents)

### 起動処理
@client.event
async def on_ready():
    print('discord鯖接続完了')
    print('/help でコマンド確認')

### メッセージ受信処理
@client.event
async def on_message(message):

# ヘルプの表示
    if message.content == '/help':
        await message.channel.send('/status  : 状態確認') 
        await message.channel.send('/start   : 起動')
        await message.channel.send('/stop    : 停止') 
        await message.channel.send('/resume  : 再開')
        await message.channel.send('/suspend : 中断') 


## インスタンス操作
    else:
        cmd = ''

        if message.content == '/status':
            await message.channel.send('=== インスタンス状態取得 ===')
            cmd = 'gcloud compute instances describe ' + INSTANCE + ' --zone ' + ZONE + ' | grep status:'

        elif message.content == '/start':
            await message.channel.send('=== インスタンス起動開始 ===')
            cmd = 'gcloud compute instances start ' + INSTANCE + ' --zone ' + ZONE + '| echo DONE!'

        elif message.content == '/stop':
            await message.channel.send('=== インスタンス停止開始 ===')
            cmd = 'gcloud compute instances stop ' + INSTANCE + ' --zone ' + ZONE + '| echo DONE!'

        elif message.content == '/resume':
            await message.channel.send('=== インスタンス再開開始 ===')
            cmd = 'gcloud compute instances resume ' + INSTANCE + ' --zone ' + ZONE + '| echo DONE!'

        elif message.content == '/suspend':
            await message.channel.send('=== インスタンス中断開始 ===')
            cmd = 'gcloud compute instances suspend ' + INSTANCE + ' --zone ' + ZONE + '| echo DONE!'

## コマンド実行
        if cmd != '':
            stdout, stderr, rt = exec_subprocess(cmd)
            if len(stdout) != 0:
                await message.channel.send(stdout.decode("utf-8"))
            if len(stderr) != 0:
                await message.channel.send('stderr: ' + stderr.decode("utf-8"))
            await message.channel.send('--- コマンド実行完了 ---')
```

![SnapCrab_NoName_2024-9-29_1-39-51_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/56893/19de0746-0f4f-4fee-59a8-651ec8bd141f.png)

# 実際に運用してみた結果

拠点にて複数の工業Mod、魔法Modのブロックを動作させたり、
チャンクローダーも数十個配置されていましたが平時は特にストレスなく遊べていました。

ただしチャンク生成処理だけはかなり重く、ワールドを広げようとするとチャンクの境界で待つ必要がありそこはネックでした。
エンドをエリトラで移動するとチャンク生成を空中で待つ必要があり厳しかったですが...
Modなのでホバーパックとか浮遊魔法とかで対処出来たりで逆にいい縛りになったかなと言ったところです。


かなりケチって5人前後なら十分な構成に出来たかなと思います。
4月末から6下旬まで運用し、GW中はフル稼働、それ以降は1日6時間程度の稼働でした。

さて気になるコストですが...

## かかった費用

![SnapCrab_NoName_2024-9-29_1-42-37_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/56893/bb7f5836-5626-0667-6fec-204849eea2cf.png)

月3000円ちょいになりました。

Minecraft MODレンタルサーバーの相場も2core/8GBで月3000円前後なので微妙な結果です...
とはいえ使い回しやすいインスタンスを整えれたのでいい経験になったかなと思います。