mybook

Kata: マルチテナントSaaS — テナント分離戦略

課題の提示

「タクミさん、一つのデータベースにA社とB社のデータが混在している。何が怖い?」

タクミは即答した。「A社がB社のデータを見られたら...最悪です」

「そう。これがマルチテナントの本質的な問題よ。共有のインフラで、複数の顧客を安全に分離する」


Kata 8: マルチテナントSaaSプラットフォーム

BtoB SaaS として複数の企業に同じシステムを提供したい。

  • テナント数: 最初は10社、3年後に1,000社を目標
  • テナント間の完全なデータ分離(漏洩は致命的)
  • テナントごとにカスタマイズ可能な設定
  • 大口顧客(500人以上)と小口顧客(10人以下)が混在
  • テナントごとに異なるプラン(Free/Pro/Enterprise)
  • コンプライアンス要件: 一部の顧客はデータを国内に限定したい

「3つの分離戦略がある。どれを選ぶか、まず自分で考えなさい」とナオミは言った。


設計判断

テナント分離の3戦略

Loading diagram...
戦略コスト分離強度管理複雑さ向いているケース
行レベル分離多数の小口顧客
スキーマ分離バランス型
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 = ?
end

WARNING

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
end

AWSインフラ: ハイブリッド分離

Loading diagram...

動的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
end

WARNING

テナント分離は「動けば良い」の設計ではない。データ漏洩が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)完全保証コスト高、接続数増