DiscourseをHelmで導入してみた + API経由でのサーバー設定

# はじめに

久しぶりにDiscourseをKubernetesで動作させようと思っていて、今回もOpenID Connectを使ってユーザー認証を行う予定です。

以前はDiscourseの標準機能であるDiscourse SSO (現Discourse Connect)を利用しましたが、今回はPluginを使うことにしました。

また本格的に利用するためにテスト環境と本番環境の設定を一致させるための管理スクリプトも作成したので、それの説明も加えています。

> 【2024/02/19追記】Discourse v3.2.0では "backup_frequency", "maximum_backups", "min_topic_title_length" のパラメータの変更時にCSRF対策用Tokenを評価するようになったため、このスクリプトからは削除しています。site-config.yamlファイルには引き続き残していますが反映されないので注意してください。

# 環境

アップグレードを経て、次の環境で稼動しています。

* Kubernetes v1.30.4 (kubespray v2.26.0)
* Rook/Ceph v1.15.9
* [Bitnami Discourse Helm Chart 15.1.6 (Discourse v3.4.1)](https://artifacthub.io/packages/helm/bitnami/discourse)
* [Zalando PostgreSQL Operator](https://opensource.zalando.com/postgres-operator/) (image: ghcr.io/zalando/spilo-17:4.0-p2)

## 環境面での制約

通常はkubernetes上にホスティングしたアプリケーションは同一ホスト上でcontext-rootを別にして複数のサービスを一つのホスト名でサービスしています。

Discourseについてはcontext-root(subpath)の変更はできないため、専用のホスト名を割り当ててIngressオブジェクトを作成しています。

:::note info
Discourse公式のForumではcontext-rootの変更には技術的な難しさがあるので、Enterprise契約と個別のコンサルタントが必要だと回答されています。
:::

ホスト名を1つ占有するのはDiscourseの良くない点だと思いますが、REST APIで操作が完結するシステムであることを考えると製品としてはすばらしいと思います。

# 参考資料

* https://bitnami.com/stack/discourse/helm
* https://artifacthub.io/packages/helm/bitnami/discourse#prerequisites
* https://meta.discourse.org/t/discourse-openid-connect/103632
* [Discourse API - https://docs.discourse.org/](https://docs.discourse.org/)
* https://nnstt1.hatenablog.com/entry/2021/01/22/070000
* https://meta.discourse.org/t/available-settings-for-global-rate-limits-and-throttling/78612

# 設定

Bitnamiが提供するHelm Chartsは便利な場合もありますが、以前に苦労した経験があるので普段はできるだけ避けています。

今回は適当な方法が見当らなかったので、BitnamiのChartを利用することにしました。

設定上の変更点は通常はvalues.yamlファイルを編集してgitで管理していますが、今回はMakefileで設定を行っています。

## Posgresql Operator (Zaland)

次のようなYAMLファイルを実行し、PosgreSQLのPodを生成しておきます。

```yaml:
apiVersion: acid.zalan.do/v1
kind: postgresql
metadata:
  labels:
    team: discourse-team
  name: pgcluster
  namespace: discourse
spec:
  allowedSourceRanges: []
  databases:
    discoursedb: pguser
  numberOfInstances: 2
  postgresql:
    version: '16'
  resources:
    limits:
      cpu: 500m
      memory: 1500Mi
    requests:
      cpu: 100m
      memory: 100Mi
  teamId: discourse-team
  users:
    pguser:
      - superuser
      - createdb
  volume:
    size: 50Gi
    storageClass: rook-ceph-block
```

これを反映(apply -f)して次のようなPod/Serviceオブジェクトが生成された状態になっています。

```bash:
$ kcga -l team=discourse-team
NAME              READY   STATUS    RESTARTS   AGE
pod/pgcluster-0   1/1     Running   0          4d16h
pod/pgcluster-1   1/1     Running   0          4d16h

NAME                     TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
service/pgcluster        ClusterIP   10.233.55.41    <none>        5432/TCP   4d16h
service/pgcluster-repl   ClusterIP   10.233.23.108   <none>        5432/TCP   4d16h

NAME                         READY   AGE
statefulset.apps/pgcluster   2/2     4d16h

NAME                                 TEAM             VERSION   PODS   VOLUME   CPU-REQUEST   MEMORY-REQUEST  
 AGE     STATUS
postgresql.acid.zalan.do/pgcluster   discourse-team   16        2      50Gi     100m          100Mi           
 4d16h   Running
```

パスワードは導入時に決定されるので、後述するbash functionを利用しています。

## Redisの導入

Redisは動けば何でも良いのですが、これまで自動的に導入されてきたBitnami/Redisが導入できなくなっています。

https://github.com/bitnami/charts/issues/14327

DiscourseのオプションからはRedisのvolumePermissions関連の設定は変更できないので、しばらく自前でRedisを動作させる必要がありそうです。

解決すればRedisは付属のものを利用しようと思いますが、それまではそのため手動で次のような設定で動作させています。

```yaml:redis-svc.yaml
---
apiVersion: v1
kind: Service
metadata:
  namespace: discourse
  name: redis-svc
spec:
  type: ClusterIP
  ports:
    - port: 6379
      targetPort: 6379
      protocol: TCP
  selector:
    app: redis
    tier: webapp
```

```yaml:redis-pvc.yaml
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: redis-pvc
  namespace: discourse
spec:
  accessModes: [ "ReadWriteOnce" ]
  storageClassName: rook-ceph-block
  resources:
    requests:
      storage: 10Gi
```

```yaml:redis-deploy.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: discourse
  name: redis
  labels:
    app: redis
    tier: webapp
spec:
  replicas: 1
  selector:
    matchLabels:
      app: redis
      tier: webapp
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: redis
        tier: webapp
    spec:
      containers:
      - name: redis
        image: redis:7.2.6
        ports:
        - containerPort: 6379
          name: redis
        volumeMounts:
        - name: redis-pvc
          mountPath: /data
      volumes:
      - name: redis-pvc
        persistentVolumeClaim:
          claimName: redis-pvc
```

この変更に伴なってMakefileも変更しています。

## Makefile

普段はHelm用に共通のMakefileを使い回していますが、今回のMakefileはDiscourseのインストール専用に準備したものです。

```makefile:Makefile

APPNAME = stage-discourse
NS = discourse

CHART_VERSION ?= 15.1.6

DISCOURSE_OPTIONS = --set auth.email=admin@example.com \
        --set auth.username=admin \            
        --set host=discourse.example.com \                
        --set ingress.enabled=true \           
        --set ingress.ingressClassName=nginx \
        --set ingress.hostname=discourse.example.com \
        --set smtp.enabled=true \
        --set smtp.host=smtp.example.com \
        --set smtp.port=25 \
        --set postgresql.enabled=false \
        --set externalDatabase.host=pgcluster \
        --set externalDatabase.port=5432 \
        --set externalDatabase.user=pguser \
        --set externalDatabase.password=.... \
        --set externalDatabase.database=discoursedb \
        --set externalDatabase.postgresUser=postgres \
        --set externalDatabase.postgresPassword=.... \
        --set redis.enabled=false \ 
        --set externalRedis.host=redis-svc \
        --set externalRedis.port=6379 \
        --set global.storageClass=rook-cephfs \
        --set persistence.accessModes[0]=ReadWriteMany \
        --set volumePermissions.enabled=false \ 
        --set discourse.resourcesPreset=none \
        --set sidekiq.resourcesPreset=none \
        --set discourse.livenessProbe.enabled=false \
        --set discourse.readinessProbe.enabled=false \ 
        --set discourse.plugins[0]=https://github.com/discourse/discourse-openid-connect

.PHONY: all
all:
        @echo Please see other tasks of the Makefile.

.PHONY: create
create:
        sudo kubectl create ns $(NS)

.PHONY: install
install:
        sudo helm install -n $(NS) $(APPNAME) $(DISCOURSE_OPTIONS) \
                oci://registry-1.docker.io/bitnamicharts/discourse --version $(CHART_VERSION)

.PHONY: upgrade
upgrade:
        sudo helm upgrade -n $(NS) $(APPNAME) $(DISCOURSE_OPTIONS) \
                --set replicaCount=1,discourse.skipInstall=true \
                oci://registry-1.docker.io/bitnamicharts/discourse --version $(CHART_VERSION)

.PHONY: delete
delete:
        sudo helm delete -n $(NS) $(APPNAME)
```

これを利用して``make install``などで操作を行っています。

## k8s.envrc

この他にkubernetes用のaliasなどを設定するファイルに次のようなshell-functionを設定しています。

```bash:k8s.envrc(bash用)
test -f ~/k8s.envrc && . ~/k8s.envrc
ns="discourse"
chkc "${ns}"

DISCOURSE_BACKUP_DIRPATH=/opt/bitnami/discourse/public/backups/default/

function discourse-password() {
    export DISCOURSE_PASSWORD=$(kcg secret stage-discourse-discourse -o jsonpath="{.data.discourse-password}" | base64 -d)
    echo User:     admin
    echo Password: $DISCOURSE_PASSWORD
}

function discourse-podname() {
  kcg pod -l app.kubernetes.io/name=discourse -o jsonpath='{.items[0].metadata.name}'
}

function discourse-pod() {
  echo $(discourse-podname)
}

function discourse-check-backups() {
  echo podname: "$(discourse-podname)"
  echo backup-dir: "${DISCOURSE_BACKUP_DIRPATH}"
  kc exec -it $(discourse-podname) -- ls "${DISCOURSE_BACKUP_DIRPATH}"
}

function discourse-bash() {
  kc exec -it $(kcg pod -l app.kubernetes.io/name=discourse -o jsonpath='{.items[0].metadata.name}') -- bash
}

function discourse-logsf() {
  kc logs -f $(kcg pod -l app.kubernetes.io/name=discourse -o jsonpath='{.items[0].metadata.name}')
}

function discourse-delete-all-pvc() {
  for pvc in $(kcg pvc -o jsonpath='{.items[*].metadata.name}')
  do
    kc delete pvc $pvc
  done
}

function passwd-postgres() {
  kcg secret postgres.pgcluster.credentials.postgresql.acid.zalan.do -o jsonpath='{.data.password}' | base64 -d
  echo ""
}

function passwd-pguser() {
  kcg secret pguser.pgcluster.credentials.postgresql.acid.zalan.do -o jsonpath='{.data.password}' | base64 -d
  echo ""
}

function postgres-bash() {
  echo "$ psql -h pgcluster -U pguser "
  echo "pguser's password: $(kcg secret pguser.pgcluster.credentials.postgresql.acid.zalan.do -o jsonpath='{.data.password}' | base64 -d)"
  kc exec -it pgcluster-0 -- bash
}
```

sourceや**.**(ピリオド)シェルコマンドで現在のbashで実行し、shellのcontextに読み込みます。

```bash:
$ . ./k8s.envrc
```

## 設定上の注意点

いくつか気になった点があったのでまとめておきます。

### テストでもhost=の右辺にIPアドレスを渡してはいけない

helmの``--set host=``で渡しているところでIPアドレスを記述した場合はアイコンなどのダウンロードが正常に行われず、非表示となりました。

これはアイコンをサービスするディレクトリ名にホスト名が使われているからですが、host=の右辺をIPアドレスにしてしまうとディレクトリ名には**localhost**が使われてしまい、その不整合からWebブラウザ上では表示することが出来なくなります。

### DiscourseのPodがcrashbackoffを繰り返す場合

例えばPostgreSQLへ接続するパスワードが正しくない場合でも、``kubectl logs``のログからは原因が特定できませんでした。

```text:discourse podのログメッセージ
discourse 03:19:57.02 INFO  ==> Ensuring Discourse directories exist
discourse 03:19:57.03 INFO  ==> Trying to connect to the database server
discourse 03:19:57.07 INFO  ==> Populating database
```

この表示のまま、一定時間経過するとReadnessProbeによって再起動されたり、これを無効化してもCrashBackoffによる再起動を繰り返す現象が発生しました。

Database関連の処理で停止している場合には、PostgreSQLのログを確認するようにしましょう。

```bash:
$ sudo kubectl -n discourse exec -it pgcluster-0 -- bash
$ root@pgcluster-0:/home/postgres# cd pgdata/pgroot/pg_log/
$ root@pgcluster-0:/home/postgres/pgdata/pgroot/pg_log# ls -altr
total 1140
drwxr-xr-x 4 postgres postgres    4096 Nov 20 01:55 ..
-rw-r--r-- 1 postgres postgres       0 Nov 20 01:55 postgresql-0.csv
-rw-r--r-- 1 postgres postgres       0 Nov 20 01:55 postgresql-1.csv
-rw-r--r-- 1 postgres postgres       0 Nov 20 01:55 postgresql-2.csv
-rw-r--r-- 1 postgres postgres       0 Nov 20 01:55 postgresql-5.csv
-rw-r--r-- 1 postgres postgres       0 Nov 20 01:55 postgresql-4.csv
-rw-r--r-- 1 postgres postgres       0 Nov 20 01:55 postgresql-6.csv
-rw-r--r-- 1 postgres postgres       0 Nov 20 01:55 postgresql-7.csv
drwxr-xr-x 2 postgres postgres    4096 Nov 20 01:55 .
-rw-r--r-- 1 postgres postgres     204 Nov 20 01:55 postgresql-3.log
-rw-r--r-- 1 postgres postgres 1154809 Nov 20 03:20 postgresql-3.csv
$ root@pgcluster-0:/home/postgres/pgdata/pgroot/pg_log# tail postgresql-3.csv
```

この結果表示されたログのメッセージが次のようになります。

```text:
2024-11-20 03:17:15.921 UTC,"pguser","discoursedb",1554,"10.233.78.65:50198",673d54bb.612,2,"authentication",2024-11-20 03:17:15 UTC,9/1601,0,FATAL,28000,"pg_hba.conf rejects connection for host ""10.233.78.65"", user ""pguser"", database ""discoursedb"", no encryption",,,,,,,,,"","client backend",,0
```

これでパスワードを見直して再度インストールを行って解決しました。

### 最新のhelm chartではOOMKilledが発生する

``sudo helm repo update``でChartを更新すると、導入時に``discourse.resources``を設定するよう警告されます。

簡易的な``discourse.resourcesPreset``を利用していたのですが、``small``でもエラーになります。デフォルトの``2xlarge``ではCPUのコア数が合わないのでPodが割り当てられずに停止します。

警告文では``resourcesPreset``は使わずに個別に指定するようにガイドされていますので、どの程度のキャパシティが必要か確認するため``none``を指定するのが良さそうです。

```text:初期化中のkubectl top podの出力
NAME                               CPU(cores)   MEMORY(bytes)
pgcluster-0                        7m           128Mi 
pgcluster-1                        5m           103Mi
redis-85dfb98d86-k2jf7             0m           7Mi
stage-discourse-85d6459b94-ms5j4   3096m        3050Mi
```

定常状態では1GiB以下のメモリ使用率ですが、一時的には3GiBを越えるメモリを要求しているので、これが原因でOOMKilledが発生したようです。

# 管理者権限での設定項目

次のような内容についてログイン後に設定を変更しています。

## OIDC Pluginの設定画面

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78296/5e663d65-4eb5-a595-f014-5ce09a2c94f6.png)


## 管理者権限の追加

OIDC経由のみのログインに切り替えるとadminユーザーを含めたローカルで作成したユーザーはログインすることができなくなります。そのため事前にOIDC経由でログインしたユーザーに

以上の設定に加えてLoginタブの**Enable local logins**を無効にすることで、Loginボタンを押下するとただちにOIDCサーバーにリダイレクトします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78296/c9cc8fe6-587a-593d-afc5-b22e6d09fe94.png)

## 差分の全体

この他の設定値は次のようになっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78296/60e72dbc-7f0d-470b-4208-2f34b2fc4236.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78296/96555158-44bd-0ee9-8e84-e7846e170a67.png)

