EC2とDiscordコマンドを活用したマイクラ鯖構築

この記事は [ドワンゴ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/dwango) 18日目の記事です。

## なにこれ

**Discord鯖仲間の誰かがふと思った『マイクラで遊びたいな……』**

というわけで身内用マイクラ鯖を構築するために模索する機会が発生しました。
せっかくなのでその記録をここに残します。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">AWS EC2上で動くマイクラ鯖が完成～<br>起動してる時間に応じて課金されるのでDiscordのコマンドを作って起動・停止できるようにする神対応 <a href="https://t.co/wqD8kcRRdp">pic.twitter.com/wqD8kcRRdp</a></p>&mdash; りゃま (@RyumaRyama) <a href="https://twitter.com/RyumaRyama/status/1685582739079311360?ref_src=twsrc%5Etfw">July 30, 2023</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

謎のかたつむり、りゃまがお送りいたします。

### 書くこと、書かないこと
how to記事ではありません。
環境構築の手順などを詳しく記載することはしません。

どのようなサービスを活用してどんなシステムになったのか。
細かな工夫の独断と偏見によるおもしろポイント。
ちょっとしたコードの貼り付け。
記載するのはこのあたりになります。

### 目指すもの
サブ趣味的なものなのであまりお金をかけたくありません。
とはいえ自宅PCに環境を構築してポート開放、というのもなんとなく面白味を感じませんでした。

そこで目指すは **ロマン** × **コスパ**！！
クラウドサーバーを用いた十分なスペックの鯖を出来る限り安価で構築してやろうというのを目標とします。

使用するクラウドサービスは業務でもお世話になっているAWSにします。
従量課金っていいね。

## コスパを求める

### 環境選び
雑知識からなんとなくの候補はふたつ。

- Lightsail
- EC2

ひとまず設定がお手軽そうなLightsailの無料枠を試しましたが、マイクラにはパワー不足でした。
有料枠まで手を伸ばすとなると高くつきそうだったのでEC2を使用することに決定。
結局自前で設定する環境が安いのです。

### 最安リージョンはムンバイ
EC2を使用するとはいえどうするのが一番安いのか。無料枠ではだめでした。
少しずつスペックを上げながら問題なく動作するラインを模索。
するとvcpu4、メモリ16GB程度あると安定することがわかりました。
料金表を徹底的に比較した結果ムンバイの`t4g.xlarge`が良さそうなのでこれに決定。

東京と比較して約2倍の価格差があるようです。恐ろしいですね。
レスポンス速度よろしくないので、速度が求められる場合には注意が必要です。
ssh接続でターミナルを使用するにも少しラグがあるほど。
ですがゲームには影響しませんでした。やったね！

![スクリーンショット 2023-12-17 162622.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264913/92e611ac-7bd7-dd46-f232-591837a163a9.png)

![スクリーンショット 2023-12-18 013643.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264913/bcab7829-f11e-ddaf-5737-9ec8bf495805.png)


### マイクラ特化レンタルサーバーで良い説ある...？
世の中にはマイクラに特化したサービス形態のレンタルサーバーがあるらしいです。
実はそんなに高くないのでは...？という疑問があったので比較しましょう。

ぐーぐる先生に聞くとこんなサイトを発見。
https://www.conoha.jp/game/function/minecraft/

どうやら長い期間契約する前提でお金を出すことでかなり安くなります。
今回目指すのは同時に5人以上遊ぶこともある環境かつmodの導入も考えたいので、最高スペックを選んだ際の月2000円ほど。そんなに高くない...！
しかしゲームなんて飽きが来ますね。3年間も遊び続けるとは思いません。

![スクリーンショット 2023-12-18 031013.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264913/482baca4-8f98-fba1-98d4-8107564427e8.png)

1時間ごとの課金はどうでしょう。AWSと同じ従量課金というわけです。
`14.6円/時`。
ムンバイの `t4g.xlarge` が1ドル150円で考えた場合に `13.44円/時`となるので、多少AWSが安いですね。
円安が解消すればさらに安くなります。（悪化するとお財布が大爆発します）

![スクリーンショット 2023-12-18 031204.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264913/c8e1ca9b-7b20-fcd7-955a-3bf695cdc631.png)

ロマンを追い求めることも踏まえると、EC2を活用した鯖構築をする意義は十分にありそうです。
~~EC2以外の部分で課金が発生するから実はそんなに変わらないけどね！！！~~

## 遊びやすさを求めて
従量課金であるEC2オンデマンドを利用するので、必要なときに必要な時間だけ起動することでコスパを求める形になります。
そこで起動&停止を簡単にして気軽に遊べるような仕組みを用意しましょう。

### ざっくり全体像
脳内でフローをこねこねした結果、以下の図のような構造にたどり着きました。
（Xへのポスト用にネタで作った画像を流用した適当図です許してね）

![F2SZLjxbkAA6CSc.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264913/9926bb7b-97a7-6db3-d7a1-b672878c8d84.png)

用意したシステムたちを紹介していきましょう。

