GCE上のVMインスタンスを起動・停止できるDiscord botを書いた

これのちょっとしたオマケというか続き:

pione30.hatenablog.com

インスタンスを起動しっぱなしだと月に5000円くらいかかってしまいそうなので、このマイクラサーバで遊ぶ人達には遊ぶ時だけ起動して終わったら停止してもらうようにお願いしていて、運用としては最初の頃はインスタンスの起動と停止ができる権限だけをその人達に付与してGCPのWebコンソール画面上から起動・停止ボタンをポチポチ押してもらっていたのだけど、基本的に皆がコミュニケーションをとる起点はDiscordなのでDiscordから離れることなくコマンドを打つだけでインスタンスの起動・停止ができたほうが便利だと思い簡単なbotを書いた:

github.com

今はマイクラサーバ用にしか使っていないけど一応誰でも自分の権限があるプロジェクトに導入できるように汎用的に書いている。

Googleが公式に推奨しているGCE用のクライアントライブラリはNode.js実装しか存在しなかった 1 のと、discord.jsというDiscord APIのNode.jsクライアントライブラリがあったのでこれらを使ってNode.jsで書くことにした。

github.com

github.com

discord.jsのほうは型定義が充実していて体験は良かったけど @google-cloud/compute のほうは型定義が存在しなかったので、実際にAPIを叩いてみて戻り値を見ながら「あ〜インスタンスの外部IPアドレスはこれね……」みたいに確認していく作業があったりして非常につらかった。

認証方法はsecret tokenを環境変数で渡す形式かなと思っていた(discord.jsのほうはそうだった)けど @google-cloud/compute のREADMEを読むと、サービスアカウントというアカウントを作成して適切な権限を付与してから、そのサービスアカウントの認証情報を検出させる、という方式であることがわかった:

cloud.google.com

botはDockerコンテナ化しても良かったけど今はマイクラサーバ用にしか使ってないしCloud Storageの容量を消費するのが嫌だったので無料枠のf1-microインスタンスを立ててSSHログインしてリポジトリをgit cloneしてから直実行するという令和とは思えない運用で動かすことにした。また困ったらもうちょいクラウドネイティブな感じに頑張るってことで良いんじゃないでしょうか。

Minecraft サーバを GCE 上に建てるコマンド

僕は Minecraft を遊んだことはないが最近知人からマイクラサーバを建ててほしいと頼まれたのでドキュメントを諸々読みながら GCE 上に建ててみた。

コマンド自体は Minecraft に限らず応用が効くと思うので将来のためにメモっておく。

最低限

CONTAINER_IMAGE="itzg/minecraft-server:java8"
INSTANCE_NAME="minecraft"
MACHINE_TYPE="e2-highcpu-2"
ZONE="asia-northeast1-a"
MEMORY="1500M"
DISK_AUTO_DELETE="no"

CONTAINER_ENV="\
EULA=TRUE,\
TZ=Asia/Tokyo,
MEMORY=$MEMORY\
"

gcloud compute instances create-with-container $INSTANCE_NAME \
  --container-image $CONTAINER_IMAGE \
  --machine-type $MACHINE_TYPE \
  --zone $ZONE \
  --container-env $CONTAINER_ENV \
  --create-disk name=$INSTANCE_NAME-data,device-name=$INSTANCE_NAME-data,size=10GB,auto-delete=$DISK_AUTO_DELETE \
  --container-mount-disk mount-path="/data",name=$INSTANCE_NAME-data \
  --tags minecraft-server

gcloud compute firewall-rules create allow-minecraft \
  --allow tcp:25565 --target-tags minecraft-server

MACHINE_TYPEZONEMEMORY あたりは要件に応じて変えてください)

コマンドの意味は gcloud CLI リファレンス 1 を見れば全部書いてあるけれど順に解説していく。

create-with-container

まずサーバを建てるときに Docker イメージが公開されていたら嬉しいなと思って検索したら既に公開してくれている先人がいたので感謝しかないですね:

github.com

