0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWS上でパルワールドサーバを立ててみた ~Discord連携を添えて~

0
Posted at

はじめに

AWS EC2上にパルワールドのサーバを立てる機会があったのですが、プレイヤーがいないのにサーバが開きっぱなしになっているのは無駄なので、Discord botと連携してサーバを停止・起動までできるようにしてみました。
備忘録的に書いておきますがもっと良い方法はあるかも。

サーバを準備する

EC2を立てる

まずはサーバ用の箱を作る、具体的にはEC2インスタンスの作成する。
AMIは無料枠のAmazon Linux 2023。
公式ドキュメント( https://docs.palworldgame.com/ja/getting-started/requirements/ )にはCPU4コア、メモリ16GB(安定した動作のために32GB以上を推奨)が必要とあるため、インスタンスタイプはt2.xlargeを選択。

セキュリティグループには以下の設定をしておく。
プロトコル: カスタムUDP、ポート: 8211、送信元: 0.0.0.0/0
プロトコル: SSH、送信元: マイIP

また、サーバを立てたり落としたりする際にIPが変わらないようにElastic IPを付与しておく。

パルワールドサーバの立ち上げ

サーバに入ってDockerを導入

# 1. EC2 に SSH 接続
ssh -i /path/to/your-key.pem ec2-user@<EC2のIPアドレス>

# 2. パッケージの更新と Docker のインストール (Amazon Linux の場合)
sudo dnf update -y
sudo dnf install -y docker

# 3. Docker サービスの起動と自動起動設定
sudo systemctl start docker
sudo systemctl enable docker

# 4. ec2-user に Docker の実行権限を付与(次回ログイン時から sudo なしで実行可能に)
sudo usermod -aG docker ec2-user

公式Dockerコンテナ用いてパルワールドサーバを立てる

mkdir -p ~/palworld_server
# ※操作を簡略化するためパーミッションを全開放
chmod 777 ~/palworld_server

# 公式 SteamCMD コンテナでダウンロード・インストールを実行
# コンテナ内の /data にインストール、Steamに匿名アカウントでログイン、Palworld Dedicated ServerのアプリID(2394010)を指定し、ダウンロード
docker run -it --rm \
  -v ~/palworld_server:/data \
  steamcmd/steamcmd:latest \
  +force_install_dir /data \
  +login anonymous \
  +app_update 2394010 validate \
  +quit

sudo chmod -R 777 ~/palworld_server

# サーバの起動
# -w /palworldで作業ディレクトリを/palworldにセット
docker run -d \
  --name palworld-server \
  -p 8211:8211/udp \
  -v ~/palworld_server:/palworld \
  -w /palworld \
  cm2network/steamcmd \
  ./PalServer.sh

# 次回から EC2 起動時に自動で立ち上がるように設定
docker update --restart always palworld-server
# 手動でコンテナを立ち上げる場合
docker start palworld-server

これで:8211にパルワールド上でアクセスすることができる。
サーバにパスワードなどの設定をする場合は、PalWorldSettings.iniを編集すれば良い。詳細な変数は公式ドキュメントに記載されている
https://docs.palworldgame.com/ja/settings-and-operation/configuration/

cd ~/palworld_server/Pal/Saved/Config/LinuxServer/
cp ~/palworld_server/DefaultPalWorldSettings.ini ./PalWorldSettings.ini
nano PalWorldSettings.ini
# ServerPassword="" の部分を編集

Discord連携機能を追加する

これで基本的なサーバとしての機能は完成しました。
続いて「はじめに」で記載したように、このサーバをDiscordのbot経由で立ち上げたり落としたりできる機能を追加します。

IAMロールの追加

LabmdaにEC2の立ち上げ・CloudWatchへのログを書き込む権限を付与するために、ロールを作成します。

  1. IAMのページから「ロールを作成」を選択
  2. 信頼されたエンティティタイプ「AWS のサービス」を選択、ユースケース: Lambda を選択して、「次へ」 をクリック
  3. AmazonEC2FullAccess、AWSLambdaBasicExecutionRole を追加(簡易的に実装するためFullAccessにしていますが、対象に絞ったほうが良いです)
  4. ロール名: Discord-EC2-Lambda-Role など分かりやすい名前を入力し、一番下の 「ロールを作成」 をクリック

image.png

Lambda関数の設定

次に、Discord botから呼び出されたらEC2を立ち上げたり落としたりするようなLambda関数を作成する。

  • Lambda関数の作成
    「関数を作成」を選択。以下の設定を投入。
    • 関数名: PalServerController(適当に決める)
    • ランタイム: Node.js
    • その他の設定 -> カスタム実行ロール: 既存のロールを使用する を選択、先ほど作った Discord-EC2-Lambda-Role を選択image.png

  • 関数URLの発行
  1. Lambda画面のタブから「設定」を選択
  2. 左メニューから「関数 URL」をクリックし、「関数URLの作成」を押す
  3. 認証タイプ: NONE(認証なし)を選択し、「保存」 をクリック
  4. 生成された「関数URL」をコピーしておく(これをDiscord botに登録する)

(参考)認証タイプをNONEとしているため、URLを持つユーザーは誰でも関数にアクセスできる状態となっている。そのため今回は認証をコードベースで実装している。

image.png


Discord botの作成

  1. Discord Developer Portal( https://discord.com/developers/home )を開き、アプリを新規作成する
  2. 作成したBotアプリケーションを選択し、左メニューの「一般情報」を開く
  3. 名前や概要を入力したら「インタラクション・エンドポイントURL」 の欄に、先ほどコピーしたLambdaの関数URLを貼り付ける。
  4. 下に記載の「Lambdaにコードを配置」が完了したら、画面下の 「変更を保存」 をクリック
    これでDiscord Botが作成された。

image.png

Lambdaにコードを配置

以下のコードを配置する。

index.mjs
import { crypto } from 'node:crypto';
import { EC2Client, StartInstancesCommand, StopInstancesCommand, DescribeInstancesCommand } from "@aws-sdk/client-ec2";

// ★ Discord Developer Portal の General Information にある PUBLIC KEY
const DISCORD_PUBLIC_KEY = 'Discord botで表示されているPUBLIC_KEYを貼り付け';

// ★ EC2のリージョンとインスタンスID
const REGION = 'リージョンをここに貼り付け';
const INSTANCE_ID = 'EC2インスタンスIDをここに貼り付け';

const ec2 = new EC2Client({ region: REGION });

// Ed25519 署名検証ロジック (Node.js標準機能のみで動作)
function verifySignature(event) {
    const headers = Object.fromEntries(
        Object.entries(event.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
    );
    const signature = headers['x-signature-ed25519'];
    const timestamp = headers['x-signature-timestamp'];
    const body = event.body || '';

    if (!signature || !timestamp) return false;

    try {
        const key = crypto.createPublicKey({
            key: Buffer.concat([
                Buffer.from('302a300506032b6570032100', 'hex'), // Ed25519 ASN.1 Header
                Buffer.from(DISCORD_PUBLIC_KEY, 'hex')
            ]),
            format: 'der',
            type: 'spki'
        });

        return crypto.verify(
            null,
            Buffer.from(timestamp + body),
            key,
            Buffer.from(signature, 'hex')
        );
    } catch (e) {
        return false;
    }
}

export const handler = async (event) => {
    // 1. 署名検証(不正なら 401 を返す:Discord要件)
    if (!verifySignature(event)) {
        return { statusCode: 401, body: JSON.stringify('invalid request signature') };
    }

    const body = JSON.parse(event.body || '{}');

    // 2. Discord PING テストに応答 (Type 1 -> Type 1)
    if (body.type === 1) {
        return {
            statusCode: 200,
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ type: 1 })
        };
    }

    // 3. スラッシュコマンド処理
    const commandName = body.data?.name;
    let message = "未知のコマンドです。";

    if (commandName === 'start') {
        await ec2.send(new StartInstancesCommand({ InstanceIds: [INSTANCE_ID] }));
        message = "🚀 **パルワールド サーバーの起動リクエストを送信しました!**\n1〜2分ほどで接続可能になります。";
    } else if (commandName === 'stop') {
        await ec2.send(new StopInstancesCommand({ InstanceIds: [INSTANCE_ID] }));
        message = "🛑 **パルワールド サーバーの停止リクエストを送信しました。**";
    } else if (commandName === 'status') {
        const res = await ec2.send(new DescribeInstancesCommand({ InstanceIds: [INSTANCE_ID] }));
        const state = res.Reservations[0]?.Instances[0]?.State?.Name;
        const statusEmoji = state === "running" ? "🟢" : state === "stopped" ? "🔴" : "🟡";
        message = `${statusEmoji} **現在のサーバー状態:** \`${state}\``;
    }

    return {
        statusCode: 200,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            type: 4,
            data: { content: message }
        })
    };
};

