Running Ghost blog on Nomad Cluster

Running Ghost blog on Nomad Cluster

このブログは数年間単一のサーバーで稼働していました。

学習も兼ねてわざわざ自前でサーバーを運用していたのですが、最近新しい構成に移したくなり、Kubernetes の流行もあってクラスターやマイクロサービスといった時代の流れに乗るべく、Nomad クラスターへの移行を行いました。

Nomad クラスターの作成までは下記記事にまとめています (インフラやクラスターの設定等はここからさらに変更しています)。

今回はこの Nomad クラスター上で Ghost を動かすところまでをまとめます。

構成

以下の通り、DigitalOcean のマネージドロードバランサー配下に Droplet を 3台配置し、クラスターを構成しています。

クラスターを構成するノードではそれぞれ Nomad と Consul が稼働しており、Consul でサービスディスカバリーを、Nomad でワークロードオーケストレーションを行なっています。

稼働しているジョブ

Nomad はジョブという単位でワークロードを定義します。ジョブの中にさらに複数のグループやタスクを定義できるのですが、現在のところは 1ジョブ 1グループ、1タスクとしています。以下のジョブが稼働しています。

  • Nginx
  • Ghost
  • Mackerel

ドライバーとして全て Docker を使用しています。Nginx のバックエンドに Ghost を置いています。Mackerel はホストの監視を行なっています。mackerel-agent を直接ノードにインストールすることもできますが、今回はせっかくなので Mackerel もジョブの 1つとして動かしています。

Docker イメージは Ghost を除き公式のものをそのまま使っています。

試行錯誤

Ghost を動かすまでに苦労した点をまとめます。

データの共有

真っ先にどうしようか悩んだのが、Ghost のデータの共有です。

Ghost はブログである以上、画像と記事というステートフルなデータを保持します。クラスター上では複数の Ghost が稼働しており、もし記事を更新したとしてもそれはある一つの Ghost のみであり、他の Ghost 上では反映されないので、何かしらの方法で同期する必要があります。

まずヒントを求めて Ghost の公式サイトを見たのですが、「Ghost はクラスター上で稼働するための仕組みは持っていません」という無情な FAQ があるのみでした…。

Ghost does not support load-balanced clustering or multi-server setups of any description, there should only be one Ghost instance per site.

Ghost - Clustering, sharding and other multi-server setups

そこで Ghost のコンテンツディレクトリを Docker ボリュームとして、ホスト間で上手く共有できないか考えましたが、良い感じの方法は思いつかず。

  • NFS 的なものを使う -> DigitalOcean でそのようなサービスは無いし、自前で作るのは流石に手間と時間が…
  • オブジェクトストレージを使う -> DigitalOcean Spaces があるが、無理やりファイルシステムとして使うのは厳しそう
  • rsync -> …

色々調べると、Portworx というコンテナのような分散環境用に作られた永続ストレージを構築するツールが Nomad と連携して使うことができ、これを使うことで解決できそうな雰囲気でした。

Portworx を Nomad のジョブとして各ノードで動かすことで、分散ブロックデバイスを構築してくれます。

良さそう!と思って試してみたところ、Portworx を動作させるためには最低 1GB のメモリーを確保する必要があり、断念しました… (一番安い Droplet を使用しており、これはメモリが 1GBなので…)。

msg="ERROR: Specified --memory (134217728) must be between 1073741824 and 1039364096"

使おうとして発生したエラー

なお、一応 Nomad 公式でこの手の話が上がっており、今後の機能としては予定されているようです!

そこでとりあえず今はストレージを同期することは諦め、Docker イメージに全てを押し込む方法を考えました。Docker イメージ自体をステートフルなものにしてしまえば、Pull するだけで良くコンテナ間のデータの同期も考えなくて済みます。敗北感のある使い方ですが…。

デフォルトの Ghost のイメージでは Ghost のコンテンツが含まれるディレクトリ /var/lib/ghost がボリュームとして指定されているので、記事を更新して docker commit しても反映されません。

従ってこの部分を  /var/lib/dummy としてしまうことで、docker commit で反映させる荒技を施します。まずこれを設定した Docker イメージを用意します。

