ククログ

株式会社クリアコード > ククログ > HerokuでRroongaを使う方法

HerokuでRroongaを使う方法

RubyやRailsも使えるPaaSであるHerokuRroongaを使えるようにしました。これにより、高速な全文検索機能を提供するRubyによるWebアプリケーションをHeroku上で動かすことができるようになりました。

ここでは、HerokuでRroongaを使う方法と、どのように動いているかを簡単に説明します。

サンプルアプリケーション

Heroku上でRroongaを使えることを示すサンプルアプリケーションとして、Rroongaで全文検索できるブログを作成しました。

Railsでscaffoldしたものに、全文検索関連の機能を追加して見た目を整えた1だけの簡単なアプリケーションです。

全文検索機能はページ上部の検索ボックスにキーワードを入力してサブミットすると確認できます。キーワードにマッチするとキーワードがハイライトするようになっていますが、これもRroongaの機能です。

Rroongaを使ったアプリケーションの作り方

それでは、Herokuで動くRroongaを使ったアプリケーションの作り方を説明します。

Heroku用Railsアプリケーションの作成

まず、Railsアプリケーションを作ります。Heroku用のRailsアプリケーションの作り方の詳細はHerokuのドキュメント(英語)を参考にしてください。

% rails new rroonga-blog --database=postgresql --skip-bundle
% cd rroonga-blog
% git init

Gemfileに次の内容を追加します。rails_12factorはHeroku用で、rroongaはRroongaを使うためです。

gem 'rails_12factor', group: :production

gem 'rroonga'

config/database.ymlはproduction用の設定を次のように変えるだけでよいです。

production:
  url: <%= ENV['DATABASE_URL'] %>

Debian GNU/Linuxではデータベース周りの初期設定は次の通りです。

% sudo -H apt-get install -V -y postgresql postgresql-server-dev-all
% sudo -u postgres -H createuser --createdb $USER
% bundle install
% rake db:create

scaffoldでベースの機能を作ります。

% rails generate scaffold post title:string content:text
% rake db:migrate

これでブログができました。

Rroongaの組み込み

それでは、ここにRroongaを組み込んでいきます。

まず、次の内容でconfig/initilizers/groonga.rbを作ります。やっていることはGroongaデータベースの作成またはオープンです。

require 'fileutils'
require 'groonga'

database_path = ENV['GROONGA_DATABASE_PATH'] || 'groonga/database'
if File.exist?(database_path)
  Groonga::Database.open(database_path)
else
  FileUtils.mkdir_p(File.dirname(database_path))
  Groonga::Database.create(path: database_path)
end

Herokuで動くときはGroongaのデータベースのパスは環境変数GROONGA_DATABASE_PATHで渡ってきます2。しかし、テストのためにローカルで動かすときはこの環境変数が設定されていないため、デフォルト値として'grooonga/detabase'を使っています。

Groongaのデータベースはリポジトリーに入れる必要はないので無視するようにします。

.gitignore:

# ...
/groonga/database

config/initializers/に書いたので、RailsアプリケーションはどこでもGroongaのデータベースにアクセスできるようになりました。

このブログは次のデータをデータベースに格納します。

  • title: タイトル
  • content: 内容
  • created_at: 作成時間
  • updated_at: 更新時間

今回はすべてにインデックスを張って検索できるようにします。そのためのGroongaのスキーマをgroonga/init.rb3で定義します。後述する通り、このGroongaのデータベースは永続的なものではなく、何度でも作り直すものなので、マイグレーションのような仕組みは不要です。

groonga/init.rb:

require_relative '../config/environment'

# データを保存するテーブルを定義。カラムはPostgreSQLと同じ。
Groonga::Schema.define do |schema|
  schema.create_table('Posts',
                      type: :hash,
                      key_type: :uint32) do |table|
    table.short_text('title')
    table.text('content')
    table.time('created_at')
    table.time('updated_at')
  end
end

# 後でここにPostgreSQLのデータをインポートするコードを入れる

# インデックスを定義。通常はこのパラメーターで十分。
Groonga::Schema.define do |schema|
  schema.create_table('Terms',
                      type: :patricia_trie,
                      key_type: :short_text,
                      normalizer: 'NormalizerAuto',
                      default_tokenizer: 'TokenBigram') do |table|
    table.index('Posts.title')
    table.index('Posts.content')
  end

  schema.create_table('Times',
                      type: :patricia_trie,
                      key_type: :time) do |table|
    table.index('Posts.created_at')
    table.index('Posts.updated_at')
  end
end

Groonga側にテーブルができたので、PostgreSQLにデータを追加・更新・削除するときにGroongaのデータベースの中身も更新するようにします。

まず、Groongaのインデックスを更新するクラスを作ります。

lib/post_indexer.rb:

