Installing on Kubernetes

Yes

Whatever ENV you specify when you finally do you docker run on the image … will take.

And doing those things at every boot of the container is reasonable?

Some people like this pattern, I do not, that is why rails introduces some super fancy locking around migrations to ensure they never run concurrently when a cluster of 20 app try to run migrations at the same time.

Some people just boot the same image and run the migrations in some out-of-band process.

But each container should still rake assets:precompile at least once?

Depends on how do you host those.

If you have a NFS share of some sort (like AWS EFS) or if you upload those to some object storage service that can only be done on bootstrap.

ご丁寧な回答をいただき、ありがとうございます。これを正しく行う方法について、いくつか追加の質問があります。

コンテナのビルドに必要な情報のみを記述した、最小限の app.yaml を作成しようとしています。そこに含まれる情報の一部(ボリュームのマウントやポートのマッピングなど)は、明らかにコンテナの実行時に使用されるものだとわかります。しかし、環境変数については確信が持てません。とりあえず試してみようと思いますが、これらの環境変数はコンテナのビルド時(Dockerfile へ何らかの方法で注入されるなど)に使用されるのでしょうか、それともコンテナの実行時のみでしょうか?後者であれば、適切な k8s 設定ファイルに含めるようにします。

次に、ここにはプライベートなコンテナレジストリにイメージをプッシュするという話が出ていましたが、これは必須でしょうか。言い換えれば、ビルドされたイメージには、Docker Hub のようなパブリックなリポジトリに公開すべきでない機密情報が含まれているのでしょうか?(私たちはまだプライベートなコンテナレジストリを持っていませんし、設置したくありません。)

最後に、作成されるコンテナの名前を制御する app.yaml の設定はありますか?これは単なる仕上げの問題ですが、あればうれしいです :slight_smile:

ご協力いただき、ありがとうございます!(古いスレッドを掘り起こしてすみません。Kubernetes 上で Discourse をインストールする方法で Google 検索した際、これが最初のヒットでした。)

@Geoffrey_Challen Discourse リポジトリとプラグインを使用してイメージを作成し、Ruby Gems やその他の依存関係をインストールして、レジストリ(DockerHub など)にプッシュすることができます。このリポジトリは環境に依存しないものであり、公開することも可能です(プライベートなプラグインなどを含まない限り)。このベースイメージは、ステージング環境や本番環境、さらには同じプラグインを使用する異なるプロジェクトでも利用できます。

ただし、アセットのプリコンパイル、データベースマイグレーション、SSL 証明書の生成などの手順は、最終的なイメージを生成するために、ターゲットマシン上で実行する必要があります。

これを Kubernetes クラスタにどう組み込むかはっきりとはわかりません。私は保守的なアプローチを選び、Discourse チームの公式ガイドに基づいて使用し、2 つのステップに分けています。

この部分は少し理解できません。これらは必要に応じてコンテナ内で自動的に実行されないのでしょうか?私はこれをクラウドにプッシュするだけで、マシンへのシェルアクセスを取得する必要がまったくないことを願っています。Discourse の Docker コンテナにログインする必要がほとんど(あるいは全く)ないのと同じように。

コンテナを明示的に指定する必要はありません。私が言いたいのは、CI パイプラインなどで生成されたプリコンパイル済みのイメージをそのまま使用することはできないということです。なぜなら、これはデータベースが存在するターゲットマシンで実行する必要があるからです(これは自動化可能ですが、k8s ではまだ実装していません。Ansible では実装しました)。

ああ、なるほど。私はデータベースをコンテナに含めるために「オールインワン」テンプレートを使用しています。これは、10〜1000人の学生で構成されるクラスをサポートするという私たちのユースケースに適しています。少なくとも、私のクラスではこの設定でオールインワンは問題なく機能しています。つまり、データベースはコンテナ内部にあります。

しかし、いずれにせよ、Discourseはコンテナの起動時にデータベースのマイグレーションやその他のセットアップステップを実行しないのでしょうか?

データベースが本当にコンテナ内にあると確信していますか?それとも、RDBMS(この場合は PostgreSQL)のことでしょうか?サポートされているインストール方法では、データベースはコンテナ外(これが期待される動作です)にあり、ボリュームをコンテナ内からホスト(外部)にマッピングしています。さらに、コンテナを再構築するとコンテナが再作成され、すべてのデータが失われます。

もし本当にコンテナ内にある場合、公式のインストールに基づいてアップグレードする方法は正確にはわかりません。launcher スクリプトは、再構築時にコンテナを複数回作成・破棄するようであり(--rm フラグで実行されるため、コンテナが停止するとデータベースを含むすべてのデータが失われます)。

