0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

EC2でMinecraftサーバーを建ててDiscordから管理する備忘録

Last updated at Posted at 2025-09-27

また建てることになった時のための備忘録。

やったこと

  • EC2でForgeサーバーを建てる
  • Lambdaでインスタンスを起動、停止する関数を作成
    • 最初はハードコアをやる予定だったので、/worldディレクトリの再生成もできるように
  • DiscordコマンドからLambda経由でEC2のインスタンスを操作
  • Elastic IPを使うと別途料金がかかるので、インスタンス起動時にはIPをDiscordに返却

きっかけ

以前からたまに友達に頼まれマイクラサーバーを建てることがあったのだけれど、その時はConohaとかで建てていた。
今回はふとEC2でやろうと思ったのだ。ただそれだけ。

コスト

なんでEC2でやろうと思ったかというとコスパがいい気がしたのだ。
ただ実際に建て終わった今、円安を加味すると別に国産VPSで建ててもあんまり変わらなかった疑惑が出ている。

EC2のメリット

入れたいModが決まっていないなら契約プランを変えずに前提MODを切り替えられるし、どれくらいの期間遊ぶか決まっていなかったりするなら従量課金というメリットは得られると思う。(国産VPSにも従量課金があるサービスはあるけれども)

Forgeサーバーを建てる

インスタンスの作成

公式が一番わかりやすかった

SSH接続したいならインバウンドルールに自身のIPを追加しておく。

image.png

Javaのインストール

Minecraftのバージョンに対応したインストール可能なAmazon Correttoの検索。

$ sudo yum search java-17
Last metadata expiration check: 0:10:02 ago on Fri Sep 26 11:19:29 2025.
================================================ Name Matched: java-17 =================================================
java-17-amazon-corretto.aarch64 : Amazon Corretto development environment
java-17-amazon-corretto-debugsymbols.aarch64 : Amazon Corretto 17 zipped debug symbols
java-17-amazon-corretto-devel.aarch64 : Amazon Corretto 17 development tools
java-17-amazon-corretto-headless.aarch64 : Amazon Corretto headless development environment
java-17-amazon-corretto-javadoc.aarch64 : Amazon Corretto 17 API documentation
java-17-amazon-corretto-jmods.aarch64 : Amazon Corretto 17 jmods

バージョン対応表いつも忘れるのでここで確認させてもらっています。

インストール

$ sudo yum install -y java-17-amazon-corretto-headless.aarch64

Complete!が出たらOK。

必要に応じてバージョンの切り替え。

$ sudo alternatives --config java

There are 3 programs which provide 'java'.

  Selection    Command
-----------------------------------------------
*  1           /usr/lib/jvm/java-21-amazon-corretto.aarch64/bin/java
 + 2           /usr/lib/jvm/java-11-amazon-corretto.aarch64/bin/java
   3           /usr/lib/jvm/java-17-amazon-corretto.aarch64/bin/java

Enter to keep the current selection[+], or type selection number: 

ForgeのDL・展開

適当にディレクトリ一つ切ってその中で wget

$ wget https://maven.minecraftforge.net/net/minecraftforge/forge/1.21.8-58.1.0/forge-1.21.8-58.1.0-installer.jar

URLはForgeのサイト行ってバージョン選んで、Skipのところ右クリックでリンク取得

image.png

落としてきたjarファイルを実行。

$ java -jar forge-1.21.8-58.1.0-installer.jar nogui --installServer

run.sh などが作成されると思うのでそれを一度実行。

$ sh run.sh

eula同意で引っかかって中断するはずなので同意する。

$ echo 'eula=true' > eula.txt

再度run.shを実行するとmodsフォルダができると思うので必要に応じてそこにModを突っ込む。
以降はrun.shを実行すればサーバーが起動する。

Lambdaからインスタンスの起動/停止を操作する

インスタンス起動でMinecraftサーバーを自動起動

一度インスタンスを停止してユーザーデータを編集。

image.png

