1台のLinuxマシンに全部載せる — Caddy、nginx、三つのgunicorn、そしてブローカー
三つの本番サイトと、顧客ドメインのためのオンデマンドTLSと、systemdのワーカーと、MQTTブローカーが、障害まで共有することなく1台のマシンを分け合う方法。
- 圧縮前のバイト数
- 123,345
- マーケティングのページ、いまは23,904
- バックエンドのテスト
- 332
- 40カラムを落とす前に
- ブラウザ側のアサーション
- 255
- 同じ破壊的リリースで
背景
このプラットフォームはLinuxマシン1台の上で動いています。クラスタでもマネージドの基盤でもなく、1台の箱です。その箱には、まったく関係のない別のアプリケーションが二つ同居していて、コードの隣にあるPostgreSQLがそのまま本番のデータベースです。ステージング環境はありません。編集される作業ツリーが、そのままアプリケーションサーバーの読むツリーです。
この構成はコストで決めたもので、しばらくは何事もありませんでした。それが単純でなくなったのは2026年6月、公開された各ストアに無料の{handle}.memberhub.liveサブドメインが付き、有料のストアは自分の持っているドメインをプラットフォームに向けられるようになったときです。データベース側で増減する、上限のないホスト名の集合に証明書を出す仕事は、あらかじめ用意した一覧で片付くものではありません。
ただし、答えの形を決めた制約はプラットフォーム自身のものではありませんでした。同居している二つのアプリケーションです。新しいホスト名のためにTLSを終端するものは、この箱全体のTLSを終端しなければならない。443番ポートは、半分だけ所有できるものではないからです。
作ったもの
いちばん前に立つのがCaddyで、箱全体の443番と80番を握り、ループバックのポートで待つnginxへ素のHTTPで中継します。nginxは持っていたサーバーブロックをそのまま保っています。静的ファイル、メディア、リアルタイム機能を運ぶWebSocketのアップグレード、素のファイルとして配るマーケティングサイト。変わったのは待ち受けるポートと、TLSが剥がされたことだけです。その後ろにサイトごとに一つずつ、三つのアプリケーションサービスが並びます。どれもgunicornのプロセス群で、プロセスではなくsystemdが所有するunixソケットに紐づいています。だからソケットはワーカーの生死と無関係に存在します。前段はいつでも接続でき、再起動が届いたリクエストと競合することもありません(このユニットはreloadに対応していないので、選べるのは再起動だけです)。
- Caddy箱全体の443番と80番を握る
- nginxループバックのポートで、サーバーブロックはそのまま、TLSは剥がして
- systemdのソケットワーカーの生死と無関係に存在する
- gunicornサイトごとに一つのプロセス群、三つ
ソケットより下では、一つのデータベースクラスタが三つのアプリケーションすべてを支え、一つのMQTTブローカーがプラットフォームの共同編集機能を運びます。ブラウザは二つ目のポートを開けることなく、同じオリジン・同じ証明書のまま、中継されたWebSocketの経路でそこへ届きます。リクエストの中に収まらない仕事はタイマーが二つ引き受けます。一つは5分ごとに起きて、保留中の動画を1本だけ変換します。もう一つは日次で、マーケティングサイトのニュース欄を作り直し、ファイルを原子的に書き込みます。失敗したときに残るのは空のファイルではなく、昨日のファイルです。
- 未知のホスト名 — ストアの所有者が 向けたばかりのドメイン
- Caddyが止める — 証明書はまだ無く、 勝手には求めない
- askエンドポイント — アプリとマーケティングの ホスト、次にストアのリゾルバ
- 証明書を発行 — ストアを出し、 保留中のドメインは有効に
- 何も発行しない — 週あたりの上限を 他人に使わせない
- 未知のホスト名 → Caddyが止める
- Caddyが止める → askエンドポイント (ホスト名をクエリパラメータで)
- askエンドポイント → 証明書を発行 (200)
- askエンドポイント → 何も発行しない (403)
オンデマンドTLSは、そのホスト名が最初にTLSハンドシェイクをしてきた瞬間に証明書を1枚発行し、以後は自動で更新します。放っておけばこれは招待状です。誰でもこの住所にドメインを向けるだけで、発行元の週あたりの上限を使い切らせることができてしまう。安全弁がaskエンドポイントです。Caddyは何かを発行する前に、ホスト名を添えてアプリケーションに問い合わせ、200か403を受け取ります。約10行のビューで、アプリのホストとマーケティングのホストを許可し、次にストアのリゾルバが知っているホストを許可し、それ以外は断る。肝心なのは、そこで呼ぶリゾルバが、どのストアへのリクエストかを判断するためにミドルウェアがすでに使っているものと同一だということです。「どのホストに証明書を出すか」と「どのホストがストアを出すか」は、二度尋ねられた同じ問いになり、食い違いようがありません。
ホストの検証は、そのミドルウェアに丸ごと移しました。フレームワーク側の許可リストは何でも通す設定にしてあり、ミドルウェアこそが正式な検査であるとコメントで明記されています。既知のアプリケーションのホストは通り、それ以外はストアに解決できなければ404。これでHostヘッダ注入は塞がり、知らないホスト名は安上がりになります。ルックアップ一回と拒否一回。結果はワーカーごとに60秒キャッシュし、上限は1万件です。ランダムなホスト名を試し続けられても、メモリが際限なく増えることはありません。
設計上の判断
nginxを置き換えるのではなく、前に立てる。 自動証明書発行を入れるその日に、四つのサイト分のルーティングを新しいサーバーへ移すのは、移行を二つ同時にやるということです。重ねる形にしたので、nginxは待ち受けポート以外そのままでした。代償は中継が一段増えること。そして、面倒はまさにそこから来ました。
顧客ドメインに所有権トークンを求めない。 一般的な設計では、所有者にDNSのTXTレコードを立てさせて所有を証明させます。私はそれをやめました。保留中のホスト名に実際にHTTPSのリクエストが届いた時点で、TXTレコードより多くのことが証明されているからです。DNSがここを向いていて、そのホスト名の証明書発行が成功した。ミドルウェアはその最初の一撃でドメインを保留から有効に切り替え、そのままストアを出します。
ツリーが汚れている前提でデプロイする。 変更した静的ファイルはフレームワークの収集コマンドではなく、名前を挙げて個別にコピーします。このツリーには自分の書きかけの仕事が日常的に置いてあり、まとめてコピーすればそれごと本番に出てしまうからです。テンプレートはソースから描画されるので、テンプレートを編集することはそれ自体がデプロイです。だからテンプレートの編集は最後の手順に隔離してあります。
うまくいかなかったこと
外からは見えないリダイレクトループ。 Caddyがnginxへ素のHTTPで話すので、nginxからは暗号化されていないリクエストに見えます。その結果、HTTPSで届いたリクエストに対してアプリケーションのHTTPSリダイレクトが発火しました。転送プロトコルのマップを通して解決しましたが、続きのほうが面白い。askエンドポイントは自分でそのヘッダを立てる必要があり、標準のプロキシ設定を併せて読み込ませたせいで、ヘッダが二重に送られました。アプリケーションは最初の値を読み、この接続は安全でないと判断し、askにリダイレクトを返した。証明書の発行元はリダイレクトを追いません。エンドポイントが計算していた答えとはまったく関係のない理由で、証明書が拒否されていたわけです。
途中の中継で止まっていた圧縮。 数か月後にページの重さを測ってみたら、何も圧縮されていませんでした。nginxのgzipは設定されていたし正しかった。ただ一度も発火しなかったのです。前段が通り抜けざまにクライアントのエンコーディングのヘッダを剥がすので、nginxからは圧縮を求めていないクライアントに見えていました。圧縮は前段へ移すしかありません。二つの層は、内部のポートにHostヘッダ付きで直接curlすれば切り分けられます。修正後、あるバンドルは46,279バイトから15,798バイトへ、別のものは14,191から2,858へ、マーケティングのページは123,345から23,904になりました。
本番のデータベースから40のカラムを落とす。 古いストアフロントのレンダラーを畳むということは、稼働中の設定テーブルからおよそ40のカラムを落とすということでした。予行演習をするステージングの複製はありません。だから手を動かす前に統制を書き出しました。まずデータベースをダンプする。カラムを読むのをやめるデプロイと、カラムを落とすデプロイに分け、動いているコードが存在しないカラムを参照する瞬間を作らない。データを救い出す手順は削除の直前、同じマイグレーションの中に置き、救済と破壊を切り離せないようにする。そして実行の直前に、持ち主へ一行の予告を送る。
結果
三つのアプリケーションと、上限のないストアフロントのホスト名の集合と、顧客が持ち込んだドメインが、1台のマシンを分け合っています。どれも自分で更新される証明書を持ち、どれも他人のデータを出せません。ストアのホスト名に届いたリクエストはストアフロントの経路に閉じ込められ、そこで別の組織の公開データを取ろうとすれば404です。この生きている箱からは、あの40カラムの削除を含めて何十回もデプロイが出ていき、いまのところデータ損失の事故はありません。破壊的なあの一回は、実行を許す前に332件のバックエンドテストと255件のブラウザ側のアサーションで確かめてあります。
限界は隠さずに書いてあります。オンデマンドの発行は、ホスト名ごとに1枚を、登録ドメインごとの週あたりの上限から使います。ストアが数十なら問題はなく、逃げ道も決めてあります。サブドメインの分をワイルドカードの証明書1枚にまとめることです。同居サイトのための古い更新は、DNSで検証されて80番ポートを必要としないので、Caddyの隣でいまも動いています。プラットフォーム自身の80番ポートを使う更新のほうは、毎晩静かに失敗させておくのではなく、はっきり止めました。そして、箱を共有するということは障害の範囲を共有するということです。同居するアプリケーションのセキュリティ強化が匿名接続をブローカーで無効にした日、こちらのコードは1行も変わっていないのに、リアルタイム機能はつながらなくなりました。