Tag に latest を指定するとこの記事を書いている時点では Java 11 でサーバが起動するようだが個人的に latest よりもバージョンは明示的に指定したいと思っているのと Java 15 は LTS ではないようなので java8 にした (筆者は Minecraft にも Java にも詳しくないという事情もある)。

GCE では「Docker コンテナをデプロイして起動するように仮想マシンVMインスタンスまたはインスタンス テンプレートを構成でき」る 2 ので、これを使いましょう。 そのコマンドが

gcloud compute instances create-with-container INSTANCE_NAME \
  --container-image CONTAINER_IMAGE

です。コマンドのリファレンスはこちら:

https://cloud.google.com/sdk/gcloud/reference/compute/instances/create-with-container

ちなみに各 VM インスタンスにデプロイできるコンテナは 1 つのみという制限 3 があり、 複数コンテナをデプロイしたい場合は Kubernetes (GKE) を使ってくださいということらしい。

さてこれでサーバは起動するのですが Minecraft はゲームなのでゲームデータという状態を持ち、その状態はコンテナ外のどこかに永続化したい。

上記の minecraft-server の README を読んでみるとホスト側のデータ保存用ディレクトリをコンテナの /data ディレクトリにマウントすれば良いと書いてあり、 gcloud compute instances create-with-container コマンドでは --create-disk オプションを指定するとインスタンスの作成時と同時に永続ディスクを作成してインスタンスにアタッチすることができ、さらに --container-mount-disk オプションを一緒に指定することでその作成したディスクをコンテナの指定した path にマウントできる、のでそいつを使おうという寸法です。

ここで注意すべき点としては --container-mount-disk でマウントするディスクはそのディスク名と device-name(そのディスクの、OS が認識する名前(?)。詳しいことはよくわからない……)が同じでなければならない、ということです (これらが異なるとマウントに失敗します)。 なので --create-disk 時に明示的に namedevice-name に同じに名前を指定しています。

また auto-delete はこのインスタンスが削除されたときに自動でこの永続ディスクも一緒に削除するかどうかを指定するフラグです。 これを no に指定することで、誤ってインスタンスを削除してしまい今まで作ったワールドデータが全部吹っ飛んでしまう、というような事故を防ぎます。 デフォルト値は yes なので、検証用に別のサーバを一時的に建てるというような場合は auto-delete を指定する必要はないかもしれません。

--tags は後述の firewall-rules で説明します。

firewall-rules

無事マイクラサーバが建ってもクライアント側から接続できないと意味が無いので、マイクラ用のポートだけファイアウォールに穴を開けたいと思います。

さてガイドが存在して:

cloud.google.com

コンテナポートには、ホスト VM ポートへの 1 対 1 のマッピングがあります。たとえば、コンテナポート 80 はホスト VM ポート 80 にマップされます。Compute Engine ではポートの公開(-p)フラグをサポートしていないため、このフラグを指定しなくても、マッピングは機能します。

コンテナのポートを公開するには、ホスト VM インスタンスのポートへのアクセスを許可するようにファイアウォール ルールを構成します。コンテナの対応するポートには、ファイアウォールのルールに従って自動的にアクセスできるようになります。

具体的には create-with-container 時に --tags オプションでインスタンスにタグを付け、firewall-rules createファイアウォールルールを作成し、そのルールを適用したい対象インスタンスのタグを --target-tags オプションで指定すれば良いというわけですね。

https://cloud.google.com/sdk/gcloud/reference/compute/firewall-rules/create#--target-tags

Minecraft はデフォルトで 25565 番ポートを使用するようなのでそのポートのトラフィックを許可することにします。

これで無事マイクラサーバが順当に建ち、クライアントから接続して遊べるようになるでしょう。

--container-env についてもう少し

Minecraft には op 権限を持つユーザという概念があるらしく、上述の minecraft-server Docker image では OPS 環境変数Minecraft ユーザ名をカンマ区切りで指定することで op 権限を付与することができる 4 ので、 必要に応じて CONTAINER_ENV に追加されたし。

