はじめに
この記事は、AWSで低コストなMod環境のMinecraftサーバー構築を解説するシリーズの 後編 です。
後編では、前編で作成した Discord Bot(AWS Lambda)を拡張し、 スポットインスタンスを使った Minecraft サーバーの起動・停止・自動復旧までを実装します。
「必要な時だけサーバーを起動して、使わない時は止める」 という運用はコスト削減に非常に効果的ですが、特に Mod 環境では 必要スペックが高く料金も重くなりがち です。
そこで今回は、AWS EC2 のスポットインスタンスを活用し、 オンデマンドより大幅にコストを抑えつつ快適なサーバー運用を目指します。
- はじめに
- 概要
- 本記事について
- データボリュームとスナップショットの準備
- Minecraft Mod サーバーを配置する
- 初回スナップショットの作成
- スポットインスタンスの選定
- VPC と起動テンプレートの準備
- Lambda に機能を追加する
- 型定義と tsconfig の補足
- CDK に権限と EventBridge を追加
- Slash Command に /start /stop を追加
- 動作確認と Spot 中断テスト
- おわりに
概要
後編で扱う内容は次の通りです。
- Minecraft データ用の EBS ボリューム準備(初回スナップショット作成)
- スポットインスタンスの選び方(中断率・コストの確認)
- 起動テンプレートの作成
Lambda から EC2/EBS を操作する実装
/startで新しいインスタンス+データボリュームを立ち上げ/stopで安全停止 → スナップショット更新- Spot 中断イベント(EventBridge)を検知して自動復旧
スポットインスタンスとは?
AWS EC2 のスポットインスタンスは、 「AWS の余ったキャパシティを格安価格で利用できる」タイプのインスタンスです。
メリット
- オンデマンドに比べて 最大 70〜90% ほど安い
- 高スペックインスタンスを低コストで利用可能
デメリット
- AWS側都合で 2分前の通知とともに終了する可能性がある
しかし今回のように 「遊ぶときだけ起動」「データは EBS スナップショットで保持」 という用途ではとても相性が良い構成です。
構成図

