個人開発のDjangoサイト群を「マルチテナントCMSプラットフォーム」に作り替えた話

目次
NOUMIN ENGINEERING TECH GUIDE

💬 くるるちゃんのワンポイント図解解説

【技術背景・解説】
この記事(個人開発のDjangoサイト群を「マルチテナントCMSプラットフォーム」に作り替えた話)におけるIT技術の基本概念と重要ポイントを、わかりやすく整理・解説しています。

【アイキャッチ図解のポイント】
構成要素とデータフローを視覚的に整理し、初心者でも要点を掴みやすいグラフィック構造で表示しています。

【資格・要点ノート】
シラバス用語、基本概念の理解、実務への応用ポイント

一つの Django プロジェクトに、技術ブログ、ゲームコミュニティ CMS、学習アプリ、3D コンテンツなどの機能を追加し続けて数年。ついに、管理画面からサブドメインを指定して新しいサイトを作成し、そのまま公開できる「マルチテナント CMS プラットフォーム」への再編を完了しました。

この記事では、既存サイトを壊さずにマルチサイト化するために行った設計判断、実際に踏んだ罠、セキュリティ対策、移行手順、運用上の工夫を備忘録としてまとめます。

対象読者は、すでに本番稼働している Django プロジェクトを、URL や SEO、既存機能を維持したままマルチサイト化したい人です。

結論から言うと、成功の最大の要因は次の2点でした。

  • 全コンテンツを共通モデルへ移行せず、既存 CMS に site 外部キーを追加する方式を採用した
  • 全体を6つのフェーズに分割し、各フェーズを独立してデプロイ・ロールバックできる状態にした

「理想的な設計へ一気に作り替える」のではなく、「現在動いているものを壊さず、少しずつ置き換える」ことを最優先にしています。

何を作ったのか

完成後のプラットフォームでは、次のことができるようになりました。

  • 管理専用サブドメインへ、スタッフ権限と TOTP 認証を使ってログインできる
  • 管理画面からサイト一覧の確認と新規サイトの作成ができる
  • サブドメイン名を入力してサイトを作成すると、DNS の個別設定なしで即座に公開できる
  • 各サイトで、記事、固定ページ、イベント、メニュー、ウィジェット、アラート、チャットなどの機能を個別に有効化できる
  • 既存の技術ブログを、URL・SEO・デザインを変えずにサイト台帳へ登録できる
  • 既存ブログから、イベント告知などCMS側の共有機能だけを借りられる
  • サイトの閉鎖、改名、URL変更、別ゲームへの移籍を管理画面から実行できる
  • 破壊的な操作には、スナップショット作成とパスワード再認証を必須化できる

新しいサイトは、ワイルドカードDNSとワイルドカード証明書を利用して、次のようなURLで公開されます。

https://<新サイト名>.example.com/

設計判断:なぜ「共通モデルへの全面移行」をやめたのか

最初に検討したのは、すべてのコンテンツを ContentEntry のような共通モデルへ統合する、いわゆる王道のマルチテナント設計です。

しかし、この案は採用しませんでした。理由は単純です。既存サイトのURL、SEO、HTML出力、レンダリング結果を可能な限り変えたくなかったからです。既存の技術ブログには、数年分の被リンクと検索流入があります。全面移行によってURLやHTML構造、canonical、内部リンク、メタ情報に不整合が発生した場合、その代償は小さくありません。

そこで採用したのが、「既存CMSエンジンの多サイト化」です。具体的には、次のような構成にしました。

  • 既存CMSの主要モデルへ site 外部キーを追加する
  • 既存CMSを、複数サイトから利用できる再利用可能なエンジンへ変更する
  • サイト台帳を管理する小さな専用アプリを新設する
  • 既存ブログのモデルには原則として手を入れない
  • 既存ブログは「ディレクトリ型サイト」としてサイト台帳へ登録する