再構築の方法を変更する試みは行っていませんが、コンテナを再作成することなく、すべてをコンテナ内で実行するように変更できると仮定すると、そのコンテナをレジストリにプッシュできるはずです(シークレットが含まれるため、必ずプライベートにしてください)。ただし、このアプローチはいくつかの理由(前述のものも含む)から推奨しません。

標準インストールでは、コンテナ内に nginx、Rails、Postgres、Redis が含まれます。Postgres と Redis のデータには外部ボリュームが使用されます。再構築やアップグレード時に破棄されることはありません。

はい、彼がデータベースがコンテナ内にあると言っていたのが奇妙だと気づきました。標準的なインストール方法が変更されたか、データベース自体ではなく PostgreSQL を意味しているのでしょうか。

いいえ、マイグレーションとアセットコンパイルの手順は、プラグインの解決後に ./launcher bootstrap フェーズで実行されます。その後、コンテナは必要に応じて何度でも再起動できますし、Web プロセスを複数のマシンに分散することも可能です。


イメージとして、セットアップは以下のようなものになるはずです:

  • 公式ビルドにはすでに Discourse のベースイメージが含まれています。
    • さらに、独自の Docker を搭載した discourse_docker 用のコンテナも必要になります。
  • クラスター内にプライベートレジストリを構築します。
  • app.yml の内容を格納した ConfigMap を作成します。
  • VM ベースのノード(Docker ソケットへのアクセスなし)上でネストされた Docker を使用して ./launcher bootstrap を実行する Job を実行し、生成されたイメージを名前を変更してプライベートレジストリにプッシュします(latest ではなく、タイムスタンプベースのラベルを使用します)(local_discourse はここでの名前として適切ではありません)。その後、デプロイメントを新しいラベルにロールアウトします。
    • うわあ、アップグレード Job にはこれだけの権限が必要になるんですね。

PostgreSQL はコンテナ内で動作します。標準のインストールテンプレートを使用する場合、データはコンテナ外に保存されますが、データベースサーバー自体はコンテナ内で動作します。Redis も同様です。私が「データベースがコンテナ内で動作する」と言うとき、データベースファイルがコンテナ外に存在していても、データベースサーバーのことを指しています。(データベースファイルは「動作」しないため、私の表現は明確だと考えていましたが、明らかに十分ではなかったようです :slight_smile:。)

PS:実際には、Docker でそのディレクトリをバインドマウントするように設定しない限り、データが必ずしもコンテナ外に保存されるわけではありません。私はブートストラップ時にこれをスキップできましたが、おそらく良い考えではありません。なぜなら、その場合、コンテナの再起動後にデータベースの内容が失われてしまうからです。

今では、特に docker-compose、ランチャー・スクリプトなどに関する長いリンク先の会話を読んだ後、これがより理解できるようになりました。

私が実現したいことは以下の通りです:

  • ローカルで ./launcher bootstrap を実行し、Postgres、Redis などのすべての依存関係を含む「太った」Discourse イメージを作成する
  • そのイメージを Kubernetes にデプロイする
  • 後で ./launcher bootstrap を再実行してイメージを更新し、データを破壊せずに再デプロイする(当たり前ですが)

私の理解では、この「太った」Discourse イメージは外部サービスの依存関係を必要としないはずです。ただし、コンテナのアップグレード後にデータが生存するためには、Postgres のデータベースファイルはコンテナの外に存在する必要があります。それは問題ありません。それらのために k8s の永続ボリュームを作成できます。

ここで予想される唯一の問題があります。./launcher bootstrap の間に起こる大部分の処理は、コンテナ内に存在するファイルのみを操作します。例えば、アセットのプリコンパイルなどです。これは問題ありません。なぜなら、その結果はコンテナ内にあり、アップグレード後に生存する必要がないからです。

大きな例外はデータベースのマイグレーションです。 このステップは、ブートストラップ完了後に使用されるデータベースにアクセスする必要があります。そのため、私にはこれが、太った Discourse イメージをクラウドに簡単にデプロイする際の主要な障壁のように思えます。

@sam 氏は、お客様向けに Discourse を再デプロイする際、私が上記で説明したようなワークフローとほぼ似た方法を採用していると何度も言及しています。しかし、これが機能する理由は、彼らの Discourse イメージがクラスター上で実行されるデータベースサーバー(おそらく Redis も同様)を使用するように設定されているためではないかと推測します。これは複数のデプロイメントをサポートする上で理にかなっていますが、私がやりたいこととは少し異なります。つまり、ブートストラップ処理が生産データベースを変更できる、あるいは単にデータベースのアップグレードとマイグレーションが外部で処理されるため、データベースのマイグレーションステップが完全にスキップされるのかもしれません。@sam 氏:確認していただけますか?

