kenju's blog

About Programming and Mathematics

Run Multiple gRPC Load Testing using ghz

先日、gRPC server の負荷試験に、ghz が使えるという旨の記事を書きました。

ghz の欠点は、複数の RPC を呼び出したいときに、複数の ghz binary を動かす必要がある点です。例えば、3 RPC が同時にそれぞれの推定負荷値でアクセスしてきたときのパフォーマンスを測定したいとすると、それぞれの推定負荷値に合わせて ghz にオプションを渡して起動し、background で別プロセスとかで動かす必要があります。

柔軟に推定負荷値を変更させながら、気軽にスケールアウトもできる負荷試験を行いたかったので、Ruby で Thread を起動し ghz を動かす wrapper scripts を書きました。

設計

設計としては、

  • 負荷をかける minion (Thread)
  • 複数の minions を管理する master

に切り分けました。RPC ごとに class Minion を継承した minon を作成し、別スレッドで起動させます。Master は、全 minions の終了を待って、レポーティング結果をファイルに出力してプロセスを終了します。

負荷サーバーに Ruby のランタイムが必要な点が欠点ではあります。

Implementation

#!/usr/bin/ruby

require "fileutils"
require "optparse"

class Minion
  attr_reader :outdir, :options
  def initialize(outdir:, options:)
    @outdir = outdir
    @options = options
  end

  protected

  def run_command(options)
    command_str = build_command_str(options)
    puts "[DEBUG] executing '#{command_str}'"
    result = system(command_str)
    result
  end

  def command
    "ghz"
  end

  def host
    if options[:env] == "production"
      "***.***.***.***:5050"
    else
      "127.0.0.1:50051"
    end
  end

  def proto
    "protobuf-definitions/v1/hello.proto"
   end

  def package
    "services.v1.Hello"
   end

  def metadata
    "testdata/metadata.json"
  end

  def request_count
    10_000
  end

  # QPS(= Query Per Second)
  def rate_limit
    1_000
  end

  private 

  def build_command_str(options)
    common_opts = [
      command,
      "-proto #{proto}",
      "-n #{request_count}",
      "-q #{rate_limit}",
      "-M #{metadata}",
      "-insecure",
    ]

    [common_opts, options, host].flatten.join("\s")
  end
end

class HelloMinion < Minion
  def run
    options = [
      "-call #{package}.Hello",
      "-D testdata/hello.json",
      "-o #{File.join(outdir, 'hello.log')}",
    ]
    run_command(options)
  end
end

class Master
  def initialize(options)
    @options = options
  end

  def run
    setup
    load_test
    cleanup
  end

  private

  def outdir
    "log"
  end

  def setup
    puts "[INFO] rm #{outdir}/*.log..."
    FileUtils.rm(Dir.glob("#{outdir}/*.log"))

    puts "[INFO] mkdir #{outdir}..."
    FileUtils.mkdir_p(outdir)

    puts "[INFO] starting load testing..."
  end

  def load_test
    minions = [
      HelloMinion,
    ]
    threads = in_parallel(minions) {|minion| minion.run }
    threads.each(&:join)
  end

  def cleanup
    puts "[INFO] load test log is emmitted at log/"
  end

  def in_parallel(minions, &block)
    minions
      .map {|minion| minion.new(outdir: outdir, options: @options) }
      .map {|minion|
        Thread.new { block.call(minion) }
      }
  end
end

class CLIOptionParser
  def self.parse
    options = {}
    OptionParser.new do |opts|
      opts.banner = "Usage: run-bench-parallel [options]"

      options = {
        env: 'development',
      }

      opts.on('-e', '--env VALUE', "environment value (default: #{options[:string]})") {|v|
        options[:env] = v
      }
    end.parse!
    options
  end
end

options = CLIOptionParser.parse
Master.new(options).run

Usage

Development/Production の環境ごとにホストを分けたかったので、optparse で渡せるようにしています。

development:

$ bin/run-bench-parallel --env development

production:

$ bin/run-bench-parallel --env production