仕組みは以下の通り。

  • ボットの受信用 URLはインターネット上に公開されるため、悪意のある第三者が勝手にサーバーを起動・停止できないよう、「本当に Discord から届いたリクエストか?」を暗号署名(Ed25519)で厳格にチェック。
// リクエストから署名とタイムスタンプ、リクエスト本文を抽出
// Discordの仕様上、「タイムスタンプ + リクエスト本文」 の文字列を秘密鍵で暗号化したものが 署名(signature) になる
const signature = headers['x-signature-ed25519']; // ① 署名データ
const timestamp = headers['x-signature-timestamp']; // ② タイムスタンプ
const body = event.body || '';                       // ③ リクエスト本文

...

// Ed25519のパブリックキーであるという目印となるヘッダを付与
const key = crypto.createPublicKey({
    key: Buffer.concat([
        Buffer.from('302a300506032b6570032100', 'hex'), // Ed25519 ASN.1 Header
        Buffer.from(DISCORD_PUBLIC_KEY, 'hex')
    ]),
    format: 'der',
    type: 'spki'
});

...

// verify で検証
return crypto.verify(
    null, // Ed25519は暗号化の内部でハッシュ化を行うためアルゴリズム指定は null
    Buffer.from(timestamp + body), // 検証対象の元のデータ
    key,                           // 復元したパブリックキー
    Buffer.from(signature, 'hex')  // Discordから届いた署名
);
  • PING テスト応答。Discord Developer Portal で Endpoint URL を設定・保存するときに、Discord から type: 1(PING)が飛んでくるので、それに対して type: 1(PONG)を返さないと、Endpoint の登録に失敗する。