サイト台帳アプリが持つ主要モデルは、最終的に次の7種類になりました。当初は「サイト・ホスト・機能フラグ・変更履歴・ゲーム所属履歴・スナップショット」の6種類に限定して設計しましたが、後述する設定バージョニング機能のために1つ追加しています。

  • サイト
  • ホスト
  • 機能フラグ
  • 変更履歴
  • ゲーム所属履歴
  • スナップショット
  • 設定バージョン履歴

django-tenants などを使ったスキーマ分離方式も検討しました。ただし、個人開発規模のプロジェクトとしては、データベース運用、マイグレーション、バックアップ、障害対応の複雑さが増えすぎると判断しました。そのため、今回は同一スキーマ内で site 外部キーによってデータを分離する方式を採用しています。

この方式には明確な欠点があります。クエリにサイト条件を付け忘れると、別サイトのデータが表示される可能性があることです。このリスクについては、アプリケーション設計だけで完全に防ごうとせず、専用マネージャー、ヘルパー関数、2テナント隔離テストによって継続的に検出する方針としました。

ホストルーティング:Hostヘッダーからサイトを解決する

マルチサイト化の心臓部は、MIDDLEWARE の先頭付近に配置したサイト解決ミドルウェアです。処理の流れは次のとおりです。

  1. リクエストのHostヘッダーを取得する
  2. サイトホスト台帳から該当ホストを検索する
  3. 登録済みの場合は request.site へサイト情報を格納する
  4. サイトのCMSエンジンに応じて request.urlconf を切り替える
  5. 旧ホストの場合は正規ホストへ301リダイレクトする
  6. 閉鎖済みサイトの場合は、設定に応じて301または410を返す
  7. 未登録のワイルドカードサブドメインには421を返す

サイトホスト台帳の検索結果はキャッシュし、保存や削除のシグナルを受けた時点で即座に無効化しています。

未登録ホストを421で拒否する理由

ワイルドカードDNSを設定すると、存在しないサブドメインへのアクセスもすべてオリジンサーバーへ到達します。例えば、次のようなデタラメなホスト名でもリクエストが届きます。

attacker-controlled-name.example.com
random.example.com
unknown.example.com

台帳に存在しないホストを既定で拒否しなければ、Hostヘッダー操作、キャッシュキーの不整合、誤った絶対URL生成などの攻撃面が広がります。そのため、未登録ホストには次のステータスを返す設計としました。

421 Misdirected Request

障害時は主要サイトだけを生存させる

サイト台帳やキャッシュが利用できない場合に、プラットフォーム全体が停止する構成にはしませんでした。主要サイトについては、最小限のハードコードされたフォールバック表を用意しています。これにより、次のような状況でも主要サイトを継続できます。

  • サイト台帳アプリのマイグレーションが未適用
  • Redisが停止している
  • サイト解決用キャッシュが全消失している
  • サイト台帳の参照処理で一時的な障害が発生している

ただし、これはすべてを無条件に許可するフェイルオープンではありません。既知の主要サイトのみをフォールバック対象とし、未知のホストは引き続き拒否します。

データのマルチテナント化:3段階マイグレーション

既存テーブルへの site 外部キー追加は、必ず3段階に分けました。各段階を独立してデプロイできるようにし、途中で作業を停止しても既存機能が壊れない構成にしています。

M1:nullableな外部キーを追加する

最初に、null=Truesite 外部キーを追加します。

site = models.ForeignKey(
    Site,
    null=True,
    blank=True,
    on_delete=models.PROTECT,
)

サイトは論理的に「閉鎖」するだけで物理削除しない設計のため、CASCADE ではなく PROTECT を使っています。誤ってサイトの行を削除した際に、紐づく全コンテンツが巻き添えで消えることを防ぐためです。

この段階では、表示や動作を変えません。既存コードは site が未設定でも動作する状態を維持します。

M2:既存データをbackfillする

次に、既存の全行へ既存サイトを割り当てます。サイトを取得するときは、環境によって値が変わる主キーを直接指定せず、固定された slug などを使って解決します。

site = Site.objects.get(slug="legacy-site")

次のような書き方は避けました。

site_id = 1

主キーの直書きは、開発環境、ステージング環境、本番環境でデータ作成順が異なる場合に事故の原因になります。

