ローファイ日記

出てくるコード片、ぼくが書いたものは断りがない場合 MIT License としています http://udzura.mit-license.org/

serverengine を使い、Rubyでもサーバーを書こう

Rubyを書いていると、サーバを書きたくなることがあります。皆さんもそうだと思います。

ということで今日はRubyでスッとサーバを書くためのgem、serverengineの簡単な使い方メモ。

github.com

Rubyでサーバを書きたくなった時

そもそも的に、Rubyでただサーバを書くのは非常に簡単である。具体的には Kernel#loop などを回してその中でリクエストを待ったり、何かしら処理を行えば終わり。特別なgemは必要ないし、TCPを扱うクラスなども組み込みで用意されている。

以下のような9行のスクリプトを起動すれば、サーバを書いたと言える。ところで TCPServer#accept_nonblockでないと、acceptでブロックしてしまって終了処理が遅れたりするのでノンブロッキングの方のAPIを好んで使うのがいいだろう。

require 'socket'
server = TCPServer.new(5310)
loop do
  c = server.accept_nonblock(exception: false)
  if c.is_a?(IO)
    c.puts("Hello")
    c.close
  end
end
$ telnet localhost 5310
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hello
Connection closed by foreign host.

「ちゃんとした」サーバを書く

とはいえこの簡素なサーバをプロダクションの何かに投入するのは不安があるだろう。具体的には運用面。きょうびのサーバーは、以下のような機能を実装していてほしい。

  • ログを吐いてほしい
  • pid fileを吐いてほしい
  • SIGINT、SIGTERMなどを正しく扱ってほしい。Interrupt例外とか生で出て欲しくない
  • 何ならSIGUSR2あたりを受け取ったら設定のリロードをしてほしい
  • prefork型のワーカをたくさん立ち上げて処理を負荷分散してほしい。Rubyなこともあり、マルチプロセスがいい
  • プロセスが ruby hoge.rb だとカッコ良くないので、かっこいい名前をpsコマンドで出したい(?)

これらをRubyで一から実装していくのはなかなか手間であるが、serverengine gemを使えばこれらの機能はそのままパッケージングされている。

簡単な利用例。先ほどのHelloを出力するだけのサーバをserverengineベースで書き直す。

require 'serverengine'

module MyWorker
  def run
    server = TCPServer.new(5310)
    until @stop
      c = server.accept_nonblock(exception: false)
      if c.is_a?(IO)
        c.puts("Hello")
        c.close
      end
    end
  end

  def stop
    @stop = true
  end
end

se = ServerEngine.create(nil, MyWorker, {
  daemonize: true,
  log: 'myserver.log',
  pid_path: 'myserver.pid'
})
se.run

コアの部分は一緒なことが分かるかと思う。一緒に以下の内容もやってくれている。

  • ログが出る
  • pid fileが出る
  • デーモン化する(systemdに飼ってもらう場合などはしなくてもOK)

ここで、serverengineは一種のフレームワークだが、以下のような点には気をつけなければならない。

serverengineのカバーする範囲は、あくまでもサーバとしての機能の提供であるということで、逆に言うと影響部分は非常に限定的なので、他の箇所は通常のRubyプログラミングの感覚で書いていけば良い。

ワーカサーバを書く

serverengineは幾つかのワーカモデルに対応しているが、Rubyのサーバでマルチコアをちゃんと使いたい人は多いだろうので、 worker_type: "process" の設定はよく使うことになるのではないかと考える。

上にサーバがいて、その下にワーカーがいる場合のコードを書く。と言ってもワーカー1つの場合、ワーカーの上のモジュールを定義するのと、幾つか設定を変更するだけでOK。ついでに worker_process_name と言うオプションでプロセスの名前も変えられて便利。

require 'serverengine'

module MyServer
  def before_run
    @sock = TCPServer.new(5310)
  end

  attr_reader :sock
end

module MyWorker
  def run
    until @stop
      c = server.sock.accept_nonblock(exception: false)
      if c.is_a?(IO)
        c.puts("Hello")
        c.close
      end
    end
  end

  def stop
    @stop = true
  end
end

se = ServerEngine.create(MyServer, MyWorker, {
  daemonize: true,
  log: 'myserver.log',
  pid_path: 'myserver.pid',
  worker_type: 'process',
  workers: 1,
  worker_process_name: "test-worker"
})
se.run

立ち上げるとこうなる。

ruby tmp/sample3.rb
 \_ test-worker

マルチプロセスなワーカサーバを書く

複数のワーカープロセスがいる場合、便利なユーティリティがあり、具体的には SocketManager と言うものを使う。

普通複数のプロセスで一つのポートをリッスンすることはできない(正確には SO_REUSEPORT と言うものがあるのでそっちを使うこともできるが、使えない環境でSocketManagerはよしなにしてくれる)。SocketManagerを使うことで、あるポートへのアクセスを上手にワーカーに振り分けてくれる。

require 'serverengine'