実際には設定変更のほぼ全てはスクリプトから行っていて、これらを手動では設定していません。

# APIによる操作の自動化

Discourseのデプロイを含めたテストを繰り返すとWebブラウザでの操作は単調に感じます。

Webブラウザからの操作は裏ではAPIを叩いているので、まずWebブラウザのInspectorなどを起動した状態で操作を行い、その時のペイロードを確認する方法が効果的なようです。

* https://meta.discourse.org/t/discourse-rest-api-documentation/22706

これを参考に繰り返しの操作をスクリプトに置き換えていきます。

## カテゴリーの自動追加

実際にカテゴリーを細かく用意したいので、curlコマンドでカテゴリー"test"を追加してみました。

```bash:curlコマンドによるAPIへのアクセス
curl -k -X POST "https://discourse.example.com/categories" \
        -H "Content-Type: application/json" \
        -H "Api-Key: 3faca8701563aa5df673ef1fd4cbe72acf711064c5d6b8344010a62c6f95ccb6" \
        -H "Api-Username: user1" \
        -d '{"name":"test","slug":"test","color":"0088CC","text_color":"FFFFFF","permissions":{"everyone":1},"allow_badges":true,"category_setting_attributes":{},"custom_fields":{},"form_template_ids":[],"required_tag_groups":[],"topic_featured_link_allowed":true,"search_priority":0}'
```

