株式会社クリアコード > ククログ

ククログ


日本Ruby会議2010発表資料: るりまサーチの作り方 - Ruby 1.9でgroonga使って全文検索

注: 長いです。

日本Ruby会議2010るりまサーチの作り方について発表しました。

るりまサーチの作り方

[rk10][29S06] るりまサーチの作り方 - Ruby 1.9でgroonga使って全文検索

2010-08-30
再生: 561
コメント: 24
マイリスト: 5

[rk10][29S06] るりまサーチの作り方 - Ruby 1.9でgroonga使って全文検索 (32:59)
Kouhei Sutou (ClearCode Inc. / COZMIXNG)このトークではるりまサーチについてとるりまサーチの作り方について話します。るりまサーチはRubyリファレンスマニュアル刷新計画の成果物であるRubyのリファレンスマニュアルを高速に検索するWebアプリケーションです。るりまサーチはRubyインタプリタとしてRuby 1.9.1(MRI)、全文検索エンジンとデータストアとしてgroonga、Rubyとgroongaのインターフェイスとしてrroongaを使っています。作り方の説明では、特にこれらの技術の使い方について詳しく説明します。るりまサーチ: http://rurema.clear-code.com/

こんな動画まで風評被 RRMさん!?まずいです RRMさんをすこれ お覚悟を。 すごー buzztterはgrongaを使 rackngaが便利そう 検索: rrooonga HTTPでも出来るー 検索se...

ステージから見た感じだと立ち見の人もいたようでした。セッションに参加してくれたみなさん、会場を担当してくれたりレポートしてくれたスタッフのみなさん、ありがとうございました。

時間の関係で省略したことも含めてまとめておきます。

話すこと

話すこと

資料の中では、まず、るりまサーチについて説明し、その後、全文検索システムとしてのるりまサーチをどう作るのかを説明しています。

るりまサーチとは

るりまサーチとは

るりまサーチはRubyリファレンスマニュアル刷新計画 (通称るりま)の成果物であるRuby本体のリファレンスマニュアルを全文検索するためのWebアプリケーションです。るりまサーチが必要とされていた理由は、既存のリファレンスマニュアル閲覧Webアプリケーションに組み込まれていた検索機能の速度が遅かった*1からです。せっかく有益なリファレンスマニュアルがあっても、目的のエントリにたどりつくのが難しければ、有効に活用することができません。検索機能の面からリファレンスマニュアルの有効活用を支援する全文検索システムがるりまサーチです。

ポイント: ドリルダウン

ポイント: ドリルダウン

るりまサーチはRubyのリファレンスマニュアルに特化した小さな全文検索システムですが、最近の全文検索システムにとって重要なエッセンスが含まれています。全文検索システムを開発する場合はこれらのエッセンスを含めることを検討してみてください。

まず1つ目はドリルダウンと呼ばれる機能です。Solrなど他の全文検索システムによってはファセットと呼ぶこともあります。ドリルダウンとは、通常の検索結果に加えて、別のパラメータでの絞り込み結果も同時に提供する機能です。スライド中では「Rubyのバージョンで絞り込んだ結果、何件ヒットするか」という情報も表示しています。

この機能で嬉しいことは以下の2点です。

  • 検索キーワードを入力しなくてもクリックだけで結果を絞り込んでいける。
  • 絞り込み結果が0件になる条件を除外するので、「絞り込んだ後に0件ヒットになる」無駄な条件を指定せずに済む。

どちらもユーザの使い勝手を向上させるインターフェイスにつながります。ショッピングサイトなどでも使われているインターフェイスですね。

ポイント: URL

ポイント: URL

2つ目はURLのパスに絞り込み条件を含めることです。これは、内部ネットワーク用の全文検索システムではなく、インターネット上に公開する全文検索システム向けです。

最近ではURLにUTF-8でエンコードされたページ情報を含めることは一般的になってきました。WikipediaやAmazonでも行っています。Web検索エンジンはURLからも検索用の情報を抽出しているようなので、SEOになると考えられます。

ポイント: キャッシュ

ポイント: キャッシュ

3つ目はキャッシュです。より快適に検索・絞り込みを行うにはできるだけ速いレスポンスが求められます。レスポンスを高速化するためには、以下のような方法があります。

  • アルゴリズムを改良し、少ない計算量で結果を計算できるようにする。
  • 同じ結果を返す処理の処理結果を保存して、2回目以降の処理で結果を再利用する。

手軽に高速化する場合は後者のキャッシュ機能が便利です。キャッシュをする場合はキャッシュを無効化するタイミングを慎重に検討する必要があります。このタイミングを誤ると、期待した結果が返ってこないという問題が発生します。

キャッシュを無効にするタイミングはアプリケーションに依存します。一般的に、データが変更されるまでは同じキャッシュを利用できます。るりまサーチの場合は1日1回バッチ処理で元データを更新しています。そのため、同じキャッシュを1日使いまわすことができます。これにより高速にレスポンスを返すことができます。

