マルチテナント設計とランブック実例
SaaS を作っていると、あるタイミングで必ずこうした不安が出てきます。
- 「このままユーザーが増えたら、本当に耐えられるのか?」
- 「1 社ごとにカスタマイズしていたら、あとでメンテ不能にならないか?」
- 「マルチテナントって言うけれど、何をどこまでやれば“ちゃんと”スケールするのか?」
本記事では、Next.js + Vercel + Postgres の構成を前提にしつつ、
- SaaS がスケールするための“構造的な前提”
- その中核となるマルチテナント設計の考え方
- 実際のランブック(設計ドキュメント)の具体例
を整理します。
個別技術の使い方ではなく、**「なぜこの構造にしておくとスケールに耐えられるのか」**という観点にフォーカスします。
1. 「スケールしても耐えうる」とは何に耐えることか
SaaS が成長するときに増えていくものは、だいたい次の 3 つに集約できます。
-
リクエストの量
同時にアクセスするユーザー数やリクエスト数。
例:朝 9 時に全ユーザーがダッシュボードを開きにくる。 -
データの量
DB に溜まるレコード・履歴・ログの量。
例:TODO、OKR、コメント、ログが年単位で積み上がる。 -
複雑さ
機能数・画面数・外部連携・顧客ごとの例外ルール。
例:A 社だけ OKR なし、B 社だけ独自のレイアウト、など。
「スケールしても耐えうる」というのは、これらが増えてもなお、
- 体感速度が落ちない(レスポンスが安定している)
- サービスが落ちたり、データが壊れたりしない(信頼性が保たれる)
- 開発・運用コストが指数関数的に増えない(ビジネスとして採算が合う)
という状態を維持できることです。
2. SaaS がスケールするための 4 つの構造
抽象化すると、SaaS がスケールに耐えるために必要なのは、次の 4 つの構造です。
- アプリケーション層を「横に増やせる」構造(スケールアウト)
- データ層で「きちんと区切って・絞って読む」構造
- 混雑時に壊れないための混雑制御
- 人力に依存しすぎない運用・監視の仕組み
ひとつずつ、SaaS 全般の話として整理します。
2-1. アプリを「横に増やせる」構造
人気のレストランを思い浮かべてみてください。
- 悪いパターン:厨房を巨大化して 1 店舗で全部さばこうとする
- 良いパターン:同じキッチンを複数店舗として横に並べて、注文を分散する
サーバーも同じで、1 台の超モノリスに寄せず、同じアプリを横に並べられる構造が必要です。
そのためのポイントは、
- リクエストごとに状態を持たない(stateless)
- セッションやユーザー情報は、Cookie / JWT / セッションストアなどに逃がす
- 同じアプリを複数インスタンス立てても問題なく動く
といった「スケールアウト前提の設計」です。
Next.js + Vercel のような構成は、serverless 関数が基本的に stateless なため、
- アクセスが増えたら関数インスタンスを増やす
- アプリケーション側では 1 リクエストを素早く処理して返す
という構造を取りやすくなっています。
2-2. データを「きちんと区切って・絞って読む」構造
真のボトルネックになりやすいのは、DB です。
よくある失敗パターンは、
- 全ユーザー・全顧客のデータを 1 つのテーブルにベタ置きして
- 「なんとなく WHERE 句で検索するだけ」の状態
です。これだと、データ量の増加とともに、
- 読み込みが遅くなる
- 間違ったクエリで他ユーザー/他社のデータが見えてしまう
という問題が顕在化します。
そこで重要になるのが、次の 3 点です。
-
必ず「区切りとなる軸」を持たせる
例:tenant_id(どの会社か)、workspace_id(どのチームか)、created_at(いつのデータか) -
その軸で絞り込み・索引を持つ
例:where tenant_id = ? and workspace_id = ?に対応するインデックス -
重い処理はキューやバッチで分散する
例:大量集計・エクスポートは同期 API でやらず、バッチキューに流す
ここで出てくる tenant_id / workspace_id が、まさに後述する「マルチテナント設計」の中核になります。
2-3. 混雑時に壊れない混雑制御
スケールの世界では、
- 普段は問題ないが、ピーク時間だけ急に遅くなる/落ちる
という現象がよく起こります。
これを避けるには、
- 同時実行数に上限を設ける(レートリミット)
- 処理しきれないタスクはキューに積んで、少しずつ処理する
といった交通整理の仕組みが有効です。
SaaS の機能が増えるほど、
- 「ユーザー操作に対して即時に必要な処理」と
- 「裏側でゆっくりやってよい処理」
を切り分ける設計が、スケール性に直結してきます。
2-4. 人力に頼らない運用・監視
最後に、人間のリソースも「スケールの限界」です。
- リリースのたびに手作業で設定を変える
- 障害のたびにログを生で見て原因を追う
という世界だと、ユーザーが増えるほど運用担当者が疲弊していきます。
そこで重要になるのが、
- CI/CD(自動テスト+自動デプロイ)
- モニタリングとアラート(異常検知)
- ロールバックの容易さ
といった、自動化されたオペレーションの仕組みです。
3. マルチテナント設計が SaaS のスケールに効く理由
ここからは、SaaS ならではの論点である「マルチテナント」について掘り下げます。
3-1. よくあるアンチパターン
SaaS の顧客が増えてくると、次のような構成をとりたくなる誘惑があります。
パターン A:顧客ごとにシステムを丸ごとコピー
my-saas-appmy-saas-app-tommy-saas-app-xyz
と、リポジトリやプロジェクトを顧客ごとにコピーしてしまうパターンです。
これは一見シンプルですが、
- バグ修正や新機能をすべてのコピーに反映する必要がある
- どの環境にどのバージョンが入っているか分かりにくい
- 顧客ごとの差分が増えるほど管理不能になる
という意味で、運用・開発コストの面でスケールしません。
パターン B:1 つの DB にベタ置きで「誰のデータか」分からない
もうひとつは、全ユーザー分のデータを 1 つのテーブルに詰め込みつつ、
- 「どの会社のデータか」を示すカラムがない
- あっても必須でなかったり、インデックスがなかったりする
というパターンです。
これだと、
- クエリの書き方ひとつで他社のデータが見えてしまうリスク
- 行数が増えたときにパフォーマンスが急激に悪化するリスク
が高くなります。
3-2. 「1 プロダクト + マルチテナント」が基本線
スケール性と運用性のバランスをとるために、多くの SaaS が採用しているのが、
プロダクト(コードベース)は 1 つ。
その中に、複数の「区画(テナント)」を切る。
という構造です。
簡単に言うと、
- アプリケーションコードは共通
- DB も 1 つ(または少数)
- しかし、すべてのデータに「どのテナントの、どのワークスペースのデータか」という“住所ラベル”を必ず付ける
という設計です。
代表的なカラムが、次の 2 つです。
-
tenant_id:どの会社(クライアント)のデータか -
workspace_id:その会社の中の、どのチーム/プロジェクトか
すべての業務テーブルにこの 2 軸を入れておくことで、
- データをテナントごとにきちんと区切る
- クエリは必ず
tenant_id/workspace_idで絞る - インデックスもこの 2 軸に合わせて貼る
という構造を取ることができます。
4. マルチテナント設計ランブックの具体例
ここからは、実際に筆者が書いた「マルチテナント&ワークスペース設計ランブック」の一部を例に、どのような粒度で整理しているかを紹介します。
前提技術は、次の通りです。
- Next.js 15(App Router)
- Vercel(Serverless / Edge)
- Postgres(Supabase / 素の pg)
- RLS は使わず、アプリケーション側で分離を担保する方針
4-1. 用語定義:ドメイン/テナント/ワークスペース
まず、ランブックの冒頭で用語を固定します。
- ドメイン: `app.example.com`, `tom.example.com` などのサブドメイン
- テナント: 1 社・1 クライアント単位(例: TOM 株式会社)
- ワークスペース: テナント内のチーム/プロジェクト単位
- ユーザー: 1 つのテナントに属し、複数のワークスペースに参加しうる
これを曖昧にしたまま実装を進めると、後から「会社なのかチームなのかプロジェクトなのか」が混線するので、最初にきっちり固定しておきます。
4-2. tenants / workspaces テーブル設計
ランブックでは、最初にテナントとワークスペースのテーブル定義を示します。
create table if not exists tenants (
id uuid primary key default gen_random_uuid(),
subdomain text not null unique, -- 'app', 'tom' など
name text not null, -- テナント名
plan text not null default 'standard', -- 'standard', 'pro', 'custom' など
theme jsonb not null default '{}'::jsonb,
features jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create table if not exists workspaces (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references tenants(id),
name text not null,
config jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create index if not exists workspaces_tenant_idx
on workspaces(tenant_id);
ポイントは、
-
tenants.subdomainにapp/tomなどを入れておき、ホスト名と紐づける -
workspaces.configは JSONB にしておき、タブ構成やレイアウトを柔軟に変えられるようにしておく
ことです。
さらに、ワークスペースのメンバーシップも定義します。
create table if not exists workspace_members (
id uuid primary key default gen_random_uuid(),
workspace_id uuid not null references workspaces(id),
user_id uuid not null references users(id),
role text not null default 'member',
created_at timestamptz not null default now(),
unique (workspace_id, user_id)
);
create index if not exists workspace_members_workspace_idx
on workspace_members (workspace_id);
ここまで整えておくと、
- 「どのテナントの」「どのワークスペースに」「誰が」「どんな権限で」属しているか
- という情報を DB レベルで一貫して扱えるようになります。
4-3. 業務テーブルへの tenant_id / workspace_id 付与
次に、TODO や OKR など、実際の業務データテーブルに軸を追加します。
alter table todos
add column if not exists tenant_id uuid,
add column if not exists workspace_id uuid;
-- 既存データは既存APPテナント+Defaultワークスペースにひも付ける
update todos
set tenant_id = (select id from tenants where subdomain = 'app'),
workspace_id = (select id from workspaces where name = 'Default' limit 1)
where tenant_id is null or workspace_id is null;
alter table todos
alter column tenant_id set not null,
alter column workspace_id set not null;
alter table todos
add constraint todos_tenant_fk foreign key (tenant_id) references tenants(id),
add constraint todos_workspace_fk foreign key (workspace_id) references workspaces(id);
create index if not exists todos_tenant_workspace_idx
on todos (tenant_id, workspace_id);
ここで重要なのは、
- 「既存のシングルテナント環境」を 1 つの
tenant_idに集約する移行ステップ - 以降、新規データは必ず
tenant_id/workspace_idを持つことを強制する NOT NULL 制約
です。
4-4. アプリケーション層でのテナント解決
Next.js では、headers().get("host") からホスト名を取得できます。
ランブックでは、RootLayout でテナントを解決する例を示しています。
// app/layout.tsx
import { headers } from "next/headers";
import { getTenantBySubdomain } from "@/lib/server/tenants";
import { TenantProvider } from "@/lib/client/tenant-context";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const host = headers().get("host") ?? "app.example.com";
let subdomain = host.split(".")[0];
// ローカルやステージングは強制的に 'app' として扱う例
if (host.startsWith("localhost")) {
subdomain = "app";
}
if (host.endsWith(".stg.example.com")) {
subdomain = "app";
}
const tenant = await getTenantBySubdomain(subdomain);
if (!tenant) {
throw new Error(`Unknown tenant for subdomain: ${subdomain}`);
}
return (
<html>
<body>
<TenantProvider tenant={tenant}>{children}</TenantProvider>
</body>
</html>
);
}
こうすることで、
-
app.example.comに来たリクエスト →subdomain = 'app' -
tom.example.comに来たリクエスト →subdomain = 'tom'
と解釈し、テナント情報をコンポーネントツリー全体に渡すことができます。
4-5. リポジトリ層を「tenant-aware」にする
さらに、DB アクセスを行うリポジトリ層では、必ず tenantId / workspaceId を引数に取るようにします。
// lib/server/repos/todos.ts
import { db } from "@/lib/server/db";
export async function listTodos(params: {
tenantId: string;
workspaceId: string;
userId: string;
}) {
const { tenantId, workspaceId, userId } = params;
return db
.selectFrom("todos")
.selectAll()
.where("tenant_id", "=", tenantId)
.where("workspace_id", "=", workspaceId)
.where("user_id", "=", userId)
.orderBy("created_at", "desc")
.execute();
}
生 SQL や ORM を直接呼ぶのではなく、
- 「tenantId / workspaceId を必須とするリポジトリ関数」
を経由させることで、
- 「どのテナントの」「どのワークスペースの」データを触っているのかが常に明示的になる
- テストや監査のときも、リポジトリの呼び出しを追えば全体像が追いやすい
という状態を作れます。
5. まとめ:構造を先に決めておくことが、スケールへの一番の投資
本記事で整理したポイントを、SaaS 全般の話としてまとめると、次のようになります。
- スケールに耐えるかどうかは、アプリの構造・データの構造・運用の構造にかかっている
- マルチテナント設計はその中でも、「1 プロダクトで複数顧客を安全にさばく」ための核心部分
-
tenant_id/workspace_idのような軸を最初からスキーマに入れておくことで、- データをきちんと区切れる
- クエリを安全に書ける
- 将来、テナントを増やしたり分割したりする余地が残る
- ランブック(設計ドキュメント)に、
- 用語定義
- テーブル設計
- アプリ側の解決フロー
- リポジトリ層のルール
- DOD(Definition of Done)
を明文化しておくことで、チームメンバーや将来の自分が迷わず進められる
特に、まだテナント数が少ない段階こそ、
- 「どうせ今は 1 社だけだし」とシングルテナント前提で作るのではなく
- 「いずれ増える前提で、軸だけは先に入れておく」
ことが、将来の“作り直しコスト”を大きく減らします。
もし今、SaaS の設計を進めていて、
- マルチテナントをどう扱うか
- テナント単位の設定・カスタマイズをどう吸収するか
に悩んでいる場合は、
- まず「用語定義」と「テーブル設計」だけでも、ランブックとして 1 ファイルにまとめてみる
ところから始めてみると、チーム内の認識合わせにも大きく役立つはずです。