検索するとWebブラウザの挙動をエミュレートしようとしたのかContent-Typeが*multipart/form-data*になっている例もありますが、おそらく動作しても将来的にはAPIエンドポイントの挙動としては正しく動作しない可能性がありそうです。

これで新規に登録する方法は問題ないですが、後から変更しようとするとIDをキーにしたURL(e.g., ...example.com/categories/4)を組み立てる必要があるため、後からの変更は少し面倒そうです。

親カテゴリーの追加は、IDで指定するので初期状態が分かればともかく汎用的に使うためには名前からIDを取得するロジックを構築しないと無理そうです。

このままでも簡単な設定ファイルを準備して、繰り返しcurlコマンドを実行するだけで新規追加作業は自動化できそうです。

## 管理者ユーザーの追加

特定のユーザーに対する管理者権限の剥奪・付与はそれぞれ、エンドポイント``/admin/users/$userid/revoke_admin``、``/admin/users/$userid/grant_admin``を呼び出すだけで実現できます。

付与する際には管理者のメールアドレスに送信されたリンクをクリックする必要があるので、SMTPサーバーの設定が完了している必要があります。

local loginsを無効にする場合には緊急時のために特定の管理者を管理者にしたり、local loginsそのものを一時的に有効にする方法をWEbブラウザを使わずに実現する方法を確保しておいた方がよさそうです。