また、キャッシュの効果を高めるためには、処理の内部よりもクライアントに近いところでキャッシュする必要があります。その方がより多くの計算を省略することができるからです。るりまサーチはログインせずに使えるシステムなので、同じ検索リクエストの結果はクライアントに関わらず同一になります。そのため、レスポンスをまるごとキャッシュすることができ、とても高い効果があります。

ログインが必要なシステムの場合は、クライアント毎に変更される部分のみJavaScriptで動的に生成したり、iframeを用いて別HTMLにすることにより、ログインによって変更されない部分ではキャッシュを利用することができます。それが難しい場合はもっと処理の内部でキャッシュをすることになります。この場合はキャッシュの効果が薄くなります。

キャッシュを用いることにより劇的にレスポンス速度を改善することができますが、キャッシュの有効期限とキャッシュする場所についてはよく検討する必要があります。

ポイント

ポイント

るりまサーチに含まれている最近の全文検索システムに重要なエッセンスは以下の3つです。

  • ドリルダウン
  • URLに検索条件を含める
  • キャッシュ

それでは、このようなエッセンスを含む全文検索システムるりまサーチの作り方について説明します。

全文検索システム

全文検索システム

全文検索システムは以下の5つの要素からなります。

  • 検索対象
  • クローラー
  • インデクサー
  • 全文検索エンジン
  • 検索インターフェイス

まず、検索対象からクローラーが検索対象とする文書を収集します。次に、それらからインデクサーがテキストやメタ情報を抽出して全文検索エンジンに登録します。全文検索エンジンに登録したデータからユーザが求めるデータを検索して提示するのが検索インターフェイスです。

るりまサーチの場合

るりまサーチの場合

るりまサーチの場合は以下のようになります。

検索対象
リファレンスマニュアル。
クローラー
リファレンスマニュアルはリポジトリからチェックアウトするので必要なし。
インデクサー
BitClustに含まれる機能を使ってリファレンスマニュアルの情報を全文検索エンジンに登録する。新規開発。
全文検索エンジン
groonga
検索インターフェイス
Ruby 1.9とRackを用いたWebインターフェイス。新規開発。

この中で、るりまサーチの重要な部分である全文検索エンジンgroongaについて説明します。

groonga: 特徴

groonga: 特徴

発表当日に初のメジャーバージョン1.0.0がリリースされたgroongaは、MySQLとの組み合わせで広く利用されているSennaの後継プロジェクトです。Sennaでのよいところを維持しつつ、さらに改良が加えられています。

Sennaは妥協しない転置索引実装と参照ロックしない更新アルゴリズムによるリアルタイム検索の実現が大きな特徴でした。Senna自体はデータストア機能を持たず、MySQLなど外部のデータストアと連携します。MySQLとSennaを連携させるソフトウェアはTritonnと呼ばれ、SQLで高速な全文検索機能を利用できることから広く使われています。しかし、MySQL側のロックモデルのため常に検索可能な状態で更新処理を行うことができません。そのため、せっかくのSennaの参照ロックフリーな更新アルゴリズムの特徴を活かしきれませんでした。

そこで、groongaでは独自のデータストア機能を提供し、外部のシステムによる制限を回避してgroongaの性能を発揮できるようにしました。データストアはドリルダウンを高速に実現できるカラム指向を採用しています。

また、HTTP/memcached/独自プロトコルなどのネットワークプロトコルも実装し、Solrのように検索サーバとして利用することもできるようになっています。

その他にも、より大規模な文書に対してもスケールするような性能改善や、モバイル端末の普及により重要性が増している位置情報データに対応するなど新規機能が含まれています。ただし、これらの改善のためにSennaとの互換性がなくなっています。Sennaの後継としてgroongaと名前を変更した理由はこのためです。

定義例: るりまサーチ

定義例: るりまサーチ

それでは、るりまサーチのケースを例にしてgroongaの使い方を説明します。手順は以下の通りです。

  1. スキーマ定義
  2. データ登録
  3. 検索

RDBと同じようにgroognaでも、まず、スキーマを定義します。

スキーマはRDBと同じように以下の3つの要素から構成されます。

  • テーブル
  • カラム

RDBではさらに索引もでてきますが、groongaでは↑の3つの要素を使って索引を作成するので、RDBより特別な存在ではありません。

スキーマを定義するときは、まず、検索対象がなにかを考えます。そして、その対象がどのくらいの粒度で1エントリになるかを考えます。るりまサーチではリファレンスマニュアルが検索対象で、メソッドやクラスそれぞれが1つのエントリになります。検索対象全体をテーブルとし、エントリをテーブルの各レコードにします。るりまサーチでは検索対象全体を扱う「Entries」テーブルを定義しています。

テーブルには検索結果に表示したい内容と検索時に利用する内容をカラムとして定義します。るりまサーチの場合にはメソッド名やクラス名を格納する「name」カラムやドキュメントを格納する「description」カラムなどを定義しています。

検索対象用のテーブルを定義したら索引を定義します。ここがRDBと異なる部分です。全文検索用の索引では単語と文書を対応させる語彙表が必要になりますが、同じトークナイザー*2を利用している場合は同じ語彙表を共有して省スペース化したり、同じテキストに複数のトークナイザーを適用して検索精度や検索漏れのトレードオフを調整したり、といったRDBよりも細かい制御ができます。

