multitenancy について
マルチテナントアプリケーションの設計を扱うスキルです。テナント分離方式やリクエスト単位の識別に加え、実装パターン、アンチパターン、本番向けチェックリストを紹介します。
主な機能
- 01データベース・スキーマ・行単位の分離を比較
- 02共有スキーマでのテナントスコープ例
- 03PostgreSQLの行レベルセキュリティを解説
- 04テナント識別方法を列挙
- 05本番向け分離チェックリスト
活用シーン
- 01SaaSの分離方式を選定
- 02Prismaクエリをテナント単位に限定
- 03PostgreSQLの行レベルセキュリティを適用
- 04本番前にテナント分離を確認
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ claude-dev-suite/ claude-dev-suite --skill multitenancyインストール後、Claude Code で/multitenancyを入力して読み込まれたか確認します。
詳細
マルチテナントアーキテクチャ
分離戦略
| 戦略 | 分離レベル | 複雑さ | コスト |
|---|---|---|---|
| テナントごとにデータベースを用意 | 最高 | 高 | 高 |
| テナントごとにスキーマを用意 | 高 | 中 | 中 |
| 共有スキーマ(tenant_id 列) | 中 | 低 | 低 |
| 行レベルセキュリティ(RLS) | 中〜高 | 中 | 低 |
テナント ID を使用した共有スキーマ(最も一般的)
// Middleware: resolve tenant from subdomain or header
function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
const host = req.hostname; // acme.myapp.com
const subdomain = host.split('.')[0];
const tenant = await tenantRepo.findBySubdomain(subdomain);
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
req.tenantId = tenant.id;
next();
}
// Always filter by tenant
app.get('/api/products', async (req, res) => {
const products = await db.product.findMany({
where: { tenantId: req.tenantId },
});
res.json(products);
});
テナントスコープを設定した Prisma
// Extension to auto-apply tenant filter
const prisma = new PrismaClient().$extends({
query: {
$allOperations({ args, query, operation }) {
if (['findMany', 'findFirst', 'count', 'updateMany', 'deleteMany'].includes(operation)) {
args.where = { ...args.where, tenantId: getCurrentTenantId() };
}
if (['create', 'createMany'].includes(operation)) {
args.data = { ...args.data, tenantId: getCurrentTenantId() };
}
return query(args);
},
},
});
PostgreSQL の行レベルセキュリティ
-- Enable RLS
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
-- Policy: users see only their tenant's data
CREATE POLICY tenant_isolation ON products
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- Set tenant context per request
SET app.tenant_id = 'tenant-uuid-here';
SELECT * FROM products; -- auto-filtered
// Set tenant context on each request
pool.on('connect', async (client) => {
// Set after getting connection from pool
});
async function withTenant<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query(`SET app.tenant_id = $1`, [tenantId]);
return await fn();
} finally {
await client.query('RESET app.tenant_id');
client.release();
}
}
テナントごとのスキーマ
// Dynamic schema selection
function getTenantSchema(tenantId: string): string {
return `tenant_${tenantId.replace(/-/g, '_')}`;
}
async function createTenantSchema(tenantId: string) {
const schema = getTenantSchema(tenantId);
await db.query(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
await db.query(`SET search_path TO ${schema}`);
await runMigrations(); // Apply schema migrations
}
テナント解決戦略
| 戦略 | 例 | 最適な用途 |
|---|---|---|
| サブドメイン | acme.myapp.com | B2B SaaS |
| パスプレフィックス | myapp.com/acme/... | よりシンプルなセットアップ |
| カスタムヘッダー | X-Tenant-ID: acme | API ファースト |
| JWT クレーム | { tenantId: "acme" } | 認証済み API |
アンチパターン
| アンチパターン | 対策 |
|---|---|
| クエリにテナントフィルターがない | ミドルウェアまたは ORM 拡張を使って自動的に適用する |
| クライアントから渡されたテナント ID を検証しない | 認証トークンまたはサブドメインから導出する |
| テナント間のデータ分離をテストしていない | テナント間の分離を検証するテストを作成する |
| テナントプレフィックスのない共有キャッシュ | すべてのキャッシュキーにテナント ID をプレフィックスとして付ける |
| テナントを考慮したレート制限がない | グローバルではなく、テナントごとにレート制限する |
本番環境チェックリスト
- すべてのルートにテナント解決ミドルウェアを設定する
- 自動テストでデータ分離を検証する
- キャッシュキーにテナント ID をプレフィックスとして付ける
- テナントごとにレート制限する
- テナントスコープのバックグラウンドジョブを設定する
- テナントのプロビジョニングおよびデプロビジョニングのフローを用意する
- テナント間クエリを防止する(RLS または ORM による適用)
コメント
Markdown 対応