以下の内容はhttps://techblog.raksul.com/entry/2025/12/16/183925より取得しました。


社内検証環境のマルチテナント化

本記事は ラクスル Advent Calendar 2025 16 日目の記事です。

TL;DR

  • 社内検証環境の 11 環境を 1 インスタンスで処理するためにマルチテナント化(データベース分離方式)を実施。
  • Railsアプリケーションで、Rails標準の Active Record マルチDB機能(Sharding) を採用し、リクエストごとのホスト名で接続先DBを切り替え。
  • 効果:年間コストを約 300万円 削減(11 インスタンス運用→ 1 インスタンス)。
  • トレードオフ:全環境で同一コードが動くため、環境ごとの独立デプロイは不可に。

はじめに

こんにちは、エンタープライズ開発部でエンジニアをしている木下(@m_k01104)です。

今回、新規開発した認証関連の基盤マイクロサービスに対して、複数ある社内検証環境(以下、QA環境)のマルチテナント対応を行い、1インスタンスで全QA環境をカバーできる構成にしました。

本記事では、その設計方針、実装のポイント、そして得られたコストメリットについてまとめます。

※ 本アプリケーションはRuby on Railsで実装しています。

※ 本文中のコードは説明用に簡略化しています。

背景

マルチテナントとは

マルチテナントとは、1つのインスタンス(サーバやアプリケーション)を複数の利用者(テナント)が共有して利用するアーキテクチャのことです。

マンションのように、1つの大きな建物の中に複数の世帯がそれぞれの部屋に分かれて住むイメージです。

対となるのがシングルテナントで、1つのインスタンスを1つの利用者が占有する形式(一戸建てのイメージ)です。

本記事における「QA環境のマルチテナント」とは、1つのアプリケーションインスタンスを複数のQA環境で共有して使用するアーキテクチャを指します。

マルチテナント化した理由

ラクスルでは、以下の理由からQA環境のマルチテナント化を行うことになりました。

  • QA環境数が多い:ラクスルのQA環境は 11 環境 存在します。
  • 全環境での稼働が必須:認証基盤というサービスの性質上、すべてのQA環境で利用できる必要があります。
  • コストの課題:11環境それぞれに個別サーバを立てると、インフラコストが肥大化します。

方針

マルチテナントの実装方式

マルチテナントの実装方式には、主に「Row Level方式」「Schema分離方式」「データベース分離方式」の3種類があります。

  • Row Level方式:すべて同じテーブルに保存し、 tenant_id カラムで区別する。
  • Schema分離方式:共通のDB内で、テナントごとにスキーマを分ける。
  • データベース分離方式:テナントごとにデータベースそのものを分ける。

マルチテナントの実装方式

今回は、以下の理由から「データベース分離方式」を採用しました。アプリは単一プロセスで稼働し、リクエストごとに接続先DBを動的に切り替えます。

  • 完全なデータ分離による高いセキュリティ
  • モデルをクリーンに保持(全テーブルへのtenant_idが不要)
  • 構造が直感的で、障害調査もしやすい

技術選定

実装にあたり、Rails公式の Active RecordのマルチDB機能 を採用しました。(Railsガイド参照)

検討段階では、以下のサードパーティGemも候補に挙がりました。

しかし、どのサードパーティGemもメンテナンスが不安定だったため、長期的なメンテナンス性とサポートの確実性を優先し、Rails本体の機能を利用することに決定しました。

一部高度な機能は自前で実装が必要になりますが、フレームワーク標準に乗るメリットの方が大きいと判断しました。

実装

全体の流れ

  1. 事前の設定:テナントごとの設定とDBとの対応付けを行う。
  2. リクエスト処理:nginxが全QA環境のリクエストを受け付け、アプリケーションに流す。
  3. DB切り替え:ホスト名からテナントを特定し、対応付いた接続先DBに切り替える。

全体の流れ

1. テナントごとの設定、DBとの対応付け

1.1 テナントごとの設定の管理

テナント定義は config/settings/qa.yml に集約し、ホストやAPIキーを一元管理します。

config gemで Settings 定数として参照できるようにします。

# config/settings/qa.yml
tenants:
  qa1:
    routes:
      url_options:
        host:
    api_key:
  qa2:
    # ...
  qa3:
    # ...

1.2 データベース設定

各テナント用DBを database.yml で定義します。

