はんドンクラブ 運営ブログ

Mastodonインスタンス「handon.club」の運営ブログです。コラムなど,Mastodonに関する一般的な記事も投稿予定です。

はんドンクラブを支える技術(継続的デリバリー)

こんばんは。はんドンクラブのはんです。

はんドンクラブでは、昨年インフラを刷新しました。その目的の一つは、CI&CDを実現できる環境づくりです。

CI&CDとは、継続的インテーグレーション・継続的デリバリー の略で、ざっくりと言うと「実装変更(コーディング)・テスト・本番適用までを自動化すること」です。一般には、CI&CDを実現することで、サービスデリバリーのアジリティ向上、ならびにサービス品質の維持・向上ができると考えられています。特に後者については、個人運営のサーバーであることを考慮しても、大幅に作業負担を軽減できますので、他の作業に時間を使えるようになり、結果的にサービス品質の維持・向上に向けた作業ができる、という訳です。

ただ、最初に申し上げておくと、残念ながらはんドンクラブは全ての作業をCI&CD化した訳ではありません。これは、意図があって自動化したくない作業、言い換えると「私が目で実行結果を確認し、その後に心を込めてエンターキーを押したい作業」があるためです。ただし、自動化したかった箇所について、特にデリバリー作業に関連する作業については、私の手作業はほとんどなくなりました。

そこで、今日は、特にはんドンクラブのデリバリー作業の自動化に関連した、いくつかの技術を紹介したいと思います。正直、まったく難しいことはしていません。そのため、あまり読み応えはないと思います。

全体のビルド・デリバリー構成

手で作業する箇所は4つ(図中、青矢印で記載)のみです。本当は全部自動化できたのですが、Mastodonプロセスのログ確認やブラウザによるアクセス確認を人力で行いGoサインを出したかったので、あえて何回か止めています。

さて、キーポイントとなる技術を2つ紹介します。

Docker

Mastodonは本来、Dockerコンテナのイメージが公開されています。かつ、githubには docker-compose.yml も掲載されています。そのため、少々の設定をして docker compose up -d と打つだけで、実はサーバーの運用が可能な状態になります。

しかし、はんドンクラブでは、ソースコードをそれなりに修正しています。かつ、様々なパフォーマンスチューニングを行いたかったため、dockerはあえて使っていませんでした*1。以上の理由と運用面を考慮し、ホストサーバー上で直接プロセスを動かしていました。

再Docker化

新旧構成の比較

今回の移行で、上の図のとおり再docker化を行いました。これは、はんドンクラブの独自DockerイメージをビルドしGithubにプッシュすることで、自動リリースを容易にするためです。

このDocker化により、大幅な変更が無いMastodonへのアップデート(バグFix、セキュリティパッチ等)への追従であれば、ソースコードのマージ・Dockerイメージのビルド・ステージング環境へのデプロイまで、完全自動化できるようになりました。

# ソースコードのマージ
$ git checkout -b ${UPSTREAM_BRANCH} ${TAG}
$ git checkout -b ${HANDON_CI_BRANCH} ${HANDON_PROD_BRANCH}
$ git rebase ${UPSTREAM_BRANCH}

# ビルド
$ docker image build -t ghcr.io/highemerly/mastodon/handon:handon-nightly .
$ docker push ghcr.io/highemerly/mastodon/handon:handon-nightly

# デプロイ(ステージング環境)
$ docker compose pull
$ docker compose restart

ところで、自動化したといっても、やってることは実は上記だけです(実際のコードにはエラー処理がありますが、わかりやすさのためにこの記事では記載を省略しています)。

なお、はんドンクラブのDockerイメージは、handon-nightly タグ と handon-latest タグの2つを運用しています。 docker-compose.yml を書き分けることで、ステージング環境では handon-nightly を、本番環境では handon-latest を使っています。そして、handon-nightly には 検証なしで 気軽にプッシュ しますが、handon-latest へのプッシュは各種目視確認を得て実施する運用ルールとしています。

# docker-compose.yml のイメージ (ステージング環境)
services:
  web:
    image: ghcr.io/highemerly/mastodon/handon:handon-nightly
    restart: always
    env_file: .env.production
    command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
    ports:
      - '127.0.0.1:3000:3000'
    volumes:
      - ./public/system:/mastodon/public/system
    depends_on:
      - pgbouncer

余談です。当初はk8s化を考えていました。しかし、事前検証の結果、コスト面でのデメリットが大きく、今回は見送ることとしました。サーバーの規模が倍以上になる場合は、再度k8s化を検討したいと思っています。

クラウドロードバランサー

構成の差分

2023年のインフラ移行前後で、ロードバランシングの実現方式が変わりました。先の構成図のとおり、移行前のフロントエンドは3インスタンス、移行後のフロントエンドは2インスタンスで運用されています*2が、エンドユーザーからのリクエストを分配する方法は、以下のようになっていました。

