T-CREATOR

Dify マルチテナント設計:データ分離・キー管理・レート制限の深掘り

Dify マルチテナント設計:データ分離・キー管理・レート制限の深掘り

AI アプリケーション開発プラットフォームとして急成長を遂げている Dify は、複数の企業や開発者が同じプラットフォームを共有する「マルチテナント」環境を実現しています。しかし、その裏側では、データ分離、API キー管理、レート制限といった複雑な技術的課題と向き合ってきました。

本記事では、Dify がどのようにマルチテナント設計を実現し、約 50 万個ものデータベースコンテナを単一システムに統合したのか、その設計思想と実装の詳細を深掘りしていきます。初心者の方にもわかりやすく、図解を交えながら解説していきますね。

背景

マルチテナントアーキテクチャとは

マルチテナントアーキテクチャとは、1 つのアプリケーションやインフラを複数の顧客(テナント)が共有する設計パターンです。各テナントは独立した環境を持っているように見えますが、実際には同じシステムリソースを効率的に共有しています。

Dify の場合、1 つのテナントは 1 つの ワークスペース に対応します。企業や開発チームごとにワークスペースが割り当てられ、それぞれが独立した AI アプリケーションを構築・運用できる仕組みになっています。

Dify プラットフォームの特性

Dify は以下のような特徴を持つ LLM アプリケーション開発プラットフォームです。

  • RAG(Retrieval-Augmented Generation)パイプライン:ドキュメント取り込みから検索まで包括的な機能を提供
  • エージェントフレームワーク:50 以上の組み込みツールを備えた LLM Function Calling ベースのエージェント
  • Backend-as-a-Service:すべての機能に対応する API を提供
  • LLMOps:モニタリングと分析機能を統合

これらの機能を多数のテナントに同時提供するには、効率的なリソース共有とデータ分離の両立が不可欠でした。

マルチテナント環境の全体像

以下の図は、Dify におけるマルチテナント環境の基本的な構造を示しています。

mermaidflowchart TB
    user1["企業A<br/>(テナント1)"]
    user2["企業B<br/>(テナント2)"]
    user3["企業C<br/>(テナント3)"]

    workspace1["ワークスペースA"]
    workspace2["ワークスペースB"]
    workspace3["ワークスペースC"]

    platform["Dify プラットフォーム"]

    db[("統合データベース<br/>(TiDB)")]

    user1 -->|API Key A| workspace1
    user2 -->|API Key B| workspace2
    user3 -->|API Key C| workspace3

    workspace1 --> platform
    workspace2 --> platform
    workspace3 --> platform

    platform --> db

    style platform fill:#4a90e2,color:#fff
    style db fill:#50c878,color:#fff

各テナントは独自の API キーを持ち、ワークスペースを通じて Dify プラットフォームにアクセスします。プラットフォームは統合データベースを使用しながら、論理的なデータ分離を実現しています。

図で理解できる要点:

  • 各企業(テナント)が独立したワークスペースを持つ
  • API キーによって認証と識別が行われる
  • 物理的には単一のデータベースを共有しつつ、論理的に分離されている

課題

初期設計の限界

Dify の初期のマルチテナント設計では、各開発者に独立したデータベースコンテナを割り当てるという方式を採用していました。これは一見シンプルで安全な方法に思えますが、プラットフォームの成長とともに深刻な問題を引き起こしました。

最盛期には、管理すべきデータベースコンテナの数は 約 50 万個 にまで膨れ上がっていました。これは想像を絶する規模です。

具体的な課題

この設計による主な課題を整理すると、以下のようになります。

#課題カテゴリ具体的な問題影響
1運用複雑性50 万個のコンテナの監視、バックアップ、更新管理運用チームの負担が極限まで増大
2コスト各コンテナに必要な最小リソースの確保インフラコストが非効率的に増加
3スケーラビリティ新規テナント追加時のコンテナプロビジョニングサービス開始までの時間が長大化
4パフォーマンスコンテナ間の通信オーバーヘッドレスポンス時間の低下
5メンテナンスデータベースバージョンアップの一斉適用システム全体の更新に数週間を要する

