docker-compose で構築した mastodon を k8s 上に 12 時間かけて構築し直す

※本記事は [Kubernetes3 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/kubernetes3) 18 日目の記事となっています.

# docker-compose で構築した mastodon を k8s 上に 12 時間かけて構築し直す
## 一言でいうと
この記事では「素の k8s 環境にアプリ構築するのめっちゃ大変ですね」という話を書く.
思いのほか分量が多くなってしまいとても申し訳ない気持ちでいっぱい

# 動機
勉強がてら, 以前ローカルマシン上に docker-compose で構築した test 用 mastodon を k8s PaaS 上に構築する. ローカルマシンがなくても test 環境を用意できるようにしたい.

k8s 周辺のツール紹介などはよく見かけるが, 大きなアプリを構築したよという話が（コード込みで）あまり目に触れないというのも動機. トラブった時のデバッグ込みでどういう流れでやるのか確認したい.

## 目標
細かいところまではよくわからんが, k8s にデプロイした mastodon が見た目上まともに動いとるぞ，という状態にする. 少なくとも自分が立てたインスタンス同士で連合を組める状態にしたい.

## 留意事項

- Mastodon の運用環境を構築して公開する話ではない. 末尾へ運用周りの話をリンクしてあるので参考にどうぞ
- データ移行の話は書けなかった
- k8s の細かい動きとか内部実装については触れない
- kustomize などの個別ツールについては特に解説しない

筆者情報.

- 普段は EKS (AWS の k8s PaaS) を使っている
- Dockerfile 自分で書いたりする
- helm や kustomize もそこそこ触る
- k8s 上でアプリを作ってサービス公開したりとかを一から全部やったことはない
- Network の専門家とかではない. Ingress も触ったことない

## k8s で動かしたい container image：mastodon-http-local
https://hub.docker.com/r/novsyama/mastodon-http-local