### 【ユーザー ⇔ Discordコマンド】起動、停止を便利に。
今回構築したマイクラ鯖で遊ぶのは普段Discordで通話する仲間たち。
そこでDiscordのコマンドから起動、停止が行える環境を目指しました。
仕組みとしてはコマンドを実行するとEC2を操作するlambdaが叩かれるというシンプルなものです。

#### Discordコマンドを用意
[Discord Developer Portal](https://discord.com/developers/applications) というものがあり、そこでアプリケーションを作成することでトークンIDなどが取得できました。
アプリケーション設定を特定のAPIに対してPUTすることでコマンド登録が行えるようです。

今回は4つの機能を用意。

- Start: EC2の起動
- Stop: EC2の停止
- Status: EC2の状態を表示
- Cost: 今月の大まかな料金を表示

Pythonで作成したコマンド登録スクリプトがこちら。

```python
import requests

APP_ID = ""
SERVER_ID = ""
BOT_TOKEN = ""

url = f'https://discord.com/api/v10/applications/{APP_ID}/guilds/{SERVER_ID}/commands'

json = [
  {
    'name': 'minecraft',
    'description': 'マイクラ鯖の起動・停止を制御できます',
    'options': [
      {
        "name": "command",
        "description": "The type of command",
        "type": 3,
        "required": True,
        "choices": [
          {
            "name": "Start",
            "value": "start"
          },
          {
            "name": "Stop",
            "value": "stop"
          },
          {
            "name": "Status",
            "value": "status"
          },
          {
            "name": "Cost",
            "value": "cost"
          }
        ]
      }
    ]
  }
]

response = requests.put(url, headers={
  'Authorization': f'Bot {BOT_TOKEN}'
}, json=json)

print(response.json())
```

成功するとDiscord上でコマンドが確認できます。
![スクリーンショット 2023-12-18 020101.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264913/106fbec4-ab67-4f98-38bd-06da1dd25b03.png)

lambda関数作成後に取得できる実行用URLをDiscord Applicationのブラウザ設定から`INTERACTIONS ENDPOINT URL` として設定することでコマンドとの紐づけが完了します。

#### lambdaにて関数作成
Discordコマンドが実行された際にlambdaからEC2を操作するような関数を作成します。
boto3を利用してAWSリソースをあれこれするだけ。
ざっくりでもよいので日本円価格がしりたいとの要望があったため、[ExcelAPI](https://excelapi.org/)さんのAPIを使用した価格計算も実装。
記事執筆の時点では1日1万件まで無料で使用できるようです。ありがたや。

用意したものがこちら。

```python
import json
import os
import boto3
import requests

from nacl.signing import VerifyKey
from datetime import datetime
from dateutil.relativedelta import relativedelta

APPLICATION_PUBLIC_KEY = ''

verify_key = VerifyKey(bytes.fromhex(APPLICATION_PUBLIC_KEY))

def verify(signature: str, timestamp: str, body: str) -> bool:
  try:
    verify_key.verify(f"{timestamp}{body}".encode(), bytes.fromhex(signature))
  except Exception as e:
    print(f"failed to verify request: {e}")
    return False

  return True

def lambda_handler(event: dict, context: dict):
  headers: dict = { k.lower(): v for k, v in event['headers'].items() }
  rawBody: str = event['body']

  # validate request
  signature = headers.get('x-signature-ed25519')
  timestamp = headers.get('x-signature-timestamp')
  if not verify(signature, timestamp, rawBody):
    return {
      "cookies": [],
      "isBase64Encoded": False,
      "statusCode": 401,
      "headers": {},
      "body": ""
    }

  req: dict = json.loads(rawBody)
  if req['type'] == 1: # InteractionType.Ping
    return {
      "type": 1 # InteractionResponseType.Pong
    }
  elif req['type'] == 2: # InteractionType.ApplicationCommand
    action = req['data']['options'][0]['value']

    if action == 'start':
      start_ec2()
      text = req['data']['name'] + ": 鯖起動"

    if action == 'stop':
      stop_ec2()
      text = req['data']['name'] + ": 鯖停止"

    if action == 'status':
      status = get_ec2_status()
      text = req['data']['name'] + ': ステータス【' + status + '】'

    if action == 'cost':
      cost = get_cost()
      jpy = str(exchange_rate(cost))
      text = req['data']['name'] + ': 今月のコスト【' + jpy + '円】'

    return {
      "type": 4, # InteractionResponseType.ChannelMessageWithSource
      "data": {
        "content": text
      }
    }

# EC2 setting
region = 'ap-south-1'
instances = ['']
ec2 = boto3.client('ec2', region_name=region)

def start_ec2():
  ec2.start_instances(InstanceIds=instances)
  print('started your instances: ' + str(instances))

def stop_ec2():
  ec2.stop_instances(InstanceIds=instances)
  print('stopped your instances: ' + str(instances))

def get_ec2_status():
  status = ec2.describe_instances(
    Filters=[
      {
        'Name': 'instance-id',
        'Values': instances
      }
    ]
  )["Reservations"][0]["Instances"][0]['State']['Name']
  return status

def get_cost():
  ce = boto3.client('ce')
  response = ce.get_cost_and_usage(
    TimePeriod={
      'Start': datetime.now().strftime("%Y-%m-01"),
      'End' : (datetime.now()+relativedelta(months=1)).strftime("%Y-%m-01"),
    },
    Granularity='MONTHLY',
    Metrics= [
      'UnblendedCost'
    ]
  )
  return response['ResultsByTime'][0]['Total']['UnblendedCost']['Amount']

def exchange_rate(usd):
  url = "https://api.excelapi.org/currency/rate?pair=usd-jpy"
  response = requests.get(url)
  rate = response.json()
  jpy = float(usd) * float(rate)
  return int(jpy)
```

これでDiscordからの起動、停止は完璧！！

### 【EC2 ⇔ Route53】IPアドレスとドメインの紐づけ
まだやることがありました。
EC2くん、パブリックなIPアドレスを割り振るとサーバー停止時に課金が発生するんですよね。
つまり固定のアドレスを振ると支払金額が増えてしまいます。
ベストは起動時にIPアドレスを割り振ってもらい接続すること。でもマイクラの接続先設定に毎回IPアドレス入れるの面倒...

そこで必要なのがドメインとの自動紐づけです。
偶然（？）にもお遊び用ドメインである ryumaryama.com を所持していたりゃま氏。
そのサブドメインにマイクラ用のものを用意してEC2起動ごとにAレコードを更新してしまおうというのが今回の策になります。

今回のEC2ではlinux環境を用意しており、linuxにはデーモンなる機能で起動時に一定のスクリプトを実行できるようです。
にわか知識なので詳しくはわかりませんがノリでshを実装。

```sh
#!/bin/bash
DOMAIN_NAME=""
SUB_NAME=""
IP_ADDRESS=`curl -s http://169.254.169.254/latest/meta-data/public-ipv4`

HOSTED_ZONE_ID=""

BATCH_JSON='{
  "Changes": [
    { "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "'${SUB_NAME}'.'${DOMAIN_NAME}'",
        "Type": "A",
        "TTL" : 300,
        "ResourceRecords": [
          { "Value": "'${IP_ADDRESS}'" }
        ]
      }
    }
  ]
}'

