AWSで低コストなMod環境のMinecraftサーバー構築[後編]

はじめに

この記事は、AWSで低コストなMod環境のMinecraftサーバー構築を解説するシリーズの 後編 です。

後編では、前編で作成した Discord BotAWS Lambda)を拡張し、 スポットインスタンスを使った Minecraft サーバーの起動・停止・自動復旧までを実装します。

「必要な時だけサーバーを起動して、使わない時は止める」 という運用はコスト削減に非常に効果的ですが、特に Mod 環境では 必要スペックが高く料金も重くなりがち です。

そこで今回は、AWS EC2 のスポットインスタンスを活用し、 オンデマンドより大幅にコストを抑えつつ快適なサーバー運用を目指します。



概要

後編で扱う内容は次の通りです。

  • Minecraft データ用の EBS ボリューム準備(初回スナップショット作成)
  • スポットインスタンスの選び方(中断率・コストの確認)
  • 起動テンプレートの作成
  • Lambda から EC2/EBS を操作する実装

    • /start で新しいインスタンス+データボリュームを立ち上げ
    • /stop で安全停止 → スナップショット更新
    • Spot 中断イベント(EventBridge)を検知して自動復旧

スポットインスタンスとは?

AWS EC2 のスポットインスタンスは、 「AWS の余ったキャパシティを格安価格で利用できる」タイプのインスタンスです。

メリット

  • オンデマンドに比べて 最大 70〜90% ほど安い
  • 高スペックインスタンスを低コストで利用可能

デメリット

  • AWS側都合で 2分前の通知とともに終了する可能性がある

しかし今回のように 「遊ぶときだけ起動」「データは EBS スナップショットで保持」 という用途ではとても相性が良い構成です。


構成図


本記事について

後編では、Minecraft サーバーの起動/停止/復旧までを Lambda で自動化する部分を中心に説明します。

前編では Discord Bot の基本実装まで進めましたが、 後編ではそれを拡張し、実際に AWS インフラを動かす仕組みへ接続します。

この後の章では、以下の順に進めていきます。

  1. 初回スナップショットの作成(Minecraft データ配置)
  2. スポットインスタンスの選定
  3. 起動テンプレートの作成
  4. Lambda に /start /stop を実装
  5. EventBridge を使った中断通知フロー
  6. 動作確認

データボリュームとスナップショットの準備

まずは、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 インスタンスタイプを選びます。

aws.amazon.com

このページで中断の頻度を確認し、あまり中断率が高くないインスタンスを選びます。

今回は

  • 2 vCPU / 8 GiB
  • 中断率 5% 未満

となっていた m7a.large を使うことにします。

中断率が高いと数分で止まることもあるので、ある程度低いものを選ぶのがおすすめです。


VPC と起動テンプレートの準備

Minecraft 用のサーバーを起動するための起動テンプレートを作ります。

VPC / サブネット / SG

  • VPC: 任意(既存 VPC でも新規でもよい)
  • サブネット: 任意の VPC にパブリックサブネットを作成

セキュリティグループは次のようにします。

タイプ ポート範囲 ソース 説明
カスタム 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

ストレージ設定:

  • 新しいボリュームを追加

    • バイス名: 任意
    • スナップショット: 起動テンプレートの設定に含めない
    • サイズ: 8GiB
    • ボリュームタイプ: gp3
    • IOPS: 3000
    • 終了時に削除: はい
    • 暗号化: なし
    • スループット: 125

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 に機能を追加する

  • @aws-sdk/client-ec2 3.918.0
  • @aws-sdk/client-eventbridge 3.659.0
  • rcon 1.1.0

これらを含めた環境で 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.tscommands に次を追加します。

  {
    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)を使います。

docs.aws.amazon.com

このチュートリアルを参考に、 データボリュームに付けたタグ

  • Name = Minecraft-Data
  • BlogApp = MinecraftApp
  • BlogServer = MinecraftServer
  • BlogRole = MinecraftData

を使ってターゲットを選び、Spot 中断イベントを発生させて挙動を確認します。


おわりに

今回の後編では、Discord から /start /stopMinecraft サーバーを操作し、 スポットインスタンスと EBS スナップショットを組み合わせて 必要なときだけ起動する省コスト構成を作りました。


コストまとめ

1. スポットインスタンス m7a.large の実測値はだいたい:

  • 約 0.05 USD/時(オンデマンドの約半額)

遊ぶときだけ起動する形なら、かなり安く運用できます。


2. EBS スナップショット(データ保存)

15 GiB のワールドデータで:

  • 約 0.75 USD/月(約 110〜120 円/月)

固定費としてはこの部分だけ。


3. Lambda(Discord Bot / 管理処理)

  • ほぼ 0 円(無料枠内)

実行時間も短く、コストは気にしなくてよいレベル。


この構成なら、 「必要なときだけ遊べて、使わないときはほぼ課金なし」 という Minecraft サーバーを実現できます。

気が向いたら /status/backup なども追加しつつ、 自分だけの“省コスト自動管理サーバー”に育ててみてください。

ここまで読んでいただき、ありがとうございました。