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

はじめに

この記事では、AWSで低コストなMod環境のMinecraftサーバー構築する手順を、前編・後編の 2 回に分けて紹介します。

特に Minecraft の Mod 環境(ATM 系や大規模 Modpack など)は、 メモリ・CPU をしっかり確保したい一方で、常時稼働させるほど遊ばない というケースが多いと思います。

そこで今回は、

  • コストを抑えて Minecraft サーバーを動かしたい
  • 遊ぶときだけサーバーを起動できる仕組みがほしい
  • AWS を触りながら、Infra as Code や Lambda の構成も学びたい

という方に向けて、 Discord Bot からサーバーの起動/停止を操作し、遊び終わったら自動で止める構成を作っていきます。



概要

この構成では、以下のように Discord から Minecraft サーバーを操作できる仕組みを作ります。

  • Discord BotAWS Lambda 上で動かす
  • Slash Command(/start, /stop)でサーバーを制御
  • Minecraft サーバーは スポットインスタンスを使ってコスト削減
  • EBS スナップショットでワールドデータを永続化
  • Spot 中断通知が来たら、自動で安全停止+再起動する復旧フローも実装

想定しているユースケース

  • 友達と Mod 環境の Minecraft を遊びたい
  • でも常時サーバーを稼働させるほどではない
  • そこそこ高いスペックが必要なので、できる限り安く済ませたい
  • AWS の勉強としても、触りながら構築してみたい

「遊ぶときだけ /start」 「終わったら /stop」 で快適に運用できる仕組みを目指します。


構成図


本記事について

この記事は 前編 で、主に以下を扱います。

  • Discord Bot を Lambda 上で動かす構成
  • Slash Command の受信・署名検証
  • /hello /echo の実装
  • Discord → Lambda → Discord の基本的な往復

ここまでできれば、後編の 「Lambda からスポットインスタンスMinecraft サーバーを起動する」 にスムーズに進むことができます。


プロジェクト準備

今回の構築では、以下の環境で動作確認しています。

使用バージョン

  • Node.js 22.21.0
  • npm 10.9.4
  • AWS CDK CLI 2.1014.0
  • TypeScript 5.6.3
  • ts-node 10.9.2
  • aws-cdk-lib 2.194.0
  • constructs 10.0.0
  • discord-interactions 4.0.0
  • node-fetch 3.3.2
  • dotenv 17.2.3

作業ディレクトリを作成し、CDK プロジェクトを初期化します。

mkdir blog-discord-bot
cd blog-discord-bot
cdk init --language=typescript

CDK で Lambda を定義する

lib/blog-discord-bot-stack.ts に、Discord リクエストを受ける Lambda を作成します。

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';

export class BlogDiscordBotStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    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.seconds(60),
      bundling: {
        externalModules: [],
        minify: true,
        sourceMap: true,
        format: lambdaNode.OutputFormat.CJS,
        target: 'node20',
      },
      environment: {
        DISCORD_PUBLIC_KEY: process.env.DISCORD_PUBLIC_KEY ?? '',
        DISCORD_APPLICATION_ID: process.env.DISCORD_APPLICATION_ID ?? '',
      },
    });

    // Discord の Interactions Endpoint 用に Function URL を作成
    const url = fn.addFunctionUrl({
      authType: lambda.FunctionUrlAuthType.NONE,
      cors: {
        allowedOrigins: ['*'],
        allowedMethods: [lambda.HttpMethod.ALL],
      },
    });

    new cdk.CfnOutput(this, 'FunctionUrl', { value: url.url });
  }
}

stack の書き方は、以下の API Reference を参照してください。

docs.aws.amazon.com

準備ができたら、cdk deploy で Lambda が作成されます。 コンソールに表示される Function URL を後で Discord 側に設定します。


Lambda の処理を作成する

lambda/index.ts を作成します。 Discord の署名検証と、Slash Command の応答を行うシンプルなハンドラです。

import { APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
import { verifyKey } from 'discord-interactions';

const { DISCORD_PUBLIC_KEY } = process.env;

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',
};

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 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: any) {
  const signature = hget(event.headers, 'x-signature-ed25519');
  const timestamp = hget(event.headers, 'x-signature-timestamp');

  if (!DISCORD_PUBLIC_KEY || !signature || !timestamp) {
    return { ok: false, code: 401, msg: 'missing headers or PUBLIC_KEY' };
  }

  const rawBody =
    event.isBase64Encoded && event.body
      ? Buffer.from(event.body, 'base64').toString('utf8')
      : event.body ?? '';

  try {
    const ok = await verifyKey(rawBody, signature, timestamp, DISCORD_PUBLIC_KEY);
    if (!ok) return { ok: false, code: 401, msg: 'invalid request signature' };
    return { ok: true, rawBody };
  } catch {
    return { ok: false, code: 401, msg: 'verification error' };
  }
}