M3:ユニーク制約をサイト単位へ変更する

最後に、グローバルな unique=True を解除し、サイト単位の複合ユニーク制約へ切り替えます。

class Meta:
    constraints = [
        models.UniqueConstraint(
            fields=["site", "slug"],
            name="unique_slug_per_site",
        )
    ]

これにより、異なるサイトで同じ slug を使用できるようになります。

PostgreSQLの _like インデックスでマイグレーションが落ちた話

ここで注意が必要なのが、PostgreSQLが文字列系のユニークフィールドに作成する _like 系インデックスです。SlugField などの unique=True を解除し、別のマイグレーションで新しい制約を追加すると、インデックス名や削除順序の関係で、PostgreSQL環境だけマイグレーションが失敗することがあります。SQLiteでは再現しないため、ローカル開発だけでは見逃しやすい問題です。

今回の対策は次の2点です。

  • unique=True の解除と複合ユニーク制約の追加を、同じマイグレーションファイル内で行う
  • 使い捨てのDocker PostgreSQL環境で、forwardとbackwardの両方を検証する

最低でも、次の往復は確認します。

旧状態
  ↓ migrate
新状態
  ↓ migrate <previous>
旧状態
  ↓ migrate
新状態

ローカル開発にSQLiteを使用している場合でも、マイグレーション検証だけは本番と同じデータベースエンジンで行うことを強く勧めます。

会員モデルのOneToOneFieldをサイト単位へ変更する

もう一つ大きな変更となったのが、会員モデルです。変更前は、ユーザーと会員情報が1対1でした。

user = models.OneToOneField(
    User,
    on_delete=models.CASCADE,
)

マルチサイト化後は、同じユーザーが複数サイトへ所属できる必要があります。そこで、次の構成へ変更しました。

user = models.ForeignKey(
    User,
    on_delete=models.CASCADE,
)

site = models.ForeignKey(
    Site,
    on_delete=models.PROTECT,
)

さらに、サイトとユーザーの組み合わせを一意にします。

models.UniqueConstraint(
    fields=["site", "user"],
    name="unique_member_per_site",
)

問題は、既存コードに user.member という逆参照が9か所存在していたことです。これらをすべて、次のようなサイト明示型のヘルパー経由へ置き換えました。

member = get_site_member(user, site)

作業時は、IDE検索と grep を併用して逆参照をすべて洗い出し、一括で改修したあと、専用テストを追加しました。

テストが本番障害を未然に防いだ話

多サイト化で最も危険だったバグを記録しておきます。サイト解決ミドルウェアが request.site へ格納するのは、毎リクエストでORMオブジェクトを取得するコストを避けるための軽量な dataclass です。つまり、request.site はDjangoモデルのインスタンスではありません。

しかし、作成系ビューの7か所で、次のような代入をしていました。

obj.site = resolve_site(request)

resolve_site(request) が軽量 dataclass を返した場合、Djangoの外部キーディスクリプターはモデルインスタンス以外の代入を許可しないため、ValueError が発生します。つまり、本番で記事やイベントを作成した瞬間に500エラーになるバグでした。

なぜ単体テストでは見つからなかったのか

ローカルの単体テストでは、request.site が設定されていない状態でビューを直接呼び出していました。その場合、サイト解決処理はデータベースへフォールバックし、本物のモデルインスタンスを返します。そのため、テストでは正常に動いてしまいました。

この問題は、2つのサイトを作成し、ミドルウェアを含む実際のHTTP経路でアクセスする隔離テストを追加した瞬間に発覚しました。作成系ビュー7か所がすべて失敗しました。

教訓は明確です。マルチテナント化のテストでは、必ず2つ目のテナントを作成し、本番と同じミドルウェア経路で検証する必要がある。1テナントだけのテストでは、テナント間のデータ分離も、ホスト解決も、誤ったサイト参照も証明できません。

セキュリティ:ワイルドカードDNSを開ける前の必須条件

ワイルドカードDNSは便利ですが、受け付けるホスト名の範囲が大きく広がります。そのため、DNSを開放する前に、優先度の高い対策をすべて実施しました。

