Heroku でマルチテナントな Rails アプリを実現する

はじめに

Web アプリでマルチテナントをやる場合、 すべてのテーブルに tenant_id を追加してデータを分けるのではなく、 テナントごとにデータベースを分けたい。

データベースを分けておけば、バグで他のテナントのデータまで変更してしまう可能性を減らせる。

データベースを分けたマルチテナントをサポートする、 『Apartment』という gem を使った記事を以前書いた。

Heroku 上での運営を考えているので、 Heroku 上でもテナントごとにデータベースを分けたマルチテナントが可能か試してみた。

なお、Heroku Postgres を使うから、厳密にはテナントごとにスキーマを分けることになる。

Heroku にデプロイ

Rails + Apartment でマルチテナントな Web サービスを作る - present で作成した Rails プロジェクトを使うことが前提。 git で Heroku に push する。

$ git remote add heroku git@heroku.com:tnakamura-sandbox.git
$ git push heroku master

テナント作成

テナントを登録するテーブルが必要なので、まずマイグレーションを実行する。

$ heroku run bundle exec rake db:migrate

Heroku 上で rails console を実行し、テスト用のテナントを登録する。

$ heroku run bundle exec rails console
irb(main)001:0> Tenant.create(name:"foobar")
irb(main)002:0> Tenant.create(name:"hogefuga")

テナントのスキーマを作成

テナントを登録したので、各テナントのスキーマを Heroku Postgres 上に作成。 その後、各スキーママイグレーションを実行する。

$ heroku run bundle exec rake apartment:create
$ heroku run bundle exec rake db:migrate

今回は rake タスクで実行したけど、スキーマの作成と削除は Tenant のコールバックにしたほうが良さそうだ。

サブドメインを登録

Qiita::Team みたいに、テナントごとに異なるサブドメインを使いたい。

テナントが増えるたびにサブドメインを登録するのは手間なので、 例えば次のような、ワイルドカードサブドメインを登録する。

cname *.yourdomain.jp tnakamura-sandbox.herokuapp.com.

自分はバリュードメインを使っているので、バリュードメインで設定した。 末尾のピリオドは必須。これを忘れたせいで、1日潰してしまった。要注意。

Heroku にドメインを追加

バリュードメインで登録した、ワイルドカードサブドメインを Heroku に追加する。

$ heroku domains:add *.yourdomain.jp

これで、例えば foobar.yourdomain.jp と hogefuga.yourdomain.jp で、 Heroku 上の Rails アプリにアクセスできる。

Heroku 上でテナントごとにスキーマが違っているか確認

Web ブラウザで、まず foobar のサブドメインにアクセス(実際のドメイン名は都合により隠している)。

f:id:griefworker:20140725215141p:plain

サインアップしてみる。

f:id:griefworker:20140725215209p:plain

ログイン後のトップページにユーザーの一覧が表示された。

次に hogefuga のサブドメインにアクセス。

f:id:griefworker:20140725215238p:plain

サブドメインが違うので、こちらではまだログインしていない状態になっている。

こちらでもサインアップしてみる。

f:id:griefworker:20140725215320p:plain

すると、ユーザー一覧にはサインアップしたユーザーだけが表示された。

f:id:griefworker:20140725215353p:plain

ちゃんとテナントごとにスキーマが分かれているようだ。

まとめ

ワイルドカードサブドメインを使っているので、 テナントごとに異なるサブドメインを使える。

また、Apartment を使っているので、サブドメインごとにスキーマを分けることができた。 結果、テナントごとにスキーマを分けることに成功。

ワイルドカードサブドメインと Apartment を使うことで、 Heroku でもマルチテナントを実現できた。

Rails のベストプラクティスに従っているかチェックするために rails_best_practices を導入

Rails の使い方が間違っていないかどうかを、人が目視でチェックするのは、 無駄ではないけど時間がもったいない。

もしツールでチェックできるとしたら、ツールでやった方がいい。 設計とか、他にもっとレビューするべきところはあるのだから。

そこで rails_best_practices という gem の出番。

この gem は、Rails Best Practices というサイトで公開されているベストプラクティスに、 Rails アプリが従っているかをチェックしてくれる。

gem install rails_best_practices

でインストールしたら、Rails プロジェクトのルートディレクトリで

rails_best_practices -f html .

を実行。すると rails_best_practices_output.html が出力される。 こんな感じのやつ。

f:id:griefworker:20140716205020p:plain

あとは警告を1つずつ潰していけばいい。

Apartment を使った Rails アプリを Heroku にデプロイできない

マルチテナント用の gem である apartment を使っている Rails アプリを、 Heroku に push すると下記のエラーが発生した。

