Article

マルチテナントSaaS開発事例:一つのプラットフォームで複数企業を支援

高田晃太郎
マルチテナントSaaS開発事例:一つのプラットフォームで複数企業を支援

単一のSaaSプラットフォームで複数企業を同時運用しながら、主要エンドポイントのP95(全体の95%が収まる遅延)を実用的な水準に保ち、テナント間データ混入を防ぐ。これは理想論ではなく、業界で確立された設計と運用の積み重ねで到達可能な目標だ。ベンチマークとしては、SaaSではテナント単位の可観測性やP95/P99での管理が推奨され、ノイジーネイバー(他テナントの負荷が自分に波及する現象)対策を含む運用設計が重要とされている¹¹²。初期段階ではシングルテナントの複製運用でしのぐチームも多いが、顧客追加のたびにデプロイが増殖し、運用コストと複雑性が加速度的に高まる。テナント別コストの可視化と最適化はSaaSにおける共通課題であり、計測と配賦の仕組みを先に作ることが肝要だ⁴⁹。アーキテクチャの転換(共有から分離のハイブリッド化など)により、テナント数が増えてもコスト増を緩やかにし、オンボーディング時間を週単位から日単位へ短縮できることは一般に報告されている⁴。

マルチテナントは魔法ではない。最大のリスクは、性能ノイズの波及とセキュリティ境界の曖昧化だ。だからこそ分離の強度、強制の仕組み、運用の見える化を先に決め、その制約の中で拡張する。これは一般にノイジーネイバー問題と可観測性・分離戦略の設計として整理され、広く推奨されている²¹⁰。以下では、RLSとスキーマ分離のハイブリッド戦略、ゼロダウンタイムのプロビジョニング、テナントごとの認証・監査、SLOとコスト管理まで、現場で機能する実装パターンをコードと要点で解きほぐす。

分離戦略の設計:RLSとスキーマ分離のハイブリッド

最初に決めるべきは分離レベルだ。全テナントを単一スキーマで共有しRow Level Security(RLS: 行レベルでアクセス制御するDB機能)で強制する方法は、開発速度が速くコスト効率に優れる。一方で、高トラフィックの大口テナントはクエリ競合とストレージ膨張を招きやすく、スキーマまたはDB分離でのスケールアウトが望ましい。実務では、コアはRLS、ホットパスや大容量はスキーマ分離へ昇格という二層構えがよく採用される。RLSはPostgreSQLが正式に提供する分離機構であり、適切に適用すればテナント間のデータアクセスを強制できるが、設計と実装の徹底が必要だ¹⁵¹⁰。アプリは常にテナントIDをコンテキストに持ち、PostgreSQLのセッション変数に流し込む。強制はDBポリシー、利便はアプリ側の型とミドルウェアで担保する⁵。

テナント解決とセッション強制

テナントはサブドメインとOIDCクレームの双方で解決する。HTTP層で判定し、DB接続ごとにセッション変数を設定してクエリに透過させる⁵。

// src/middleware/tenant.ts
import { Request, Response, NextFunction } from 'express';
import { Pool, PoolClient } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export type TenantContext = Request & { tenantId: string; db: PoolClient };

export async function tenantContext(req: Request, res: Response, next: NextFunction) {
  const host = req.headers.host || '';
  const sub = host.split('.')[0];
  const claim = (req as any).user?.tenant_id as string | undefined;
  const tenantId = claim || sub;
  if (!tenantId) return res.status(400).send('tenant not resolved');

  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    await client.query('SET LOCAL app.tenant_id = $1', [tenantId]);
    (req as TenantContext).tenantId = tenantId;
    (req as TenantContext).db = client;
    res.on('finish', async () => {
      try { await client.query('COMMIT'); } finally { client.release(); }
    });
    next();
  } catch (e) {
    await client.query('ROLLBACK');
    client.release();
    next(e);
  }
}