開発チームは、「本当に重要なこと、つまりより良い AI アプリケーションの構築に集中できない」という状況に陥っていました。

課題の可視化

以下の図は、初期設計における課題を視覚的に表現しています。

mermaidflowchart TD
    tenant1["テナント1"] --> container1[("DB<br/>コンテナ1")]
    tenant2["テナント2"] --> container2[("DB<br/>コンテナ2")]
    tenant3["テナント3"] --> container3[("DB<br/>コンテナ3")]
    tenantN["テナントN<br/>(約50万)"] --> containerN[("DB<br/>コンテナN")]

    container1 --> issue1["運用負荷"]
    container2 --> issue1
    container3 --> issue1
    containerN --> issue1

    container1 --> issue2["コスト増大"]
    container2 --> issue2
    container3 --> issue2
    containerN --> issue2

    container1 --> issue3["スケーリング困難"]
    container2 --> issue3
    container3 --> issue3
    containerN --> issue3

    style issue1 fill:#ff6b6b,color:#fff
    style issue2 fill:#ff6b6b,color:#fff
    style issue3 fill:#ff6b6b,color:#fff

図で理解できる要点:

  • テナント数に比例してデータベースコンテナが増加
  • 各コンテナが独立して管理コストとリソースコストを発生させる
  • 運用負荷、コスト、スケーラビリティの 3 つの課題が同時に発生

データ分離とセキュリティの懸念

さらに、マルチテナント環境では以下のようなセキュリティ要件も満たす必要がありました。

テナント間のデータ完全分離

  • あるテナントが別のテナントのデータにアクセスできないこと
  • クエリレベルでの厳密なフィルタリングが必要

API キー管理の複雑性

  • 各テナントに一意の API キーを発行
  • キーのローテーション、無効化、権限管理
  • キーごとの使用状況の追跡

レート制限の公平性

  • テナントごとに適切な利用制限を設定
  • 特定テナントがリソースを独占しないよう制御
  • サービスティアに応じた柔軟な制限設定

これらの要件を 50 万個のコンテナで実現することは、技術的にもコスト的にも持続不可能な状態でした。

解決策

TiDB への統合による根本的な改善

Dify は課題解決のため、約 50 万個のデータベースコンテナを単一の TiDB Cloud 実装に統合するという大胆な決断を下しました。TiDB は、MySQL 互換のインターフェースを持ちながら、水平スケーラビリティと分散トランザクションを実現する NewSQL データベースです。

この統合により、以下の驚異的な成果を達成しました。

typescript// 統合前後の比較
interface SystemMetrics {
  databaseContainers: number; // データベースコンテナ数
  infraCostReduction: string; // インフラコスト削減率
  operationalOverheadReduction: string; // 運用オーバーヘッド削減率
  managementComplexity: string; // 管理複雑性
}

// 統合前
const beforeMigration: SystemMetrics = {
  databaseContainers: 500000, // 約50万個
  infraCostReduction: '0%',
  operationalOverheadReduction: '0%',
  managementComplexity: '極めて高い',
};

// 統合後
const afterMigration: SystemMetrics = {
  databaseContainers: 1, // 単一TiDBクラスタ
  infraCostReduction: '80%', // 80%削減
  operationalOverheadReduction: '90%', // 90%削減
  managementComplexity: '大幅に簡素化',
};

上記のコードは、統合前後のシステムメトリクスを比較したものです。インフラコストは 80%削減、運用オーバーヘッドは 90%削減という劇的な改善を実現しています。

データ分離戦略:論理的分離の実装

単一データベースへの統合において最も重要なのが、論理的なデータ分離の実現です。物理的には同じデータベースを共有しながら、各テナントのデータを完全に分離する仕組みが必要でした。

Dify が採用した主要な戦略は以下の通りです。

テナント ID による識別

すべてのテーブルに tenant_id カラムを追加し、各レコードがどのテナントに属するかを明示的に記録します。

