メインコンテンツへスキップ
  1. 記事一覧/

APIを再デプロイせずにWebフロントエンドをリリースする

· loading · loading ·
仁才徳
著者
仁才徳
韓国ソウル在住のリーダー兼ソフトウェアエンジニア

少し前まで、MaruのWebアプリへの変更はすべてDjango APIの変更と同じ道を通って出ていました。Dockerイメージをビルドし、プッシュし、Fly.io上のマシン全台にローリングで展開する。Webフロントエンド(ExpoのWebエクスポートと、Hugoが生成するマーケティングページ)はAPIと同じイメージにコピーされていたので、他に出しようがなかったのです。CSSの修正1つに7分ほどかかり、11台のマシンが再起動しました。8月のある日は、これを6回やりました。

この記事では、それを何に置き換えたかを順に追います。デプロイスクリプトがフロントエンドの2つのツリーをビルドし、バージョン付きプレフィックスでオブジェクトストレージにアップロードし、ポインタのオブジェクトを1つ書き換え、全マシンが新しいバージョンを配信するまで待ってから、CloudflareのキャッシュをAPI経由でパージします。反対側では小さなDjangoモジュールがリクエストごとにポインタを読み、そこに書かれたバージョンを配信します。APIはまったく再起動しません。

以前のやり方の何が問題だったか
#

いちばん大きなコストは再起動でした。11台がローリングするということはコールドブートが11回で、共有CPUではDjangoが立ち上がるのに13秒ほど、そのうち7秒はDjangoのimportだけに使われます。本物の訪問者にそのコストを払わせないよう、デプロイスクリプトの最後に全マシンを1回ずつ叩くウォームアップの工程を置いていましたが、そのせいでWebのリリースは毎回、いちばん遅いマシンを待って終わっていました。 そのコールドブートがFly.ioでどれだけ高くつくかは、ゼロスケールと、再起動のたびに起きる30秒の障害に書きました。要するに再起動のたびに訪問者が504を受け取りうる窓が開くので、何も再起動しないデプロイ経路はWebのリリースからその窓を丸ごとなくしてくれます。

2つ目の問題は、Hugoの出力が分かれていたことです。HTMLはイメージに入るのに、RSSフィードと検索インデックスは直接バケットに行っていました。同じソースからビルドされてはいるものの同じ瞬間ではないので、食い違うことがありえて、食い違うまで誰も気づきません。

3つ目は習慣の話です。リリース1回に7分と再起動がかかると、小さなリリースをしなくなります。修正を3つ4つ溜めてからまとめて出すようになり、それこそが1行の変更が壊れた何かと一緒に出ていくパターンです。

バケットの中の配置
#

すべてTigrisの静的バケットのweb/プレフィックス以下にあります。

web/current                              ← バージョン文字列を1つ入れたテキストファイル
web/a1b2c3d-20260914021500/manifest.json ← このバージョンの全ファイル一覧
web/a1b2c3d-20260914021500/dist/...      ← ExpoのWebエクスポート
web/a1b2c3d-20260914021500/hugo/...      ← Hugoの出力
web/9f8e7d6-20260912093012/...           ← 前のバージョン、そのまま残っている

バージョンは短いgit SHAとUTCのタイムスタンプなので、どのコミットがいつ上がったかがそのまま読めます。リリースが本番になるとき変わるのはweb/currentだけです。

デプロイスクリプトがやること
#

全体はscripts/deploy_web.pyという1本のスクリプトで、リポジトリのルートから実行します。順番に:

1. 汚れたツリーを拒否する。 最初にgit status --porcelainを実行し、何か出てきたら終了します。ディスクにあるものがそのままアップロードされるのですが、以前のHugoのデプロイスクリプトがその形でコミットしていない文言を出してしまったことがありました。分かってやる場合のために--allow-dirtyフラグがありますが、使ったことはありません。その後git rev-parse --short HEADと現在時刻からバージョン文字列を作ります。

2. 2つのツリーをビルドする。 アプリはnpm run build:web:prodで、本番のAPIとWebSocketのURLを埋め込んだexpo export --platform webのあとにBrotliの事前圧縮と、管理画面用ブロックエディタのバンドルが続きます。事前圧縮は説明しておく価値があります。Cloudflareはオンザフライでもブロトリ圧縮してくれますが品質レベルが低く、このバンドルではgzipより悪い結果でした(1.66MBに対して2.04MB)。ビルド時に品質11で一度だけ圧縮すると1.28MBになるので、スクリプトは1KBを超えるJS・CSS・JSONファイルごとに.brの双子を隣に書き出します。マーケティングサイトはhugo-site/hugo --gc --minifyを実行し、public/に書き出します。

3. アップロードする。 mobile-app/distpublic/を走査し、全ファイルを16スレッドでweb/<version>/dist/...web/<version>/hugo/...にアップロードします。各オブジェクトにはファイル名(先に.brを外したもの)から推定したContent-Typeが付き、Brotliの双子にはContent-Encoding: brも付きます。全ファイルが上がって初めて、相対パスのリスト2つを持つJSONであるmanifest.jsonを書きます。この順番が重要です。Django側はマニフェストのないバージョンを存在しないものとして扱うので、途中までしか上がっていないバージョンが現行になることはありえません。今は314ファイルで、アップロードは6秒ほどです。

4. ポインタを書き換える。 put_object1回でweb/currentにバージョン文字列を書きます。これがリリースです。

5. マシンを待つ。 各APIマシンはポインタを5秒キャッシュするので、書き換え直後の一瞬は一部がまだ古いバージョンです。スクリプトは/api/web-version/を0.5秒ごとにポーリングし、新しいバージョンを返す連続回答を数えます。Flyは全マシンに負荷分散するので、一致する回答が16回連続(マシン数より多い)で出れば、全台がポインタを読み直したと判断できます。古い回答が1つでも来るとカウントはリセットされます。90秒で諦めて手で確認するよう促します。最初の本番の切り替えが30秒強かかったので、上限はそれより下げていません。