セッション変数を使うのは、アプリ側でクエリ条件の付け忘れを防ぐためだ。これに合わせて、PostgreSQLのRLSを有効化し、app.tenant_idと一致する行のみ見えるようにする⁵。

-- migrations/2025_enable_rls.sql
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id', true));

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = current_setting('app.tenant_id', true));

実運用では、USINGに加えてWITH CHECKポリシーを定義して「書き込み時のテナント整合性」も強制するのが推奨だ。国内事例でもRLS導入に際し、この点を含む設計・検証が重要と報告されている⁵¹³。

-- 推奨: 書き込み時の整合性も強制
CREATE POLICY tenant_isolation_insupd ON invoices
  FOR INSERT, UPDATE
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true));

CREATE POLICY tenant_isolation_insupd ON projects
  FOR INSERT, UPDATE
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true));

RLSは強力だが、間違ったクエリ計画や全表スキャンが他テナントに影響する場合がある。RLS適用時の性能面の注意点や代替案は複数報告されているため、ホットテナントは専用スキーマへ昇格し、同じアプリケーションから接続先を切り替える方針が現実的だ¹⁰²。接続メタはコントロールプレーンで管理し、ランタイムで引き当てる²。

// src/tenants/registry.ts
import { Pool } from 'pg';

type Target = { dsn: string; kind: 'shared' | 'dedicated'; schema?: string };
const registry = new Map<string, Target>();

export function setTenantTarget(tenantId: string, target: Target) {
  registry.set(tenantId, target);
}

export function getTenantPool(tenantId: string) {
  const target = registry.get(tenantId);
  if (!target) throw new Error('tenant not registered');
  const pool = new Pool({ connectionString: target.dsn });
  return { pool, target };
}

プロビジョニングと移行:ゼロダウンタイムで増やし、育てる

テナントは増やすだけでなく、成長に応じて昇格させる必要がある。新規テナントの作成はトランザクションに閉じ込め、失敗時は自然にロールバックする。重複実行に強いように冪等キーを用意し、ジョブは再実行可能に設計する。専用スキーマを割り当てる昇格処理はオンラインで進め、読み取りを先行切り替え、書き込みを二相でスワップする。

// src/workers/provisioner.ts
import { Queue, Worker, Job } from 'bullmq';
import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const queue = new Queue('tenant-provision');

export const worker = new Worker('tenant-provision', async (job: Job) => {
  const { tenantId, plan } = job.data as { tenantId: string; plan: 'shared'|'dedicated' };
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    await client.query('INSERT INTO tenants(id, plan) VALUES ($1,$2) ON CONFLICT (id) DO NOTHING', [tenantId, plan]);
    await client.query('SET LOCAL app.tenant_id = $1', [tenantId]);
    await client.query('INSERT INTO projects(id, tenant_id, name) VALUES (gen_random_uuid(), $1, $2) ON CONFLICT DO NOTHING', [tenantId, 'Getting Started']);
    if (plan === 'dedicated') {
      await client.query(`CREATE SCHEMA IF NOT EXISTS t_${tenantId}`);
      await client.query(`CREATE TABLE IF NOT EXISTS t_${tenantId}.events (id uuid primary key, ts timestamptz, body jsonb)`);
    }
    await client.query('COMMIT');
  } catch (e) {
    await client.query('ROLLBACK');
    throw e;
  } finally {
    client.release();
  }
});

スキーマ昇格はテーブルロックを避け、バックフィルを分割して行う。長時間トランザクションを持たないように、バッチの境界でコミットする。書き込み二重化期間を短く取り、スワップの瞬間を明確にする。

-- online migration pattern (simplified)
ALTER TABLE invoices ADD COLUMN customer_ref uuid;
-- backfill in chunks
WITH cte AS (
  SELECT id, customer_id FROM invoices WHERE customer_ref IS NULL ORDER BY id LIMIT 10000
)
UPDATE invoices i SET customer_ref = c.customer_id FROM cte c WHERE i.id = c.id;
-- repeat until done, then swap
ALTER TABLE invoices ALTER COLUMN customer_ref SET NOT NULL;
CREATE INDEX CONCURRENTLY idx_invoices_tenant_ref ON invoices(tenant_id, customer_ref);