typescript// テナント情報を含むデータモデルの例
interface Workspace {
  id: string; // ワークスペースID
  tenant_id: string; // テナント識別子
  name: string; // ワークスペース名
  created_at: Date; // 作成日時
  settings: WorkspaceSettings; // 設定情報
}

interface Document {
  id: string; // ドキュメントID
  tenant_id: string; // テナント識別子(必須)
  workspace_id: string; // ワークスペースID
  content: string; // ドキュメント内容
  vector_embedding: number[]; // ベクトル埋め込み
  created_at: Date;
}

このように、すべてのデータモデルに tenant_id を含めることで、データの所属を明確にしています。

クエリレベルでのフィルタリング

すべてのデータベースクエリに対して、自動的に tenant_id によるフィルタリングを適用します。これにより、開発者が意図せず他のテナントのデータにアクセスすることを防ぎます。

typescript// ORM(Object-Relational Mapping)レベルでのテナント分離
class TenantAwareRepository<T> {
  private tenantId: string;

  constructor(tenantId: string) {
    this.tenantId = tenantId;
  }

  // すべてのクエリに自動的にtenantIdフィルタを追加
  async find(conditions: Partial<T>): Promise<T[]> {
    return await db.query({
      ...conditions,
      tenant_id: this.tenantId, // 自動的に追加
    });
  }

  async create(data: Omit<T, 'tenant_id'>): Promise<T> {
    return await db.insert({
      ...data,
      tenant_id: this.tenantId, // 自動的に追加
    });
  }

  async update(id: string, data: Partial<T>): Promise<T> {
    // 更新時も必ずtenantIdで絞り込み
    return await db.update(
      { id, tenant_id: this.tenantId },
      data
    );
  }
}

このリポジトリパターンでは、コンストラクタで渡された tenantId を使用して、すべてのデータベース操作に自動的にフィルタを適用しています。

インデックス戦略

tenant_id を含む複合インデックスを作成することで、クエリパフォーマンスを最適化します。

sql-- テナントIDを含む複合インデックスの例
CREATE INDEX idx_documents_tenant_workspace
ON documents(tenant_id, workspace_id, created_at);

-- ベクトル検索のための複合インデックス
CREATE INDEX idx_documents_tenant_vector
ON documents(tenant_id, vector_embedding)
USING ivfflat;

これらのインデックスにより、特定のテナントのデータを高速に検索できるようになります。

API キー管理システム

マルチテナント環境では、各テナントに一意の API キーを発行し、適切に管理する必要があります。Dify が実装したキー管理システムの主要な機能を見ていきましょう。

API キーの生成と保存

typescriptimport crypto from 'crypto';

// APIキー生成サービス
class APIKeyService {
  // セキュアなAPIキーを生成
  generateAPIKey(): string {
    // プレフィックス + ランダム文字列
    const prefix = 'dify_';
    const randomBytes = crypto.randomBytes(32);
    const key = randomBytes.toString('base64url');
    return `${prefix}${key}`;
  }

  // APIキーのハッシュ化(保存用)
  hashAPIKey(apiKey: string): string {
    return crypto
      .createHash('sha256')
      .update(apiKey)
      .digest('hex');
  }

  // APIキーを作成してデータベースに保存
  async createAPIKey(
    tenantId: string,
    name: string,
    permissions: string[]
  ): Promise<{ apiKey: string; keyId: string }> {
    const apiKey = this.generateAPIKey();
    const hashedKey = this.hashAPIKey(apiKey);

    // ハッシュ化されたキーのみをデータベースに保存
    const keyRecord = await db.apiKeys.create({
      tenant_id: tenantId,
      name: name,
      hashed_key: hashedKey,
      permissions: permissions,
      created_at: new Date(),
      last_used_at: null,
      is_active: true,
    });

    // 生成されたキーは一度だけ返却(再表示不可)
    return {
      apiKey: apiKey, // 平文のキー(ユーザーに表示)
      keyId: keyRecord.id, // キーID
    };
  }
}

このコードでは、セキュアなランダム文字列を生成し、ハッシュ化してからデータベースに保存しています。平文のキーはユーザーに一度だけ表示され、以降は確認できません。