if (body.type === 1) {
    return {
        statusCode: 200,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ type: 1 })
    };
}
  • チャットで打たれたコマンド名に応じて AWS EC2 に命令を渡す。Discord サーバーから Lambda へのリクエストも、Lambda から Discord へのレスポンスも、すべて JSON 形式のデータ構造になっている
    /start ➜ StartInstancesCommand(サーバー起動)
    /stop ➜ StopInstancesCommand(サーバー停止)
    /status ➜ DescribeInstancesCommand(今の状態を取得:running 🟢 や stopped 🔴 など)
const commandName = body.data?.name;

if (commandName === 'start') {
    // サーバー起動
    await ec2.send(new StartInstancesCommand({ InstanceIds: [INSTANCE_ID] }));
    message = "🚀 **パルワールド サーバーの起動リクエストを送信しました!**\n1〜2分ほどで接続可能になります。";

} else if (commandName === 'stop') {
    // サーバー停止
    await ec2.send(new StopInstancesCommand({ InstanceIds: [INSTANCE_ID] }));
    message = "🛑 **パルワールド サーバーの停止リクエストを送信しました。**";

} else if (commandName === 'status') {
    // サーバーの状態確認 (running / stopped / pending など)
    const res = await ec2.send(new DescribeInstancesCommand({ InstanceIds: [INSTANCE_ID] }));
    const state = res.Reservations[0]?.Instances[0]?.State?.Name;
    const statusEmoji = state === "running" ? "🟢" : state === "stopped" ? "🔴" : "🟡";
    message = `${statusEmoji} **現在のサーバー状態:** \`${state}\``;
}

...

return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        type: 4,
        data: { content: message }
    })
};

handlerの設定

Lambdaの「コード」->「ランタイム設定」から、ハンドラーを「index.handler」に指定する。
image.png

DiscordにBot、コマンド登録

Discord サーバーに Bot を招待する

以下の手順で自分の Discord サーバーにbotを招待する。

  1. Discord Developer Portal の左メニューから 「OAuth2」 ➔ 「OAuth2 URLジェネレーター」 をクリック。
  2. スコープ には「bot」にチェックを入れる、BOTの権限 では「メッセージを送る」や 「スラッシュコマンドを使用」にチェックを入れる。
  3. 画面下部に生成された URL をコピー してブラウザで開き、bot導入したいDiscord サーバーを選択すればよい。

image.png

スラッシュコマンド(/start など)を登録する

最後にスラッシュコマンドの機能を追加する。
これはローカルから以下のコマンドを叩けばよい。

  • ★YOUR_APPLICATION_ID: Developer Portal の General Information にある APPLICATION ID
  • ★YOUR_BOT_TOKEN: Developer Portal の Bot タブにある TOKEN(Reset Token で取得できるもの)
curl -X PUT "https://discord.com/api/v10/applications/★YOUR_APPLICATION_ID/commands" \
  -H "Authorization: Bot ★YOUR_BOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '[
    {"name": "start", "description": "パルワールドサーバーを起動します"},
    {"name": "stop", "description": "パルワールドサーバーを停止します"},
    {"name": "status", "description": "パルワールドサーバーの状態を確認します"}
  ]'

Discordでテストする

  • /status と入力して送信
    ➔ 🟢 現在のサーバー状態: running や 🔴 stopped などが返ってくれば成功
  • /start と入力して送信
    ➔ EC2 インスタンスの起動リクエストが送信される
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?