## APIによる操作が必要な場面

カテゴリを操作しているとWeb UIから子カテゴリの名前を変更して親カテゴリに変更すると、一時的に操作がおかしくなりました。

APIを直接操作して問題のIDを指定して削除したところ解決しましたが、APIを叩けないとバグでUXに深刻なダメージを与える場合もありそうです。

# スクリプトによるDiscourseサーバーの設定

テスト環境と本番環境を同一にするため、簡単な設定ファイルからAPIを順次呼び出す仕組みを準備しました。

既定値があるサーバーの設定は良いのですが、カテゴリやユーザーのようにデフォルトでは存在しないオブジェクトの場合には、新規作成する場合と、既存のオブジェクトの設定を変更する場合では動作が異なるため、それらをWebブラウザのデバッグ環境を使って確認しながら作業を進めました。

## 設定ファイル (site-settings.yaml)

OIDC関連の設定はSecretなどの情報を含んでいるので、別に環境変数で渡すようにしています。

全体の設定ファイルは次のようになっています。

```json:site-setting.yaml
---
discourse:
  title: "フォーラム"
  site_description: "ここは議論や質問のためのオープンな場です。このサイトはDiscourseを利用しています。"
  short_site_description: "このサイトはDiscourseを利用しています。"
  contact_email: "admin@example.com"
  contact_url: ""
  site_contact_username: "user01"
  notification_email: "admin@example.com"
  company_name: "Example Company"
  allow_user_locale: true
  set_locale_from_accept_language_header: true
  ## enable_local_logins must be set by hand
  enable_local_logins_via_email: false
  email_editable: false
  allow_new_registrations: true
  auth_skip_create_confirm: true
  auth_overrides_email: true
  auth_overrides_username: true
  auth_overrides_name: true
  enable_system_message_replies: true
  default_trust_level: 1
  include_thumbnails_in_backups: true
  backup_frequency: 1
  maximum_backups: 10
  min_topic_title_length: 8
  openid_connect_authorize_scope: "openid profile groups email"
  openid_connect_overrides_email: true
  openid_connect_allow_association_change: false
  chat_enabled: false
  disable_system_edit_notifications: true
  disable_category_edit_notifications: true
  disable_tags_edit_notifications: true
  allow_users_to_hide_profile: false
  use_name_for_username_suggestions: false
  use_email_for_username_and_name_suggestions: true
  default_trust_level: 2
  display_name_on_posts: true
  subcategories:
    - comp:
      - comp.linux:
          topic-title: ":bookmark: comp.linux - このカテゴリーについて"
          topic-contents: "Linuxに関連する話題を扱うカテゴリー"
          color: "BF1E2E"
          slug: "linux"
  categories:
    - comp:
        topic-title: ":bookmark: comp - このカテゴリーについて"
        topic-contents: |
          コンピュータ・システムに関する議論と質問のためのカテゴリー

          このカテゴリーには以下のサブカテゴリーが存在します。
          * <a href="/c/comp/linux/">comp.linux</a>
        slug: "comp"
        color: "BF1E2E"
```