単にヒットしたかどうかではなく、検索結果の重み付けも重要です。有用な検索結果を提供するためには、クエリに適していると思われる結果ほど上位に提示する必要があります。しかし、どのように重み付けをするのが適切かは全文検索システムに大きく依存します。そのため、groongaでは索引毎に重み付けをカスタマイズする機能を提供しています。

るりまサーチではメソッド名やクラス名に完全一致した場合はよりマッチしていると判断するように*3、名前と完全一致だけする語彙表「Names」テーブル*4を定義し、そこに「name」カラムの索引を定義します。検索時にはこの索引にマッチした場合は重み付けを大きくします。

ドキュメント部分(「summary」カラムと「description」カラム)はトークナイザーを設定した全文検索用の語彙表「Terms」テーブルを共有しています。こっちの索引にマッチした場合は重み付けを小さくします。

スキーマはgroongaが提供している組み込みのDDLで定義する方法と、groongaのRubyバインディングであるrroongaが提供するDSLで定義する方法があります。

groongaのDDL:

# 検索対象のテーブル
table_create Entries TABLE_HASH_KEY ShortText
# 全文検索用の語彙表。トークナイザーとしてN-gramを使用。
table_create Terms TABLE_PAT_KEY ShortText --default_tokenizer TokenBigram
# 完全一致検索用の語彙表。トークナイザーはなし。
table_create Names TABLE_HASH_KEY ShortText

# 検索対象のデータ格納場所
column_create Entries name COLUMN_SCALAR Names
column_create Entries summary COLUMN_SCALAR Text
column_create Entries description COLUMN_SCALAR Text

# 全文検索用の索引
column_create Terms Entries_summary COLUMN_INDEX Entries summary
column_create Terms Entries_description COLUMN_INDEX Entries description

# 完全一致検索用の索引
column_create Names Entries_name COLUMN_INDEX Entries name

rroongaのDDL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Groonga::Schema.define do |schema|
  # 完全一致検索用の語彙表。トークナイザーはなし。
  schema.create_table("Names",
                      :type => :hash,
                      :key_type => "ShortText") do |table|
  end

  # 検索対象のテーブル
  schema.create_table("Entries",
                      :type => :hash,
                      :key_type => "ShortText") do |table|
    table.reference("name", "Names")
    table.text("summary")
    table.text("description")
  end

  # 全文検索用の語彙表。トークナイザーとしてN-gramを使用。
  schema.create_table("Terms",
                      :type => :patricia_trie,
                      :key_type => "ShortText",
                      :default_tokenizer => "TokenBigram",
                      :key_normalize => true) do |table|
   # 全文検索用の索引
    table.index("Entries.summary")
    table.index("Entries.description")
  end

  schema.change_table("Names") do |table|
    # 全文検索用の索引
    table.index("Entries.name")
  end
end

登録例: るりまサーチ

登録例: るりまサーチ

スキーマを定義したらデータを登録します。索引は自動で更新されるため、データ用のカラムにデータを登録するだけで動作します。

データの登録方法はgroongaのloadコマンドを使う方法と、rroongaを使う方法があります。

groongaのloadコマンド:

load --table Entries
[
  ["_key", "name", "summary", "description"],
  ["String#sub", "sub", "置換", "1つ置換"],
  ["String#gsub", "gsub", "置換", "全部置換"]
]

rroonga:

1
2
3
4
5
6
7
8
9
entries = Groonga["Entries"]
entries.add("String#sub",
            name: "sub",
            summary: "置換",
            description: "1つ置換")
entries.add("String#gsub",
            name: "gsub",
            summary: "置換",
            description: "全部置換")

Rubyで登録データの前処理を行う場合はrroongaを使う方がよいでしょう。Ruby以外で処理を行う場合はデータからJSONを生成し、groongaのloadコマンドを使う方がよいでしょう。るりまサーチはRubyで前処理*5をしているのでrroongaでデータを登録しています。

検索例: るりまサーチ

検索例: るりまサーチ

全文検索する場合は検索対象のカラムを指定する方法と、明示的に利用する索引を指定する方法の2通りあります。カラム単位で重み付けをしたい場合はカラムを指定し、索引単位で重み付けをしたい場合は索引を指定します。両方の指定方法を混ぜ合わせることもできます。

データの検索方法はgroongaのselectコマンドを使う方法と、rroongaを使う方法があります。

groongaのselectコマンド:

# 「description」カラムに「1つが」含まれているエントリを検索
select Entries description "1つ"
[[...],
 [[[...],
   [..., ["_key", ...], ["name", ...], ["summary", ...], ["description", ...], ...]],
  [..., "String#sub", "sub", "置換", "1つ置換", ...],
  ...]]