また Minecraft 界隈ではサードパーティによる独自拡張である "mod" が多数公開されていて、複数の mods をクライアント・サーバに配置して遊ぶことがよく行われている(らしい)。 上述の minecraft-server Docker image では MODS 環境変数に jar ファイルの URL をカンマ区切りで指定するとコンテナ起動時に自動でその jar ファイルをダウンロードしてくれる 5 (便利ですね)。

ここで読者諸賢は --container-env=[KEY=VALUE, …,…] という構文中で VALUE としてナイーブにカンマを使うと KEY=VALUE の組を分ける意味のカンマと衝突し構文エラーになることに気付くであろう。 実際 create-with-container しようとすると以下のようにエラーが表示される:

ERROR: (gcloud.compute.instances.create-with-container) argument --container-env: Bad syntax for dict arg: [...]. Please see `gcloud topic flags-file` or `gcloud topic escaping` for information on providing list or dictionary flag values with special characters.

エラーメッセージが親切にも案内してくれているように gcloud topic escaping を読んでみましょう:

https://cloud.google.com/sdk/gcloud/reference/topic/escaping

……なるほど!!!つまり --container-env=^DELIM^ と書くことで各 KEY=VALUE 同士の delemeter をカンマではなく DELIM に変更することができるので、その上で VALUE にカンマを含めることができるというわけですね。 DELIM: とか - のような一文字だけでなく :: とか -- のような複数文字も許されています。MODS に URL を指定することを考えると :: あたりが良い選択肢ではないでしょうか。

具体例として今回僕は Buildcraft, Storage Drawers, Packing Tape という mod を入れることにしたので、 CONTAINER_ENV は以下のようになりました:

CONTAINER_ENV="\
^::^\
EULA=TRUE::\
TZ=Asia/Tokyo::\
MEMORY=$MEMORY::\
TYPE=FORGE::\
MODS=\
https://edge.forgecdn.net/files/2502/739/buildcraft-7.1.23.jar,\
https://github.com/jaquadro/StorageDrawers/releases/download/sd-1.10.8/StorageDrawers-1.7.10-1.10.8.jar,\
https://github.com/gigaherz/PackingTape/releases/download/v0.3.5/Packing.Tape-0.3.5.jar::\
VERSION=1.7.10::\
OPS=pione30,foo_user,bar_user\
"

おわりに

具体例として Minecraft の話をしましたが Docker Hub に image が公開されていて環境変数の指定だけで設定を変えてスッと起動できる場合であればコマンド数本で手軽に GCE にインスタンスを建てられるんだな〜ということがわかったので良い収穫でした。

やはり Docker image を起点としてなるべく状態を持たないように起動できると検証用にサーバを建てたり壊したりするのが簡単かつ気軽にできて良いですね。

GitHub Actions の if, 否定演算子, そして YAML

GitHub Actions のワークフロー構文に jobs.<job_id>.if 1jobs.<job_id>.steps[*].if 2 というものがあり 、その job や step の実行を if に書かれた条件が満たされた場合だけに限定することができます。

また GitHub Actions のワークフロー構文として "expression syntax" (式構文)3 というものが存在します。 これは ${{ <expression> }} と書くと <expression> 部分が文字列ではなく式として評価されるようになる、という特殊な構文です。

そしてドキュメントの該当部分 4 を読むと面白いことが書いてあります:

When you use expressions in an if conditional, you may omit the expression syntax (${{ }}) because GitHub automatically evaluates the if conditional as an expression.

(日本語訳) 5

if 条件の中で式を使用する際には、式構文 (${{ }})を省略できます。これは、GitHubif 条件を式として自動的に評価するためです。

興味深いですね。つまり例えばこういう YAML が書けるわけです:

name: CI

on:
  pull_request:
    types:
      - opened
      - reopened
      - synchronize

jobs:
  if-omit-expression-syntax-test:
    runs-on: ubuntu-20.04
    steps:
      - name: Explicit expression syntax
        if: ${{ github.actor == 'pione30' }}
        run: echo "Explicitly github.actor is pione30"
      - name: Expression syntax omitted
        if: github.actor == 'pione30'
        run: echo "github.actor is pione30 even though the expression syntax omitted"

上記の steps はどちらも syntax error など起こさず実行されます:

https://github.com/pione30/github-actions-sandbox/runs/1800678250

ところで GitHub Actions の式の中において利用できる演算子があり、否定演算子 ! もあります 6

そこで例えばこんな steps を追加するとどうでしょうか:

name: CI

on:
  pull_request:
    types:
      - opened
      - reopened
      - synchronize

jobs:
  if-omit-expression-syntax-test:
    runs-on: ubuntu-20.04
    steps:
      - name: Explicit expression syntax
        if: ${{ github.actor == 'pione30' }}
        run: echo "Explicitly github.actor is pione30"
      - name: Expression syntax omitted
        if: github.actor == 'pione30'
        run: echo "github.actor is pione30 even though the expression syntax omitted"
      # 以下の steps を追加
      - name: Not operator on the head in the explicit expression syntax
        if: ${{ !startsWith(github.head_ref, 'pione30/') }}
        run: echo "Explicitly github.head_ref does NOT startsWith 'pione30/'"
      - name: Not operator on the head without the expression syntax
        if: !startsWith(github.head_ref, 'pione30/')
        run: echo "github.head_ref does NOT startsWith 'pione30/'"

……残念ながらこのワークフローは実行されません。最後の step の if: !startsWith(github.head_ref, 'pione30/') の部分で "You have an error in your yaml syntax on line 24" と言われてしまいます:

https://github.com/pione30/github-actions-sandbox/actions/runs/525066684

どうしてでしょうか?わからないので YAML の仕様書を見に行きましょう。

まず Preview をざっと眺めると Tags 7 という節が目に留まります。 YAML のノード(スカラー、シーケンス、マッピングのいずれか 8 )は ! 記号を使って明示的に型を指定することができる、例えば !something とか !!str とかのように、と書かれています。

もう少し詳しく BNF による形式的定義 9, 10 を追っていくと、タグは

  • Verbatim Tag
  • Tag Shorthand
  • Non-Specific Tag

の 3 種類に大別されることがわかります。


Verbatim Tag

!< URI として利用可能な文字列 > の形式。 !<tag:yaml.org,2002:str> が例として挙げられています。

Tag Shorthand

Tag Shorthand は先頭が Tag Handle、その後 Tag Char の連続と記されています。 Tag Handle は !, !! のいずれか 11 、Tag Char は URI として利用可能な文字セットから !, ,, [, ], {, } を除いたもの 12 です。

(正確には Tag Handle にはさらに !英数字ハイフンの連続! という形式も含まれますが、Tag Shorthand として使うためには TAG ディレクティブで指定する必要があり、今回は説明を省きます。)

例としては !foo !local !!int !!str とかがあります。

Non-Specific Tag

ただの ! が単一でタグになります。そのノードは強制的に tag:yaml.org,2002:seq, tag:yaml.org,2002:map, tag:yaml.org,2002:str のいずれかの型として解釈されます。


というわけで、式構文 (${{ }})を省略した上で否定演算子のつもりで先頭に ! をつけると YAML の仕様によりラベルとして解釈されてしまうことがわかりました。 GitHub Actions のドキュメントには明記されていないのですが意外とハマりポイントではないでしょうか 13

上記の !startsWith(github.head_ref, 'pione30/') を解釈しようとすると、Tag Char には , が含まれないので , の前 !startsWith(github.head_ref までが Tag Shorthand としてパースされるが、その直後に(Collection の開始記号 [ or { およびエントリー無しに), が来てしまうので syntax error となったのですね 14YAML 初心者なので間違っていたら教えてください)。

そうすると、否定演算子としての意味は無くなってしまいますが

if: !<tag:yaml.org,2002:str> startsWith(github.head_ref, 'pione30/')

とか

if: !!str always()

とか書いても構文的には合法な気がしたので試してみたのですが、"The workflow is not valid. .github/workflows/ci.yml: Unexpected tag 'tag:yaml.org,2002:str'" と怒られてしまいました。僕にはもうよくわかりません。

おわりに

GitHub Actions、ロジックを記述するのになんで YAML の構文で書くことになってしまったのか???