module MyServer
  def before_run
    @socket_manager_path = ServerEngine::SocketManager::Server.generate_path
    @socket_manager_server = ServerEngine::SocketManager::Server.open(@socket_manager_path)
  end

  def after_run
    @socket_manager_server.close
  end

  attr_reader :socket_manager_path
end

module MyWorker
  def initialize
    @socket_manager = ServerEngine::SocketManager::Client.new(server.socket_manager_path)
  end

  def run
    lsock = @socket_manager.listen_tcp('0.0.0.0', 5310)
    until @stop
      c = lsock.accept_nonblock(exception: false)
      if c.is_a?(IO)
        logger.info("Accept request @ worker-id=#{worker_id}")
        c.puts("Hello")
        c.close
      end
    end
  end

  def stop
    @stop = true
  end
end

se = ServerEngine.create(MyServer, MyWorker, {
  daemonize: true,
  log: 'myserver.log',
  pid_path: 'myserver.pid',
  worker_type: 'process',
  workers: 4,
  worker_process_name: "test-worker"
})
se.run

プロセスはこうなる。

ruby tmp/sample3-5.rb
 \_ test-worker
 \_ test-worker
 \_ test-worker
 \_ test-worker

ログを見ればわかる通り、アクセスはランダムに振り分けられている。

I, [2018-04-24T09:58:43.618371 #13950]  INFO -- : Accept request @ worker-id=2     
I, [2018-04-24T09:58:43.633567 #13954]  INFO -- : Accept request @ worker-id=3     
I, [2018-04-24T09:58:43.648199 #13950]  INFO -- : Accept request @ worker-id=2     
I, [2018-04-24T09:58:43.659586 #13950]  INFO -- : Accept request @ worker-id=2     
I, [2018-04-24T09:58:43.673359 #13942]  INFO -- : Accept request @ worker-id=1     
I, [2018-04-24T09:58:43.686264 #13940]  INFO -- : Accept request @ worker-id=0     
I, [2018-04-24T09:58:43.696595 #13942]  INFO -- : Accept request @ worker-id=1     
I, [2018-04-24T09:58:43.713487 #13950]  INFO -- : Accept request @ worker-id=2     
I, [2018-04-24T09:58:43.731814 #13940]  INFO -- : Accept request @ worker-id=0     
...

リロードを試す

ナウなサーバなら何かしらのシグナルを受け取って設定をオンメモリでリロードしてほしい、みんなもそう思うだろう。serverengineであればスッと書ける。

今回はリロードのシグナルを受け取るごとに、リロード回数をカウントアップする実装をやってみる。もちろん、 reload の中で設定ファイルを読み直せば、動的な設定リロードの実装になる(し、その実装はRubyistなら簡単であろう)。

require 'serverengine'

module MyServer
  def before_run
    @sock = TCPServer.new(5310)
  end

  attr_reader :sock
end

module MyWorker
  def initialize
    @reload_count = 0
  end

  def run
    until @stop
      c = server.sock.accept_nonblock(exception: false)
      if c.is_a?(IO)
        c.puts("Hello, reload count: #{@reload_count}")
        c.close
      end
    end
  end

  def reload
    @reload_count += 1
  end

  def stop
    @stop = true
  end
end

se = ServerEngine.create(MyServer, MyWorker, {
  daemonize: true,
  log: 'myserver.log',
  pid_path: 'myserver.pid',
  worker_type: 'process',
  workers: 1,
  worker_process_name: "test-worker"
})
se.run

このサーバを起動し、workerの根元のプロセス(pid fileに書かれたpidを持っているプロセス)に SIGUSR2 を送りつけると、ワーカー側の reload の処理が走る。

$ telnet localhost 5310                                                                                                                        
Trying 127.0.0.1...                      
Connected to localhost.                  
Escape character is '^]'.                
Hello, reload count: 0                   
Connection closed by foreign host.       

# 別のターミナルからSIGUSR2を送る
$ telnet localhost 5310                                     
Trying 127.0.0.1...                      
Connected to localhost.                  
Escape character is '^]'.                
Hello, reload count: 1                   
Connection closed by foreign host.       

# さらに何度か送る
$ telnet localhost 5310                                     
Trying 127.0.0.1...                      
Connected to localhost.                  
Escape character is '^]'.                
Hello, reload count: 3                   
Connection closed by foreign host. 

リロードがちゃんと動いていることを確認できた。

他にも、serverengineはLive restartなどにも対応している。

まとめと所感

serverengine を試し、いくつか実際に使われそうな機能を試した。

再度になるが serverengine はあくまでサーバとしての機能をパッケージしたものなので、ノンブロッキングのライブラリの使い方などには習熟していた方が使いこなせるであろう。とはいえ、実際書くとなると面倒なマルチプロセスの管理、リロードなんかを自分で書かずに済むメリットは大きいと思った。

広く使われているFluentdの デーモンのコア部分はserverengine である。実績という点でも十分だろう。

serverengine 本体は依存ライブラリがとても少なく、Rubyのコア機能中心で実装されているのも好き。


ということでRubyでも割とちゃんとしたサーバをまあまあ楽に書くことができそうです。皆様の引き出しにどうぞ。