おうちKubernetesにmisskeyをデプロイする　～RaspberryPi 3Bに無理やりコンテナを突っ込む～

twitterにの次の時代の分散SNSとしてmisskeyというものがにわかに注目を集めている気がします。

https://join.misskey.page/ja-JP/

これと似た分散型SNSとしてはmastodonのほうが有名な気がしますが、開発の歴史としてはmisskeyのほうがやや歴史が長いようです。

https://gihyo.jp/article/2023/02/misskey-01

>読者の方の中には、同じ分散型SNS用ソフトウェアである「Mastodon」を聞いたことがある人もいると思います。MisskeyはMastodonの仲間ではありますが、Mastodonのフォークや改造版といったものではなく、お互い全く別個に開発されたソフトウェアです。
>ソフトウェアとしてはMisskeyのほうが歴史が古く(Misskeyは2014年、Mastodonは2016年から開発されています)、Misskeyは日本の開発者がメインでメンテナンスされてきました。ただし、「⁠分散型」としての歴史はMastodonのほうが古くなります。

また、mastodonと比べて、より「twitter風」のSNSを意識して作られているようで、使用感もtwitterに近いものがあるようですね。

そして分散型SNSの最大の特徴は、自分でサーバを立てられるという点だと思います。今回はこのmisskeyサーバを自分で立ててみようと思います。

# おうちKubernetes

おうちKubernetesとは、複数台のRaspberryPiで構成されたKubernetesクラスタのことで、本格的なサーバ機を複数台揃えるよりもずっと安価に、物理的に複数のマシンが協調するKubernetesクラスタを体験できます。

構築に際した過去の関連記事はこちら

https://techblog.kayac.com/2019/12/17/080000

https://qiita.com/commojun/items/09237c71b3ad06dfcd3b

https://qiita.com/commojun/items/1f641e8290213c81c975

https://qiita.com/commojun/items/5762c2e9f874755fb5ce

せっかくKubernetesクラスタを構築したので、うまく活かして遊ぶ題材としてmisskeyは良いかも、と思ったのでデプロイしてみようと思います。

# ノード構成

我が家のおうちKubernetesクラスタの構成はこんな感じになっています。

- RaspberryPi 4B メモリ4GB SSDでOS起動 コントローラノード
- RaspberryPi 3B メモリ1GB microSDでOS起動 ワーカノード
- RaspberryPi 3B メモリ1GB microSDでOS起動 ワーカノード
- RaspberryPi 3B メモリ1GB microSDでOS起動 ワーカノード

コントローラノードとなっているRaspberryPi 4Bのみ他よりもCPUやメモリに余裕があり、SSDにOSをインストールしているので、なにかデータを保存する場合はすべてこのSSDに集約したいです。逆に、3台のRaspberryPi 3Bはステートレスに動作できるようにしたいところです。

# 必要なコンテナとリソース

misskeyの構築手順のページには、自力で構築する方法から、docker-compose、Kubernetesで構築する方法まで用意されています。なかなか充実しています。

https://misskey-hub.net/docs/install.html

しかし、Kubernetes上への構築方法は、Helmを使う方法のみの紹介となっていました。個人的にHelmを使ったことが無いことと、RaspberryPiクラスタを使うという特殊な制約に対応する必要があるので、設定ファイルを自分で書くこととします。

misskeyのgithubリポジトリにdocker-compose.ymlのサンプルがあったので、これを参考に必要なものを考えます。
https://github.com/misskey-dev/misskey/blob/develop/docker-compose.yml.example

必要そうなリソースはこんな感じになりそうです。

コンテナ
- web
- db
- redis

ボリューム
- misskeyのconfigを保存するボリューム（これはConfigMapで良かったかもしれない）
- アップロードしたファイルを保存するボリューム
- dbのデータ用ボリューム
- redisのデータ用ボリューム

これらのうち、webコンテナはステートレスに動くようなので、こちらは冗長化に挑戦してみます。

# 事前準備

Kubernetesクラスタが使えるようになっていることに加えて、RaspberryPi 4B上にNFSを用意して、コンテナ側が生成したファイルを永続化できるようにします。

NFSサーバのインストール
```
$ sudo apt install nfs-kernel-server
```

今回、自分の環境では `/home/commojun/nfs` を公開ディレクトリとしました。ディレクトリの公開設定は以下のようになりました。

```
$ sudo sh -c 'echo "/home/commojun/nfs 192.168.10.0/24(rw,sync,no_root_squash)" >> /etc/exports'
```

この設定をすることで、PersistentVolumeのNFSが使えるようになります。

そして、必要となるボリュームに合わせてそれぞれディレクトリを掘っておきます。