Preparing app for Rails asset pipeline
       Running: rake assets:precompile
       rake aborted!
       Gem::LoadError: Specified 'sqlite3' for database adapter, but the gem is not loaded. Add `gem 'sqlite3'` to your Gemfile (and ensure its version is at the minimum required by ActiveRecord).
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/activerecord-4.1.0/lib/active_record/connection_adapters/connection_specification.rb:190:in `rescue in spec'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/activerecord-4.1.0/lib/active_record/connection_adapters/connection_specification.rb:187:in `spec'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/activerecord-4.1.0/lib/active_record/connection_handling.rb:50:in `establish_connection'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/apartment-0.24.3/lib/apartment/adapters/abstract_adapter.rb:82:in `block in process_excluded_models'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/apartment-0.24.3/lib/apartment/adapters/abstract_adapter.rb:81:in `each'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/apartment-0.24.3/lib/apartment/adapters/abstract_adapter.rb:81:in `process_excluded_models'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/apartment-0.24.3/lib/apartment/database.rb:18:in `init'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/apartment-0.24.3/lib/apartment/railtie.rb:31:in `block in <class:Railtie>'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/activesupport-4.1.0/lib/active_support/callbacks.rb:438:in `instance_exec'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/activesupport-4.1.0/lib/active_support/callbacks.rb:438:in `block in make_lambda'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/activesupport-4.1.0/lib/active_support/callbacks.rb:184:in `call'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/activesupport-4.1.0/lib/active_support/callbacks.rb:184:in `block in simple'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/activesupport-4.1.0/lib/active_support/callbacks.rb:185:in `call'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/activesupport-4.1.0/lib/active_support/callbacks.rb:185:in `block in simple'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/activesupport-4.1.0/lib/active_support/callbacks.rb:86:in `call'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/activesupport-4.1.0/lib/active_support/callbacks.rb:86:in `run_callbacks'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/actionpack-4.1.0/lib/action_dispatch/middleware/reloader.rb:83:in `prepare!'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/actionpack-4.1.0/lib/action_dispatch/middleware/reloader.rb:55:in `prepare!'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/railties-4.1.0/lib/rails/application/finisher.rb:52:in `block in <module:Finisher>'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/railties-4.1.0/lib/rails/initializable.rb:30:in `instance_exec'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/railties-4.1.0/lib/rails/initializable.rb:30:in `run'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/railties-4.1.0/lib/rails/initializable.rb:55:in `block in run_initializers'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/railties-4.1.0/lib/rails/initializable.rb:54:in `run_initializers'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/railties-4.1.0/lib/rails/application.rb:288:in `initialize!'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/config/environment.rb:5:in `<top (required)>'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/activesupport-4.1.0/lib/active_support/dependencies.rb:247:in `require'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/activesupport-4.1.0/lib/active_support/dependencies.rb:247:in `block in require'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/activesupport-4.1.0/lib/active_support/dependencies.rb:232:in `load_dependency'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/activesupport-4.1.0/lib/active_support/dependencies.rb:247:in `require'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/railties-4.1.0/lib/rails/application.rb:264:in `require_environment!'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/railties-4.1.0/lib/rails/application.rb:367:in `block in run_tasks_blocks'
       /tmp/build_4f21ff1d-af6a-46cb-ba2e-935e4be9cdd8/vendor/bundle/ruby/2.0.0/gems/sprockets-rails-2.1.3/lib/sprockets/rails/task.rb:55:in `block (2 levels) in define'
       Tasks: TOP => environment
       (See full trace by running task with --trace)
 !
 !     Precompiling assets failed.
 !

 !     Push rejected, failed to compile Ruby app

Rails 初期化時に apartment の初期化を行っており、 database.yml に書かれている production 環境のアダプタを読み込もうとしてエラーが発生していた。 SQLite のアダプタを読み込もうとしたのが原因みたい。

Heroku に push したとき、 Heroku Postgres を使うように database.yml が自動で書き換わるんだけど、 Rails 初期化のタイミングではまだ書き換わっていなかったようだ。

database.yml で

production:
  adapter: postgresql
  host: HerokuPostgresホスト名
  port: 5432
  username: ユーザー名
  password: パスワード
  database: データベース名

という風に、SQLite ではなく PostgreSQL のアダプタを使うように書いておけば回避できた。

Rails Assets を使って Rails プロジェクトに AngularJS をインストール

Rails で AngularJS を使おうと思っていたけど、

  • ファイルをダウンロードして vendor/assets に直接配置するのは管理が面倒
  • AngularJS をラップした gem は AngularJS 本体がバージョンアップしたとき対応するまでタイムラグがある
    • そもそも angular-gem や angular-rails や angularjs-rails-resource など乱立してるし
  • Bower で AngularJS を管理するのがいいかも
    • Bower を動かすために Node が必須なので、Heroku デプロイ時に AngularJS をインストールできない
    • カスタム Buildpack を作れば可能だけど、アプリの本質ではないので労力をかけたくない。