## 環境変数の設定

本番用とテスト環境用にファイルを分けることで動作を変更させています。

```text:envrc
DC_API_URL_PREFIX="https://discourse.example.com"
DC_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
DC_API_USER="user1"

export DC_API_URL_PREFIX DC_API_KEY DC_API_USER

DC_OIDC_ID="example-app"
DC_OIDC_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
DC_OIDC_URL="https://dex.example.com/.well-known/openid-configuration"

export DC_OIDC_ID DC_OIDC_SECRET DC_OIDC_URL
```

API経由で設定するために、あらかじめ管理者権限で制限のないAPIキーを発行しておきます。

## MyDiscourseクラス

Rubyで作成していて次のようなコードになっています。

```ruby:mydiscourse.rb
# coding: utf-8

require 'bundler/setup'
Bundler.require

class MyDiscourse

  DC_API_RATE_LIMIT = 55
  
  ## define constant variables by environment variables
  def self.getenv(e = "", default = "")
    ret = default
    ret = ENV[e] if ENV.has_key?(e)
    return ret
  end
  DC_API_URL_PREFIX = getenv("DC_API_URL_PREFIX", "https://example.com/dex")
  DC_API_KEY = getenv("DC_API_KEY", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
  DC_API_USER = getenv("DC_API_USER", "user")
  DC_API_CATEGORIES = "/categories"
  DC_API_GET_SUFFIX = "_and_latest"
  DC_OIDC_ID= getenv("DC_OIDC_ID", "example-app")
  DC_OIDC_SECRET= getenv("DC_OIDC_SECRET", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
  DC_OIDC_URL = getenv("DC_OIDC_URL", "https://dex.example.com/.well-known/openid-configuration")
  
  DC_API_ADMIN_ITEMS = [
    "title", "site_description", "short_site_description", "contact_email", "notification_email",
    "contact_url", "site_contact_username", "company_name", "allow_user_locale", "set_locale_from_accept_language_header",
    "enable_local_logins", "chat_enabled",
    "disable_system_edit_notifications", "disable_category_edit_notifications", "disable_tags_edit_notifications",
    "enable_local_logins_via_email", "allow_new_registrations", "auth_skip_create_confirm",
    "auth_overrides_email", "auth_overrides_username", "auth_overrides_name", "email_editable",
    "enable_system_message_replies", "default_trust_level", "include_thumbnails_in_backups",
    "openid_connect_authorize_scope", "openid_connect_overrides_email",
    "openid_connect_allow_association_change",
    "default_composer_category", "allow_users_to_hide_profile", "use_name_for_username_suggestions",
    "use_email_for_username_and_name_suggestions", "default_trust_level", "display_name_on_posts"
  ]
  DC_API_ADMINURL_PREFIX = "/admin/site_settings"
  DC_OIDC_ITEMS = {
    "openid_connect_client_id" => DC_OIDC_ID,
    "openid_connect_discovery_document" => DC_OIDC_URL,
    "openid_connect_client_secret" => DC_OIDC_SECRET
  }

  DC_CATEGORY_COLOR_DEFAULT = "0088CC"
  DC_CATEGORY_COLOR_ADMIN = "F7941D"
  DC_CATEGORY_COLOR_EDUCATION = "12A89D"
  DC_CATEGORY_COLOR_SYSTEM = "BF1E2E"

  DC_CATEGORY_UPDATE_ITEMS = [
    "name", "color", "slug"
  ]

  def initialize
    @http = HTTPClient.new
    @api_headers = {
      "Content-Type" => "application/json",
      "Api-Key" => DC_API_KEY,
      "Api-Username" => DC_API_USER
    }
    @api_call_count = 0
  end

  def get_api(url)
    @api_call_count += 1
    sleep 60 and @api_call_count = 0 if @api_call_count > DC_API_RATE_LIMIT
    STDERR.puts "get_api: #{url}"
    res = @http.get(url, header: @api_headers)
    ret = JSON.parse(res.body) unless res.body.empty?
    throw ret if ret.class == Hash and ret.has_key?("errors")
    return ret
  end

  def put_api(url, data)
    @api_call_count += 1
    sleep 60 and @api_call_count = 0 if @api_call_count > DC_API_RATE_LIMIT
    STDERR.puts "put_api: #{url}"
    res = @http.put(url, body: data.to_json, header: @api_headers)
    ret = JSON.parse(res.body) unless res.body.empty?
    throw ret if ret.class == Hash and ret.has_key?("errors")
    return ret
  end

  def post_api(url, data)
    @api_call_count += 1
    sleep 60 and @api_call_count = 0 if @api_call_count > DC_API_RATE_LIMIT
    STDERR.puts "post_api: #{url}"
    res = @http.post(url, body: data.to_json, header: @api_headers)
    ret = JSON.parse(res.body) unless res.body.empty?
    throw ret if ret.class == Hash and ret.has_key?("errors")
    return ret
  end

  def patch_api(url, data)
    @api_call_count += 1
    sleep 60 and @api_call_count = 0 if @api_call_count > DC_API_RATE_LIMIT
    STDERR.puts "patch_api: #{url}"
    res = @http.patch(url, body: data.to_json, header: @api_headers)
    ret = JSON.parse(res.body) unless res.body.empty?
    throw ret if ret.class == Hash and ret.has_key?("errors")
    return ret
  end

  def delete_api(url)
    @api_call_count += 1
    sleep 60 and @api_call_count = 0 if @api_call_count > DC_API_RATE_LIMIT
    STDERR.puts "delete_api: #{url}"
    res = @http.delete(url, header: @api_headers)
    ret = JSON.parse(res.body) unless res.body.empty?
    throw ret if ret.class == Hash and ret.has_key?("errors")
    return ret
  end

  def init_server(params)
    params.each do |key, value|
      if DC_API_ADMIN_ITEMS.include?(key)
        url = URI(DC_API_URL_PREFIX + DC_API_ADMINURL_PREFIX + "/" + key)
        data = { key => value }
        response = self.put_api(url, data)
      end
    end
  end

  def init_oidc
    DC_OIDC_ITEMS.each do |key,value|
      url = URI(DC_API_URL_PREFIX + DC_API_ADMINURL_PREFIX + "/" + key)
      data = { key => value }
      response = self.put_api(url, data)
    end
  end
```

