🐳

Discordでスラッシュコマンドを実行するだけでMinecraftサーバーを管理できるようなボットを作った

に公開

はじめに

自分は普段Minecraftサーバーを自宅サーバーでDockerを使って動かしているのですが、以下のようなことを思っていました。

  • 友達とやるときに毎回ちまちま起動するのがめんどくさい
  • あまりサーバーなどを触れることのない友達にサーバー貸すのが難しい
  • CraftyなどすでにWebUIで管理できるものもあるが、もっと簡単に手軽に扱いたい

そこでDiscordボット経由でマイクラサーバーを作成したりバックアップを取れたらいいなと思い、今回Discordボットを作成しました

ボットの概要

このDiscordボットはスラッシュコマンドで指示することでサーバーを管理することができます。
下の画像は/startコマンドを実行してマイクラサーバーを起動したときの様子です。

埋め込みに表示されている項目は/createでサーバーを作成する際に設定できます。

このほかにもホワイトリストや管理者(Operator)を設定できたり、バックアップを作成する機能などがあります。
自分の用途的に同時にサーバーを二つ以上起動することが少ないので、このボットでは同時に起動できるサーバーは1つだけという仕様になっています。

使ったライブラリ

  • Bun Discordボットを動かすランタイム
  • discord.js 今回はTypeScriptを使うためこちらを使用
  • Dockerode JavaScriptで書かれたDocker SDK

ボットでやっていること

このボットではマイクラサーバーをDockerコンテナで起動したりしているのですが、このボット自体もDockerコンテナで動かしています。ただ、ボットのコンテナの中でマイクラサーバーのコンテナが動いているかというとそうではなく、ボットとマイクラサーバーのコンテナが並列で動いています。

ではどうやってボットがホスト上で動いているコンテナを操作するのかというとDOOD(Docker outside of Docker)という仕組みを使います。通常コンテナは外部を操作できませんが、/var/run/docker.socketをマウントすることによって、ホストのDockerエンジンを操作することができます。これによってボットはホスト上にコンテナを起動できます。

ちなみにDOODとは対照にDIND(Docker in Docker)というものもあり、こちらはDockerコンテナ内でさらにコンテナを起動するものです。こちらはコンテナ内で解決するため、隔離性などが高かったりしますが、その反面コンテナが重くなったり、リソースを消費したりします。

実際の実装

discord.jsの使い方などは多くの方が説明していると思うので、ここでは機能の肝であるDockerodeの使い方を紹介します。

Dockerodeの基本的な使い方

ここでは実際にDockerodeを使ってマイクラサーバーのコンテナを作成してみます。

DockerodeはJavaScriptで書かれているため型パッケージも一緒にインストールします。

bun install dockerode @types/dockerode

DockerインスタンスのcreateContainer()メソッドを使うことで簡単にコンテナを作成できます。
ここではMinecraftサーバーのDockerイメージであるitzg/minecraft-serverを使います

import Docker from "dockerode";

const docker = new Docker();

const container = await docker.createContainer({
  Image: "itzg/minecraft-server", // Minecraftサーバーのイメージ
  name: "minecraft-server",
  Env: [
    "EULA=TRUE",
  ],
  HostConfig: {
    PortBindings: {
      "25565/tcp": [
        {
          HostPort: "25565", // 25565ポートにマッピング
        },
      ],
    },
  },
});

// コンテナを起動
await container.start()

// await container.stop() // ストップ
// await container.remove() // 削除

上のコードを実行してみると実際にコンテナが作成されました。

docker ps
CONTAINER ID   IMAGE                   COMMAND    CREATED              STATUS                        PORTS                      NAMES
7326a3e4a0b8   itzg/minecraft-server   "/start"   About a minute ago   Up About a minute (healthy)   0.0.0.0:25565->25565/tcp   minecraft-server

他にも特定のコンテナの情報を取得したり、コンテナの一覧を取得することもできます。

