Kata: マルチテナントSaaS — テナント分離戦略
課題の提示
「タクミさん、一つのデータベースにA社とB社のデータが混在している。何が怖い?」
タクミは即答した。「A社がB社のデータを見られたら...最悪です」
「そう。これがマルチテナントの本質的な問題よ。共有のインフラで、複数の顧客を安全に分離する」
Kata 8: マルチテナントSaaSプラットフォーム
BtoB SaaS として複数の企業に同じシステムを提供したい。
- テナント数: 最初は10社、3年後に1,000社を目標
- テナント間の完全なデータ分離(漏洩は致命的)
- テナントごとにカスタマイズ可能な設定
- 大口顧客(500人以上)と小口顧客(10人以下)が混在
- テナントごとに異なるプラン(Free/Pro/Enterprise)
- コンプライアンス要件: 一部の顧客はデータを国内に限定したい
「3つの分離戦略がある。どれを選ぶか、まず自分で考えなさい」とナオミは言った。
設計判断
テナント分離の3戦略
| 戦略 | コスト | 分離強度 | 管理複雑さ | 向いているケース |
|---|---|---|---|---|
| 行レベル分離 | 低 | 中 | 低 | 多数の小口顧客 |
| スキーマ分離 | 中 | 高 | 中 | バランス型 |
| DB分離 | 高 | 最高 | 高 | 大口・Enterprise |
判断: ハイブリッド戦略を採用する。
- Free/Pro: 行レベル分離(コスト効率重視)
- Enterprise: DB分離(完全な分離保証)
- 国内限定要件の顧客: 専用RDSインスタンス
INFO
Shopify、Salesforce、GitLabなど主要SaaSの多くはハイブリッド戦略を使う。顧客のプランとリスク許容度に応じて分離レベルを変える。
実装: 行レベル分離
Apartment gem によるマルチテナント
# Gemfile
gem "apartment"
# config/initializers/apartment.rb
Apartment.configure do |config|
# テナントごとにPostgreSQLスキーマを切り替える
config.excluded_models = %w[Tenant Plan] # グローバルなテーブルはスキーマを切り替えない
config.tenant_names = -> { Tenant.pluck(:subdomain) }
# テナントのスキーマ名
config.use_schemas = true
endテナントモデルの設計
# app/models/tenant.rb
class Tenant < ApplicationRecord
has_many :users
belongs_to :plan
validates :name, presence: true
validates :subdomain, presence: true, uniqueness: true,
format: { with: /\A[a-z0-9-]+\z/, message: "小文字英数字とハイフンのみ使用可" }
enum :status, { active: "active", suspended: "suspended", cancelled: "cancelled" }
def switch!
Apartment::Tenant.switch!(subdomain)
end
def self.current
Thread.current[:current_tenant]
end
def self.current=(tenant)
Thread.current[:current_tenant] = tenant
end
endテナント切り替えミドルウェア
# app/middleware/tenant_resolver.rb
class TenantResolver
def initialize(app)
@app = app
end
def call(env)
request = Rack::Request.new(env)
tenant = resolve_tenant(request)
if tenant.nil?
return [404, { "Content-Type" => "text/plain" }, ["テナントが見つかりません"]]
end
unless tenant.active?
return [403, { "Content-Type" => "text/plain" }, ["このアカウントは停止されています"]]
end
Tenant.current = tenant
# テナントのスキーマに切り替えてリクエスト処理
Apartment::Tenant.switch(tenant.subdomain) do
@app.call(env)
end
ensure
Tenant.current = nil
end
private
def resolve_tenant(request)
# サブドメインからテナントを特定
# 例: company-a.saas.example.com → subdomain = "company-a"
subdomain = request.host.split(".").first
Tenant.active.find_by(subdomain: subdomain)
end
end
# config/application.rb
config.middleware.use TenantResolverテナントスコープの自動適用
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
# デフォルトスコープでテナントを常に絞り込む(行レベル分離時)
default_scope { tenant_scope }
private
def self.tenant_scope
return all unless column_names.include?("tenant_id")
return all if Tenant.current.nil?
where(tenant_id: Tenant.current.id)
end
end
# テナントIDの自動設定
module TenantScoped
extend ActiveSupport::Concern
included do
belongs_to :tenant
before_validation :set_tenant
validates :tenant_id, presence: true
# デフォルトスコープで現在のテナントに限定
default_scope { where(tenant_id: Tenant.current&.id) }
end
private
def set_tenant
self.tenant ||= Tenant.current
end
end
# 使用例
class Project < ApplicationRecord
include TenantScoped
# 以降の全クエリはテナント限定になる
# Project.all → SELECT * FROM projects WHERE tenant_id = ?
endWARNING
default_scope のテナント絞り込みは強力だが、間違えると全テナントのデータを誤って操作するリスクがある。必ずRSpecでテナント越境テストを書くこと。
テナントごとのカスタマイズ
# app/models/tenant_setting.rb
class TenantSetting < ApplicationRecord
belongs_to :tenant
# 設定をJSONBで柔軟に保存
store_accessor :config,
:theme_color,
:logo_url,
:email_sender_name,
:timezone,
:locale,
:custom_fields,
:feature_flags
def feature_enabled?(feature_name)
feature_flags&.dig(feature_name.to_s) == true
end
end
# 機能フラグの使用
class ProjectsController < ApplicationController
def create
unless current_tenant.settings.feature_enabled?(:advanced_project_management)
return render json: { error: "この機能はProプラン以上で利用できます" }, status: :forbidden
end
# 処理続行
end
endプラン管理と使用量制限
# app/models/plan.rb
class Plan < ApplicationRecord
TIERS = %w[free pro enterprise].freeze
has_many :tenants
validates :tier, inclusion: { in: TIERS }
def limits
{
max_users: tier == "free" ? 5 : tier == "pro" ? 50 : Float::INFINITY,
max_projects: tier == "free" ? 3 : tier == "pro" ? 100 : Float::INFINITY,
storage_gb: tier == "free" ? 1 : tier == "pro" ? 50 : 1000,
api_calls_per_month: tier == "free" ? 1000 : tier == "pro" ? 100_000 : Float::INFINITY
}
end
end
# app/services/usage_limiter.rb
class UsageLimiter
def initialize(tenant)
@tenant = tenant
@plan = tenant.plan
end
def check_user_limit!
current_count = @tenant.users.active.count
limit = @plan.limits[:max_users]
if current_count >= limit
raise PlanLimitError, "ユーザー上限(#{limit}名)に達しました。プランをアップグレードしてください"
end
end
def check_storage_limit!(file_size_bytes)
used_gb = @tenant.file_uploads.sum(:file_size) / 1.gigabyte
limit_gb = @plan.limits[:storage_gb]
if used_gb + (file_size_bytes.to_f / 1.gigabyte) > limit_gb
raise PlanLimitError, "ストレージ上限(#{limit_gb}GB)に達しました"
end
end
endAWSインフラ: ハイブリッド分離
動的DB接続
# config/database.yml
default: &default
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
production:
primary:
<<: *default
host: <%= ENV['SHARED_DB_HOST'] %>
database: saas_production
enterprise_db:
<<: *default
host: <%= ENV['ENTERPRISE_DB_HOST'] %>
database: enterprise_production
# Enterprise テナントは別DBに接続
class EnterpriseRecord < ApplicationRecord
self.abstract_class = true
connects_to database: { writing: :enterprise_db, reading: :enterprise_db }
endテナント間のデータ越境防止
テスト戦略が最も重要だ。
# spec/support/tenant_isolation_spec.rb
RSpec.shared_examples "tenant isolation" do |model_class|
it "テナントAのデータがテナントBから見えない" do
tenant_a = create(:tenant)
tenant_b = create(:tenant)
record = nil
Apartment::Tenant.switch(tenant_a.subdomain) do
Tenant.current = tenant_a
record = create(model_class.name.underscore.to_sym)
end
Apartment::Tenant.switch(tenant_b.subdomain) do
Tenant.current = tenant_b
expect(model_class.find_by(id: record.id)).to be_nil
end
end
end
# spec/models/project_spec.rb
RSpec.describe Project do
it_behaves_like "tenant isolation", Project
endWARNING
テナント分離は「動けば良い」の設計ではない。データ漏洩が1件でも起きたら、SaaSビジネスは終わる。テナント越境テストを全モデルに対して自動化する。
テナントのオンボーディング自動化
# app/services/tenant_provisioner.rb
class TenantProvisioner
def self.provision!(name:, subdomain:, owner_email:, plan:)
ActiveRecord::Base.transaction do
# グローバルにテナントを作成
tenant = Tenant.create!(
name: name,
subdomain: subdomain,
plan: plan,
status: :active
)
# テナントスキーマを作成
Apartment::Tenant.create(subdomain)
# スキーマ内にオーナーユーザーを作成
Apartment::Tenant.switch(subdomain) do
Tenant.current = tenant
User.create!(
email: owner_email,
role: :owner,
tenant: tenant
)
# デフォルト設定を作成
TenantSetting.create!(tenant: tenant)
end
# ウェルカムメールを送信
TenantOnboardingMailer.welcome(tenant, owner_email).deliver_later
tenant
end
rescue => e
# ロールバック: スキーマを削除
Apartment::Tenant.drop(subdomain) rescue nil
raise
end
end振り返り
「一番難しかったのは何?」ナオミが聞いた。
「テナント境界をコードで表現することです。ミドルウェア、デフォルトスコープ、テスト。設計が一箇所でも漏れたら、データ漏洩になる」
「そう。マルチテナントの設計は、防御を多層に重ねること。ミドルウェアで切り替え、モデルで絞り込み、DBスキーマで物理分離、テストで検証。どれか一層が外れても守れるように」
INFO
Kata 8 の学び: マルチテナントの分離戦略は「コスト」vs「分離強度」vs「管理複雑さ」のトレードオフ。正解は一つではない。顧客のリスク許容度とプランに応じてハイブリッドに設計する。
トレードオフの記録
| 決定 | メリット | デメリット |
|---|---|---|
| スキーマ分離(メイン) | バランス型、マイグレーション並列 | テナント数増加で管理コスト増 |
| 行レベル分離(Free) | コスト最小 | アプリのバグでデータ越境リスク |
| DB分離(Enterprise) | 完全保証 | コスト高、接続数増 |