Rubyを書いていると、サーバを書きたくなることがあります。皆さんもそうだと思います。
ということで今日はRubyでスッとサーバを書くためのgem、serverengineの簡単な使い方メモ。
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は一種のフレームワークだが、以下のような点には気をつけなければならない。
- コマンドラインオプションのパーサなどはないので、thorやgliなどを使って綺麗にする必要がある
- ノンブロッキングIOのフレームワークを自分で用意しないといけなくて、Fiberなどを使う、event-machine、cool.io、あるいはcelluloid-ioなどと組み合わせることが推奨される
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でも割とちゃんとしたサーバをまあまあ楽に書くことができそうです。皆様の引き出しにどうぞ。