また建てることになった時のための備忘録。
やったこと
- EC2でForgeサーバーを建てる
- Lambdaでインスタンスを起動、停止する関数を作成
- 最初はハードコアをやる予定だったので、/worldディレクトリの再生成もできるように
- DiscordコマンドからLambda経由でEC2のインスタンスを操作
- Elastic IPを使うと別途料金がかかるので、インスタンス起動時にはIPをDiscordに返却
きっかけ
以前からたまに友達に頼まれマイクラサーバーを建てることがあったのだけれど、その時はConohaとかで建てていた。
今回はふとEC2でやろうと思ったのだ。ただそれだけ。
コスト
なんでEC2でやろうと思ったかというとコスパがいい気がしたのだ。
ただ実際に建て終わった今、円安を加味すると別に国産VPSで建ててもあんまり変わらなかった疑惑が出ている。
EC2のメリット
入れたいModが決まっていないなら契約プランを変えずに前提MODを切り替えられるし、どれくらいの期間遊ぶか決まっていなかったりするなら従量課金というメリットは得られると思う。(国産VPSにも従量課金があるサービスはあるけれども)
Forgeサーバーを建てる
インスタンスの作成
公式が一番わかりやすかった
SSH接続したいならインバウンドルールに自身のIPを追加しておく。
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のところ右クリックでリンク取得
落としてきた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サーバーを自動起動
一度インスタンスを停止してユーザーデータを編集。
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": "*"
}
]
}
許可がこうなっていれば大丈夫なはず。
ロール作成
エンティティタイプ:AWSのサービス
ユースケース:Lambda
許可を追加で先ほど作成したポリシーをアタッチしてあとは適当に名前を付けて保存。
Lambda作成
ランタイム:Python
アーキテクチャ:x86_64
実行ロール:既存のロールを使用する→先ほど作ったロールをアタッチ。
その他の構成 > 関数URLを有効化
本当は認証タイプNONEは良くないんだろうけど自分で使う分には問題ないだろうと思っている…。
コード
ワールドリメイクのところ、作ってはいるけど多分ちゃんと動かない。
remake.shの中身はシンプルにrmコマンドで/worldディレクトリを削除しているだけだけれど、その前にサーバーを停止しないといけないはず。
サーバー停止はscreenをうまく使ってやるといいと思うんだけどそこまでたどり着かず力尽きている。
ただ起動・停止はちゃんと動くはず。
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_IDとWEBHOOK_URLをコード内で使っているので設定 > 環境変数から設定しておく。
WEBHOOK_URLはDiscordサーバーのWebhookURLなので詳しくは後述。
設定
タイムアウト時間が確かデフォルトだと5sだったはずで、インスタンスの起動時などにタイムアウトしてしまうことがあったので5分に変更している。
URLの取得
設定 > 関数URLからLambdaのURLを控えておく。
DiscordBotの作成
今回の構成として
- DiscordBotでユーザーの入力を検知
- DiscordBotからLambdaを叩く
- LambdaがDiscordのWebhookを使ってメッセージを返却
という形をとっている。
なぜならLambdaのレスポンスをDiscordBotで受け取って、みたいなことができるかわからなかったし作るのが面倒だったからだ。
WebhookURLの取得
適当なDiscordサーバーのサーバー設定 > 連携サービスでWebhookを作成して「ウェブフックURLをコピー」でURLを取得する。
取得したURLをさっき作ったLambdaの環境変数のWEBHOOK_URLの部分に設定しておく。
Botの作成
基礎的な作成手順などは一旦省かせていただく。
今回Slashコマンドを使いたいのでDiscordAPPの設定 > Installationから以下のようにスコープを設定しておく。
あとはDiscordAPPの設定 > BotからTOKENを取得しておけばOK。
コーディング
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コマンドを使った場合以下のようにレスポンスが返ってくるはず
まとめ
Lambdaの部分もBotの部分もClaude Codeくんに手伝ってもらったおかげで思ったよりはさっくりできてうれしい。
本当はインスタンス停止前にワールドを保存したり、誰もサーバーにインしていなかったら自動的にインスタンスを停止するようにしたいよね。次やるときがあったら挑戦してみようかな。
それよりDiscordBotの常時起動に適した無料サービスがあったら教えてください。
参考