認証・権限・監査:テナント境界を越えさせない

認証はOIDCをテナント単位で構成し、JIT(ログイン時)プロビジョニングでユーザを作る。権限は役割ベースに属性ベースを足し、プロジェクトや部門の境界を柔軟に切る。重要なのは、テナントIDがリクエスト全体を貫通する構造だ。OIDCのクレームに紐づけ、APIからDB、ログ、メトリクスまで同じIDを持ち運ぶのがSaaSで推奨される観測・運用パターンである¹¹。

// src/auth/oidc.ts
import { Issuer, generators } from 'openid-client';
import express from 'express';
import session from 'express-session';

const app = express();
app.use(session({ secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false }));

async function setup() {
  const issuer = await Issuer.discover(process.env.OIDC_ISSUER!);
  const client = new issuer.Client({ client_id: process.env.OIDC_CLIENT_ID!, client_secret: process.env.OIDC_CLIENT_SECRET!, redirect_uris: [process.env.OIDC_REDIRECT!], response_types: ['code'] });
  app.get('/login', (req, res) => {
    const state = generators.state();
    const url = client.authorizationUrl({ scope: 'openid email profile', state });
    (req.session as any).state = state;
    res.redirect(url);
  });
  app.get('/callback', async (req, res) => {
    const tokenSet = await client.callback(process.env.OIDC_REDIRECT!, req.query, { state: (req.session as any).state });
    const id = tokenSet.claims();
    const tenantId = (id as any)['https://example.com/tenant_id'];
    if (!tenantId) return res.status(400).send('tenant claim missing');
    (req.session as any).user = { sub: id.sub, email: id.email, tenant_id: tenantId, roles: id.roles };
    res.redirect('/');
  });
}
setup();

全アクションは監査ログに書き込み、パーティションで保持する。ログは不可逆で、後からの削除は論理削除に限る。検索性能を保つために日付とテナントIDで二重にパーティションを切る、といった運用もテナント可視化の基盤として有効だ¹¹。

-- audit log DDL with partitioning by month and tenant
CREATE TABLE IF NOT EXISTS audit_log (
  id bigserial PRIMARY KEY,
  ts timestamptz NOT NULL DEFAULT now(),
  tenant_id text NOT NULL,
  actor text NOT NULL,
  action text NOT NULL,
  resource text NOT NULL,
  payload jsonb NOT NULL
) PARTITION BY RANGE (ts);

CREATE TABLE IF NOT EXISTS audit_log_2025_08 PARTITION OF audit_log FOR VALUES FROM ('2025-08-01') TO ('2025-09-01');
CREATE INDEX IF NOT EXISTS audit_log_tenant_ts ON audit_log_2025_08 (tenant_id, ts DESC);
// src/audit/logger.ts
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export async function writeAudit(tenantId: string, actor: string, action: string, resource: string, payload: object) {
  const client = await pool.connect();
  try {
    await client.query('INSERT INTO audit_log(tenant_id, actor, action, resource, payload) VALUES ($1,$2,$3,$4,$5)', [tenantId, actor, action, resource, JSON.stringify(payload)]);
  } finally {
    client.release();
  }
}

テナントごとのレート制限も重要だ。Redisベースのトークンバケットを使い、テナントごとに上限とバーストを設定する。バックエンドは恒常的な高負荷よりもバーストに弱いので、単位時間の平滑化が効く²。

// src/middleware/rateLimit.ts
import { Redis } from 'ioredis';
import { Request, Response, NextFunction } from 'express';

const redis = new Redis(process.env.REDIS_URL!);

