Minecraft工業サーバ建立奮闘記

## 更新
- 2024年11月2日
    - Minecraftサーバの起動引数がおかしかったので修正
        - Log4Shellの対策設定がおかしかったので修正(`-Dlog4j.configurationFile`)
        - GC作動時の停止時間が0.2秒が長かったので短縮(`MaxGCPauseMillis`)
    - 修正前：
        ```dockerfile
        ENTRYPOINT ["./run.sh", "-XX:+AlwaysPreTouch", "-XX:+DisableExplicitGC", "-XX:+ParallelRefProcEnabled", "-XX:+PerfDisableSharedMem", "-XX:+UnlockExperimentalVMOptions", "-XX:+UseShenandoahGC", "-XX:+UseTransparentHugePages", "-XX:+UseNUMA", "-XX:InitiatingHeapOccupancyPercent=20", "-XX:MaxGCPauseMillis=200", "-XX:MaxTenuringThreshold=1", "-XX:SurvivorRatio=32", "-Xmx48G", "-Xms48G", "-jar", "-server", "-Dlog4j2_112-116.xml", "forge-1.12.2-14.23.5.2860.jar", "--nogui", "--universe", "/minecraft/."]
        ```
    - 修正後
        ```dockerfile
        ENTRYPOINT ["./run.sh", "-XX:+AlwaysPreTouch", "-XX:+DisableExplicitGC", "-XX:+ParallelRefProcEnabled", "-XX:+PerfDisableSharedMem", "-XX:+UnlockExperimentalVMOptions", "-XX:+UseShenandoahGC", "-XX:+UseTransparentHugePages", "-XX:+UseNUMA", "-XX:InitiatingHeapOccupancyPercent=20", "-XX:MaxGCPauseMillis=50", "-XX:MaxTenuringThreshold=1", "-XX:SurvivorRatio=32", "-Xmx48G", "-Xms48G", "-jar", "-server", "-Dlog4j.configurationFile=log4j2_112-116.xml", "forge-1.12.2-14.23.5.2860.jar", "--nogui", "--universe", "/minecraft/."]
        ```

## きっかけ

友人に「Nomi CEU っていう Greg ベースのModpack やってみん?」と言われた。
それワタシがサーバを用意するやつよね？

Nomi CEuについてはこちら。
- https://github.com/Nomi-CEu/Nomi-CEu

### 背景
E5-2697 v2とかDDR3 256GBとかがあったら流石にそうなる。
とりあえずポート開放が前提なので最低限やることやってかないとね。

## 構成
Proxmox VE8を動かしているので、その上にKVMとしてMinecraftサーバを建てる。
それだけだと面白味に欠けるので、Dockerの上でJVMを動かす。

### マシン構成
↓構成はこんな感じか？
緑の`gameserver`がVM、紫枠の`mc-forge-main`がDocker上のMinecraftサーバになる。

<img width="300" alt="MC-Forge on Docker" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3535545/67faf86b-cd71-5dfa-df8d-7a80d2590618.png">

|gameserver|VM resources|
|---|---|
|CPU|12 cores, 1 socket|
|RAM|64 GB|
|Storage|100 GB / SSD|

### OS/ミドルウェア

```shell-session
thrust2799@gameserver:~$ docker -v
Docker version 27.3.1, build ce12230
thrust2799@gameserver:~$ docker compose version
Docker Compose version v2.29.7
thrust2799@gameserver:~$ cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04.1 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04.1 LTS (Noble Numbat)"
VERSION_CODENAME=noble
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=noble
LOGO=ubuntu-logo
```

### 構成の考え方

Docker上で動かすので、OpenJDKはDockerイメージに含めて環境が汚れないというメリットがある。また、インシデントが起こっても被害がコンテナ内に留まるのではないかという狙いもある。
さらに今回は`docker compose`コマンドで全体を管理するので、デーモン化を含めて管理が非常に楽になる。
あとは、サーバにアクセスする人を制限したりしたいが...。