設定ファイルには何でも記述できるようにしたかったので、スクリプト側でどの項目を反映対象とするかチェックするロジックにしています。

そのためスクリプト上の定数定義がへんな感じになっていますが、設定ファイルを読み込んで値があれば反映するような仕組みになっています。

## カテゴリの設定について

実際にはMyDiscourseクラスはカテゴリ作成・変更用のメソッドも含んでいます。

いまのところ問題なく動作していますが、まだデバッグ・リファクタリングが必要な状態です。

API経由でカテゴリを操作しようとする際、問題になりそうなのは次のような点です。

1. サブカテゴリーの作成はトップレベルのカテゴリーと同様に、まずトップレベルにカテゴリーを作成する
1. サブカテゴリーにしたいカテゴリーを他のトップレベルカテゴリーの下に移動させる
1. APIによっては取得したカテゴリーリストにサブカテゴリーが含まれない場合がある
1. 全てのカテゴリーをAPI経由で取得した場合、**parent_category_id**の値をみてサブカテゴリーか見分ける必要がある

そのため操作する際にはカテゴリーに移動についてはほぼ考慮していません。

### include_subcategories=trueオプションが動作しない

最新版の3.2.0では改善されているのかもしれませんが、現状の3.1.3ではAPIにある``/categories.json``エンドポイントに対する**include_subcategories=true**オプションを指定してもレスポンスにはサブカテゴリーは含まれていませんでした。

