S
Shineos Tech Blog
マルチテナントSaaSアーキテクチャの設計指針 - スケーラブルなSaaS基盤の構築

マルチテナントSaaSアーキテクチャの設計指針 - スケーラブルなSaaS基盤の構築

| Shineos Dev Team
Share:

はじめに

SaaS(Software as a Service)ビジネスにおいて、マルチテナントアーキテクチャは複数の顧客(テナント)を効率的にサポートしながら、コストとリソースを最適化する重要な設計パターンです。しかし、適切な設計なしでは、セキュリティリスクやスケーラビリティの課題に直面する可能性があります。

本記事では、実務でマルチテナントSaaSを構築する際の設計指針を、データ分離戦略からスケーラビリティの実現方法まで詳しく解説します。

マルチテナントSaaSとは?

マルチテナントSaaSとは、単一のアプリケーションインスタンスとインフラで複数の顧客(テナント)にサービスを提供するアーキテクチャです。各テナントはアプリケーションとデータベースを共有しつつ、論理的には互いに分離されています。

マルチテナントの主な特徴

特徴説明メリット
リソース共有複数テナントが同じインフラを使用コスト削減、運用効率化
論理的分離データとアクセス権限を厳密に分離セキュリティ確保、プライバシー保護
集中管理単一のコードベースとデプロイ迅速なアップデート、メンテナンス効率化
柔軟なスケーリングテナント数に応じた動的なスケール成長に対応、リソース最適化

まとめ

本記事で解説したマルチテナントSaaSアーキテクチャの重要なポイントは以下の通りです:

データ分離戦略

  • テナントのセキュリティ要件とコストのバランスを考慮
  • スキーマ共有型、スキーマ分離型、データベース分離型の3つのパターンから選択
  • 多くのケースでスキーマ共有型が最適なバランスを提供

スケーラビリティ設計

  • 水平スケーリングを前提とした設計
  • キャッシング戦略とCDN活用による負荷軽減
  • シャーディングによる大規模データの分散

セキュリティとガバナンス

  • Row Level Security(RLS)による厳密なデータ分離
  • テナント単位の監視とリソース制限
  • 定期的なセキュリティ監査と脆弱性対策

データ分離戦略

マルチテナントSaaSで最も重要な設計判断の一つが、データ分離戦略です。主に3つのパターンがあります。

1. スキーマ共有型(Shared Schema)

最も一般的なパターンで、全てのテナントが同じデータベーステーブルを共有します。各レコードに tenant_id カラムを追加して、テナントを識別します。

メリット

  • 最もコスト効率が良い
  • リソース利用率が高い
  • 新規テナントの追加が容易

デメリット

  • データ漏洩リスクの慎重な管理が必要
  • 大規模テナントのパフォーマンス影響を受けやすい
// PostgreSQLでのRow Level Security (RLS) 実装例
CREATE TABLE users (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  email VARCHAR(255) NOT NULL,
  name VARCHAR(255),
  created_at TIMESTAMP DEFAULT NOW()
);

-- RLSポリシーの有効化
ALTER TABLE users ENABLE ROW LEVEL SECURITY;

-- テナントIDでフィルタリングするポリシー
CREATE POLICY tenant_isolation ON users
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- アプリケーションコードでテナントIDを設定
// Node.js + PostgreSQLの例
async function setTenantContext(client, tenantId) {
  await client.query(
    `SET LOCAL app.current_tenant_id = $1`,
    [tenantId]
  );
}

マルチテナントデータ分離の比較図

2. スキーマ分離型(Shared Database, Separate Schema)

データベースは共有しますが、各テナントが専用のスキーマ(テーブルセット)を持ちます。

メリット

  • データ分離が明確
  • テナント毎のカスタマイズが容易
  • バックアップと復元がテナント単位で可能

デメリット

  • スキーマ変更時のマイグレーション複雑化
  • 接続プールの管理が煩雑
-- テナント専用スキーマの作成
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;

-- 各スキーマにテーブルを作成
CREATE TABLE tenant_acme.users (
  id UUID PRIMARY KEY,
  email VARCHAR(255) NOT NULL,
  name VARCHAR(255)
);

CREATE TABLE tenant_globex.users (
  id UUID PRIMARY KEY,
  email VARCHAR(255) NOT NULL,
  name VARCHAR(255)
);

3. データベース分離型(Separate Database)

各テナントが完全に独立したデータベースを持ちます。

メリット

  • 最高レベルのデータ分離とセキュリティ
  • テナント毎のパフォーマンスチューニングが可能
  • 規制要件への対応が容易