という紆余曲折を経て、『Rails Assets』を使ってみることにした。

Rails Assets は、Bower のパッケージを gem にコンバートし、ホストしているサービス。 Gemfile に

source "https://rails-assets.org"

を追加し、

gem "rails-assets-<Bower パッケージ名>"

という形式で指定することで、Bundler で JavaScript ライブラリをインストールできる。

Rails で AngularJS を使いたい場合、

source "https://rails-assets.org"
gem "rails-assets-angular"
gem "rails-assets-angular-route"

と書いて bundle install でインストール。 このとき依存するライブラリもインストールしてくれる。

あとは app/assets/javascript/application.js に

//= require angular
//= require angular-route

と書けば、Asset Pipeline に AngularJS が組み込まれる。

Rails Assets は gem のインストールなので Bower 不要。Node も不要。 カスタム Buildpack を使わなくても Heroku で動かせる。

Rails で外部キー制約を使う方法

Rails で外部キー制約を設定したいなら、foreigner を使うのが良さそう。

foreigner をインストールすると、マイグレーションで外部キー制約を設定できる。

テーブル作成時なら

class CreateComments < ActiveRecord::Migration
  def change
    create_table do |t|
      t.reference :entry
      t.string    :content
      t.timestamp
      t.foreign_key :entries, dependent: :delete
    end
  end
end

テーブル変更時なら

class AddForeignKeyToComments < ActiveRecord::Migration
  def change
    change_table do |t|
      t.foreign_key :entries, dependent: :delete
    end
  end
end

という風に書ける。

SQLite では外部キー制約が設定されないので、動作確認は MySQLPostgreSQL でやる必要あり。

MySQL で試してみたところ、Comment に存在しない entry_id をセットして保存しようとしたら

ActiveRecord::InvalidForeignKey: Mysql2::Error: Cannot add or update a child row: a foreign key constraint fails 

というエラーが発生した。外部キーで紐づいている Entry を削除したら、Comment もちゃんと削除された。

データの整合性を保つのはデータベースの得意分野なのだから、Rails でゴリゴリやらずにデータベースに任せてしまえ、って思うようになった今日この頃。

Vagrant + rails-dev-box で Rails アプリ開発環境を作成

はじめに

自宅の MacBook Pro は開発専用じゃないので、 PostgreSQLMemcached といったミドルウェアをインストールしたくない。 そこで Vagrant を使って、気軽に作ったり壊したりできる開発環境を構築してみることにした。

今回は rails-dev-box を使ってみる

Rails 本体の開発環境を作るための box。 Ruby だけでなく MySQLPostgreSQLMemcached といった、もろもろのミドルウェアがインストールされた環境を、簡単に作成できる。

Rails アプリの開発でも使えそう。

早速やってみた

Vagrant と git は既にインストールしていることが前提。

host $ git clone https://github.com/rails/rails-dev-box.git
host $ cd rails-dev-box
host $ vagrant up

たったこれだけで、box をダウンロードして、仮想マシンを起動し、 Puppet の実行までやってくれる。

ゲスト OS とホスト OS 間で rails-dev-box ディレクトリを同期できる

ホスト OS の rails-dev-box ディレクトリをゲスト OS の /vagrant にマウントするので、

host $ rails new sample_app --skip-bundle
host $ vagrant ssh
guest $ cd /vagrant
guest $ ls
MIT-LICENSE  puppet  README.md  sample_app  Vagrantfile

という感じで、ホスト OS 側で作成した Rails プロジェクトを、ゲスト OS 側からも触れる。

ゲスト OS で起動した Rails アプリにホスト OS から手軽にアクセスできる

rails-dev-box の Vagrantfile には、 ホスト OS の 3000 番ポートに送られてきたデータを、ゲスト OS の 3000 番ポートに送信する、 ポートフォワード設定が記述されている。

そのため、

guest $ cd sample_app
guest $ bundle install
guest $ rails server

でゲスト OS 側で起動したサーバーに、ホスト OS 側のブラウザでは localhost:300 でアクセスできる。

ホスト OS 側でコードを書いて、デバッグやテストをゲスト OS 上で実行する開発フローが可能

この開発フローは rails-dev-box の README に書いてある。

参考までに、Windows マシンでも試してみたら上手くいった。 Windows には ssh コマンド無いから、PuTTYTeraTerm 使う必要があったけど。 その他の注意点としては、Bundler を実行するとき

bundle install --path vendor/bundle

ではなく

bundle intall

にすること。そうしないと、自分の環境では gem のインストールに失敗してしまった。

Rails + Apartment でマルチテナントな Web サービスを作る

はじめに

例えば Qiita::Team のような、企業やチーム向けに提供するマルチテナントな Web サービスの場合、 テナントを分ける方法としてぱっと思いつくのは

  • すべてのテーブルに tenant_id 列を追加し、tenant_Id で常に絞り込む
  • テナントごとにデータベースを分ける

