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_id と workspace_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 の事例は、初期の設計課題を認識し、適切なタイミングで大胆なアーキテクチャ変更を行うことの重要性を示しています。マルチテナント設計は一度実装すれば終わりではなく、ビジネスの成長とともに進化させていく必要があるのです。
本記事が、皆さんのマルチテナントアーキテクチャ設計の参考になれば幸いです。
関連リンク
articleDify マルチテナント設計:データ分離・キー管理・レート制限の深掘り
articleDify ワークフロー定型 30:分岐・並列・リトライ・サーキットブレーカの型
articleDify を Kubernetes にデプロイ:Helm とスケーリング設計の実践
articleDify のベクトル DB 選定比較:pgvector・Milvus・Weaviate の実測レポート
articleDify のコール失敗を解決:429/5xx/タイムアウト時の再試行とバックオフ戦略
articleDify で実現する RAG 以外の戦略:ツール実行・関数呼び出し・自律エージェントの全体像
articleHaystack で RAG アーキテクチャ設計:ハイブリッド検索と再ランキングの最適解
articleFFmpeg 配信設計の実際:tee muxer でマルチプラットフォーム同時配信
articleESLint でアーキテクチャ境界を守る:層間 import を規制する設計パターン
articleGrok で研究論文を 10 分要約:重要箇所抽出 → 図表化までの手順
articleDify マルチテナント設計:データ分離・キー管理・レート制限の深掘り
articleClips AI とは?自動切り抜き・自動字幕で動画編集を爆速化する仕組み
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来