こんにちは。
プロダクト開発部 PROGOS•SMART Method開発グループ
所属の奥山と申します。
レアテクは今年4月、株式会社レアジョブの子会社として誕生した会社です。
誕生と同時に自分も入社し、以降、SMART Methodの開発に携わらせていただいてます。
さて、テックブログを書くと分かったのが1週間前くらいなのですが、
(※これは自分がスケジュール把握してなかったせいです。突然割り振られるものではありません。)
その時、ちょうど少しばかり引っかかっていたキーワードが
「SaaS、マルチテナント」でした。
理由を言語化すると複数、しかも意外と一つにまとまらなくて混乱したので、「その場のノリ」とします。
概観
書いてあることは下記の通りです。
- マルチテナントアーキテクチャに関する用語
- Tenancy for Laravelをハンズオン形式で紹介
- ハンズオンをした所感
マルチテナントアーキテクチャの調査
用語:
- SaaS:ライセンス購入ではなく、定期的にお支払いいただき、自社で開発しているソフトウェアにアクセスしてご利用いただくサービス。
お客様のデータは自社で管理しているサーバ内で管理する。ビジネスモデルの文脈もあるが割愛する。 - テナント:自社のSaaSに契約いただいているお客様1単位(企業単位、個人事業主単位、など)を表現する。
- マルチテナント:契約しているお客様が複数いらっしゃる状態を指す。
マルチテナントについていくつか資料を読んだのですが、AWSの動画の内容がしっくりきたので、 その中からいくつか紹介いたします。 内容は全て英語のため、自分の方で理解した内容を書きます。
インフラ構成の方針:
- サイロ:DBやサーバー、アプリケーションなどが単一のテナントで実行されている環境
- プール:DBやサーバー、アプリケーションなどが複数のテナント間で共有されている環境
- ブリッジ:サイロとプールを混ぜたもの
その他
- お客さんを一意に識別する方法についての一例
- テナント登録が実行されたら、どう実行環境を立ち上げるか?
- メトリクスについて
- アプリケーション・インフラをひっくるめたレベルで実装する方法の例
- 顧客ごとの課金に関する話
などについても言及されてました。
理解した話ではないので細かく言及しません。
概観がなんとなく掴めたところで、実物を触ってみたいと思います。
候補として、AWSの動画の中で示されている実装パターンのハンズオンがあります。
しかし、まずは自分が慣れた道具でマルチテナントに触れてみたいと思いました。
Laravelでマルチテナントを実現するサードパーティライブラリはいくつかあるのですが、
こちらのドキュメントを読み、Tenancy for Laravelに手軽さを感じ、選択しました。
Tenancy for Laravel ハンズオン
方針:
- テナントがどう作成されるか確認します
- CentralとTenantの間でデータがどうシェアされるか確認します
前提となる開発環境:
概念説明:
- Tenant Route:登録していただいたテナントごとに作成するルート。サブドメインで分けたり、ドメインごとに分けるといった設定ができる模様。
- Central Route:SaaS全体のデータを集約して管理する際に利用する、SaaSプロバイダーの管理画面作成用のルーティングと思われる。
インストール:
composer create-project laravel/laravel tenancy "8.*" cd tenancy composer require stancl/tenancy php artisan tenancy:install php artisan migrate
ここまでやると
対象のDBには下記のようにテーブルが作成されます。
mysql> show tables; +------------------------+ | Tables_in_tenancy | +------------------------+ | domains | | failed_jobs | | migrations | | password_resets | | personal_access_tokens | | tenants | | users | +------------------------+ 7 rows in set (0.00 sec)
対象ファイルへの追記作業:
下記のようにファイルに対して指定した内容を記述していきます。
config/app.php
/* * Application Service Providers... */ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\RouteServiceProvider::class, App\Providers\TenancyServiceProvider::class, // これを追加する
app/ModelsにTenant.phpを作成。
<?php namespace App\Models; use Stancl\Tenancy\Database\Models\Tenant as BaseTenant; use Stancl\Tenancy\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Concerns\HasDatabase; use Stancl\Tenancy\Database\Concerns\HasDomains; class Tenant extends BaseTenant implements TenantWithDatabase { use HasDatabase, HasDomains; }
config/tenancy.php
<?php // tenant_model のvalueの箇所を書き換える 'tenant_model' => \App\Models\Tenant::class,
app/Providers/RouteServiceProvider.php
<?php 省略 protected function mapWebRoutes() { foreach ($this->centralDomains() as $domain) { Route::middleware('web') ->domain($domain) ->namespace($this->namespace) ->group(base_path('routes/web.php')); } } protected function mapApiRoutes() { foreach ($this->centralDomains() as $domain) { Route::prefix('api') ->domain($domain) ->middleware('api') ->namespace($this->namespace) ->group(base_path('routes/api.php')); } } protected function centralDomains(): array { return config('tenancy.central_domains'); }
同ファイルのbootメソッド
<?php public function boot() { $this->configureRateLimiting(); $this->routes(function () { $this->mapApiRoutes(); $this->mapWebRoutes(); }); }
database/migrations/2014_10_12_000000_create_users_table.php を database/migrations/tenant/2014_10_12_000000_create_users_table.php に移動する。
テナント作成を実体験する:
$ php artisan tinker >>> $tenant1 = App\Models\Tenant::create(['id' => 'foo']); => App\Models\Tenant {#3653 id: "foo", data: null, updated_at: "2022-09-07 12:48:59", tenancy_db_name: "tenantfoo", } >>> $tenant1->domains()->create(['domain' => 'foo.localhost']); => Stancl\Tenancy\Database\Models\Domain {#4525 domain: "foo.localhost", tenant_id: "foo", updated_at: "2022-09-07 12:49:03", created_at: "2022-09-07 12:49:03", id: 1, tenant: App\Models\Tenant {#4678 id: "foo", created_at: "2022-09-07 12:48:59", updated_at: "2022-09-07 12:48:59", data: null, tenancy_db_name: "tenantfoo", }, }
ここまでやってMySQLのコンソールで確認してみると
mysql> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| mysql |
| performance_schema |
| sys |
| tenancy |
| tenantfoo |
+--------------------+
とtenantfooというデータベースが増えてます。 テーブル構成は下記の通りです。
mysql> show tables; +---------------------+ | Tables_in_tenantbar | +---------------------+ | migrations | | users | +---------------------+ 2 rows in set (0.00 sec)
テーブルの中身はそれぞれ下記の通りです。
mysql> select * from migrations; +----+--------------------------------------+-------+ | id | migration | batch | +----+--------------------------------------+-------+ | 1 | 2014_10_12_000000_create_users_table | 1 | +----+--------------------------------------+-------+ 1 row in set (0.00 sec) mysql> select * from users; Empty set (0.00 sec)
tinkerで引き続き下記のように実行してみます。
>>> $tenant2 = App\Models\Tenant::create(['id' => 'bar']); => App\Models\Tenant {#4680 id: "bar", data: null, updated_at: "2022-09-07 12:49:08", tenancy_db_name: "tenantbar", } >>> $tenant2->domains()->create(['domain' => 'bar.localhost']); => Stancl\Tenancy\Database\Models\Domain {#4672 domain: "bar.localhost", tenant_id: "bar", updated_at: "2022-09-07 12:49:13", created_at: "2022-09-07 12:49:13", id: 2, tenant: App\Models\Tenant {#3688 id: "bar", created_at: "2022-09-07 12:49:08", updated_at: "2022-09-07 12:49:08", data: null, tenancy_db_name: "tenantbar", }, }
すると、tenantbarという名前のデータベースが増えてます。
mysql> show databases; +--------------------+ | Database | +--------------------+ | information_schema | | mysql | | performance_schema | | sys | | tenancy | | tenantbar | | tenantfoo | +--------------------+ 7 rows in set (0.00 sec)
試しにtenancyのdomainsとtenantsテーブルをみてみると、下記の通りとなります。
mysql> select * from domains; +----+---------------+-----------+---------------------+---------------------+ | id | domain | tenant_id | created_at | updated_at | +----+---------------+-----------+---------------------+---------------------+ | 1 | foo.localhost | foo | 2022-09-07 12:49:03 | 2022-09-07 12:49:03 | | 2 | bar.localhost | bar | 2022-09-07 12:49:13 | 2022-09-07 12:49:13 | +----+---------------+-----------+---------------------+---------------------+ 2 rows in set (0.00 sec) mysql> select * from tenants; +-----+---------------------+---------------------+----------------------------------+ | id | created_at | updated_at | data | +-----+---------------------+---------------------+----------------------------------+ | bar | 2022-09-07 12:49:08 | 2022-09-07 12:49:08 | {"tenancy_db_name": "tenantbar"} | | foo | 2022-09-07 12:48:59 | 2022-09-07 12:48:59 | {"tenancy_db_name": "tenantfoo"} | +-----+---------------------+---------------------+----------------------------------+ 2 rows in set (0.00 sec)
ここまででお分かりのように、Tenantモデルを用いて作成処理を実行すると
- 1テナントごとにデータベースが自動で作成され
- マイグレーションファイルが実行済みとなりテーブルが作成される
ことがわかります。
アプリケーション起動前の準備:
routes/tenant.phpの該当箇所を下記のように変更します。 それぞれのテナントで/tenant-web にアクセス可能となります。
<?php 省略 Route::middleware([ 'web', InitializeTenancyByDomain::class, PreventAccessFromCentralDomains::class, ])->group(function () { Route::get('/', function () { return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id'); }); // ここを追加。フルパスで指定する必要あり。 Route::get('/tenant-web', 'App\Http\Controllers\HandsOnController@index'); });
routes/web.phpに下記のように追記します。
routes/web.php
Route::get('/central-web', 'HandsOnController@index');
app/Http/Controllers/HandsOnController.phpを作成します。
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; class HandsOnController extends Controller { public function index() { $tenantId = tenant('id') ?? 'Central'; return "Now, you are in ".$tenantId; } }
テナントにアクセスしてみる:
この時点で3つのURLを叩くことができます。
localhost:8000 // Central Route foo.localhost:8000 // Tenant Route (foo) bar.localhost:8000 // Tenant Route(bar)
アプリケーションを起動してアクセスしてみます。
php artisan serve
例えば
foo.localhost:8000/tenant-web
にアクセスすると、下記イメージのようになります。
localhost:8000/central-web
とすれば、下記イメージのようになります。
状況整理:
少しいじる:
Central Routeの方で、テナント情報を集約してみたいと思います。
ECサイトで言えば、「お客様の情報をまとめてリストとして表示したい」に近しい行為かと思います。
確認用データを準備します。
database/seeders/DatabaseSeeder.php を下記のように変更します。
<?php namespace Database\Seeders; use Illuminate\Database\Seeder; use App\Models\Tenant; use App\Models\User; class DatabaseSeeder extends Seeder { /** * Seed the application's database. * * @return void */ public function run() { // \App\Models\User::factory(10)->create(); Tenant::all()->runForEach(function () { User::factory()->create(); }); } }
下記コマンドでテナントのDBのみにデータが入ります。
php artisan tenants:seed
実際にMySQLのコンソールでデータを見ると、下記の通りになります。
mysql> use tenancy; Reading table information for completion of table and column names You can turn off this feature to get a quicker startup with -A Database changed mysql> select * from users; Empty set (0.01 sec) mysql> use tenantbar; Reading table information for completion of table and column names You can turn off this feature to get a quicker startup with -A Database changed mysql> select * from users; +----+------------------------+--------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+ | id | name | email | email_verified_at | password | remember_token | created_at | updated_at | +----+------------------------+--------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+ | 1 | Mrs. Bryana Langosh MD | sean52@example.org | 2022-09-07 14:23:48 | $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi | D4audYw0BN | 2022-09-07 14:23:48 | 2022-09-07 14:23:48 | | 2 | Alexane Murazik MD | xdoyle@example.org | 2022-09-07 14:23:48 | $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi | fcVff6ssPX | 2022-09-07 14:23:48 | 2022-09-07 14:23:48 | +----+------------------------+--------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+ 2 rows in set (0.00 sec) mysql> use tenantfoo; Reading table information for completion of table and column names You can turn off this feature to get a quicker startup with -A Database changed mysql> select * from users; +----+------------------------+-------------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+ | id | name | email | email_verified_at | password | remember_token | created_at | updated_at | +----+------------------------+-------------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+ | 1 | Ms. Shemar Stoltenberg | delta23@example.com | 2022-09-07 14:23:48 | $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi | JwdwYb4Wji | 2022-09-07 14:23:48 | 2022-09-07 14:23:48 | | 2 | Lambert Little | dare.lester@example.net | 2022-09-07 14:23:48 | $2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi | CcjBpEFH9b | 2022-09-07 14:23:48 | 2022-09-07 14:23:48 | +----+------------------------+-------------------------+---------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+ 2 rows in set (0.00 sec)
先ほど作成したapp/Http/Controllers/HandsOnController.phpに下記のようにメソッドを追加します。
<?php 省略 public function aggregate() { $aggregate = collect(); $tenants = Tenant::all(); foreach($tenants as $tenant){ tenancy()->initialize($tenant->id); $aggregate = $aggregate->merge(User::all()); } dd($aggregate->pluck('name')); }
最後に、config/app.phpに
Route::get('/aggregate', 'HandsOnController@aggregate');
を追加します。
localhost:8000/aggregate にアクセスすると
Illuminate\Support\Collection {#342 ▼ #items: array:4 [▼ 0 => "Mrs. Bryana Langosh MD" 1 => "Alexane Murazik MD" 2 => "Ms. Shemar Stoltenberg" 3 => "Lambert Little" ] #escapeWhenCastingToString: false }
と表示され、確かに集約できます。
補足すると、テナントの方に実装すれば、テナント側でも呼び出せてしまいます。
この辺りはコーディング、インフラ両方の側面で縛りをかける必要があるとわかりました。
以上、ハンズオンでした。
所感
思いついた順に述べます。
- テナントが別テナントにアクセスできないよう仕組みを考える重要性を擬似的に体験できて良かったです。
- テナント登録からログインの導線をどう実装するのが良いか考える必要があります。
公式ドキュメントにスポンサーコンテンツがあり、そこでシングルサインオンに関する言及がなされているようです。 - インフラに関する知識と経験の重要性をさらに強く認識したため、消化不良感があります。
- AWSの動画内で言及されている内容について少し消化できたので、実際のハンズオンを見つつ、他人に噛み砕いて説明しながら実装できることを目的として理解を深めてみようかと思います。
参考にしたコンテンツ
AWS re:Invent 2021 - SaaS architecture patterns: From concept to implementation - YouTube
Tenancy for Laravel