```
$ mkdir /home/commojun/nfs/misskey
$ mkdir /home/commojun/nfs/misskey/config
$ mkdir /home/commojun/nfs/misskey/db
$ mkdir /home/commojun/nfs/misskey/redis
$ mkdir /home/commojun/nfs/misskey/files
```

`files` はコンテナ内の別ユーザが書き込みをするので、権限を変更しておきます。

```
$ chmod 0777 /home/commojun/nfs/miskey/files
```

# RedisとDB Pod

RedisとDBのPodは次のように定義してみました。我が家のクラスタでは、RaspberryPi 4BのIPアドレスを `192.168.10.41` 、ホスト名を `pi41` としています。

ポイント
- redis, dbともRaspberryPi 4B内に用意したNFS内に永続化データを保存するようにしました
- 今回の作戦では、Pod自体もRaspberryPi 4Bにスケジューリングされるよう、NodeAffinityの設定もしました
- 両者を同じPodに詰め込んでも良かったかもしれません
- ストレージ関連の冗長化を考えるととてもハードルが高くなりそうだったので、StatefulSetとかも採用せず単純なPodにしました

``` redis.yml
apiVersion: v1
kind: Service
metadata:
  name: redis
  namespace: misskey
spec:
  selector:
    app: redis
  ports:
  - name: http
    port: 6379

---

kind: PersistentVolume
apiVersion: v1
metadata:
  name: pv-misskey-redis
  namespace: misskey
spec:
  storageClassName: misskey-redis
  capacity:
    storage: 1Gi
  accessModes:
  - ReadWriteOnce
  nfs:
    server: 192.168.10.41
    path: /home/commojun/nfs/misskey/redis

---

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: pvc-misskey-redis
  namespace: misskey
spec:
  storageClassName: misskey-redis
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi

---

apiVersion: v1
kind: Pod
metadata:
  name: redis
  namespace: misskey
  labels:
    app: redis
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: kubernetes.io/hostname
            operator: In
            values:
              - pi41
  restartPolicy: Always
  containers:
  - name: redis
    image: redis:7
    volumeMounts:
    - mountPath: /data
      name: redis-volume
    resources:
      limits:
        memory: "100Mi"
        cpu: "250m"
    ports:
    - containerPort: 6379
  volumes:
  - name: redis-volume
    persistentVolumeClaim:
      claimName: pvc-misskey-redis
```

``` db.yml
apiVersion: v1
kind: Service
metadata:
  name: db
  namespace: misskey
spec:
  selector:
    app: db
  ports:
  - name: http
    port: 5432

---

kind: PersistentVolume
apiVersion: v1
metadata:
  name: pv-misskey-db
  namespace: misskey
spec:
  storageClassName: misskey-db
  capacity:
    storage: 4Gi
  accessModes:
  - ReadWriteOnce
  nfs:
    server: 192.168.10.41
    path: /home/commojun/nfs/misskey/db

---

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: pvc-misskey-db
  namespace: misskey
spec:
  storageClassName: misskey-db
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 4Gi

---

apiVersion: v1
kind: Pod
metadata:
  name: db
  namespace: misskey
  labels:
    app: db
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: kubernetes.io/hostname
            operator: In
            values:
              - pi41
  restartPolicy: Always
  containers:
  - name: psql
    image: postgres:15
    volumeMounts:
    - mountPath: /var/lib/postgresql/data
      name: db-volume
    envFrom:
      - secretRef:
          name: misskey-secret
    resources:
      limits:
        memory: "800Mi"
        cpu: "1"
    ports:
    - containerPort: 5432
  volumes:
  - name: db-volume
    persistentVolumeClaim:
      claimName: pvc-misskey-db
```

# webコンテナすぐ死ぬ

ここまではそれなりに上手く行ったのですが、webコンテナの設定がかなりの鬼門でした。

作戦としては、RaspberryPi 3B 3台で冗長化して頑張ってみようとう方法を取ってみたのですが、すぐにメモリ容量が足らなくなってコンテナが強制終了されてしまいます。

```
$ kubectl describe pod web-deployment-******
~略~
Status:           Failed
Reason:           Evicted
Message:          The node was low on resource: memory. Threshold quantity: 100Mi, available: 102204Ki. Container web was using 486968Ki, request is 0, has larger consumption of memory.
```

コンテナ自体のログを観察してみると、どうやらマイグレーションの段階でメモリオーバーしているようでした。

ちょっと調べてみると、RaspberryPiでmisskeyを立てている事例はあるようなのですが、RaspberryPi **4B** です。さすがにメモリが1Gしかない3Bで立てるのは無理があるのか…？

https://zenn.dev/uboar/articles/884617f310701f

# RaspberryPi 3Bでどうしても起動したい