Content-Type: multipart/mixed; boundary="//"
MIME-Version: 1.0

--//
Content-Type: text/cloud-config; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment; filename="cloud-config.txt"

#cloud-config
cloud_final_modules:
- [scripts-user, always]

--//
Content-Type: text/x-shellscript; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment; filename="userdata.txt"

#!/bin/bash
sh /home/ec2-user/minecraft_server/run.sh

--//--

shコマンドはフルパスじゃないとNo such file or directoryされた。

Lambda用のIAM作成

ポリシー作成

{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Effect": "Allow",
			"Action": [
				"logs:CreateLogGroup",
				"logs:CreateLogStream",
				"logs:PutLogEvents"
			],
			"Resource": "arn:aws:logs:*:*:*"
		},
		{
			"Effect": "Allow",
			"Action": [
				"ec2:Describe*",
				"ec2:Start*",
				"ec2:Reboot*",
				"ec2:Stop*"
			],
			"Resource": "*"
		},
		{
			"Effect": "Allow",
			"Action": [
				"ssm:SendCommand",
				"ssm:ListCommandInvocations",
				"ssm:GetCommandInvocation"
			],
			"Resource": "*"
		}
	]
}

許可がこうなっていれば大丈夫なはず。

image.png

ロール作成

エンティティタイプ:AWSのサービス
ユースケース:Lambda

image.png

許可を追加で先ほど作成したポリシーをアタッチしてあとは適当に名前を付けて保存。

image.png

Lambda作成

ランタイム:Python
アーキテクチャ:x86_64
実行ロール:既存のロールを使用する→先ほど作ったロールをアタッチ。
その他の構成 > 関数URLを有効化

本当は認証タイプNONEは良くないんだろうけど自分で使う分には問題ないだろうと思っている…。

image.png

コード

ワールドリメイクのところ、作ってはいるけど多分ちゃんと動かない。
remake.shの中身はシンプルにrmコマンドで/worldディレクトリを削除しているだけだけれど、その前にサーバーを停止しないといけないはず。
サーバー停止はscreenをうまく使ってやるといいと思うんだけどそこまでたどり着かず力尽きている。

ただ起動・停止はちゃんと動くはず。

python 3.13
import json
import os
import time
import base64
import boto3
import urllib3

ec2 = boto3.client('ec2')
ssm = boto3.client('ssm')

INSTANCE_ID = os.environ['INSTANCE_ID']
WEBHOOK_URL = os.environ['WEBHOOK_URL']

WAIT_INTERVAL = 10        # 秒
WAIT_TIMEOUT = 300        # 秒(最大5分待つ - 停止→起動のため)

def get_state(instance_id: str) -> str:
    d = ec2.describe_instances(InstanceIds=[instance_id])
    return d['Reservations'][0]['Instances'][0]['State']['Name']

def get_public_ip(instance_id: str) -> str | None:
    d = ec2.describe_instances(InstanceIds=[instance_id])
    return d['Reservations'][0]['Instances'][0].get('PublicIpAddress')

def wait_until(instance_id: str, target_state: str) -> bool:
    """target_state になるまで待機。タイムアウト時は False"""
    deadline = time.time() + WAIT_TIMEOUT
    while time.time() < deadline:
        state = get_state(instance_id)
        if state == target_state:
            return True
        time.sleep(WAIT_INTERVAL)
    return False

def start_or_restart(instance_id: str) -> tuple[str, str | None]:
    """
    停止中なら起動、起動中なら停止→起動。
    戻り値: (結果文字列, IPアドレス or None)
    """
    state = get_state(instance_id)
    if state == 'stopped':
        ec2.start_instances(InstanceIds=[instance_id])
        ok = wait_until(instance_id, 'running')
        ip = get_public_ip(instance_id) if ok else None
        return ('started' if ok else 'start-timeout', ip)

    if state == 'running':
        # 停止してから起動
        ec2.stop_instances(InstanceIds=[instance_id])
        stopped_ok = wait_until(instance_id, 'stopped')
        if not stopped_ok:
            return ('stop-timeout', None)
        
        # 停止後に起動
        ec2.start_instances(InstanceIds=[instance_id])
        started_ok = wait_until(instance_id, 'running')
        ip = get_public_ip(instance_id) if started_ok else None
        return ('restarted' if started_ok else 'restart-timeout', ip)

    return (f'skipped (state={state})', get_public_ip(instance_id))