未登録ホストは421で拒否する

前述のとおり、サイト台帳に存在しないホストは、アプリケーション処理へ流さず421で拒否します。

Cookieを親ドメインで共有しない

次の設定は未設定のまま維持しました。

SESSION_COOKIE_DOMAIN
CSRF_COOKIE_DOMAIN

.example.com を指定してCookieを全サブドメインで共有すると、一つのサイトで発生したXSSが、ほかのサイトのセッションへ影響する可能性があります。Cookieはホスト限定にし、サイト間で共有しない設計としました。

CSRF_TRUSTED_ORIGINSを安易にワイルドカード化しない

サブドメインを追加するたびに、次のような設定を追加したくなるかもしれません。

CSRF_TRUSTED_ORIGINS = [
    "https://*.example.com",
]

しかし、同一オリジンから送信される通常のフォームであれば、原則として安易な追加は不要です。Django の新しいバージョンでは、Origin ヘッダーによる同一オリジン照合で CSRF チェックが通るため、サブドメイン間の通常フォーム送信に CSRF_TRUSTED_ORIGINS の追加は不要でした。外部オリジンからのPOSTを本当に許可する必要がある場合だけ、対象を限定して追加するべきです。「とりあえず全サブドメインを信頼する」という設定は避けました。

ALLOWED_HOSTSはドメイン限定で許可する

一方で、ALLOWED_HOSTS にはサブドメインを受け付ける設定が必要です。サイト解決ミドルウェアへ到達する前に、DjangoのHost検証で400になってしまうためです。

ALLOWED_HOSTS = [
    ".example.com",
]

ただし、次のような全許可は使用しません。

ALLOWED_HOSTS = ["*"]

Django側では親ドメイン配下を受け付け、実際に利用可能なホストかどうかはサイト台帳で判定します。

WebSocketのOriginをサイト台帳で検証する

Django Channelsの AllowedHostsOriginValidator は、基本的に ALLOWED_HOSTS を参照します。そのため、ALLOWED_HOSTS.example.com を許可すると、未登録サブドメインをOriginとするWebSocket接続まで許可対象に含まれる可能性があります。

そこで、WebSocketについてはサイトホスト台帳と照合する独自のOrigin Validatorへ置き換えました。これは、ワイルドカードサブドメイン運用で見落としやすいポイントです。

アップロードファイルを検証する

WYSIWYGエディターの添付ファイル経路を確認したところ、SVGをそのままアップロードできる状態になっていました。SVGはスクリプトや外部参照を含められるため、配信方法によってはstored XSSの経路になります。対策として、次の検証を追加しました。

  • 拡張子の検証
  • Content-Typeの検証
  • SVGなど危険な形式の拒否
  • Pillowによる画像デコード
  • マジックバイトの確認
  • ファイルサイズ上限

拡張子やContent-Typeはクライアント側で偽装できるため、それだけを信用しないことが重要です。

破壊的操作には再認証と監査ログを必須化する

次の操作には、パスワード再認証を必須としました。

  • サイト閉鎖(301リダイレクトまたは410として終了する操作。移転先URLの指定もこの操作に含まれます)
  • サイト移籍
  • URL変更(正ホストの切り替え)

あわせて、操作者、実行日時、対象サイト、変更前後の値を監査ログへ記録します。

なお、サイト設定の復元はサーバー側の管理コマンドとして実装しており、実行にはサーバーそのものへのアクセス権限が必要です。これはWebのパスワード再認証とは別の防御線として位置づけています。

これらは、ログイン済み端末の放置やセッションハイジャックによる「サイト消滅」を防ぐ最後の防御線です。

Cloudflare Full(strict)化と、秘密鍵を外へ出さない証明書更新

オリジン証明書が自己署名証明書のままでは、CloudflareのSSL/TLSモードを Full (strict) にできません。この状態でワイルドカードDNSを先に追加すると、証明書検証に失敗し、サブドメインで526エラーが発生する可能性があります。

そのため、インフラ変更は次の順序で実施しました。

  1. オリジン証明書を正しい証明書へ差し替える
  2. Cloudflareを Full (strict) へ変更する
  3. ワイルドカードDNSを追加する