class PostIndexer
  def initialize
    @posts = Groonga['Posts']
  end

  def add(post)
    attributes = post.attributes
    id = attributes.delete('id')
    @posts.add(id, attributes)
  end

  def remove(post)
    @posts[post.id].delete
  end
end

lib/に置いたのでconfig.autoload_pathslib/を追加します。

config/application.rb:

# ...
module RroongaBlog
  class Application < Rails::Application
    # ...
    config.autoload_paths += ["#{config.root}/lib"]
  end
end

Postクラスにコールバックを設定します。

app/models/post.rb:

class Post < ActiveRecord::Base
  after_save do |post|
    indexer = PostIndexer.new
    indexer.add(post)
  end

  after_destroy do |post|
    indexer = PostIndexer.new
    indexer.remove(post)
  end
end

これで、データが変わるとGroongaのデータベースと同期するようになりました。

それでは全文検索機能を組み込みましょう。queryというパラメーターが指定されたらタイトルと内容を全文検索します。

app/controllers/posts_controller.rb:

class PostsController < ApplicationController
  # ...
  def index
    query = params[:query]
    if query
      @posts = search(query)
    else
      @posts = Post.all
    end
  end
  # ...
  private
  # ...
  def search(query)
    # Groongaを使って全文検索
    groonga_posts = Groonga['Posts']
    matched_groonga_posts = groonga_posts.select do |record|
      # titleかcontentにqueryがマッチ、という検索パターンを指定
      record.match(query) do |match_target|
        match_target.title | match_target.content
      end
    end
    # Groongaのデータベースでは各レコードのキーに
    # PostgreSQLのレコードのIDが入っているので、
    # それを使って対象レコードを取得。
    post_ids = matched_groonga_posts.collect(&:_key)
    Post.where(id: post_ids)
  end
end

検索フォームを追加します。

app/views/posts/index.html.erb:

<h1>Listing posts</h1>

<%= form_tag posts_path, method: "get" do %>
  <%= search_field_tag :query, params[:query] %>
  <%= submit_tag 'Search' %>
<% end %>

<!-- ... -->

http://localhost:3000/postsをWebブラウザーで開いてテストデータを投入し、検索してみてください。AND検索だけではなく、OR検索やNOT検索もできます。もちろん、日本語も使えます。

  • AND検索:キーワードをスペースで区切る。
    • 「hello」と「world」を両方含んでいたらヒットする例:「hello world」
  • OR検索:キーワードを「 OR 」で区切る。
    • 「hello」または「world」をどちらか含んでいたらヒットする例:「hello OR world」
  • NOT検索:キーワードの前に「-」を付ける。
    • 「hello」は含むが「world」を含まなかったらヒットする例:「hello -world」

ローカルで動作することを確認できたのでコミットします。Herokuにデプロイするときはgit pushする必要があるのでコミットしておかないといけません。

% git add .
% git commit --message 'Import'

Herokuで動かす

ローカルで動作することを確認できたので、いよいよHerokuで動かします。

Heroku Toolbeltをインストール済みであるという前提で説明します。

まず、Herokuアプリケーションを作ります。Rroonga用のビルドパックを指定することがポイントです。Rroonga用のビルドパックはGroongaを追加でインストールすること以外はRuby用のビルドパックと同じです。そのため、Rroognaを使っていないHeroku用のRailsアプリケーションと同じように開発できます。

% heroku apps:create --buildpack https://codon-buildpacks.s3.amazonaws.com/buildpacks/groonga/rroonga.tgz

PostgreSQLデータベースの初期設定をします。

% heroku run rake db:migrate

これでHeroku上でブログを使えるようになりました。Webブラウザーで開いて動作を確認してみてください。

% heroku apps:open

HerokuでRroongaを使って全文検索をするアプリケーションができましたね。Herokuでも全文検索したい人は試してみてください。

HerokuでRroongaを動かすということ

ここからはHerokuでRroongaを動かすということは、どのようなメリット、どのような制約があるのかについて説明します。このあたりにあまり興味がなく、単に使えれば十分という人は次の2点だけ覚えておけば十分です。

  • マスターデータはどこかに持っておき、groonga/init.rbでGroongaデータベースにマスターデータを投入すること4
  • 扱えるデータの量は多くても100MB程度

揮発性ローカルストレージのHerokuとローカルストレージに保存するRroonga

Herokuはgit pushしたり、dynoが再起動する毎にローカルストレージの内容が消えます。

Rroongaはローカルストレージにデータベースを作成し、そこに対して読み書きします。もちろん、dynoが再起動するとRroongaが作ったデータベースも消えます。相性が悪いですね。

この相性の悪さは毎回Groongaのデータベースを1から作成することで解決します。Immutable InfrastructureとかDisposable Componentsのような考え方です。デプロイする毎にGroongaのデータベースが破棄されることを前提にします。そのため、毎回データベースを1から作成します。そのための仕組みがgroonga/init.rbです。