とにかく、私にとっての結論は、データベースのマイグレーションを ./launcher bootstrap 中にではなく、コンテナ起動時に実行する方法を見つける必要があるということです。その場合、一つの方法は以下のようになるでしょう:

  • ローカルで ./launcher bootstrap を使用して太った Discourse コンテナを構築する。この際、後で使用されない空のローカルデータベースを指すボリュームマウントを使用する。これによりコンテナ内の設定はすべて整うが、Postgres の作業は完了しない。
  • 実際の生産データベース上でデータベースのマイグレーションステップを実行する方法を見つける。おそらく k8s のイニシャライゼーションコンテナを使用する?
  • 古い Discourse イメージを新しいものに置き換える

マルチサイト構成に興味があるかもしれません。

現在、2 つの大きな問題に直面しています。Discourse は Kubernetes に対応していないため、カスタムコードが必要です。また、Discourse チームが収益を得ている分野(多数のフォーラムのホスティング)に踏み込みつつあるため、サポートレベルは低下するでしょう。

私のアドバイスは、VM への静的スケジューリングによるマルチサイト構成を、クラスターの外側で完全に実施することです。(または、同じ Ingress を維持するために VM を指す Service Type=ExternalName を使用します。)

OK… この作業を行う方法の一つを見つけました。100% 満足しているわけではありませんが、機能しており、Postgres や Redis などを含む単一のコンテナ(ファットイメージ)で Discourse を Kubernetes にデプロイしようとしている他の人々にも魅力的かもしれません。

私のアプローチ

ブートストラッププロセスを検討した結果、残念ながらこのプロセスは 2 つの異なる種類の操作を混在させていることが明らかになりました。一つは基盤となるコンテナにのみ影響を与える操作、もう一つは主に PostgreSQL のデータファイルが存在する /shared ボリュームマウントを通じて周囲の環境に干渉する操作です。これらのステップを無理に分離しようとするのではなく、コンテナが実際にデプロイされる環境でブートストラップステップを実行する方が合理的だと考えました。

残念ながら、launcher bootstrap はコンテナを作成し Docker を使用することを前提としています。そのため、別のコンテナ内(例えばクラウド上で実行されているコンテナ内)で launcher を実行する場合、Docker-in-Docker 環境と格闘するか(可能ですがベストプラクティスとは見なされていません)、基盤となる Docker デーモンを公開する必要があります。後者のアプローチが機能するかも確信が持てません。なぜなら、これはノードのローカルファイルシステムに対するボリュームマウントとして解釈される可能性が高く、私たちのシナリオでは /shared を永続的な Kubernetes ボリュームにマウントしたいからです。もしかしたら Docker-in-Docker 方式が機能するかもしれませんが、その場合、ネストされたコンテナから外側のコンテナへ、さらにそこから永続的な Kubernetes ボリュームへと至る奇妙な三重のボリュームマウントが必要になります。それは…賢明ではないように思えます。

しかし、本質的に launcher bootstrapapp.ymltemplates 値を処理することで 1 つの大きな .yml ファイルを生成し、ブートストラッププロセスが完了した際にそれを Discourse ベースイメージに渡します。つまり、設定ファイルを抽出できれば、どのマシンでも設定を生成でき、その後クラウド内で起動するコンテナにそれを渡す方法だけを考えれば済みます。

したがって、概要として、私たちが従う手順は以下の通りです:

  1. 修正された launcher を使用してブートストラップ設定を生成する
  2. その設定を修正された Discourse ベースイメージに渡す。このイメージは pups を使用してコンテナのブートストラップを行い、その後 Discourse を起動する

ブートストラップ設定の生成

以下は、マージされた設定を STDOUT に書き出す dump コマンドをサポートするために launcher に必要な変更です:

run_dump() {
  set_template_info
  echo "$input"
}

(このコマンドは、私たちの fork である discourse_docker で利用可能です。)

したがって、最初のステップは、上記で追加された新しい launcher dump コマンドを使用して、ブートストラップ設定を作成することです:

# app の設定名を適宜置き換えてください
./launcher dump app > bootstrap.yml

初期コンテナの作成

次に、/sbin/boot 経由で起動する前に pups を実行してコンテナをブートストラップするコンテナが必要です。ベースの Discourse イメージに小さな変更を加えるために、以下の Dockerfile を使用しました:

FROM discourse/base:2.0.20191219-2109
COPY scripts/bootstrap.sh /
CMD bash bootstrap.sh