順序を逆にしないことが重要です。

秘密鍵はサーバー内で生成する

証明書にはCloudflare Origin CAを利用しました。ただし、Cloudflareの画面上で秘密鍵を生成する方式は使用していません。サーバー内で秘密鍵とCSRを生成し、CSRだけをCloudflareへ渡す方式にしました。

サーバー内
  ├─ 秘密鍵を生成
  └─ CSRを生成
          ↓
CloudflareへCSRのみ送信
          ↓
証明書を受け取る

この方式では、秘密鍵が一度もサーバー外へ出ません。チャット、クリップボード、作業端末、画面共有、作業ログへ秘密鍵が残るリスクを構造的に減らせます。数分の追加作業で済むため、オリジン証明書の発行では常にこの方式を採用してよいと考えています。

Dockerの証明書bind mountで mv を使ってはいけない

証明書ファイルをDockerコンテナへ個別ファイルとしてbind mountしている場合、更新方法にも注意が必要です。次のように mvrename で差し替えると、ホスト側では新しいファイルになっていても、コンテナ側では古いinodeを参照し続けることがあります。

mv new-cert.pem cert.pem

その結果、コンテナ内から新しい証明書が見えない状態になる可能性があります。今回は、既存ファイルの中身を上書きしてinodeを維持する方式にしました。

cp new-cert.pem cert.pem

その後、Nginxの設定確認とreloadを実行します。

nginx -t
nginx -s reload

コンテナへディレクトリ単位でマウントしているか、ファイル単位でマウントしているかによって挙動が異なるため、証明書更新手順は事前に検証しておく必要があります。

既存サイトを壊さないために使った3つの技法

今回のプロジェクトで、実は最も重要だったのは「変えないことを証明する仕組み」でした。

1. バイト一致スナップショットテスト

既存ブログの共通テンプレートへ共有機能のフックを追加する前に、機能フラグがすべてOFFの状態でHTMLをレンダリングし、その結果をスナップショットとして保存しました。変更後も同じ入力でレンダリングし、バイト単位で完全一致することをテストします。

self.assertEqual(actual_html, expected_html)

「見たところ変わっていない」と、「CIが1バイトも変わっていないことを保証している」では、安心感がまったく異なります。ただし、動的なCSRFトークン、日時、ランダム値、属性順序などが含まれる場合は、比較前に正規化する必要があります。

2. テンプレートタグの読み込み範囲を限定する

ここでは一度、実際に事故が発生しました。ブログの base.html 先頭へ、CMSエンジンのテンプレートタグを追加しました。

{% load cms_tags %}

すると、CMSアプリを INSTALLED_APPS に含めていない軽量テスト設定で、テンプレートの読み込み時に TemplateSyntaxError が発生しました。

対策として、CMS機能を使う部分を部分テンプレートへ分離し、機能フラグがONの場合だけ読み込む構成へ変更しました。

{% if cms_feature_enabled %}
    {% include "cms/_shared_hook.html" %}
{% endif %}

そして、cms/_shared_hook.html の内部でのみテンプレートタグを読み込みます。

{% load cms_tags %}

これにより、機能フラグがOFFの環境では、CMS用テンプレートタグへの依存を最小限に抑えられます。{% include %} の中身は、そのブロックが実際に実行されるまでコンパイルされない、というDjangoテンプレートエンジンの遅延評価の性質を利用した対策です。

3. レスポンスヘッダーの完全一致テスト

サイト別CSPを導入する際も、既存サイトのレスポンスヘッダーを変更しないことをテストで固定しました。既存ヘッダーの値をリテラルとして保持し、完全一致を確認します。

self.assertEqual(
    response.headers["Content-Security-Policy"],
    LEGACY_CSP_HEADER,
)

これにより、次の状態を維持できます。

  • 新しいサイトには強制CSPを適用する
  • 既存サイトには従来のCSPを維持する
  • 将来のリファクタリングで意図せず既存ヘッダーが変わった場合はCIを失敗させる