# 「sub」が含まれているエントリを検索。ただし、「name」が
# 「sub」だった場合は重みを大きくする。
select Entries "name * 100 | summary | description" "sub"
[[...],
 [[[...],
   [..., ["_key", ...], ["name", ...], ["summary", ...], ["description", ...], ...]],
  [..., "String#sub", "sub", "置換", "1つ置換", ...],
  ...]]

groongaのselectコマンド(HTTP経由):

# 「description」カラムに「1つが」含まれているエントリを検索
% wget -O - 'http://localhost:10041/d/select?table=Entries&match_columns=description&query=1つ'
[[...],
 [[[...],
   [..., ["_key", ...], ["name", ...], ["summary", ...], ["description", ...], ...]],
  [..., "String#sub", "sub", "置換", "1つ置換", ...],
  ...]]
# 「sub」が含まれているエントリを検索。ただし、「name」が
# 「sub」だった場合は重みを大きくする。
% wget -O - 'http://localhost:10041/d/select?table=Entries&match_columns=name*100|summary|description&query=sub'
[[...],
 [[[...],
   [..., ["_key", ...], ["name", ...], ["summary", ...], ["description", ...], ...]],
  [..., "String#sub", "sub", "置換", "1つ置換", ...],
  ...]]

rroonga:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
entries = Groonga["Entries"]
# 「description」カラムに「1つ」が含まれているエントリを検索
result = entries.select do |record|
  record.description =~ "1つ"
end
# 「sub」が含まれているエントリを検索。ただし、「name」が
# 「sub」だった場合は重みを大きくする。
result = entries.select do |record|
  target = record.match_target do |match_record|
    (match_record["name"] * 100) |
      (match_record["summary"]) |
      (match_record["description"])
  end
  target =~ "sub"
end

PHPなどRuby以外の言語から利用する場合はgroongaサーバを立てて、HTTP経由で検索するのがよいでしょう。Rubyから利用する場合は、selectコマンドで十分ならselectコマンドを利用、より複雑なことをしたい場合はrroongaを利用するのがよいでしょう。selectコマンドでもドリルダウンはサポートされて入るので、多くの場合はselectコマンドで十分でしょう。

るりまサーチでは、selectコマンドが提供するクエリ書式を利用したくない、rroongaが提供するページネーション機能を利用したい、などの理由でselectコマンドではなくrroongaを使っています。rroongaを利用してドリルダウンを実現する例にもなっています。

るりまサーチを例にして、groongaを用いて全文検索システムを開発する場合の基本的な流れを説明しました。より詳しいことはGitHubのるりまサーチのリポジトリにあるソースコードを見てください。

racknga

racknga

るりまサーチの検索WebインターフェイスはRuby 1.9とRackの上に構築されています*6。るりまサーチを開発した際に、るりまサーチ以外でも使えそうな部分がでてきたので、rackngaという名前でるりまサーチと別パッケージとして公開しています。

rackngaにはRackのミドルウェアとMuninプラグインが含まれています。MuninのプラグインはPassengerの以下の情報を収集します。

  • 処理したリクエスト数
  • 処理中のリクエスト数
  • プロセスの状態
  • プロセスの起動時間

Rackのミドルウェアは1つずつ説明します。

エラー通知

エラー通知

アプリケーション内でエラーが発生した場合にメールでその内容を通知するミドルウェアです。RailsのException NotifierのRack用です。

以下のように利用します。

config.ru:

1
2
3
4
5
6
7
8
9
10
11
12
13
require 'racknga'

notifier_options = {
  "host" => 127.0.0.1,
  "from" => "rurema@example.com",
  "to" => "developer@example.com",
  "charset" => "iso-2022-jp",
  "subject_label" => "[るりまサーチ] ",
}
notifiers = [Racknga::ExceptionMailNotifier.new(notifier_options)]
use Racknga::Middleware::ExceptionNotifier, :notifiers => notifiers
# ...
run your_application

できるだけ多くのエラーを検出するためになるべく最初の方でuseしてください。

キャッシュ

キャッシュ

主にサーバ1台や2台などで処理できる程度の中規模のPassenger環境で利用することを想定したキャッシュミドルウェアです。ヘッダーやボディを含めHTTPのレスポンス全体をgroongaのデータストアにキャッシュします。Passengerでは複数のインスタンスが別プロセスで起動しますが、groongaは複数プロセス間で同一のデータベースを操作することができるため、別のインスタンスがキャッシュした内容を他のインスタンスから参照することができます。以下のように利用します。

config.ru:

1
2
3
4
5
6
7
8
9
10
11
require 'racknga'
require 'racknga/middleware/cache'

# ...
# use Rack::Deflater
# use Rack::ConditionalGet
# ...
base_dir = Pathname.new(__FILE__).dirname.cleanpath.realpath
cache_database_path = base_dir + "var" + "cache" + "db"
use Racknga::Middleware::Cache, :database_path => cache_database_path.to_s
run your_application

他のミドルウェアと組み合わせやすいように、なるべくアプリケーションに近い部分に置くことをよいでしょう。

複数のサーバ間でキャッシュを共有したい場合は別の仕組みを利用することをオススメします。

条件付き圧縮

条件付き圧縮