aws route53 change-resource-record-sets --hosted-zone-id ${HOSTED_ZONE_ID}  --change-batch "${BATCH_JSON}"
```

EC2環境では `http://169.254.169.254/latest/meta-data/public-ipv4` にアクセスすることで自身のIPアドレスを取得できるようです。
それを利用してアドレスを取得し、aws cliからRoute53へレコードを登録します。
これでいつでも一定のURLから鯖接続ができる環境が整いました！

### 【おまけ】鯖の停止忘れを防止したい
ダメ押しにもう一本！
遊び終わった後に手動停止のみでは停止忘れによる無限課金が発生してしまうかもしれません。
誰も遊んでいない状態が確認でき次第停止する機構を追加しましょう。

手法は先ほどのデーモンくんを活用。
彼は起動時だけでなく定期的な実行も可能らしいです。
鯖起動してから10分に1回以下のスクリプトを実行するように設定しました。

```sh
#!/usr/bin/sh

ACTIVE=`expect -c "
set timeout 20
spawn rconclt localhost:25575 list
expect \"Password:\"
send \"{PASS}\n\"
expect \"Password:\"
" | grep online | sed -r "s/There are ([0-9]+) of a max of 20 players online.*/\1/"`


if [ $ACTIVE -le 0 ]; then
  echo "シャットダウン"
  curl --location 'https://discord.com/api/webhooks/{APP_ID}/{TOKEN}' \
       --header 'Content-Type: application/json' \
       --data '{
         "content": "ﾖﾝ！（自動停止）"
       }'
  aws ec2 stop-instances --instance-ids {INSTANCE_ID}
else
  echo "使用中"
fi
```

spawnと呼ばれるマイクラのコマンドを外から実行できる機能をスクリプト内で使用し、接続人数を文字列から抜き出して確認します。
おまけ機能なので力業です。

だれも遊んでいないことがわかったら鯖停止&DiscordのWebhookを叩いて通知。
かわいい柴犬BOTが自動停止をお知らせしてくれます。
[犬は4！って鳴く](https://dic.nicovideo.jp/a/4%E3%81%A3%E3%81%A6%E9%B3%B4%E3%81%8F%E7%8A%AC)よね。
![スクリーンショット 2023-12-18 024301.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264913/86078259-0f1a-c9d5-0e53-752aea16b0af.png)

## まとめ
これらの環境を整えることでDiscord鯖民が自由にEC2を立ち上げ、マイクラで遊べるようになりました。
月によって使用する時間もまちまちなので、遊ばない月は安く収まります。
まさにコスパを目指した完璧な環境になりました。

チャンネルもわいわいにぎわって楽しいね。
![スクリーンショット 2023-12-18 025427.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264913/7f68e155-e3e8-c624-fc36-1d227313b409.png)
（勝手に停止しようとしつつ課金コストで煽ってくるDiscord鯖主の図。良い子はマネしないでね）