SEO対応で見つけたDjango sitemapの落とし穴

サブドメインサイト用に sitemap.xml を追加した際、既存構成では誤ったURLが生成されるケースを発見しました。

django.contrib.sitemaps をDjango Sites Frameworkと組み合わせて利用している場合、ドメイン部分が SITE_ID に対応するSiteオブジェクトから組み立てられることがあります。一方、URLパスはリクエスト時のURLconfを使って reverse されます。その結果、構成によっては次のようなURLが生成されます。

メインドメイン + サブドメインサイト用パス

例えば、本来は次のURLであるべきなのに、

https://game.example.com/events/1/

次のような存在しないURLが出力される可能性があります。

https://example.com/events/1/

または、ホストとパスの組み合わせが不整合になる場合があります。マルチホスト構成でDjango標準のsitemapビューを使用している場合は、実際に出力されたURLを確認することを勧めます。

リクエストホストを使うsitemapビューを作成した

エンジンサイト用には、リクエストのホスト情報を基準に絶対URLを生成する独自ビューを用意しました。さらに、サイトに所属する公開済みコンテンツだけを出力します。メインドメインのsitemapはすでにSearch Consoleへ登録済みだったため、既存側は変更していません。

canonicalはHTTP Linkヘッダーで追加した

新しいサイトへcanonicalを追加する必要がありましたが、既存テンプレートへ次のタグを追加すると、既存HTMLの出力が変わります。

<link rel="canonical" href="https://example.com/page/">

そこで、新サイトに限ってHTTPの Link ヘッダーを付与しました。

Link: <https://game.example.com/page/>; rel="canonical"

これにより、既存テンプレートを変更せずにcanonicalを指定できます。Googleが公式にサポートしている方式です。

HTMLを書き換えるミドルウェアとの戦い

リリース直後、自分自身が管理画面を確認したところ、「文字色が背景と同化して読めない」という問題が発生しました。原因は、今回追加したCMSではありません。既存の別機能として、すべてのHTMLレスポンスへユーザーテーマ属性とCSSを注入するミドルウェアが存在していました。

注入されたCSSの一部は次のような内容でした。

html[data-persona-theme] body {
    color: #1f2937;
}

この指定が、ダークテーマの管理画面に設定していた明るい文字色を上書きし、暗い背景に濃いグレーの文字が表示されていました。

レスポンス書き換え系ミドルウェアは本番相当で確認する

HTMLを書き換えるミドルウェアが存在するプロジェクトでは、新しい画面を単体で確認するだけでは不十分です。次の条件を含む、本番相当の経路で確認する必要があります。

  • 全ミドルウェアを有効化する
  • 本番用コンテキストプロセッサーを利用する
  • 本番用テンプレート継承を利用する
  • 実際の認証状態でアクセスする
  • ライトテーマとダークテーマの両方を確認する

対策として、注入されるCSSより詳細度の高いルールを管理画面側へ追加しました。必要な箇所に限り、!important も利用しています。さらに、対抗CSSがテンプレートから削除された場合にテストが失敗するよう、その存在をテストで固定しました。

移行を6フェーズに分割した

今回の移行は、次の6フェーズに分割しました。

フェーズ1:サイト台帳とホストルーティング サイト台帳モデルの追加、ホスト台帳の追加、サイト解決ミドルウェアの追加、未登録ホストの421拒否、主要サイトのフォールバック。

フェーズ2:既存CMSのマルチテナント化 主要モデルへの site 外部キー追加、既存データのbackfill、サイト単位のユニーク制約追加、クエリのサイトスコープ化。

フェーズ3:管理画面とアクセスゲート 管理専用サブドメイン、スタッフ権限チェック、TOTP認証、サイト作成画面、機能フラグ管理。

フェーズ4:サイトライフサイクル サイト閉鎖、改名、URL変更、301・410制御、ゲーム移籍、パスワード再認証、監査ログ。

フェーズ5:既存サイトの編入 既存ブログをサイト台帳へ登録、既存URLの維持、機能フラグOFF時の完全互換、バイト一致スナップショットテスト、既存CSP・レスポンスヘッダーの維持。