API キー認証ミドルウェア

typescript// Express.jsミドルウェアでのAPI認証
import { Request, Response, NextFunction } from 'express';

interface AuthenticatedRequest extends Request {
  tenantId?: string;
  apiKeyId?: string;
}

// API認証ミドルウェア
async function authenticateAPIKey(
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
): Promise<void> {
  // Authorizationヘッダーからキーを取得
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    res.status(401).json({
      error: 'Unauthorized',
      message: 'API key is required',
    });
    return;
  }

  const apiKey = authHeader.substring(7); // "Bearer "を除去

  try {
    // キーをハッシュ化して検索
    const hashedKey = crypto
      .createHash('sha256')
      .update(apiKey)
      .digest('hex');

    const keyRecord = await db.apiKeys.findOne({
      hashed_key: hashedKey,
      is_active: true,
    });

    if (!keyRecord) {
      res.status(401).json({
        error: 'Unauthorized',
        message: 'Invalid API key',
      });
      return;
    }

    // リクエストにテナント情報を追加
    req.tenantId = keyRecord.tenant_id;
    req.apiKeyId = keyRecord.id;

    // 最終使用日時を更新
    await db.apiKeys.update(keyRecord.id, {
      last_used_at: new Date(),
    });

    next(); // 次のミドルウェアへ
  } catch (error) {
    console.error('Authentication error:', error);
    res.status(500).json({
      error: 'Internal Server Error',
      message: 'Authentication failed',
    });
  }
}

このミドルウェアは、すべての API リクエストに対して実行され、API キーの検証とテナント識別を行います。認証が成功すると、リクエストオブジェクトに tenantId が追加され、後続の処理で使用できるようになります。

レート制限の実装

公平なサービス提供のため、テナントごとにレート制限を設定します。これにより、特定のテナントがリソースを独占することを防ぎます。

Redis ベースのレート制限

typescriptimport Redis from 'ioredis';

// レート制限サービス
class RateLimitService {
  private redis: Redis;

  constructor(redisClient: Redis) {
    this.redis = redisClient;
  }

  // スライディングウィンドウ方式のレート制限
  async checkRateLimit(
    tenantId: string,
    endpoint: string,
    maxRequests: number,
    windowSeconds: number
  ): Promise<{ allowed: boolean; remaining: number }> {
    const key = `ratelimit:${tenantId}:${endpoint}`;
    const now = Date.now();
    const windowStart = now - windowSeconds * 1000;

    // Redisパイプラインで複数コマンドを効率的に実行
    const pipeline = this.redis.pipeline();

    // 古いエントリを削除
    pipeline.zremrangebyscore(key, 0, windowStart);

    // 現在のウィンドウ内のリクエスト数をカウント
    pipeline.zcard(key);

    // 現在のタイムスタンプを追加
    pipeline.zadd(key, now, `${now}-${Math.random()}`);

    // キーの有効期限を設定
    pipeline.expire(key, windowSeconds);

    const results = await pipeline.exec();
    const currentCount = results![1][1] as number;

    // レート制限チェック
    if (currentCount >= maxRequests) {
      // 最後のリクエストを削除(カウントしない)
      await this.redis.zrem(key, `${now}-${Math.random()}`);

      return {
        allowed: false,
        remaining: 0,
      };
    }

    return {
      allowed: true,
      remaining: maxRequests - currentCount - 1,
    };
  }
}

このコードでは、Redis の Sorted Set を使用してスライディングウィンドウ方式のレート制限を実装しています。時系列データを効率的に管理し、正確な制限を実現できます。

レート制限ミドルウェア