ネットワーク帯域を節約するためには、レスポンスを圧縮して返すことが有効です。しかし、Internet Explorer 6では問題があることがわかっています。そのため、Internet Explorer 6の場合は常に圧縮しないようにするのがこのミドルウェアです。Rack::Deflaterのラッパーです。以下のように利用します。

config.ru:

1
2
3
4
5
6
7
require 'racknga'

# ...
use Racknga::Middleware::Deflater
# use Rack::ConditionalGet
# ...
run your_application

JSONP

JSONP

Web APIとしてサービスを提供する場合、JSON形式で結果を返すことが多くなっています。クライアント側でWeb APIにアクセスする場合はJSONPを利用することになります。

このミドルウェアはJSONPに対応しておらず単にJSONデータを返すだけのアプリケーションをJSONPに対応させることができます。また、以下のような配置にすることにより、キャッシュを有効にしたままJSONP対応にすることができます。

config.ru:

1
2
3
4
5
6
7
8
9
10
require 'racknga'
require 'racknga/middleware/cache'

use Rack::Middleware::JSONP

base_dir = Pathname.new(__FILE__).dirname.cleanpath.realpath
cache_database_path = base_dir + "var" + "cache" + "db"
use Racknga::Middleware::Cache, :database_path => cache_database_path.to_s

run your_application # "Content-Type: application/json"のレスポンスを返す

現在、るりまサーチはWebサービスを提供していませんが、将来の拡張を念頭においてこのミドルウェアがrackngaに含まれています。

まとめ

まとめ

るりまサーチはドリルダウンやキャッシュを利用することにより、快適に目的のドキュメントへ到達できるような工夫をしています。るりまサーチ以外にもリファレンスマニュアルを利用するツールがあるので有効活用しましょう。

ドリルダウンを効果的に利用した高速な全文検索システムにはgroongaが適しています。Rubyとの親和性も高いgroongaで全文検索システムを開発してみてはいかがでしょうか。汎用ユーティリティであるrackngaも一緒に用いることにより開発・運用が改善されるでしょう。

最後にお知らせです。クリアコードではプログラミングが好きな開発者を募集しています。プログラミングが好きな人は検討してみてください。

お知らせ

*1 数十秒以上かかる。

*2 文章から単語を抜き出す処理。

*3 メソッド名で検索することは多いですよね?

*4 実体はトークナイザーなしのハッシュテーブル。

*5 BitClustを使ってメソッド単位にドキュメントを分割するなど。

*6 RailsやSinatraなどは使っていません。

タグ: Ruby

この記事の続き

2010-09-01

お知らせ: hbstudy#15: milter managerで簡単迷惑メール対策

お知らせです。

来週の週末9/18(土)の19:00-開催されるhbstudy#15milter managerを紹介します。

hbstudyはインフラエンジニアのための勉強会で、#15では迷惑メール対策ソフトウェアmilter managerと監視ソフトウェアZabbixがテーマです。同じ回で違う分野を扱うので、知識の幅が広がりやすいのがよいですね。

「複数のmilterを同時に使ったときの挙動を理解できること」を目標に話す予定です。まだ少し空いているようなので興味のある方は参加してみてください。

2010-09-07

メタプログラミングRuby

日本Ruby会議2010A Metaprogramming Spell BookというタイトルのRubyの魔術の話がありましたが、それはこの本の内容がベースになっています。

メタプログラミングRuby
Paolo Perrotta/角征典
KADOKAWA/アスキー・メディアワークス
¥ 3,024

Rubyはとても動的な言語で、ほとんどのことが実行時に決まります。このRubyの特徴はメタプログラミングと非常に相性がよいのです。「メタプログラミングとは、コードを記述するコードを記述すること」です。プログラムを実行して動的にプログラムを作っていくのです。

1冊まるごとRubyのメタプログラミング機能について書いてあります。RailsでWebアプリケーションが作れるようになってきて、少し余裕がでてきた人あたりにあっているのではないでしょうか。Railsはよくも悪くもメタプログラミングしまくっています。少しレールを外れたことをしようとしたらRailsの中に飛び込んで中を調べるようになるでしょう。そのとき、この本に書いてあることが役に立つでしょう。

この本のすごいところは、訳がとてもこなれていて読みやすいことと、サンプルコードがきちんと整形されていて読みやすいことです。

訳はMartin Fowler's Blikiの翻訳でお馴染みの角さんなので安心して読めるのは驚くことではありませんね。

コードがきちんとインデントされていて、スタイルが一貫しているのはとても好感が持てます。プログラミング関連の書籍にはきちんと整形されていなくて汚いソースコードが載っていることがあります*1が、コードもきれいに整形できない人が書いていることを誰が信用できるでしょうか。いくら役立つことを書いていたとしても、いくらすごいことをするプログラムであっても、信用することはできません。

この本は、日本語としてもRubyとしても読みやすくできているため、内容に集中することができます*2。Rubyのメタプログラミングについて知りたくなったら信用できるこの本を読んでみるとよいでしょう。Rubyをちゃんと知って、信用できるようになれば、「ダークサイド」に堕ちることもないでしょう。

