スケーラブルな LMS アーキテクチャ: マルチテナント パターン
の世界市場 学習管理システム (LMS) が合格しました 280億ドル 2025年に、2030 年までに約 700 億ドルの成長が予測されています。 Moodle、Canvas、Docebo、Coursera には、数千の組織にサービスを提供するという巨大なアーキテクチャ上の課題が隠されています 同時に、データの分離を確保し、ピーク使用時に水平方向に拡張します。 同時に接続している数百万人の学生に対して 1 秒未満の遅延を維持します。
La マルチテナンシー そしてこれらすべてを可能にするアーキテクチャ パターン。マルチテナント LMS では、 単一のアプリケーション インスタンスが複数の組織 (テナント) にサービスを提供し、それぞれに独自のユーザーが存在します。 コース、コンテンツ、構成は異なりますが、基礎となるインフラストラクチャは共有されます。パターンの選択 正しいマルチテナントが運用コスト、セキュリティ、パフォーマンス、拡張能力を決定します。 システムを書き換えることなく、10 から 10,000 のテナントに対応できます。
このシリーズの最初の記事では エドテックエンジニアリング、徹底的に分析します LMS プラットフォームのマルチテナント アーキテクチャ、データ分離パターンの比較、 スケーリング戦略、最適化されたデータ モデル、リアルタイム機能のためのイベント駆動型パターン。 各概念には、TypeScript と Python の具体的なコード例が付属します。
この記事で学べること
- 3 つの基本的なマルチテナント パターンとそれぞれをいつ使用するか
- 数千のテナントに対応する LMS データ モデルを設計する方法
- JWT と RBAC を使用したマルチテナント コンテキストでの認証と認可
- テナント解決のための REST API と GraphQL を設計する
- リアルタイムの通知と分析のためのイベント駆動型アーキテクチャ
- エンタープライズ LMS プラットフォームのマイクロサービスへの分解
- Moodle、Canvas、Blackboard、および最新のプラットフォームのアーキテクチャの比較
- 高トラフィック LMS のパフォーマンス ベンチマークとキャッシュ戦略
EdTech Engineering シリーズ概要
| # | アイテム | 集中 |
|---|---|---|
| 1 | 現在位置 - スケーラブルな LMS アーキテクチャ | LMS プラットフォームのマルチテナント パターン |
| 2 | 教育ビデオストリーミング | ビデオコンテンツ配信アーキテクチャ |
| 3 | ラーニング アナリティクスと xAPI | 学習データの収集と分析 |
| 4 | SCORM とコンテンツのパッケージ化 | 教育内容の基準 |
| 5 | AI による適応学習 | トレーニングコースのパーソナライズ化 |
| 6 | プラットフォームにおけるゲーミフィケーション | バッジ、リーダーボード、モチベーション |
| 7 | 監督と評価 | 安全で不正防止のオンライン試験 |
| 8 | リアルタイムコラボレーション | ホワイトボードの共有と共同編集 |
| 9 | モバイルおよびオフライン学習 | PWAとオフライン同期 |
| 10 | 仮想講師としての LLM | eラーニングへの言語モデルの統合 |
学習管理システムの状況
最新の LMS は、追跡システムを備えた単純なコース リポジトリではなくなりました。プラットフォーム 今日の統合 アダプティブビデオストリーミング, インタラクティブな評価, 予測分析, リアルタイムのコラボレーション そして、ますます頻繁に、 人工知能ベースの家庭教師。この機能の複雑さは、 アーキテクチャの複雑さに直接影響します。
LMS プラットフォームは、導入モデルに基づいて 3 つのマクロ カテゴリに分類されます。
- セルフホスト型 (オンプレミス): ムードル、オープンedX。組織はインフラストラクチャ全体を管理します。最大の制御、最大の運用オーバーヘッド。
- マルチテナントSaaS: Canvas Cloud、TalentLMS、Docebo。ベンダーがすべてを処理します。各テナントには、論理的に分離された独自のスペースがあります。
- PaaS/ハイブリッド: Blackboard Learn Ultra、Brightspace。ベンダー管理のインフラストラクチャですが、高度なカスタマイズ オプションとプライベート展開が可能です。
2025 年から 2026 年の主要なトレンドは建築に向かいます クラウドネイティブのマルチテナント、 オンプレミス ソリューションの 7% と比較して、SaaS プラットフォームの前年比成長率は 34% です。 この変化は、運用コストを削減し、市場投入までの時間を短縮する必要性によって推進されています。 多くの新機能を提供し、小規模なエンジニアリング チームを持つますます多くの組織にサービスを提供します。
マルチテナント LMS プラットフォームの主な課題
- データの分離: ある大学のデータに別の大学からアクセスできないようにしてはなりません
- 騒々しい隣人: 50,000 人の学生を抱えるテナントは、小規模テナントのパフォーマンスを低下させるべきではありません
- カスタマイズ: 各テナントは異なるブランディング、ワークフロー、統合を望んでいます
- コンプライアンス: GDPR、FERPA、SOC 2 では、居住地とデータ処理に関する特定の保証が必要です
- 不均一なスケーリング: 使用量のピークは季節(学期の始まり、試験)によって異なり、テナントによって異なります。
マルチテナンシーの 3 つのパターン
マルチテナント パターンの選択は、LMS の設計において最も重要なアーキテクチャ上の決定です。 基本的なアプローチは 3 つあり、それぞれ分離性、コスト、複雑さのプロファイルが異なります。
パターン 1: テナントごとのデータベース (サイロ モデル)
各テナントは専用のデータベースを受け取ります。このアプローチでは、最高レベルの分離が提供されます。 データは物理的に分離されており、1 つのテナントのパフォーマンスが他のテナントに影響を与えることはなく、運用にも影響を与えません。 バックアップと復元は独立しています。これは、大規模なサービスを提供するプラットフォームで好まれるパターンです。 厳しいコンプライアンス要件を持つ企業。
- 絶縁: 最大。データを完全に物理的に分離します。
- 料金: 高い。各データベースは専用のリソース (接続、ストレージ、バックアップ) を消費します。
- 複雑: 高い。スキーマの移行はデータベースごとに実行する必要があります。
- スケーラビリティ: 直線的ですが高価です。新しいテナントはすべて新しいインフラストラクチャです。
- 理想的な使用例: 大規模組織が 100 未満の企業、FERPA/HIPAA 要件。
パターン2:テナントスキーム(ブリッジモデル)
すべてのテナントは同じデータベース インスタンスを共有しますが、それぞれが独自のスキーマ (名前空間) を持ちます。 たとえば、PostgreSQL では、各テナントは同一のテーブルを持つ独自のスキーマで動作しますが、データは分離されています。 このアプローチにより、分離と運用コストのバランスが取れます。
- 絶縁: 強い。スキーマレベルでの論理的な分離。明示的なエラーがなければ相互汚染は不可能です。
- 料金: 中くらい。単一の DB クラスターがすべてのテナントにサービスを提供します。
- 複雑: 平均。移行にはすべてのスキーマにわたる反復が必要ですが、自動化できます。
- スケーラビリティ: PostgreSQL インスタンスごとにスキーマが最大 500 ~ 1,000 個までであれば、シャーディングが必要になります。
- 理想的な使用例: 50 ~ 1,000 の中規模のテナントを持つ SaaS では、スキーマごとのカスタマイズが必要です。
パターン3:テナントID付き共有スキーマ(プールモデル)
すべてのテナントが同じテーブルを共有します。絶縁は柱によって保証されます tenant_id
各テーブルに存在し、から 行レベルのセキュリティ (RLS) データベースレベルで。そしてそのパターン
最もコスト効率が高く、運用管理が最も簡単ですが、規律が必要です
アプリケーションコードでは厳密です。
- 絶縁: 論理的。それは RLS とアプリケーションの検証によって異なります。コードのバグにより、テナント間データが公開される可能性があります。
- 料金: ベース。単一のデータベース インスタンスが数千のテナントにサービスを提供します。
- 複雑: 移行 (単一スキーム) については低く、セキュリティについては高くなります (各クエリにテナント フィルターが含まれている必要があります)。
- スケーラビリティ: 素晴らしい。 tenant_id ベースのシャーディング (Citus for PostgreSQL) で数千のテナントをサポートします。
- 理想的な使用例: 数千の中小規模のテナントを備えた大容量 SaaS。
パターン間の比較
| 基準 | テナント向けデータベース | テナント制度 | 共有スキーマ + RLS |
|---|---|---|---|
| データの分離 | 物理的(最大) | ロジカル(強い) | 論理 (RLS) |
| テナントあたりのコスト | 高い (月額 50 ~ 200 ドル) | 中 (月額 10 ~ 50 ドル) | 低額 (月額 1 ~ 10 ドル) |
| スキーマの移行 | N 回の実行 (DB ごとに 1 回) | N 回の実行 (スキームごとに 1 回) | 1回の実行 |
| 最大推奨テナント | 50-200 | 500~2,000 | 10,000+ |
| 騒々しい隣人のリスク | 誰でもない | 中 (共有 I/O) | 高 (シャーディングで軽減可能) |
| FERPA/GDPR への準拠 | 素晴らしい | 良い | 追加の監査が必要 |
| 運用の複雑さ | 高い | 平均 | 低い |
| パターンのカスタマイズ | 合計 | スキームごと | 制限付き (カスタムフィールド) |
テナントの解決: ミドルウェアと戦略
マルチテナント システムでは、各 HTTP リクエストを正しいテナントに関連付ける必要があります 前に あらゆるアプリケーションロジックの。このプロセスは、 テナントの解決、そして最初の アーキテクチャの層であり、セキュリティの重要なポイントです。解決が失敗するかバイパスされる場合、 全体の孤立が崩壊します。
解決戦略
リクエストのテナントを特定するには、主に次の 4 つの戦略があります。
- サブドメイン:
mit.lms-platform.com,stanford.lms-platform.com。最も一般的なものでは、ワイルドカード DNS 構成が必要です。 - パスの接頭辞:
lms-platform.com/tenants/mit/courses。シンプルですが、API パスを汚染します。 - HTTPヘッダー:
X-Tenant-ID: mit-university。マシン間 API に柔軟に対応します。 - JWT の主張: テナントは認証トークン内でエンコードされます。安全ですが、テナントを変更するには再認証が必要です。
解決ミドルウェア(TypeScript/Express)の実装
次の Express ミドルウェアは、プライマリとしてサブドメイン、 テナント レジストリに対する検証を伴う、フォールバックとしての HTTP ヘッダー。
import { Request, Response, NextFunction } from 'express';
import { LRUCache } from 'lru-cache';
// Interfaccia tenant immutabile
interface TenantConfig {
readonly id: string;
readonly slug: string;
readonly dbSchema: string;
readonly plan: 'free' | 'pro' | 'enterprise';
readonly region: 'eu-west-1' | 'us-east-1' | 'ap-southeast-1';
readonly features: ReadonlyArray<string>;
readonly maxUsers: number;
readonly isActive: boolean;
}
// Cache per evitare lookup al DB su ogni richiesta
const tenantCache = new LRUCache<string, TenantConfig>({
max: 5000,
ttl: 1000 * 60 * 5, // 5 minuti
});
// Repository per il lookup dei tenant
interface TenantRepository {
findBySlug(slug: string): Promise<TenantConfig | null>;
}
export function createTenantMiddleware(repo: TenantRepository) {
return async (req: Request, res: Response, next: NextFunction) => {
// 1. Estrai slug dal sottodominio
const host = req.hostname;
let tenantSlug = extractSubdomain(host);
// 2. Fallback: header X-Tenant-ID
if (!tenantSlug) {
tenantSlug = req.headers['x-tenant-id'] as string | undefined
?? null;
}
// 3. Validazione: slug obbligatorio
if (!tenantSlug) {
res.status(400).json({
error: 'TENANT_NOT_RESOLVED',
message: 'Unable to determine tenant from request',
});
return;
}
// 4. Lookup con cache
let config = tenantCache.get(tenantSlug) ?? null;
if (!config) {
config = await repo.findBySlug(tenantSlug);
if (config) {
tenantCache.set(tenantSlug, config);
}
}
// 5. Tenant non trovato o disattivato
if (!config || !config.isActive) {
res.status(404).json({
error: 'TENANT_NOT_FOUND',
message: `Tenant '${tenantSlug}' not found or inactive`,
});
return;
}
// 6. Inietta il contesto tenant nella request
(req as any).tenantConfig = config;
(req as any).tenantId = config.id;
next();
};
}
function extractSubdomain(host: string): string | null {
const parts = host.split('.');
// es: "mit.lms-platform.com" -> ["mit", "lms-platform", "com"]
if (parts.length >= 3) {
const subdomain = parts[0];
// Escludi subdomini di sistema
const reserved = ['www', 'api', 'admin', 'status'];
return reserved.includes(subdomain) ? null : subdomain;
}
return null;
}
Pythonの実装(FastAPI)
FastAPI を使用している場合は、以下と同等のものがあります。 依存性注入 テナント解決の場合:
from fastapi import Request, HTTPException, Depends
from functools import lru_cache
from dataclasses import dataclass, field
from typing import Optional
import asyncio
@dataclass(frozen=True)
class TenantConfig:
"""Configurazione immutabile del tenant."""
id: str
slug: str
db_schema: str
plan: str # 'free' | 'pro' | 'enterprise'
region: str
features: tuple[str, ...] = field(default_factory=tuple)
max_users: int = 100
is_active: bool = True
class TenantResolver:
"""Risolve il tenant dalla richiesta HTTP."""
def __init__(self, repository):
self._repo = repository
self._cache: dict[str, TenantConfig] = {}
async def resolve(self, request: Request) -> TenantConfig:
# 1. Sottodominio
slug = self._extract_subdomain(request.headers.get("host", ""))
# 2. Fallback: header
if not slug:
slug = request.headers.get("x-tenant-id")
if not slug:
raise HTTPException(
status_code=400,
detail="Unable to determine tenant from request"
)
# 3. Cache lookup
if slug in self._cache:
config = self._cache[slug]
else:
config = await self._repo.find_by_slug(slug)
if config:
self._cache[slug] = config
if not config or not config.is_active:
raise HTTPException(
status_code=404,
detail=f"Tenant '{slug}' not found or inactive"
)
return config
@staticmethod
def _extract_subdomain(host: str) -> Optional[str]:
parts = host.split(".")
if len(parts) >= 3:
subdomain = parts[0]
reserved = {"www", "api", "admin", "status"}
return None if subdomain in reserved else subdomain
return None
# Dependency injection in FastAPI
async def get_tenant(request: Request) -> TenantConfig:
resolver = request.app.state.tenant_resolver
return await resolver.resolve(request)
マルチテナント LMS のデータ モデル
LMS のデータ モデルは、ユーザー、組織、組織などの複雑なエンティティとその関係をキャプチャする必要があります。 コース、モジュール、レッスン、評価、提出、登録、進捗状況、証明書。文脈の中で マルチテナントの場合、各エンティティを所有テナントに関連付ける必要があり、クエリは シングルテナント (標準) 操作とクロステナント (管理レポート) 操作の両方で効率的です。
データベースコアスキーマ
次の SQL スキーマは、「共有スキーマ + tenant_id」パターンを持つマルチテナント LMS のコアを表します。 PostgreSQL を使用する 行レベルのセキュリティ (RLS) 絶縁を確保します。
-- ============================================
-- TABELLA TENANT (registro delle organizzazioni)
-- ============================================
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(63) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
plan VARCHAR(20) NOT NULL DEFAULT 'free',
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
-- ============================================
-- TABELLA UTENTI
-- ============================================
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
email VARCHAR(255) NOT NULL,
full_name VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'student',
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, email)
);
-- ============================================
-- TABELLA CORSI
-- ============================================
CREATE TABLE courses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
instructor_id UUID NOT NULL REFERENCES users(id),
title VARCHAR(500) NOT NULL,
slug VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
max_enrollment INTEGER,
settings JSONB NOT NULL DEFAULT '{}',
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, slug)
);
-- ============================================
-- MODULI E LEZIONI (struttura gerarchica)
-- ============================================
CREATE TABLE modules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE lessons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
content_type VARCHAR(30) NOT NULL, -- 'video', 'text', 'quiz', 'assignment'
content_ref TEXT, -- URL o ID del contenuto
duration_min INTEGER,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================
-- ISCRIZIONI (enrollments)
-- ============================================
CREATE TABLE enrollments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
user_id UUID NOT NULL REFERENCES users(id),
course_id UUID NOT NULL REFERENCES courses(id),
status VARCHAR(20) NOT NULL DEFAULT 'active',
enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
UNIQUE (tenant_id, user_id, course_id)
);
-- ============================================
-- PROGRESSI (lesson completion tracking)
-- ============================================
CREATE TABLE lesson_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
user_id UUID NOT NULL REFERENCES users(id),
lesson_id UUID NOT NULL REFERENCES lessons(id),
status VARCHAR(20) NOT NULL DEFAULT 'not_started',
progress_pct SMALLINT NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
UNIQUE (tenant_id, user_id, lesson_id)
);
-- ============================================
-- ASSESSMENT E SUBMISSION
-- ============================================
CREATE TABLE assessments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
course_id UUID NOT NULL REFERENCES courses(id),
title VARCHAR(500) NOT NULL,
assessment_type VARCHAR(30) NOT NULL, -- 'quiz', 'exam', 'assignment', 'peer_review'
max_score DECIMAL(6,2) NOT NULL DEFAULT 100,
passing_score DECIMAL(6,2) NOT NULL DEFAULT 60,
time_limit_min INTEGER,
max_attempts INTEGER DEFAULT 1,
questions JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
assessment_id UUID NOT NULL REFERENCES assessments(id),
user_id UUID NOT NULL REFERENCES users(id),
attempt_number SMALLINT NOT NULL DEFAULT 1,
answers JSONB NOT NULL DEFAULT '{}',
score DECIMAL(6,2),
status VARCHAR(20) NOT NULL DEFAULT 'submitted',
submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
graded_at TIMESTAMPTZ
);
-- ============================================
-- ROW-LEVEL SECURITY (RLS)
-- ============================================
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE courses ENABLE ROW LEVEL SECURITY;
ALTER TABLE enrollments ENABLE ROW LEVEL SECURITY;
ALTER TABLE lesson_progress ENABLE ROW LEVEL SECURITY;
ALTER TABLE submissions ENABLE ROW LEVEL SECURITY;
-- Policy: ogni utente vede solo i dati del proprio tenant
CREATE POLICY tenant_isolation_users ON users
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation_courses ON courses
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation_enrollments ON enrollments
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- INDICI per performance multi-tenant
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_courses_tenant ON courses(tenant_id);
CREATE INDEX idx_enrollments_tenant_user ON enrollments(tenant_id, user_id);
CREATE INDEX idx_enrollments_tenant_course ON enrollments(tenant_id, course_id);
CREATE INDEX idx_lesson_progress_tenant_user ON lesson_progress(tenant_id, user_id);
CREATE INDEX idx_submissions_tenant_assessment ON submissions(tenant_id, assessment_id);
注意: テナント ID を使用した複数列インデックス
共有スキーマ LMS では、 各インデックスには次のものが含まれている必要があります tenant_id 最初の列として。
のインデックス (email) テナントによってフィルタリングされたクエリには役に立ちません。それは役立ちます (tenant_id, email)。
分散型 Citus/シャーディングでは、分散列 (tenant_id) が存在する必要があります
すべての複合主キーとすべての UNIQUE 制約で。
関係図
| 実在物 | 関係 | 接続されたエンティティ | 枢機卿 |
|---|---|---|---|
| テナント | 所有している | ユーザー、コース | 1:いいえ |
| ユーザー | あなたはサインアップします | コース(登録経由) | N:M |
| コース | 含まれています | モジュール | 1:いいえ |
| モジュール | 含まれています | レッスン | 1:いいえ |
| コース | ha | 評価 | 1:いいえ |
| ユーザー | 提出する | 提出物(評価による) | 1:いいえ |
| ユーザー | 進捗状況を追跡する | レッスンの進行状況(レッスン経由) | 1:いいえ |
マルチテナントの認証と認可
マルチテナント LMS では、認証を解決する必要があります 2つの問題を同時に抱えている: ユーザーの身元を確認し、ユーザーがどのテナントに属しているかを判断します。認可により追加されるのは、 3 番目のレベル: ユーザーがテナント内でアクセスできるリソース。
クレームテナントを使用した JWT
最も一般的なアプローチでは、 JSONウェブトークン (JWT) テナント専用の請求を伴う。 トークンは認証後に発行され、解決に必要なすべての情報が含まれています データベースをさらに検索することなく、コンテキストの情報を取得できます。
import jwt from 'jsonwebtoken';
// Payload JWT immutabile
interface LmsTenantJwtPayload {
readonly sub: string; // User ID
readonly tenantId: string; // Tenant UUID
readonly tenantSlug: string; // Per display e logging
readonly role: 'student' | 'instructor' | 'admin' | 'super_admin';
readonly permissions: ReadonlyArray<string>;
readonly iat: number;
readonly exp: number;
}
// Generazione token con contesto tenant
function generateTenantToken(
user: { id: string; role: string },
tenant: { id: string; slug: string },
permissions: ReadonlyArray<string>
): string {
const payload: Omit<LmsTenantJwtPayload, 'iat' | 'exp'> = {
sub: user.id,
tenantId: tenant.id,
tenantSlug: tenant.slug,
role: user.role as LmsTenantJwtPayload['role'],
permissions,
};
return jwt.sign(payload, process.env.JWT_SECRET!, {
expiresIn: '8h',
issuer: 'lms-platform',
audience: tenant.slug,
});
}
// Middleware di validazione
function validateTenantToken(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'MISSING_TOKEN' });
}
try {
const token = authHeader.slice(7);
const decoded = jwt.verify(token, process.env.JWT_SECRET!, {
issuer: 'lms-platform',
}) as LmsTenantJwtPayload;
// Verifica coerenza tenant tra URL e token
const urlTenantId = (req as any).tenantId;
if (urlTenantId && decoded.tenantId !== urlTenantId) {
return res.status(403).json({
error: 'TENANT_MISMATCH',
message: 'Token tenant does not match request tenant',
});
}
(req as any).user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'INVALID_TOKEN' });
}
}
LMS の RBAC モデル
LMS での認可は通常、テンプレートに従います。 役割ベースのアクセス制御 (RBAC) 4 つの主要な役割があり、それぞれに累積的な権限があります。
| 役割 | 主要な権限 | ほうき |
|---|---|---|
| 学生 | 登録済みのコースの表示、課題の提出、成績の表示 | 自分が登録しているコースのみ |
| インストラクター | コースの作成/編集、提出物の評価、コースの分析の表示 | あなただけのコース |
| 管理者 | ユーザーの管理、テナントの構成、すべてのコースの表示、レポートのエクスポート | テナント全体 |
| スーパー管理者 | テナント、請求、機能フラグ、クロステナント サポートの管理 | 全テナント(プラットフォーム運営者) |
マルチテナント LMS の API 設計
マルチテナント LMS の API はバランスをとる必要があります 使いやすさ 開発者向け クライアント (フロントエンド、モバイル、統合) 厳格なセキュリティ データ分離中。 REST と GraphQL のどちらを選択するかは、主なアクセス パターンによって異なります。
REST API: マルチテナンシーの規則
LMS の REST アーキテクチャでは、テナントはミドルウェア (サブドメインまたはヘッダー) によって解決されます。 したがって、リソース パスにはテナント ID が含まれません。これにより API が簡素化され、 環境間で移植可能。
# Tenant risolto via sottodominio: mit.lms-api.com
# Il tenant_id e iniettato dal middleware, NON presente nei path
# === CORSI ===
GET /api/v1/courses # Lista corsi del tenant
GET /api/v1/courses/:courseId # Dettaglio corso
POST /api/v1/courses # Crea corso (instructor+)
PUT /api/v1/courses/:courseId # Aggiorna corso
DELETE /api/v1/courses/:courseId # Archivia corso (admin+)
# === ISCRIZIONI ===
POST /api/v1/courses/:courseId/enroll # Iscrivi utente corrente
GET /api/v1/enrollments # Le mie iscrizioni
GET /api/v1/courses/:courseId/students # Studenti iscritti (instructor+)
# === CONTENUTI ===
GET /api/v1/courses/:courseId/modules # Moduli del corso
GET /api/v1/modules/:moduleId/lessons # Lezioni del modulo
PUT /api/v1/lessons/:lessonId/progress # Aggiorna progresso
# === ASSESSMENT ===
GET /api/v1/courses/:courseId/assessments # Assessment del corso
POST /api/v1/assessments/:assessmentId/submissions # Sottometti risposta
GET /api/v1/submissions/:submissionId # Dettaglio submission
# === ANALYTICS (instructor/admin) ===
GET /api/v1/analytics/courses/:courseId/completion # Tassi completamento
GET /api/v1/analytics/tenant/overview # Overview tenant
GraphQL: フロントエンド向けの柔軟なクエリ
GraphQL は、ページで頻繁に GraphQL が必要になるため、LMS クライアントに特に効果的です。 複雑な集計: 登録済みコース、進捗状況、今後のコースに関する学生ダッシュボード 割り当てと通知を 1 つのリクエストで実行できます。
# Il contesto tenant e iniettato automaticamente dal middleware
# Ogni resolver filtra per tenant_id dal contesto
type Query {
# Corsi disponibili per il tenant corrente
courses(
status: CourseStatus
search: String
pagination: PaginationInput
): CourseConnection!
# Dettaglio corso (solo se nel tenant corrente)
course(id: ID!): Course
# Dashboard studente: dati aggregati in una singola query
myDashboard: StudentDashboard!
# Analytics per instructor
courseAnalytics(courseId: ID!): CourseAnalytics!
}
type Course {
id: ID!
title: String!
description: String
instructor: User!
modules: [Module!]!
enrollmentCount: Int!
completionRate: Float
status: CourseStatus!
createdAt: DateTime!
}
type StudentDashboard {
enrolledCourses: [EnrolledCourseProgress!]!
upcomingAssessments: [Assessment!]!
recentActivity: [ActivityEvent!]!
overallProgress: Float!
certificatesEarned: Int!
}
type EnrolledCourseProgress {
course: Course!
progressPercent: Float!
lastAccessedAt: DateTime
nextLesson: Lesson
}
type Mutation {
enrollInCourse(courseId: ID!): Enrollment!
updateLessonProgress(lessonId: ID!, progress: Int!): LessonProgress!
submitAssessment(assessmentId: ID!, answers: JSON!): Submission!
createCourse(input: CreateCourseInput!): Course!
}
type Subscription {
# Notifiche real-time per lo studente
studentNotifications: Notification!
# Aggiornamenti live del progresso (per instructor dashboard)
courseProgressUpdates(courseId: ID!): ProgressUpdate!
}
コンテンツ配信とメディアストリーミング
LMS は、PDF ドキュメント、プレゼンテーション、オンデマンドビデオ、ライブストリーミング、 インタラクティブなクイズと仮想ワークショップ。コンテンツ配信アーキテクチャは、次のことを保証する必要があります。 低遅延, テナントごとの分離 e アクセス制御 粒状。
マルチテナントストレージ戦略
ストレージ パターンの選択はデータベースの選択を反映します。分離性が高いほど、コストも高くなります。 SaaS LMS の最も一般的なアプローチでは、 単一の S3 バケット プレフィックスベースの分離を使用する場合:
# Pattern di organizzazione S3 per LMS multi-tenant
# Bucket: lms-content-production
# Contenuti del corso
s3://lms-content/{tenant_id}/courses/{course_id}/videos/lecture-01.mp4
s3://lms-content/{tenant_id}/courses/{course_id}/documents/syllabus.pdf
s3://lms-content/{tenant_id}/courses/{course_id}/images/cover.webp
# Submission degli studenti
s3://lms-content/{tenant_id}/submissions/{assessment_id}/{user_id}/file.pdf
# Asset del tenant (logo, branding)
s3://lms-content/{tenant_id}/branding/logo.svg
s3://lms-content/{tenant_id}/branding/theme.json
# Bucket policy limita l'accesso per prefix = tenant_id
# Signed URLs con expiry di 1 ora per accesso ai contenuti
教育ビデオ用の CDN アーキテクチャ
ビデオは、最新の LMS のトラフィックの 70 ~ 80% を占めています。配信アーキテクチャ ビデオには次のものが必要です。
- アダプティブトランスコーディング: 各ビデオは、アダプティブ ビットレート ストリーミング用の HLS/DASH を使用して複数の解像度 (360p、720p、1080p) でエンコードされます。
- トークン認証を使用した CDN: 有料コンテンツへの不正アクセスを防止する署名付き URL を備えた CloudFront または Cloudflare。
- テナントごとのエッジ キャッシュ: 大規模なテナントからのコンテンツは、TTFB を削減するために PoP エッジに事前にロードされます。
- 動的透かし: コンテンツ漏洩を追跡するためのユーザー ID を含む非表示のオーバーレイ。
ビデオ パイプライン アーキテクチャ
インストラクターがアップロードしたビデオの完全なフロー:
- アップロード: S3 への直接アップロード用の署名付き URL (アプリケーション サーバー バイパス)
- S3イベント: Lambda/クラウド関数をトリガーする
s3:ObjectCreated - トランスコーディング: マルチビットレート HLS を生成する ECS/Cloud Run 上の AWS MediaConvert または FFmpeg
- メタデータ: LMS データベースに書き込まれる期間、サムネイル、サイズ
- CDN の無効化: 所有テナントのキャッシュウォーミング
- 通知: WebSocket 経由で講師に通知するためにイベントが公開されました
リアルタイム機能のためのイベント駆動型アーキテクチャ
最新の LMS には、多くの非同期およびリアルタイム機能が必要です。インストラクターが通知を受け取る際にプッシュ通知が必要です。 投票の投稿、ランキングのライブ更新、ダッシュボードでのリアルタイムの進捗状況の追跡 インストラクター、今後の締め切りの自動リマインダー。これらのニーズに必要なのは、 あるイベント駆動型アーキテクチャ.
LMS のドメイン イベント
最初のステップは、重要なプラットフォーム ドメイン イベントを特定することです。 すべてのイベントは、システム内で起こったことを説明する不変の事実です。
// Evento base immutabile
interface LmsDomainEvent {
readonly eventId: string;
readonly eventType: string;
readonly tenantId: string;
readonly userId: string;
readonly timestamp: string; // ISO 8601
readonly version: number; // Schema versioning
}
// === ENROLLMENT EVENTS ===
interface StudentEnrolledEvent extends LmsDomainEvent {
readonly eventType: 'student.enrolled';
readonly payload: {
readonly courseId: string;
readonly courseName: string;
readonly enrollmentId: string;
};
}
// === PROGRESS EVENTS ===
interface LessonCompletedEvent extends LmsDomainEvent {
readonly eventType: 'lesson.completed';
readonly payload: {
readonly courseId: string;
readonly moduleId: string;
readonly lessonId: string;
readonly progressPercent: number; // 0-100 per il corso
};
}
// === ASSESSMENT EVENTS ===
interface AssessmentSubmittedEvent extends LmsDomainEvent {
readonly eventType: 'assessment.submitted';
readonly payload: {
readonly assessmentId: string;
readonly submissionId: string;
readonly courseId: string;
readonly attemptNumber: number;
};
}
interface AssessmentGradedEvent extends LmsDomainEvent {
readonly eventType: 'assessment.graded';
readonly payload: {
readonly submissionId: string;
readonly assessmentId: string;
readonly score: number;
readonly maxScore: number;
readonly passed: boolean;
};
}
// === COURSE LIFECYCLE EVENTS ===
interface CoursePublishedEvent extends LmsDomainEvent {
readonly eventType: 'course.published';
readonly payload: {
readonly courseId: string;
readonly courseName: string;
readonly instructorId: string;
readonly moduleCount: number;
readonly lessonCount: number;
};
}
// Event bus con partizionamento per tenant
class LmsEventBus {
constructor(private readonly kafka: KafkaProducer) {}
async publish(event: LmsDomainEvent): Promise<void> {
await this.kafka.send({
topic: `lms.events.${event.eventType.split('.')[0]}`,
messages: [{
key: event.tenantId, // Partizionamento per tenant
value: JSON.stringify(event),
headers: {
'event-type': event.eventType,
'tenant-id': event.tenantId,
'event-version': String(event.version),
},
}],
});
}
}
消費者と予測
イベントは、通知、通知などの副作用を実行する特殊なハンドラーによって消費されます。 マテリアライズド ビュー、分析、電子メール スケジュールの更新。パターン ドメインごとのコンシューマー 結束力を維持します。
| イベント | 消費者 | アクション |
|---|---|---|
student.enrolled | 通知サービス | ウェルカムメール + プッシュ通知 |
student.enrolled | 分析サービス | 履修登録カウンターを更新しました |
lesson.completed | ProgressService | コース全体の進捗状況を再計算する |
lesson.completed | ゲーミフィケーションサービス | バッジのロックが解除されるかどうかを確認する |
assessment.graded | 通知サービス | 生徒に成績を通知する |
assessment.graded | 証明書サービス | コースを完了した場合は証明書を生成します |
course.published | SearchIndexService | カタログ Elasticsearch インデックスを更新する |
course.published | 通知サービス | 登録した生徒に教師に通知する |
マイクロサービスへの分解
100,000 人を超える同時ユーザーにサービスを提供するエンタープライズ LMS プラットフォームの場合、モノリスは次のようになります。 ボトルネック。マイクロサービスへの分解により、独立して拡張できるようになります 最もストレスがかかるコンポーネント。重要なのは、 境界のあるコンテキスト 正しい ドメイン駆動設計の原則に従っています。
LMS の境界付きコンテキスト
| マイクロサービス | 責任 | データベース | プロトコル |
|---|---|---|---|
| テナントサービス | テナント登録、構成、請求、機能フラグ | 専用のPostgreSQL | REST + gRPC |
| アイデンティティサービス | 認証、SSO (SAML/OIDC)、ユーザー管理、RBAC | PostgreSQL + Redis (セッション) | REST + OAuth 2.0 |
| コースサービス | CRUD コース、モジュール、レッスン、カタログ、検索 | PostgreSQL + Elasticsearch | REST + GraphQL |
| 登録サービス | 登録、進行状況、完了、証明書 | PostgreSQL + Redis (進捗キャッシュ) | REST + Kafka イベント |
| 評価サービス | クイズエンジン、採点、ルーブリック、査読 | PostgreSQL + S3 (ファイル送信) | REST + Kafka イベント |
| コンテンツサービス | アップロード、トランスコーディング、ストレージ、CDN、SCORM パッケージ化 | S3 + DynamoDB (メタデータ) | REST + S3 イベント |
| 通知サービス | 電子メール、プッシュ、アプリ内、WebSocket、ダイジェスト スケジュール | Redis + SQS | Kafka + WebSocket イベント |
| 分析サービス | xAPI/Caliper、ダッシュボード、レポート、データ エクスポート | ClickHouse + S3 (データレイク) | Kafka + REST イベント |
サービス間の通信
コミュニケーションは 2 つの基本的なパターンに従います。
- 同期 (REST/gRPC): 即時応答が必要な操作向け。例: コース サービスは、コースを作成する前に ID サービスを呼び出してユーザーの権限を確認します。
- 非同期 (Kafka): 即時の応答を必要としない操作の場合。例: 生徒がレッスンを完了すると、通知、分析、ゲーミフィケーションとは関係なく、イベントが公開および消費されます。
黄金律: API 呼び出しよりもイベントを優先する
アクションが同期応答を必要としない場合、 常にイベントを使用する。 これにより、サービス間の結合が減少し、回復力 (消費者の障害の軽減) が向上します。 プロデューサーをブロックしません)、デバッグとリカバリのためのイベントの再生を可能にします。 LMS では、イベントが約 70%、同期呼び出しが 30% が理想的な比率です。
スケーラビリティ: キャッシュ、シャーディング、CDN
エンタープライズ LMS は、大学の学期の初日、 何万人もの学生が同時にログインします。オンライン試験プラットフォーム 試験セッション中に 10 倍の負荷がかかります。適切に設計されたスケーリング戦略がなければ、 システムは最も必要なときに正確に崩壊します。
マルチレベルのキャッシュ戦略
| レベル | テクノロジー | カチャティデータ | TTL |
|---|---|---|---|
| L1: ブラウザ | Service Worker + キャッシュ API | 静的アセット、レッスンコンテンツ(オフライン) | 24 時間 (バージョン管理による無効化) |
| L2: CDN エッジ | CloudFront / Cloudflare | HLS ビデオ、画像、CSS/JS、PDF ドキュメント | 7 日間 (公開時無効) |
| L3: API ゲートウェイ | レディス/ワニス | GET API レスポンス (コースカタログ、ユーザープロフィール) | 5~15分 |
| L4: アプリケーション | Redis クラスター | セッション、テナント構成、学生の進捗状況、RBAC 権限 | 5~30分 |
| L5: データベース | PostgreSQL マテリアライズド ビュー | 集計分析、リーダーボード、コース統計 | 5 ~ 60 分ごとに更新 |
Citus for PostgreSQL を使用したシャーディング
数百万のユーザーと「共有スキーマ」パターンを持つ LMS の場合、 シタス (PostgreSQL 拡張機能
分散コンピューティング用) を使用すると、SQL の互換性を維持しながら、複数のノードにデータを分散できます。
マルチテナント LMS の自然なシャーディング戦略とパーティション化 tenant_id.
-- Distribuire le tabelle principali per tenant_id
SELECT create_distributed_table('users', 'tenant_id');
SELECT create_distributed_table('courses', 'tenant_id');
SELECT create_distributed_table('enrollments', 'tenant_id');
SELECT create_distributed_table('lesson_progress', 'tenant_id');
SELECT create_distributed_table('submissions', 'tenant_id');
-- Tabelle di riferimento (replicate su ogni nodo)
SELECT create_reference_table('tenants');
-- Query automaticamente distribuite
-- Citus instrada le query al nodo corretto basandosi su tenant_id
SELECT c.title, COUNT(e.id) AS enrolled_students
FROM courses c
JOIN enrollments e ON c.id = e.course_id AND c.tenant_id = e.tenant_id
WHERE c.tenant_id = 'uuid-tenant-mit'
GROUP BY c.title
ORDER BY enrolled_students DESC;
-- Performance: query single-tenant eseguita su un singolo nodo
-- Nessun shuffle di dati tra nodi = latenza minima
パフォーマンスのベンチマーク
次のベンチマークは、5,000 のテナントと総ユーザー数 200 万人の LMS に基づいています。
PostgreSQL 16 + Citus、3 ノードを使用して AWS にデプロイ r6g.2xlarge:
| 手術 | Citus なし (単一ノード) | Citusあり(3ノード) | 改善 |
|---|---|---|---|
| コース一覧(テナント別) | 45ミリ秒 | 12ミリ秒 | 3.7倍 |
| 学生ダッシュボード | 180ミリ秒 | 35ミリ秒 | 5.1倍 |
| コース分析(集計) | 2,400ミリ秒 | 320ミリ秒 | 7.5倍 |
| 登録バースト (同時 1,000) | タイムアウト (>5 秒) | 89ミリ秒 (p99) | >56x |
| クロステナントレポート (管理者) | 12,000ミリ秒 | 1,800ミリ秒 | 6.7倍 |
アーキテクチャの比較: 実際の LMS プラットフォーム
理論的なパターンが実際の実装にどのように変換されるかを理解するために、分析してみましょう 市場の主要な LMS プラットフォームのアーキテクチャ。
| プラットフォーム | 建築 | マルチテナンシー | 技術スタック | スケーラビリティ |
|---|---|---|---|---|
| ムードル | モジュラーモノリス | テナントごと(MoodleCloud)またはシングルテナント(自己ホスト型)データベース | PHP 8、MySQL/PostgreSQL、cron ジョブ | 垂直型、大規模なチューニングなしでインスタンスあたり最大 10,000 ユーザーに制限 |
| キャンバス LMS | 衛星サービスを備えたモノリス | 機関別のシャードとの共有スキーマ (Switchman gem) | Ruby on Rails、PostgreSQL、Redis、S3、Consul | シャーディングによる水平型、数百万のユーザーにサービスを提供 |
| Blackboard Learn Ultra | マイクロサービス (Java モノリスからの移行) | ハイブリッド: テナントごとのデータベース (エンタープライズ)、共有スキーマ (SaaS) | Java/Spring Boot、AWS、マイクロサービス、Kafka | クラウドネイティブ、サービスごとの自動スケーリング |
| オープンedX | サービス指向 (Django サービス) | 組織ごとのインスタンス (講師) またはマルチサイト | Python/Django、MySQL、MongoDB、Celery、RabbitMQ | Kubernetesベースのサービスごとのスケーリング |
| ドセボ | クラウドネイティブSaaS | tenant_id + RLS による共有スキーマ | プロプライエタリ (Node.js/Go を想定)、AWS、AI ネイティブ | 自動スケーリング、3,800 を超える企業顧客にサービスを提供 |
| コーセラ | イベント駆動型のマイクロサービス | パートナーごとのシャーディングを備えたネイティブ マルチテナント | Scala、Python、Cassandra、Kafka、Kubernetes | グローバル、1 億 3,000 万人以上のユーザー、マルチリージョン |
実際のプラットフォームからの教訓
- キャンバス: Switchman gem によるシャーディングは、適切に設計されたモノリスがマイクロサービスを書き直すことなく数百万のユーザーに拡張できることを示しています。
- 黒板: Java モノリスからマイクロサービスへの移行には 4 年以上かかり、多額の投資がかかりました。学んだ教訓: 早期に分解するか、まったく分解しない。
- コーセラ: Kafka をバックボーンとしたイベント駆動型のアーキテクチャにより、既存のサービスを変更せずに新しい機能 (AI レコメンデーション、分析) を追加できます。
- edX を開きます。 Django のマルチサービス アプローチは、「モノリシック」フレームワークでも、境界のあるコンテキストを適切に分離できることを示しています。
アーキテクチャ選択ガイド
普遍的に最適なパターンはありません。選択は製品プロファイルによって異なります。 予想されるテナント数と対応可能なエンジニアリング チームによって決定されます。実際の意思決定ツリーは次のとおりです。
デシジョンツリー
- 2年後には何人のテナントが入ると予想していますか?
- 50 社未満、カスタム契約を結んでいるすべての企業 → テナントごとのデータベース
- 50~2,000、サイズ混合 → テナントごとのスキーマ
- 2,000 以上、ほとんどが中小企業 → 共有スキーマ + RLS
- コンプライアンスはどの程度重要ですか?
- 厳格な監査証跡を備えた FERPA/HIPAA → 物理的分離を優先 (テナントごとの DB)
- 標準の GDPR → 適切な監査を伴うテナントまたは RLS スキーム
- エンジニアリングチームの規模はどれくらいですか?
- 3 ~ 5 人の開発者 → 共有スキーマ (運用上のオーバーヘッドが少ない)
- 10 ~ 20 人の開発者 → テナントまたは軽量のマイクロサービス スキーマ
- 20 人以上の開発者 → チームの所有権でマイクロサービスを完成させる
- マイクロサービスかモノリスか?
- 同時ユーザー数が 50,000 未満 → モジュラーモノリス (Canvas のような)
- 50,000~500,000 の同時ユーザー → メディアと分析のためのモノリス + サテライト サービス
- 50万人以上の同時ユーザー → イベントバスによる完全なマイクロサービス
結論と次のステップ
スケーラブルなマルチテナント LMS の設計には、影響を与えるアーキテクチャ上の決定が必要です 製品の成長能力、データ セキュリティ、運用コストを長年にわたって考慮してきました。 唯一の解決策はありません。最も成功しているプラットフォームはアーキテクチャを進化させています。 時間の経過とともに、多くの場合、共通のパターンを持つモノリスから始まり、徐々に分解されていきます。 最も重要なサービス。
この記事の重要なポイント:
- 顧客プロファイルに基づいてマルチテナント パターンを選択する、技術的なファッションではありません。ほとんどの SaaS LMS では、共有スキーマ + RLS で十分です。
- テナント解決とセキュリティの基盤。 キャッシュと厳密な検証を備えた堅牢なミドルウェアとしてデプロイします。
- PostgreSQL 行レベル セキュリティはあなたの最良の味方です アプリケーションを過度に複雑にすることなくデータベースレベルの分離を実現します。
- イベント駆動型アーキテクチャが不可欠 通知、分析、最新の LMS のすべてのリアルタイム機能に対応します。
- Citus for PostgreSQL 単一ノードでは不十分な場合に、最小限のアプリケーション変更で水平方向に拡張できます。
- モジュラーモノリスから始める 規模やチーム組織で必要な場合にのみマイクロサービスに分解します。
シリーズの次の記事では、教育ビデオストリーミングアーキテクチャ、 アダプティブトランスコーディング、保護されたコンテンツの DRM、ライブクラスの超低遅延に対処します。 ビデオ インフラストラクチャは、多くの場合、LMS の最も高価で複雑なコンポーネントであるため、分析する価値があります。 徹底的に。
詳細を学ぶためのリソース
- クランチデータのブログ: 「マルチテナント用の Postgres データベースの設計」 - PostgreSQL を使用したマルチテナントの実践的なガイド
- Citus のドキュメント: 実際のシャーディング例を含むマルチテナント SaaS チュートリアル
- キャンバス LMS (GitHub): Switchman for Rails + PostgreSQL シャーディングを使用したオープンソース コード
- オープン edX アーキテクチャ: Open edX サービス アーキテクチャの公式ドキュメント
- Microsoft Azure マルチテナント パターン: マルチテナント クラウド SaaS のアーキテクト ガイド