ここで scripts/bootstrap.sh の内容は以下の通りです:

cd /pups/ && /pups/bin/pups --stdin < /bootstrap/bootstrap.yml && /sbin/boot

これを geoffreychallen:discourse_base:2.0.20191219-2109 として公開しました。(ベースの Discourse Docker イメージの起動コマンドを変更することで同じことを達成できるかもしれませんが、pups に設定ファイルを読み込ませるために必要なシェルリダイレクトがうまく機能せず、苦労しました。)

Kubernetes 設定

次に、Kubernetes 設定が必要です。私の設定は以下の通りです:

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: kotlin-forum-pvc
  namespace: ikp
spec:
  storageClassName: rook-ceph-block
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 64Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: kotlin-forum-deployment
  namespace: ikp
spec:
  replicas: 0
  selector:
    matchLabels:
      app: kotlin-forum
  template:
    metadata:
      labels:
        app: kotlin-forum
    spec:
      volumes:
      - name: kotlin-forum
        persistentVolumeClaim:
          claimName: kotlin-forum-pvc
      - name: bootstrap
        configMap:
          name: kotlin-forum-bootstrap
      containers:
      - name: kotlin-forum
        image: geoffreychallen/discourse_base:2.0.20191219-2109
        imagePullPolicy: Always
        volumeMounts:
        - name: kotlin-forum
          mountPath: /shared/
        - name: bootstrap
          mountPath: /bootstrap/
        ports:
        - containerPort: 80
        env:
        - name: TZ
          value: "America/Chicago"
        - name: LANG
          value: en_US.UTF-8
        - name: RAILS_ENV
          value: production
        - name: UNICORN_WORKERS
          value: "3"
        - name: UNICORN_SIDEKIQS
          value: "1"
        - name: RUBY_GLOBAL_METHOD_CACHE_SIZE
          value: "131072"
        - name: RUBY_GC_HEAP_GROWTH_MAX_SLOTS
          value: "40000"
        - name: RUBY_GC_HEAP_INIT_SLOTS
          value: "400000"
        - name: RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR
          value: "1.5"
        - name: DISCOURSE_DB_SOCKET
          value: /var/run/postgresql
        - name: DISCOURSE_DEFAULT_LOCALE
          value: en
        - name: DISCOURSE_HOSTNAME
          value: kotlin-forum.cs.illinois.edu
        - name: DISCOURSE_DEVELOPER_EMAILS
          value: challen@illinois.edu
        - name: DISCOURSE_SMTP_ADDRESS
          value: outbound-relays.techservices.illinois.edu
        - name: DISCOURSE_SMTP_PORT
          value: "25"
---
apiVersion: v1
kind: Service
metadata:
  name: kotlin-forum
  namespace: ikp
spec:
  type: NodePort
  ports:
  - name: http
    port: 80
    targetPort: 80
  selector:
    app: kotlin-forum
---
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  namespace: ikp
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
  name: kotlin-forum-ingress
spec:
  rules:
  - host: kotlin-forum.cs.illinois.edu
    http:
      paths:
      - backend:
          serviceName: kotlin-forum
          servicePort: 80

あなたの環境では異なるでしょう。私は上流で HTTPS を終端しているため、Ingress 設定に修正を加えています。また、すべての設定を 1 つのファイルにまとめ、反復処理中に動作しない部分を削除し、次に kubectl create -f を実行した際に Kubernetes が重複をスキップするようにしています。また、デプロイメントが設定されるとすぐに起動しないように replicas: 0 に設定している点にも注意してください。これは、完了させるべき追加の設定が 1 つあるためです。

環境変数のリストは、launcher start によってコンテナに渡されているものからコピーしました。これらすべてが必要かどうかはわかりませんし、あなたの設定によっては他の変数が不足している可能性もあります。YMMV です。

コンテナにマッピングされている 2 つのボリュームに注意してください。最初のものは PostgreSQL 用で、ポッドの再起動後も生存する永続ボリュームとして設定されています。2 つ目は、以下のように作成された設定マッピングです:

kubectl create configmap kotlin-forum-bootstrap --from-file=bootstrap.yml=<path/to/bootstrap.yml>

ここで kotlin-forum-bootstrap はあなたの Kubernetes 設定と一致している必要があり、path/to/bootstrap.yml は上記の launcher dump を使用して作成した bootstrap.yml ファイルへのパスです。

configmap が設定されれば、デプロイメントをレプリカ数 1 にスケールして、Discourse が launcher bootstrap が実行するのと同じブートストラッププロセスを開始し、稼働する様子を確認できるはずです。これには数分かかります。これが完了すると、Discourse のインストールが起動します。