// 特定のコンテナの情報を取得
const containerInspect = await container.inspect();
console.log(`Container Name: ${containerInspect.Name}`); // => /minecraft-server
console.log(`Container State: ${containerInspect.State.Status}`); // => running

// コンテナの一覧を取得
const containers = await docker.listContainers();
containers.forEach((containerInfo) => {
  console.log(`Container ID: ${containerInfo.Id}`); // => 7326a3e4a0b8...
  console.log(`Names: ${containerInfo.Names}`);     // => /minecraft-server
  console.log(`Image: ${containerInfo.Image}`);     // => itzg/minecraft-server
  console.log(`Status: ${containerInfo.Status}`);   // => Up 1 second (health: string)
});

お気づきかもしれませんが、listContainer()inspect()で返ってくるデータ型が異なるで注意してください。listContainer()Docker.ContainerInfo[]を返すのに対し、inspect()Docker.ContainerInspectInfoを返します。

メタ情報を使う

今回のボットはマイクラサーバーを作成しますが、マイクラサーバーの設定は内部のserver.propertiesなどに保存されるため、コンテナが止まっていると設定を確認することができません。そこでDockerラベルというものを使っています。

Dockerラベルはコンテナのメタデータとして、キー・バリューペアの形式でデータを保存できるものです。メタデータ自体はコンテナが止まっていても読み取ることができるため、ここにユーザーが設定した情報を保存することで停止しているマイクラサーバーの設定を確認することができます。

一応DBを用意して、コンテナに対応する情報を保存することはできますが、多くて十数個程度しかコンテナを扱わないことや、コンテナとDBの両方を管理する必要があり整合性を保つのが面倒だという理由でDockerラベルを使用しています。

https://docs.docker.jp/v17.06/engine/userguide/labels-custom-metadata.html

もちろんDockerodeでもDockerラベルを扱うことができます。また、listContainer()はラベルの値でフィルターすることもできます。

const server = {
	name: "minecraft-server",
	version: "1.21.10",
	description: "Minecraft Server Container",
};

// コンテナを作成
const container = await docker.createContainer({
	Image: "itzg/minecraft-server", // Minecraftサーバーのイメージ
	name: server.name,
	Env: [
		// 省略...
	],
	// ラベルを設定
	Labels: {
		name: server.name,
		version: server.version,
		description: server.description,
	},
	HostConfig: {
		// 省略...
	},
});

// 検証用にもう一つコンテナを作成
const otherContainer = await docker.createContainer({
	Image: "itzg/minecraft-server", // Minecraftサーバーのイメージ
	name: "other-minecraft-server",
	Env: [
		// 省略...
	],
	Labels: {
		name: "other-minecraft-server",
		version: "1.20.0",
		description: "Other Minecraft Server Container",
	},
	HostConfig: {
		// 省略...
	},
});

const containers = await docker.listContainers();
containers.forEach((containerInfo) => {
	// ラベルのキーから値を取得する
	console.log(`Name: ${containerInfo.Labels["name"]}`);
	// => /minecraft-server, /other-minecraft-server
	console.log(`Version: ${containerInfo.Labels["version"]}`);
	// => 1.21.10, 1.20.0
	console.log(`Description: ${containerInfo.Labels["description"]}`);
	// => Minecraft Server Container, Other Minecraft Server Container
});

// ラベルでフィルタリングする
const filteredContainers = await docker.listContainers({
	filters: { label: [`name=${server.name}`] },
});
filteredContainers.forEach((containerInfo) => {
	console.log(`Name: ${containerInfo.Labels["name"]}`);
	// => /minecraft-server
	console.log(`Version: ${containerInfo.Labels["version"]}`);
	// => 1.21.10
	console.log(`Description: ${containerInfo.Labels["description"]}`);
	// => Minecraft Server Container
});

RCON経由で設定を行う

