読者です 読者をやめる 読者になる 読者になる

たごもりすメモ

コードとかその他の話とか。

JRuby+Thorで-Xmx指定したりdaemonizeしたりする

JRuby 1.7.4でのお話。

JRubyでなんらかのサーバプログラムを作ろうと思うと、やることはいろいろあるが、とにかくヒープ容量 -Xmx2g みたいなのを指定できるようにしないとお話にならないし、だいたいデフォルトだと client VM になってしまう。普通のjavaだと -server -Xmx2g だがjrubyではこれを -J-server -J-Xmx2g みたいにして渡す。
が、これはjrubyコマンドのオプションとして渡すため、たとえば rubygem に実行ファイルを含めて配布したいときには普通には指定できない。実行ファイルが起動したとき、そのJVMでは既にヒープ容量が決まってしまっているからだ。
またdaemonizeしたい、というケースでもこれは割と困る。shが使えれば一発だけどrubygemの実行ファイルはすべてrubyスクリプトとして実行されちゃうんだよね……。

なので、解決方法としては以下のようになる。コマンド名を awesome_one としよう。

  1. awesome_one start で起動する
  2. 渡されたオプション(-Xmxなど)をピックアップする
  3. jruby コマンド経由で自分自身を実行(exec)しなおす
    • jvmオプションなどはこのときに jruby コマンドに渡す
    • daemonizeする必要があるならこのとき exec ではなく spawn し、子プロセス側でも対応する処理をやる

ユーザに叩かせたいのはサブコマンド start だが、ここに「実行しなおす」時用のコードも含めて全部突っ込んでしまうとユーザに起動されたときと「実行しなおし」で起動されたときを何らかの方法(環境変数? 隠しオプション?)で区別しないといけないし、コードがたいへんみにくくなる。ので、サブコマンドは分けてしまおう。実行しなおし用は actualstart とでもしておこうか。
つまり 'awesome_one start' -> '(jruby -Xmx..) awesome_one actualstart' のような起動順序の関係になる。

しかし start と actualstart では使えるコマンドラインオプションは共通でなければならない。でないと起動時に Thor に怒られる。のでこれをDRY化する。
いっぽう awesome_one help したときに actualstart の方が見えてしまうとちょっと不恰好だ。隠しておきたい。

ということで、最終的にはこんな感じでコードを書いた。これで rubygem として配布できる thor なdaemonが作れる。
実際には pidfile をコマンドラインオプションで指定できるようにしたり、標準出力のリダイレクト先(awesome_one.out)を指定できるようにしたり、あれやこれや色々やらないといけないけど。まあ骨格としてはこれでいいんじゃないでしょうか。ついでに stop コマンドも付けておいた。

module AwesomeOne
  module CLIUtil
    def self.start_options(klass)
      option :daemonize, :type => :boolean, :default => false
      option :host, :type => :string, :default => '0.0.0.0'
      option :port, :type => :numeric, :default => 10080
      # other options for 'start' and 'actualstart' ...
    end
  end

  class CLI < Thor
    # for start
    CLIUtil.start_options(self) # add options
    option :version, :type => :boolean, :default => false # start specific options here.
    desc "start [options]", "start this awesome server process"
    def start(*options)  # -Xmx などがinvalid optionとしてThorに怒られるのを防ぐためここで任意の引数を受け取れる必要がある
      ARGV.shift # discard "start"  # ARGVには解析済みオプションも含めて入力されたものが全て入ってる、サブコマンドも
      next_argv = ["actualstart"]
      jruby_options = ["-J-server"]
      ARGV.each do |arg|
        if arg =~ /^(-X.*)$/
          jruby_options.push "-J#{$1}"
        else
          next_argv.push arg
        end
      end

      # this file is "lib/awesome_one/cli.rb" and bin file is "bin/awesome_one"
      binpath = File.expand_path(File.join(File.dirname(__FILE__), "..", "..", "bin", "awesome_one"))
      args = jruby_options + [binpath] + next_argv

      if options[:daemonize]
        out_file_path = '/tmp/awesome_one.out' # to check child process execution
        File.open(out_file_path, 'w'){|file| file.write "write from parent process"}

        pid_file_path = '/tmp/awesome_one.pid'
        pid_file = File.open(pid_file_path, 'w')
        pid = spawn("jruby", *args, :pgroup => 0)
        pid_file.write pid.to_s
        pid_file.close

        # wait until child process successfully executed
        waiting_child = true
        while waiting_child && sleep(1)
          out_file_text = File.open(out_file_path){|f| f.readline}
          waiting_child = false if out_file_text =~ /working on #{pid}/
        end
        Process.detach(pid)
      else
        exec('jruby', *args)
      end
    end

    # for actualstart
    CLIUtil.start_options(self) # add options as same as 'start'
    desc "actualstart [options]", "hidden subcommand", :hide => true # hide in help
    def actualstart
      if options[:daemonize]
        Dir.chdir("/")
        out_file_path = '/tmp/awesome_one.out'
        STDIN.reopen("/dev/null")
        out_file = File.open(out_file_path, 'w')
        STDOUT.reopen(out_file)
        STDERR.reopen(out_file)
        puts "working on #{$PID}"
      end

      # ... actual server process code ...
    end

    desc "stop", "stop daemonized process"
    def stop
      unless test(?r, "/tmp/awesome_one.pid")
        puts "Cannot find pid file"
        exit(1)
      end
      pid = File.open("/tmp/awesome_one.pid"){|f| f.read}.to_i
      timeout = Time.now + 5 # magic!
      waiting = true
      Process.kill(:TERM, pid)
      begin
        while waiting && Time.now < timeout
          sleep(0.5)
          status = Process.waitpid(pid, Process::WNOHANG)
          if status
            waiting = false
          end
        end
      rescue Errno::ECHILD
        waiting = false
      end
      if waiting
        puts "Faild to stop server #{pid}"
        exit(1)
      else
        File.unlink("/tmp/awesome_one.pid")
      end
    end
  end
end

これで、JRubyでも、rubygems.orgで配れる、JVMのオプションも指定できてdaemonizeもできるサーバプログラムが書ける。
べんり!