【GCP】統合版MinecraftサーバーをGCEで建てる2【統合版Minecraft】

# 概要
前回GCPに構築した統合版Minecraftサーバーの課題対応。

とりあえず今回は、GCEに固定パブリックIPアドレス割り当て → エフェメラルIP化＆DNS経由で名前アクセス、について。


# 目的
+ なるべくコスト削減（Realmsよりは安くしたいが、多少はお勉強代、自分への投資と割り切る）
+ GCEに直接固定パブリックIPを振っているところを、DNS経由で名前アクセス出来るようにする。
+ Switch の統合版Minecraftで外部サーバーを追加する際に、名前での登録・接続が出来るか確認。


# 事前調査
+ **コスト比較**
下記公式サイトにて、ざっくりコスト計算を行った。
[Google Cloud Pricing Calculator](https://cloud.google.com/products/calculator/)

「土日に5時間ずつ遊ぶ（それ以外の時間はサーバー停止）」という条件での概算コストが下記。
![mcs_cost.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2608228/6d01aba7-969f-d171-9f35-aef99fa29d13.png)

固定パブリックIP費用がヤバいのである。
サーバーの費用を抑えるために停止している時間、逆に固定パブリックIP費用が跳ね上がるのである。
これはAWSなどの別クラウドでも同様の仕様となっており、世界的に有限リソースである固定パブリック（グローバル）IPを無駄遣いさせないためだと思われるので、致し方なしである。

問題は、上記課題を構築直後に把握していたのに、約2週間放置した筆者の危機管理能力の甘さである（自戒）

閑話休題。
せっかくサーバー費用が300円弱に抑えられているのに、固定パブリックIP代で800円もかかっていてはRealms料金よりも高くなってしまうので、なんとかしたい。

~~ということで、代案としての Cloud NAT を利用する場合どうなるか。~~
 → 要らなかった…(´・ω・｀)
実は当初、NAT＋DNSが必要だと考えていたのだが、よくよく考えたらサーバー発信のアウトバウンド通信がなさそうなので、NATは不要だった。
下記はNATを利用する場合のコスト計算（比較）の話なので今回は無関係となったのだが、せっかく書いたので参考情報として残しておくこととする。

<details><summary> Cloud NAT の料金計算（参考）</summary><div>

[ネットワーキングのすべての料金体系](https://cloud.google.com/vpc/network-pricing?hl=ja)
>Cloud NAT により使用される、または Cloud VPN トンネル用のパブリック IP として使用される、転送ルールにアタッチされた静的 IP アドレスとエフェメラル IP アドレス。	無料

なんと無料である。
ただ、これはあくまで「固定パブリックIP費用が無料」という話で、Cloud NAT 自体に別途料金が発生する。

[Cloud NAT の料金](https://cloud.google.com/nat/pricing?hl=ja)

上記は米国料金の例しかないため、SKUで東京リージョンの料金を確認。
[Google Cloud Platform SKUs](https://cloud.google.com/skus/?currency=JPY&filter=NAT+asia-northeast1)

NAT Gateway: Uptime charge in Tokyo　　　　　：　0.161441 JPY per hour
NAT Gateway: Data processing charge in Tokyo　：　5.189175 JPY per gibibyte

0.161441 * 720 = 116.23752
5.189175 * 10 = 51.89175

計 約170円といったところ。

※データ転送量は全く読めないので、テキトーである。
※古い情報だと、「通常VMとプリエンプティブVMで料金が異なる」ような情報も見受けられるのだが、現状公式ドキュメントを見る限りそのような記載が見つからなかったので、一律同料金という前提で計算している。

よく見たら GCP Calculator でも計算出来た……まあ概ね合ってそう。
これなら十分許容範囲なので、構築を進めることにする。
![nat_cost2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2608228/e6258cc8-a986-e302-543f-7d94c3bd2f03.png)
</div></details>

# 1. 構築
DNS構築と、DNSレコード自動更新の仕組みを構築する。

## 1.1. 事前準備

前回のGCEコードから、固定グローバルIPを削除（コメントアウト）
```service01\service01_gce.tf
/*
# Global IP 作成
resource "google_compute_address" "mcs-ip" {
  name         = "mcs"
  description  = "external IP for mcs"
  network_tier = "STANDARD"
  region       = var.gcp_common.region
  project      = join("-", [var.gcp_common.org_name, var.service01_pj, terraform.workspace])
}
# Global IP 作成 END
*/
```
　
エフェメラルIPとするため、GCEの network_interface ブロックの access_config ブロックを空にする。
```service01\service01_gce.tf
  network_interface {
    subnetwork = google_compute_subnetwork.service01-gce-subnets.id
    access_config {
      #nat_ip       = google_compute_address.mcs-ip.address
      #network_tier = "STANDARD"
    }
  }
```
　
GCEに割り当てられている外部IPアドレスが、「エフェメラル」となった。
ちなみに、「外部IPアドレス」を固定化した場合、新規取得したIPが割り当てられるが、固定→エフェメラルに変更した場合は、一旦はIPアドレスは維持されるようだ。
※「外部IPアドレス」とインスタンス名は、念のため削除している
![gip.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2608228/92028742-077a-41b0-1b23-c2ad13834ee3.png)

## 1.2. DNS構築
DNSの管理はHost Projectだろうということで、前回のコードに下記を追加。
```infrastructure\dns.tf
# DNS for hoge
resource "google_dns_managed_zone" "hoge_zone" {
  for_each = var.dns_zone[terraform.workspace]

  name        = each.key
  dns_name    = each.value.dns_name
  project     = join("-", [var.gcp_common.org_name, var.host_pj.service_name, terraform.workspace])
  description = each.value.description
  visibility  = each.value.visibility

}

resource "google_dns_record_set" "hoge_record" {
  for_each = var.dns_record[terraform.workspace]

  name         = "${each.value.name}.${google_dns_managed_zone.hoge_zone[each.key].dns_name}"
  managed_zone = google_dns_managed_zone.hoge_zone[each.key].name
  type         = each.value.type
  ttl          = each.value.ttl
  project      = join("-", [var.gcp_common.org_name, var.host_pj.service_name, terraform.workspace])

  rrdatas = each.value.rrdatas

  depends_on = [
    google_dns_managed_zone.hoge_zone,
  ]
}
```
　
変数は下記。
```infrastructure\vars_dns.tf
variable "dns_zone" {
  default = {
    dev = {
      hoge = {
        name        = "hoge"
        visibility  = "public"
        dns_name    = "hoge"
        description = "Public access for hoge"
      }
    }
    prd = {
    }
  }
}

variable "dns_record" {
  default = {
    dev = {
      hoge = {
        name    = "hoge"
        type    = "A"
        ttl     = 300
        rrdatas = ["127.0.0.1"]
      }
    }
    prd = {
    }
  }
}
```
本来、GCEの外部IPアドレスをAレコードとして登録するのだが、この時点では仮の「127.0.0.1」としている。
本サーバーは停止している時間の方が長いため、おそらく起動するたびに外部IPアドレス（エフェメラル）が変わるはずである。
なので、ここで書いても意味が無い、対応策は後述。


## 1.3. ドメインレジストラにネームサーバー登録
ドメイン取得した「お名前.com」にて、「1.2. DNS構築」で作成したゾーンのネームサーバーを登録する。
反映されるのに、24～72時間かかるとのこと…。
![domain_registrar.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2608228/7d54d84f-6a5a-e9fd-6d79-6477857daaee.png)

9時間後くらいに確認したら、ドメイン配下の DNS レコードを nslookup 出来るようになっていた。
ずっとチェックしていたわけではないので、詳細な時間は不明。

## 1.4. DNS レコードを自動更新
 GCE 起動時に DNS レコードを自動更新する仕組みを作る。

前回、Discord から gcloud コマンドを叩く仕組みを構築しているので、そこへ追加する。

<details><summary>service01\scripts\mineserver-op.py</summary><div>

```service01\scripts\mineserver-op.py
# インストールした discord.py を読み込む
import discord
import os
import time
import subprocess
from subprocess import PIPE


# 変数定義
TOKEN = 'hoge' # Discord Bot のトークン
SERVICE_ACCOUNT = 'minecraft@hoge.iam.gserviceaccount.com' # gclud コマンド用サービスアカウント
INSTANCE_NAME = 'hoge' # 統合版Minecraftサーバー名（GCP上のVM名）
PROJECT_NAME = 'hoge' # 統合版Minecraftサーバーが属している GCP Project
HOST_PROJECT_NAME = 'hoge' # 統合版Minecraftサーバーが属している GCP Project
ZONE_NAME = 'asia-northeast1-b' # 統合版Minecraftサーバーが属している GCP ゾーン
DNS_ZONE = 'hoge'
RECORD_NAME = 'hoge.'
RECORD_TTL = '300'
RECORD_TYPE = 'A'
MCS_PORT = '19132' # 統合版Minecraftサーバーポート
CH_ID = hoge # Bot が発言する Discord のチャンネルID
MY_BOT_NAME = 'hoge' # 上記チャンネルの Webhook URL から発言した際の Bot の名前
client = discord.Client()

# 起動時に動作する処理
@client.event
async def on_ready():
    ch = client.get_channel(CH_ID)
    await ch.send('Minecraft Administrator Bot 起動')

    # 起動したらターミナルにログイン通知が表示される
    print('ログインなう')
    print('/mcs help でコマンドの確認ができるよ')

    # debug
    #command = ['gcloud', f'--account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(Status)"']
    #instance_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(Status)"'], shell=True)
    #print(str(instance_status))

# メッセージ受信時に動作する処理
@client.event
async def on_message(message):
    # メッセージ送信者がBotだった場合は処理しない、ただし特定のBotのみ処理を継続する
    if message.author.bot:
        author = message.author
        if f'{author}' == f'{MY_BOT_NAME}':
            pass
        else:
            return

#サーバーの起動
    if message.content == '/mcs start':
        await message.channel.send('統合版Minecraftサーバーを起動開始')
        await message.channel.send('※「起動完了」が表示されるまで他のコマンドを実行させないこと※')
        await message.channel.send('統合版Minecraftサーバーを起動中……')
        subprocess.run([f'gcloud --account={SERVICE_ACCOUNT} compute instances start {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME}'], shell=True)

        instance_status = ""
        while 'RUNNING' in str(instance_status):
            time.sleep(5)
            instance_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(Status)"'], shell=True)
            print(str(instance_status))
        
        # MinecraftサーバーのGIP取得
        instance_gip = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(networkInterfaces.accessConfigs.natIP)"'], shell=True)
        instance_gip = str(instance_gip)[4:-5]
        print(instance_gip)

        # Debug
        #instance_gip = '127.0.0.1'
        #print(instance_gip)

        # DNS Record の状態取得
        dns_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} dns record-sets list --zone={DNS_ZONE} --name={RECORD_NAME} --project {HOST_PROJECT_NAME} --format="value(DATA)"'], shell=True)
        dns_status = str(dns_status)[2:-3]
        print(dns_status)

        # DNS Record 書き換え
        subprocess.run([f'gcloud beta dns record-sets transaction start --zone={DNS_ZONE} --account={SERVICE_ACCOUNT} --project={HOST_PROJECT_NAME}'], shell=True)
        subprocess.run([f'gcloud beta dns record-sets transaction remove {dns_status} --name={RECORD_NAME} --ttl={RECORD_TTL} --type={RECORD_TYPE} --zone={DNS_ZONE} --account={SERVICE_ACCOUNT} --project={HOST_PROJECT_NAME}'], shell=True)
        subprocess.run([f'gcloud beta dns record-sets transaction add {instance_gip} --name={RECORD_NAME} --ttl={RECORD_TTL} --type={RECORD_TYPE} --zone={DNS_ZONE} --account={SERVICE_ACCOUNT} --project={HOST_PROJECT_NAME}'], shell=True)
        subprocess.run([f'gcloud beta dns record-sets transaction execute --zone={DNS_ZONE} --account={SERVICE_ACCOUNT} --project={HOST_PROJECT_NAME}'], shell=True)

        # 確認
        dns_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} dns record-sets list --zone={DNS_ZONE} --name={RECORD_NAME} --project {HOST_PROJECT_NAME} --format="value(DATA)"'], shell=True)
        print(str(dns_status)[2:-3])

        await message.channel.send('……統合版Minecraftサーバーを起動完了')
#サーバーの停止
    if message.content == '/mcs stop':
        await message.channel.send('統合版Minecraftサーバーを停止開始')
        await message.channel.send('※「停止完了」が表示されるまで他のコマンドを実行させないこと※')
        await message.channel.send('統合版Minecraftサーバーを停止中……')
        subprocess.run([f'gcloud --account={SERVICE_ACCOUNT} compute instances stop {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME}'], shell=True)

        instance_status = ""
        while 'TERMINATED' in str(instance_status):
            time.sleep(5)
            instance_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(Status)"'], shell=True)
            print(str(instance_status))

        await message.channel.send('……統合版Minecraftサーバーを停止完了')

#ヘルプの表示
    if message.content == '/mcs help':
        await message.channel.send('/mcs start : 統合版Minecraftサーバの起動')
        await message.channel.send('/mcs stop : 統合版Minecraftサーバの停止')
        await message.channel.send('/mcs status : 統合版Minecraftサーバの状態確認')

#サーバーの状態確認
    if message.content == '/mcs status':
        await message.channel.send('統合版Minecraftサーバの状態確認中……')
        instance_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(Status)"'], shell=True)
        print(str(instance_status))

        if 'RUNNING' in str(instance_status):
            await message.channel.send('……統合版Minecraftサーバ稼働中')
        elif 'TERMINATED' in str(instance_status):
            await message.channel.send('……統合版Minecraftサーバ停止中')
        else:
            await message.channel.send('……統合版Minecraftサーバの状態不明')


client.run(TOKEN)
```
</div></details>

　
変更点としては、変数に下記を追加。
```service01\scripts\mineserver-op.py
HOST_PROJECT_NAME = 'hoge' # DNS を管理する GCP Project
DNS_ZONE = 'hoge'
RECORD_NAME = 'hoge'
RECORD_TTL = '300'
RECORD_TYPE = 'A'
```
　
BotがGCEを起動する際の処理に、下記を追加。
```service01\scripts\mineserver-op.py
        # MinecraftサーバーのGIP取得
        instance_gip = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} compute instances describe {INSTANCE_NAME} --project {PROJECT_NAME} --zone {ZONE_NAME} --format="value(networkInterfaces.accessConfigs.natIP)"'], shell=True)
        instance_gip = str(instance_gip)[4:-5]
        print(instance_gip)

        # Debug
        #instance_gip = '127.0.0.1'
        #print(instance_gip)

        # DNS Record の状態取得
        dns_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} dns record-sets list --zone={DNS_ZONE} --name={RECORD_NAME} --project {HOST_PROJECT_NAME} --format="value(DATA)"'], shell=True)
        dns_status = str(dns_status)[2:-3]
        print(dns_status)

        # DNS Record 書き換え
        subprocess.run([f'gcloud beta dns record-sets transaction start --zone={DNS_ZONE} --account={SERVICE_ACCOUNT} --project={HOST_PROJECT_NAME}'], shell=True)
        subprocess.run([f'gcloud beta dns record-sets transaction remove {dns_status} --name={RECORD_NAME} --ttl={RECORD_TTL} --type={RECORD_TYPE} --zone={DNS_ZONE} --account={SERVICE_ACCOUNT} --project={HOST_PROJECT_NAME}'], shell=True)
        subprocess.run([f'gcloud beta dns record-sets transaction add {instance_gip} --name={RECORD_NAME} --ttl={RECORD_TTL} --type={RECORD_TYPE} --zone={DNS_ZONE} --account={SERVICE_ACCOUNT} --project={HOST_PROJECT_NAME}'], shell=True)
        subprocess.run([f'gcloud beta dns record-sets transaction execute --zone={DNS_ZONE} --account={SERVICE_ACCOUNT} --project={HOST_PROJECT_NAME}'], shell=True)

        # 確認
        dns_status = subprocess.check_output([f'gcloud --account={SERVICE_ACCOUNT} dns record-sets list --zone={DNS_ZONE} --name={RECORD_NAME} --project {HOST_PROJECT_NAME} --format="value(DATA)"'], shell=True)
        print(str(dns_status)[2:-3])
```
1. MinecraftサーバーのGIP取得
2. DNS Record の状態取得
3. DNS Record 書き換え
3.1. レコードセットのトランザクションを開始。
3.2. 既存レコードから、旧IPのAレコードを削除する。
3.3. 既存レコードに、新IPのAレコードを追加する。
3.4. レコードセットのトランザクションを実行する。
4. 確認（Discordへの通知が必要なケースを考え念のため入れているがいらないかも）

Aレコードの Value のみを変更（更新）出来れば楽だったのだが、どうもそういったコマンド・APIが用意されていないようなので、面倒だが上記処理とした。
これで、DNS レコードが常に最新状態に保たれ、統合版Minecraftサーバーへ名前でのアクセスが可能となった。

## 1.5. 動作確認
想定通り、同じ名前に対する nslookup で、都度異なる IP が引けた。
<details><summary>powershell</summary><div>

```powershell
PS D:\hoge> nslookup hoge
Server:  UnKnown
Address:  hoge

Non-authoritative answer:
Name:    hoge
Address:  127.0.0.1

PS D:\hoge> nslookup hoge
Server:  UnKnown
Address:  hoge

Non-authoritative answer:
Name:    hoge
Address:  hoge.195

PS D:\hoge> nslookup hoge
Server:  UnKnown
Address:  hoge

Non-authoritative answer:
Name:    hoge
Address:  hoge.168
```
</div></details>

PC版の統合版Minecraftクライアント、Switch版の統合版Minecraftクライアント、双方からも問題なく名前で接続が出来た。
ただ、たまたまかもしれないが、Switch版が不安定というか、若干繋がりにくい＆ちょいちょい落ちるようになったような……気のせいかもだけど。
まあ数分程度の動作確認での中の話なので、今後実際にゲームを遊んでみて要検証とする。

## 1.6. まとめ
若干遠回りをしてしまったが（NAT関連）、結果的にNATも使わずかなり料金を削減することが出来たので良かった。
何年か前に仕事で構築したAWS環境で似たような仕組み（スクリプトでDNSレコード更新）をやったことがあったので、イメージも湧きやすく構築もそれほど手間取らず出来たのも良かった。

あとは、実際にゲームを遊んでいく中で、その他の課題対応を進めていければと思う。

ちょっと気になっているのは、GCEのスペック（e2-standard-4）もっと下げられるのでは？、というところ。
現状、CPU使用率が2％を下回ったら自動停止するようにしているが、1～2人ログインしている状態でもCPU使用率が数%～30%くらいで大して使われておらず、自動停止しまうこともあった。
まあ、動作確認でログインしているだけなので、実際に4人とかでガリガリ遊ぶようになればもっと上がるかもしれないが、そのあたりも要検証＆可能ならスペック下げて更にコスト削減を目指したい。

あと、構築して2週間くらい経つのに、まだまともに遊べていない＆誰も遊んでいないのでパパ悲しい……(´；ω；｀)