🦄

ec2でマイクラサーバ & ec2起動停止のDiscord slash commandをlambdaにデプロイ

2021/12/18に公開

はじめに

今回は以下の記事をawsで作り直したものです。備忘録として残します。めちゃくちゃ省略して書いています。

https://zenn.dev/xianglishan/articles/45cb2271c78c3e

やりたいこと

  • 友達といつでもだれでも遊べるマイクラサーバを用意したい
  • 普段はほとんどやらないので非稼働時のコストを抑えたい
  • どうせならDiscordでメンバーならだれでもスイッチオン/オフできる
    →そのままボイスチャットで「あいつオンラインじゃんおれもやろ」みたいになったらうれしい

やったこと

ざっくりいうと以下図

前回と比べるとAzureVMをec2に、Azure functionをlambdaに変えています。

  • ec2でマイクラサーバ
  • Discord slash commandで起動/停止をlambdaにhttp req
  • lambdaからec2起動/停止

最安を求めるとAMI保存してec2インスタンス終了 & AMIから起動。でも今回はec2インスタンス停止/そのまま起動してます。

ディスクそんなに使わないのでまったく使わない月で100円くらいなのでまあええやろって感じで運用してます。

そのうち上記AMI使う感じにしたいと思いつつやってないです。

やったこと詳細

ec2でマイクラサーバ

適当にec2インスタンスを起動して、javaとminecraft serverをインストールして、systemdで自動起動設定。

インスタンスタイプは4vcpu, メモリ4Gbくらいあれば快適に遊べると思います。

この辺見ました。
https://minecraft.server-memo.net/minecraftserver-3/
https://www.minecraft.net/ja-jp/download/server

Discord slash command 登録

Discord developer portalでDiscordのbot作ってslash command登録。

この辺見てやりました。
https://zenn.dev/drumath2237/articles/112fd0bfa7ea4f836195
https://discordpy.readthedocs.io/ja/latest/api.html

TypeScriptでやったけどpythonとかでもできるよ

コードはこんな感じ

index.js
import fetch from "node-fetch";

const appID = {botのappID};
const guildID = {招待したDiscordサーバのguildID};
const apiEndpoint = 
  `https://discord.com/api/v8/applications/${appID}/guilds/${guildID}/commands`;
const botToken = {botのbotToken};

const commandData = {
    name: "vmss",
    description: "command to start/stop vm",
    options: [
      {
        name: "action",
        description: "start/stop",
        type: 3,
        required: true,
        choices: [
          {
            name: "start",
            value: "start"
          },
          {
            name: "stop",
            value: "stop"
          },
          {
            name: "test",
            value: "test"
          },
        ],
      },
    ],
  };

  async function main() {

    const response = await fetch(apiEndpoint, {
    method: "post",
    body: JSON.stringify(commandData),
    headers: {
      Authorization: "Bot " + botToken,
      "Content-Type": "application/json",
    },
  });
  const json = await response.json();

  console.log(json);
}
main();

一応github置いときます
https://github.com/xianglishan/discbotcommands

lambda関数

discord botからhttp req待ち受けてec2起動/停止するlambda関数つくります。

これを見てやりました。
https://note.sarisia.cc/entry/discord-slash-commands/

これはpython3で、
コードは以下

lambda_function.py
import json
import os
import boto3

from botocore.vendored import requests
from nacl.signing import VerifyKey

DISCORD_TOKEN = '{discord bot token}'
APPLICATION_ID = '{discord app id}'
APPLICATION_PUBLIC_KEY = '{app pub key}'
COMMAND_GUILD_ID = '{guild id}'

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):
    # API Gateway has weird case conversion, so we need to make them lowercase.
    # See https://github.com/aws/aws-sam-cli/issues/1860
    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
        # command options list -> dict
        # opts = {v['name']: v['value'] for v in req['data']['options']} if 'options' in req['data'] else {}
        action = req['data']['options'][0]['value']
        username = req['member']['user']['username']

        if action == 'start':
            boto3.client('lambda').invoke(
                FunctionName='startmcs',
                InvocationType='Event'
            )
            text = 'hi ' + username + ", server will start in a minute."
            
        if action == 'stop':
            boto3.client('lambda').invoke(
                FunctionName='stopmcs',
                InvocationType='Event'
            )
            text = "server will stop."
            
        if action == 'test':
            status = boto3.client('ec2').describe_instances(
                Filters=[{'Name':'instance-id', 'Values':['{取得したいEC2のインスタンスID}']}]
            )["Reservations"][0]["Instances"][0]['State']['Name'] 
            text = 'server is ' + status + '!!!!'

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

今回はlambda関数→lambda関数の呼び出しをやってみたくてstart/stopの場合は別関数を呼び出しています。

呼び出し先の関数は以下参照
https://aws.amazon.com/jp/premiumsupport/knowledge-center/start-stop-lambda-cloudwatch/

testの時はec2インスタンスの状態をboto3から呼び出しています。

以上で完成

以下画像の感じでスラッシュコマンドでec2起動してminecraftできる

さいごに

ハマった点

  • systemd
  • DiscordのAPIの仕様とか
  • lambdaにライブラリのimport(zipでアップロード)

そのうちだけどec2インスタンス起動/停止→ec2インスタンスのAMI保存してインスタンス終了/AMIからec2インスタンス起動に変えたい。

GitHubで編集を提案

Discussion