tl; dr
- 分散オブジェクトプログラミングなdRuby入門
- dRubyで分散負荷試験のサンプルコード
はじめに
dRubyってご存知でしょうか。標準ライブラリdrb
としてRubyに同梱されていて、
分散オブジェクトプログラミングのためのライブラリです。
Ruby のプロセスから他のRubyプロセスにあるオブジェクトのメソッド を呼びだすことができます。他のマシン上のプロセスにも アクセスできます。
と説明されています1。純Rubyな標準ライブラリなのでRubyさえ動けば簡単に分散プログラミングすることができます。
dRubyを使って分散負荷試験をやってみたらとても楽ちんだったので紹介します。
RabbitMQの負荷試験をするためにdRubyを使いましたが、Webでもなんでも使える汎用的な仕組みです。
環境
この記事のコードは次の環境で動作確認をしています。
- MacOS X 10.11.1
- Ruby 2.2.3
経緯
普通のWebサービスならApache BenchなりJMeterなりを使えばいいです。
とあるサービスの開発でRabbitMQの負荷試験をするために負荷試験用のプログラムを作る必要がありました。
RabbitMQはメッセージキューの中では性能は悪い方ですが、とはいえ1プロセスからでは負荷をかけきるのが難しい2程度の性能はあります。そこでdRubyを使って複数のサーバから同時に負荷をかけることにしました。
dRuby
基本的な使い方
簡単にdRubyの使い方を説明します。詳しい使い方はドキュメントをみてください。Ruby: dRuby(drb)もわかりやすいです。
dRubyを使うと他プロセスのオブジェクトのメソッドを呼べるようになります。ここでは、他プロセスのオブジェクトを__リモートオブジェクト__と言い、リモートオブジェクトを提供するプロセスを__dRubyサーバ__と呼びます。
dRubyサーバは、1つのリモートオブジェクトを1つのURIで提供します。
例えばハッシュをリモートオブジェクトとして提供するdRubyサーバのコードは次のようになります。
(サーバ側ではリモートオブジェクトとして提供するオブジェクトを__フロントオブジェクト__といいます)
irb
やpry
を2つ起動すると簡単に試せます。
require "drb"
front = { foo: "bar", hoge: "moge" }
uri = "druby://localhost:55331"
DRb.start_service(uri, front)
DRb.thread.join
DRb.start_service
にサーバのURIとフロントオブジェクトを渡します。サーバはスレッドとして起動するため、メインスレッドが終了ないようにjoin
でサーバの終了を待つようにしておきます。
リモートオブジェクトを使う側のdRubyクライアントは次のようなコードになります。
>> require "drb"
=> true
>> remote_object = DRbObject.new_with_uri("druby://localhost:55331")
=> #<DRb::DRbObject:0x007fccdd8f2968 @ref=nil, @uri="druby://localhost:55331">
>> remote_object[:foo]
=> "bar"
>> remote_object[:aaa] = "bbb"
=> "bbb"
DRbObject.new_with_uri
でURIからリモートオブジェクトを取得できます。リモートオブジェクトはHash
ではなくDRb::DRbObject
というクラスのインスタンスになっており、こいつはリモートオブジェクトのproxyのようなものです。
分散オブジェクト
次のように任意のクラスのオブジェクトをリモートオブジェクトとして提供することが可能です。
require "drb"
URI = "druby://localhost:55331"
class Server
attr_reader :history
def initialize
@history = []
end
def multiply(x, y)
(x * y).tap do |res|
puts "#{x} * #{y} = #{res}"
history.push res
end
end
def perform(*args, &block)
puts "Performing with arguments: #{args}"
history[history.size] = block.call(args)
end
end
DRb.start_service(URI, Server.new)
DRb.thread.join
使う側も簡単です。
>> require "drb"
=> true
>> remote = DRbObject.new_with_uri("druby://localhost:55331")
=> #<DRb::DRbObject:0x007fd0d3a674c0 @ref=nil, @uri="druby://localhost:55331">
>> remote.multiply(3, 4)
=> 12
>> remote.multiply(4, 4)
=> 16
>> remote.history
=> [12, 16]
参照渡しと値渡し
ここで少し注意しないといけないことがあります。サーバーからのServer#history
の配列は値渡しになっています。つまり、クライアント側にはコピーされた配列が返ってきています。
dRubyではメソッド呼び出しの引数や返り値のオブジェクトは、マーシャリング可能な場合マーシャリングしてコピーされます。そのため、配列も通常だとコピーされて値渡しになります。
>> remote.history << 1
=> [12, 16, 1]
>> remote.history
=> [12, 16]
参照渡しにしたいときはマーシャリングできないようにする必要があります。dRubyではDRb::DRbUndumped
をinclude
することでマーシャリング不可能にできます。
class Server
def initialize
@history = [].extend(DRb::DRbUndumped)
end
end
クライアント側ではリモートオブジェクトとしてDRb::DRbObject
のインスタンスが返ってきます。
>> remote = DRbObject.new_with_uri("druby://localhost:55331")
=> #<DRb::DRbObject:0x007fd0d4937b30 @ref=nil, @uri="druby://localhost:55331">
>> remote.multiply(3, 4)
=> 12
>> remote.multiply(4, 4)
=> 16
>> remote.history
=> #<DRb::DRbObject:0x007fd0d399bd70 @ref=70237500564640, @uri="druby://localhost:55331">
>> remote.history << 1
=> #<DRb::DRbObject:0x007fd0d4068158 @ref=70237500564640, @uri="druby://localhost:55331">
>> remote.history.join(", ")
=> "12, 16, 1"
ブロック
dRubyはブロック付きのメソッド呼び出しにも対応しています。しかし、クライアント側でもdRubyサーバを起動する必要があります。Proc
がマーシャリング不可能なため、参照を渡す必要があるからです。
クライアント側でサーバを起動しないとブロック付きメソッド呼び出しはエラーになります。
>> remote.perform(3, 4) { |x, y| x * y }
DRb::DRbConnError: DRb::DRbServerNotFound
from /Users/nownabe/.anyenv/envs/rbenv/versions/2.2.3/lib/ruby/2.2.0/drb/drb.rb:1718:in `current_server'
クライアント側でdRubyサーバを起動してやればうまくいきます。
>> DRb.start_service
=> #<DRb::DRbServer:0x007fd0d3a243a0 ...
>> remote.perform(3, 4) { |x, y| x * y }
=> 12
>> remote.history.join(", ")
=> "12, 16, 1, 12"
分散負荷試験
RabbitMQ負荷試験ツール
前置きが長くなりましたが、いよいよ本題の負荷試験です。
まずは、RabbitMQの負荷試験をするために作ったツールについて説明します。図にするとこんな感じになります。何の変哲もないですね。
それぞれのボックスが1台の仮想サーバと対応しています。
__Load Master__は次の役目を持つアプリケーションです。
- 設定ファイルのパース
- 負荷試験の進行
- 対象サーバ(RabbitMQ, LVS)のメトリクス収集、保存
- 対象サーバの状態リセット
- Load Slaveへの指示
- Load Slaveからの結果を保存
- メトリクスや結果の集計
Load Masterは次のようなYAMLで試験とパラメータを記述できるようになっています。
connections:
parameters:
connections_count:
- 10
- 20
- 30
queued_messages:
parameters:
message_size:
- 256
- 1024
messages_count:
- 100000
- 200000
- 300000
__Load Slave__は次の役目を持つアプリケーションです。
- 対象サーバへの各種負荷試験実施
- 対象サーバへの結果報告
- dRubyで、負荷試験のインターフェイスとなるリモートオブジェクトの提供
ベースクラスを継承した負荷クラスを追加することで様々な負荷をかけることができるようになっています。
module LoadSlave
module Loads
class QueuedMessages < Base
def load
# loads
end
end
end
end
扱う仮想サーバが多いので、デプロイやプロセス起動・停止などにはCapistranoを使いました。
簡略版HTTP負荷ツール
ここからは、説明簡略化のために次のようなHTTPサーバに負荷をかけるシステムを考えます。
- Load MasterはSlaveへの指示と結果の取得・出力を行う
- Load SlaveはMasterから指示されると対象サーバに負荷をかけて結果をMasterへ報告する
ファイル構成
Load Master、Load Slaveを構成するファイル群です。
$ tree .
.
├── bin # 実行ファイル
│ ├── master
│ └── slave
├── config
│ └── slaves.yml # Load SlaveのURI
└── lib
├── load_master
│ ├── runner.rb
│ ├── slaves.rb
│ └── test.rb
└── load_slave
├── loader.rb
├── runner.rb
└── server.rb
Load Slave
まずは実際に負荷をかけるLoad Slaveから実装していきます。
LoadSlaveはMasterから指示されるとローカルのab
コマンドを使って対象のHTTPサーバに負荷をかけ、かかった秒数を返します。
クラスはLoadSlave::Loader
、LoadSlave::Runner
、LoadSlave::Server
の3つです。
それぞれのコードを紹介します。
LoadSlave::Loader
実際にApache Benchで負荷をかけるクラスです。このクラスのインスタンスがリモートオブジェクトになります。
Load MasterがLoadSlave::Loader#execute
を実行することになります。
負荷試験では途中経過の状態を持つこともあるので、各試験ごとでインスタンスを使い捨てることができるように設計してあります。
require "drb"
module LoadSlave
class Loader
include DRb::DRbUndumped
AB_BINARY = "/usr/sbin/ab"
attr_reader :url, :requests, :concurrency
def initialize(url, requests, concurrency)
@url = url
@requests = requests
@concurrency = concurrency
end
def execute
puts "Execute `#{command}`"
start_time = Time.now
`#{command}`
time = Time.now - start_time
puts "Finished ab (#{time}s)"
time
end
private
def command
"#{AB_BINARY} -n #{requests} -c #{concurrency} #{url}"
end
end
end
LoadSlave::Server
LoadSlave::Server
のインスタンスがフロントオブジェクトになります。
Load Slaveのサーバプロセスはずっと起動しているので、このインスタンスはプロセスが終了するまで保持されることになります。
Load MasterはLoadSlave::Server#generate_loader
を実行してLoadSlave::Loader
のリモートオブジェクトを取得します。
require "load_slave/loader"
module LoadSlave
class Server
def generate_loader(url, requests, concurrency)
Loader.new(url, requests, concurrency)
end
end
end
LoadSlave::Runner
dRubyサーバーを起動するクラスです。ホストとポートを指定できます。
require "drb"
require "load_slave/server"
module LoadSlave
class Runner
attr_reader :host, :port
def initialize(host: "localhost", port: 55331)
@host = host
@port = port
end
def run
puts "Starting LoadSlave server"
DRb.start_service(uri, Server.new)
puts "Waiting requests"
DRb.thread.join
end
def uri
@uri ||= "druby://#{host}:#{port}"
end
end
end
実行ファイル
実行ファイルです。
#!/usr/bin/env ruby
if ARGV[0] =~ /-h/
puts "Usage: bin/slave [port]"
exit
end
$LOAD_PATH.unshift File.expand_path("../../lib", __FILE__)
require "load_slave/runner"
LoadSlave::Runner.new(port: ARGV[0] || 55331).run
Load Master
簡略版ではかなりの機能をカットして、単純にLoad Slaveに指示を出して結果を受取るだけです。
クラスはLoadMaster::Runner
、LoadMaster::Slaves
、LoadMaster::Test
の3つです。
LoadMaster::Test
1つの負荷試験を表すクラスです。試験の条件を持ちます。
Test = Struct.new(:url, :requests, :concurrency)
LoadMaster::Slaves
Load Slaveのリモートオブジェクトを取得して、指示を出すクラスです。
Parallelを使って全Slaveに同時3に指示を出します。
require "drb"
require "parallel"
module LoadMaster
class Slaves
def initialize(drb_uris)
@slaves = drb_uris.map { |uri| DRbObject.new_with_uri(uri) }
end
def count
@slaves.count
end
def load(test)
Parallel.map(@slaves, in_threads: count) do |slave|
slave.generate_loader(
test.url,
test.requests,
test.concurrency
).execute
end
end
end
end
LoadSlave側でload_master/test
をrequire
すればLoadSlave::Server#generate_loader
にTest
オブジェクトをそのまま渡すこともできます。
LoadMaster::Runner
負荷試験を実施するクラスです。YAMLの設定ファイルから各Load SlaveのURIを読み込みます。
require "yaml"
require "load_master/slaves"
require "load_master/test"
module LoadMaster
class Runner
attr_reader :test, :slaves_uris
def initialize(url, requests, concurrency, config_file)
@test = Test.new(url, requests, concurrency)
@slaves_uris = YAML.load_file(config_file)
end
def run
p slaves.load(test)
end
private
def slaves
@slaves ||= Slaves.new(slaves_uris)
end
end
end
実行ファイル
パラメータが多いです。
#!/usr/bin/env ruby
if ARGV[0] =~ /-h/
puts "Usage: bin/master url requests concurrency config_file"
exit
end
$LOAD_PATH.unshift File.expand_path("../../lib", __FILE__)
require "load_master/runner"
url, requests, concurrency, config_file = ARGV
LoadMaster::Runner.new(
url,
requests,
concurrency,
config_file
).run
負荷かけてみる
準備
負荷をかける対象のHTTPサーバはあらかじめ用意しておいてください。めんどくさい場合はWEBrickでOKです。
echo "hello" > index.html
ruby -rwebrick -e 'WEBrick::HTTPServer.new(DocumentRoot: "./", Port: 3000).start'
また、Parallel gemが必要なのでインストールしておいてください。
gem install parallel
YAMLの設定ファイルも準備しておきます。今回Load Slavesは2つ起動することにします。
- druby://localhost:55331
- druby://localhost:55332
実施
まずはLoad Slaveを2プロセス起動します。今回はローカルで全て起動しますが、別のサーバで起動しても大丈夫です。
$ bin/slave 55331
Starting LoadSlave server
Waiting for requests
$ bin/slave 55332
Starting LoadSlave server
Waiting for requests
Load Slaveが起動できたら、Load Masterから指示を出して負荷をかけます。
$ bin/master http://localhost:3000/ 1000 2 config/slaves.yml
[4.99813, 5.002736]
負荷をかけ終わると、Load Slaveの方は次のように出力されているはずです。
$ bin/slave 55331
Starting LoadSlave server
Waiting for requests
Execute `/usr/sbin/ab -n 1000 -c 2 http://localhost:3000/`
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Completed 600 requests
Completed 700 requests
Completed 800 requests
Completed 900 requests
Completed 1000 requests
Finished 1000 requests
Finished ab (4.99813s)
おわりに
と、dRubyを使えばめちゃくちゃ簡単に分散負荷試験が可能です!
分散オブジェクトプログラミングが必要になったらぜひ使ってみてください
LindaのRuby実装であるRindaなどを使うとさらに便利になります