はじめに
久しぶりに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)
- Zalando PostgreSQL Operator (image: ghcr.io/zalando/spilo-17:4.0-p2)
環境面での制約
通常はkubernetes上にホスティングしたアプリケーションは同一ホスト上でcontext-rootを別にして複数のサービスを一つのホスト名でサービスしています。
Discourseについてはcontext-root(subpath)の変更はできないため、専用のホスト名を割り当ててIngressオブジェクトを作成しています。
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://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を生成しておきます。
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オブジェクトが生成された状態になっています。
$ 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が導入できなくなっています。
DiscourseのオプションからはRedisのvolumePermissions関連の設定は変更できないので、しばらく自前でRedisを動作させる必要がありそうです。
解決すればRedisは付属のものを利用しようと思いますが、それまではそのため手動で次のような設定で動作させています。
---
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
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-pvc
namespace: discourse
spec:
accessModes: [ "ReadWriteOnce" ]
storageClassName: rook-ceph-block
resources:
requests:
storage: 10Gi
---
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のインストール専用に準備したものです。
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を設定しています。
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に読み込みます。
$ . ./k8s.envrc
設定上の注意点
いくつか気になった点があったのでまとめておきます。
テストでもhost=の右辺にIPアドレスを渡してはいけない
helmの--set host=
で渡しているところでIPアドレスを記述した場合はアイコンなどのダウンロードが正常に行われず、非表示となりました。
これはアイコンをサービスするディレクトリ名にホスト名が使われているからですが、host=の右辺をIPアドレスにしてしまうとディレクトリ名にはlocalhostが使われてしまい、その不整合からWebブラウザ上では表示することが出来なくなります。
DiscourseのPodがcrashbackoffを繰り返す場合
例えばPostgreSQLへ接続するパスワードが正しくない場合でも、kubectl logs
のログからは原因が特定できませんでした。
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のログを確認するようにしましょう。
$ 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
この結果表示されたログのメッセージが次のようになります。
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
を指定するのが良さそうです。
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の設定画面
管理者権限の追加
OIDC経由のみのログインに切り替えるとadminユーザーを含めたローカルで作成したユーザーはログインすることができなくなります。そのため事前にOIDC経由でログインしたユーザーに
以上の設定に加えてLoginタブのEnable local loginsを無効にすることで、Loginボタンを押下するとただちにOIDCサーバーにリダイレクトします。
差分の全体
この他の設定値は次のようになっています。
実際には設定変更のほぼ全てはスクリプトから行っていて、これらを手動では設定していません。
APIによる操作の自動化
Discourseのデプロイを含めたテストを繰り返すとWebブラウザでの操作は単調に感じます。
Webブラウザからの操作は裏ではAPIを叩いているので、まずWebブラウザのInspectorなどを起動した状態で操作を行い、その時のペイロードを確認する方法が効果的なようです。
これを参考に繰り返しの操作をスクリプトに置き換えていきます。
カテゴリーの自動追加
実際にカテゴリーを細かく用意したいので、curlコマンドでカテゴリー"test"を追加してみました。
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などの情報を含んでいるので、別に環境変数で渡すようにしています。
全体の設定ファイルは次のようになっています。
---
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"
環境変数の設定
本番用とテスト環境用にファイルを分けることで動作を変更させています。
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で作成していて次のようなコードになっています。
# 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経由でカテゴリを操作しようとする際、問題になりそうなのは次のような点です。
- サブカテゴリーの作成はトップレベルのカテゴリーと同様に、まずトップレベルにカテゴリーを作成する
- サブカテゴリーにしたいカテゴリーを他のトップレベルカテゴリーの下に移動させる
- APIによっては取得したカテゴリーリストにサブカテゴリーが含まれない場合がある
- 全てのカテゴリーを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に投稿された記事を確認してください。
さいごに
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/
が起点となります。
以下の操作を行う前に必ずWebページからAdmin → Backupsの設定画面を開いてください。
初期状態では/opt/bitnami/discourse/public/backups/直下は空になっていて、default/ディレクトリが作成されていません。
まずリストアを許可する(restore_allow)を有効にしてから作業を継続してください。
$ 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ユーザーなどローカルで作成したユーザーでもログインができるようになります。
#!/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を利用していました。
以前はこの状態で問題なくリストアできていて、確認した範囲では問題は発生していません。
リストアできない場合がある
取得したバックアップファイルから戻そうとしたところエラーが発生しました。
[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'
このメッセージで検索をすると、次の記事がみつかります。
同じバージョンではなく、より新しいバージョンを期待しているので、エラーになる場合があるという回答があります。
この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回目の短い停止はリストア作業時の停止期間です。
スペックはそこそこといったシステムでも20分以上かかっているので、明確にエラーだと分かるまでは放置するようにしてください。