export const handler = async (event: any) => {
  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;
  try {
    interaction = JSON.parse(verified.rawBody);
  } catch {
    return json(400, { error: 'invalid json' });
  }

  // Ping
  if (interaction.type === 1) {
    return json(200, { type: 1 });
  }

  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: any) => o.name === 'text')?.value ??
      '(テキストがありません)';

    return json(200, {
      type: 4,
      data: { content: `echo: ${text}` },
    });
  }

  return json(200, { type: 5 });
};

署名検証が通らないと Discord に拒否されるため、 verifyDiscordRequest が正しく動作していることが重要です。

interactions については以下のドキュメントを参照してください。

discord.com


Discord 側の設定

まず、Discord のアプリケーションを作成します。

https://discord.com/developers/applications

アプリケーション作成後の画面から、以下の情報を取得します。

  • Application ID
  • Public Key

これらを .env に保存します。

DISCORD_APPLICATION_ID=xxxxxxxxxxxx
DISCORD_PUBLIC_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

次に、アプリケーション画面内の「Interactions Endpoint URL」に、 CDK デプロイ時に出力された Function URL を入力します。

認証が成功すればそのまま保存できます。


Bot をサーバーに追加する

続いて、Bot を Discord サーバーに追加します。

アプリケーションの Bot ページを開き、「Reset Token」でトークンを発行しておきます。

権限の追加は不要です。 画面下の Generated URL をコピーします。

新しいタブに URL を貼り付けて開き、 管理権限を持つ任意のサーバーへ Bot を追加します。


.env への追記

コマンド登録時に必要となる以下の情報を .env に追加します。

DISCORD_BOT_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxx
  • Guild ID(サーバーID)

Discord の「開発者モード」を有効化し、 Bot を使用するサーバーを右クリック → ID をコピー

DISCORD_GUILD_ID=xxxxxxxxxxxxxxxxxxxx

ここまでで、Discord 側の準備は完了です。


Slash Command を登録する

script/register-commands.ts を作成します。 Bot Token を使って Discord API にコマンドを PUT します。

import fetch from 'node-fetch';
import 'dotenv/config';

const {
  DISCORD_APPLICATION_ID,
  DISCORD_BOT_TOKEN,
  DISCORD_GUILD_ID,
} = process.env;

if (!DISCORD_APPLICATION_ID) throw new Error('DISCORD_APPLICATION_ID is missing');
if (!DISCORD_BOT_TOKEN) throw new Error('DISCORD_BOT_TOKEN is missing');

const apiBase = 'https://discord.com/api/v10';

const commands = [
  { name: 'hello', description: '挨拶を返します' },
  {
    name: 'echo',
    description: '入力したテキストをそのまま返します',
    options: [{ type: 3, name: 'text', description: '返す内容', required: true }],
  },
];

async function registerCommands() {
  const url = DISCORD_GUILD_ID
    ? `${apiBase}/applications/${DISCORD_APPLICATION_ID}/guilds/${DISCORD_GUILD_ID}/commands`
    : `${apiBase}/applications/${DISCORD_APPLICATION_ID}/commands`;

  console.log('Registering commands to', url);

  const res = await fetch(url, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
    },
    body: JSON.stringify(commands),
  });

  if (!res.ok) {
    console.error(await res.text());
    process.exit(1);
  }

  console.log('Registered successfully');
}

registerCommands().catch((e) => {
  console.error(e);
  process.exit(1);
});

実行します。

npx ts-node script/register-commands.ts

成功すると Discord サーバー側で /hello /echo が利用できるようになります。


動作確認

Discord サーバーで /hello を実行すると Lambda が応答し、

と返ってくれば成功です。 /echo text:xxx も同様に応答します。


おわりに

ここまでで、Discord からのリクエストを Lambda で受け取り、 簡単なコマンド(/hello /echo)に応答できるところまで構築できました。

次の記事(後編)では、今回の Bot を拡張し、 スポットインスタンスを用いた Minecraft サーバーの起動/停止、自動復旧フローまでを解説します。

サーバー側のインフラ構築や Lambda との連携が本格的に始まるので、ぜひ続けてご覧ください。