Help us understand the problem. What is going on with this article?

dRubyで分散負荷試験

More than 3 years have passed since last update.

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サーバのコードは次のようになります。
(サーバ側ではリモートオブジェクトとして提供するオブジェクトをフロントオブジェクトといいます)
irbpryを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のようなものです。

分散オブジェクト

次のように任意のクラスのオブジェクトをリモートオブジェクトとして提供することが可能です。

server.rb
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::DRbUndumpedincludeすることでマーシャリング不可能にできます。

server.rb
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::LoaderLoadSlave::RunnerLoadSlave::Serverの3つです。
それぞれのコードを紹介します。

LoadSlave::Loader

実際にApache Benchで負荷をかけるクラスです。このクラスのインスタンスがリモートオブジェクトになります。
Load MasterがLoadSlave::Loader#executeを実行することになります。
負荷試験では途中経過の状態を持つこともあるので、各試験ごとでインスタンスを使い捨てることができるように設計してあります。

lib/load_slave/loader.rb
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のリモートオブジェクトを取得します。

lib/load_slave/server.rb
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サーバーを起動するクラスです。ホストとポートを指定できます。

lib/load_slave/runner.rb
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

実行ファイル

実行ファイルです。

bin/slave
#!/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::RunnerLoadMaster::SlavesLoadMaster::Testの3つです。

LoadMaster::Test

1つの負荷試験を表すクラスです。試験の条件を持ちます。

lib/load_master/test.rb
Test = Struct.new(:url, :requests, :concurrency)

LoadMaster::Slaves

Load Slaveのリモートオブジェクトを取得して、指示を出すクラスです。
Parallelを使って全Slaveに同時3に指示を出します。

lib/load_master/slaves.rb
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/testrequireすればLoadSlave::Server#generate_loaderTestオブジェクトをそのまま渡すこともできます。

LoadMaster::Runner

負荷試験を実施するクラスです。YAMLの設定ファイルから各Load SlaveのURIを読み込みます。

lib/load_master/runner.rb
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

実行ファイル

パラメータが多いです。

bin/master
#!/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つ起動することにします。

config/slaves.yml
- druby://localhost:55331
- druby://localhost:55332

実施

まずはLoad Slaveを2プロセス起動します。今回はローカルで全て起動しますが、別のサーバで起動しても大丈夫です。

LoadSlave(1)
$ bin/slave 55331
Starting LoadSlave server
Waiting for requests
LoadSlave(2)
$ bin/slave 55332
Starting LoadSlave server
Waiting for requests

Load Slaveが起動できたら、Load Masterから指示を出して負荷をかけます。

LoadMaster
$ bin/master http://localhost:3000/ 1000 2 config/slaves.yml
[4.99813, 5.002736]

負荷をかけ終わると、Load Slaveの方は次のように出力されているはずです。

LoadSlave(1)
$ 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を使えばめちゃくちゃ簡単に分散負荷試験が可能です! :laughing::sparkles:
分散オブジェクトプログラミングが必要になったらぜひ使ってみてください :bulb:

LindaのRuby実装であるRindaなどを使うとさらに便利になります:wink:

参考リンク


  1. http://docs.ruby-lang.org/ja/2.2.0/library/drb.html 

  2. RabbitMQの性能上限に届く前に負荷をかけるクライアントの上限が来てしまう 

  3. 厳密に同時ではないです 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away