フェーズ6:SEO・セキュリティ・運用強化 サイト別sitemap、canonical、ワイルドカードDNS、Cloudflare Full(strict)、WebSocket Origin検証、スナップショット、ドリフト監査、設定履歴と復元機能。

各フェーズは単体でデプロイでき、問題が発生した場合は、そのフェーズだけをロールバックできるようにしました。

バックアップを「任意」ではなく「実行条件」にする

サイト閉鎖や移籍などの操作は、完了済みスナップショットが存在しなければ実行できないようにしました。スナップショットには、次の情報を含めます。

  • 対象サイトの全行データ
  • 関連モデルのJSON
  • 参照メディアファイル

メディアファイルはtar形式でまとめ、Webから直接公開されないディレクトリへ保存します。重要なのは、「バックアップを作成できる」ではなく、「バックアップを作成しなければ破壊的操作へ進めない」ことです。人間の注意力に依存する運用は、いずれ失敗します。

壊れたことに気づく道具と、戻す道具を先に作る

マルチサイト化では、新機能を増やすだけでなく、異常を検知して復旧する仕組みも必要です。今回追加した主な運用機能は次のとおりです。

  • DNSとサイト台帳のドリフト監査(DNSが解決できないホスト、正規ホストが未設定のサイト、正規ホストが重複しているサイトを検出する)
  • サイト設定のバージョン履歴と復元コマンド
  • 破壊的操作の前提条件チェック(完了済みスナップショットが存在しない場合は、操作自体を実行できないようにする)

「壊れない設計」だけでは不十分です。壊れたときに気づけることと、すぐに戻せることをセットで設計する必要があります。

完成後の状態

ここまで実装した結果、次の状態を実現できました。

  • 既存サイトのURLを維持
  • 既存サイトの主要HTML出力を維持
  • 既存サイトのSEO設定を維持
  • 既存サイトのレスポンスヘッダーを維持
  • 外形監視は全項目正常
  • 未登録サブドメインは421で拒否
  • 管理画面から新規サイトを作成可能
  • 新規サイトは作成直後から公開可能
  • サイト単位でCMS機能を切り替え可能
  • 閉鎖や移籍にはスナップショットと再認証が必須
  • 変更履歴と復元手段を確保

次の段階では、以下の対応を予定しています。

  • Cloudflare Authenticated Origin Pullsを利用したオリジンmTLS
  • アップロード資産の別オリジン分離
  • メディア専用CSP
  • サイト単位のレート制限
  • サイト単位のストレージ使用量制限
  • テナント横断クエリの静的検査
  • 管理操作への承認フロー追加
  • 421レスポンス数や閉鎖サイトへのアクセスを可視化する監視ダッシュボード
  • サイト設定の差分表示、メディアファイルの欠損チェック
  • 301リダイレクトループの自動検出、sitemap内URLの定期的な外形検査

まとめ

今回のマルチテナント化で最も重要だったのは、きれいな新設計を作ることではありません。すでに動いているものを壊さず、段階的に新しい構造へ移行するための仕組みを作ることでした。

特に効果が大きかったのは、次の方法です。

  • nullable外部キーから始める3段階マイグレーション
  • 既存HTMLのバイト一致スナップショットテスト
  • 2テナントを使ったHTTPレベルの隔離テスト
  • 主要サイトだけを生存させる限定的なフォールバック
  • 変更順序を固定したインフラ切り替え
  • バックアップを破壊的操作の実行条件にする設計
  • 異常検知と復元手段を先に用意する運用設計

これらの仕組みは、マルチテナント化に限らず、既存システムの大規模リファクタリング、CMS統合、認証基盤の変更、データベース移行などにも応用できます。

「動いているものを壊さずに作り替えたい」と考えている人の参考になれば幸いです。

読んだ内容を10問練習と実技で確認

記事で理解した用語を、StudyQuestの演習とクラウド実技ラボで定着させます。

10問練習 実技ラボ

コメント(0件)

まだコメントはありません。最初のコメントを投稿してください!

コメントを投稿