Herokuはアプリケーションが動くまで次のような流れになります。

  • git push heroku masterすると、slugを作成する。
  • slugを元にdyno(アプリケーション)を起動する。
  • なにかあったらslugを元にdynoを再起動する。

ポイントは、1つのslugを使ってN回dynoを起動するということです。

groonga/init.rbはslugを作るときに動きます。groonga/init.rbが作ったGroongaのデータベースはslugの中に含まれるため、dynoを起動したときはGroongaのデータベースがセットアップされた状態になります。ただし、dynoを起動した後にマスターデータが更新されないアプリケーションの場合は、という条件がつきます。

簡単に言うと、更新機能がないアプリケーションならgroonga/init.rbで作ったGroongaデータベースを使い続けられます。例えば、るりまサーチはそのようなタイプのアプリケーションです5

この記事で作成したブログはこの条件には当てはまりません。ブログに記事を投稿したり、既存の記事を削除したりできるからです。これはマスターデータを変更しているため、slugを作成するときに作ったGroongaのデータベースは古くなっています。

このようなアプリケーションの場合はdynoを起動する毎にGroongaのデータベースを1から作成します。この記事で作成したブログでは次のようになります。

groonga/init.rb:

# ...
# データを保存するテーブルを定義。カラムはPostgreSQLと同じ。
# ...

# 後でここにPostgreSQLのデータをインポートするコードを入れる
if Post.table_exists?
  indexer = PostIndexer.new
  Post.all.each do |post|
    indexer.add(post)
  end
end

# インデックスを定義。通常はこのパラメーターで十分。
# ...

PostgreSQLからデータを持ってきてGroongaのデータベースを更新しているだけです6

これをdynoが起動するタイミングでも実行します。

Procfile:

web: ruby groonga/init.rb && bin/rails server -p $PORT -e $RAILS_ENV

dynoが起動するたびに実行するとdynoの起動が非常に遅くならないか心配になると思いますが、次の理由から問題にはならないでしょう。

  • Groongaの更新速度は速い
  • データ量は多くない

slugの最大サイズは300MB

データ量はどうして多くないのか説明します。

それは、dynoが使えるローカルストレージのサイズにそんなに大きくない上限があるだろうからです。実際に上限がいくつかはわかりませんが、1GB強くらいでしょう。

予想してみましょう。

slugの最大サイズは300MBです。slugはgzで圧縮されています。Rubyのビルドパックでできるファイルがだいたい100MBで、それをtar.gzにすると25MBくらいです。そのため、ここでは1/4くらいに圧縮できると考えます。dynoではslugを展開して利用します。1/4に圧縮されているとすると、展開後は1.2GBになります。そのため、1GB強くらいが上限になっていると考えられます。

しかし、展開後で1.2GBになるdynoは想定外でしょうから、実際に使えるのはもっと少ないと考えるべきです。半分の500MBくらいとしましょう。そのうち、Ruby関連のファイルで100MBくらい使います。残りは400MBです。Groongaのデータベースはインデックスの張り方にもよりますが、少なくとも検索対象のデータ(入力データ)の3倍以上の大きさになります。実際にはいくつかインデックスを張るでしょうから、4倍以上などもっとサイズが増えます。よって、入力データは多くても100MBより少なくしなければいけません。

つまり、それほど大きなデータを扱うことはできないということです。そのため、Groongaのデータベースの作成にかかる時間も短くなり、dynoを起動する毎にGroongaのデータベースを作ることも現実的になります。

HerokuでRroongaを動かすということはなんだったのか

HerokuでRroongaを使うということは、ちょっとした全文検索機能つきWebアプリケーションをRubyで簡単に開発・運用できるということです。大規模システムには向きませんが、手軽にやりたいことを試せます。

まとめ

この記事で説明したことをまとめます。

  • HerokuでRroongaを使えるようになった
    • Rroonga用のビルドパックを使う
    • groonga/init.rbを用意する
    • あとはRuby用のビルドパックのときと同じ
  • Groongaのデータベースは毎回1から作る
    • Herokuの特性に合わせた
    • Disposable Database
  • 全文検索機能つきのちょとしたWebアプリケーションをRubyで簡単に開発・運用できる
    • 大規模データは対象外

ビルドパックの作り方も説明しようとしましたが、力尽きました。またの機会に。。。

  1. @mallowlabsさんが整えてくれました。

  2. 詳細はまた別の機会に。

  3. このファイル名は固定です。詳細は後述します。

  4. この記事で作っているブログではまだ実装していない。

  5. ただし、るりまサーチはデータサイズの制限という別の制限に引っかかるためHerokuでは動かせません。

  6. Post.table_exists?でテーブルがあるかをチェックしているのはheroku run rake db:migrateをする前の最初のgit push heroku masterのときでもエラーにならないようにするためです。