本記事について
後編では、Minecraft サーバーの起動/停止/復旧までを Lambda で自動化する部分を中心に説明します。
前編では Discord Bot の基本実装まで進めましたが、 後編ではそれを拡張し、実際に AWS インフラを動かす仕組みへ接続します。
この後の章では、以下の順に進めていきます。
- 初回スナップショットの作成(Minecraft データ配置)
- スポットインスタンスの選定
- 起動テンプレートの作成
- Lambda に
/start/stopを実装 - EventBridge を使った中断通知フロー
- 動作確認
データボリュームとスナップショットの準備
まずは、Minecraft サーバーのデータを置く EBS ボリュームを作成し、その中に Modpack サーバーをセットアップして「初期スナップショット」を作ります。 以後、このスナップショットから新しいボリュームを作成して、Spot インスタンスにアタッチして使います。
一時的な EC2 インスタンスを起動する
任意の EC2 インスタンスを起動します。
- 名前: 任意
- マシンイメージ: Amazon Linux 2023 kernel-6.1 AMI
- アーキテクチャ: 64 ビット(x86)
- インスタンスタイプ: t3.medium
- キーペア名: 任意(既存でも新規でも可)
- セキュリティグループ: 自分の IP からの SSH を許可
データ用 EBS ボリュームを作成する
Minecraft のワールドや Modpack データを置くためのボリュームを別途作ります。
このボリュームを、起動済みの EC2 にアタッチします。
ボリュームをマウントして初期セットアップする
任意の方法で EC2 に接続します。
ssh -i "blog-key.pem" ec2-user@ec2-xx-xxx-xx-xxx.ap-northeast-1.compute.amazonaws.com
接続後、ルートへ移動してブロックデバイスを確認します。
cd / lsblk
例:
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS nvme0n1 259:0 0 8G 0 disk ├─nvme0n1p1 259:1 0 8G 0 part / ├─nvme0n1p127 259:2 0 1M 0 part └─nvme0n1p128 259:3 0 10M 0 part /boot/efi nvme1n1 259:4 0 15G 0 disk
この場合、サイズ 15G の nvme1n1 が先ほどのボリュームです。
マウントするディレクトリを作成します。
sudo mkdir -p /mnt/vol1
ボリュームをマウントします。
sudo mount -t xfs /dev/nvme1n1 /mnt/vol1
マウントしたディレクトリの所有者を ec2-user に変更します。
sudo chown -R ec2-user:ec2-user /mnt/vol1
ボリュームの UUID をメモしておきます。
sudo xfs_admin -u /dev/nvme1n1 # UUID = xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
後で起動テンプレートの user-data からこの UUID でボリュームを探します。
Minecraft Mod サーバーを配置する
Java とディレクトリ準備
サーバーデータ用ディレクトリを作ります。
mkdir -p /mnt/vol1/opt/minecraft/server
依存関係を更新し、Java 21 をインストールします。
sudo dnf update -y sudo dnf install -y java-21-amazon-corretto-devel java -version
Modpack サーバーを展開する
AllTheMods 10 のサーバーファイル(version 4.14)を使う例です。 クライアント用ではなく「サーバー用」の Zip をダウンロードします。
cd /mnt/vol1/opt/minecraft/server/ wget https://mediafilez.forgecdn.net/files/7121/795/ServerFiles-4.14.zip unzip ServerFiles-4.14.zip
server.properties に RCON の設定を追加します。
enable-rcon=true rcon.password=<任意のパスワード> rcon.port=25575
Java のメモリ設定は、初期インスタンスが非力なのでいったん下げておきます。
vi user_jvm_args.txt
-Xms4G -> -Xms2G -Xmx8G -> -Xmx3G
に変更します。
起動スクリプトに実行権限を付与して一度起動します。
chmod +x startserver.sh ./startserver.sh
ログに次のような EULA のメッセージが出たら Ctrl + C を 2 回押して停止します。
[main/INFO] [minecraft/Eula]: By answering 'true' to this prompt you are indicating your agreement to Minecraft's EULA (https://account.mojang.com/documents/minecraft_eula) EULA:
eula.txt を編集して合意します。
vi eula.txt # eula=false -> eula=true
メモリ設定を元に戻しておきます。
vi user_jvm_args.txt -Xms4G -Xmx8G
ボリューム上のサーバーデータの準備はここまでです。 このインスタンス自体はもう不要なので終了してしまって構いません。
初回スナップショットの作成
先ほどアタッチしていたボリュームに対してスナップショットを作成します。
- 説明: 任意(例:
MinecraftServerData) タグ:
- Name = Minecraft-Data
- BlogApp = MinecraftApp
- BlogServer = MinecraftServer
- BlogRole = MinecraftData
このタグで後から Lambda からスナップショットを検索します。 スナップショット作成後、元のボリュームは削除して OK です。
スポットインスタンスの選定
次に、サーバーを動かすための Spot インスタンスタイプを選びます。
このページで中断の頻度を確認し、あまり中断率が高くないインスタンスを選びます。
今回は
- 2 vCPU / 8 GiB
- 中断率 5% 未満
となっていた m7a.large を使うことにします。
中断率が高いと数分で止まることもあるので、ある程度低いものを選ぶのがおすすめです。
VPC と起動テンプレートの準備
Minecraft 用のサーバーを起動するための起動テンプレートを作ります。
VPC / サブネット / SG
セキュリティグループは次のようにします。
| タイプ | ポート範囲 | ソース | 説明 |
|---|---|---|---|
| カスタム TCP | 25565 | 0.0.0.0/0 | Minecraft |
| カスタム TCP | 25575 | 自分の IP | RCON |
| SSH | 22 | 自分の IP | SSH |
起動テンプレート
起動テンプレートの設定:
- 起動テンプレート名: 任意
- マシンイメージ: Amazon Linux 2023 kernel-6.1 AMI
- アーキテクチャ: 64 ビット(x86)
- インスタンスタイプ:
m7a.large(Spot で使うもの) - キーペア: 任意
- サブネット: 先ほど作ったサブネット
- アベイラビリティゾーン: サブネットと同じ AZ
- セキュリティグループ: 上記で作成した SG
ストレージ設定:
新しいボリュームを追加
Spot の設定:
- 購入オプション: スポットインスタンス
- リクエストタイプ: 1 回限り
- 中断動作: 終了
user-data による自動マウント
起動テンプレートの「高度な詳細」から user-data を設定します。
先ほどメモした UUID を TARGET_UUID に入れます。
#!/bin/bash
set -euxo pipefail
dnf update -y
dnf install -y java-21-amazon-corretto-devel
TARGET_UUID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
MOUNT_POINT="/mnt/vol1"
DATA_DEVICE=""
for i in $(seq 1 30); do
for dev in /dev/nvme*n1; do
# UUID が一致するデバイスを探す
if blkid "$dev" | grep -q "$TARGET_UUID"; then
DATA_DEVICE="$dev"
break
fi
done
[ -n "$DATA_DEVICE" ] && break
sleep 3
done
if [ -z "$DATA_DEVICE" ]; then
echo "Error: data volume not found"
exit 1
fi
mkdir -p "$MOUNT_POINT"
echo "UUID=$TARGET_UUID /mnt/vol1 xfs defaults,nofail 0 2" >> /etc/fstab
mount "$MOUNT_POINT"
cat >/etc/systemd/system/minecraft.service <<'EOF'
[Unit]
Description=Minecraft Server
After=network.target mnt-vol1.mount
[Service]
Type=simple
WorkingDirectory=/mnt/vol1/opt/minecraft/server
ExecStart=/mnt/vol1/opt/minecraft/server/startserver.sh
User=ec2-user
Environment=ATM10_RESTART=false
KillMode=control-group
KillSignal=SIGINT
TimeoutStopSec=120
SuccessExitStatus=0 130 143
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
chown -R ec2-user:ec2-user /mnt/vol1 || true
systemctl daemon-reload
systemctl enable minecraft
systemctl start minecraft
起動テンプレート作成後、LAUNCH_TEMPLATE_ID を控えて .env に追記します。
LAUNCH_TEMPLATE_ID=lt-xxxxxxxxxxxxxxxxx
Lambda に機能を追加する
ここからは Lambda コードの実装に入ります。
EC2 や EBS などを扱うため、後編では以下の追加ライブラリを利用しています。
Lambda に機能を追加する
これらを含めた環境で Lambda を構築しています。
Lambda のエントリーポイント(lambda/index.ts)に以下のコードを追記してください。
Discord から /start /stop を受け取り EC2 を起動・停止し、Spot 中断通知(EventBridge → Lambda)にも対応する構成です。
// lambda/index.ts import { APIGatewayProxyStructuredResultV2 } from 'aws-lambda'; import { verifyKey } from 'discord-interactions'; import fetch from 'node-fetch'; import { EC2Client, RunInstancesCommand, DescribeInstancesCommand, CreateVolumeCommand, AttachVolumeCommand, DescribeVolumesCommand, DetachVolumeCommand, CreateSnapshotCommand, DescribeSnapshotsCommand, TerminateInstancesCommand, Filter, Tag, CreateTagsCommand, DeleteVolumeCommand, DeleteSnapshotCommand, } from '@aws-sdk/client-ec2'; import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge'; import Rcon from 'rcon'; const { DISCORD_PUBLIC_KEY, DATA_VOLUME_DEVICE, LAUNCH_TEMPLATE_ID, RCON_PASSWORD = 'password', NAME_VALUE, TAG_KEY, TAG_VALUE, SERVER_KEY, SERVER_VALUE, DATA_ROLE_KEY, DATA_ROLE_VALUE, LOG_LEVEL = 'info', } = process.env; const ec2 = new EC2Client({}); const eb = new EventBridgeClient({}); // CORSレスポンスヘッダ const CORS_HEADERS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST,OPTIONS', 'access-control-allow-headers': 'content-type,x-signature-ed25519,x-signature-timestamp', }; // JSONレスポンス生成 function json(statusCode: number, body?: unknown): APIGatewayProxyStructuredResultV2 { return { statusCode, headers: { ...CORS_HEADERS, 'content-type': 'application/json' }, body: body !== undefined ? (typeof body === 'string' ? body : JSON.stringify(body)) : '', }; } // ログ系ユーティリティ function logDebug(...args: any[]) { if ((LOG_LEVEL ?? 'info').toLowerCase() === 'debug') console.log('[DEBUG]', ...args); } function logInfo(...args: any[]) { console.log('[INFO]', ...args); } function logWarn(...args: any[]) { console.warn('[WARN]', ...args); } function logError(...args: any[]) { console.error('[ERROR]', ...args); } // タグ共通 const baseTags = (): Tag[] => [ { Key: 'Name', Value: NAME_VALUE }, { Key: TAG_KEY, Value: TAG_VALUE }, { Key: SERVER_KEY, Value: SERVER_VALUE }, ]; const dataTags = (): Tag[] => [...baseTags(), { Key: DATA_ROLE_KEY, Value: DATA_ROLE_VALUE }]; const tagFilters = (tags: Tag[]): Filter[] => tags.map((t) => ({ Name: `tag:${t.Key}`, Values: [t.Value!] })); // ヘッダ名大小区別なし取得 function hget(headers: Record<string, string | undefined> | undefined, name: string) { if (!headers) return undefined; const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase()); return key ? headers[key] : undefined; } // Discord署名検証 async function verifyDiscordRequest(event: { headers?: Record<string, string | undefined>; body?: string | null; isBase64Encoded?: boolean; }): Promise<{ ok: true; rawBody: string } | { ok: false; code: number; msg: string }> { const PUBLIC = DISCORD_PUBLIC_KEY; const signature = hget(event.headers, 'x-signature-ed25519'); const timestamp = hget(event.headers, 'x-signature-timestamp'); if (!PUBLIC || !signature || !timestamp) { const msg = 'missing headers or PUBLIC_KEY'; logWarn('verifyDiscordRequest:', msg); return { ok: false, code: 401, msg }; } const rawBody = event.isBase64Encoded && event.body ? Buffer.from(event.body, 'base64').toString('utf8') : (event.body ?? ''); try { const ok = await verifyKey(rawBody, signature, timestamp, PUBLIC); if (!ok) return { ok: false, code: 401, msg: 'invalid request signature' }; return { ok: true, rawBody }; } catch (e: any) { logError('verifyDiscordRequest: verifyKey threw', e?.message ?? e); return { ok: false, code: 401, msg: 'verification error' }; } } // Lambda エントリーポイント export const handler = async (event: any): Promise<any> => { if (event?.headers && event?.body !== undefined) return httpHandler(event); return eventHandler(event); }; // Discord Bot コマンド入口(API Gateway / HTTP) export const httpHandler = async (event: any): Promise<APIGatewayProxyStructuredResultV2> => { logInfo('httpHandler:', 'method=', event?.requestContext?.http?.method, 'path=', event?.requestContext?.http?.path); if (event?.requestContext?.http?.method === 'OPTIONS') return json(204, ''); const verified = await verifyDiscordRequest(event); if (!verified.ok) return json(verified.code, { error: verified.msg }); let interaction: { type: number; token: string; data?: { name: string; options?: { name: string; value: string }[]; }; application_id: string }; try { interaction = JSON.parse(verified.rawBody); } catch { return json(400, { error: 'invalid json' }); } // PING/PONG if (interaction.type === 1) return json(200, { type: 1 }); const hook = `https://discord.com/api/v10/webhooks/${interaction.application_id}/${interaction.token}`; const cmd = interaction.data?.name; // /hello if (cmd === 'hello') { return json(200, { type: 4, data: { content: 'こんにちは 👋' }, }); } // /echo if (cmd === 'echo') { const text = interaction.data?.options?.find((o) => o.name === 'text')?.value ?? '(echo するテキストがありません)'; return json(200, { type: 4, data: { content: `echo: ${text}` }, }); } // /start, /stop は非同期実行へエンキュー if (cmd === 'start') await enqueue('start', hook); else if (cmd === 'stop') await enqueue('stop', hook); else logWarn('unknown command', cmd); // Deferred response return json(200, { type: 5 }); }; // EventBridge 入口(バックグラウンド処理) export const eventHandler = async (event: any) => { logInfo('eventHandler:', 'source=', event?.source, 'detailType=', event?.['detail-type']); if (event.source === 'minecraft.control') { const { action, webhookUrl } = event.detail as { action: 'start' | 'stop'; webhookUrl: string }; if (action === 'start') { try { const publicIp = await startNewInstanceFromSnapshot(); await discordFollowup(webhookUrl, `起動完了: IP: ${publicIp ?? 'N/A'}`); } catch (e: any) { await discordFollowup(webhookUrl, `起動失敗: ${e?.message ?? e}`); } return; } if (action === 'stop') { try { await stopInstanceByTag(webhookUrl); } catch (e: any) { await discordFollowup(webhookUrl, `停止失敗: ${e?.message ?? e}`); } return; } } // Spot 割り込みイベント if (event['detail-type'] === 'EC2 Spot Instance Interruption Warning') { const iid: string | undefined = event.detail?.['instance-id']; if (iid) await handleSpotInterruption(iid); } }; // Discord follow-up メッセージ送信 async function discordFollowup(webhookUrl: string, content: string) { try { await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }); } catch (e: any) { logWarn('discordFollowup failed:', e?.message ?? e); } } // EventBridge に制御イベントを送信 async function enqueue(action: 'start' | 'stop', webhookUrl: string) { await eb.send(new PutEventsCommand({ Entries: [{ Source: 'minecraft.control', DetailType: action, Detail: JSON.stringify({ action, webhookUrl }) }], })); } // 最新スナップショット検索(タグベース) async function findLatestSnapshotIdByTag(): Promise<string | undefined> { const res = await ec2.send(new DescribeSnapshotsCommand({ Filters: tagFilters(dataTags()), OwnerIds: ['self'], MaxResults: 1000, })); const snaps = (res.Snapshots ?? []).sort((a, b) => new Date(b.StartTime ?? 0).getTime() - new Date(a.StartTime ?? 0).getTime()); const sid = snaps[0]?.SnapshotId; return sid; } // シンプルな sleep const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); // DescribeInstances での NotFound 判定 const isNotFound = (e: any) => (e?.name === 'InvalidInstanceID.NotFound') || (typeof e?.message === 'string' && e.message.includes('InvalidInstanceID.NotFound')); // インスタンス状態待ち async function waitInstanceState(instanceId: string, target: 'running' | 'stopped') { const maxAttempts = 120; for (let i = 0; i < maxAttempts; i++) { try { const d = await ec2.send(new DescribeInstancesCommand({ InstanceIds: [instanceId] })); const st = d.Reservations?.[0]?.Instances?.[0]?.State?.Name; logDebug('waitInstanceState:', instanceId, '->', st); if (st === target) return; } catch (e: any) { if (isNotFound(e)) { logDebug('waitInstanceState: not found yet, retrying...'); } else { throw e; } } await sleep(Math.min(1000 * (i + 1), 5000)); } throw new Error(`インスタンス状態待機タイムアウト: ${target}`); } // RCON で Minecraft サーバーを停止 async function stopMinecraftViaRcon(host: string) { return new Promise((resolve, reject) => { const conn = new Rcon(host, 25575, RCON_PASSWORD); conn.on('auth', () => { conn.send('stop'); }); conn.on('response', (str) => { if (str.includes('Stopping the server')) { conn.disconnect(); resolve('ok'); } }); conn.on('error', (err) => { reject(err); }); conn.connect(); }); } // /start 処理 // EC2 を起動 // 起動後、その AZ にスナップショットから EBS を作成 // EBS を DATA_VOLUME_DEVICE にアタッチ async function startNewInstanceFromSnapshot(): Promise<{ instanceId: string; publicIp?: string; dataVolumeId?: string; }> { const snapshotId = await findLatestSnapshotIdByTag(); if (!snapshotId) throw new Error('最新スナップショットが見つかりません'); // まずインスタンスだけ起動 const out = await ec2.send(new RunInstancesCommand({ LaunchTemplate: { LaunchTemplateId: LAUNCH_TEMPLATE_ID }, MinCount: 1, MaxCount: 1, TagSpecifications: [{ ResourceType: 'instance', Tags: baseTags() }], })); const inst = out.Instances?.[0]; const instanceId = inst?.InstanceId; if (!instanceId) throw new Error('インスタンス起動に失敗'); logInfo('instance launched:', instanceId); // 新規作成直後は整合性遅延が出やすいので少し待つ await sleep(3000); // running まで待機して AZ を取得 await waitInstanceState(instanceId, 'running'); const d = await ec2.send(new DescribeInstancesCommand({ InstanceIds: [instanceId] })); const ni = d.Reservations?.[0]?.Instances?.[0]; const publicIp = ni?.PublicIpAddress; const az = ni?.Placement?.AvailabilityZone; if (!az) throw new Error('インスタンスのAZ取得に失敗'); // 同じ AZ にスナップショットからデータ EBS を作成(タグ付け) const created = await ec2.send(new CreateVolumeCommand({ AvailabilityZone: az, SnapshotId: snapshotId, VolumeType: 'gp3', TagSpecifications: [{ ResourceType: 'volume', Tags: dataTags() }], })); const dataVolumeId = created.VolumeId!; // available まで待つ for (let i = 0; i < 40; i++) { const dv = await ec2.send(new DescribeVolumesCommand({ VolumeIds: [dataVolumeId] })); if (dv.Volumes?.[0]?.State === 'available') break; await new Promise((r) => setTimeout(r, 3000)); } // アタッチしてタグ付け await ec2.send(new AttachVolumeCommand({ InstanceId: instanceId, VolumeId: dataVolumeId, Device: DATA_VOLUME_DEVICE })); await ec2.send(new CreateTagsCommand({ Resources: [dataVolumeId, instanceId], Tags: [{ Key: 'AttachedTo', Value: instanceId }, ...dataTags()], })); return { instanceId, publicIp, dataVolumeId }; } // /stop 処理 // 対象インスタンスをタグで特定 // RCON で停止 // データ EBS をデタッチ → スナップショット作成 → EBS 削除 // インスタンス Terminate async function stopInstanceByTag(webhookUrl: string) { const res = await ec2.send(new DescribeInstancesCommand({ Filters: [...tagFilters(baseTags()), { Name: 'instance-state-name', Values: ['running'] }], })); const inst = res.Reservations?.flatMap((r) => r.Instances ?? [])?.[0]; if (!inst?.InstanceId) { await discordFollowup(webhookUrl, '停止対象が見つかりません'); return; } const instanceId = inst.InstanceId; // 実行中のデータボリューム(DATA_VOLUME_DEVICE)を拾う const volId = inst.BlockDeviceMappings?.find((b) => b.DeviceName === DATA_VOLUME_DEVICE)?.Ebs?.VolumeId; if (!volId) throw new Error('データボリュームが見つかりません'); // RCON で停止(失敗しても続行) const publicIp: string = inst.PublicIpAddress || ''; try { await stopMinecraftViaRcon(publicIp); } catch (e: any) { logWarn('RCON stop failed:', e?.message ?? e); } // デタッチ → available 待機 await ec2.send(new DetachVolumeCommand({ VolumeId: volId, InstanceId: instanceId, Device: DATA_VOLUME_DEVICE, Force: true })); for (let i = 0; i < 40; i++) { const dv = await ec2.send(new DescribeVolumesCommand({ VolumeIds: [volId] })); if (dv.Volumes?.[0]?.State === 'available') break; await new Promise((r) => setTimeout(r, 3000)); } // スナップショット作成(タグ付け)→ EBS 削除 → 古いスナップショット削除 const oldSnapshotId = await findLatestSnapshotIdByTag(); const snap = await ec2.send(new CreateSnapshotCommand({ VolumeId: volId, Description: 'minecraft data snapshot' })); if (snap.SnapshotId) await ec2.send(new CreateTagsCommand({ Resources: [snap.SnapshotId], Tags: dataTags() })); await new Promise((r) => setTimeout(r, 5000)); await ec2.send(new DeleteVolumeCommand({ VolumeId: volId })); await ec2.send(new DeleteSnapshotCommand({ SnapshotId: oldSnapshotId })); // インスタンス終了 await ec2.send(new TerminateInstancesCommand({ InstanceIds: [instanceId] })); await discordFollowup(webhookUrl, `停止完了: ${instanceId} / スナップショット作成 ${snap.SnapshotId}`); } // Spot 割り込みハンドリング // 可能なら既存ボリュームを温存 → 代替インスタンスへ再アタッチ // 失敗時はスナップショット化してから削除 async function handleSpotInterruption(instanceId: string) { const di = await ec2.send(new DescribeInstancesCommand({ InstanceIds: [instanceId] })); const volId = di.Reservations?.[0]?.Instances?.[0]?.BlockDeviceMappings?.find((b) => b.DeviceName === DATA_VOLUME_DEVICE)?.Ebs?.VolumeId; if (!volId) return; // RCON で停止(失敗しても続行) const publicIp: string = di.Reservations?.[0]?.Instances?.[0]?.PublicIpAddress || ''; try { await stopMinecraftViaRcon(publicIp); } catch (e: any) { logWarn('RCON stop failed:', e?.message ?? e); } // 終了(terminated)まで軽く待つ for (let i = 0; i < 60; i++) { try { const d = await ec2.send(new DescribeInstancesCommand({ InstanceIds: [instanceId] })); const st = d.Reservations?.[0]?.Instances?.[0]?.State?.Name; logDebug('wait instance terminated:', instanceId, '->', st); if (st === 'terminated') break; } catch (e: any) { if (isNotFound(e)) { logDebug('instance not found -> treat as terminated'); break; } throw e; } await sleep(5000); } let newInstanceId: string | undefined; const oldSnapshotId = await findLatestSnapshotIdByTag(); try { const out = await ec2.send(new RunInstancesCommand({ LaunchTemplate: { LaunchTemplateId: LAUNCH_TEMPLATE_ID }, MinCount: 1, MaxCount: 1, TagSpecifications: [{ ResourceType: 'instance', Tags: baseTags() }], })); newInstanceId = out.Instances?.[0]?.InstanceId; if (!newInstanceId) throw new Error('代替インスタンス起動に失敗'); await waitInstanceState(newInstanceId, 'running'); // ボリューム available 待機 → アタッチ for (let i = 0; i < 40; i++) { const dv = await ec2.send(new DescribeVolumesCommand({ VolumeIds: [volId] })); if (dv.Volumes?.[0]?.State === 'available') break; await new Promise((r) => setTimeout(r, 3000)); } await ec2.send(new AttachVolumeCommand({ InstanceId: newInstanceId, VolumeId: volId, Device: DATA_VOLUME_DEVICE })); await ec2.send(new CreateTagsCommand({ Resources: [volId, newInstanceId], Tags: [{ Key: 'AttachedTo', Value: newInstanceId }, ...dataTags()] })); } catch (e) { // 代替インスタンス起動・アタッチに失敗した場合はスナップショット化してから削除 try { const snap = await ec2.send(new CreateSnapshotCommand({ VolumeId: volId, Description: 'snapshot on spot failure' })); if (snap.SnapshotId) await ec2.send(new CreateTagsCommand({ Resources: [snap.SnapshotId], Tags: dataTags() })); } finally { await ec2.send(new DeleteVolumeCommand({ VolumeId: volId })); await ec2.send(new DeleteSnapshotCommand({ SnapshotId: oldSnapshotId })); } } }
また、envに以下の変数を追記します。
RCON_PASSWORD は、server.properties に 設定した rcon.password です。
.env:
RCON_PASSWORD=r3XcTwNk NAME_VALUE=Minecraft-Data TAG_KEY=BlogApp TAG_VALUE=MinecraftApp SERVER_KEY=BlogServer SERVER_VALUE=MinecraftServer DATA_ROLE_KEY=BlogRole DATA_ROLE_VALUE=MinecraftData
型定義と tsconfig の補足
rcon には型定義がないので、簡単な d.ts を追加します。
types/rcon.d.ts:
declare module 'rcon' { interface RconOptions { tcp?: boolean; challenge?: boolean; } export default class Rcon { constructor( host: string, port: number, password: string, options?: RconOptions ); connect(): void; disconnect(): void; send(data: string): void; on(event: 'auth', callback: () => void): void; on(event: 'response', callback: (str: string) => void): void; on(event: 'end', callback: () => void): void; on(event: 'error', callback: (err: any) => void): void; } }
tsconfig.json に include を追加して、Lambda と型定義を拾うようにします。
{ "include": [ "lambda/**/*.ts", "types/**/*.d.ts" ] }
CDK に権限と EventBridge を追加
最後に、Lambda に必要な EC2 / EventBridge / IAM 権限と、EventBridge のルールを CDK 側に追加します。
// infra/lib/infra-stack.ts import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as lambdaNode from 'aws-cdk-lib/aws-lambda-nodejs'; import * as events from 'aws-cdk-lib/aws-events'; import * as targets from 'aws-cdk-lib/aws-events-targets'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as path from 'path'; export class InfraStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const projectRoot = path.join(__dirname, '..', '..'); const fn = new lambdaNode.NodejsFunction(this, 'BlogDiscordBot', { entry: 'lambda/index.ts', handler: 'handler', runtime: lambda.Runtime.NODEJS_20_X, architecture: lambda.Architecture.ARM_64, memorySize: 512, timeout: cdk.Duration.minutes(10), bundling: { externalModules: [], minify: true, sourceMap: true, format: lambdaNode.OutputFormat.CJS, target: 'node20', }, environment: { DISCORD_APPLICATION_ID: process.env.DISCORD_APPLICATION_ID ?? '', DISCORD_PUBLIC_KEY: process.env.DISCORD_PUBLIC_KEY ?? '', DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID ?? '', DATA_VOLUME_DEVICE: process.env.DATA_VOLUME_DEVICE ?? '', LAUNCH_TEMPLATE_ID: process.env.LAUNCH_TEMPLATE_ID ?? '', RCON_PASSWORD: process.env.RCON_PASSWORD ?? '', NAME_VALUE: process.env.NAME_VALUE ?? '', TAG_KEY: process.env.TAG_KEY ?? '', TAG_VALUE: process.env.TAG_VALUE ?? '', SERVER_KEY: process.env.SERVER_KEY ?? '', SERVER_VALUE: process.env.SERVER_VALUE ?? '', DATA_ROLE_KEY: process.env.DATA_ROLE_KEY ?? '', DATA_ROLE_VALUE: process.env.DATA_ROLE_VALUE ?? '', }, }); fn.addToRolePolicy( new iam.PolicyStatement({ actions: [ 'ec2:RunInstances', 'ec2:DescribeInstances', 'ec2:TerminateInstances', 'ec2:CreateVolume', 'ec2:AttachVolume', 'ec2:DetachVolume', 'ec2:DeleteVolume', 'ec2:DescribeVolumes', 'ec2:CreateSnapshot', 'ec2:DeleteSnapshot', 'ec2:DescribeSnapshots', 'ec2:CreateTags', 'events:PutEvents', 'iam:PassRole', ], resources: ['*'], }), ); fn.addToRolePolicy(new iam.PolicyStatement({ actions: ['iam:CreateServiceLinkedRole'], resources: ['*'], conditions: { StringEquals: { 'iam:AWSServiceName': ['spot.amazonaws.com', 'spotfleet.amazonaws.com'] } } })); const url = fn.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.NONE, cors: { allowedOrigins: ['*'], allowedMethods: [lambda.HttpMethod.ALL], }, }); new cdk.CfnOutput(this, 'FunctionUrl', { value: url.url }); const controlRule = new events.Rule(this, 'ControlRule', { eventPattern: { source: ['minecraft.control'], detailType: ['start', 'stop'], }, }); controlRule.addTarget(new targets.LambdaFunction(fn)); const spotRule = new events.Rule(this, 'SpotInterruptRule', { eventPattern: { source: ['aws.ec2'], detailType: ['EC2 Spot Instance Interruption Warning'], }, }); spotRule.addTarget(new targets.LambdaFunction(fn)); } }
cdk deploy で反映します。
Slash Command に /start /stop を追加
script/register-commands.ts の commands に次を追加します。
{ name: 'start', description: 'サーバーを起動', }, { name: 'stop', description: 'サーバーを停止' }
再度コマンド登録を実行します。
npx ts-node script/register-commands.ts
動作確認と Spot 中断テスト
*/start**
Discord で /start を実行すると、Bot が「考え中…」になり、最終的に
起動完了: IP: xx.xxx.xxx.xx
のようなメッセージが返ってくれば成功です。 IP の値が Minecraft クライアントから接続するアドレスなので実際に接続して確認します。
こちらのサイトでIPでサーバーが起動したかを確認できます。 tt0.link