デメリット

  • 運用コストが高い
  • スケールアップに限界がある
  • クロステナント分析が困難

スケーラビリティの実現

マルチテナントSaaSは、テナント数の増加に応じてスケールする必要があります。

水平スケーリング戦略

アプリケーション層とデータベース層の両方で水平スケーリングを実装します。

// アプリケーション層: ステートレス設計
// セッション情報はRedisなど外部ストアに保存
import { createClient } from 'redis';

const redisClient = createClient({
  url: process.env.REDIS_URL
});

// セッション管理
async function getSession(sessionId: string) {
  const session = await redisClient.get(`session:${sessionId}`);
  return session ? JSON.parse(session) : null;
}

async function setSession(sessionId: string, data: any) {
  await redisClient.setEx(
    `session:${sessionId}`,
    3600, // 1時間
    JSON.stringify(data)
  );
}

// テナント情報のキャッシング
async function getTenantConfig(tenantId: string) {
  const cacheKey = `tenant:${tenantId}:config`;
  
  // キャッシュから取得を試みる
  let config = await redisClient.get(cacheKey);
  
  if (!config) {
    // キャッシュミスの場合はDBから取得
    config = await db.query(
      'SELECT * FROM tenant_configs WHERE tenant_id = $1',
      [tenantId]
    );
    
    // キャッシュに保存(10分間)
    await redisClient.setEx(cacheKey, 600, JSON.stringify(config));
  }
  
  return JSON.parse(config);
}

データベースシャーディング

大規模なSaaSでは、テナントデータを複数のデータベースに分散させるシャーディングが有効です。

// シャーディングロジックの実装例
interface ShardConfig {
  id: number;
  host: string;
  port: number;
  database: string;
}

class TenantShardRouter {
  private shards: ShardConfig[];
  
  constructor(shards: ShardConfig[]) {
    this.shards = shards;
  }
  
  // テナントIDに基づいてシャードを決定
  getShardForTenant(tenantId: string): ShardConfig {
    // 一貫性ハッシュを使用してシャードを選択
    const hash = this.hashTenantId(tenantId);
    const shardIndex = hash % this.shards.length;
    return this.shards[shardIndex];
  }
  
  private hashTenantId(tenantId: string): number {
    let hash = 0;
    for (let i = 0; i < tenantId.length; i++) {
      const char = tenantId.charCodeAt(i);
      hash = ((hash << 5) - hash) + char;
      hash = hash & hash; // Convert to 32bit integer
    }
    return Math.abs(hash);
  }
  
  // データベース接続プールの管理
  async getConnection(tenantId: string) {
    const shard = this.getShardForTenant(tenantId);
    return await this.connectionPool.get(shard.id);
  }
}

マルチテナントSaaSのスケーリングアーキテクチャ

キャッシング戦略

効率的なキャッシングは、データベース負荷を軽減し、応答時間を改善します。

// 多層キャッシング戦略
class MultiTierCache {
  private l1Cache: Map<string, any>; // アプリケーションメモリ
  private l2Cache: any; // Redis
  
  async get(key: string): Promise<any> {
    // L1キャッシュチェック
    if (this.l1Cache.has(key)) {
      return this.l1Cache.get(key);
    }
    
    // L2キャッシュチェック
    const l2Value = await this.l2Cache.get(key);
    if (l2Value) {
      // L1キャッシュに昇格
      this.l1Cache.set(key, l2Value);
      return l2Value;
    }
    
    return null;
  }
  
  async set(key: string, value: any, ttl: number): Promise<void> {
    // 両方のキャッシュに保存
    this.l1Cache.set(key, value);
    await this.l2Cache.setEx(key, ttl, JSON.stringify(value));
  }
}

セキュリティとガバナンス

マルチテナント環境では、セキュリティが最優先事項です。

アクセス制御の実装

// ミドルウェアでテナントコンテキストを設定
import { Request, Response, NextFunction } from 'express';

interface TenantRequest extends Request {
  tenantId?: string;
  user?: {
    id: string;
    tenantId: string;
    role: string;
  };
}

// テナント検証ミドルウェア
async function tenantAuthMiddleware(
  req: TenantRequest,
  res: Response,
  next: NextFunction
) {
  try {
    // JWTトークンからユーザー情報を取得
    const token = req.headers.authorization?.replace('Bearer ', '');
    const decoded = verifyToken(token);
    
    // テナントIDの検証
    const tenantId = req.headers['x-tenant-id'] as string;
    if (decoded.tenantId !== tenantId) {
      return res.status(403).json({ error: 'Tenant mismatch' });
    }
    
    // リクエストにテナント情報を追加
    req.tenantId = tenantId;
    req.user = decoded;
    
    next();
  } catch (error) {
    res.status(401).json({ error: 'Unauthorized' });
  }
}

