Chienomi

分散にも便利 SO_REUSEPORT

Live With Linux::practical

SO_REUSEPORT って便利だよという話を聞いたので試してみた。

SO_REUSEPORTはソケットオプションで、複数のプロセスで同じポートを指定できるようになる。

類似のものにSO_REUSEADDRがあるが、これはTCPポートがTIME_WAIT状態にあるとき、そのポートを直ちに再利用できるようにするものである。 socatnc, netcatで待ち受けていると、使い終わったあと履歴からもう一度実行するとAddress already in useと言われて拒否された経験がある人も多いだろう。このような状況のときに活躍するオプションである。

対して、SO_REUSEPORTは同時に複数のプロセスで同一のポートでの待ち受けが可能になる。 複数のサーバープロセスが同一ポートで待ち受けることで分散処理などが可能だ。

fork

このような手法でごく単純なのが、同じServer Socketを複数プロセスでacceptするというものだ。

ソケット・ファイルディスクリプタはひとつのプロセスのみがacceptできるわけではない。 acceptするものが特にない場合、acceptはプロセスをブロックし、acceptできるのを待つ。 複数のプロセスがacceptしているのであれば、そのソケットに接続したとき、そのいずれかがacceptでき、そのほかは引き続きブロックされて待ち続ける。

なお、誰もacceptしていない状態でソケットに接続すると、それはそれでブロックされてたまっていく。

これを利用して、ひとつのソケットを作ってからforkすることで、複数プロセスに分散させることができる。 例えば次のRubyコード

require 'socket'

server = TCPServer.new(10000)

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'

server = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0)
server.setsockopt , , true
server.bind Socket.sockaddr_in 10000, "127.0.0.1"
server.listen 32

sock = server.accept

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'

server_socket = Socket.for_fd(0)

sock, addr = server_socket.accept
data = sock.read
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_REUSEPORTnet/ipv4およびnet/ipv6に実装されているため、net/unixに実装されているUnixドメインソケットに対しては適用できない。

シンプルなTCPサーバーのマルチワーカー化への発展、既存プロトコルのサーバーのマルチワーカー化に適する。非常に低コストな手法である。

REUSEPORTとSystemd Serviceの組み合わせ

Systemdの.serviceユニットはプログラムを実行するユニットである。

様々なデーモンプロセス等に利用されるが、serviceユニットファイル名を@.serviceの形にすると、同serviceユニットは複数起動が可能になる。 具体的にはsystemctl start server@foo.servicesystemctl start server@bar.serviceは異なるserviceユニットとして扱われるのだが、実際のユニットファイルはserver@.serviceのひとつだけである。 この@に続く文字列により任意の数のserviceユニットを並列起動できる。

serviceユニットは再起動の制御も可能であり、サービスの管理をプログラムに組み込まなくて良いのはメリット。 ただし、単にRestart=alwaysとしただけである場合、プロセスが落ちたときにプロセスが起動できない状態になっていると、Too Quicklyと言われてしまいユニットの再起動自体に失敗する。 この状態からリカバリすることはできない。

これはStartLimitIntervalSec(デフォルト10)以内にStartLimitBurst回数(デフォルト5)以上ユニットを起動しようとしたためであり、デフォルトのRestartSecは100msなのでどうしても発生する。 通常RestartSecを伸ばすことで回避できるが、やっていることをきちんと理解しているのであれば、StartLimitIntervalSecStartLimitBurstをいじることも考えられる。 可変の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はそれを実現する。