/stop
/stop を実行して、停止メッセージが返り、EC2 が終了していることも確認します。
停止完了
のようなメッセージが返ってくれば成功です。
spot interruptions
Spot 割り込みのテストには AWS FIS(Fault Injection Simulator)を使います。
このチュートリアルを参考に、 データボリュームに付けたタグ
- Name = Minecraft-Data
- BlogApp = MinecraftApp
- BlogServer = MinecraftServer
- BlogRole = MinecraftData
を使ってターゲットを選び、Spot 中断イベントを発生させて挙動を確認します。
おわりに
今回の後編では、Discord から /start /stop で Minecraft サーバーを操作し、
スポットインスタンスと EBS スナップショットを組み合わせて
必要なときだけ起動する省コスト構成を作りました。
コストまとめ
1. スポットインスタンス m7a.large の実測値はだいたい:
- 約 0.05 USD/時(オンデマンドの約半額)
遊ぶときだけ起動する形なら、かなり安く運用できます。
2. EBS スナップショット(データ保存)
15 GiB のワールドデータで:
- 約 0.75 USD/月(約 110〜120 円/月)
固定費としてはこの部分だけ。
3. Lambda(Discord Bot / 管理処理)
- ほぼ 0 円(無料枠内)
実行時間も短く、コストは気にしなくてよいレベル。
この構成なら、 「必要なときだけ遊べて、使わないときはほぼ課金なし」 という Minecraft サーバーを実現できます。
気が向いたら /status や /backup なども追加しつつ、
自分だけの“省コスト自動管理サーバー”に育ててみてください。
ここまで読んでいただき、ありがとうございました。