export async function rateLimit(req: Request, res: Response, next: NextFunction) {
  const tenantId = (req as any).tenantId || 'public';
  const key = `rl:${tenantId}`;
  const limit = 1000; // tokens per minute
  const now = Math.floor(Date.now() / 1000);
  const window = 60;
  const tokens = await redis.eval(
    `local tokens = redis.call('GET', KEYS[1])
     if not tokens then tokens = ARGV[1] end
     tokens = tonumber(tokens)
     tokens = math.min(tokens + (ARGV[1] * (ARGV[3]/ARGV[2])), ARGV[1])
     if tokens < 1 then redis.call('SET', KEYS[1], tokens) return 0 end
     tokens = tokens - 1
     redis.call('SETEX', KEYS[1], ARGV[2], tokens)
     return tokens`,
    1, key, limit, window, 1
  );
  if (Number(tokens) <= 0) return res.status(429).send('rate limit');
  next();
}

観測性とSLO:数字で管理し、早めに手当てする

マルチテナントでは、平均値は意味を失う。テナントごと・エンドポイントごとの分布を見て、P95やP99(遅延分布の上位5%/1%の指標)の裾野を詰める。OpenTelemetryでトレースにtenant_idを必ずタグ付けし、メトリクスはレイテンシ、エラー率、スロットリング、キュー滞留、DB待機を同じ指標系で並べる。スローダウンの主因を特定し、ホットテナントの保護他テナントへの波及抑制を両立させることが推奨される²¹¹。

// src/telemetry/otel.ts
import { trace, context, SpanKind } from '@opentelemetry/api';

export function withTenantSpan(tenantId: string, name: string, fn: () => Promise<any>) {
  const tracer = trace.getTracer('app');
  const span = tracer.startSpan(name, { kind: SpanKind.SERVER });
  span.setAttribute('tenant.id', tenantId);
  return context.with(trace.setSpan(context.active(), span), async () => {
    try { return await fn(); } catch (e) { span.recordException(e as Error); throw e; } finally { span.end(); }
  });
}

例えば、共有スキーマのままだと月次締め処理などの重いジョブでP99が高止まりしやすい。バッチをオフピークへずらし、インデックスをカバリングに調整し、さらに支払いテーブルなどホットな領域だけを専用スキーマへ昇格させると、ピーク時間帯の長尾遅延が目に見えて改善する、という報告は少なくない。DB接続プールをテナント間で論理分割し、ホットテナント向けの最大接続に上限を設けると、他テナントへの干渉を抑えつつ全体の成功率を高水準で維持しやすい。これらは一般に「ノイジーネイバー抑制」と「ワークロードのパーティショニング」が有効とされる知見とも整合する²。インフラコストは、コスト配賦と可視化を前提に、圧縮やTTLの導入でストレージ増分を抑制し、テナント数の増加に対してコスト増分を鈍化させるアプローチが広く採られている⁴⁹。オンボーディング時間も、自動プロビジョニングとテンプレート化で日単位まで短縮可能なケースが多い。

障害時のふるまいと回復設計

障害はテナント境界で止める。バックグラウンドジョブはテナント別のキューに分け、再試行は指数バックオフ、期限切れはデッドレターに送る。キャッシュはテナントIDを必ずキーに含め、エッジでのキャッシュもパージ単位をテナントに合わせる。フェイルオーバは共有DBのスローダウンを検知し、専用スキーマへ昇格させるエスカレーションを自動化することで、SLO違反の兆しを被害最小で止めやすくなる。

ビジネス効果:共通化しつつ、企業の違いに寄り添う

マルチテナントの真価は運用効率だけではない。機能フラグと設定の拡張点を意図的に設計しておけば、個別要望をソース分岐なしに吸収できる。レイアウトや承認フロー、データ保持期間の違いをメタデータで表現し、実行時に解決する。プロダクトの一貫性を壊さずに顧客の現場に合わせることができる。これらはテナント単位のメトリクス・設定駆動運用と相性が良く、SaaSの一般的な推奨事項でもある¹¹⁴。

// src/feature/flags.ts
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