*1 翻訳のものの方がそんな傾向が多い印象。

*2 最近読んだ技術書の中で誤植を見つけられなかったのはこの本くらいです。

タグ: Ruby
2010-09-13

hbstudy#15発表資料: milter managerで簡単迷惑メール対策

hbstudy#15でmilterについて発表しました。

milter managerで簡単迷惑メール対策

公開しているスライドの内容は実際に使ったものと異なっています*1。実際に使ったものや当日の雰囲気などが気になる人はUstreamの録画を観てください。

スライドのPDFやソース、当日使ったmilterなどはスライドページからダウンロードできます。milterはスライドのソースと同じアーカイブに含まれています。

スライドはRabbitというRubyで書かれたフリーソフトウェアで作成しています。Ruby界隈ではとても有名なプレゼンツールなのですが、インフラ界隈ではあまり有名ではないので、当日使ったRabbitの機能を簡単に説明しておきます。

スライドの下にでていたうさぎとかめは、うさぎがページ数を、かめが経過時間を示しています。うさぎが前を走っていればペースが速い、かめが前を走っていれば間に合わない、というようにプレゼンテーションの進み具合が視覚的に一瞬でわかるのがよいところです。「残り7:35」と出てもいい感じで進んでいるのかどうかがわからないですよね。また、タイマー用のテキストがスライドの中にあると不自然ですが、うさぎとかめがスライドの中にいても不自然ではないので、発表者用のビューではなく、表示用のビューに表示することができるのもよいところです。PCの画面とディスプレイの内容を同じ内容にできるので、ディスプレイ側の表示だけおかしくなっている、という状態を防ぐことができます。ただし、聞いている人が発表よりもうさぎとかめの方が気になってしまうという問題があります。ここは発表者の腕でなんとかする必要があります。

また、携帯電話でRabbitを遠隔操作していました。仕組みを図示したのものがとちぎRuby会議02の資料公開にあります。今回も大体これと同じ構成です。処理の流れは以下の通りです。

  1. 携帯電話のi-modeブラウザで「次のページへ遷移」リンクを辿る ーHTTP→
  2. サーバ上で動いているHTTPサーバ(Rabrick: Rubyで書かれている)がノートPC上のRabbitプロセスの「次のページへ遷移メソッド」をdRubyを使って呼び出す ーdRuby→
  3. ーSSHトンネル→
  4. ーdRuby→ ノートPC上のRabbitプロセスの「次のページへ遷移メソッド」が呼び出されて、次のページへ遷移

システムとしてはカッコイイのですが、目の前のノートPC上で動いているRabbitを操作するためにインターネットを経由しているので多少タイムラグがあります。

それでは、以下に発表内容を簡単にまとめておきます。省略している部分が気になる場合は上述のUstreamの録画を観てください。

概要

話すこと

タイトルは「milter managerで簡単迷惑メール対策」でしたが、参加者がそれほどメール環境になじみの深い人ばかりではなさそうだったので、milter managerそのものの話は最後に少しする程度にしました。内容のほとんどはmilter managerがベースとしている技術であるmilterについてです。

milterを言葉や図だけで理解するのは少し大変なので、今回は実際にありうる例を動かしながら理解していきます。

milterについて

milterとは

milterとはSendmailが作ったメールフィルターの仕組みです。実際には、この仕組みの中で使われるネットワークプロトコルや、メールフィルターを開発するためのAPI、メールフィルターそのもののことも「milter」と呼ぶことがあります。ただ、多くの場合は文脈からどれを指しているかがわかるので、それほど混乱することはありません。

milterは2001年9月にリリースされたSendmail 8.12.0から正式サポートされています。9年の歴史がある枯れた技術といえます。また、SendmailだけではなくPostfixでもサポートされています。Postfixでは2006年6月にリリースされた2.3.0からサポートされ、現時点の最新版2.7.1ではmilterのほとんどの機能がサポートされています。こちらも4年の歴史があり、もう十分に実践に投入できるほど使われています。

つまり、現時点ではmilter*2を用いてメールフィルター機能を実現することは「実験的な試み」ではなく「いくつかある有力な選択肢の1つ」といえます。

しかし、日本語でのmilterの情報が少ないことは事実です。milterに関する英語の情報はmilter.orgに集まっています。milter.orgにはmilter*3を検索する機能や開発者向けの情報なども載っています。

たとえば、Technical Overview - Control Flowに載っている以下の擬似コードを見れば、複数のmilter*4を同時に用いたときにどのような動作になるかはわかります*5

For each of N connections {
  For each filter
    process connection (xxfi_connect)
  For each filter
    process helo (xxfi_helo)
  MESSAGE:For each message in this connection (sequentially) {
    For each filter
      process sender (xxfi_envfrom)
    For each recipient {
      For each filter
        process recipient (xxfi_envrcpt)
    }
    For each filter {
      process DATA (xxfi_data)
      For each header
        process header (xxfi_header)
      process end of headers (xxfi_eoh)
      For each body block
        process this body block (xxfi_body)
      process end of message (xxfi_eom)
    }
  }
  For each filter
    process end of connection (xxfi_close)
}