typescript// レート制限を適用するミドルウェア
async function rateLimitMiddleware(
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
): Promise<void> {
  const tenantId = req.tenantId!;
  const endpoint = req.path;

  // テナントのサービスティアを取得
  const tenant = await db.tenants.findOne({ id: tenantId });
  const tier = tenant.service_tier; // 'free', 'pro', 'enterprise'

  // ティアごとのレート制限を設定
  const rateLimits: Record<
    string,
    { max: number; window: number }
  > = {
    free: { max: 100, window: 3600 }, // 1時間あたり100リクエスト
    pro: { max: 1000, window: 3600 }, // 1時間あたり1000リクエスト
    enterprise: { max: 10000, window: 3600 }, // 1時間あたり10000リクエスト
  };

  const limit = rateLimits[tier] || rateLimits.free;

  const rateLimitService = new RateLimitService(
    redisClient
  );
  const result = await rateLimitService.checkRateLimit(
    tenantId,
    endpoint,
    limit.max,
    limit.window
  );

  // レスポンスヘッダーに制限情報を追加
  res.setHeader('X-RateLimit-Limit', limit.max.toString());
  res.setHeader(
    'X-RateLimit-Remaining',
    result.remaining.toString()
  );
  res.setHeader(
    'X-RateLimit-Reset',
    (Date.now() + limit.window * 1000).toString()
  );

  if (!result.allowed) {
    res.status(429).json({
      error: 'Too Many Requests',
      message: `Rate limit exceeded. Max ${limit.max} requests per hour.`,
      retry_after: limit.window,
    });
    return;
  }

  next();
}

このミドルウェアは、テナントのサービスティアに基づいて動的にレート制限を適用します。制限に達した場合は、HTTP 429 ステータスコードを返します。

統合アーキテクチャの全体像

以下の図は、TiDB への統合後のアーキテクチャを示しています。

mermaidflowchart TB
    subgraph clients["クライアント層"]
        tenant1["テナント1<br/>(APIキーA)"]
        tenant2["テナント2<br/>(APIキーB)"]
        tenant3["テナント3<br/>(APIキーC)"]
    end

    subgraph api["API層"]
        auth["認証<br/>ミドルウェア"]
        ratelimit["レート制限<br/>ミドルウェア"]
        router["ルーティング"]
    end

    subgraph service["サービス層"]
        workspace["ワークスペース<br/>サービス"]
        document["ドキュメント<br/>サービス"]
        agent["エージェント<br/>サービス"]
    end

    subgraph data["データ層"]
        tidb[("TiDB<br/>統合データベース")]
        redis[("Redis<br/>キャッシュ・制限")]
    end

    tenant1 --> auth
    tenant2 --> auth
    tenant3 --> auth

    auth -->|tenant_id抽出| ratelimit
    ratelimit --> router

    router --> workspace
    router --> document
    router --> agent

    workspace -->|tenant_idフィルタ| tidb
    document -->|tenant_idフィルタ| tidb
    agent -->|tenant_idフィルタ| tidb

    ratelimit --> redis

    style tidb fill:#50c878,color:#fff
    style redis fill:#dc143c,color:#fff
    style auth fill:#4a90e2,color:#fff
    style ratelimit fill:#4a90e2,color:#fff

図で理解できる要点:

  • クライアントは API キーで認証される
  • 認証ミドルウェアが tenant_id を抽出
  • レート制限ミドルウェアが Redis を使用して制限をチェック
  • すべてのサービス層で tenant_id によるフィルタが適用される
  • 単一の TiDB インスタンスで論理的に分離されたデータを管理

具体例

ここでは、Dify のマルチテナント設計を実際のユースケースで見ていきましょう。具体的なコードとともに、各機能がどのように連携するかを解説します。

ワークスペース作成フロー

新しいテナントがワークスペースを作成する際の処理フローです。

typescript// ワークスペース作成のエンドポイント
import { Router } from 'express';

const workspaceRouter = Router();

// POST /api/workspaces - 新規ワークスペース作成
workspaceRouter.post(
  '/workspaces',
  authenticateAPIKey, // 認証ミドルウェア
  rateLimitMiddleware, // レート制限ミドルウェア
  async (req: AuthenticatedRequest, res: Response) => {
    try {
      const tenantId = req.tenantId!;
      const { name, description, settings } = req.body;

      // ワークスペース作成
      const workspace = await createWorkspace({
        tenant_id: tenantId, // 自動的に割り当て
        name: name,
        description: description,
        settings: settings || {},
      });

      res.status(201).json({
        success: true,
        workspace: workspace,
      });
    } catch (error) {
      console.error('Workspace creation error:', error);
      res.status(500).json({
        error: 'Internal Server Error',
        message: 'Failed to create workspace',
      });
    }
  }
);

