GCEにマイクラサーバーを立てた記録

## はじめに

MineCraft、楽しいですよね。
ステイホームな昨今、仲間内で気軽に集って遊ぶ用にマイクラのサーバーを立てる計画が立ちました。

さくらのVPSやConoHaといった便利なレンタルサーバーもあるようですが、
今回はお勉強も兼ねて自力でGCPで立てることとしました。

## 前提

- GCPコンソールが利用できること。
- discordアカウントを持っていること。

## 要件

好きな時にログインでき、最大10人弱が同時にログインできるような環境が求められてました。
課金額を最小限にするために遊ぶときだけサーバーをオンにしたいですが、筆者以外のメンバーはGCPのGの字も知らないので、誰でも簡単にサーバーオンオフができる仕組みが必要でした。
あと、極力サクッと。。（構築じゃなくて建築がしたいんです）

## 構成

### MineCraftサーバー
GCEインスタンスに[公式](https://www.minecraft.net/en-us/download/server/bedrock)から出ているサーバーソフトウェアを入れて構築します。
また、マイクラに接続するためにudp: 19132のポート開放が必要なため、firewallをかませます。

### サーバーのオンオフ

普段ゲーム中の通話ツールとして使用しているdiscordなら馴染みやすいだろうということで、
discordのチャットから、サーバーの起動・停止をするBotを作ることとします。
Botのデプロイ先として、マイクラサーバーとは別のGCEインスタンスを用意します。

また、遊び終わった後のサーバーオフを忘れて課金額がかさむのは悲しいですね。
そんなことがないよう、CloudFunctions + CloudSchedulerで定時にサーバーを必ずオフにする仕組みを作ります。

図で表すとこんな感じ
![micra_architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1957879/66b9f5ce-db5b-fd52-fc04-957bd6f66092.png)

## 手順

ここから長くなりますが、各構築手順について細かく説明していきます。

### MineCraftサーバー構築

#### 1. インスタンスの作成

「Compute Engine」 → 「VMインスタンス」 →  「インスタンスを作成」でマイクラ用のインスタンスを作りましょう。
各パラメータは以下の通り。特に書いてないやつは任意で大丈夫です。

| 項目 | 値 | 備考 |
|:-:|:-:|:-:|
|名前| minecraft-server  | - |
|リージョン| asia-northeast1  | -  |
| ゾーン  |  asia-northeast1-b | -  |
| マシンタイプ  | n1-standard-1  | スペックが低いとゲームが重くなるので、ある程度は確保したいです  |
| ブートディスク  | Ubuntu 20.04 LTS  | Ubuntu用の公式サーバーソフトウェアを使うので  |
| ラベル  |  キー：`env`<br> 値：`minecraft`| 最後のCloudScheduler設定で必要になります  |
| ネットワークタグ  |  任意 | ファイアウォール設定用に必要なので何かしらつけておきましょう  |


#### 2. 外部IPアドレスの固定

静的IPアドレスを取得して、先ほど作成したインスタンスに設定します。
「ネットワーキング」 → 「VPCネットワーク」 → 「外部IPアドレス」 → 「静的アドレスを予約」で設定画面へ移り、
各値を入れて「予約」を押下します。


| 項目 | 値 | 備考 |
|:-:|:-:|:-:|
|名前| 任意  | 適当でOK |
|リージョン| asia-northeast1  | インスタンスと合わせます  |
|接続先| 1で立てたマイクラサーバー名  | - |


#### 3. ファイアウォールの設定

「ネットワーキング」→「VPCネットワーク」→「ファイアウォール」→「ファイアウォールルールを作成」で設定画面へ移ります。
各値を入れて「作成」を押下します。

| 項目 | 値 | 備考 |
|:-:|:-:|:-:|
|名前| 任意  | - |
|ターゲット| 指定されたターゲットタグ  | - |
|ターゲットタグ| 1で設定したネットワークタグ  | - |
|ソースフィルタ| IP範囲  | -  |
|ソースIPの範囲| 0.0.0.0/0  | -  |
|プロトコルとポート| 指定したプロトコルとポート  | -  |
|udp| 19132  | -  |

#### 4. サーバーの設定

ここから本番。インスタンスにSSH接続して各種設定をしていきます。

##### 4-1. SSH接続

「Compute Engine」 → 「VMインスタンス」でコンソールを開きます。
1で作成したインスタンスを起動したら、SSH接続してください。

##### 4-2. MineCraftのDL

[公式](https://www.minecraft.net/en-us/download/server/bedrock)からサーバーソフトウェアをDLします。
事前にUbuntu版サーバーソフトウェアのURLをコピーしておきましょう。

```bash
#!/bin/bash

# 初めの準備
sudo apt update
mkdir /home/minecraft
cd /home/minecraft

# MinecraftサーバーソフトウェアをDL(コピーしたURLを使う)
wget https://minecraft.azureedge.net/bin-linux/bedrock-server-1.17.11.01.zip

# DLしたzipファイルの解凍
apt install -y zip unzip
unzip bedrock-server-1.17.11.01.zip
```

##### 4-3. MineCraftサーバーの常時稼働

実は4-2終了時点で、クライアント側からマイクラサーバーへの接続はできるようになっています。
が、このままではSSHを切断した時点でサーバーが落ちてしまうので、SSH接続を切ってもサーバーが稼働し続けるようにします。

```bash
#!/bin/bash

# 実行権限の変更
sudo chmod 755 /home/minecraft/bedrock_server

# screenでbedrock serverを起動
LD_LIBRARY_PATH=. screen -dmS bds /home/minecraft/bedrock_server
```

screenコマンドについては[こちらの記事](https://qiita.com/non_cal/items/008ef7e1a8bc03415817)を参考にさせていただきました。

##### 4-4. インスタンスの起動・停止に合わせたサーバーの起動・停止

現時点では、インスタンスを停止後再起動したら、またSSH接続してサーバーを起動しにいかなければいけません。
そんなことは到底面倒くさいので、インスタンスに合わせてサーバーも起動するようにします。

まずはインスタンスを停止してください。
停止したら、「インスタンスの詳細」→「編集」で編集画面に移り、カスタムメタデータの編集を行います。
①起動用
キー: `startup-script`

```bash:値
LD_LIBRARY_PATH=. screen -dmS bds /home/minecraft/bedrock_server
```

②停止用
キー: `shutdown-script`

```bash:値
screen -r bds -X stuff 'stop\n'
```

### discordBot構築

ここからは、マイクラサーバーの起動・停止を担うdiscordBotを作成していきます。


#### 1. インスタンスの作成

マイクラサーバー用と立て方は同じです。
「Compute Engine」 → 「VMインスタンス」 →  「インスタンスを作成」でインスタンスを作りましょう。
discordBot用はスペックはいらないので、各パラメータは無料枠に収まるようにします。

| 項目 | 値 | 備考 |
|:-:|:-:|:-:|
|名前| discord-bot  | - |
|リージョン| us-central  | -  |
| ゾーン  |  us-central1-a | -  |
| マシンタイプ  | e2-micro  | 無料枠のスペックは[公式ドキュメント](https://cloud.google.com/free/docs/gcp-free-tier/?hl=ja#free-tier)を参照しておきましょう  |
| ブートディスク  | Ubuntu 20.04 LTS  | マイクラサーバーと合わせましたが、何でもいいです  |
| ファイアウォール  |  HTTPトラフィックを許可する,HTTPSトラフィックを許可する  | discordとの通信のために開ける必要があります  |

#### 2. サーバーの設定

##### 2-1. SSH接続

SSH接続して、必要なものを入れていきます。
「Compute Engine」 → 「VMインスタンス」でコンソールを開いたら、インスタンスを起動してSSH接続してください。

##### 2-2. discord.pyのインストール

今回dicordBotは[discord.py](https://discordpy.readthedocs.io/ja/latest/index.html)を利用して作成します。GCE内にdiscord.py関連を入れていきましょう。

```bash
#!/bin/bash

# 初めの準備
sudo yum update

# pythonのインストール
yum install python3ｌ　　

# discord.pyのインストール
python3 -m pip install -U discord.py
```
##### 2-2. Google Cloud SDK関係のインストール

マイクラサーバーのオンオフはGCEの操作になるため、SDKをインストールする必要があります。
こちらは[参考にさせていただいたこちらの記事](https://qiita.com/thinceller/items/6bc7d28a04a8da75e818#google-cloud-sdk%E9%96%A2%E4%BF%82)の通りに行えば問題ありません。
ここで必要な作業は下記3点です。

- Google Cloud SDKのインストール
- `$ gc![discord.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1957879/8bdb30b0-64ea-d4d7-0274-d7e19a962559.png)
loud init`による初期化
- Google API Clientのインストール

#### 3. discordの設定

ここではGCPを離れ、discord側を触っていきます。
といっても作業は多くありません。

##### 3-1. サーバー/テキストチャンネル作成

普段使いのサーバーがあればそれを使えば構いません。discordが初めて！という場合だけ、サーバーを作成しましょう。
またBotにチャットを送るように、テキストチャンネルを一つ用意しましょう。
![discord.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1957879/78bf79f9-9e2c-ea31-6168-008346af3355.png)

##### 3-2. Bot登録/トークン取得

[Discord Developer サイト](https://discord.com/developers/applications)から、以下を実施します。
- 新規Bot登録
- Bot用トークン取得

具体的な手順は[こちらの記事](https://qiita.com/PinappleHunter/items/af4ccdbb04727437477f#bot%E7%94%A8%E3%81%AE%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3%E3%82%92%E6%89%8B%E3%81%AB%E5%85%A5%E3%82%8C%E3%82%8B)を参考にさせていただきました。

##### 3-3. Botの実装
いよいよBotの実装です。
[こちら](https://qiita.com/thinceller/items/6bc7d28a04a8da75e818#%E5%AE%9F%E8%A3%85)のbot.pyを参考にさせていただきました。

```python:dsicordBot.py
# 各種import
import asyncio
from time import sleep
from googleapiclient import discovery
import discord

# BOTのアクセストークン
TOKEN = '(取得したトークン)'

# 接続に必要なオブジェクトを生成
client = discord.Client()

# GCEインスタンス情報
PROJECT = '(PROJECT_ID)'
ZONE = 'asia-northeast1-b'
INSTANCE = '(INSTANCE_NAME)'

# Build and initialize google api
compute = discovery.build('compute', 'v1')

# Bot起動時に動作する処理
@client.event
async def on_ready():
    print('Logged in as')
    print(client.user.name)
    print(client.user.id)
    print('------')


# Discord - GCE間のやりとり
@client.event
async def on_message(message):
    # /minecraft から始まる各種コマンド'
    if message.content.startswith('/minecraft'):
        command = message.content.split(' ')[1]
        channel = message.channel

        # インスタンス起動コマンド'
        if command == 'start':
            m = await channel.send('Server starting up...')
            start_server(PROJECT, ZONE, INSTANCE)
            await m.edit(content='Success! Server started up.')
            sleep(10)
            await m.delete()
        
        # インスタンス停止コマンド'
        elif command == 'stop':
            m = await channel.send('Server stopping...')
            stop_server(PROJECT, ZONE, INSTANCE)
            await m.edit(content='Success! Server stopped.')
            sleep(10)
            await m.delete()

        # インスタンスのstatus確認コマンド'
        elif command == 'status':
            status = get_server_status(PROJECT, ZONE, INSTANCE)
            if status == 'RUNNING':
                m = await channel.send('Server is running! Please enjoy Minecraft!')
                sleep(10)
                await m.delete()
            elif status in {'STOPPING', 'STOPPED'}:
                m = await channel.send('Server is stopped. If you play Minecraft, please chat in this channel, `/minecraft start`.')
                sleep(10)
                await m.delete()
            else:
                m = await channel.send('Server is not running. Please wait for a while, and chat in this channel, `/minecraft start`.')
                sleep(10)
                await m.delete()
            await channel.send(m)

# GCE操作の各関数定義
def start_server(project, zone, instance):
    compute.instances().start(project=project, zone=zone, instance=instance).execute()

def stop_server(project, zone, instance):
    compute.instances().stop(project=project, zone=zone, instance=instance).execute()

def get_server_status(project, zone, instance):
    res = compute.instances().get(project=project, zone=zone,
                                  instance=instance).execute()
    return res['status']


# Botの起動とDiscordサーバーへの接続
client.run(TOKEN)
```

作成できたら、任意のフォルダに配置しましょう。

##### 3-4. botのサービス化

bot起動のために、サービス化してしまいます。
サービス化については[こちらの記事](https://qiita.com/min06s/items/cca78fb9796406e6dc80)等を参照しました。

```bash:serviceの作成
sudo su -
cd /etc/systemd/system
vi ./mc-discord-bot.service
```

```text:serviceの設定
[Unit]
Description = Discord bot deamon

[Service]
User = (ユーザーの名前)
ExecStart = (botの配置パス)
Restart = always
Type = simple

[Install]
WantedBy = multi-user.target
```

サービス登録出来たら、起動します。

```bash:serviceの作成
#!/bin/bash
sudo systemctl reload
sudo systemctl enable mc-discord-bot.service
sudo systemctl start mc-discord-bot.service
```

##### 3-5. discordBotの稼働確認

ここまでで、discordのチャットからコマンドを入力すると、マイクラサーバーが起動/停止するようになります。
Bot作成時に指定したサーバーのテキストチャンネルから、以下を入力してみましょう。
discordへのBotからの返事＋GCEインスタンス操作ができれば成功です。

- `/minecraft start` ：マイクラサーバーの起動
- `/minecraft stop`　：マイクラサーバーの停止
- `/minecraft status`：マイクラサーバーのステータス確認

### 定時shutdown処理実装

最後に、定時でマイクラサーバーを停止する処理をCloudFunctions + CloudSchedulerを使って実装していきます。

#### 1. 関数の作成

「Cloud Functions」 → 「関数の作成」から新しい関数を作りましょう。
discordBot用はスペックはいらないので、各パラメータは無料枠に収まるようにします。

| 項目 | 値 | 備考 |
|:-:|:-:|:-:|
|関数名| stopInstance  | - |
|リージョン| asia-northeast1  | -  |
| トリガー  | Cloud Pub/Sub  | -  |
| Cloud Pub/Subトピック選択  | トピックを作成する  | 適当な名前でいいので、トピックを作成してしまいましょう  |
![cloudfunctions.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1957879/a55161b0-1a0f-c814-eb89-83054126bd5d.png)

#### 2. コードの作成

[公式ドキュメント](https://cloud.google.com/scheduler/docs/start-and-stop-compute-engine-instances-on-a-schedule?hl=ja)を参考に、今回はJavascriptで作成しました。
- ランタイム : Node.js 10 
- エントリポイント : stopInstance

```js:index.js
const Compute = require('@google-cloud/compute');
const compute = new Compute();

/**
 * Stops Compute Engine instances.
 *
 * Expects a PubSub message with JSON-formatted event data containing the
 * following attributes:
 *  zone - the GCP zone the instances are located in.
 *  label - the label of instances to stop.
 *
 * @param {!object} event Cloud Function PubSub message event.
 * @param {!object} callback Cloud Function PubSub callback indicating completion.
 */
exports.stopInstancePubSub = async (event, context, callback) => {
  try {
    const payload = _validatePayload(event);
    const options = {filter: `labels.${payload.label}`};
    const [vms] = await compute.getVMs(options);
    await Promise.all(
      vms.map(async instance => {
        if (payload.zone === instance.zone.id) {
          const [operation] = await compute
            .zone(payload.zone)
            .vm(instance.name)
            .stop();

          // Operation pending
          return operation.promise();
        } else {
          return Promise.resolve();
        }
      })
    );

    // Operation complete. Instance successfully stopped.
    const message = 'Successfully stopped instance(s)';
    console.log(message);
    callback(null, message);
  } catch (err) {
    console.log(err);
    callback(err);
  }
};

/**
 * Validates that a request payload contains the expected fields.
 *
 * @param {!object} payload the request payload to validate.
 * @return {!object} the payload object.
 */
const _validatePayload = event => {
  let payload;
  try {
    payload = JSON.parse(Buffer.from(event.data, 'base64').toString());
  } catch (err) {
    throw new Error('Invalid Pub/Sub message: ' + err);
  }
  if (!payload.zone) {
    throw new Error("Attribute 'zone' missing from payload");
  } else if (!payload.label) {
    throw new Error("Attribute 'label' missing from payload");
  }
  return payload;
};
```

```json:package.json
{
  "name": "cloud-functions-schedule-instance",
  "version": "0.1.0",
  "private": true,
  "license": "Apache-2.0",
  "author": "Google Inc.",
  "repository": {
    "type": "git",
    "url": "https://github.com/GoogleCloudPlatform/nodejs-docs-samples.git"
  },
  "engines": {
    "node": ">=12.0.0"
  },
  "scripts": {
    "test": "mocha test/*.test.js --timeout=20000"
  },
  "devDependencies": {
    "mocha": "^8.0.0",
    "proxyquire": "^2.0.0",
    "sinon": "^10.0.0"
  },
  "dependencies": {
    "@google-cloud/compute": "^2.0.0"
  }
}
```

#### 3. CloudScheduler設定

「CloudScheduler」 → 「ジョブを作成」から新しいジョブを作ります。

| 項目 | 値 | 備考 |
|:-:|:-:|:-:|
|名前| stopInstanceEvent  | - |
|頻度| 0 4 * * *  | 例えば毎日朝4時に関数を動かしたい場合の設定です  |
| タイムゾーン  | 日本標準時  | -  |
| ターゲットタイプ  | Pub/Sub  | -  |
| Cloud Pub/Subトピック選択  | 1で作成したトピック  | -  |
| メッセージ本文  | `{"zone":"asia-northeast1-b","label":"env=minecraft"}`  | ここで対象とするインスタンスを指定しています。  


## 最後に

長々と手順を書いてきましたが、ここまで実施すると以下ができるようになっています。

- GCE上に立っているマイクラサーバーへの接続
- discordからコマンドを打つことでマイクラサーバーの起動/停止
- 毎朝4時にマイクラサーバーの停止（仲間にはサーバーダウンの時間を周知しておきましょう）

無事遊べますね！ここからWin10版しかりswtich版からサーバーに接続していくことになりますが、
それはまた別のお話で。。。