MinecraftサーバーをIaC化してみた【Part4】- Discord Bot実装編
この記事で書くこと
MinecraftサーバーをIaCで管理するプロジェクトのPart4です。
今回はDiscord Botを実装して、Discordのスラッシュコマンドでサーバーを操作できるようにしました。
- Discord Interactions APIの仕組みを理解する
- Lambda + Lambda Function URLでサーバーレスBotを構築する
-
/server start,stop,status,logs,backupを実装する - OpenTofuでLambdaをデプロイする
- Deferred Responseパターンでタイムアウト問題を解決する
実装中にかなりハマったので、詰まったポイントも含めて残します。
作ったものの全体像
Discord: /server status
│
▼
Lambda(Receiver)
├── 署名検証(本物のDiscordか?)
├── 「考え中...」を即返す(3秒以内)
└── Worker Lambdaを非同期起動
Lambda(Worker)
├── EC2の状態を確認
├── SSMでMinecraftコンテナに問い合わせ
└── Discordに結果をPATCH送信
DiscordのスラッシュコマンドはHTTPで届くので、APIサーバーとしてLambda + Function URLを使います。Gateway APIのようにWebSocket常時接続は不要で、使った分だけ課金のサーバーレス構成にできます。
ファイル構成
discord-bot/src/
├── handler.py # Receiver Lambda(入口)
├── worker.py # Worker Lambda(実際の処理)
├── commands/
│ └── server.py # /server コマンドの処理
└── utils/
├── discord_utils.py # Discord APIヘルパー
├── ec2.py # EC2操作
└── ssm.py # SSMコマンド実行
discord_utils.py - レスポンスを組み立てる関数たち
IntEnumで数値に名前をつける
DiscordとのやりとりはすべてJSONです。リクエストには "type": 2(スラッシュコマンド)、レスポンスには "type": 4(メッセージを送る)のように数値が使われます。
この数値をそのままコードに書くと意味不明になるので、IntEnum で管理します。
from enum import IntEnum
class InteractionType(IntEnum):
PING = 1 # Discordからの疎通確認
APPLICATION_COMMAND = 2 # スラッシュコマンド
class InteractionResponseType(IntEnum):
PONG = 1 # PINGへの返事
CHANNEL_MESSAGE_WITH_SOURCE = 4 # 通常のメッセージ
DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5 # 「考え中...」
IntEnum を継承することで、名前で管理しながら数値としても使えます。
# 意図が不明
if interaction_type == 2:
# 意図が明確
if interaction_type == InteractionType.APPLICATION_COMMAND:
これは「マジックナンバーを避ける」という設計原則の実践です。
2種類のレスポンス
Discordには「通常メッセージ」と「Embed(カード形式)」があります。
def create_response(content: str, ephemeral: bool = False) -> dict:
"""ただのテキストメッセージ"""
response = {
"type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
"data": {"content": content}
}
if ephemeral:
response["data"]["flags"] = 64 # 送信者にだけ見える
return response
def create_embed_response(title, description, color=0x00ff00, fields=None, ...) -> dict:
"""タイトル・色・フィールドを持つカード形式"""
embed = {"title": title, "description": description, "color": color}
if fields:
embed["fields"] = fields
...
使い分けはこうです。
-
create_embed_response()→ あらゆる結果表示に使う(情報量に関わらずEmbedで統一) -
create_error_response()→ 赤色Embedのエラー専用ラッパー -
create_deferred_response()→ 「考え中...」を返すDeferredパターン専用 -
create_response()→ 定義はしたが今回は未使用(将来的にシンプルな通知で使う想定)
ec2_utils.py - EC2を操作する
boto3.client("ec2") でEC2を操作します。状態を取得・起動・停止するのに加えて、Waiterという仕組みを使います。
def wait_for_instance_running(timeout: int = 300) -> bool:
ec2 = get_ec2_client()
waiter = ec2.get_waiter("instance_running")
try:
waiter.wait(
InstanceIds=[EC2_INSTANCE_ID],
WaiterConfig={"Delay": 15, "MaxAttempts": timeout // 15}
)
return True
except Exception:
return False
Waiterは「ある状態になるまで定期的に確認し続ける」boto3の機能です。自分でポーリングループを書く必要がなくなります。
Delay: 15 は15秒ごとに確認、MaxAttempts: timeout // 15 は最大試行回数です。300 // 15 = 20 なので最大5分待ちます。
ssm_utils.py - EC2の中でコマンドを実行する
EC2の中でコマンドを叩くのに、SSHではなくSSMを使います。
SSHを使わない理由:
- 秘密鍵の管理が不要
- 22番ポートを開放しなくていい
- IAMで権限管理できる
def run_command(commands: List[str], timeout_seconds: int = 60) -> Dict[str, Any]:
ssm = get_ssm_client()
response = ssm.send_command(
InstanceIds=[EC2_INSTANCE_ID],
DocumentName="AWS-RunShellScript", # シェルコマンドを実行するテンプレート
Parameters={"commands": commands},
TimeoutSeconds=timeout_seconds
)
command_id = response["Command"]["CommandId"]
# ポーリングで完了を待つ
for _ in range(timeout_seconds // 2):
time.sleep(2)
try:
result = ssm.get_command_invocation(
CommandId=command_id, InstanceId=EC2_INSTANCE_ID
)
if result["Status"] in ["Success", "Failed", "TimedOut", "Cancelled"]:
return {"success": result["Status"] == "Success", ...}
except ssm.exceptions.InvocationDoesNotExist:
continue # まだ結果が準備できていない→次のループへ
send_command() は命令を送るだけで完了を待ちません。なので get_command_invocation() で結果をポーリングします。送信直後は結果が存在しないので InvocationDoesNotExist 例外を continue でスキップするのがポイントです。
RCONパスワードの落とし穴
# ❌ $RCON_PASSWORD はEC2の環境変数 → 空になる
'docker exec minecraft-server rcon-cli --password "$RCON_PASSWORD" list'
# ✅ Lambda側の環境変数を展開してから渡す
RCON_PASSWORD = os.environ.get("RCON_PASSWORD", "")
f'docker exec minecraft-server rcon-cli --password "{RCON_PASSWORD}" list'
このコマンドはLambdaからSSM経由でEC2上で実行されます。$RCON_PASSWORD はEC2の環境変数を参照しますが、EC2側には設定されていないので空文字になります。LambdaのPythonコードでf文字列として展開してから渡す必要がありました。
最初のつまずき:「アプリケーションが応答しませんでした」
実装してデプロイしたら最初にこのエラーに遭遇しました。
CloudWatchを見ると:
08:33:01.828Z Handling command: server ← ここまで正常
08:33:06.185Z END ← 4.3秒後に終了
Discordの仕様:3秒以内にレスポンスがなければ失敗と判断します。
/server status は get_instance_status() に加えて get_minecraft_players() でSSMコマンドを実行しており、その中に time.sleep(2) があったため4秒超えてしまいました。
Deferred Responseパターンで解決する
「考え中...」を先に返して、後から結果を送る仕組みです。
なぜ1個のLambdaでは解決できないのか
def lambda_handler(event, context):
return create_deferred_response() # ← ここで return = Lambda終了
# この後にコードを書いても実行されない
return した瞬間にLambdaは終了します。「返してから続きをやる」は1個では物理的に不可能です。
Lambdaを2個に分ける
Receiver Lambda(既存を改修)
├── 署名検証
├── type:5(「考え中...」)を即 return ← 1秒以内
└── Worker Lambdaを非同期起動(待たない)
Worker Lambda(新規作成)
├── EC2確認・SSM実行(時間かかってOK)
└── Discord Webhookに結果をPATCH
Receiver側
# handler.py
lambda_client = boto3.client("lambda")
lambda_client.invoke(
FunctionName=WORKER_FUNCTION_NAME,
InvocationType="Event", # ← 非同期。"Event"=待たない
Payload=json.dumps(body).encode()
)
return {
"statusCode": 200,
"body": json.dumps(create_deferred_response()) # type:5を即返す
}
InvocationType="Event" が非同期呼び出しのキーワードです。"RequestResponse"(デフォルト)だとWorkerの完了を待ってしまいます。
Worker側
# worker.py
def send_followup_message(token: str, content: dict) -> None:
url = (
f"https://discord.com/api/v10/webhooks/"
f"{DISCORD_APPLICATION_ID}/{token}/messages/@original"
)
data = json.dumps(content).encode("utf-8")
req = urllib.request.Request(
url, data=data, method="PATCH",
headers={
"Content-Type": "application/json",
"User-Agent": "DiscordBot (https://github.com/minecraft-bot, 1.0.0)"
}
)
with urllib.request.urlopen(req) as res:
print(f"Discord webhook response: {res.status}")
PATCH /messages/@original で「考え中...」を結果に上書きします。User-Agent ヘッダーが必須です。これがないとCloudflare(Discordの前段のWAF)に 403: error code 1010 で弾かれます。
Discordの Interaction Token
Workerが後からメッセージを更新するには、Discordのリクエストに含まれる token が必要です。
{
"type": 2,
"token": "aW50ZXJhY3...",
"data": {"name": "server", ...}
}
ReceiverからWorkerに body をそのまま渡すことで、WorkerもこのTokenを使えます。
OpenTofu: Worker Lambda を追加する
# Worker Lambda用のIAM権限(ReceiverがWorkerを呼べるようにする)
resource "aws_iam_role_policy" "discord_bot_invoke_worker" {
...
policy = jsonencode({
Statement = [{
Action = ["lambda:InvokeFunction"]
Resource = aws_lambda_function.discord_bot_worker.arn
}]
})
}
# Worker Lambda
resource "aws_lambda_function" "discord_bot_worker" {
function_name = "${local.name_prefix}-discord-bot-worker"
handler = "worker.lambda_handler" # worker.py のlambda_handler
runtime = "python3.11"
timeout = 300 # 5分。SSM待機があるので長めに
...
environment {
variables = {
DISCORD_APPLICATION_ID = var.discord_application_id
EC2_INSTANCE_ID = aws_instance.minecraft.id
RCON_PASSWORD = var.rcon_password
...
}
}
}
ReceiverのLambdaにも WORKER_FUNCTION_NAME を環境変数として追加します。
# Receiver側の環境変数に追加
WORKER_FUNCTION_NAME = aws_lambda_function.discord_bot_worker.function_name
起動・停止完了を通知する
Deferredパターンを導入したことで、WorkerのLambdaタイムアウトが最大5分になりました。これを活かして「完全に起動/停止するまで待ってから通知する」実装にしました。
# server.py(handle_start)
result = start_instance()
if not result["success"]:
return create_error_response(result["message"])
# EC2がrunningになるまで待つ(最大5分)
running = wait_for_instance_running(timeout=300)
if running:
new_status = get_instance_status()
return create_embed_response(
title="🟢 サーバー起動完了",
description="Minecraft サーバーに接続できます!",
fields=[{"name": "接続先", "value": f"`{new_status['public_ip']}:25565`"}]
)
else:
return create_embed_response(title="⏰ 起動タイムアウト", ...)
/server start を打つと「考え中...」が表示され、2〜3分後に「🟢 起動完了」に更新されます。
詰まったところまとめ
| 症状 | 原因 | 解決 |
|---|---|---|
| 「アプリケーションが応答しませんでした」 | Discordの3秒制限を超えた | Deferredパターンに移行 |
403: error code 1010 |
User-Agentヘッダーがない |
DiscordBot (url, version) 形式を追加 |
| プレイヤー情報が常に「起動中/準備中」 |
$RCON_PASSWORDがEC2で空になる |
Lambda側でf文字列に展開してから渡す |
NameError: name 'boto3' is not defined |
importを書き忘れた |
import boto3 を追加 |
| コードを直しても反映されない |
tofu applyが差分なしとスキップ |
tofu apply -replace=aws_lambda_function.xxx |
デプロイフロー
# コードを修正したら
tofu apply
# archive_fileリソースがsource_dirの変更を検知して
# ZIPを作り直し → source_code_hashが変われば自動デプロイ
tofu apply だけで反映されます。手動でZIPを作り直す必要はありません。
動作確認
/server status → EC2状態・IPアドレス・プレイヤー数が表示される
/server start → 「考え中...」→ 2〜3分後に「🟢 起動完了 + IP」に更新
/server stop → 「考え中...」→ 1〜2分後に「🛑 停止完了」に更新
/server logs → 直近10行のサーバーログが表示される
/server backup → バックアップが実行される
Phase4で学んだこと
Discord API
- Interactions APIはHTTPのリクエスト/レスポンス方式なのでサーバーレスと相性がいい
- リクエストには必ずEd25519署名が付いており、公開鍵で検証する
- 3秒ルールと、それを回避するDeferredパターン
設計パターン
- IntEnumでマジックナンバーを排除する
- Deferredパターン:「即レスポンス + 非同期処理 + 後から更新」
-
InvocationType="Event"による非同期Lambda呼び出し
Python
-
List[str],Dict[str, Any]などの型ヒント - デフォルト引数に可変オブジェクトを渡してはいけない(
fields=Noneパターン) - f文字列でコマンドの変数を展開してからEC2に渡す
AWS
- Lambda Function URLはAPI Gatewayなしで使えるシンプルなHTTPSエンドポイント
- SSMはSSHより安全(鍵不要・ポート開放不要)
- Waiterでポーリングの実装を省略できる
次にやること(Phase5)
- CloudWatch監視・アラート
- CI/CDパイプライン(GitHub Actions)
- ポートフォリオとしての整備
また進んだら記事にします!
Discussion