MastodonインスタンスをGKEクラスタにデプロイする

# 注意
* ここで紹介する手順は行き当たりばったりです。最短手順ではありません
* とりあえず使えるインスタンスが立てられますがこのまま本運用しない方がいいと思います

# この記事を書いた人の知識
* 現職Webエンジニア
* Docker Composeを使ったことはない
* Kubernetesを使ったことはない
 * 他のコンテナオーケストレーションツールを使ったことはある
* GKEも使ったことはない
 * GCP自体は使ったことがある
* RoRを使ったことはない

# 対象とする読者
* Mastodonのインスタンスを立ててみたいと思っている
* Docker，Docker Compose，Kubernetes，GKEがそれぞれなんなのか知っていて，必要ならば公式ドキュメントを読んで理解することができる

# まえおき
「なんかMastodonのインスタンス立てるの流行ってるっぽいな〜」
「みんなVPS上にいろいろインストールしてるけど公式でDockerイメージ用意してあるしKubernetes使ったら簡単にデプロイできんじゃね？」
と考えてKubernetes使ったことない人がやってみたら意外と手こずった話です

# 準備
以下GKEを使うので，[GKEのQuickstartドキュメント](https://cloud.google.com/kubernetes-engine/docs/quickstart)の _Creating GKE Cluster_ までを済ませてGKEクラスタを作成しておきます。クラスタの名前はなんでもいいのですが`mstdn`としておきます

# Mastodonのインスタンスを立ててみよう
## まずは公式ドキュメントを見に行く
とりあえずMastodonのリポジトリを見に行くと，Dockerを使ってデプロイする方法が書かれたドキュメントがあります
👉https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Docker-Guide.md
[Docker Compose](https://docs.docker.com/compose/)のComposeファイルが提供されているので，そのまま乗っかって[Kompose](http://kompose.io/)を使ってGKEクラスタにデプロイを試みてみることにします

## Composeファイルを修正する
既に用意されているDockerイメージを使いたいので，公式ドキュメントの _Getting the Mastodon image / Using a prebuilt image_ に従って作業していきます
まず [docker-compose.yml](https://github.com/tootsuite/mastodon/blob/master/docker-compose.yml)をダウンロードして，作業ディレクトリに保存します。ドキュメントに従って`build .`をコメントアウトします。Dockerイメージのバージョンはデフォルトで大丈夫でしょう。コンテナのビルドも必要無いので`docker-compose build`は実行しません。手許の`public`ディレクトリも使わないので作成する必要も権限を変える必要もありません

## Mastodonの設定ファイルを生成する
ドキュメントを見ると設定ファイルの生成のために
`$ docker-compose run --rm web bundle exec rake mastodon:setup`
を実行しろと書いてあるのですが，手許のコンテナ上で設定ファイルを生成してもこれがクラスタの上に載るわけではないので，生成された設定ファイルを手に入れて他の方法でなんとかすることにします。そこで，設定ファイルを生成するコマンドを
`$ docker-compose run web bundle exec rake mastodon:setup`
としてファイルの生成後にファイルシステムを残すことにし，`docker cp`コマンドを使ってファイルをコンテナ内からホストに取ってくることにします

というわけで
`$ docker-compose run web bundle exec rake mastodon:setup`
を実行するといろいろと聞かれます。
`Domain name:`
には適当なドメインを入れ，
`Do you want to enable single user mode?`
はたぶんYesでもNoでもどっちでもよさそう？とりあえずNoで
`Are you using Docker to run Mastodon?`
にはYes
PostgreSQLの設定はとりあえずデフォルトのまま
Redisの設定もデフォルトのまま
`Do you want to store uploaded files on the cloud?`
は設定めんどくさいのでNo
`Do you want to send e-mails from localhost?`
もNo
Eメールの設定はあとで書き換えるのでここもとりあえずデフォルトのまま
`Save configuration?`
にYesと答え，データベースのセットアップやらアセットのコンパイルやら管理者ユーザの作成やらは後回しにします。設定ファイルが生成されたら
`$ docker container ls`
でwebコンテナの名前を確かめて
`$ docker cp <container name>:/mastodon/.env.production ./`
として`.env.production`ファイルをコピーしてきます。ここで
`$ kompose up`
とするとGKEクラスタ上にアプリケーションがデプロイされます👏

## ヘッドレスサービスの罠
ここでGCPコンソールからGKEクラスタのワークロードを見に行くと，sidekiqデプロイのステータスが _Does not have minimum availability_ になっています。デプロイの詳細を開き，“Container logs”からログを見ると，`getaddrinfo: Name does not resolve`というメッセージが見えるので，何かのアドレスの解決に失敗しているようです。前後を見るとRedisのモジュールを呼び出しているようなので，Redisのアドレス解決に失敗している？
ここで結構詰まったのですが，実はKubernetes DNSはふつうのサービスについてはアドレス解決ができるようになっているのですが，ヘッドレスサービスについてはアドレス解決ができません（当たり前）。RedisとPostgreSQLはdocker-compose.ymlでportsが指定されていないので，Komposeがヘッドレスサービスと解釈してしまったためアドレス解決ができなかったというわけです

なのでdocker-compose.ymlの各サービスで`ports`を指定します
dbサービスは

```yaml
ports:
  - "127.0.0.1:5432:5432"
```

を，redisサービスは

```yaml
ports:
  - "127.0.0.1:6379:6379"
```
を指定して，`$ kompose down && kompose up`を実行してデプロイをやり直します。これでステータスがOKになりました👏
ここで早速Mastodonにアクセスしてみたいのですが，どうやら外部からアクセスできるIPアドレスが割り当てられていないようです。というわけで外部からアクセスできるようにしたいと思います

## 外部からアクセス可能なIPアドレスを割り当てる
Kubernetesのサービスに外部からアクセスするためにはサービスタイプを`LoadBalancer`としなければいけないようです。この辺からだんだんKubernetesのいろいろの機能を使うので，docker-compose.ymlファイルをKubernetesの設定ファイルにいったん変換しておきます
`$ kompose convert`
を実行するとdocker-compose.ymlが変換され，Kubernetes用のYAMLファイルが大量に生成されます。この中からweb-service.yamlファイルを開き，Pod specに次のようにしてサービスタイプを追加します

```yaml
spec:
  type: LoadBalancer
  ...
```

`$ kompose down`を実行して先ほどのデプロイの後片付けをして，新しいKubernetes設定ファイルを使ってデプロイをしてみましょう
`$ kubectl create -f <configuration file name>`
を実行すると，渡したファイルの中に書かれたKubernetesオブジェクトが生成されます。一連のYAMLファイル全てをこのコマンドに渡して全てのKubernetesオブジェクトを生成するとデプロイ完了です
ここでもう一度GCPコンソールからGKEのサービスを見に行くと，「サービスのタイプ」が「ロードバランサ」となっていて，「エンドポイント」のところに外部からアクセス可能なアドレスが書かれていることがわかります。早速このアドレスにアクセスしてみますが，いつまで経ってもロードされません。webサービスのログを見に行くと，どうやらHTTPSに転送されているらしいのですが，いまはまだ証明書がないのでHTTPSでアクセスしても何も返ってこないというわけです

どうやらいまのMastodonはproduction環境でHTTPで接続することはできないらしいですね。他の記事を見ると環境変数に`LOCAL_HTTPS=false`としているものがありますが今のバージョンでは無効のようです。ここで取り得る方法は，
1. 素直にドメインと証明書を取ってHTTPSで接続する
2. local環境のイメージをビルドし直してデプロイする
の二つだと思うのですが，ここではドメインと証明書を取ってHTTPSで接続する設定をやることにします。まず，ドメインは適当なところで取ってきてください。以下`mastodon.carbontwelve`を取得したとします

## 静的IPアドレスを使う
この先は静的IPアドレスがないといろいろと困るので，静的IPアドレスを取得します。[GCPのドキュメントのReserving a new static external IP address](https://cloud.google.com/compute/docs/ip-addresses/reserve-static-external-ip-address#reserve_new_static)に従って静的IPアドレスを予約します。IPアドレスが予約できたらDNSの設定でmastodon.carbontwelveをそのアドレスに対応させるレコードを作成してください。数時間ぐらいするとドメイン名で予約したIPアドレスにアクセスできるようになります。web-service.yamlのPod specに

```yaml:web-service.yaml
spec:
  type: LoadBalancer
  loadBalancerIP: "<your static IP address>"
  ...
```

を追加してwebサービスを生成し直します
`$ kubectl replace --force -f web-service.yaml`
を実行すると強制的にオブジェクトが生成し直されます。ここでコンソールを見に行くと，webサービスに指定した静的IPアドレスが割り当てられているのがわかります

## https-portalを使って証明書を取得する
ここでLet's Encryptで証明書を取るのですが，使い方を読むとcertbotというツールを使えと書いてあります。おそらく
1. certbotで証明書を取得し，GCPのロードバランサに登録して，ロードバランサにSSL終端をさせる
2. certbotとHTTPサーバを一緒にしたコンテナを作成して証明書の取得とSSL終端をさせる
の二つの選択肢があると思うのですが，ここはいったん[https-portal](https://github.com/SteveLTN/https-portal)というnginxとcertbotを一緒にしたDockerイメージを使わせてもらうことにします。まず，このコンテナ用のKubernetesデプロイ，サービス，Persistent Volume Claimの設定ファイルを書きます

### nginx-deployment.yaml
デプロイの設定ファイルに必要なことは

* imageにsteveltn/https-portal:1を指定する
* portsに80(HTTP)と443(HTTPS)を指定する
* envにDOMAINSとSTAGE，WEBSOCKETを指定する
* /var/lib/https-portalにPersistent Volumeをマウントする（一度取得した証明書を保存しておくため）
* /var/lib/nginx-conf/mastodon.carbontwelve.ssl.conf.erbにカスタムのnginx設定ファイルを置く（ここではconfigmapをvolumeとしてマウントすることでファイルを置いています）

自分が用意した設定ファイルは以下の通りです

```yaml:nginx-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: steveltn/https-portal:1
        ports:
        - containerPort: 80
        - containerPort: 443
        env:
        - name: DOMAINS
          value: mastodon.carbontwelve
        - name: STAGE
          value: production
        - name: WEBSOCKET
          value: "true"
        volumeMounts:
        - name: "nginx-config"
          mountPath: "/var/lib/nginx-conf/mastodon.carbontwelve.ssl.conf.erb"
          subPath: "mastodon.carbontwelve.ssl.conf.erb"
        - mountPath: /var/lib/https-portal
          name: nginx-pvc
      volumes:
      - name: "nginx-config"
        configMap:
          name: "nginx-config"
      - name: nginx-pvc
        persistentVolumeClaim:
          claimName: nginx-pvc
```

### mastodon.carbontwelve.ssl.conf.erb
基本的には公式ドキュメントに書いてあるnginx設定ファイルなのですが，https-portalに合わせて以下の点を変更してあります

* `ssl_certificate`と`ssl_certificate_key`, `ssl_dhparam`をhttps-portalの設定ファイル生成ツールが置き換えるマクロに置き換えてある
* `proxy_cache CACHE;`を消してある（アドホックな修正）
* `proxy_pass`を`http://web:3000`と`http://streaming:4000`にそれぞれ置き換えてある

```nginx:mastodon.carbontwelve.ssl.conf.erb
server {
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  server_name mastodon.carbontwelve;

  (omitted)

  # Uncomment these lines once you acquire a certificate:
  ssl_certificate <%= domain.chained_cert_path %>;
  ssl_certificate_key <%= domain.key_path %>;
  ssl_dhparam <%= dhparam_path %>;

  (omitted)

  location @proxy {
    (omitted)

    proxy_pass http://web:3000;
    proxy_buffering on;
    proxy_redirect off;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    proxy_cache_valid 200 7d;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    add_header X-Cached $upstream_cache_status;
    add_header Strict-Transport-Security "max-age=31536000";

    tcp_nodelay on;
  }

  location /api/v1/streaming {
    (omitted)

    proxy_pass http://streaming:4000;
    proxy_buffering off;
    proxy_redirect off;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    tcp_nodelay on;
  }

  error_page 500 501 502 503 504 /500.html;
}
```

### nginx-service.yaml
サービスの設定ファイルで必要なことは

* 80番と443番ポートを開ける
* `loadBalancerIP`に取得した静的IPアドレスを指定する

```yaml:nginx-service.yaml
kind: Service
apiVersion: v1
metadata:
  name: nginx
spec:
  selector:
    app: nginx
  ports:
  - protocol: TCP
    name: "https"
    port: 443
    targetPort: 443
  - protocol: TCP
    name: "http"
    port: 80
    targetPort: 80
  type: LoadBalancer
  loadBalancerIP: "your static IP address"
```

### nginx-pvc.yaml
これは適当です

```yaml:nginx-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: nginx-pvc
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 100Mi
```

`$ kubectl create configmap nginx-config --from-file=mastodon.carbontwelve.ssl.conf.erb`
を実行してconfigmapを追加し，いま作成した各Kubernetesオブジェクトを実際に生成します。ここでnginxワークロードのログを見に行くと証明書が取得できていると思います👏
ただここでmastodon.carbontwelveにアクセスするとエラーが出ます
`ERROR: relation "session_activations" does not exist`
データベースの初期化忘れてました。というわけで
`$ kubectl get pod`
でwebポッドのPod IDを確認し，
`$ kubectl exec -it <pod ID> /bin/bash`
でポッド上でbashを起動して
`$ SAFTY_ASSURED=1 bundle exec rake db:setup`
でDBを初期化します。もう一度アクセスしてみるとトップページが表示されますおめでとう！🎉🎉🎉

# やり残したこと
* nginxのconfigurationをまともにする
 * いろいろアドホックなことをしまくったのでまともにする必要がある気がする？
* メールサーバの設定をする
 * ここではまだメールサーバの設定をしてないので，ユーザを作成しても確認メールが来ないので使えません
* RedisにPersistent Volumeをくっつける
 * いまのままだと再起動するとRedisのデータが消えます
* PostgreSQLをCloud SQLのインスタンスに変更する
 * コンテナ上にPostgreSQLを立てるよりもCloud SQL使ったほうがパフォーマンスいいはず
* ファイアウォールなどの設定をする
* SSL終端をLBにする
 * L7のLBが使えたほうがいろいろたぶん便利

# 感想
Kubernetes使った意味なんだったんだろう

# 質問などについて
この記事は筆者がやった方法をまとめたという位置づけなので，この通りにやってデプロイできることを保証しません。デプロイできなかったとしても質問は遠慮願います
記事の誤りについての質問・指摘は歓迎しています