この例では、認証とレート制限を通過したリクエストに対して、自動的に tenant_id が割り当てられたワークスペースが作成されます。

ドキュメント検索とベクトル検索

RAG(Retrieval-Augmented Generation)における重要な機能であるドキュメント検索を、マルチテナント環境でどのように実装するかを見ていきます。

typescript// ドキュメント検索サービス
class DocumentSearchService {
  private tenantId: string;

  constructor(tenantId: string) {
    this.tenantId = tenantId;
  }

  // キーワード検索
  async searchByKeyword(
    workspaceId: string,
    keyword: string,
    limit: number = 10
  ): Promise<Document[]> {
    // tenantIdとworkspaceIdの両方でフィルタリング
    return await db.documents.search({
      tenant_id: this.tenantId, // テナント分離
      workspace_id: workspaceId, // ワークスペース分離
      content: { contains: keyword }, // キーワード検索
      limit: limit,
    });
  }

  // ベクトル類似度検索
  async searchByVector(
    workspaceId: string,
    queryEmbedding: number[],
    topK: number = 5
  ): Promise<
    Array<{ document: Document; similarity: number }>
  > {
    // PostgreSQL + pgvectorを使用したベクトル検索の例
    const query = `
      SELECT
        id, tenant_id, workspace_id, content, vector_embedding,
        1 - (vector_embedding <=> $1::vector) as similarity
      FROM documents
      WHERE tenant_id = $2
        AND workspace_id = $3
      ORDER BY vector_embedding <=> $1::vector
      LIMIT $4
    `;

    const results = await db.raw(query, [
      JSON.stringify(queryEmbedding), // クエリベクトル
      this.tenantId, // テナントID
      workspaceId, // ワークスペースID
      topK, // 取得件数
    ]);

    return results.rows.map((row: any) => ({
      document: {
        id: row.id,
        tenant_id: row.tenant_id,
        workspace_id: row.workspace_id,
        content: row.content,
        vector_embedding: row.vector_embedding,
      },
      similarity: parseFloat(row.similarity),
    }));
  }
}

このコードでは、すべての検索クエリに tenant_idworkspace_id の両方によるフィルタリングが適用されています。ベクトル検索でも、テナント分離が厳密に守られていることがわかります。

エージェント実行とレート制限

AI エージェントを実行する際、複数の API 呼び出しが発生するため、レート制限が特に重要になります。

typescript// エージェント実行サービス
class AgentExecutionService {
  private tenantId: string;
  private rateLimitService: RateLimitService;

  constructor(
    tenantId: string,
    rateLimitService: RateLimitService
  ) {
    this.tenantId = tenantId;
    this.rateLimitService = rateLimitService;
  }

  // エージェントの実行
  async executeAgent(
    workspaceId: string,
    agentId: string,
    input: string
  ): Promise<AgentResponse> {
    // レート制限チェック
    const rateLimitCheck =
      await this.rateLimitService.checkRateLimit(
        this.tenantId,
        'agent-execution',
        50, // 1時間あたり50回
        3600 // 1時間
      );

    if (!rateLimitCheck.allowed) {
      throw new Error(
        'Rate limit exceeded for agent execution'
      );
    }

    // エージェント設定を取得(テナント分離)
    const agent = await db.agents.findOne({
      id: agentId,
      tenant_id: this.tenantId, // 必須フィルタ
      workspace_id: workspaceId,
    });

    if (!agent) {
      throw new Error('Agent not found or access denied');
    }

    // エージェント実行ログを記録
    const executionLog = await db.agentExecutions.create({
      tenant_id: this.tenantId,
      workspace_id: workspaceId,
      agent_id: agentId,
      input: input,
      started_at: new Date(),
      status: 'running',
    });

    try {
      // LLM API呼び出し(実際の処理)
      const result = await this.callLLMAPI(agent, input);

      // 実行結果を更新
      await db.agentExecutions.update(executionLog.id, {
        output: result.output,
        completed_at: new Date(),
        status: 'completed',
        tokens_used: result.tokensUsed,
      });

      return result;
    } catch (error) {
      // エラーを記録
      await db.agentExecutions.update(executionLog.id, {
        error: (error as Error).message,
        completed_at: new Date(),
        status: 'failed',
      });

      throw error;
    }
  }