webコンテナのDockerfileを見てみると、毎起動時にマイグレーションをしたあとにサーバを立てるというような設定になっていました。

``` Dockerfile
CMD ["pnpm", "run", "migrateandstart"]
```

しかしよく考えると、DBマイグレーションを行うのは、misskey本体にDBスキーマの変更があるほど大きなアップデートがあるときのみです。そこで、「マイグレーションを伴わない起動であればラズパイ3Bでも耐えられるのでは？」と考えました。

githubのリポジトリの `package.json` の `script` の箇所を調べてみると、マイグレーションをせずサーバをスタートするコマンドがありました。

``` package.json
	"scripts": {
		"start": "pnpm check:connect && cd packages/backend && node ./built/boot/index.js",
		"migrateandstart": "pnpm migrate && pnpm start",
        ~略~
	},
```

そこで、Kubernetesにデプロイするコンテナにはこのコマンドを利用することにしました。

``` yaml
      containers:
      - name: web
        image: misskey/misskey:latest
        command: ["pnpm", "run", "start"]
```

# 無理やりマイグレーション

肝心のマイグレーションはどうするのかというと、パワーのある手元のPC(docker-desktop)で無理やり実行することにしました。以下のようなことをします。

まずこんな感じのdocker-compose.ymlと設定ファイルを用意しておきます。

``` docker-compose.yml
version: "3"

services:
  migration:
    image: misskey/misskey:latest
    command: ["pnpm", "run", "init"]
    ports:
      - "3000:3000"
    volumes:
      - ${PWD}/files:/misskey/files
      - ${PWD}/local.yml:/misskey/.config/default.yml
```

``` local.yml
url: https://domain/
db:
  host: host.docker.internal
  port: 5432
  db: misskey
  user: misskey
  pass: hogehoge
redis:
  host: host.docker.internal
  port: 6379
id: 'aid'
port: 3000
```

RedisとDBをポートフォワードします。
```
(それぞれ別のコンソールで)
$ kubectl port-forward db 5432:5432
$ kubectl port-forward redis 6379:6379
```

docker-composeを実行（普通にdocker runでワンライナーで実行したほうが簡素かもです）
```
$ docker compose run migration
```

これでなんとかマイグレーションができました。

# あたって砕けるwebコンテナ軍団

マイグレーションを予め済ませておくことで、RaspberryPi 3B上でもなんとかコンテナが生きながらえることができるようです。それでも、負荷とかなんらかの理由で1日に1回くらいはメモリオーバーでコンテナが死んでしまいます。そこはもうRaspberryPi 3Bの限界なのかな、と割り切ることにして、 **レプリカ3台のうち1台でも生きていればそれでいい** ことにしました。

最終的に設定ファイルはこんな感じになりました。DB、Redis含めまだチューニングできる箇所はたくさんあると思いますがひとまずできたことにします。

``` web.yml
apiVersion: v1
kind: Service
metadata:
  name: web
  namespace: misskey
spec:
  type: NodePort
  selector:
    app: web
  ports:
  - name: http
    port: 3000
    targetPort: 3000
    nodePort: 30080

---

kind: PersistentVolume
apiVersion: v1
metadata:
  name: pv-misskey-config
  namespace: misskey
spec:
  storageClassName: misskey-config
  capacity:
    storage: 5Mi
  accessModes:
  - ReadWriteOnce
  nfs:
    server: 192.168.10.41
    path: /home/commojun/nfs/misskey/config

---

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: pvc-misskey-config
  namespace: misskey
spec:
  storageClassName: misskey-config
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 5Mi

---

kind: PersistentVolume
apiVersion: v1
metadata:
  name: pv-misskey-files
  namespace: misskey
spec:
  storageClassName: misskey-files
  capacity:
    storage: 10Gi
  accessModes:
  - ReadWriteOnce
  nfs:
    server: 192.168.10.41
    path: /home/commojun/nfs/misskey/files

---

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: pvc-misskey-files
  namespace: misskey
spec:
  storageClassName: misskey-files
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi

---

kind: Deployment
apiVersion: apps/v1
metadata:
  name: web-deployment
  namespace: misskey
  labels:
    deploy: web
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: "75%"
      maxSurge: "50%"
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
                - key: kubernetes.io/hostname
                  operator: In
                  values:
                    - pi31
                    - pi32
                    - pi33
      containers:
      - name: web
        image: misskey/misskey:latest
        volumeMounts:
        - mountPath: /misskey/files
          name: misskey-files
        - mountPath: /misskey/.config
          name: misskey-config
        ports:
        - containerPort: 3000
        resources:
          limits:
            cpu: "2"
        command: ["pnpm", "run", "start"]
        readinessProbe:
          httpGet:
            path: /
            port: 3000
            scheme: HTTP
      volumes:
      - name: misskey-files
        persistentVolumeClaim:
          claimName: pvc-misskey-files
      - name: misskey-config
        persistentVolumeClaim:
          claimName: pvc-misskey-config
```

