
はじめに
こんにちは、リテールハブ開発部でバックエンドエンジニアをしているホシと言います。
現在、小売アプリの開発でLaravel11を利用してサービス開発を行っています。
今回は現在サービス提供している環境をマルチテナント化したお話をしようと思います。
既存の単一テナント前提のLaravelのサービスを、コンシューマ向けアプリと企業向け管理画面を維持したままマルチテナント化しています。
現在の構成をなるべく維持しつつ、最小改修・性能・運用の観点で2方式を比較しました。
比較した結果として、テナントごとに実行環境を分離する方式を採用しています。
本記事では、その判断基準とLaravel側の実装ポイントを少しサンプルコードも入れてお話できればと思います。
要件について
ただ、マルチテナント化といっても様々な方法があると思いますが、
今回検討したマルチテナントは以下の要件で行いました。
- APIサーバーはLaravel、Octane+Swoole、Auroraを使用。
- リリース済みのサービスに、コンシューマ向けアプリと企業向け管理画面の二系統があり、テナント(企業)ごとにデータ分離したい。
- 現時点のコードは単一テナント前提。最小の変更で安全に分離しつつ、なるべくパフォーマンス劣化せずにOctane/Swooleのコネクション再利用リスクも避けたい。
また、本来はインフラ構成も非常に重要ですが話が広がりすぎるため、サーバー側のみにフォーカスします。
Octane/Swooleについて
Octane/Swooleを使用するとリクエストごとにプロセスを初期化せず再利用することで処理が高速化することができます。
そのため、同じDB接続先であればメリットを大きく受けられますが、今回の様な接続先の切り替えを行うような処理があると、
以前の状態が残ったものがそのまま使用されることでバグを引き起こす場合があります。
そのため、接続/切断の処理対策を行う必要があります。
共通設計のポイント
- テナント判定:Host(サブドメイン)、X-Tenant-Idヘッダ、URLパスのいずれかで特定 → 今回はサブドメインで判定
- データベース:テナント毎にデータベースを分離し、テーブル内で各テナントデータが混在しない方式にする
- セッション衝突回避:session.cookie名、session.domainをテナント別にする
- Swoole/Octane:接続の再利用に注意。purge/disconnectを「必ず」行い、クロスリクエスト汚染を防ぐ
2パターンの方式について
要件、共通設計のポイントを踏まえ、2パターンの方式を検討しました。
それぞれにメリット、デメリットがあるため、それぞれ詳細に見ていきたいと思います。
① Middlewareで接続先判定+mysqlを書き換え リクエストごとにdatabase.connections.mysqlを書き換える方式
② Ecspressoの設定をテナントごとに分け、タスク(実行環境)をテナント分離する方式
以下にサンプルコードを記載していますが、検討中に実装した検証コードのため、
動作保証できておりませんので参考程度にご覧いただけると幸いです。
① Middlewareで接続先判定+接続設定(mysql)を書き換える方式(最小改修)
【全体の流れ】
- Middlewareでテナントを判定する。(テナント判定は管理DBの判定用テーブルを用意する。)
- 該当テナントのDB接続情報をconfig([‘database.connections.mysql’ => …])で上書き
- DB::purge(‘mysql’) → API処理 → DB::disconnect(‘mysql’)(Octane/Swooleで超重要)
サンプル:テナント解決 & 接続切替 Middleware
namespace App\Http\Middleware;
use Closure;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
class SwitchTenantConnection
{
public function handle($request, Closure $next)
{
$tenant = $this->resolveTenant($request);
abort_unless($tenant, 400, 'Tenant not specified');
$conn = $this->lookupTenantConnection($tenant);
Config::set('database.connections.mysql', array_merge(
config('database.connections.mysql'),
[
'host' => $conn['host'],
'database' => $conn['database'],
'username' => $conn['username'],
'password' => $conn['password'],
]
));
DB::purge('mysql');
config(['session.cookie' => 'sess_'.$tenant]);
config(['cache.prefix' => 'cache_'.$tenant]);
try {
return $next($request);
} finally {
DB::disconnect('mysql');
}
}
private function resolveTenant($request): ?string
{
$host = $request->getHost();
$parts = explode('.', $host);
return $parts[0] ?? null;
}
private function lookupTenantConnection(string $tenant): array
{
return [
'host' => 'aurora.cluster-xxxx.ap-northeast-1.rds.amazonaws.com',
'database' => "sample_{$tenant}",
'username' => 'app_user',
'password' => '***',
];
}
}
メリット
- 同じタスク内で複数のテナントを処理できるため、サーバーコストも抑えやすい。
- 既存のリポジトリ/サービス層をほぼ変更せずにテナントごとにDB切り替えができるので一番低コストで実現可能。
- サーバーアクセスの見込みが予測しやすく、突発的なアクセス増が起きにくい場合もメリットが大きくなる。
デメリット(重要)
- 各リクエストで必ず「purge/disconnect」が必要(Octane/Swooleで接続がワーカーに残るため)
- コンシューマ側アクセスが多いと接続の張り直しコストが効いてくる(パフォーマンス劣化のリスク)
- サーバアクセス増の考慮が他の方法よりもより慎重に対応する必要がある。
② タスク(実行環境)をテナント分離(設定だけで完結)
Ecspressoのタスク定義を分離させる対応を行うだけで、Laravel視点だとコード改修ゼロで対応可能。
ecspresso.yml
region: ap-northeast-1
cluster: cluster_name
service: tenant-xxxxxx-server
service_definition: ecs-service-def.jsonnet
task_definition: ecs-task-def.jsonnet
timeout: "30m0s"
plugins:
- name: tfstate
config:
url: s3://tfstate-xxxxx.com/tenant.tfstate
escpressoの設定ファイルに以下の定義をテナントごとにすることで、
DBの接続などをそれぞれに分け、タスクをテナント分離します。
Laravel側は「ecspressoの設定ファイル違い」だけ
environments.libsonnet
{
"name": "APP_NAME",
"value": ”tenantA"
},
{
"name": "DB_DATABASE",
"value": "tenantA_db"
},
{
"name": "DB_USERNAME",
"value": "tenantA"
},
テナント毎にDBの接続先を変え、必要に応じてIDやPASSなども各テナントごとに変更する。
DBのパスワードなどはSeacretManagerなどを利用して設定する。
メリット
- コード改修なしで分離することができる。
- 性能/安全性/運用が単独サービスと同等で明確(Octaneの接続再利用も気にしなくて良い)
- アクセス増などはこの方式でもDB自体のリスクはあるが、他の方式よりも考慮点を少なくできる。
デメリット
- タスク分のサーバーコスト懸念がある。(ただし実務では“思ったほど増えない”ケースが多い)
テナントが大量にあり、ひとつひとつが非常に少ない負荷で行える場合はこのデメリットは大きくなる。 - 設定がテナント分増えるので管理が煩雑になる可能性あり。
採用した結論について
上記内容を踏まえ、最終的に採用したのは方式②でした。
- コードの修正が不要で今まで通りの処理をそのまま使用できる。
- 切り替え処理によるパフォーマンス劣化の検証、対策が不要でマルチテナント化できる。
- ecspressoを採用しており、タスク定義の分離をしやすく、設定もテナントごとに管理しやすい。
- 設定は少し工夫することで、テナントが増えた際の設定内容も最小限にできる。
- 1番の懸念だったサーバーコスト増も方式①との差分もそこまで気になるレベルでなかったこと。
最初は方式①のつもりでしたが、サーバーコスト増の懸念が詳細にみてみると実はそこまで気にならないレベルだった点、
①の対応は改修コストもそうですが、検証コストもより上乗せされる点や、特に初期段階では②のメリット部分が一番適している状態だったのもありました。
まとめ
これまでの内容を表にまとめてみました。
| パターン | 内容 | メリット | デメリット | 向いている条件 |
|---|---|---|---|---|
① Middlewareで接続先判定+mysqlを書き換え |
リクエストごとに database.connections.mysql を書き換え、DB::purge() / disconnect() する |
コード変更が最小。既存リポジトリへの影響が小さい | Octane/Swooleでは都度切断が必須。コンシューマ側アクセス増で性能懸念 | 大量の既存コードをほぼ触れないで切り替えたい、少数テナント/低負荷、Octane/Swooleを採用しない方法もあり |
| ② タスク(実行環境)をテナント分離 | Ecspresso/ECSでテナントごとにタスクを分け、.env や Secret で接続先を分離 |
アプリ改修ほぼ不要。性能/安全性が明確 | コスト懸念(とはいえ実務では想定ほど増えないことも多い) | テナント数が限定的、明確な分離・運用のわかりやすさを優先 |
今回は、コード改修ほぼゼロ・挙動が明確な ②(テナントごとに実行環境を分離)を採用していますが、
1テナント毎の規模は小さいが、大量のテナントを捌く要件なら ① も有力であると思いました。
但し、①は確実な切替制御、データ漏洩の対策、検証をしっかり行なった上という前提があるため、検証コストが多くかかってしまう可能性も大きいかなとも思いました。
おわりに
いかがでしたでしょうか。
最初はマルチテナント化するには処理内で分岐させDBの接続を切り替える方式で良いかと思っていたのですが、
それぞれ検証してみると、様々なメリットデメリットがあり使い分けが非常に重要であることがわかりました。
インフラ費用面の懸念も結局ケースによるため、タスク分離がすぐに高コストにも一概には繋がらず検討が必要なことがわかりました。
今回はLaravelでお話はしていますが、考え方は他の環境でも活用できるのではないかと思っています。
本記事が、これからマルチテナント化を検討する上で少しでも参考になれば幸いです。
最後までお読みいただきありがとうございました。
コメント