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 でもマルチテナントを実現できた。

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 のアダプタを使うように書いておけば回避できた。

wkhtmltopdf-binary-11 は Heroku 上で canvas を描画してくれない

wkhtmltopdf-binary -11でインストールされる wkhtmltopdf だと、Heroku 上で canvas を描画できなかった。(2014/03/16 時点)

Mac OS X だと描画できるのに、なぜ Heroku ではできないんだ、って思ってたけど、ソースコードを読んで納得。Mac OS XLinux で、呼び出している実行ファイルが違った。

wkhtmltopdf-binary と wkhtmltopdf-heroku では canvas を描画できることを確認。どちらも wkhtmltopdf 本体のバージョンは古いけど。

PDF を出力できる Rails アプリを作成して Heroku で動かす

Rails アプリに PDF 出力を実装して、それを Heroku で動かすまでの作業メモ。

プロジェクトを作成

rails new pdf_sample --skip-bundle

gem は vendor/bundle にインストールしたいから、bunele install はスキップして後でやる。

Heroku には git を使ってデプロイするので、git リポジトリも作成しておく。

git init
git add .
git commit -m "Initial commit"

wicked_pdf と wkhtmltopdf-binary-11 をインストール

PDF 出力には wicked_pdf を使う。

wicked_pdf は wkhtmltopdf に依存しているので、wkhtmltopdf-binary-11 も使う。 bundler で wkhtmltopdf-binary-11 をインストールすれば、wkhtmltopdf 本体もインストールされる寸法。

wkhtmltopdf-binary は wkhtmltopdf 本体のバージョンが低いので、wkhtmltopdf-binary-11 を選択した。

ruby "2.0.0"

gem "wkhtmltopdf-binary-11"
gem "wicked_pdf"

gem "sqlite3", group: [:development, :test]
gem 'rails_12factor', group: [:production]

sqlite3 は Heroku 上では使わないので、開発中またはテストのみインストールする。

あとは

bundle install --path vendor/bundle --without production

を実行して gem のインストール完了。

コントローラーを作成

bundle exec rails g controller home index

でひな形を生成し、PDF を表示するコードを記述。

class HomeController < ApplicationController
  def index
    respond_to do |format|
      format.html { redirect_to root_path(format: :pdf, debug: 1) }
      format.pdf do
        render pdf: "index",
          encoding: "UTF-8",
          layout: "pdf.html",
          show_as_html: params[:debug].present?
      end
    end
  end
end

PDF 用のレイアウトを作成

app/views/layout 下に pdf.html.erb を作成する。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">  
    <title>PdfSample</title>
    <%= wicked_pdf_stylesheet_link_tag "application" -%>
    <%= wicked_pdf_javascript_include_tag "application" %>
  </head>

  <body>

  <%= yield %>

  </body>
</html>

PDF 用のビューを作成

app/views/home 下に index.pdf.erb を作成する。中身は index.html.erb をまんまコピペ。

<h1>Home#index</h1>
<p>Find me in app/views/home/index.pdf.erb</p>

ルーティングを修正

今回は home#index をルートに割り当てる。

PdfSample::Application.routes.draw do
  root 'home#index'
end

ローカルで確認

bundle exec rails server

でアプリを起動し http://localhost:3000/?format=pdf にアクセス。

f:id:griefworker:20140313204229p:plain

PDF のビューアーで表示されたら成功。

あとは、これまでの修正を git リポジトリに忘れずコミットしておく。

git add .
git commit -m "PDF 出力を作成"

Heroku 上で確認

あらかじめ Heroku にアプリを作成しておき、git リポジトリの remote に追加。

そして

git push heroku master

でデプロイ。 データベース使わないからマイグレーションは不要。 しばらく待って、デプロイが無事成功したら、Heroku 上のアプリにアクセスしてみる。

f:id:griefworker:20140313204244p:plain

Heroku 上でも PDF ビューアーで表示されたら、めでたしめでたし。

2014/03/16 追記

canvas を描画したい場合は、wkhtmltopdf-heroku または wkhtmltopdf-binary を使った方がいい。

Heroku で FeedNormalizer を使って嵌った

RSS をパースするのに feed-normalizer を使っていたんだけど、Heroku 上で

require "open-uri"
require "feed-normalizer"

feedlink = "http://tnakamura.hatenablog.com/feed"
feed = FeedNormalizer::FeedNormalizer.parse(open(feedlink))
feed.title #=> nil
feed.url #=> nil

を実行したら title と url が nil を返してしまう。RSS をパースしたときはちゃんと取得できたんで、Atom をパースしたときこの問題に遭遇する模様。

ローカルでは取得できるのに、Heroku では取得できなくて嵌った。上記のサンプルを heroku run rails console で実行して発覚。対処方法は現在調査中だけど、ここまで調べるだけでも結構時間使ったんで、忘れないうちにメモっておこう。

追記1

feed.parser #=> "SimpleRSS"

を実行して、内部で使っているパーサーが SimpleRSS ということが分かった。SimpleRSS がちゃんとパースできていない可能性が高い。

追記2

SimpleRSS は parse の中で

tag_cleaned = clean_tag(tag)
instance_variable_set("@#{ tag_cleaned }", clean_content(tag, $2, $3))
self.class.send(:attr_reader, tag_cleaned)

という風に、ATOM のタグと属性をもとに、動的にインスタンス変数とメソッドを作成していた。

そして、Heroku では

self.class.send(:attr_reader, tag_cleaned)

で動的に作成したアクセサが private メソッドになってしまっていた。試しに Heroku で

class Hoge; end
h = Hoge.new("foo")

h.instance_variable_set(:@age, 20) #=> 20
h.class.send(:attr_reader, "age")
h.age #=> NoMethodError: private method `age' called for #<Hoge:0x0000000726ad70 @age=20>

を実行したら、動的に追加したアクセサが private メソッドになっているのを確認できた。ちなみに、ローカル環境で実行すると public メソッドになっていた。

title や author が private なメソッドなので、FeedNormalizer は title や author を取り出せない。その結果、nil が返していたようだ。

追記3

Feedzirra は Heroku でも動いたんで、RSS/Atom パーサーは次から Feedzirra を使おう。

Heroku の Dyno を寝かせない

Rails 製 Web アプリを Heroku で動かしているんだけど、Heroku の Dyno は1時間以上放置されると寝ちゃうようだ。

寝起きも悪いんで、1時間おきにかまってあげて Dyno を寝かさないようにした方が良さそう。

Heroku では curl が使えるから、アドオンの Heroku Scheduler を追加して、

curl http://your-app-name.herokuapp.com/

のコマンドを1時間おきに実行するように設定。これで今のところ、Web アプリにアクセスして数10秒待たされる、なんてことは無くなった。