Tootsuite 公式の Mastodon docker image を改造したもの. 「Mastodon 何それ」って方は[こちら](https://github.com/tootsuite/mastodon).

主に自宅内ネットワークに閉じたテスト用途で開発した. これを作った動機は以下.

- Mastodon REST API からデータを読み取るテストクライアントを作成していた. のだが連合の挙動込みでローカルに閉じてテストする環境が欲しかった
- 証明書の取得管理に手間がかかる問題
  - わざと http (NO TLS) で連合を組むように改造
- ドメインの取得管理に手間がかかる問題
  - 代わりに IP アドレスでインスタンス同士が認識できるようにする

SMTP server 情報と有効な Email address を準備して script を叩くと docker-compose で立ち上がる.
そのうち詳細を書く予定だけど k8s とは関係ないので割愛.

# 作業の流れ
現構成は大雑把に言うと５つのコンテナと１つのリバースプロキシ nginx コンテナからなる. それらをどうやって k8s 上にデプロイするか（追加でどういう設定やツールなどが必要になるか）を書いていく.

- GKE の利用開始
- k8s へ container image をデプロイするための設定を書いた, manifest ファイルを作る
  - それぞれ docker-compose でコンテナだったものを k8s へ pod としてデプロイ
- ウェブブラウザから Mastodon を開ける状態にする
- LoadBalancer へのアクセス元を制限する

※以降は実際の作業にかかった時間を h (時間) min（分）で表記
※調べながら作業しているので調査にかかった時間も入っている. **熟達しているならすぐ終わる作業もたくさんあるだろうし, 何ならスクリプトを書いて自動化してもよい**, と思う. 要領が悪すぎるという指摘はごもっともで後から考えると色々とさぼれる部分はあった気がする

## 現構成
![qiita-adevent-2019-before.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/230336/b453cbdd-5c2a-43d1-f352-f756b1617b5a.png)

- 括弧内は image 名を示す
- web, streaming と sidekiq は共通 image となっているが役割ごとにコンテナを分けてある
  - 主要な役割は web が担っている
- Mastodon が載っている VM の port 80 へリクエストが来たら nginx に forward する（ように docker run の `-p` option で指定してある）
  - nginx は飛んできたリクエストを web と streaming の API に振り分ける
- sidekiq は web から受け取った非同期 job を捌く

## 今回の作業で得られた最終構成
![qiita-adevent-2019-after.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/230336/2a7b281c-b20a-7111-e1e4-ba182dc83b50.png)

k8s network の部分はいろいろごまかして書いている（実際には複数の node で構成されている）. container が pod に代わっていることに注意. また, あくまで今回の作業結果であって最適な構成ではない.

## k8s へ移す際に新たに使うことになったサービスやツール

- GKE (GCP の k8s PaaS)
  - 気づかぬうちに GCP 無料枠が $300 になっていたので
- google cloud storage
- kubectl. GCP の CloudShell に最初から入っている
- kompose. なくても良かったかも
- kustomize
- helm v2. GCP の CloudShell に最初から入っている
  - kustomize に統一したかったがやむなく利用

# GKE の利用開始（大体 15min 程度）
まず GKE アカウント（クレカ）を登録する.

## クラスタを作成
https://cloud.google.com/kubernetes-engine/docs/quickstart?hl=ja

上記に沿ってやれば難なく終わる. 以降はほぼ CloudShell 上で作業. 目を離すと割とすぐにセッションが切れるが、つながるのも早いのであまり気にならなかった. 他にやったこととして

- logging 用に BigQuery dataset 作成
  - 今回の作業範囲ではなくてもよかった
- gcloud tool インストール（振り返ってみるとほとんど使わなかった）
  - https://cloud.google.com/sdk/docs/quickstart-linux?hl=ja

## Spec
![cluster-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/230336/01e53a27-b5ad-d965-810b-7899de3cee38.png)
![cluster-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/230336/cbd37d56-8da2-7399-b013-f136b4be89ce.png)

# k8s へ container image をデプロイするための設定を書いた manifest ファイルを作る
## kompose で docker-compose.yaml を kustomize 向けの manifest file へ変換（大体 1h 程度）

- [ここの](https://github.com/kubernetes/kompose) Binary installation の手順で kompose をインストール
  - そんなに何度も使うものでもないのですぐに消して問題ない
- `kompose convert -f docker-compose.yaml` で kustomize 向けの manifest file を生成
  - docker-compose が参照する `.env` ファイルなども同一ディレクトリへ配置した上で実行する
- `kustomize build .` で出力された manifest が文法エラーなくビルド可能なことを確認
  - GKE の k8s が 1.13 だったので CloudShell 上に [kustomize をインストール](https://github.com/kubernetes-sigs/kustomize)
  - 1.14 以降であれば kubectl に kustomize の機能が同梱されている

当初のファイル構成はこんな感じだった.

```tree
.
   ├── kustomization.yaml
   ├── db-claim0-persistentvolumeclaim.yaml
   ├── db-deployment.yaml
   ├── redis-claim0-persistentvolumeclaim.yaml
   ├── redis-deployment.yaml
   ├── sidekiq-claim0-persistentvolumeclaim.yaml
   ├── sidekiq-deployment.yaml
   ├── sidekiq-env-development-configmap.yaml
   ├── streaming-deployment.yaml
   ├── streaming-env-development-configmap.yaml
   ├── web-claim0-persistentvolumeclaim.yaml
   ├── web-deployment.yaml
   └── web-env-development-configmap.yaml
```

各種ファイルのとても大雑把な説明を書くと

- kustomization.yaml はそのデプロイでどの yaml ファイルを読み込むか等を記述
  - db-claim0-persistentvolumeclaim から web-env-development-configmap まですべてのファイルがリストアップされていた
- deployment.yaml には pod をどの image を使って立ち上げるかなどを記述
  - 似たような役割を持つものに replicaset とか statefulset とかあるのだが今回は割愛
- configmap には pod の環境変数などの設定情報を記述
  - deployment に直接記述することも可能だが分けるのが一般的？
- persistentvolumeclaim (pvc) には pod に割り当てる永続化ボリュームの情報を記述

### kustomize 向け manifest ファイルを試しにデプロイ
試しに `kustomize build . | kubectl apply -f -` ですべての pod をデプロイしてみると当然のように動かない.

```
$ kubectl get pods
NAME                         READY   STATUS             RESTARTS   AGE
db-54dd975db5-njctb          0/1     CrashLoopBackOff   41         3h10m
redis-99bd86b67-n9c79        1/1     Running            0          3h10m
sidekiq-6b86d8f95f-lvvrk     0/1     CrashLoopBackOff   41         3h10m
streaming-666564bbcf-lmlwg   0/1     CrashLoopBackOff   65         3h10m
web-d644c8c9f-9c9gb          0/1     CrashLoopBackOff   45         3h10m
```

ので地獄のデバッグが始まる. 被依存層である datastore からひとつひとつ, 次の順番で疎通確認していく.

- db (postgresql)
- redis
- web
- streaming
- sidekiq

デバッグしながらの作業となるので次をどうやるのかも確認する.

- pod の動作（疎通）確認方法
- 個別の pod を立てたり消したりするための pod の uninstall 方法

### manifest ファイル最終構成
先に書いてしまうと作業後のファイル構成はこんな感じになった（途中経過を記録し忘れてしまった）.

```tree
.
├── db（最終的に helm で代用したため使用せず）
│   ├── db-claim0-persistentvolumeclaim.yaml
│   ├── db-deployment.yaml
│   ├── db-service.yaml
│   └── kustomization.yaml
├── ingress
│   ├── ingress.yaml
│   ├── kustomization.yaml
│   ├── nginx.conf
│   ├── nginx-deployment.yaml
│   └── nginx-service.yaml
├── kustomization.yaml
├── redis
│   ├── kustomization.yaml
│   ├── redis-claim0-persistentvolumeclaim.yaml
│   ├── redis.conf
│   ├── redis-deployment.yaml
│   └── redis-service.yaml
├── sidekiq
│   ├── kustomization.yaml
│   ├── sidekiq-claim0-persistentvolumeclaim.yaml
│   ├── sidekiq-deployment.yaml
│   ├── sidekiq-env-development-configmap.yaml
│   └── sidekiq-service.yaml
├── streaming
│   ├── kustomization.yaml
│   ├── streaming-deployment.yaml
│   ├── streaming-env-development-configmap.yaml
│   └── streaming-service.yaml
└── web
    ├── kustomization.yaml
    ├── web-claim0-persistentvolumeclaim.yaml
    ├── web-deployment.yaml
    ├── web-env-development-configmap.yaml
    └── web-service.yaml
```

ディレクトリを分けたのは pod を個別にデプロイ削除したかったため. それぞれのディレクトリについて, どのファイルをデプロイに使用するのか kustomization に書いておく.

ほとんどの pod で次の修正が必要となった.

- kompose はサービス間通信を実現するための service を作成してくれないので自力で追加
- deployment に書きだされた command の値が怪しいので修正
- persistentvolumeclaim (pvc) の情報に不足があったので追記
  - この辺も参考にしつつ https://cloud.google.com/kubernetes-engine/docs/tutorials/?hl=ja

## db (postgresql) の manifest をデバッグ（大体 2.5h 程度）
Postgresql 公式 image がそのままだと k8s 上で動きそうになかったので次の３通りを試す羽目になった.

- kompose の生成した manifest yaml を採用
- GKE が配布している helm manifest を採用
- helm が配布している manifest を採用

結論を書くと helm v2 で helm が配布している [stable/postgresql](https://github.com/helm/charts/tree/master/stable/postgresql) をインストールした. helm v3 は過渡期でちゃんと調べてから使わないとハマりそうだったため断念.

### helm が配布している manifest を採用
helm ではそれぞれの application （例えば postgres や mysql, traefik など）を [charts](https://github.com/helm/charts/tree/master/stable) という単位で管理しており, k8s 上にデプロイされている charts のバージョンなどを一括して管理する仕組みがある. helm v2 では tiller という ServiceAccount を k8s 上に作って charts を管理しているのだが, tiller は先に準備しておく必要がある.

tiller の設定は[こちらを参考にした](https://qiita.com/nykym/items/1573ca568a80d0d01c45#%E3%81%93%E3%81%AE%E8%A8%98%E4%BA%8B%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6). postgresql インストールには次のコマンドを使用した.

```bash
helm install --name db stable/postgresql --tiller-namespace tiller-world --namespace tiller-
world
```

postgresql のパラメータはデフォルトのものを使用している. namespace が tiller-world となっているのは上の記事のサンプルにあった, tiller がアクセス可能な namespace を変更するのが面倒だったため. 本当は他の pod と揃えるべきだった.

インストールすると postgres へアクセスするためのパスワード情報が自動生成されるため次のコマンドで取得しておく.
これは後で web pod (mastodon) の configmap へ設定することになる.

```bash
export POSTGRES_PASSWORD=$(kubectl get secret --namespace tiller-world db-postgresql -o jsonpath="{.data.postgresql-password}" | base64 --decode)
echo ${POSTGRES_PASSWORD}
```

### 動作確認

- まず `kubectl get svc -n tiller-world` で postgres に対応するサービス名を取得する
- psql を使える pod を別途立てて次を実行
- `kubectl exec -it ${psql を使える pod} -- psql -U postgres -h ${サービス名}.tiller-world.svc.cluster.local`
  - パスワードを聞かれるので入力しログインできることを確認する

### Uninstall

```bash
helm delete --purge db --tiller-namespace tiller-world
```

pvc も消えているかどうか `kubectl get pvc --all-namespaces` で確認しておく.
pvc が消えていないと以前のデータや postgres のパスワードがうまくリセットされずに残ってしまう.

#### 何故 helm の manifest 採用に至ったかの経緯（読み飛ばし推奨）
##### kompose の生成した yaml を採用？
まず `kubectl describe pods` で何が起きたか確認したところ pvc に問題があるように見えた.
下記を参考にしつつ storageClassName を指定するよう db-claim0-persistentvolumeclaim を修正. ついでに他の pod についても pvc に関する manifest を修正した.

https://github.com/GoogleCloudPlatform/kubernetes-engine-samples/tree/master/wordpress-persistent-disks

db-claim0-persistentvolumeclaim.yaml 修正前:

```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  creationTimestamp: null
  labels:
    io.kompose.service: db-claim0
  name: db-claim0
spec:
  accessModes:
  - ReadWriteOnce
  storageClassName: standard
  resources:
    requests:
      storage: 100Mi
```

db-claim0-persistentvolumeclaim.yaml 修正後:

```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  creationTimestamp: null
  labels:
    io.kompose.service: db-claim0
  name: db-claim0
spec:
  accessModes:
  - ReadWriteOnce
  storageClassName: standard
  resources:
    requests:
      storage: 10Gi
```

上記のエラーは出なくなったが, 何のログも吐かずに pod が落ちるようになってしまった. `kubectl describe pods` で確認するとプロセスが落ちたことしか記録されていない. `kubectl logs` には特にエラーログもなく突然処理が途切れている.

```
initdb: directory "/var/lib/postgresql/data" exists but is not empty
It contains a lost+found directory, perhaps due to it being a mount point.
Using a mount point directly as the data directory is not recommended.
```

docker-compose で動かしたときのコンテナ内ログと比較すると permission 周りの処理を実施しようとして落ちているように見える. 次の issue が関係しそう？

https://github.com/docker-library/postgres/issues/451

権限周りだろうか, よくわからん.
試しに initContainers フィールドに chmod 処理を追加してみたが変わらず.

```yaml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  labels:
    io.kompose.service: db
  name: db
spec:
  replicas: 1
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        io.kompose.service: db
    spec:
      initContainers:
      - name: init-db
        image: debian
        imagePullPolicy: "Always"
        resources:
          requests:
            cpu: 250m
            memory: 256Mi
        command:
        - /bin/sh
        - -c
        - |
          mkdir -p /var/lib/postgresql/data
          chmod 700 /var/lib/postgresql/data
          find /var/lib/postgresql -mindepth 0 -maxdepth 1 -not -name ".snapshot" -not -name "lost+found" | \
            xargs chown -R 1001:1001
          chmod -R 777 /dev/shm
        securityContext:
          runAsUser: 0
        volumeMounts:
        - mountPath: /var/lib/postgresql/data
          name: db-claim0
      containers: 以下略
```

GKE や helm の配布している manifest が参照している image を見ると, postgres 公式の image をそのまま k8s に持ってきているわけではなさそう. おそらくそのままだと動かないのだろう..

#### GKE が配布している helm manifest を採用？
https://github.com/GoogleCloudPlatform/click-to-deploy/tree/master/k8s/postgresql

パラメータがやたらと多かったので（時間の都合上）断念.
README には `postgresql.image` を指定しろと書いてあるが, helm template ファイルを見たところ実際には `postgresql.image.repo` と `postgresql.image.tag` を指定する必要があるっぽい.

## redis の manifest をデバッグ（大体 1h 程度）
なんか知らんが redis だけは特に何もせずとも動いているように見える.

が, この状態だと web pod から見えない.以下の作業を行う.

- service を追加する（当初これを忘れて文字通り小一時間悩んでしまった）
  - これをやらず web pod 上で `rails db:migrate` すると name or service unknown と言われてしまう
- redis.conf に bind の設定を追加する
  - これをやらず web pod 上で `rails db:migrate` すると connection refused と言われてしまう

解決するために web と redis を行ったり来たりして, この２つの修正に結局２時間程度かかってしまった.

### service を追加する
redis-deployment.yaml:

```
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  labels:
    app: redis
    io.kompose.service: redis
  name: redis
spec:
  replicas: 1
  selector:
    matchLabels:
      app: redis
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: redis
        io.kompose.service: redis
    spec:
      containers: 以下略
```

redis-service.yaml: selector の部分に注意. これを追加することで, k8s cluster 内のサービスから `redis:6379` もしくは `redis.default.svc.cluster.local:6379` へのアクセスがあると, label が `app: redis` に一致する pod を適当に選んで 6379 port へ通信を橋渡ししてくれるようになる. 負荷分散させたい場合は追加の設定が必要になるが今回は割愛.

```yaml
apiVersion: v1
kind: Service
metadata:
  name: redis
  labels:
    app: redis
spec:
  type: ClusterIP
  ports:
    - port: 6379
  selector:
    app: redis
```

### redis.conf の追加
以下を参考にしつつ redis.conf を configmap 経由で pod 上に mount する.

- https://kubernetes.io/ja/docs/tutorials/configuration/configure-redis-using-configmap/
- https://hub.docker.com/_/redis

redis.conf:

```
bind 0.0.0.0
```

kustomization.yaml: redis.conf を redis-config という名前の configmap へファイルとして格納する.

```yaml
resources:
- redis-claim0-persistentvolumeclaim.yaml
- redis-deployment.yaml
- redis-service.yaml
configMapGenerator:
- name: redis-config
  files:
  - redis.conf
```

redis-deployment.yaml: redis-config を `/etc/redis/` へ volume mount すると, redis.conf がその直下（`/etc/redis/redis.conf`）へ展開される.

```yaml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  labels:
    app: redis
    io.kompose.service: redis
  name: redis
spec:
  replicas: 1
  selector:
    matchLabels:
      app: redis
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: redis
        io.kompose.service: redis
    spec:
      containers:
      - args:
        - redis-server
        - /etc/redis/redis.conf
        image: redis:5.0-alpine
        livenessProbe:
          exec:
            command:
            - redis-cli
            - ping
        name: redis
        resources: {}
        volumeMounts:
        - mountPath: /data
          name: redis-claim0
        volumeMounts:
        - mountPath: /etc/redis/
          name: config
      restartPolicy: Always
      volumes:
      - name: redis-claim0
        persistentVolumeClaim:
          claimName: redis-claim0
      - name: config
        configMap:
          name: redis-config
          items:
          - key: redis.conf
            path: redis.conf
```

### 動作確認

- redis pod の replica 数を一時的に２つにして相互に疎通確認
  - 先に redis pod の名前を `kubectl get pods` で確認し
  - `kubectl exec -it ${redis pod の名前} -- redis-cli -h redis.default.svc.cluster.local`
- 後述する web pod から `rails db:migrate` を実行して connection refused とならないことを確認
  - 先に web pod の名前を `kubectl get pods` で確認し
  - `kubectl exec -it ${web pod の名前} -- rails db:migrate`

### Uninstall
`kustomize build ./redis | kubectl delete -f -` で redis のみを削除できる.

Uninstall 時に中途半端に消えたものが残ってしまったのか, 途中ゾンビ状態の pods や replicasets, また pvc が乱立した状態になることがあった. それぞれ以下のコマンドで確認可能.

- `kubectl get pods --all-namespaces`
- `kubectl get replicasets --all-namespaces`
- `kubectl get pvc --all-namespaces`

## web の manifest を debug（大体 1h 程度）

- configmap を設定する
  - Mastodon の必要とする SECRET_KEY_BASE と OTP_SECRET の２つは先に手元で作成しておく.
  - db pod 作成時に取得したパスワード情報を DB_PASS へ指定
  - あとは変動しない情報なので docker-compose 時と同じ
- deployment manifest へ initContainers を追加
  - 移行元の docker-compose 内に書かれていない手順として, コンテナ内で `rails db:migrate` というコマンドなどを叩く必要があったのだが, これを initContainers フィールドで実行する. initContainers には読んで字のごとく container 起動前の DB 初期化処理などを記述できる
  - 作業中に initContainers 処理の成否がわからんという問題が発生した. initContainers 内で処理が失敗したら exit 1 とか返すようにしておく必要があるが, やっつけで書いたので成否の判定ができていなかった
  - initContainers 内の処理に関しては interactive に作業ができないので debug がつらい..
- デバッグ中は livenessProbe をコメントアウトしていた
  - livenessProbe の処理がこけると pod が落ちてしまい, `kubectl exec` などで container に入って interactive に debug することができないため

web-depoyment.yaml: initContainers の command に `rails db:migrate` などの処理を記述している.

```
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  labels:
    io.kompose.service: web
    app: web
  name: web
spec:
  replicas: 1
  selector:
    matchLabels:
      app: web
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        io.kompose.service: web
        app: web
    spec:
      initContainers:
      - name: init-db
        image: novsyama/mastodon-http-local
        imagePullPolicy: "Always"
        resources:
          requests:
            cpu: 250m
            memory: 256Mi
        command:
        - /bin/sh
        - -c
        - |
          rails db:migrate
          rails assets:precompile
          chown -hR mastodon public
        securityContext:
          runAsUser: 0
        env:
        - name: DB_HOST
          valueFrom:
            configMapKeyRef:
              key: DB_HOST
              name: web-env-development
        - name: DB_NAME
          valueFrom:
            configMapKeyRef:
              key: DB_NAME
              name: web-env-development
        - name: DB_PASS
          valueFrom:
            configMapKeyRef:
              key: DB_PASS
              name: web-env-development
        - name: DB_PORT
          valueFrom:
            configMapKeyRef:
              key: DB_PORT
              name: web-env-development
        - name: DB_USER
          valueFrom:
            configMapKeyRef:
              key: DB_USER
              name: web-env-development
        - name: LOCAL_HTTPS
          valueFrom:
            configMapKeyRef:
              key: LOCAL_HTTPS
              name: web-env-development
略
```

web-env-development-configmap.yaml: data に定義した値は web-deployment.yaml の configMapKeyRef で参照されている. configmap の情報を書き換えても pod へ直ちに反映されないため, 書き換えの際には一旦 pod を削除する必要がある.

```yaml
apiVersion: v1
data:
  DB_HOST: db-postgresql.tiller-world.svc.cluster.local
  DB_NAME: postgres
  DB_PASS: hogehogepi
  DB_PORT: "5432"
  DB_USER: postgres
  LOCAL_HTTPS: "false"

中略

kind: ConfigMap
metadata:
  creationTimestamp: null
  labels:
    io.kompose.service: web-env-development
  name: web-env-development
```

### 動作確認

- web pod から `rails db:migrate` を実行して task が成功することを確認
  - 先に web pod の名前を `kubectl get pods` で確認し
  - `kubectl exec -it ${web pod の名前} -- rails db:migrate`
- wget が通ることを確認
  - `kubectl exec -it ${web pod の名前} -- wget -v localhost:3000/api/v1/instance`
- コメントアウトした livenessProbe を元に戻して web pod を再起動し pod が落ちないことを確認

### Uninstall
`kustomize build ./web | kubectl delete -f -` で web pod のみを削除できる.
pvc も消えているかどうか `kubectl get pvc --all-namespaces` で確認しておく.

## streaming の manifest を debug（大体 30min 程度）
web とほぼ同じでつまるところはなかった.

### 動作確認
先に streaming pod の名前を `kubectl get pods` で確認し, `kubectl logs ${streaming pod の名前}` でエラーが出ていないか確認.

### Uninstall
`kustomize build ./streaming | kubectl delete -f -` で streaming pod のみを削除できる.
pvc も消えているかどうか `kubectl get pvc --all-namespaces` で確認しておく.

## sidekiq の manifest を debug（大体 30 min 程度）
ローカルマシンに docker-compose で mastodon 構築した際には, web container と sidekiq container が host filesystem 上の /public 配下を共有していた. しかし pod 間で filesystem 共有を実現するのには難があり, どうするか少し悩んだ.

- 結論としては, mastodon が filesystem の代わりに s3 (または google cloud storage) bucket を利用したファイル共有をサポートしているので, それを使うことにした
- kompose が生成した sidekiq-deployment-...yaml の volume mount 定義を眺めると別々の pvc を作成しており, そもそも docker-compose で構築した際に実現していた container 間のデータの共有ができていない.
- k8s で ReadWriteMany での PVC 共有（同時に複数の pod から同じストレージ領域に書き込む）は悪手に見え, s3 などのストレージサービスを頼ったほうが良さそう
- もし sidekiq が /public 配下への書き込みをしないのであれば, readonly での pvc 共有が使えそう
  - https://cloud.google.com/kubernetes-engine/docs/how-to/persistent-volumes/readonlymany-disks?hl=ja

### s3 設定

- バケットを作成して
  - https://console.cloud.google.com/storage/create-bucket
- アクセストークンを発行する
  - https://cloud.google.com/storage/docs/migrating#keys
- web, streaming, sidekiq の configmap および deployment manifest に s3 設定を追記して再起動
  - db 消したつもりで pvc 残っているのに気づかず
  - 1h 程度これで費やした. config を変えても直ちに反映されないので pod は毎回 delete していた

### 動作確認
先に sidekiq pod の名前を `kubectl get pods` で確認し, `kubectl logs ${sidekiq pod の名前}` でエラーが出ていないか確認.

### Uninstall
`kustomize build ./sidekiq | kubectl delete -f -` で sidekiq pod のみを削除できる.
pvc も消えているかどうか `kubectl get pvc --all-namespaces` で確認しておく.

# ウェブブラウザから Mastodon を開ける状態にする
ここまでの作業で pod をすべて runnig な状態へもっていくことができた.

```
$ kubectl get pods
NAME                         READY   STATUS    RESTARTS   AGE
nginx-5499564888-vnhtf       1/1     Running   0          28h
redis-6486c997cb-kxrtl       1/1     Running   0          29h
sidekiq-7857f54cd6-cjj7l     1/1     Running   0          28h
streaming-6578b4bc4b-gssb9   1/1     Running   0          28h
web-5d9ddc8ccf-q5qpj         1/1     Running   0          28h
$ kubectl get pods -n tiller-world
NAME                             READY   STATUS    RESTARTS   AGE
db-postgresql-0                  1/1     Running   0          28h
tiller-deploy-7fb7c8f8bf-495g7   1/1     Running   0          39h
```

いよいよウェブブラウザから mastodon を開ける状態へもっていく.

## Public network から k8s 内に立てた mastodon を参照する手段を調査（大体 1h 程度）.
以下のいずれかが使えそうなことを確認. 今回は前者２つについて検討.

- Ingress
- `type LoadBalancer` で nginx などのリバースプロキシを立てる
- ingress controller

## Ingress の挙動を確認（大体 1.5h 程度）

- https://cloud.google.com/kubernetes-engine/docs/how-to/load-balance-ingress?hl=ja
- https://kubernetes.io/docs/concepts/services-networking/ingress/#single-service-ingress

上記を読みつつ挙動を確認したところ, 結論としては今話題の nginx か traefik を `type LoadBalancer` で立てたほうが良さそうに見えた.

- GKE の Ingress は frontend (Public network から見える IPv4 address) と backend service (k8s cluster 内の service へのルーティング情報) と呼ばれるものをセットにしたものらしい
- 今回の用途だとリバースプロキシとして使うには役不足に見える
  - Mastodon では web と streaming pod の WebAPI を, ひとまとめのホスト配下の API として参照する必要がある
  - backend service ごとに IPv4 が振られてしまうので, ひとつの backend service から web, streaming pod 両方へのルーティングを実現する必要がある
  - backend service は host 名での [url-mapping](https://cloud.google.com/load-balancing/docs/url-map?hl=ja
) に対応しているのだが, その host 名には IPv4 が指定できないという制約がある. 冒頭に書いた, ドメインを取得せずに IPv4 だけで mastodon 同士の識別を可能にするような用途では使えない

## nginx を `type LoadBalancer` で立てた時の挙動を確認（大体１時間程度）
### nginx の manifest を作成
kustomization.yaml:リバースプロキシの設定を行うため, nginx.conf を configmap で経由で pod 上に mount する.

```
resources:
- nginx-deployment.yaml
- nginx-service.yaml
configMapGenerator:
- name: nginx-config
  files:
  - nginx.conf
```

nginx-deployment.yaml: redis と違って[直接 config file path を指定して server 起動することができないため](https://hub.docker.com/_/nginx), 起動前に `/tmp` に mount した conf file を `/etc/nginx/conf.d` 上へコピーするというちょっと回りくどいことをしている.

```
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  labels:
    app: nginx
  name: nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  strategy: {}
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - args:
        - bash
        - -c
        - cp /tmp/nginx/nginx.conf /etc/nginx/conf.d/mastodon.nginx.conf; nginx -g 'daemon off;'
        image: nginx
        name: nginx
        resources: {}
        volumeMounts:
        - mountPath: /tmp/nginx/
          name: config
      restartPolicy: Always
      volumes:
      - name: config
        configMap:
          name: nginx-config
          items:
          - key: nginx.conf
            path: nginx.conf
```

nginx-service.yaml: redis-service.yaml で `type: ClusterIP` となっていた部分を `LoadBalancer` に書き換えている.

```yaml
apiVersion: v1
kind: Service
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  type: LoadBalancer
  ports:
    - port: 80
  selector:
    app: nginx
```

## 動作確認
取得した IPv4 アドレスをブラウザで開いてみる.

![finish-gui.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/230336/58c50796-e5d7-5276-4da1-a75bcbd0e321.png)

ローカルマシンがなくても mastodon を使ったテストができる環境を構築できた.
ついでに notification メールも飛んでくるのを確認した.

![finish.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/230336/706cb9c3-39c8-ca6b-c9b8-f44484df1e3f.png)

s3 や streaming API へのアクセスも通っているっぽい.

### nginx.conf のルーティング設定にミスがあった場合の挙動
`kubectl get pods` で nginx の pod 名を確認後, `kubectl logs ${nginx の pod 名}` すると次のようなログが出ていた.

```
kubectl logs nginx-5687bf498f-n2p25
2019/12/16 17:21:27 [error] 8#8: *3 "/home/mastodon/live/public/index.html" is not found (2: No such file or directory), clien
t: 10.128.0.3, server: 34.66.70.59, request: "GET / HTTP/1.1", host: "34.66.70.59:80"
10.128.0.3 - - [16/Dec/2019:17:21:27 +0000] "GET / HTTP/1.1" 404 555 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537
.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36" "-"
2019/12/16 17:22:21 [error] 8#8: *4 "/home/mastodon/live/public/index.html" is not found (2: No such file or directory), clien
t: 10.128.0.3, server: 34.66.70.59, request: "GET / HTTP/1.1", host: "34.66.70.59"
10.128.0.3 - - [16/Dec/2019:17:22:21 +0000] "GET / HTTP/1.1" 404 124 "-" "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:70.0) Gec
ko/20100101 Firefox/70.0" "-"
2019/12/16 17:22:35 [error] 8#8: *4 "/home/mastodon/live/public/index.html" is not found (2: No such file or directory), clien
t: 10.128.0.3, server: 34.66.70.59, request: "GET / HTTP/1.1", host: "34.66.70.59"
10.128.0.3 - - [16/Dec/2019:17:22:35 +0000] "GET / HTTP/1.1" 404 124 "-" "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:70.0) Gec
ko/20100101 Firefox/70.0" "-"
2019/12/16 17:24:09 [error] 8#8: *5 "/home/mastodon/live/public/index.html" is not found (2: No such file or directory), clien
t: 10.40.0.1, server: 34.66.70.59, request: "GET / HTTP/1.1", host: "34.66.70.59"
10.40.0.1 - - [16/Dec/2019:17:24:09 +0000] "GET / HTTP/1.1" 404 124 "-" "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:70.0) Geck
o/20100101 Firefox/70.0" "-"
```

## IP アドレスで Mastodon 同士が認識できるように IP アドレスを web pod の設定に追記（大体 2h 程度）
Mastodon インスタンス同士が連合を組むためには, Mastodon それぞれに, 自分がどういうホスト名で参照されるのかを設定しておく必要がある. 今回はドメインを発行せずに IPv4 を Mastodon 名とするので, 先に IPv4 アドレスを確保して web pod の設定にその値を書き, 再起動して web pod へ反映する必要があった.

### 確保した IP アドレスを確認して web pod の configmap へ追記
`kubectl get svc` で nginx service が確保した external IP を確認する. 下記で `xx.xx.xx.xx` となっている部分.

```
NAME         TYPE           CLUSTER-IP    EXTERNAL-IP   PORT(S)        AGE
kubernetes   ClusterIP      10.0.0.1      <none>        443/TCP        2d11h
nginx        LoadBalancer   10.0.7.76     xx.xx.xx.xx   80:32760/TCP   43h
```

もしくは [LoadBalancer](https://console.cloud.google.com/net-services/loadbalancing/loadBalancers/list) のページで IPv4 を確認する.

確認した値を web-env-development-configmap.yaml へ記述する.

### web pod を再起動して設定を反映
するとリソースが枯渇したのか `kubectl get pods` の結果が pending のまま進まない. `kubectl describe pods` すると次のような記録が残っており, cpu が足りないと言われている.

```
0/3 nodes are available: 3 Insufficient cpu.
```

Node(VM) への pod 配置バランスが偏っているのが原因かと思い, すべての pod を uninstall 後, 再配置したら running 状態に戻った.

# おまけ : LoadBalancer へのアクセス元を制限する（大体 1h 程度）
GKE で service を `type LoadBalancer` で立ち上げると，その瞬間から全世界に向け, 立ち上げた Mastodon インスタンスが公開されてしまう. GCP の場合は nginx service の `type LoadBalancer` を `ClusterIP` 等に変更し, LoadBalancer の間に Ingress を挟むことで Cloud Armor を用いた firewall rule が書けるようになるらしい.

https://cloud.google.com/kubernetes-engine/docs/how-to/cloud-armor-backendconfig

アクセス制限をかけると自宅からのアクセスしか受け付けないようにはなるが，TLS 無効にしてあるので通信経路（public インターネッツ）上に流れるデータは覗き放題である（今回は主にメールアドレスとランダム生成されたパスワードくらいなので無視）. 例えば VPC 内に踏み台 VM を建てて ssh tunneling するとか VPC 内に GUI とブラウザが使える踏み台 VM を立てるとかで, この辺の問題は回避できる.

# まとめ
この記事では「素の k8s 環境にアプリ構築するのめっちゃ大変ですね」という話を書いた. docker-compose で構築していた mastodon を GKE で構築しなおすのにざっくり１２時間ほどの時間を要した. 「ベースラインに立てたか立ててないか」な状態のため, ここから k8s の恩恵を受けられるような形にちょっとずつ変えていく必要がありそう.

## 積み残し
今回は時間切れになったためできなかったが, 本当は手を付けるべきだった諸々.

- コードの公開
- 手順が自動化されていない
  - 本当は `kusutomize build . | kubectl apply -f -` で全部立ち上がるようにすべきなのだが, 先に env manifest に指定すべき情報（postgres 起動したり IPv4 予約したり）を手動で確保する流れになっている
- secrets configmap への移行
  - 全部 configmap で書いてしまっているのでパスワードなどの sensitive な情報は secrets へ移したい
- replica 数１で動かしている. replica 数増やしたい
  - たぶん pvc 共有の問題が発生するのでそれも対処. postgres と redis 以外は pvc 要らない気もする
  - kompose が吐いた deployment manifests をそのまま使っている. Statefulset 使うべきだったのでは
- k8s 1.16 での deprecated API への対処
- データストアの管理が雑（耐障害性とパフォーマンスの追求）
  - Redis operator, postgres operator の導入
  - あるいは Cloud SQL for PostgreSQL の利用

Mastodon 固有の問題

- Update, data backup. 次の記事が参考になりそう
  - [Post-installation steps](https://docs.joinmastodon.org/administration/post-installation/)
  - [Mastodonのアップデートの手順書（k8sで動いてるよ編）](https://qiita.com/notozeki/items/df9dd93e2b98aa410d8f)
  - [Mastodonあれこれ](https://qiita.com/kiritan/items/bc43d73ca44e2e8a13bd)
- Local filesystem から s3 へのデータ移行はどうすればよいのか, まだちゃんと調べていない.

## 参考リンク
上記の作業を実施するときにはほとんど参照していなかった. 参考情報があればもう少し早く終わっていたのかも.

### Manifests for mastodon

- https://github.com/ericflo/kube-mastodon
  - ドメイン保持している場合は [external-dns](https://github.com/kubernetes-sigs/external-dns) を使うといろいろ楽できるらしい
- https://github.com/jviide/kubedon

### とても参考になる先達

- 宅内 k8s での構築 : [KubernetesインフラにMastodonエンジンを突っ込む](https://www.bluecore.net/2018/04/30/linuxmastodonvirtualization-kubernetes%E3%82%A4%E3%83%B3%E3%83%95%E3%83%A9%E3%81%ABmastodon%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%B3%E3%82%92%E7%AA%81%E3%81%A3%E8%BE%BC%E3%82%80/)
- k3s 上での構築 : [Kubernetesで隔離Mastodonネットワークを作った](https://azyobuzin.hatenablog.com/entry/2019/03/21/024504)
- GKE 上で構築 : [Kubernetesの学習のためにMastodonを構築したら勉強になった](https://blog.haramishio.xyz/entry/k8s-mastodon)