ビルド後 docker run すると Ghost が立ち上がるので、既存の Ghost からデータをエクスポートし、取り込みます。

  • 既存の Ghost の管理画面から記事をエクスポートし、Docker 版 Ghost へインポート
  • 既存の Ghost の画像データをサーバからダウンロードしてコンテナ内に配置
  • Ghost の設定ファイル (config.production.json) やその他いじったファイルを同じようにして取り込み
$ scp -r lorentzca.me:/var/www/ghost/content/images ~/Desktop/
$ docker cp ~/Desktop/images/ dde0c08f2d86:/var/lib/ghost/content/
$ docker exec -i -t dde0c08f2d86 chown -R node:node /var/lib/ghost/content/images

復元後コンテナをコミットしてプライベートイメージとして Docker Hub にプッシュします。

$ docker commit -m 'Import contents and settings' dde0c08f2d86 lorentzca/blog.lorentzca.me:v0.0.1
$ docker push lorentzca/blog.lorentzca.me:v0.0.1

これでステートフルなイメージができました。

記事の更新方法も今までと変わってきます。大まかには以下の流れになります。

  1. 最新のイメージからコンテナを起動
  2. 記事を更新
  3. 変更をコミット
  4. タグをつけて Docker Hub にプッシュ
  5. Nomad ジョブで新しいイメージを使用するように変更

手順 1 のコンテナの起動ですが、config.production.json でサイトの URL を https://blog.lorentzca.me/ としているのでうまく動作しません。そこで、環境変数を渡して URL を http://localhost:2368/ に変更します。こうすることで、ローカル上で完全に Ghost が動作します。

docker run -d -p 2368:2368 -e url=http://localhost:2368/ lorentzca/blog.lorentzca.me:v0.0.1

または Ghost の設定ファイルを config.production.json, config.development.json と分けて、config.development.json の方で localhost を URL に指定するなどして、環境変数で dev 環境に切り替える方法もあると思います。

docker run -d -p 2368:2368 -e NODE_ENV=development lorentzca/blog.lorentzca.me:v0.0.1

この辺の Ghost の設定周りは以下のドキュメントを参考にしています。

さて、 http://localhost:2368/ghost へアクセスし、記事を更新します。更新後、コンテナをコミットします。

  • タグはひとまず lorentzca/blog.lorentzca.me:v0.0.3-201906151001 のような形式にしています。v0.0.3 の部分がシステム的なバージョン (設定の変更やデザインの変更、Ghost のバージョンアップ等)、後半が記事を投稿したときの日付です。
  • もしシステムの設定だけ変更した場合は v0.0.4 だけのタグにするイメージ。
docker commit -m 'Post `OTOKOMAE CAMP #7`' ca5720117c6c lorentzca/blog.lorentzca.me:v0.0.3-(date +%Y%m%d%H%M)

Docker Hub にプッシュします。これで新しい記事を展開できるようになります。

docker push lorentzca/blog.lorentzca.me:v0.0.3-201906151134

最後に、Nomad のジョブを編集し、最新の Docker イメージのタグに書き換え、ジョブをアップデートします。

はい、たかが 1記事更新するだけでこれは正直面倒臭いです!!メリットとしては以下ですかね…。

  • 記事のバックアップをしなくて良い (Docker Hub に丸ごとあげるのでこれがバックアップになる)
  • ローカルで全く同じものを簡単に起動、確認できる
  • クラスター内でどのようにデータを同期するか等のことを考えなくてよくなる

デメリット。

  • 記事の更新がクソ面倒
  • 記事の更新がクソ面倒
  • ロジックとデータが密結合しており、ロジックのアップデート (Ghost のアップデート、テーマの更新等) が同じく手間

メモリ不足

どうにか Ghost をクラスター上で動かすことを確認できたのはいいですが、読み込みが異様に遅かったり動作が不安定。

nomad alloc status <Allocations ID> コマンドでリソースの状況を確認したところ、メモリ不足。少しずつ増やして様子を見て、現在は 512MB に落ち着いています。

ロードバランサーを介すと 503 になる

Nomad ジョブを走らせると、Docker コンテナが立ち上がるわけですが、ブリッジ接続で使われるインターフェースは Nomad の設定で使われているものではなく、インターフェースの名前の若い順になっている?っぽくて、DigitalOcean の Droplet は eth0 をパブリック IP アドレスに割り当てているので、コンテナがパブリック IP アドレスを持ってしまい、このためプライベート IP アドレスにバランシングしようとしているロードバランサーが接続先を見つけられず死んでいました。