その他の設定事項

これを(少なくとも現時点では)完全に設定するまでに気づいたいくつかの注意点があります:

  • 上流のプロキシは、X-Forwarded-ForX-Forwarded-ProtoX-Forwarded-Port の両方を含む X-Forwarded ヘッダーを転送する必要があります。これを怠ると、Google ログインやおそらく他のログインプロバイダーを使用しようとした際に奇妙な認証エラーが発生します。
  • nginx イングレスコントローラーは、グローバル設定マップで use-forwarded-headers を設定することでヘッダーを渡すように構成されている必要があります。これは正しい設定を見つけるまでに時間がかかりました。少なくとも数回は間違った設定マップを編集してしまい、設定マップが変更されたらイングレスコンテナが再起動すると期待していましたが、実際にはそうなりませんでした。

更新

デプロイされたインストールを更新するには、新しい bootstrap.yml ファイルを再生成し、設定マップを更新してから、コンテナを再起動します(最も簡単なのは、レプリカ数を 0 にして、その後 1 に戻すことです)。

ブートストラップがコンテナの構築前に実行されるため、多少のダウンタイムが発生します。しかし、設定を更新したりベースイメージを変更したりする必要がある場合、これは避けられないように思えます。launcher rebuild は「停止;ブートストラップ;起動」としてドキュメント化されており、つまりブートストラッププロセスは launcher スクリプトを使用して実行された場合でもダウンタイムを引き起こします。

コメント

もし launcher スクリプトが、(a) オフラインで実行可能でコンテナ内のファイルにのみ影響を与えるブートストラップステップと、(b) データベースやコンテナ外の他の状態にアクセスまたは変更を必要とするブートストラップステップをより明確に分離していたら、このファットコンテナによる Discourse デプロイパターンははるかにサポートしやすかったでしょう。上記のアプローチは少しフラストレーションが溜まります。なぜなら、JS の uglification やアセットのミニフィケーションなど、以前のデプロイメントで実行できたことがすべて表示されるからです…しかし、それらはデータベースへのアクセスなしには実行できないデータベースマイグレーションなどの他のことと混在しすぎています。一時的に templates/postgres.yml のステップのみを実行するコンテナを作成しようと考えましたが、その後、データベースマイグレーションが Web テンプレートによって行われていることに気づき、プラグインのことを考え、結局諦めました:slight_smile。

より良い分離があれば、ファットコンテナの再デプロイは以下のように機能するかもしれません:

  • コンテナ内部のすべてのステップを実行して、新しいファットコンテナをオフラインで構築する
  • そのコンテナを公開する
  • アップグレードの準備ができたら、前のコンテナを停止し、新しいファットコンテナを起動し、データベースアクセスを必要とするステップが完了するまで待つ。私の実験によると、それらのステップは他のブートストラップステップよりも速いようです。

これにより、多少のダウンタイムが削減されます。それだけでその労力に見合わないかもしれませんが、共有データベースやその他の複雑なデプロイメントシナリオをより簡素化できる可能性も想像できます。

なるほど、その方が理にかなっています。ブートストラップ段階を 2 つのステップに分割する際、私も同じように対応しました。最初のステップは孤立した環境(CI パイプラインなど)で実行し、Discourse リポジトリ、gems、プラグインがインストールされたベースイメージを生成します。2 番目のステップは、ターゲットマシンで実行するか(少なくとも本番データベースへのアクセスが必要)、データベースのマイグレーションとアセットの生成を行います(これはコンテナ起動時ではなく、ブートストラップ処理で行われます)。

はい、それは素晴らしいことです。すでにリクエストしていますが、それが実現されるかどうか、いつ実現されるかはわかりません。

それを完全に別環境で実装するのは難しいでしょう。アセットの事前コンパイルタスクにはデータベースへのアクセスが必要(カスタム CSS などのため)ですが、データベースに依存する部分のみを別ステップで行えれば素晴らしいですね(データベースに依存しない他のアセットも別で事前コンパイルできるなら)。ただし、技術的にそれが実現可能かどうかはわかりません。

私がこれまで行ってきた Kubernetes インストールでは、ほぼ同じように対応しています。データ用と Web 用のコンテナを分離しない、あるいは外部の PostgreSQL や Redis を使わない(私がクライアント向けに実施したインストールでは、それらには GCP リソースを使用しています)状態で k8s を使う方法や理由を想像することはできません。

また、真のゼロダウンタイムアップグレードを実現するためには、skip_post_migration_updates という環境変数の理解が不可欠です。詳細は こちら で説明されています。