🛵

AzureVMでMinecraftサーバー&Discord slash commandでVMの起動/停止

2021/09/29に公開

はじめに

はじめてこういうの書きます。すごく適当です。友達に見せる用かつ備忘録です。

やりたかったこと

  • サーバレスなんたらをさわってみる
  • Minecraftをなる安で快適に友達と遊ぶ
  • AzureVMは従量課金とやらなのでMinecraftしない時間は節約したい
  • Discordで起動してそのままボイスチャンネルにいてもらって「あいついるじゃん参加しよ」ってなったら楽しそう

実際にやったこと

  • VMにMinecraftサーバ動かす
  • Discord botにslash command登録
  • functionsにbotのinteraction受けとる関数(func1とする)置く
  • functionsにfunc1からのリクエスト受け取ってVMを起動/停止する関数置く

全体像

以下図の感じです

実際にやったこと(詳細)

上の実際にやったことを小さいスコープで解説。コード残ってるやつは見せます。

VMインスタンス作ってMinecraftサーバー動かす

AzureにサインアップしてAzure Portalまでいきます。そんでVMインスタンスつくります。自分はubuntu20で作りました。

user名とか鍵とかipaddressとかはメモ帳にでも書いときます。

なんかここまで丁寧にやる必要ない気がするので省略しつついきます。Minecraftのマルチプレイする用のマシンサイズは調べてやって
sshの鍵はいい感じに作ってダウンロードしてくれます。

そんでVMにsshします。できないときはたぶん鍵のパスが間違ってる。.sshに入れとこうね

bash
ssh -i {hoge_key.pemのパス} {username}@{ipaddress}

sshできたら一応何ですけどapt updateしてcurlとかvimとか入れます
sudoいらねーよとかは恥ずかしいので言わないでください。

bash
username@vmname:~$ sudo apt update -y
username@vmname:~$ sudo apt upgrade -y
username@vmname:~$ sudo apt install curl vim -y

そんでMinecraftのためにJavaをインストール。今回はMinecraft17.1をやるのでjava16です。以下参照
https://minecraft.mixjuice.info/2021/06/12/java16-ubuntu/

次はMinecraft-server入れます。
https://www.minecraft.net/ja-jp/download/server
ここのminecraft_server.1.17.1.jarのリンクurlをコピーしときましょう。
んで適当にディレクトリ作ってそこにcurlします。

bash
username@vmname:~$ mkdir mine
username@vmname:~$ cd mine
username@vmname:~$ curl -LO {さっきのurl}

server.jarみたいなのがあるので動かしときましょう。今回はsystemdにサービス登録してOS起動時に自動起動します。ここはめんどくさいので以下参照
https://qiita.com/nownabe/items/ca45bb4829d75460b31e

マイクラのデフォルトの25565ポート開けます。Azure Portalからvmのネットワーク設定で「受信ポートの規則を追加する」みたいなボタンで設定します。
ここまでくるとマイクラできます。

Discord のbotつくってコマンド登録

これ見てやるよ
https://zenn.dev/drumath2237/articles/112fd0bfa7ea4f836195

  • Discord developer portalでbotつくってDiscordのサーバに入れてあげる
  • Discordのapi使ってコマンド登録する
    • 適当にディレクトリつくってその中でnpm initして、npm install node-fetch requistする
    • index.jsつくる。今回は以下コード
index.js
import fetch from "node-fetch";

const appID = {botのappID};
const guildID = {招待したDiscordサーバのguildID};
const apiEndpoint = 
  `https://discord.com/api/v8/applications/${appID}/guilds/${guildID}/commands`;
const botToken = {botのbotToken};

const commandData = {
    name: "vmss",
    description: "command to start/stop vm",
    options: [
      {
        name: "action",
        description: "start/stop",
        type: 3,
        required: true,
        choices: [
          {
            name: "start",
            value: "start"
          },
          {
            name: "stop",
            value: "stop"
          },
          {
            name: "test",
            value: "test"
          },
        ],
      },
    ],
  };

  async function main() {

    const response = await fetch(apiEndpoint, {
    method: "post",
    body: JSON.stringify(commandData),
    headers: {
      Authorization: "Bot " + botToken,
      "Content-Type": "application/json",
    },
  });
  const json = await response.json();

  console.log(json);
}
main();

commandDataの中身が登録するコマンド
vmssって名前の、引数(action:start/stop/test)一つ必要なコマンドだよって

  • そんでbashでnode index.jsとして実行
  • Discordのapiエンドポイントにcommandの中身をjsonでhttp req (post)して登録
  • idとかがjsonで帰ってきたら(http res)完了

こんな感じでtabで自動補完されるようになってる。

中身ないから送信してもエラーになる