の2つ。

このうち tenant_id 列を追加する方法だと、使うのに苦労する gem がある。 Devise とか Devise とか Devise とか。いやまぁ、default_scope でやれないことはないかもしれないけど。

テナントごとにデータベースを分ける方法なら、上手くデータベースさえ切り替えられれば、 Devise もそのまま使えるはず。

データベースを分ける方法でマルチテナントを実現するための gem に Apartment がある。

これを試してみる。

まずはサンプルプロジェクト作成

rails new multi_tenant_sample --skip-bundle

Devise と Apartment をインストール

ユーザーもテナントごとに登録したい。 Devise と Apartment を組み合わせて実現出来るか挑戦。

Gemfile に

gem "devise"
gem "apartment"

を追加し、下記のコマンドを順に実行。

bundle install --path vendor/bundle
bundle exec rails g devise:install
bundle exec rails g devise User
bundle exec rake db:migrate
bundle exec rails g apartment:install

テナントを作成

テナント情報を保存するモデルを用意する。 今回は簡略化のため、テナント名を保存するだけにしておく。

bundle exec rails g model tenant name:string

テナント情報は、デフォルトのデータベースにまとめて保存したい。 Apartment が Tenant を除外するように、config/initializer/apartment.rb を編集する。 あと、テナントのデータベース名一覧を取得するための設定も記述しておく。

# config/initializers/apartment.rb
require 'apartment/elevators/subdomain'

Apartment.configure do |config|

  # Tenant を除外
  config.excluded_models = %w{Tenant}

  # ... (中略) ...

  # rake db:migrate 時にすべてのテナントのデータベース名を取得するための設定
  config.tenant_names = lambda{ Tenant.pluck :name }
end

# Apartment でデフォルトでは、サブドメインでデータベースを切り替える
Rails.application.config.middleware.use 'Apartment::Elevators::Subdomain'

tenant_names を設定していないとマイグレーションを実行できないので、先にやっておくこと。 そして

bundle exec rake db:migrate

を実行。

無事マイグレーションが実行できたら、デフォルトのデータベースにテスト用のテナント情報を登録する。

bundle exec rails c
irb(main):001:0> Tenant.create(name:"foobar")
irb(main):002:0> Tenant.create(name:"hogefuga")

テナントを登録したあと

bundle exec rake apartment:create
bundle exec rake db:migrate

を実行すると、db 下に foobar.sqlite3 と hogefuga.sqlite3 が作成され、それぞれマイグレーションが実行される。

トップページを作成

確認用にトップページを作成する。

bundle exec rails g controller home index

トップページには登録されているユーザーの一欄を表示したいので、コントローラーとビューを下記のように修正する。

# app/controllers/home_controller.rb
class HomeController < ApplicationController
  def index
    @users = User.all if user_signed_in?
  end
end
<h1>Home#index</h1>
<% if user_signed_in? %>
  <%= link_to "logout", destroy_user_session_path, method: :delete %>

  <ul>
    <% @users.each do |user| %>
      <li><%= user.email %></li>
    <% end %>
  </ul>
<% else %>
  <%= link_to "login", new_user_session_path %>
<% end %>

ルーティングも修正。

# config/routes.rb
MultiTenantSample::Application.routes.draw do
  devise_for :users
  root "home#index"
end

ローカル環境で動作確認

Pow を使って動作確認する。Pow のインストール手順は次の記事の通り。 rbenv 使っていると、ちょっと苦労する。

Pow の準備が終わったら、‾/.pow の下に rails プロジェクトへのシンボリックリンクを作成。

cd ‾/.pow
ln -s ‾/Projects/multi_tenant_sample

『登録したテナントの名前=サブドメイン=テナントが利用するデータベースの名前』になっているので、 ブラウザで http://foobar.multi_tenant_sample.dev/users/sign_in にアクセスしてみる。

f:id:griefworker:20140330172959p:plain

サインアップ。

f:id:griefworker:20140330173034p:plain

登録されているユーザーの一欄が表示される。

次に http://hogefuga.multi_tenant_sample.dev にアクセス。

先ほどサインアップしたユーザーでログインしてみると

f:id:griefworker:20140330173354p:plain

ログインに失敗し、ログイン画面に戻る。ちゃんとデータベースが切り替わっているみたいだ。

こちらでもサインアップ。

f:id:griefworker:20140330173530p:plain

ユーザー一欄には、サインアップしたユーザーだけが上がっている。

まとめ

Apartment を使うことで、テナントごとにデータベースを分け、 サブドメインで切り替えることができた。 Devise に Apartment を組み合わせることで、テナントごとにユーザーを保存するのもうまくいった。

今後、Rails でマルチテナントな Web サービスを作るときは、まず Apartment を使うことにしよう。