日本語でもこれと同じような情報を読むことができれば、milter利用の敷居が低くなるかもしれません。ここでは、実際に動かしながらmilter*6がどのように動くのかを確認していきます。

milterの挙動

milterの挙動: 3行で

上記のmilterの動作フローをざっくりとまとめると、以下のようになります。

  • 本文は直列(前のmilterの影響を受ける)
  • 本文以外は並列(前のmilterの影響を受けない)
  • 詳細な結果は最後に返す

milterは迷惑メール対策に使われることがほとんどです。迷惑メールの手法は複雑化しているので、1つの迷惑メール対策方法で完璧ということは不可能です。複数のmilter(迷惑メール対策方法)を組み合わせて効果的な迷惑メール対策システムを構築する必要があります。

しかし、milterは上記のような動作のため、milter1の結果を使ってmilter2の挙動を変えるということが難しくなっています。milter1の詳細な結果を使いたければ、メール全体の処理が終わるのを待たなくてはいけません。milter2がエンベロープ情報(送信元や宛先)を取得した段階で実行する迷惑メール対策方法*7を採用している場合は、メール全体の処理が終わるまで待つのは効率的ではありません。

milterがどのように動作するかがわかれば、複数のmilterをどのように組み合わせることが効果的かを検討することができます。そのため、milterを用いて効果的な迷惑メール対策システムを構築する場合はmilterの挙動を理解しておく必要があります。

milterの挙動の詳細

milterプロトコル

miltrの挙動を理解するためには、以下の3つの要素があることを理解するのが早道です。要素の名前はここでの説明のために便宜的に付けたもので、milterで一般的な用語ではないので注意してください。

  • ステージ: milterが処理を行うタイミング
  • アクション: milterが処理を行ったときにメールサーバに返す結果
  • メッセージ変更機能: メールフィルターとして行えること

ステージ

milterが処理を行うタイミングはSMTPのコマンド+αだけあります。どのようなタイミングかを一言解説を付けています*8

  • connect: SMTPクライアントが接続してきたとき。
  • helo: SMTPクライアントがHELO/EHLOコマンドを実行したとき。
  • mail from: SMTPクライアントがMAIL FROMコマンドを実行したとき。
  • rcpt to: SMTPクライアントがRCPT TOコマンドを実行したとき。
  • data: SMTPクライアントがDATAコマンドを実行したとき。
  • header: メールサーバがメールのヘッダー1つを解析したとき。ヘッダーの数だけn回発生する。
  • end of header: メールサーバがメールのヘッダーをすべて解析し終わったとき。
  • body: メール本文を処理しているとき。
  • end of message: メール全体を処理したとき。

「メッセージ変更機能」は「end of message」のときしか実行できません。「メッセージ変更機能」は後述します。

アクション

milterは処理を行うたびにメールサーバに結果を返さないといけません。メールサーバに返せる結果は以下の通りです。

  • continue: 処理を続行する。普通はこれ。
  • accept: 受信する。このmilterは以降の処理を行わない。
  • reject: メールを受信拒否する。SMTPレベルでは500番台の受信拒否になる。
  • tempfail: メールを一時受信拒否する。SMTPレベルでは400番台の受信拒否になる。
  • discard: メールを受信するが、配送せずにそのまま廃棄する。このmilterは以降の処理を行わない。
  • quarantine: メールを受信するが、配送はしない。(本当はアクションではないので、end of messageステージのときしか使えない。)

メッセージ変更

以下のようなメッセージ変更機能があります。メールフィルターとしては十分な機能です。

  • From変更
  • To追加
  • To削除
  • 本文変更
  • ヘッダー追加
  • ヘッダー削除
  • ヘッダー変更

それでは、以下、実例を元に実際の動作を確認します。

ケース1: mail fromでaccept

実例は5つ用意しましたが、ここでは1つだけ紹介します。他はスライドやUstreamの録画を観てください。

ケース1: accept

mail fromステージでacceptアクションを返した場合です。SMTP Authをしている場合は何も処理をしないmilterが多くあります。その場合はこのような動作になります。

mail fromステージでacceptアクションを返すmitlerはRubyで実装するとこのようになります。これはスライドのアーカイブの中のmilters/milter-accept.rbに入っています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
require 'milter'

class MilterAccept < Milter::ClientSession
  def envelope_from(from)
    p from
    accept
  end

  def envelope_recipient(to)
    p to
  end
end

command_line = Milter::Client::CommandLine.new
command_line.run do |client, _options|
  client.register(MilterAccept)
end

このとき、SMTPクライアントがRCPT TOコマンドを送ったらどうなるでしょうか。

ケース1: 答え

この場合、milterはacceptを返しているのでメールを受信します。ただし、SMTPクライアントがMAIL FROMより後のRCPT TO以降のコマンドを送ってもmitler側では何も起こりません。それ以降のステージではメールサーバがmilterに通知しないからです。

発表時には実際にSMTPを話しながら動作を確認しています。この様子はUstreamの録画を観てください。また、他の例もUstreamの録画やスライドページを見てください。