6. Cloudflareをパージする。 API呼び出し1回で、後述します。失敗したら警告を出して先へ進みます。

7. Discordに投稿する。 ランタイムのエラー通知と同じWebhookに、バージョンと実行者を1行で送ります。エラーがまとまって出たとき、何が変わった直後かと照らし合わせられるようにです。ロールバックは琥珀色で投稿されます。

最初から最後までで1分強、そのほとんどはExpoのエクスポートです。

Django側
#

フロントエンドを配信していたのはもともとDjangoです。API以外のURLはすべて、そのルートに対応するExpoエクスポートのプリレンダー済みページを返すビューが担当し、マーケティング・プライバシーポリシー・サポートのページはHugoの出力から来ます。これらのビューは以前はディスク上のファイルを開いていました。今はweb_bundle.pyという小さなモジュールを1つ経由し、その中のWebFilesクラスをどのビューも代わりに呼びます。

body = web_files.read("dist", "tabs/home.html")

リクエストごとに解決するのは3つです。現在のバージョン(web/currentから読み、5秒キャッシュ)、そのバージョンのマニフェスト(そのバージョンが現在である間キャッシュ)、そしてファイル本体(プロセスあたり48MBほどのLRUなので、ページのHTMLとメインバンドルはメモリに残ります)。404が安く済むのはマニフェストのおかげです。パスが一覧になければ、バケットに聞かずに「ない」と答えます。クライアントがBrotliを受け付けるなら.brの双子ファイルを代わりに読んでエンコーディングヘッダを付けます。アセットはファイル名に内容のハッシュが付いているので、1年間のimmutableなキャッシュヘッダ付きで出ていきます。

同じモジュールがExpoのルートテーブルも作ります。/tabs/hometabs/home.htmlに対応する、といった具合で、バージョンごとにキャッシュされるので、新しいデプロイが出ると最初のリクエストで作り直され、それより前には作られません。

それから、デプロイスクリプトがポーリングするエンドポイントがあります。

def web_version(request):
    response = JsonResponse({"source": web_files.source(), "version": web_files.version()})
    response["Cache-Control"] = "no-store"
    return response

/api/web-version/は本番ではbucket:<version>を、イメージから配信しているときはfilesystemを返します。Flyのマシンはそれぞれ自分について答えるので、十数回叩けば全体が収束したか分かります。何かおかしいときに、どのマシンが何を本番だと思っているかを確かめる一番手っ取り早い方法でもあります。

Cloudflareのパージ
#

marucommunity.comのHTMLはCloudflareのエッジで5分間キャッシュされます。パージしないと、デプロイはマシン上では動いているのに、ほとんどの訪問者は最大5分間古いページを受け取り続けることになり、これでは意味がありません。

パージはAPI呼び出し1回です。

urllib.request.Request(
    f"https://api.cloudflare.com/client/v4/zones/{zone}/purge_cache",
    data=json.dumps({"hosts": ["marucommunity.com", "www.marucommunity.com"]}).encode(),
    headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)

ページを全部列挙しなくて済むように、URLではなくホスト単位でパージしています。アセットはそもそもパージ不要です。ファイル名に内容のハッシュが付いているので、新しいビルドは新しいファイル名を指し、古いものは参照されなくなるだけです。更新が必要なのは、そのファイル名を参照しているHTMLだけです。

スクリプトがパージの 前に マシンを待つのは意図的です。先にパージすると、Cloudflareがまだ古いバージョンのマシンからキャッシュを埋め直してしまい、そのページがさらに5分間エッジに居座ることになります。

パージが失敗した場合、スクリプトは警告を出してそのまま完了します。キャッシュは5分以内に勝手に切れるので、最悪でもリリースが遅いだけで、壊れはしません。最後の工程でデプロイが中断されるよりはそのほうがいいと思っています。

本番にする前にビルドを確認する
#

--no-flipはステップ3までやって止まります。バージョンはバケットにあるけれど、何も配信していない状態です。見るにはマシン1台にWEB_BUNDLE_VERSION=<version>を設定します。web/currentが何と言おうと、そのマシンはそのビルドに固定されます。問題なければ--rollback <version>で現行にします。名前はロールバックですが実際は「currentをこのバージョンに向ける」で、前に進むのも戻るのも同じ操作です。

ロールバック
#

古いバージョンはバケットに残っているので、ロールバックは前のバージョンで--rollback <version>を実行するだけで、--listで何があるかが新しい順に見られます。前に進むデプロイと同じ待機とパージをします。まだ使う機会はありません。今のところ古いバージョンを消す仕組みはなく、小さいですし、ないよりあるほうがいいと思っています。

変わっていないこと
#

APIは今もdeploy-all.shで以前のやり方でデプロイしていますし、そうあるべきです。状態を持っていて、マイグレーションが走る場所ですから。Dockerfileも今まで通りmobile-app/distpublic/をイメージにコピーしています。これは意図的です。バケットの設定がない場合(ローカル開発やテストスイート)やバケットが読めない場合、WebFilesはディスク上のそのディレクトリに倒れるので、ストレージ障害が起きても訪問者にはエラーページではなく最後のイメージと一緒に出たフロントエンドが届きます。バケットを一時的に外す必要があれば、WEB_BUNDLE_FROM_BUCKET=0で本番にその挙動を強制できます。

フロントエンドはAPIよりずっと頻繁に変わり、リリース1回が7分ではなく1分ほどになりました。意識しなくても、まとめて出す癖はなくなりました。