Network Endpoint でもヘルスチェックに失敗したらインスタンスを自動で再起動させる

この記事は[Google Cloud Platform Advent Calendar 2020](https://qiita.com/advent-calendar/2020/gcp)の4日目の記事です。
4日目の担当の方がなかなか投稿しないので**雑に**埋めにきました。
元々はCloud SQLについての記事が投稿されそうでしたが、筆者はRDBMSに関心がないので好きなことを書くことにします。

Network Endpoint Group(NEG)、使っていますか？
筆者はこれができてからUnmanaged Instance Groupではなく、NEGを利用してVMインスタンスをBackendService経由で利用するようになりました。
Unmanaged Instance Groupでも同じことはできますが、それでもNEGを利用しているのは、NEGはVMインスタンスだけではなく他のServerlessなサービスでも利用できて応用が利くからです。

一方で、NEGを利用することでVMインスタンスをGoogle Cloud Load Balancing(GCLB)から利用できますが、Managed Instance Group(MIG)とは異なり、ヘルスチェックに失敗した際にインスタンスを自動で再起動することができません。
この記事では、NEGをもっとproduction ready(?)に使っていくための工夫を紹介します。

`Uptime checks`, `Notification channels`, `PubSub`, `Cloud Functions` 辺りの単語でやることがわかった方は読む必要がありません。

## 全てのイベントはPubSubに通ずべし
基本的な考え方として、イベントをPubSubにPublishできたら何をやるにしてもほぼ勝利が確定します。3割くらい嘘かもしれません。
ここでは、Cloud MonitoringのUptime checksを利用してインスタンスを監視し、ヘルスチェックに失敗したらイベントをPubSubに通知し、Cloud Functionsで拾って該当のインスタンスをRESETすることを目指します。

## 下準備
### VMインスタンスにアプリケーションを用意する
Container Optimized OS(COS)に単一のコンテナをデプロイするだけでは、何も工夫しなくても勝手に再起動してくれやがるので、ここではWordpressとMySQLの2つのコンテナをdocker composeで起動することにします。
使用するcomposeの定義は以下のもので試します。
簡便のため、データの永続化は無視します。
めんどくさい人は適当なOSにWEBサーバでもインストールしてsystemctl enableでもしてください。

```wordpress.yaml
version: "3"

services:
  wordpress:
    hostname: wordpress
    image: wordpress:5-php7.2-apache
    ports:
      - 80:80
    environment:
      WORDPRESS_DB_HOST: mysql
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: password
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_TABLE_PREFIX: wp_
  mysql:
    hostname: mysql
    image: mysql:5
    environment:
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: password
      MYSQL_RANDOM_ROOT_PASSWORD: "yes"
```

このファイルを、COSのホストOSでwritableな領域に保存します。
ここでは `/var/wordpress.yaml` に保存します。

続いて、COSで`docker compose`を利用するために、live restoreを無効にしてdockerを再起動します。

```bash
$ sudo sed -i 's/true/false/g' /etc/docker/daemon.json

$ sudo systemctl restart docker 
```

あとはいつも通りです。

```bash
$ docker swarm init
...

$ docker stack deploy -c /var/wordpress.yaml wordpress
Creating network wordpress_default
Creating service wordpress_wordpress
Creating service wordpress_mysql

$ docker stack services wordpress
ID                  NAME                  MODE                REPLICAS            IMAGE                       PORTS
h4tnwbuvamru        wordpress_mysql       replicated          1/1                 mysql:5
ugg0mpp6sqs5        wordpress_wordpress   replicated          1/1                 wordpress:5-php7.2-apache   *:80->80/tcp

$ curl -I localhost/readme.html
HTTP/1.1 200 OK
Date: Wed, 09 Dec 2020 14:26:06 GMT
Server: Apache/2.4.38 (Debian)
Last-Modified: Fri, 26 Jun 2020 13:58:02 GMT
ETag: "1c6e-5a8fd18ee0e80"
Accept-Ranges: bytes
Content-Length: 7278
Vary: Accept-Encoding
Content-Type: text/html
```

ところで、 `/etc/docker/daemon.json` はインスタンスが再起動するたびに元の状態に戻ってしまうので、インスタンスのスタートアップスクリプトで書き換えるようにしてしまいましょう。
具体的にはインスタンスのメタデータ `startup-script`に以下のように書き込んでください。

```bash
	
sed -i 's/true/false/g' /etc/docker/daemon.json &&\
docker stack deploy -c /var/wordpress.yaml wordpress
```

これでインスタンスの準備完了です。

### Network Endpoint Groupを作成してインスタンスを紐付ける
ここからはコマンドラインでいきます。
ネットワークなどは適宜置き換えてください。
zoneはインスタンスのzoneと同じものを指定してください。ここでは`asia-northeast1-b`とします。

NEGを作成する

```bash
$ gcloud compute network-endpoint-groups create wordpress \
  --zone=asia-northeast1-b \
  --subnet=default \
  --network-endpoint-type=GCE_VM_IP_PORT \
  --default-port=80
```
Network Endpointを作成する

```bash
$ gcloud compute network-endpoint-groups update wordpress \
  --zone=asia-northeast1-b \
  --add-endpoint=instance=<インスタンス名>,ip=<インスタンス内部IP>,port=80
```

## GCLBを起動する
コマンドラインでやるとすごく長いのでやめます。
ポイントは以下の点です。
* Zonal Network Endpoint Group `wordpress`をBackend Serviceとして利用する
* ヘルスチェックのプロトコルをHTTP, Pathを`/readme.html` にする。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121808/e8d5d399-7628-3397-4e9f-cd7d119d7b21.png)