サブカテゴリーまで含めた全ての情報を取得する確実な方法は``/cite.json``エンドポイントを利用する方法です。

### Rate Limitについて

API呼び出しには時間当りのアクセス数が制限されています。これを越えないために60回/分の制限を加えないようにカウンタとsleep呼び出しを利用しています。

デフォルトでは一般ユーザーで20回/分、管理者で60回/分の呼び出し制限があります。

詳細は[meta.discourse.orgに投稿された記事](https://meta.discourse.org/t/available-settings-for-global-rate-limits-and-throttling/78612)を確認してください。

# さいごに

RFCで標準化されているNetNewsなどのサービスがプロプライエタリなサービスに置き換わっていくのは世の中の流れかもしれませんが、寂しく感じてしまいます。

Discourseが代替としてベストかどうかは分かりませんが、ローカル・コミュニティを育てるためには何かしらのシステムを導入する必要があることは間違いなく試行錯誤しています。

オープンソースソフトウェアの持続的な発展のためには有償化とのバランスが求められることはしょうがないことだとは思いますが、出来るだけ広く有用なソフトウェアが利用できるようになることを願っています。

# リストアとアップグレードについて

残念ながらHelmを使ったアップグレードには成功したことがありません。

Makefile中にある``upgrade``タスクはPodの数を変化させるための役割しかありません。

Discourseのアップグレードはアプリケーションレベルのバックアップファイルを利用して、新バージョンのDiscourseの新規インストールとリストアで実現します。

検証のためにテスト環境に本番環境のバックアップファイルをリストアしたので、以下はその際の作業メモです。

## 環境

Discourseのバージョンは下記のとおりです。

* バックアップ元: v3.3.2 (Bitnami Helm Chart: discourse 15.0.5)
* リストア先: v3.4.1 (Bitnami Helm Chart: discourse 15.1.4)

## バックアップの取得

定期的にバックアップファイルは取得しているので、Adminページからダウンロードしておきます。

APIキーを利用している前提ですが、もしadminユーザー、GlobalレベルのAPIキーを生成していなければ、まず生成してからバックアップを取得するようにしてください。

## リストア先環境の準備

namespaceを分けても良いのですが、同じホスト名を使いたかったので、まず既存のDiscourseのHelmとnamespaceを削除しています。

次に最新のバージョンを設定したMakefileを準備して新規クラスターを構築します。

ここで紹介している設定ではパスワードが毎回ランダムに設定されますが、面倒であれば指定することもできます。

## リストア作業

あらかじめバックアップ元のAdminページからBackupファイルをダウンロードします。

リストア時にはあらかじめ ``restore_allow``設定を変更することは公式サイト等で説明されています。

バックアップファイルの転送はWebブラウザ経由ではエラーになったため、``kubectl cp``を使って、直接バックアップファイルを転送する必要がありました。

公式サイトでは``/var/discourse/``で始まるパスが掲載されていますが、このHelm環境では``/opt/bitnami/discourse/``が起点となります。

:::note warn
以下の操作を行う前に必ずWebページからAdmin → Backupsの設定画面を開いてください。
初期状態では/opt/bitnami/discourse/public/backups/直下は空になっていて、default/ディレクトリが作成されていません。

まずリストアを許可する(restore_allow)を有効にしてから作業を継続してください。
:::


```bash:
$ sudo kubectl -n discourse cp backups/discourse-2024-11-18-033044-v20230823100627.tar.gz stage-discourse-6769687c76-d4vms:/opt/bitnami/discourse/public/backups/default/
```

## リストア後の設定作業

本番環境では全てのユーザーをOIDCで認証するよう強制しています。

この設定はローカルで設定されているadminユーザーが無効化されることになるため、OIDCの利用が必須になっているのですが、リストア先はホスト名が異なるためリダイレクトURLなどのOIDC関連の設定は上書きが必須です。

本番環境で利用していたAPIキーを使って、REST API経由でOIDCを有効にして、OIDC経由で認証可能なユーザーか、ローカルユーザー認証を有効にするなどの変更を行っています。

例えば先ほどの``MyDiscourse``クラスを利用すると次のようなスクリプトでadminユーザーなどローカルで作成したユーザーでもログインができるようになります。

```ruby:
#!/usr/local/bin/ruby
#
## load gems
require 'bundler/setup'
Bundler.require

require './lib/mydiscourse'

discourse = MyDiscourse.new()
discourse.init_server({ "enable_local_logins" => true })
```

## アップグレード時のPostgreSQLのバージョンについて

今回の作業を本番環境で繰り返せば問題なくアップグレードできることは分かったのですが、PostgreSQLの**16**を利用していました。

以前はこの状態で問題なくリストアできていて、確認した範囲では問題は発生していません。

## リストアできない場合がある

取得したバックアップファイルから戻そうとしたところエラーが発生しました。

```text:リストア時のログメッセージ
[2024-11-21 05:32:29] ERROR:  unrecognized configuration parameter "transaction_timeout"
[2024-11-21 05:32:29] EXCEPTION: psql failed: ERROR:  unrecognized configuration parameter "transaction_timeout"

[2024-11-21 05:32:29] /opt/bitnami/discourse/lib/backup_restore/database_restorer.rb:92:in `restore_dump'
```
このメッセージで検索をすると、次の記事がみつかります。

https://stackoverflow.com/questions/79058325/why-does-pg-dump-include-transaction-timeout-if-psql-doesnt-understand-it

同じバージョンではなく、より新しいバージョンを期待しているので、エラーになる場合があるという回答があります。

この``transaction_timeout``自体は、version 17で導入予定となっていて、バックアップファイルを展開して確認するとpg_dumpのバージョンは17.0で、サーバーは16.3でした。

まだZalandoのPostgreSQL Operatorが16までの対応になっていて、いまのところDiscourseの推奨バージョンも16です。

ワークアラウンドは、tar.gzファイルを作り直してdump.sqlファイルから該当行を削除すれば適用することができます。

この時にtar.gzファイルの名前から元のdiscourseのバージョンを把握するので、先頭の文字列は任意で変更できますが、日付から始まる数値部分を変更するとエラーになります。

確認した範囲では特に問題はなく、リストアは無事に完了しました。

## リストア先のAPIキー設定

基本的には元の設定を引き継げるので、APIを利用するアプリケーションはそのまま接続できます。

反対にあらかじめリストア先で取得したAPIキーはバックアップから上書きされて消えてしまうため注意が必要です。

## リストア前のインストールに時間がかかる

新しいバージョンのDiscourseをインストールしようしたタイミングで、25分程度の時間がかかりました。

DiscourseのPodが数回再起動したり、Readness Probeに失敗したり、処理がまったく勧まなくなったりログをみていると失敗したのではないかと不安になりますが、そのまま放置しておけば無事に終わるはずです。

下記のグラフはuptime kumaで記録した様子です。2回目の短い停止はリストア作業時の停止期間です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78296/b4840b15-3373-435e-8816-ad1cec744d13.png)

スペックはそこそこといったシステムでも20分以上かかっているので、明確にエラーだと分かるまでは放置するようにしてください。