type Flag = { key: string; on: boolean; payload?: any };

export async function getFlags(tenantId: string): Promise<Record<string, Flag>> {
  const { rows } = await pool.query('SELECT key, on, payload FROM feature_flags WHERE tenant_id = $1', [tenantId]);
  return rows.reduce((acc, r) => { acc[r.key] = { key: r.key, on: r.on, payload: r.payload }; return acc; }, {} as Record<string, Flag>);
}

価格設計もテナント境界でメータリングし、機能段階と使用量の両軸で課金する。RLSに載せたイベントテーブルから集計し、課金の根拠と監査可能性を担保する。SaaSでは「テナント別の使用量とコストの可視化」「プロダクト内メトリクスの収集・可視化」が収益性最適化の前提とされる¹⁴¹¹。

データ所在地とコンプライアンス

地域分散が必要な案件では、テナント登録時にデータ所在を選び、そのリージョンのDB・ストレージを割り当てる。アプリは同じだが、コントロールプレーンが接続先と機能セットを決める。バックアップ、鍵管理、ログ保持のポリシーはテナントの所在に紐づけ、エクスポートや削除要求のトレーサビリティを確保する。規約やDPAの更新はテナントメタに反映させておき、監査証跡と一貫させることで、審査の摩擦を減らせる。

まとめ:強制で守り、拡張で育て、数字で運用する

マルチテナントSaaSは、分離と拡張と観測性の三脚で立つ。RLSで強制力のある境界を持たせ、負荷が高まるテナントはスキーマ分離に昇格させる。プロビジョニングは冪等で、移行はオンラインで、障害はテナント境界で止める。認証と監査はテナントIDを貫通させ、メトリクスとトレースはテナント単位で見える化する。こうした設計により、一般にP95を数百ms以下といった実用的な目標に近づけ、成功率の高水準を保ちながら、オンボーディングの短縮やコスト増の抑制が期待できる。これらはSaaSの一般的な推奨事項とも合致する¹⁵²¹¹⁰。

次に検討すべき問いは明確だ。あなたのプロダクトで、RLSの一括強制とスキーマ昇格の境界はどこに引けるだろうか。ホットテナントを特定するためのメトリクスは整っているだろうか。もし答えが揃っていないなら、まずはテナントIDの貫通と監査ログの整備から始めてほしい。今日の一歩が、明日のスケールに耐える土台になる。

参考文献

  1. AWS Database Blog(日本語): PostgreSQL の行レベルセキュリティを備えたマルチテナントデータの分離. https://aws.amazon.com/jp/blogs/news/multi-tenant-data-isolation-with-postgresql-row-level-security/
  2. New Relic Blog: Monitoring multi-tenant SaaS applications (Noisy neighbor problem). https://newrelic.com/kr/blog/how-to-relic/monitoring-multi-tenant-saas-applications
  3. AWS Partner Network Blog: Optimizing Cost Per Tenant Visibility in SaaS Solutions. https://aws.amazon.com/blogs/apn/optimizing-cost-per-tenant-visibility-in-saas-solutions/
  4. PostgreSQL Official Documentation: Row Security Policies. https://www.postgresql.org/docs/current/ddl-rowsecurity.html
  5. Simform Blog: Cost optimization in multi-tenant SaaS applications. https://www.simform.com/blog/cost-optimization-multi-tenant-saas-applications/
  6. Bytebase Blog: Postgres Row Level Security — Limitations and Alternatives. https://www.bytebase.com/blog/postgres-row-level-security-limitations-and-alternatives/
  7. AWS Partner Network Blog: Capturing and Visualizing Multi-tenant Metrics inside a SaaS Application on AWS. https://aws.amazon.com/blogs/apn/capturing-and-visualizing-multi-tenant-metrics-inside-a-saas-application-on-aws/
  8. Sansan BuildersBox: RLS を活用したマルチテナント分離の取り組み(事例). https://buildersbox.corp-sansan.com/entry/2021/05/10/110000