移行前は、(一応)DNSラウンドロビン です。注意点として、はんドンクラブの通常のHTTPS通信は全てCloudFlare経由であり、エンドユーザーからのDNSクエリを受けても実際のサーバーのIPアドレスが応答されるわけではなく、CloudFlareのエッジサーバのIPアドレスが応答されます。よって、CloudFlareがオリジンサーバへリクエストを送る際のロードバランシングアルゴリズム(たぶん、ラウンドロビン)に従って割り振られているだけで、正確にはDNSラウンドロビンとは少し性質が異なります。すなわち、普通のDNSラウンドロビンのように、エンドユーザーの選出アルゴリズムに依存する訳ではありません。

一方、移行後は、Vultrのマネージドロードバランサーを使います。Least Connectionsで分散を行います。マネージドロードバランサーの採用によって、継続的デリバリーとは直接関係ありませんが、セッションのPersistenceの担保(注: はんドンクラブではMastodonが発行するCookieを使っています)・ハートビートによる各インスタンスの死活監視・TLS処理のオフロードなどを実現しました。でも、それだけでなく、継続的デリバリーの観点でも非常に重要な構成変更です。

このロードバランサーに$10/Monthの追加経費がかかっていますが、やむを得ません

メンテナンス時のリクエスト迂回方法

今回の構成変更が継続的デリバリーに有益である理由を説明するため、まずはメンテナンス方法を説明したいと思います。Mastodonのアップデートなどを行う場合、通常はmastodon-webプロセスの再起動が必要となります。しかし当然、再起動時、数十秒〜数分の通信断が発生してしまいます

従来のはんドンクラブでは、このサービス中断を防ぐための工夫をしています。でも、CloudFlareでのロードバランシングアルゴリズムがよく分からないため、実際にロードバランシングを行っているCloudFlareでの解決は期待できませんでした*3。そのため、ホスト側のnginxでバックエンドの設定を変え、VPCを経由して生きている他サーバーのプロセスへ飛ばす 処理を行っていました。

ただ、この方法では、nginxの設定変更を行いreload*4しても、おそらくnginxの仕様で現行のTCPセッションのバックエンド側の接続先は変更されないようです。つまり、いつまで待っても旧セッションが使われ続けてしまうことがあります。加えて、そもそもこの方法は、ホスト自身のメンテナンス(例:カーネルアップデートに伴う再起動など)に対応できませんよね。

以上から、オペレーションが複雑で、かつ完全ではない状態でした。つまり、自動化に向いた構成ではありませんでした。

移行後はより単純です。マネージドロードバランサーの設定を変えるだけです。これでサービスを止めることなく、プロセスまたはホストのメンテナンスが可能です。

実際には、websocketの通信は瞬断してしまう場合があることが分かっています。しかし、少なくともMastodonのWebクライアントは、すぐに再接続が行われることを確認しています。よって、ユーザー体験への影響は一切なく、仮にホストの再起動が必要な状況でもサービスを一切止めることなくデプロイができるのです。

Vultr API

さて、実際にメンテナンスへ移行する場合に、マネージドロードバランサの設定を変更する必要があります。

マネージドロードバランサはVultrが提供しているRest APIがあり、設定の差分のみをPATCHで送信することができます。そのため、例えばHost1とHost2で運用している際に、Host1のアップデート作業を行うため切り離したい場合には、以下のコマンドを発行すればOKです。

$ curl -i "https://api.vultr.com/v2/load-balancers/${LB_ID}" \
 -X PATCH \
 -H "Authorization: Bearer ${VULTR_API_KEY}" \
 -H "Content-Type: application/json" \
 --data '{
  "instances": [
    "'${HOST2_ID}'"
  ]
 }'

HTTP/2 204 
server: nginx
date: ・・・(Snipped)

これで30秒も待てば、全てのセッションが新しい設定(この例だと、 $HOST2_IDインスタンス)に向かいます。

なお、実際に切り戻しが行われたかどうかは、インスタンスのnginxのアクセスログを監視し、本当にアクセスがなくなったことを確認しています。これは、Vultr側の反映ラグを考慮したものです。Rest APIでマネージドロードバランサー側の設定を取得することもできますが、その結果から反映が終わったように見えていても、実際にはマネージドロードバランサーの設定が終わっていないことがあるようです。

まとめ

これまでのリリース作業は、軽微なリリースであっても、1時間以上の作業を要していました。しかし、この仕組みを構築したことで、実作業は10分程度に抑えることができています。また、軽微なオペレーションミス(例:特定インスタンスのみアップデート作業を忘れてしまう)も防げるようになったと考えています。

運用に関する課題は他にもたくさんあります。継続的な改善に取り組んでいきたいと思います。引き続き、はんドンクラブをよろしくお願いいたします。

*1:正確には、第1期ではDocker、第2期・第3期では非Dockerです。今回の第4期インフラで再Docker化しました

*2:インスタンス数が減っているように見えますが、実際にはフロントエンドに割いているリソースは増えています。バックエンドのプロセスの構成などを変更したため、インスタンス数が少なくて済むようになりました

*3:実際には片方のAレコード/AAAAレコードを削除すれば確実に迂回できるのですが、検証の結果、浸透(浸透って言うなと怒られそう)にかかる時間がよく分からないのと、オペレーション複雑性の観点で、却下していました

*4:restartすれば反映されると思いますが、それでは既存TCPセッションが切れてしまいますので、困ります