一応github置いとく
https://github.com/xianglishan/discbotcommands

Discord bot からinteractionを受けとる関数をつくってfunctionsにデプロイ

Azure Portalから関数アプリをつくる。ランタイムはnodejs14LTSでプランは重量課金のやつ
vscodeに公式拡張があるのでそれでプロジェクトテンプレートをつくってデプロイまでできる。以下参照
https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-develop-vs-code?tabs=csharp
トリガーはhttp, 言語は typescript,関数名は適当に決めて出来上がったら、必要なやつインストール

bash
$ npm install
$ npm install discord-interactions express @types/node

プロジェクト/関数名/index.tsに以下コード

index.ts
import { AzureFunction, Context, HttpRequest } from "@azure/functions";
import {
  InteractionType,
  InteractionResponseType,
  verifyKey,
} from "discord-interactions";
//import fetch from "node-fetch";
import axios from "axios";

const CLIENT_PUBLIC_KEY = process.env.CLIENT_PUBLIC_KEY;
const STARTURL = process.env.STARTURL;
const STOPURL = process.env.STOPURL;

const httpTrigger: AzureFunction = async function (
  context: Context,
  req: HttpRequest
): Promise<void> {
  const sig = req.headers["x-signature-ed25519"];
  const time = req.headers["x-signature-timestamp"];
  const isValid = await verifyKey(req.rawBody, sig, time, CLIENT_PUBLIC_KEY);

  if (!isValid) {
    context.res = {
      status: 401,
      Headers: {},
      body: "",
    };
    return;
  }

  const interaction = req.body;
  if (interaction && interaction.type === InteractionType.APPLICATION_COMMAND) {
    const action = req.body.data.options[0].value;
    const username = req.body.member.user.username;

    if (action == "start") {
      axios.get(STARTURL);
      //fetch(STARTURL);

      context.res = {
        status: 200,
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
          data: {
            content: `hi ${username}, server ${action}. connect minecraft server few minutes later.`,
          },
        }),
      };

    } else if (action == "stop") {
      axios.get(STOPURL);
      //fetch(STOPURL);
      
      context.res = {
        status: 200,
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
          data: {
            content: `server ${action} in few minutes.`,
          },
        }),
      };

    } else if(action == "test"){
      context.res = {
        status: 200,
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
          data: {
            content: `${action}, by ${username}`,
          },
        }),
      };
    }

  } else {
    context.res = {
      body: JSON.stringify({
        type: InteractionResponseType.PONG,
      }),
    };
  }
};

export default httpTrigger;

ここでSTARTURLとSTOPURLは次のfunctionのエンドポイントで、環境変数に設定する(CLIENT_PUBLIC_KEYもです)。環境変数はAzure Portalの関数アプリ-メニュ-設定-構成のところで構成を追加とかってやるとできる。

action: startならSTARTURLw=にhttp req (get)する感じ。それで次のfunctionを呼び出す。resを待ってると時間掛かってbot側が勝手にエラー吐くからawaitしないで投げっぱなし

こいつをvscodeのさっきいれた拡張のデプロイボタンでできるよ。

そんでこの関数のurlをDiscord developer portalからbotアプリのinteraction end point に登録する。

これでdiscordからコマンドを打つと応答が帰ってくるようになった。

一応github置いとく
https://github.com/xianglishan/discbotInteraction2azurefunction

http req からVMを起動/停止するfunction

次はランタイムはpowershellで関数アプリつくります。

ここは設定ややこしくて難しいのでgithubおいときます。

とりあえずVMのステータス見てくる処理とか起動スタートさせる処理とかちょっと待ち時間長い感じなのでここにいきなりbotからinteractionなげると待ちが長くてエラー吐かれる。

https://github.com/xianglishan/azureFunctions2VM

デプロイしてこの関数のプロキシurlを一つ目の関数アプリに環境変数として設定したりして全部うまく行くとDiscordで/vmss action:startって打つと30秒後くらいにはマイクラできる

完成

これでDiscordでコマンドを打つとVMが起動できて、systemdで動いてるMinecraft serverで遊べるぜ
Azureのユーザーつくって渡してポータルからVMの状態確認して起動するみたいなこともない

でもなんかエラーはいてくる

コールドスタートらしい

これみるとなんとなくわかる。関数のホストプランを重量課金プランからプレミアムなやつにすることで解決するらしい
https://blog.alea12.net/serverless-functions

実際に最初の2,3発はエラー吐く

VMも2つの関数も寝てる状態から一人づつたたき起こしてる感じ?なんとかしたい

さいごに

めちゃくちゃ走り書きなのでわかりずらいと思います。時間があるときに追記します。

もし同じようなことしようとしていてここ書いて!ってのがあればなるべく対応します。

GitHubで編集を提案

Discussion