// テナント単位のレート制限
import rateLimit from 'express-rate-limit';

const tenantRateLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15分
  max: async (req: TenantRequest) => {
    // テナントプランに応じて制限を調整
    const plan = await getTenantPlan(req.tenantId);
    return plan.rateLimit;
  },
  keyGenerator: (req: TenantRequest) => {
    return `${req.tenantId}:${req.ip}`;
  }
});

監視とアラート

// テナント単位のメトリクス収集
class TenantMetrics {
  async recordApiCall(tenantId: string, endpoint: string, duration: number) {
    // PrometheusやDataDogにメトリクスを送信
    metrics.histogram('api_call_duration', duration, {
      tenant_id: tenantId,
      endpoint: endpoint
    });
    
    // テナント単位の使用量を追跡
    await this.incrementUsage(tenantId, 'api_calls', 1);
  }
  
  async checkQuota(tenantId: string, resource: string): Promise<boolean> {
    const usage = await this.getUsage(tenantId, resource);
    const quota = await this.getQuota(tenantId, resource);
    
    if (usage >= quota) {
      // アラートを送信
      await this.sendQuotaAlert(tenantId, resource, usage, quota);
      return false;
    }
    
    return true;
  }
}

実装例:Node.js + PostgreSQLでのマルチテナントAPI

完全な実装例を見てみましょう。

// server.ts
import express from 'express';
import { Pool } from 'pg';

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

// テナントコンテキストミドルウェア
app.use(async (req: TenantRequest, res, next) => {
  const tenantId = req.headers['x-tenant-id'] as string;
  
  if (!tenantId) {
    return res.status(400).json({ error: 'Tenant ID required' });
  }
  
  req.tenantId = tenantId;
  next();
});

// テナント固有のデータ取得
app.get('/api/users', async (req: TenantRequest, res) => {
  const client = await pool.connect();
  
  try {
    // トランザクション内でテナントコンテキストを設定
    await client.query('BEGIN');
    await client.query(
      `SET LOCAL app.current_tenant_id = $1`,
      [req.tenantId]
    );
    
    // RLSが自動的にテナントでフィルタリング
    const result = await client.query('SELECT * FROM users');
    
    await client.query('COMMIT');
    res.json(result.rows);
  } catch (error) {
    await client.query('ROLLBACK');
    res.status(500).json({ error: 'Internal server error' });
  } finally {
    client.release();
  }
});

app.listen(3000, () => {
  console.log('Multi-tenant API running on port 3000');
});

よくある質問

既存のシングルテナントアプリをマルチテナント化する際の注意点は?

段階的な移行を推奨します。まず、データモデルに tenant_id を追加し、既存データを単一テナントとして扱います。次に、アプリケーション層でテナントコンテキストを実装し、最後にRLSなどのデータベースレベルの分離を追加します。本番環境への影響を最小限にするため、フィーチャーフラグを使用して段階的にロールアウトすることが重要です。

マルチテナントSaaSのパフォーマンステストはどのように実施すべきか?

複数のテナントが同時に負荷をかけるシナリオでテストします。特に、大規模テナントと小規模テナントが混在する環境で、小規模テナントのパフォーマンスが影響を受けないことを確認します。テナント毎のレスポンスタイムを計測し、SLAを満たしているか検証することが重要です。

データベース分離型からスキーマ共有型への移行は可能か?

技術的には可能ですが、慎重な計画が必要です。データマイグレーション、アプリケーションコードの変更、徹底的なテストが求められます。ビジネス的にも、顧客への影響評価とコミュニケーション戦略が重要です。一般的には、新規テナントのみ新しいアーキテクチャを適用し、既存テナントは段階的に移行する方法が安全です。

おわりに

マルチテナントSaaSアーキテクチャは、スケーラブルで効率的なSaaSビジネスを構築するための重要な基盤です。本記事で解説したデータ分離戦略、スケーラビリティ設計、セキュリティ対策を組み合わせることで、信頼性の高いマルチテナントシステムを実現できます。

重要なのは、ビジネス要件と技術的制約のバランスを取りながら、適切な設計パターンを選択することです。小規模から始めて、成長に応じて段階的にアーキテクチャを進化させていくアプローチが、多くの場合で成功につながります。

私たちShineosでは、マルチテナントSaaSの設計から実装、運用まで幅広くサポートしています。SaaS基盤の構築でお困りの際は、ぜひご相談ください。

参考リンク