全テナントで同一のスキーマを使うため、マイグレーションパスは共通化しています。

# config/database.yml
default: &default
  migrations_paths: db/migrate

qa:
  qa1:
    <<: *default
    database: xxx_qa1
  qa2:
    <<: *default
    database: xxx_qa2
  # ...

1.3 テナントとDBの対応付け

各テナントを「シャード(shard)」として扱い、connects_to で テナント名(Settings.tenants のキー)とDB名を対応付けます。

必要応じてreadingも指定してください。

# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  connects_to shards: Settings.tenants.each_with_object({}) do |(tenant, _), result|
    result[tenant] = { writing: [tenant.to](http://tenant.to)_sym }
  end
end

2. リクエストごとのDBの切り替え

2.1 テナントの特定

リクエスト時、ホスト名からテナントを特定し、どのシャードに接続するかを決定しています。(shard_resolver

lock: true により、リクエスト処理中は同一シャード(DB)への接続が固定されます。(shard_selector)

また、現在のテナントをTenant.current に格納します。(後述)

Rails.application.configure do
  config.active_record.shard_selector = { lock: true }
  config.active_record.shard_resolver = ->(request) do
    Tenant.current = Settings.tenants.find do |_, config|
      [
        config.routes.url_options.host,
      ].include?(request.host)
    end.first
  end
end

2.2 リクエスト中のテナント保持

ActiveSupport::CurrentAttributes を使い、リクエストスコープ(リクエスト単位)で、特定したテナント情報を保持します。

※ バックグラウンド処理へは自動伝播しないため、別で渡す必要があります。

class Tenant < ActiveSupport::CurrentAttributes
  class TenantNotSetError < StandardError; end

  attribute :current

  def self.current_config
    raise TenantNotSetError unless current

    Settings.tenants[current]
  end
end

セキュリティとガード

ホスト制限:テナントの設定にあるホストのみを許可するようにします。

# config/application.rb
config.hosts = Settings.tenants.flat_map { |_, t| [t.routes.url_[options.hos](http://options.host)t] }.compact

ルーティング制限:設定されたホストのみにルートを公開します。

# config/routes/api.rb
hosts = [Settings.tenants.values.map](http://Settings.tenants.values.map) { |t| t.dig(:routes, :url_options, :host) }.compact
constraints ->(req) { hosts.include?([req.host](http://req.host)) } do
  namespace :yyy do
  end
end

APIキー認証:ホスト名だけでなく、Bearer Tokenと設定ファイルのAPIキーを照合して認証します。

# app/controllers/api/v1/base_controller.rb
before_action :authenticate

def authenticate
  return if authenticate_with_http_token do |token, _|
    ActiveSupport::[SecurityUtils.secure](http://SecurityUtils.secure)_compare(token, Tenant.current_config.api_key)
  end
  render_error(:unauthorized, "Unauthorized")
end

リクエスト終了処理:リクエスト終了時に特定したテナント情報をリセットします。

after_action -> { Tenant.reset }

トレードオフ

今回の方針のトレードオフは、「動作検証目的のQA環境で、環境ごとの独立したデプロイができない」点です。

すべてのQA環境が1つのアプリケーションコードで動作するため、「QA1はブランチAを、QA2ではブランチBを検証する」といった並行した検証が難しくなります。

今回開発したシステムは、頻繁な変更を必要とせず、かつ「全環境で常に動いていること」が重要だったため、コスト削減のメリットが上回りました。

しかし、開発サイクルが高速で環境ごとに異なるソースコードをテストしたいシステムには不向きなケースもあります。

効果

QA環境のインフラコストを約11分の1にでき、年間で約 300万円 削減することができました! 1インスタンスあたりのコストは、概算で2.5万円としています。(AWSコスト+諸経費)

  • Before (11インスタンス):¥ 25,000 × 11 環境 × 12ヶ月 = ¥ 3,300,000
  • After (1インスタンス):¥ 25,000 × 1 環境 × 12ヶ月 = ¥ 300,000

まとめ

Rails標準のマルチDB機能を活用することで、1インスタンスで全QA環境をカバーするマルチテナント構成を実現できました。

認証基盤としての要件を満たしつつ、インフラコストを大幅に削減できたことは大きな成果でした。




以上の内容はhttps://techblog.raksul.com/entry/2025/12/16/183925より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14