# 予想に反して動いている

知り合いを数人招いただけですが、この程度のお遊びには耐えられるようです。今回はwebコンテナを安定させることに終始しましたが、もっとユーザが増えるとDBコンテナのリソースまわりに無理が出始めると思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70121/780a4a4f-ece6-c5f9-de80-473972f8538a.png)

Podの状況はというと、この通り死屍累々です。いくつもの犠牲の上にサービスが首をつないでいます。これはある意味でKubernetesの強みが活かせているのではないでしょうか！？！？

```
$ kubectl get pods
NAME                              READY   STATUS                   RESTARTS        AGE
db                                1/1     Running                  1 (2d18h ago)   21d
redis                             1/1     Running                  1 (2d18h ago)   21d
web-deployment-764d5dcdb-2p5sv    0/1     ContainerStatusUnknown   1               17d
web-deployment-764d5dcdb-48fmz    0/1     ContainerStatusUnknown   1               11d
web-deployment-764d5dcdb-546pc    0/1     ContainerStatusUnknown   1               11d
web-deployment-764d5dcdb-5n92z    0/1     ContainerStatusUnknown   1               13d
web-deployment-764d5dcdb-5xj9l    0/1     ContainerStatusUnknown   1               14d
web-deployment-764d5dcdb-bjzxv    1/1     Running                  0               3h23m
web-deployment-764d5dcdb-fc6dm    0/1     ContainerStatusUnknown   1               15d
web-deployment-764d5dcdb-fcqnq    0/1     ContainerStatusUnknown   1               13d
web-deployment-764d5dcdb-gb5k8    0/1     ContainerStatusUnknown   1               13d
web-deployment-764d5dcdb-ghbvs    0/1     ContainerStatusUnknown   1 (2d18h ago)   10d
web-deployment-764d5dcdb-hpsc8    0/1     ContainerStatusUnknown   1 (2d18h ago)   10d
web-deployment-764d5dcdb-k4cxh    0/1     ContainerStatusUnknown   1               14d
web-deployment-764d5dcdb-kh6q4    0/1     ContainerStatusUnknown   1               17d
web-deployment-764d5dcdb-m282x    0/1     ContainerStatusUnknown   1               44h
web-deployment-764d5dcdb-nr6g7    0/1     ContainerStatusUnknown   1               12d
web-deployment-764d5dcdb-p2bxs    0/1     ContainerStatusUnknown   1               15d
web-deployment-764d5dcdb-rqrbf    1/1     Running                  0               46h
web-deployment-764d5dcdb-tlv88    0/1     ContainerStatusUnknown   1 (2d18h ago)   7d8h
web-deployment-764d5dcdb-vnsrl    1/1     Running                  0               41h
web-deployment-764d5dcdb-zgbxs    0/1     ContainerStatusUnknown   1               17d
web-deployment-79bf9d8657-25x5x   0/1     ContainerStatusUnknown   1               21d
web-deployment-79bf9d8657-29dz6   0/1     ContainerStatusUnknown   1               19d
web-deployment-79bf9d8657-2v7j7   0/1     ContainerStatusUnknown   1               21d
web-deployment-79bf9d8657-464df   0/1     ContainerStatusUnknown   1               18d
web-deployment-79bf9d8657-49g2p   0/1     ContainerStatusUnknown   1               18d
web-deployment-79bf9d8657-gt7nl   0/1     ContainerStatusUnknown   1               21d
web-deployment-79bf9d8657-l2jvk   0/1     ContainerStatusUnknown   1               20d
web-deployment-79bf9d8657-stp5w   0/1     ContainerStatusUnknown   1               21d
web-deployment-79bf9d8657-sxtcn   0/1     ContainerStatusUnknown   1               20d
web-deployment-79bf9d8657-t6rzd   0/1     ContainerStatusUnknown   1               20d
```

# まとめ

RaspberryPi 4B 1台 + 3B 3台構成のおうちKubernetesクラスタに今流行りのmisskeyをデプロイして遊んでみました。特に、メモリが1GしかないRaspberryPi 3Bにデプロイをすることに結構無理があったように思います。4Bを買い足したいという気持ちに何度もなりましたが、今は入手性も悪いし価格も高騰しているので、なんとかして今あるもので遊べないかという試行錯誤をしてみました。

実際に運用しているリポジトリへのリンクも記載しておきます。

https://github.com/commojun/misskey-k8s

またKubernetesやmisskeyを使って面白いことができたら記事にしたいと思います。