  private async callLLMAPI(
    agent: Agent,
    input: string
  ): Promise<AgentResponse> {
    // 実際のLLM API呼び出しロジック
    // ...
    return {
      output: 'Agent response',
      tokensUsed: 150,
    };
  }
}

このコードでは、エージェント実行前にレート制限をチェックし、すべてのデータベースアクセスで tenant_id によるフィルタリングを行っています。実行ログも適切に記録され、後から使用状況を追跡できるようになっています。

使用量追跡とビリング

テナントごとの使用量を追跡し、適切な課金を行うための仕組みです。

typescript// 使用量追跡サービス
class UsageTrackingService {
  // API呼び出しを記録
  async recordAPICall(
    tenantId: string,
    endpoint: string,
    responseTime: number,
    statusCode: number
  ): Promise<void> {
    await db.usageMetrics.create({
      tenant_id: tenantId,
      metric_type: 'api_call',
      endpoint: endpoint,
      response_time: responseTime,
      status_code: statusCode,
      timestamp: new Date(),
    });
  }

  // トークン使用量を記録
  async recordTokenUsage(
    tenantId: string,
    workspaceId: string,
    tokensUsed: number,
    model: string
  ): Promise<void> {
    await db.usageMetrics.create({
      tenant_id: tenantId,
      workspace_id: workspaceId,
      metric_type: 'token_usage',
      tokens_used: tokensUsed,
      model: model,
      timestamp: new Date(),
    });
  }

  // 月次使用量を集計
  async getMonthlyUsage(
    tenantId: string,
    year: number,
    month: number
  ): Promise<UsageSummary> {
    const startDate = new Date(year, month - 1, 1);
    const endDate = new Date(year, month, 0, 23, 59, 59);

    // API呼び出し数
    const apiCalls = await db.usageMetrics.count({
      tenant_id: tenantId,
      metric_type: 'api_call',
      timestamp: { between: [startDate, endDate] },
    });

    // トークン使用量
    const tokenUsage = await db.usageMetrics.sum(
      'tokens_used',
      {
        tenant_id: tenantId,
        metric_type: 'token_usage',
        timestamp: { between: [startDate, endDate] },
      }
    );

    return {
      tenant_id: tenantId,
      period: { year, month },
      api_calls: apiCalls,
      tokens_used: tokenUsage,
      estimated_cost: this.calculateCost(
        apiCalls,
        tokenUsage
      ),
    };
  }

  private calculateCost(
    apiCalls: number,
    tokensUsed: number
  ): number {
    // 料金計算ロジック
    const apiCallCost = apiCalls * 0.001; // $0.001 per call
    const tokenCost = tokensUsed * 0.00002; // $0.00002 per token
    return apiCallCost + tokenCost;
  }
}

この使用量追跡サービスは、API 呼び出しとトークン使用量を記録し、月次で集計して課金に使用します。すべてのメトリクスに tenant_id が含まれているため、テナントごとの正確な使用量を把握できます。

統合された処理フロー

以下の図は、ユーザーリクエストから応答までの完全な処理フローを示しています。

