LoginSignup
0
0

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

Last updated at Posted at 2024-02-04

はじめに

久しぶりに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上にホスティングしたアプリケーションは同一ホスト上でcontext-rootを別にして複数のサービスを一つのホスト名でサービスしていますが、Discourseについては専用のホスト名を割り当ててIngressオブジェクトを作成しています。

DiscourseはForumへの投稿でcontext-rootの変更には技術的な難しさがあるので、Enterprise契約と個別のコンサルタントが必要だとコメントしています。

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

参考資料

設定

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: '15'
  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   15        2      50Gi     100m          100Mi           
 4d16h   Running

Makefile

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

Makefile
APPNAME = stage-discourse
NS = discourse

DISCOURSE_OPTIONS = --set auth.email=admin@example.com \
            --set auth.username=admin \
            --set host=discourse.example.com \
            --set smtp.enabled=true \
            --set smtp.host=smtp.example.com \
            --set smtp.port=25 \
            --set global.storageClass=rook-cephfs \
            --set persistence.accessModes[0]=ReadWriteMany \
            --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

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

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

本番環境ではPostgreSQLをOperatorから導入しているので、次のようなオプションを利用しています。

本番用のオプション設定
APPNAME = prod-discourse
NS = discourse

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=3f08238ac0bae709808316f43b303092 \
            --set externalDatabase.database=discoursedb \
            --set externalDatabase.postgresUser=postgres \
            --set externalDatabase.postgresPassword=8f0d9366ae5342d20ce342da55b4b5be \
            --set global.storageClass=rook-cephfs \
            --set persistence.accessModes[0]=ReadWriteMany \
            --set persistence.size=100Gi \
            --set discourse.plugins[0]=https://github.com/discourse/discourse-openid-connect

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

k8s.envrc

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

k8s.envrc(bash用)
#test -f ~/k8s.envrc && . ~/k8s.envrc
ns="discourse"
# k8s-change-namespace "${ns}"
alias kubectl="sudo kubectl -n ${ns}"
alias kc="kubectl"
alias kcg="kc get"
alias kcga="kcg all"

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-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='{.d
ata.password}' | base64 -d)"
  kc exec -it pgcluster-0 -- bash
}

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

$ . ./k8s.envrc

設定上の注意点

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

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

管理者権限での設定項目

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

OIDC Pluginの設定画面

image.png

管理者権限の追加

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

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

image.png

差分の全体

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

image.png
image.png

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

APIによる操作の自動化

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

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

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

カテゴリーの自動追加

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

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などの情報を含んでいるので、別に環境変数で渡すようにしています。

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

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"

環境変数の設定

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

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で作成していて次のようなコードになっています。

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. サブカテゴリーの作成はトップレベルのカテゴリーと同様に、まずトップレベルにカテゴリーを作成する
  2. サブカテゴリーにしたいカテゴリーを他のトップレベルカテゴリーの下に移動させる
  3. APIによっては取得したカテゴリーリストにサブカテゴリーが含まれない場合がある
  4. 全てのカテゴリーを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が代替としてベストかどうかは分かりませんが、ローカル・コミュニティを育てるためには何かしらのシステムを導入する必要があることは間違いなく試行錯誤しています。

オープンソースソフトウェアの持続的な発展のためには有償化とのバランスが求められることはしょうがないことだとは思いますが、

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0