Nomad クライアントの設定で、使用するインターフェースを指定する設定があり、これにプライベート IP アドレスがアタッチされているインターフェースを指定することで解決しました。

同じホスト上に複数の同じジョブが割り当てられてしまう

これは基本問題ないです。なぜなら Nomad はリソースに余裕があるなど、より適切なノードにジョブを割り当てていくからです。

しかし、すべてのノードに均等に稼働してほしいジョブがある場合、重複されると困ってしまいます。今回の場合、Nginx と Mackerel が該当します。

Nginx は 80 番ポートを待ち受けているので、すべてのノードで立ち上がっていないとロードバランシングされた際に 503 が発生してしまいます。また、Mackerel が重複起動していると同じホストを監視することになり、意味がありません。

そこで、Nomad のジョブ設定で constraint 節を使用します。constraint 節はジョブに制約を定義することができ、例えば Kernel や CPU 等で制約をかけることができます (Linux の場合のみ動かす、等)。

constraint 節には operator パラメータとして distinct_hosts があり、これを有効にすることで同じマシン上に同じジョブを配置しないようにスケジューラに指示することができます。

あとついでにジョブの優先順位も割り振っておきます。Nginx は起動していないと困るので優先度高くする。

Nginx と Ghost の接続

Nginx から Ghost へプロキシする際に、Ghost コンテナのアドレス、ポートを指定する必要があります。ところが、両方変動する可能性のある値です。

そこで、Consul にディスカバってもらい、テンプレートとして変数を埋め込みます。以下に Consul と連携させた Nginx タスクの定義をしている箇所を抜粋します。

  • {{}} の部分が consul-template を使って動的に設定している部分です。
  • Nginx コンテナは外部からアクセスされるポート番号を常に 80 番ポートにしたいので、resources のポートの設定を static に設定しています。
  • 基本的にはポートは Nomad に任せて動的であるべき値となります。
  • 動的なポートは Consul で拾うだけではなく、Nomad の環境変数としてもセットされるのでそれを使う方法もあります。
task "nginx" {
  driver = "docker"
  config {
    image = "nginx:1.15.12"
    port_map {
      http = 80
    }
    volumes = [
      "custom/default.conf:/etc/nginx/conf.d/default.conf"
    ]
  }
  template {
    data = <<EOH
      upstream appbackend {
        ip_hash;
        {{ range service "ghost" }} server {{ .Address }}:{{ .Port }};
        {{ end }}
      }
      server {
        listen 80;
        server_name nginx.service.consul;
        location / {
          proxy_set_header X-Real-IP $remote_addr;
          proxy_set_header HOST $http_host;
          proxy_set_header X-NginX-Proxy true;
          proxy_pass http://appbackend;
          proxy_redirect off;
        }
      }
    EOH
    destination = "custom/default.conf"
  }
  resources {
    cpu = 100
    memory = 64
    network {
      mbits = 10
      port "http" {
        static = 80
      }
    }
  }
  service {
    name = "nginx"
    tags = ["nginx", "web"]
    port = "http"
    check {
      name     = "alive"
      type     = "tcp"
      interval = "10s"
      timeout  = "2s"
    }
  }
}

まとめ

ブログを移し替えるまでの流れを振り返ってみました。長い。他にもちょくちょく知見を得たので、別途まとめようと思います (特に Mackerel 編)。

現状記事を更新するのが面倒という課題があるので、Nomad 公式の永続ストレージ機能が楽しみです。

他現在把握している課題、問題は以下。

  • ロードバランサーにアタッチできる証明書が 1つだけ…。 blog.lorentzca.me 以外使えない…。ロードバランサーが発行する証明書にワイルドカード証明書は無いし…。
  • この問題のため旧ドメインの ghost.ponpokopon.me からのリダイレクトが設定できず、古い記事内にあるリンクが死んでいる。
  • Terraform でプロビジョニングまでしてしまっていて、苦しくなってきたのでそろそろ Ansible 等に切り出したい。

クラスター構成、今までと勝手が全然違くて新鮮です!!面白い!!!!