def stop_instance(instance_id: str) -> str:
    state = get_state(instance_id)
    if state in ['running', 'pending']:
        ec2.stop_instances(InstanceIds=[instance_id])
        # 完全停止待ちは長くなりがちなのでここでは即応答に留める
        return 'stopping'
    return f'skipped (state={state})'

def remake_world(instance_id: str) -> str:
    """
    ワールドをリメイクして再起動
    1. インスタンス起動(必要な場合)
    2. remake.shを実行してワールド削除
    3. run.shを実行してサーバー開始
    """
    try:
        # インスタンスが停止中なら起動
        state = get_state(instance_id)
        if state == 'stopped':
            ec2.start_instances(InstanceIds=[instance_id])
            if not wait_until(instance_id, 'running'):
                return 'remake failed: instance start timeout'
        
        # remake.sh実行(ワールド削除)
        remake_response = ssm.send_command(
            InstanceIds=[instance_id],
            DocumentName='AWS-RunShellScript',
            Parameters={'commands': ['bash /home/ec2-user/minecraft_server/remake.sh']}
        )
        
        # コマンド完了を待機
        command_id = remake_response['Command']['CommandId']
        time.sleep(5)  # 短時間待機
        
        # run.sh実行(サーバー開始)
        start_response = ssm.send_command(
            InstanceIds=[instance_id],
            DocumentName='AWS-RunShellScript',  
            Parameters={'commands': ['bash /home/ec2-user/minecraft_server/run.sh']}
        )
        
        ip = get_public_ip(instance_id)
        return f'world remade and server restarted. IP: {ip or "N/A"}'
        
    except Exception as e:
        return f'remake failed: {str(e)}'

def post_webhook(content: str) -> None:
    try:
        http = urllib3.PoolManager()
        data = json.dumps({'content': content}).encode('utf-8')
        http.request(
            'POST', 
            WEBHOOK_URL,
            body=data,
            headers={'Content-Type': 'application/json'},
            timeout=10
        )
    except Exception:
        # Webhook投稿失敗はLambdaの主処理を邪魔しない
        pass

def discord_immediate_response(content: str) -> dict:
    """Slash Commandへの即時応答(type 4)"""
    return {
        "statusCode": 200,
        "body": json.dumps({
            "type": 4,
            "data": {"content": content}
        })
    }

def lambda_handler(event, context):
    try:
        # Discord Bot からのHTTP POST または Discord Slash Command からの呼び出しを処理
        
        # HTTP POST(Bot経由)の場合
        if event.get("body") and isinstance(event.get("body"), str):
            try:
                body = json.loads(event["body"])
                if body.get("source") == "discord-bot":
                    action = body.get("action", "status")
                else:
                    # Discord Slash Command の処理
                    body_raw = event.get("body", "")
                    is_b64 = event.get("isBase64Encoded", False)
                    body_bytes = base64.b64decode(body_raw) if is_b64 else body_raw.encode('utf-8')
                    body = json.loads(body_bytes.decode("utf-8"))
                    
                    if body.get("type") == 1:  # PING
                        return {"statusCode": 200, "body": json.dumps({"type": 1})}
                    
                    data = body.get("data", {}) or {}
                    opts = {opt["name"]: opt["value"] for opt in data.get("options", [])} if data.get("options") else {}
                    action = opts.get("action", "status")
            except json.JSONDecodeError:
                return {"statusCode": 400, "body": "Invalid JSON"}
        else:
            # 直接呼び出しの場合
            action = event.get("action", "status")

        # アクション実行
        if action == "start":
            result, ip = start_or_restart(INSTANCE_ID)
            content = f"{result}. IP: {ip or 'N/A'}"
        elif action == "stop":
            result = stop_instance(INSTANCE_ID)
            content = f"{result}."
        elif action == "status":
            state = get_state(INSTANCE_ID)
            ip = get_public_ip(INSTANCE_ID)
            content = f"state: {state}, IP: {ip or 'N/A'}"
        elif action == "remake":
            result = remake_world(INSTANCE_ID)
            content = f"{result}"
        else:
            content = f"unknown action: {action}"

        # Discord Bot からの呼び出しの場合は簡単なレスポンス
        if event.get("body") and "source" in json.loads(event.get("body", "{}")):
            post_webhook(content)  # Webhook投稿
            return {
                "statusCode": 200, 
                "body": content
            }
        else:
            # Discord Slash Command の場合
            post_webhook(content)
            return discord_immediate_response(content)
            
    except Exception as e:
        error_msg = f"Lambda error: {str(e)}"
        return {
            "statusCode": 500,
            "body": error_msg
        }

