分散にも便利 SO_REUSEPORT
Live With Linux::practical
- TOP
- Articles
- Live With Linux
- 分散にも便利 SO_REUSEPORT
序
SO_REUSEPORT
って便利だよという話を聞いたので試してみた。
SO_REUSEPORT
はソケットオプションで、複数のプロセスで同じポートを指定できるようになる。
類似のものにSO_REUSEADDR
があるが、これはTCPポートがTIME_WAIT
状態にあるとき、そのポートを直ちに再利用できるようにするものである。
socat
やnc
,
netcat
で待ち受けていると、使い終わったあと履歴からもう一度実行するとAddress already in use
と言われて拒否された経験がある人も多いだろう。このような状況のときに活躍するオプションである。
対して、SO_REUSEPORT
は同時に複数のプロセスで同一のポートでの待ち受けが可能になる。
複数のサーバープロセスが同一ポートで待ち受けることで分散処理などが可能だ。
fork
このような手法でごく単純なのが、同じServer
Socketを複数プロセスでaccept
するというものだ。
ソケット・ファイルディスクリプタはひとつのプロセスのみがacceptできるわけではない。
acceptするものが特にない場合、accept
はプロセスをブロックし、acceptできるのを待つ。
複数のプロセスがacceptしているのであれば、そのソケットに接続したとき、そのいずれかがacceptでき、そのほかは引き続きブロックされて待ち続ける。
なお、誰もacceptしていない状態でソケットに接続すると、それはそれでブロックされてたまっていく。
これを利用して、ひとつのソケットを作ってからforkすることで、複数プロセスに分散させることができる。 例えば次のRubyコード
require 'socket'
= TCPServer.new(10000)
server
3.times do
fork do
while sock = server.accept
puts $$
sleep 10
end
end
end
Process.waitall
このコードによって3つのプロセスがlocalhost:10000
で待ち受ける。
このため、: | socat -u STDIN TCP-CONNECT:localhost:10000
のようにしてこのソケットに接続すると、次のように複数のプロセスがそれを受け取っていることがわかる。
❯ ruby server.rb
17773
17775
17774
17773
17775
17773
17773
17775
17774
SO_REUSEPORT
これはこれで便利だが、その利用価値は割と限定的だ。
まず、ファイルディスクリプタを共有する必要があるため、起動プロセスとワーカーとの間には(procfsを使うような手法を別とすれば)親子関係が必要で、ワーカープロセスが終了してしまった場合にワーカー数を回復するには起動プロセスを残しておいて、起動プロセスに回復を要求して生成するというようなことが必要だ。 これは状況に応じてワーカーを追加した場合にも同じことが言える。
また、forkによる共有はかなり強い偏りが発生するため、プロセス公平性が必要な場合はあまり有用ではない。
SO_REUSEPORT
は問題をよりシンプルに解決できる。
まずは単純な例である。
socat
にはアドレスオプションとしてreuseport
があり、これを使って簡単に試すことができる。
socat -u TCP-LISTEN:10000,reuseport STDOUT
この待ち受けを複数プロセスで行うことができ、socat -u STDIN TCP-CONNECT:localhost:10000 <<< hello
とすれば、そのいずれかがacceptすることになる。
Rubyの場合、なかなか見つけづらいSocket::Constants
モジュール内にSO_REUSEPORT
が定義されており、これを使うことができる。そもそもBasicSocket#setsockopt
はシンボルを渡せば事足りるため、そんなに難しい話ではない。
むしろ難しいのは、TCPServer#new
はソケットを作ったら直ちにlistenまで行ってしまうため、setsockopt
するチャンスがないということだ。
そのため、こんな感じで自前でTCPサーバーをセットアップする必要がある。
require 'socket'
= Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0)
server .setsockopt :SOCKET, :REUSEPORT, true
server.bind Socket.sockaddr_in 10000, "127.0.0.1"
server.listen 32
server
= server.accept
sock
puts "I'm #$$"
低レベルな操作なので、条件によっては「簡単に使える」とは言いにくいが、Soket(2)を使っていて、ソケットオプションを渡せるならば使える。
この方式はpush型マルチワーカー分散処理を実現するのに便利だ。 例えば次の例ではffmpegを並列で使えるようにする。
#!/bin/zsh
while true
do
socat -u TCP-LISTEN:10000,reuseport STDOUT | read
ffmpeg -i "$REPLY" -c:v libvpx-vp9 -crf 36 -b:v 15000k -c:a opus -b:a 128k ~/Videos/${${REPLY:t}:r}.webm
done
分散処理という観点ではSocketのlisten自体はネットワークを越えないもの1なので、それほど強力なものではなく、どちらかといえば簡易的なものだ。 キューサーバーを置いてpull型にしたほうが最適化の余地も大きく、扱いやすい。
機能的な話をすればタプルスペースなどとは比べようもない。 これはレイヤーが1枚違うので当然の話ではある。 Multimachine Utilsにはタプルスペースを用いた実装によるmultimachines-ff-rindaと、キューサーバーを用いた実装のmultimachines-ff-tcp-vp9があるので参考にしてみてほしい。
SO_REUSEPORT典型的な利用例は、やはりサーバー、特にHTTPサーバーだろう。 マルチワーカーが単に複数プロセスを起動するだけで実現でき、オーバーヘッドもなく実装も容易。
Systemd
これはSystemdと相性がよく、単純に複数の@.service
を用意することでマルチワーカーが実現できる。
また、socket
ユニットにReUsePort=
オプションがあり、コントロールはあまり効かないが、こちらで制御する方法もある。
この記事を書く際、いくつか試したのだが、socket
ユニットを使う方法はシンプルな例ですらうまく動作しなかったので、あまり具体的に示すことができないのだが。
シンプルな例として、実行する中身がcatで、内容をファイルに出力するとしてみよう。まず.socket
ユニットを用意する。
[Unit]
Description=test server
[Socket]
ListenStream=13500
Accept=true
そして.service
ユニットを用意する。
このユニットは.socket
ユニットと同名で、なおかつ@.service
であることを忘れないようにしなくてはいけない。
(忘れてハマることがままある。)
[Unit]
Description=test server
[Service]
ExecStart=/bin/cat
StandardInput=socket
StandardOutput=append:/tmp/testserver.log
Systemdは.socket
ファイルに書かれたソケット(TCPだけでなく、UDPやUNIXSocketも可能)を用意してlistenする。
接続されたら.service
ユニットに引き渡す。Accept=true
である場合、SystemdがそのソケットをAcceptしてから引き渡すため、.service
ユニットから起動されるプログラムは(特にStandardInput=socket
,
StandardOutput=socket
な場合は)単に標準入出力を使うことでソケットでやりとりすることができる。
Accept=false
である場合(デフォルト)、Systemdはacceptせずにソケットを引き渡す。
こちらでは@.service
ではなく.service
を使うため、serviceユニットファイルの名前が変わる(指定もてぎる)ことに注意が必要だ。
まず、.socket
ユニットファイルは次のとおり。
基本的にはAccept=false
が加わっただけで、あまり変わらない。
(後述するようにReusePort=
を指定することも考えられる)
[Unit]
Description=test server
[Socket]
ListenStream=13500
Accept=false
一方、.service
ユニットは変わらない。単に名前が@.service
から.service
に変わるだけだ。
ただし、それによって起動されるプログラムは全く違ったものになる。
ここではRubyで書くことにしようる
Rubyで「外からソケットが渡されてくる」なんて経験をすることはほとんどないが、こんな感じのプログラムになる。 ここではシンプルにソケットをAcceptして、内容を読み、それを出力するというものになっている。
#!/bin/ruby
require 'socket'
= Socket.for_fd(0)
server_socket
= server_socket.accept
sock, addr = sock.read
data STDOUT.puts data
socketユニットは要はinetdのようなものである。
ただ、書く内容が単純になるAccept=true
と比べ、個人的にはAccept=false
なsocketユニットの使いどころがよくわからない。
しかもAccept=false
がデフォルトだし。
さて、Accept=false
である場合、Systemdはlistenしているソケットもろともserviceユニットに引き渡しているため、引き続きlistenすることができない。
ReusePort=true
である場合、SystemdはSO_REUSEPORT
を使って引き続きlistenする。
Accept=true
の場合は一瞬でacceptしてserviceに渡すから、関係なさそうだ。
ただ、intedのようなものだと言っている通り、Systemdは接続を受けるたびにプログラムを起動する。
このようなオーバーヘッドの大きい設計と、Accept=false
でなおかつReusePort=true
であるという複雑な設定は噛み合っていないように見える。
何か非常に有用なケースがあるのかもしれないが、私はちょっと思いつかない。
それと比べるとより単純な方法である、SO_REUSEPORT
を使うサーバープログラムを@.service
ユニットで起動するというものはシンプルに有効性が高い。
なんといってもRestart=
オプションによりワーカーの数を維持できるため、ワーカーの面倒を見るプログラムが全く不要なのだ。
LinuxとBSDの違いについて
LinuxのSO_REUSEPORT
はLinux
3.9において追加されたものであるようだ。
意外と最近である。といってももう10年近く前だが。
一方、他のUnixにも同名のソケットオプションが存在している。
LinuxのSO_REUSEPORT
と*bsdのものが異なるものだと書かれた記事がいくつか散見されたため、実際に確認してみた。
確認方法としては、socat -u TCP-LISTEN:10000,reuseport STDOUT
としたものを複数起動し、socat -u STDIN TCP-CONNECT:localhost:10000
として接続してみるというものだ。
検証に使ったのはFreeBSD 13.1, NetBSD 9.1, OpenBSD 7.0である。
結論としては違いはない。同じ挙動を示した。
SO_REUSEPORT
を使うことでUnixプラットフォーム内の互換性を損なうということは特になさそうだ。
WindowsにもSO_REUSEPORT
が存在しているようだが、こちらはかなり異なったもののように見える。
(もっとも、ソケットプログラミングで、Windowsとの互換性が保てると考えることはあまりないだろう。)
技法の選定と実装のまとめ
単純なfork
Listener Socketを作ってからforkし、forkしたそれぞれのプロセスでacceptする技法は非常にシンプルな実装が魅力であり、簡易的なマルチワーカーサーバーを作りたい場合に有効だ。 分散処理としてはpush型マルチワーカーになり、HTTPサーバーのようなリクエストを待ち受けるタイプのサーバーにも利用可能。
最大の魅力は技術自体がシンプルであることだが、コードは意外と分かりづらく、柔軟性も高くない。 柔軟性を備えるなどより優れたシステムにしようとした場合、他の技術を採用するほうが安上がりで、メリットに乏しい。
また、forkしたプロセスが死んだ場合、ワーカーを復活させるのがなかなか面倒だ。 やり方としては、forkした大元のプロセスがなんらかの形で(普通はまた違うソケットで)通信を受け入れ、それをもとにプロセスを生成するという処理だが、そのような管理プロセスを作るくらいならもっと良い実装方法があるのが現実。
一時的な並列処理でコマンドラインから処理対象を送っていきたいようなケースに適する。
単純なSO_REUSEPORT
単純なforkに代えて、サーバーがSO_REUSEPORT
を有効にしてTCPで待ち受けるというものだ。
そのメリットは、単純なforkよりもより単純明快なソリューションであることだ。 forkによる手法は、プログラム自体はひとつにまとめることができるが、それによって生成されるプロセスは生成元プロセスと、生成されたプロセスの2種類が存在する状態になる。 また手順もあまり直感的ではない。
SO_REUSEPORT
を採用することで、そうしたマルチワーカーということを特に意識しない、単純なサーバーにソケットオプションを追加するだけという非常に簡単なコードでマルチワーカーを実現できる。
死んだプロセスは自動的に復活はしないが、
while true; do server; done
のようにループに入れ込めば復活させられるようになる。
ワーカーを追加するときは同じプログラムを起動すればよく、またワーカーを削除するときはプロセスを停止させれば良い。 コードを書くのも楽で、取り扱いも柔軟となかなか魅力的。
デメリットとしては、SO_REUSEPORT
がnet/ipv4
およびnet/ipv6
に実装されているため、net/unix
に実装されているUnixドメインソケットに対しては適用できない。
シンプルなTCPサーバーのマルチワーカー化への発展、既存プロトコルのサーバーのマルチワーカー化に適する。非常に低コストな手法である。
REUSEPORTとSystemd Serviceの組み合わせ
Systemdの.service
ユニットはプログラムを実行するユニットである。
様々なデーモンプロセス等に利用されるが、serviceユニットファイル名を@.service
の形にすると、同serviceユニットは複数起動が可能になる。
具体的にはsystemctl start server@foo.service
とsystemctl start server@bar.service
は異なるserviceユニットとして扱われるのだが、実際のユニットファイルはserver@.service
のひとつだけである。
この@
に続く文字列により任意の数のserviceユニットを並列起動できる。
serviceユニットは再起動の制御も可能であり、サービスの管理をプログラムに組み込まなくて良いのはメリット。
ただし、単にRestart=always
としただけである場合、プロセスが落ちたときにプロセスが起動できない状態になっていると、Too
Quicklyと言われてしまいユニットの再起動自体に失敗する。
この状態からリカバリすることはできない。
これはStartLimitIntervalSec
(デフォルト10)以内にStartLimitBurst
回数(デフォルト5)以上ユニットを起動しようとしたためであり、デフォルトのRestartSec
は100msなのでどうしても発生する。
通常RestartSec
を伸ばすことで回避できるが、やっていることをきちんと理解しているのであれば、StartLimitIntervalSec
やStartLimitBurst
をいじることも考えられる。
可変のRestartSec
を設定することはできない。
追加のデメリットとしては、Systemd自体がLinuxでしか利用できないことだ。
また、メリットともデメリットとも考えられるのが、運用部分がSystemd Unitに分離される。
サーバーをSystemd管理にするのと同義であり、REUSEPORTによるマルチワーカーサーバーに適した形でユニット化できる。ユニットは必要だが、サーバーの管理機能を外側に追加できる。
Systemd Socket (accept=true)
Systemdのsocketユニットでaccept=true
とし、serviceユニットと組み合わせる方法。
まずsocketユニットとserviceユニット(@.service
形式)を用意する必要がある一方、プログラム側は標準入出力を扱うだけでいいので非常に楽に書けるほか、プログラム自体がサーバーを組み込む必要がないため、コマンドラインプログラムをサーバーでも使うというような応用が効きやすい。
一方、オーバーヘッドが大きく、激しくアクセスされるようなケースには適さない。 また、ワーカー数を固定してそれを越えるものは待たせる、というような書き方が難しいのも難点。
また、完全にLinuxに依存したサーバーになる。
コマンドラインフィルタをネットワーク経由で使えるようにするのに適している。利用が激しい場合や、外部に公開するものには適さない。
Systemd Socket (accept=false)
Systemd Socketでaccept=falseとするのは、アクセスがあったときにサーバープロセスを起動し、Listener Socketを渡すという挙動になる。 socketユニットとserviceユニットを用意する必要があり、プログラムもサーバーとして構成されていなくてはいけない。
現代的に見てメリットは非常に乏しいが、非常にリソースが限られる場合は有効だろう。 「たまにしかアクセスがない」というような状況であれば、サーバーを必要なときだけ起動することでメモリ消費を抑えられる。 非常に緻密なリソース課金をされるクラウドサーバーを使っている場合にも有効だ。
もっとも、accept=true
にするのがはばかられるような量のアクセスがあるのであれば、この構成にするメリットは見出しにくい。
正直、私にはよく分からないやり方だ。
適したケースが見いだせない。
リバースプロキシサーバー
リバースプロキシサーバーはProtocol
Specificなサーバーであり、通常とは異なる方法で待ち受ける複数のワーカーに対し、本来のポートで待ち受けて割り振るのが仕事だ。
例えば、tcp/80
ポートで待ち受けるHTTPリバースプロキシサーバーが、tcp/8080
,
tcp/8081
,
tcp/8082
に振り分けたりする。あるいは、振り分け先のサーバーがプロキシサーバーを前提としているのなら、Unixドメインソケットで待ち受けているかもしれない。
リバースプロキシサーバーを自ら実装することはあまりない。通常は既に存在するものを利用し、特にHTTPサーバーはパフォーマンスなど様々な面で相応に洗練されているため、自前で実装して既存のものより優れたものにすることはなかなか難しい。 既存プロトコルのサーバーはクライアントからの接続を直接そのプロトコルで待ち受ける必要がある関係上、キューサーバーやタプルスペースなどの後側に置くことは困難であり、こうしたプロトコルのサーバーにおいて簡易的なものではなくパフォーマンスを求めるのなら最も有力な選択肢になるだろう。
優れたプロキシサーバー実装が存在する既存プロトコルの公開サーバーにおいて、パフォーマンスを追求したい場合に適している。
キューサーバー
キューサーバーは分散処理の管理サーバーである。
基本的に、複数のリクエストと複数のワーカーの間にある単一のインスタンスである。 その動作は、リクエストをキューに保存し、待機しているワーカーに渡す。 ワーカーに対してはblockingだが、リクエストは直ちに処理して返すのが簡単だ。
ワーカーはpull型で動作する。 自分の処理が終わったらキューサーバーに次のアイテムをリクエストする。 キューサーバーとワーカー間では、ワーカー側がクライアント(connect)になる。
キューサーバーは自前で実装する必要があり、ワーカーもそのキューサーバーを前提とした実装が必要で、さらにリクエストを追加する方法も用意しなければならないため、手間が多い。 一方、その分ワーカーへのリクエストの渡し方や、データの変換など、いくらでも処理を加えることができるのがメリット。他の方式よりもパワフルなバランシングができる。
また、既存のサーバープロトコルだと、リクエストをキューするという構造になっていない場合が多いため、このようなケースではそもそも適用できない。
キューサーバーはリクエストを受け付けるときも、ワーカーが待ち受けるときもconnectであるため、TCPサーバーにすれば簡単にクラスタコンピューティングに拡張できるのもメリット。
並列計算処理のためのマルチワーカーで、ネットワーク分散も視野にあり、大きなデータを受け渡したり、ノードに応じたアイテムの選択など柔軟な機能を求める場合に適している。反面、実装コストが高い。
タプルスペース
タプルスペースはシンプルな操作のみがサポートされたシステムである。
タプルスペース自体があまり有名なものではないが、Rubyユーザーにとっては標準のRindaライブラリがあるため、他の言語よりはまだ馴染みがある。 (トップクラスに使われないライブラリだが。)
動作自体はキューサーバーはほとんど同じだが、自前で実装しなくても簡単にその仕組みが作れるのがメリット。 一方、そのタプルスペースサーバー自体はライブラリによって簡単とはいえ自前で実装し、起動する必要がある。
また、キューサーバーを実装する場合はリクエストを受け付けるときは互換性のあるプロトコル、あるいはシンプルなプロトコルを採用することができるが、タプルスペースの場合はタプルスペースのプロトコルになるため、リクエストする側もそれに合わせたプログラムを必要とする。 複数の処理をひとつのタプルスペースサーバーに入れて「交易」させることができるというメリットはあるが、できるのはあくまでシンプルな操作に限るため、意外と柔軟性もそこまで高くない。
また、キューサーバー実装ではリクエストとして大きなデータを受け取る場合、データ本体はファイルとして保存しておくというのが効くが、タプルスペースでそれをやるためには共有アクセスできる場所にファイルを置いてからその場所を書いてリクエストするクライアントプログラムを書く必要があり、得手不得手が結構ハッキリしている。
高い並列パフォーマンスが求められる分散コンピューティングを実現する上では低コストな解決策だが、並列処理ではなく分散コンピューティングのアタマでないと理解しづらく、効果的な利用も難しい。
タプルスペース/Rindaについては過去記事で解説しているので参考にしてほしい。
並列計算処理のためのマルチワーカーで、ネットワーク分散も視野にあり、キューアイテムに複雑な操作を必要としない場合に適している。より細かいタスクに分割して並列化するのにも向く。
REUSEPORTのまとめ
SO_REUSEPORT
ソケットオプションは、ローカルレベルでシンプルにマルチワーカーモデルを作りたい場合に非常に有用である。
IO比率の高いサーバーでは特に優れた効果を発揮するだろう。
単純に並列実行するためのブロッキングキューを求めている場合はaccept
する前のsocketをfork
で共有するほうがより簡単である。
場合によってはFIFOのほうがもっと簡単かもしれない。
マルチワーカーの目的が計算プロセスの並列化であるならば、計算を効率するためのキューサーバーなどを用意してpull型にしたほうが柔軟性が高く、より優れた効果を発揮する。また、このような実装はキューサーバーをTCPサーバーにするなどの方法で容易にクラスタコンピューティングに発展できるため、その意味でもより適性がある。
SO_REUSEPORT
を用いたサーバーはSystemdの@.service
と組み合わせるとさらに効果を発揮することができる。
SO_REUSEPORT
は確かに非常に便利だが、実際に有用なケースはHTTPサーバーなどかなり限られたものになる。
この有用性の主たるところは、キューサーバーやプロキシサーバーなどを書く必要がなく、それに伴う面倒も負わなくていいところにある。
「同じポートを複数のプロセスで同時に待ち受けて分担できる」というのは、場合によっては非常に直感的かつ手軽で便利な解決策になるだろう。
SO_REUSEPORT
はそれを実現する。