mermaidsequenceDiagram
    participant User as ユーザー
    participant API as APIゲートウェイ
    participant Auth as 認証サービス
    participant Rate as レート制限
    participant Service as ビジネスロジック
    participant DB as TiDB
    participant Redis as Redis
    participant Usage as 使用量追跡

    User->>API: リクエスト + APIキー
    API->>Auth: APIキー検証
    Auth->>DB: ハッシュ化キーで検索
    DB-->>Auth: テナント情報
    Auth-->>API: tenant_id抽出

    API->>Rate: レート制限チェック
    Rate->>Redis: 現在のカウント取得
    Redis-->>Rate: カウント情報

    alt レート制限超過
        Rate-->>API: 制限エラー
        API-->>User: 429 Too Many Requests
    else レート制限OK
        Rate->>Redis: カウント増加
        Rate-->>API: 処理続行

        API->>Service: ビジネスロジック実行
        Service->>DB: データ取得(tenant_idフィルタ)
        DB-->>Service: フィルタされたデータ
        Service-->>API: 処理結果

        API->>Usage: 使用量記録
        Usage->>DB: メトリクス保存

        API-->>User: 200 OK + レスポンス
    end

図で理解できる要点:

  • すべてのリクエストが認証とレート制限を通過する
  • tenant_id がリクエスト全体を通じて使用される
  • データベースクエリには必ずテナントフィルタが適用される
  • 使用量が自動的に記録され、課金に利用される

まとめ

本記事では、Dify のマルチテナント設計について、データ分離、キー管理、レート制限の 3 つの観点から深掘りしてきました。最後に、重要なポイントを振り返りましょう。

達成された成果

Dify は、約 50 万個のデータベースコンテナを単一の TiDB インスタンスに統合することで、以下の劇的な改善を実現しました。

指標改善内容
インフラコスト80% 削減
運用オーバーヘッド90% 削減
管理複雑性大幅に簡素化
スケーラビリティ柔軟な水平スケーリングが可能に

これにより、開発チームは本来の目的である「より良い AI アプリケーションの構築」に集中できるようになりました。

マルチテナント設計の 3 つの柱

データ分離

  • すべてのテーブルに tenant_id を含める
  • クエリレベルでの自動フィルタリングを実装
  • 複合インデックスによるパフォーマンス最適化
  • 論理的分離により物理リソースを効率的に共有

キー管理

  • セキュアな API キー生成(crypto.randomBytes)
  • ハッシュ化による安全な保存
  • ミドルウェアレベルでの自動認証
  • 最終使用日時の追跡とキーの無効化機能

レート制限

  • Redis を使用したスライディングウィンドウ方式
  • サービスティアに応じた柔軟な制限設定
  • 公平なリソース配分の実現
  • HTTP ヘッダーによる制限情報の透明性

ベストプラクティス

Dify の事例から学べるマルチテナント設計のベストプラクティスは以下の通りです。

アーキテクチャレベル

  • 単一データベースでの論理的分離を採用し、運用コストを削減する
  • スケーラブルなデータベース(TiDB など)を選択し、将来の成長に備える
  • すべてのデータモデルに一貫してテナント識別子を含める

実装レベル

  • ORM やリポジトリパターンで自動的にテナントフィルタを適用する
  • ミドルウェアパターンで認証、認可、レート制限を統一的に処理する
  • API キーは必ずハッシュ化して保存し、平文では保持しない

運用レベル

  • 使用量メトリクスを詳細に記録し、適切な課金と容量計画に活用する
  • レート制限の閾値は柔軟に設定できるようにし、ビジネス要件に対応する
  • 監査ログを記録し、セキュリティインシデントの追跡を可能にする

マルチテナント設計の重要性

マルチテナントアーキテクチャは、SaaS ビジネスにおいて極めて重要な設計パターンです。適切に実装されれば、以下のような恩恵を受けられます。

ビジネス面

  • 運用コストの劇的な削減
  • スケーラブルな成長基盤の確立
  • 迅速な新規テナントのオンボーディング

技術面

  • リソースの効率的な利用
  • 統一された運用とモニタリング
  • セキュリティポリシーの一元管理

ユーザー面

  • 公平なサービス品質の提供
  • 透明性のある使用量と課金
  • 独立した開発環境の確保

Dify の事例は、初期の設計課題を認識し、適切なタイミングで大胆なアーキテクチャ変更を行うことの重要性を示しています。マルチテナント設計は一度実装すれば終わりではなく、ビジネスの成長とともに進化させていく必要があるのです。

本記事が、皆さんのマルチテナントアーキテクチャ設計の参考になれば幸いです。

関連リンク