環境変数

INSTANCE_IDWEBHOOK_URLをコード内で使っているので設定 > 環境変数から設定しておく。

WEBHOOK_URLはDiscordサーバーのWebhookURLなので詳しくは後述。

image.png

設定

タイムアウト時間が確かデフォルトだと5sだったはずで、インスタンスの起動時などにタイムアウトしてしまうことがあったので5分に変更している。

image.png

URLの取得

設定 > 関数URLからLambdaのURLを控えておく。

image.png

DiscordBotの作成

今回の構成として

  1. DiscordBotでユーザーの入力を検知
  2. DiscordBotからLambdaを叩く
  3. LambdaがDiscordのWebhookを使ってメッセージを返却

という形をとっている。
なぜならLambdaのレスポンスをDiscordBotで受け取って、みたいなことができるかわからなかったし作るのが面倒だったからだ。

WebhookURLの取得

適当なDiscordサーバーのサーバー設定 > 連携サービスでWebhookを作成して「ウェブフックURLをコピー」でURLを取得する。

image.png

取得したURLをさっき作ったLambdaの環境変数のWEBHOOK_URLの部分に設定しておく。

Botの作成

基礎的な作成手順などは一旦省かせていただく。
今回Slashコマンドを使いたいのでDiscordAPPの設定 > Installationから以下のようにスコープを設定しておく。

image.png

あとはDiscordAPPの設定 > BotからTOKENを取得しておけばOK。

コーディング

python 3.13
import os
import json
import logging
import aiohttp
import discord
from discord import app_commands

logging.basicConfig(level=logging.INFO)
log = logging.getLogger("mc-bot")

DISCORD_BOT_TOKEN = "*********************"
LAMBDA_FUNCTION_URL ="https://**********************"

GUILD_ID = "***********************"

intents = discord.Intents.none()
client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)

async def call_lambda(action: str) -> str:
    """
    Lambda Function URL に JSON をPOST。
    Lambda 側は { "action": "start|stop|status", "source": "discord-bot" } を想定。
    戻りはテキスト or JSON を文字列化して返す。
    """
    payload = {"action": action, "source": "discord-bot"}
    try:
        timeout = aiohttp.ClientTimeout(total=120)  # 2分タイムアウト
        async with aiohttp.ClientSession(timeout=timeout) as session:
            async with session.post(
                LAMBDA_FUNCTION_URL,
                json=payload,
                headers={"Content-Type": "application/json"}
            ) as r:
                if r.status == 200:
                    # 文字列レスポンスを取得
                    text = await r.text()
                    try:
                        # JSONとして解析を試す
                        j = json.loads(text)
                        if isinstance(j, dict):
                            return str(j.get("message") or j.get("body") or j)
                        return json.dumps(j, ensure_ascii=False)
                    except (json.JSONDecodeError, ValueError):
                        return text or "Lambdaからの応答が空でした"
                else:
                    body = await r.text()
                    return f"Lambdaがエラーを返しました (HTTP {r.status}): {body[:500]}"
                    
    except Exception as e:
        log.exception("HTTP request failed")
        return f"Lambda呼び出しに失敗しました: {e}"