#### Velocity Serverが使えない！（敗北）
最近のマイクラには[Velocity](https://papermc.io/software/velocity)というプロキシサーバがあり、これを公開して多段構成にしようとした。
Velocityは複数サーバ間で移動できるコマンドがあるので、まず`mc-forge-lobby`サーバに入ってから権限がある人のみ`mc-forge-main`サーバに移動する構成を考えていた。
また、Velocity対応の権限集中管理ツールとして[LuckPerms](https://luckperms.net)があるので、これも使いたかった。（サーバ間の権限同期にはRDBが使えるので、MariaDBも建てようと思っていた。）

ボツ案になってしまったマシン構成としてはこんな感じ。
![ボツ構成](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3535545/a2a44e29-e944-a961-f138-22ee7938727c.png)


Velocityと連携できるのは、対応したプラグインサーバに加えForgeやFabricが導入されているサーバもある。
Forgeサーバで言うと、**Minecraft 1.13以上**だと専用Modで連携ができる。それ以下のバージョンだと **`Sponge Forge`というModを入れる**ことで最低限は動くらしい。

今回入れるNomi CEuは**Minecraft 1.12.2で動く**Forge用のModpack。
しかも、**`Sponge Forge`が微妙にサポートされていない**。

:::note warn
`Sponge forge`を入れてもNomi CEuは動くけど、設定を変えたりしないといけない。
そのあたりは[GithubのWikiページ](https://github.com/Nomi-CEu/Nomi-CEu/wiki/Page-1:-Player-Information#33-using-spongeforge)にまとまっている。
:::

なお、**LuckPermsについてはそもそもForgeに対応していない**。

#### 結果
やりたいことが悉くできなかったので、諦めてMinecraft Forgeのサーバ1本で建てることにした。（その結果が最初の構成の話になる。）

## Dockerfileで作るMinecraft Forgeなコンテナイメージ

まずはDockerfileでお手製のMinecraft Forgeなコンテナイメージを作成。ベースイメージはUbuntu 24.04を使っているが、多分busyboxとかでも行けるとは思う。
別の目的もあるためOpenJDKのイメージは使わない。~~（そもそも直近まで知らなかった。）~~
```Dockerfile
FROM ubuntu:noble
```
作成過程を試行錯誤した内容とともに振り返る。

### パッケージ操作と`curl`
```Dockerfile
RUN apt-get update \
    && apt-get install -y curl \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*
```
この後Forgeのインストーラを取得するために`curl`を使うが、実はベースイメージにこのコマンドが含まれていないという。~~Ubuntu 24.04 LTS Serverには入っているので直観に反する。~~（n年ぶりm敗目）

なお、`apt-get clean`や`rm -rf /var/lib/apt/lists/*`はパッケージ操作で生じた不要ファイルを削除する目的で実行する。
Debian系のベースイメージを使うときはパターンとして入れるようにしている。

:::note info
目的はインストーラの取得なので、`curl`の代わりに`wget`を入れても良い。
:::

### Java環境をどうするかという問題 （with `ShenandoahGC`）
```Dockerfile
COPY ./rh-openjdk /jre
```
今回サーバとして建てるベースは**Minecraft 1.12.2**なので、OpenJDK 8が必要になる。
通常なら先のパッケージ操作の段階で`openjdk-8-jre-headless`を入れるのだが、今回はRedHatがビルドしているOpenJDKを利用する。（ベースイメージがbusyboxでも良さそうな理由はここにある。）

というのも、OpenJDK 11.0.9から実装されている`ShenandoahGC`を使いたくなったが、通常のOpenJDK 8ではもちろん使えない...。（1敗）
が、RedHat版ではバックポートされているらしい。`ShenandoahGC`は少ない停止時間で動くガベージコレクタとされているため、`G1GC`の代わりに使いたくなった。

:::note warn
RedHat版OpenJDK 8はダウンロードにRedHatのアカウントが必要となる。
アクセスが限られているので、このイメージは個人利用にとどめるべきだろう...。
:::

ダウンロードしたJREはホストの`rh-openjdk`フォルダに展開した。このフォルダをコンテナイメージの`/jre`へコピーして利用する。

### `minecraft`ユーザと`/minecraft`フォルダ
```Dockerfile
RUN groupadd -g 500 -o minecraft
RUN useradd -u 500 -g 500 -M -o -r -s /sbin/nologin minecraft
RUN mkdir -p /minecraft/logs
RUN mkdir -p /minecraft/mods
RUN mkdir -p /minecraft/world && chown -R minecraft:minecraft /minecraft
USER minecraft:minecraft

WORKDIR /minecraft
```
[このベストプラクティス](https://www.docker.com/ja-jp/blog/understanding-the-docker-user-instruction/)でも示されている通り、コンテナ内とはいえrootユーザでプロセスを動かすのはリスクがある。そのためDockerイメージの作成においては専用ユーザと専用フォルダを作成し、それらを用いて動作するように構成を行う。

今回はユーザIDとグループIDが`500:500`な`minecraft`ユーザを作成する。この時、システムユーザかつ`/sbin/nologin`をログインシェルとして指定し、ホームディレクトリは作成しないでおく。（気休め）
また、`/minecraft`フォルダを作成し権限を与えておく。(`logs`、`mods`、`world`フォルダはMinecraft用の基本セットという意識で作成しているが、別になくても問題ない。)

最後に`USER`命令と`WORKDIR`命令で作業ユーザと作業フォルダを変更しておく。

### Forgeサーバをインストール
```Dockerfile
WORKDIR /minecraft
RUN curl -fOsSL https://maven.minecraftforge.net/net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860-installer.jar \
    && /jre/bin/java -jar forge-1.12.2-14.23.5.2860-installer.jar --installServer
RUN curl -fSsSL https://launcher.mojang.com/v1/objects/02937d122c86ce73319ef9975b58896fc1b491d1/log4j2_112-116.xml
RUN echo "eula=true" > ./eula.txt
```
今回はForgeを使ったMinecraftサーバを建てるため、ForgeインストーラのダウンロートURLを確認してコンテナ内で取得するよう構成する。ここで先ほど入れた`curl`を使う。
インストーラの取得に併せてForgeサーバをインストールしておく。これでいつでもMinecraft + Forgeなサーバの準備が整う。

なお、ForgeインストーラはNomi CEuで指定されているバージョンをダウンロードする。

:::note warn
ここで、`java`は先ほど展開した`/jre`配下のものを絶対パスで指定する。
間違えて`java -jar ...`なんてするとパスが通ってないのでコンテナイメージのビルドに失敗する。
:::

また、大騒動になったLog4Shellを回避するために公式が出しているXMLファイルを取得しておく。
Nomi CEuのサーバファイルにも含まれているが、多分どちらでもよい...？ ~~教えて有識者~~

`eula=true`をファイルに書き出しているが、これがないとサーバ起動に失敗する。
大体のMinecraftサーバ初回起動でつまずくポイントだが、今回はあらかじめ対応済みのコンテナイメージにすることで回避する。

:::note alert
Minecraftサーバなコンテナイメージを作って配布する人は`eula.txt`を削除して配布すべきである。なぜなら、このファイルを`true`にすることがその名の通りエンドユーザライセンスへの合意であるからだ。
:::

### エントリポイントに指定する起動用スクリプトの作成
```dockerfile
RUN cp -r libraries libraries_orig
RUN echo '#!/bin/bash' > run.sh \
    && echo 'set -x' >> run.sh \
    && echo 'if [[ ! -e ./libraries/org ]]; then' >> run.sh \
    && echo '  cp -rf libraries_orig/* libraries/.' >> run.sh \
    && echo 'fi' >> run.sh \
    && echo '/jre/bin/java "$@"' >> run.sh
RUN chmod o+x run.sh
```

このバージョンのForgeは`libraries`ファイルが生成される。この後`docker compose`コマンドでコンテナにマウントするホストディレクトリを指定していくが、ホストディレクトリをマウントするとマウント先に存在したコンテナ内のファイルが使えなくなる。
今回はコンテナ内のファイルを利用しなければいけないため、この部分に対処しなければいけない。~~別のMinecraftバージョンでやった時はこんなフォルダなかったのに...。~~（5敗）

回避策として、今回はイメージ内で`libraries_orig`へ移動しておくことにする。こうすることで`docker compose`コマンドによってホストディレクトリがマウントされても削除されなくなる。
そして、この後起動用スクリプトでコピーしてこれば解決となる。

作った起動スクリプトはちゃんと動作するように実行権限を付与しておく。

#### シェルスクリプトについて
```shell
#!/bin/bash
set -x
if [[ ! -e ./libraries/org ]]; then
  cp -rf libraries_orig/* liberaries/.
fi
/jre/bin/java "$@"
```
突っ込みどころしかない部分。

- `[[ ! -e ./libraries/org ]]`は正規表現を使っていないので`[ ! -e ./libraries/org ]`で良い
- 条件文の`-e`（ファイル存在）は`-d`（ディレクトリ存在）の方が明示的
- `/jre/bin/java "$@"`、ダブルクォートで囲ってなくて動作しなかった。（1敗）
- `/jre/bin/java`じゃなくて`java`にしたせいでコンテナ起動時にエラー（1敗）
    - コレ[さっき見た](#Forgeサーバをインストール)気がする

#### この部分の方式について少し反省する点
`libraries`の扱いについて、コンテナイメージは最低限動作に必要なものが揃うのでコンテナイメージ側をベースにすべきだった。さらに、現状だとコンテナイメージ内のファイル内容がホスト側へ反映されるため「追加したファイルがどれかわからない！」という状態を引き起こしてしまう。

改善策としては、`docker compose`コマンドで`libraries_host`ディレクトリとしてマウントしておき、起動用スクリプトで`libraries`に上書きする挙動が良さそう。

### 最後のほう
```dockerfile
# RUN rm -f /minecraft/forge-1.12.2-14.23.5.2860-installer.jar /minecraft/forge-1.12.2-14.23.5.2860-installer.jar.log

EXPOSE 25565/tcp
STOPSIGNAL SIGTERM

ENTRYPOINT ["./run.sh", "-XX:+AlwaysPreTouch", "-XX:+DisableExplicitGC", "-XX:+ParallelRefProcEnabled", "-XX:+PerfDisableSharedMem", "-XX:+UnlockExperimentalVMOptions", "-XX:+UseShenandoahGC", "-XX:+UseTransparentHugePages", "-XX:+UseNUMA", "-XX:InitiatingHeapOccupancyPercent=20", "-XX:MaxGCPauseMillis=50", "-XX:MaxTenuringThreshold=1", "-XX:SurvivorRatio=32", "-Xmx48G", "-Xms48G", "-jar", "-server", "-Dlog4j.configurationFile=log4j2_112-116.xml", "forge-1.12.2-14.23.5.2860.jar", "--nogui", "--universe", "/minecraft/."]
CMD ["--world", "world"]
```
コメントアウトしてるのはForgeインストーラとそのログを削除する行。最初は入れてたけどデバッグしてるうちにコメントアウトしていた。容量削減を目指すなら含めるべき。

`EXPOSE`命令でMinecraftサーバのデフォルトポートを開放する設定を入れている。ポート変更はDocker側で設定できるのでコンテナイメージであえて変更する必要はない。
`SOTPSIGNAL`命令でCtrl-Cを押したときにサーバを正常終了させれる方向に誘導する。

`ENTRYPOINT`命令で先に記載したスクリプトを指定しつつ、JVM引数とMinecraftサーバの引数を入れる。
ここで[先ほど展開したXMLファイル](#Forgeサーバをインストール)の指定やガベージコレクタの指定、メモリ使用量の指定を行う。詳細は割愛。

`CMD`命令に`--world`引数を指定しているのは、ワールド名を変えられる余地を残すため。このあたりはお好み。

:::note alert
JVM引数をテキストファイルにまとめて`@user_jvm_args.txt`みたいな感じで指定するのはJava8ではできません！（1敗）
そのせいで`ENTRYPOINT`命令がとんでもない長さになってる！
:::


### Dockerfile
最終的に出来上がったものはこちらになります。
```dockerfile
FROM ubuntu:noble

RUN apt-get update \
    && apt-get install -y curl \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

COPY ./rh-openjdk /jre

RUN groupadd -g 500 -o minecraft
RUN useradd -u 500 -g 500 -M -o -r -s /sbin/nologin minecraft
RUN mkdir -p /minecraft/logs
RUN mkdir -p /minecraft/mods
RUN mkdir -p /minecraft/world && chown -R minecraft:minecraft /minecraft
USER minecraft:minecraft

WORKDIR /minecraft
RUN curl -fOsSL https://maven.minecraftforge.net/net/minecraftforge/forge/1.12.2-14.23.5.2860/forge-1.12.2-14.23.5.2860-installer.jar \
    && /jre/bin/java -jar forge-1.12.2-14.23.5.2860-installer.jar --installServer
RUN curl -fSsSL https://launcher.mojang.com/v1/objects/02937d122c86ce73319ef9975b58896fc1b491d1/log4j2_112-116.xml
RUN echo "eula=true" > ./eula.txt
RUN cp -r libraries libraries_orig
RUN echo '#!/bin/bash' > run.sh \
    && echo 'set -x' >> run.sh \
    && echo 'if [[ ! -e ./libraries/org ]]; then' >> run.sh \
    && echo '  cp -rf libraries_orig/* libraries/.' >> run.sh \
    && echo 'fi' >> run.sh \
    && echo '/jre/bin/java "$@"' >> run.sh
RUN chmod o+x run.sh

# RUN rm -f /minecraft/forge-1.12.2-14.23.5.2860-installer.jar /minecraft/forge-1.12.2-14.23.5.2860-installer.jar.log

EXPOSE 25565/tcp
STOPSIGNAL SIGTERM

ENTRYPOINT ["./run.sh", "-XX:+AlwaysPreTouch", "-XX:+DisableExplicitGC", "-XX:+ParallelRefProcEnabled", "-XX:+PerfDisableSharedMem", "-XX:+UnlockExperimentalVMOptions", "-XX:+UseShenandoahGC", "-XX:+UseTransparentHugePages", "-XX:+UseNUMA", "-XX:InitiatingHeapOccupancyPercent=20", "-XX:MaxGCPauseMillis=50", "-XX:MaxTenuringThreshold=1", "-XX:SurvivorRatio=32", "-Xmx48G", "-Xms48G", "-jar", "-server", "-Dlog4j.configurationFile=log4j2_112-116.xml", "forge-1.12.2-14.23.5.2860.jar", "--nogui", "--universe", "/minecraft/."]
CMD ["--world", "world"]
```


## compose.yamlで作るNomi CEuサーバ
これでDockerイメージができるので、サーバを自動構成するためのcomposeファイルを作る。
ここでも試行錯誤した内容とともに振り返る。

### ディレクトリツリー
ディレクトリ構成は最終的にこんな感じ。
「(空) or (多数)」になっているフォルダがあるのは、初回起動時は空にしておき本番起動時にデータを格納する運用のため。
```text
/srv/minecraft/
├── compose.yaml
├── forge-12.2-14.23.5.2860
│   ├── Dockerfile
│   └── rh-openjdk
│       └── (略)
└── main
    ├── banned-ips.json
    ├── banned-players.json
    ├── backups
    │   └── (空) or (多数)
    ├── config
    │   └── (空) or (多数)
    ├── crash-reports
    │   └── (空) or (多数)
    ├── groovy
    │   └── (空) or (多数)
    ├── libraries
    │   └── (空) or (多数)
    ├── local
    │   └── (空) or (多数)
    ├── logs
    │   └── (空) or (多数)
    ├── mods
    │   └── (空) or (多数)
    ├── ops.json
    ├── resources
    │   └── (空) or (多数)
    ├── scripts
    │   └── (空) or (多数)
    ├── server-icon.png
    ├── server.properties
    ├── usercache.json
    └── whitelist.json
```


### `services`定義
動かすコンテナを列挙し、それぞれの構成を記載していく。今回は1個のコンテナ`main`について記載する。

#### コンテナイメージのビルドとコンテナの基本
```yaml
services:
  main:
    build: ./forge-12.2-14.23.5.2860
    image: mc-forge:12.2-14.23.5.2860
    container_name: mc-forge-main
    hostname: mc-forge-main
    tty: true
    stdin_open: true
    user: 500:500
    # (略)
```

`build`ではコンテナイメージのビルド設定を記載する。この書き方だとビルドする`Dockerfile`が存在するディレクトリを指定している。
先に作ったDockerfileとOpenJDKが入ったディレクトリを相対パスで指定する。

`image`ではコンテナ起動に用いるイメージ名を指定する。`build`でイメージタグを指定していなければ、ビルドされるコンテナイメージはこのイメージ名/タグが設定される。

`container_name`と`hostname`では個別のコンテナ名を指定する。
`docker container`コマンドで指定する名前やDocker内でアクセスするときのホスト名に利用される。

`tty: true`、`stdin_open: true`は`docker run`コマンドにおける`-it`に相当する。
今回Minecraftサーバをコンソールから操作できるようにあらかじめTTYと標準入力を有効化しておく。

`user`はコンテナの実行UID/GIDを指定する。
すでにDockerfileに指定しているが、こちらでも念のために記載する。

#### ホストファイルの連携
```yaml
services:
  main:
    # (略)
    volumes:
      - type: bind
        source: ./main/backups
        target: /minecraft/backups
      - type: volume
        source: main_world
        target: /minecraft/world
      # (略)
```
ミソPoint。マウント方法によって挙動が異なるので、したいことと記述をしっかりと一致させる。「プログラムは書いた通りにしか動かない」と認識する。（連敗）

:::note info
今回長い書き方(Long Syntax)をしている。1行で短く記載(Short Syntax)するとマウント方式を混同する要因になるので、面倒でも複数行に明示的に記載していくのが良さそう。
:::

##### Bindマウント
ホストのフォルダを指定してマウントし、そこにデータを格納する。
コンテナ内で利用している状態そのままにホスト側に反映されるので管理に注意が必要となる。

:::note warn
パーミッションや所有権も同様にコンテナ内の状態がホスト側に染み出す。
例えば、コンテナ内の`UID:GID`は`500:500`で動作する設定のため、コンテナを動かす際はホスト側で必ず`chown -R 500:500 /srv/minecraft/main/*`を実行する必要がある。
:::


##### Volumeマウント
Docker側で確保する領域をマウントし、そこにデータを格納する。
最後のほうに記載する`volumes`で領域設定を入れるので、そこに指定した名前を書く。

##### `configs`は使えないのか？
`configs`ではコンテナで利用する構成ファイルを定義しDockerに管理を任せるもの。
例えば、`server.properties`ファイルを`configs`に記載するとするとこのようになる。
```yaml
services:
  main:
    configs:
      - source: server-properties
        target: /minecraft/server.properties
        uid: "500"
        gid: "500"
        mode: 0644
# (略)
configs:
  server-properties:
    file: ./files/create/server.propertie
```
この記載でポイントとなるのは、「UIDやGID、パーミッションの指定」と「そもそも`configs`の動作で良いのか」となる。

###### UIDやGID、パーミッションの指定
上記のように指定して起動すると起動時に以下のメッセージが表示される。
実際、これで動かそうとするとファイルが`root:root`で作成されるので、パーミッションエラーが出てサーバ起動に失敗する。（連敗）
```text
WARN[0000] config `uid`, `gid` and `mode` are not supported, they will be ignored
```
一応公式ドキュメント上にはサポートされている記載があるが、実際には動いていないのが現状。

:::note warn
おかしいと思って古い環境（`Docker Compose version V2.19.1`）で確認したところ、ちゃんと動いていた。
どうやらバージョンアップに伴って何かしらあったようだ...。
:::

###### そもそも`configs`の動作で良いのか
`configs`だと指定したファイルや環境変数などをコンテナ内に書き込む動作をする。Bindマウントと違うのは「コンテナ内で上書きされた際に、ホスト側へ反映されない」という点にある。
今回だと、ホストからマウントするファイルはほぼすべてで実行時に上書きされる可能性があるため、`configs`の動作は適さないことになる。

:::note info
今回はサーバ側で動的に設定変更することを想定した。
`server.properties`や`ops.json`などの設定を固定化したい場合は、むしろ`configs`に設定すべきかもしれない。
:::

#### ポート開放
`Dockerfile`内の`EXPOSE`命令で指定したポートをホスト外にも公開する設定。今回はポートのみ指定することで`[::]:25565`と`0.0.0.0:25565`といった形でポート開放する。
```yaml
services:
  main:
    # (略)
    ports:
      - 25565:25565
```
### `volumes`定義
```yaml
volumes:
  main_world:
```
Minecraftのワールドデータ格納用のボリュームを定義する。ここで書いたボリューム名が先のVolumeマウントで指定されている。

:::note info
何も指定していないためDocker側で適当に領域が生成されているが、NFSなどを指定することも可能らしい。
:::


### `networks`定義
```yaml
networks:
  default:
    name: mc-servers
```
Docker内部の通信を構成する。(Velocity導入構想の名残)
設定しなくてもデフォルトで作成されるネットワークがある。
複数のネットワークを使いたい時やコンテナ同士で隔離したい時などに定義すると良い。


### compose.yaml
最終的に出来上がったものはこちらになります。
```yaml
services:
  main:
    build: ./forge-12.2-14.23.5.2860
    image: mc-forge:12.2-14.23.5.2860
    container_name: mc-forge-main
    hostname: mc-forge-main
    tty: true
    stdin_open: true
    user: 500:500
    volumes:
      - type: bind
        source: ./main/backups
        target: /minecraft/backups
      - type: volume
        source: main_world
        target: /minecraft/world
      - type: bind
        source: ./main/libraries
        target: /minecraft/libraries
      - type: bind
        source: ./main/logs
        target: /minecraft/logs
      - type: bind
        source: ./main/mods
        target: /minecraft/mods
      - type: bind
        source: ./main/config
        target: /minecraft/config
      - type: bind
        source: ./main/local
        target: /minecraft/local
      - type: bind
        source: ./main/groovy
        target: /minecraft/groovy
      - type: bind
        source: ./main/scripts
        target: /minecraft/scripts
      - type: bind
        source: ./main/resources
        target: /minecraft/resources
      - type: bind
        source: ./main/crash-reports
        target: /minecraft/crash-reports
      - type: bind
        source: ./main/banned-ips.json
        target: /minecraft/banned-ips.json
      - type: bind
        source: ./main/banned-players.json
        target: /minecraft/banned-players.json
      - type: bind
        source: ./main/ops.json
        target: /minecraft/ops.json
      - type: bind
        source: ./main/usercache.json
        target: /minecraft/usercache.json
      - type: bind
        source: ./main/whitelist.json
        target: /minecraft/whitelist.json
      - type: bind
        source: ./main/server-icon.png
        target: /minecraft/server-icon.png
      - type: bind
        source: ./main/server.properties
        target: /minecraft/server.properties
    ports:
      - 25565:25565
volumes:
  main_world:
networks:
  default:
    name: mc-servers
```



## Minecraftサーバの起動
とりあえず初回起動ということで、[この通り](#ディレクトリツリー)に空のフォルダ/ファイルを`./main`配下に作成してから起動する。

:::note warn
`docker compose`コマンドはカレントディレクトリのファイルを確認しに行く。
そのため、作成した`compose.yaml`が存在するディレクトリで実行する。
:::

```shell-session
thrust2799@gameserver:/srv/minecraft$ docker compose up
```

正常にワールドが生成されたら、一旦Ctrl+Cでサーバを閉じる。これでMinecraftサーバの起動に必要な最低限のものが揃うので、Nomi CEuの起動準備に入る。

詳細は省くが、`./main`配下に対してファイル/フォルダを配置する。配置後は[ここ](#bindマウント)で触れた通り所有権の変更だけ確実に行う。
また、初回起動によりワールドデータが作成されてしまっているので削除しておく。

```shell-session
thrust2799@gameserver:/srv/minecraft$ docker volume ls
DRIVER    VOLUME NAME
local     minecraft_main_world
thrust2799@gameserver:/srv/minecraft$ docker volume rm minecraft_main_world
minecraft_main_world
```

次回以降の起動は末尾に`-d`を付けてデーモン化した状態で起動する。
管理は`docker attach`コマンドや`docker logs -f`コマンドで対応する。

```shell-session
thrust2799@gameserver:/srv/minecraft$ docker compose up -d # 起動時
thrust2799@gameserver:/srv/minecraft$ docker compose down  # 終了時
```

## その他の設定
ここまででNomi CEuサーバはきちんと動作するが、どうせなら公開にあたってセキュリティ対策などを頑張っておきたい。
ということで、FWやウイルス対策ソフトなどを導入していく。

### UFW
Ubuntu 24.04 LTSにはUFWがデフォルトで備わっている。
OSインストール時の状態だと無効化されているので、デフォルトポリシーと最低限の許可設定を投入して有効化しておく。

今回だと下記通り。`<  server ip  >`と`<local client pc ip>`には適切なIPアドレスが表示される。
```shell-session
thrust2799@gameserver:~$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
<  server ip  > 22         ALLOW       <local client pc ip>
<  server ip  > 25565      ALLOW       Anywhere
```

### clamav
LinuxにはClamAV(Clam Anti-Virus)というウイルス対策ソフトがあるので、これを入れる。

#### インストールと初期設定
インストールはいつものパッケージ操作で。
```shell-session
thrust2799@gameserver:~$ sudo apt install clamav clamav-daemon
```

インストール後はスキャン設定をしておく。
デフォルトだと全ディレクトリを検査するので、スキャン除外設定を入れておく。ここではLinuxの場合で一般的に入れておくべきものや、UbuntuやDockerに特有の除外項目を設定しておく。
また、スキャン時に利用するCPUコア数がデフォルトだとすべてになるので、Minecraftサーバが重くならないように念のため減らしておく。（今回だと`12`から`8`に減らす。）

ここでオンアクセス（リアルタイム）スキャンを有効化できるが、今回は負荷を考えて有効にはしない。

追加設定した行は次の通り。
```
ExcludePath ^/proc/
ExcludePath ^/sys/
ExcludePath ^/dev/
ExcludePath ^/snap/
ExcludePath ^/run/
ExcludePath ^/var/lib/clamav/quarantine/
ExcludePath ^/var/lib/docker/overlay2/
ExcludePath ^/var/lib/docker/volumes/backingFsBlockDev/
ExcludePath ^/var/snap/lxd/common/lxd/unix.socket
ExcludePath ^/var/snap/lxd/common/lxd-user/unix.socket
MaxThreads 8
```
※`/var/lib/clamav/quarantine/`はウイルス検知後の隔離フォルダにするので、重複検知しないよう除外する。


#### 定期スキャンの設定
このままだとスキャンはできるけど自動的に回してくれないので、1日1回はスキャンするように設定する必要がある。
そこで、大体の人はスキャンスクリプトを作ってcronジョブとして登録するが、せっかくSystemdがあるのでこちらを活用してみる。

##### スキャンスクリプト
ログ出力先とウイルス隔離先を定義して、ルートディレクトリ（`/`）配下をスキャンするようスクリプトにしている。
最後の2行は、存在するログからウイルスを検知した数をサマリとして書き出している（後で使う）。

- `/usr/local/bin/clamdscan-onschedule.sh`
```shell
#!/bin/bash

SCAN_LOG_FILE=/var/log/clamav/clamdscan-`date +%Y%m%d`.log
QUARANTINE_DIR=/var/lib/clamav/quarantine/

/usr/bin/clamdscan -m --fdpass --log=$SCAN_LOG_FILE --move=$QUARANTINE_DIR /

SUM=$(grep Infected files: /var/log/clamav/clamdscan-*.log 2>/dev/null | awk '{s += $3} END {print s}')
echo `date +%Y/%m/%d`: $SUM infected files was found in this month. | tee /var/log/clamav/summary.txt
```

##### Systemd.ServiceとSystemd.Timer
Systemdでは`/etc/systemd/system/`配下にユニットファイルを作成すると管理対象として認識してくれる。
また、cronと同じようなものとしてSystemd.Timerという機能が存在している。`.service`ファイルと同じ名前の`.timer`ファイルを作成し設定を書き込むと有効なSystemd.Timerユニットとして認識される。

今回は1日1回スキャンスクリプトを回す（デーモンではない）ので、`.service`ファイルのTypeには`onechot`と指定しておく。
`.timer`ファイルではカレンダー形式で開始時間を指定できる。cronよりも直観的に実行管理ができるが、もちろん、cronのような設定方法も存在している。
今回は毎日AM03:00に1回だけ実行するように記載する。

`/etc/systemd/system/clamav-scan.service`
```systemd
[Unit]
Description = ClamAV OnSchedule Scan Service
RefuseManualStart = no
RefuseManualStop = yes
Requires = clamav-daemon.service
After = clamav-daemon.service

[Service]
Type = oneshot
ExecStart = /usr/local/bin/clamdscan-onschedule.sh
KillMode = process
KillSignal = SIGINT

[Install]
WantedBy = multi-user.target
```

`/etc/systemd/system/clamav-scan.timer`
```systemd
[Unit]
Description = ClamAV OnSchedule Scan Timer

[Timer]
OnCalendar = *-*-* 03:00:00
Persistent = true

[Install]
WantedBy = timers.target
```

ファイルの作成が終わったら、最後にSystemdに各ユニットを認識させて有効化する。
```shell-session
thrust2799@gameserver:~$ sudo systemctl daemon-reload
thrust2799@gameserver:~$ sudo systemctl enable --now clamav-scan.timer
```

:::note info
`systemctl enable --now`で有効化と同時に起動ができるので便利。
`systemctl enable`と`systemctl start`を個別に実行しても同じように動く。
:::
:::note warn
`systemctl enable`のみだと、次回OS起動時にスケジューリングされるだけで即時にスケジューリングしてくれない。ちゃんと`systemctl enable --now`とするか`systemctl start`を実行するようにしよう。（1敗）
:::

#### 検知したことを知りたい
でもメールとか通知を送る仕組みは面倒。じゃあログインしたときにサマリが通知できれば良いよね！
ということで、先のサマリと隔離フォルダの内容を表示するように`~/.bashrc`に追記する。

```shell-session
thrust2799@gameserver:~$ tail -2 .bashrc
cat /var/log/clamav/summary.txt
ls -oa /var/lib/clamav/quarantine
thrust2799@gameserver:~$ . .bashrc
2024/10/29: 0 infected files was found in this month.
total 8
drwxr-xr-x 2 clamav 4096 Oct 29 02:51 .
drwxr-xr-x 3 clamav 4096 Oct 29 02:36 ..
thrust2799@gameserver:~$
```
いい感じ！

#### ログのローテーション
スキャンスクリプトを見返すと、このようなログ出力になっている。


```shell
SCAN_LOG_FILE=/var/log/clamav/clamdscan-`date +%Y%m%d`.log
```
これは、`/var/log/clamav/`配下に`clamdscan-<YYYYMMDD>.log`というファイルが毎日追加されることを意味している。
これではlogrotatedでも対応が難しいが、スキャン毎にログファイルは分けておきたい...。でも放置してると無限にログファイルが溜まっていく...。

ということで、この際ログの削除もSystemd.Timerで実現する。
実現することは下記通り。設定方法はスキャンスクリプトの時と同じなので詳細は割愛。

- 毎月1日のAM00:10に前月分のログファイルを`.tgz`で固める
- 同じくオリジナルの前月分ログファイルを削除する
- 同じく13か月前のログファイルがあれば削除する


`/usr/local/games/clamdscan-logarchive.sh`
```shell
#!/bin/bash
set -x

tar -czvf ./clamdscan-`date --date '1 month ago' +%Y%m`.tgz ./clamdscan-`date --1 month ago +%Y%m`*
rm -f ./clamdscan-`date --date '1 month ago' +%Y%m`*.log
rm -f ./clamdscan-`date --date '13 month ago' +%Y%m`.tgz
```

`/etc/systemd/system/clamav-scan.service`
```systemd
[Unit]
Description = ClamAV Scan Log Archiver
RefuseManualStart = no
RefuseManualStop = yes

[Service]
Type = oneshot
WorkingDirectory=/var/log/clamav
ExecStart = /usr/local/games/clamdscan-logarchive.sh
KillMode = process
KillSignal = SIGINT

[Install]
WantedBy = multi-user.target
```

`/etc/systemd/system/clamav-scan.timer`
```systemd
[Unit]
Description = ClamAV Scan Log Archive Timer

[Timer]
OnCalendar = *-*-1 00:10:00
Persistent = true

[Install]
WantedBy = timers.target
```

## サーバ側はこれでほぼ完了なのでは？

あとはルータ側で公開設定をするだけ。
どうせNAPT設定になっているので、ポート開放のついでにポート番号をランダムにずらして少しだけセキュアにすればいいかな？

この辺りの詳細は割愛。個別設定になるので自分のルータやネットワーク、ISPに応じて設定すれば良し！

友人からもインフラ力をお褒めいただけたので、今回はここまで。

<br>
<br>

~~P.S.~~
~~見た感じ敗北し続けてるな？~~