マイクラサーバーを管理するにあたって、コンテナの設定以外にもコンテナ内で実行されているMinecraftサーバーの設定を行いたい場合があると思います。そのときに利用するのがRCONです。

今回のボットではホワイトリストやオペレーターの設定などRCON経由で行っています。一応これらの設定はコンテナを作成する際に環境変数で指定することもできますが、設定を反映させるためには一度コンテナを作り直さなければならず、サーバーを止めないといけません。一方RCONはコンテナ内のMinecraftサーバーに指示を送るためコンテナを起動したまま設定を行えます。とはいってもRCONはコンテナが起動していないと実行できないというデメリットもあります。

今回のボットではコンテナ内でコマンドを実行するために以下のような関数を実装しました。
自分もあまり詳しくないのですが、Dockerのストリームは少し特殊らしく、STDOUT/STDERRが多重化しているため、modem.demuxStream()の部分で分離しています。

export const execCommands = async (
	container: Docker.Container, // コマンドを実行したいコンテナ
	cmds: string[], // 実行したいコマンドの配列
): Promise<string> => {
	const exec = await container.exec({
		Cmd: cmds,
		AttachStdout: true,
		AttachStderr: true,
	});

	// Detach: falseでコマンドが完了するまで待機
	const stream = await exec.start({ Detach: false });
	// 出力を読み取るためのストリームを作成
	const stdout = new PassThrough();
	const stderr = new PassThrough();

	// コマンドの実行結果を蓄積する変数
	let output = "";
	let errorOutput = "";

	// 先ほど作成したストリームにデータが流れてきたら出力を蓄積
	stdout.on("data", (chunk) => {
		output += chunk.toString();
	});
	stderr.on("data", (chunk) => {
		errorOutput += chunk.toString();
	});

	// Dockerストリームを分離
	container.modem.demuxStream(stream, stdout, stderr);

	try {
		// ストリームの終了を待機
		await finished(stream);
	} catch (err) {
		throw new Error(`Stream error during exec: ${err}`);
	}

	// 終了コードをみてエラーを確認
	const inspectData = await exec.inspect();
	const exitCode = inspectData.ExitCode;

	if (exitCode !== 0) {
		throw new Error(errorOutput || `Command exited with code ${exitCode}`);
	}

	// 正常終了したら出力を返す
	return output;
};

この関数を使って試しにホワイトリストを設定してみます。

ここでは"Steve"というユーザーをホワイトリストに追加します。

// getContainer()はコンテナID, 名前どちらでも取得できます
const container = docker.getContainer("minecraft-server");

const enableResult = await execCommands(container, ["rcon-cli", "whitelist", "on"]);
console.log(enableResult); // => Whitelist is now turned on

const addResult = await execCommands(container, ["rcon-cli", "whitelist", "add", "Steve"]);
console.log(addResult); // => Added Steve to the whitelist

const listResult = await execCommands(container, ["rcon-cli", "whitelist", "list"]);
console.log(listResult); // => There are 1 whitelisted player(s): Steve

ここで注意してほしいのが、Minecraftサーバーは起動するのにかなり時間がかかるのでコンテナのログをみて以下のようなものが表示されてから上のコードを実行してください。

このようにしてコンテナ内でコマンドを実行することができるようになりました。

さいごに

ここまで読んでくださった方々ありがとうございます。Dockerodeを使うことで意外と簡単にコンテナを起動することができ、個人的に満足のいくボットが作成できたかなと思っています。今後も使っていく中で見つけた問題点などを修正していけたらと思います。

今回はDockerodeの紹介になってしまいましたが、Discordボットの実装などが気になる方はリポジトリはこちらにあるので覗いてみてください。
https://github.com/nomanoma121/minecraft-discord-bot


(余談ですが)Docker SDKについて調べていたら、ごく最近Docker公式がSDKを出しているのを見つけました。今後はこちらを使うのがよいのかもしれません。
https://github.com/docker/node-sdk

Discussion