@tree.command(name="mc", description="マイクラサーバーを操作します")
@app_commands.describe(action="start | stop | status | remake")
@app_commands.choices(
    action=[
        app_commands.Choice(name="start", value="start"),
        app_commands.Choice(name="stop", value="stop"),
        app_commands.Choice(name="status", value="status"),
        app_commands.Choice(name="remake", value="remake"),
    ]
)
async def mc(interaction: discord.Interaction, action: app_commands.Choice[str]):
    # 応答タイムアウト回避のため先にdefer
    await interaction.response.defer(ephemeral=True)
    
    # 開始メッセージを先に送信
    if action.value == "start":
        await interaction.followup.send(f"🔄 サーバーを{action.value}中です... (最大5分)", ephemeral=True)
    elif action.value == "remake":
        await interaction.followup.send(f"🔄 ワールドをremake中です... しばらくお待ちください", ephemeral=True)
    
    result = await call_lambda(action.value)
    
    # 結果を送信
    await interaction.followup.send(f"✅ {result[:1900]}", ephemeral=True)

@client.event
async def on_ready():
    # デバッグ: 登録されているコマンドを確認
    log.info(f"登録されているコマンド数: {len(tree.get_commands())}")
    for cmd in tree.get_commands():
        log.info(f"コマンド: {cmd.name}")
    
    try:
        if GUILD_ID:
            # ギルド固有の同期を試す前に、既存のコマンドをクリア
            guild = discord.Object(id=int(GUILD_ID))
            tree.clear_commands(guild=guild)
            tree.copy_global_to(guild=guild)  # グローバルコマンドをギルドにコピー
            synced = await tree.sync(guild=guild)
            log.info(f"/mc をギルド {GUILD_ID} に同期しました- {len(synced)} コマンド")
        else:
            synced = await tree.sync()
            log.info(f"/mc をグローバル同期しました- {len(synced)} コマンド")
    except Exception as e:
        log.exception(f"Slash Command 同期に失敗しました: {e}")
        # グローバル同期も試す
        try:
            synced = await tree.sync()
            log.info(f"フォールバック: グローバル同期しました - {len(synced)} コマンド")
        except Exception as e2:
            log.exception(f"グローバル同期も失敗しました: {e2}")
    log.info(f"Logged in as {client.user} (ID: {client.user.id})")

if __name__ == "__main__":
    client.run(DISCORD_BOT_TOKEN)

Slashコマンドは同期まで時間がかかるけれどギルドIDを指定すると即時反映がされるらしい。
なのでデバッグ用にギルドIDを指定して検証できるようにしている。

Bot起動時に以下のように「登録されているコマンド数」が1で出てきたらコマンドの同期が完了してどのサーバーでもコマンドが使える状態になっている。

INFO:mc-bot:登録されているコマンド数: 1
INFO:mc-bot:コマンド: mc

コマンドとしては以下の3つ(remakeコマンドは動かないものとする)

  • /mc start
    インスタンスの立ち上げ、と同時にサーバーの起動。成功するとWebhookで設定したチャンネルにIPが返ってくる。
  • /mc stop
    サーバーを閉じる
  • /mc status
    サーバーの状態が確認できる
    起動してたらIPが返ってくるし、閉じてたらstoppedみたいに返ってくる

うまくいってると例えば /mc startコマンドを使った場合以下のようにレスポンスが返ってくるはず

image.png

まとめ

Lambdaの部分もBotの部分もClaude Codeくんに手伝ってもらったおかげで思ったよりはさっくりできてうれしい。
本当はインスタンス停止前にワールドを保存したり、誰もサーバーにインしていなかったら自動的にインスタンスを停止するようにしたいよね。次やるときがあったら挑戦してみようかな。
それよりDiscordBotの常時起動に適した無料サービスがあったら教えてください。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?