ヘルスチェックに成功し、GCLBのIPアドレスでWordpressの初期設定画面が出ることを確認してください。

## PubSub Topic, Uptime checks, Notification channelを作成する
インスタンスのヘルスチェックを行うための`Uptime checks`
Uptime checksに失敗したときに通知を飛ばす`Notification channel`
通知先となる`PubSub Topic`を作成していきます。

### PubSub Topic

```bash
$ gcloud pubsub topics create notification
```
### Notification Channel
上で作成したtopicをフルネームで指定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121808/1c3af7d5-edea-0a4f-606b-79c55b291972.png)

### Uptime Checks
インスタンスに対してHTTPリクエストでチェックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/121808/e60db487-09f7-71e9-8f51-32741f516463.png)

## 必要なIAMを設定する

MonitoringのService AccountがPubSub Topic `notification` へメッセージをPublishするために、役割を設定します。
[ドキュメント](https://cloud.google.com/monitoring/support/notification-options?authuser=1&_ga=2.194053608.-438569764.1607150220#pubsub)によると

>最初の Pub/Sub チャネルを作成すると、Cloud Monitoring は、チャネルが作成されたプロジェクトの Monitoring Notification Service Agent 用のサービス アカウントを作成します。

とあるのですが、一向に出来上がらないです。
しかし、出来上がるのを見たことはあるので、この辺がまだβなのかもしれません。
production readyとは一体...ウゴゴゴ。

## Cloud Functionsで自動再起動する

かなりやっつけですがnodejsでコードを書いてきました。
PubSubのメッセージから該当インスタンスのzoneなどを取得してresetをします。
resetされたインスタンスは、`startup-script`によって必要なコンテナを起動します。

```index.js
const Compute = require('@google-cloud/compute');
const compute = new Compute();

exports.resetInstance = async (event, context, callback) => {
  const data = JSON.parse(Buffer.from(event.data, 'base64').toString());
  const vm   = await getVm(data);

  try {
    await vm.reset();
  } catch (err) {
    console.error(err);
    callback(err);
  }
}

async function getVm(data) {
  const zone       = compute.zone(data.incident.resource.labels.zone);
  const vmname     = data.incident.resource_display_name;
  const vminstance = zone.vm(vmname);

  return vminstance;
}
```

```package.json
{
  "name": "instance-auto-restarter",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "@google-cloud/compute": "^2.4.0"
  }
}
```

あとはこいつをデプロイして、コンテナを止めてUptime checksに失敗させたかったのですが、
自動で作成されるはずのサービスアカウントが作成されず、動作を試せません。

サービスアカウントが作成され次第、**覚えていたら**追記したいと思います。