milter managerが便利なところ

milter managerの特徴

例を使ってmilterの動作を確認した通り、milterを連携させることが難しいケースがあります。milter managerを使うことでそこを補うことができます。

例えば、「milter評価モード」機能を使うことによって、「milter1がreject/temp failを返した」という情報をmilter2で利用することができます。通常はend of messageまで待たなければ情報を利用することができませんが、reject/temp failはどのステージでも利用することができるので、もっと早い段階で情報を利用することができます。

以下、milter managerの利用例を1つ紹介します。他の利用例などはまた別の機会にでも紹介できるとよいですね。

管理例: ユーザ毎の設定

milter managerを使うことによってユーザ毎に迷惑メール対策方法を変えることができるシステムを構築することができます。例えば、MySQLにユーザ毎の設定を格納しておきます。格納した情報は各milterではなくmilter managerが参照してmilterの挙動を動的に制御します。各milterがそれぞれMySQLの情報を参照するようなシステムにすることもできますが、そうすると、複数のmilterを使う場合に大変です。それぞれのmilterがMySQLに対応し、さらに、MySQL内の情報をどのように使うかを判断しなければいけません。milter managerを使うことにより、その部分を一括管理することができます。

まとめ

管理例: ユーザ毎の設定

迷惑メールが多様化しているため、複数のmilterでそれに対応する必要があります。ただし、複数のmilterを利用した場合の挙動は少し複雑です。まるで理解しがたいというものではなく、少し落ち着いて考えれば理解できるものなので、milterを利用する場合は一度くらいは落ち着いてmilterの挙動を確認しましょう。

複数のmilterを利用する場合はmitler managerも一緒に導入するとより便利です。

お知らせ

宣伝1: クリアコードではプログラミングが好きな開発者を2名募集中です。興味がある人は応募してみてください。よさそうな人を知っていたら教えてあげてください。

宣伝2: クリアコードの開発者は全員オープンソースソフトウェアの開発に関わって磨いてきた技術力を持っています。オープンソースソフトウェアに関して技術的にお困りのことがあったらぜひご相談ください。もちろん、milterに関することもOKです。

*1 その場だけで使う用のものや再配布用ではないものを抜いている。

*2 これは仕組みのこと。

*3 この「milter」はmilterの仕組みを使って実現されたメールフィルターそのもののこと。

*4 これもメールフィルターそのもののこと。

*5 ちょっとウソ。milterの他のことも少し知らないとこれだけだとわからない。

*6 これもメールフィルターのこと。これ以降は注釈をつけませんがわかるはずです。

*7 例えば、SPFやGreylisting(グレイリスト)。SPFは迷惑メール対策にも使われることが多い。

*8 わかりやすさを重視したので、厳密には違う場合もあるので注意してください。

タグ: milter manager | Ruby
2010-09-20

git-utilsをGitHubへ移動

git用のコミットメール配信システムであるgit-utilsのリポジトリをGitHubへ移動しました。こんなこともあろうかとGitHubにクリアコードアカウントを取得しておいたのです。

0.0.1以降リリースしていませんが、少しずつ機能を追加していて、GitHubのPost-Receive Hooksを用いたGitHub上のリポジトリのコミットメール配信にも対応しています。tDiaryのコミットメールgroongaのコミットメールなどがgit-utilsを利用しています。GitHub上のリポジトリでコミットメールを配信したい場合はお問い合わせフォームからご連絡ください。(無償・無保証で対応しています。)

github-post-reciever/以下を利用して自分でPost-Receive Hooksを受け付けるサーバを立ち上げることもできます。具体的な手順のかかれたインストールドキュメントはありませんが、RackアプリケーションなのでPassengerなどを設定したことがある方は問題なく設置できるでしょう。

GitHubに移動したことでforkしたりpull requestを出しやすくなっているので、自由に利用したり変更したりしてください。改良やドキュメント作成などフィードバックは積極的に取り込んでいく予定です。ライセンスはGPLv3 or laterです。

タグ: Ruby

この記事の続き

2010-09-27

«前月 最新記事 翌月»
タグ:
年・日ごとに見る
2008|05|06|07|08|09|10|11|12|
2009|01|02|03|04|05|06|07|08|09|10|11|12|
2010|01|02|03|04|05|06|07|08|09|10|11|12|
2011|01|02|03|04|05|06|07|08|09|10|11|12|
2012|01|02|03|04|05|06|07|08|09|10|11|12|
2013|01|02|03|04|05|06|07|08|09|10|11|12|
2014|01|02|03|04|05|06|07|08|09|10|11|12|
2015|01|02|03|04|05|06|07|08|09|10|11|12|
2016|01|02|03|04|05|06|07|08|09|10|11|12|
2017|01|02|03|04|05|06|07|08|09|10|11|12|
2018|01|02|03|04|05|06|07|08|09|10|11|12|
2019|01|02|03|04|05|06|07|08|09|10|11|12|
2020|01|02|03|04|05|06|07|08|09|10|11|12|