systemdのsocketを使ってserverを作る


論よりコード

TCP localhost:18080 で待ち受けて、 implementation.bash を実行した結果を journal に書き込む例です。

※ systemd は一般ユーザ権限でも利用可能です。今回は一般ユーザ権限で行いました。
その際、systemd 関連のファイルは $HOME/.config/systemd/user/ に配置する必要があります。ない場合は mkdir -p $HOME/.config/systemd/user/ として作ってからファイルを配置してください。
→ 実は systemctl --user enable $PWD/server.socket と、パスを指定すれば自動的にディレクトリを作ってくれてsymlinkでファイルを配置してくれることがわかりました。

server.socket ファイル

server.socket ファイルはSocket を作成して待ち受けをし、クライアントから接続を受け付けたら [email protected] を呼び出します。 (man systemd.socket)

[email protected] というファイル名は Service = で指定可能です。指定しない場合は .socket のファイル名から導出されます(今回の例だと server)。

このファイルは systemctl --user enable コマンドで自動的に $HOME/.config/systemd/user/ に配置されます。

server.socket
[Unit]
Description = Simple TCP server (listener)

[Socket]
ListenStream = 127.0.0.1:18080
Accept = yes

[Install]
WantedBy=sockets.target

[email protected] ファイル

ファイル名の @ はテンプレートユニットであることの宣言となります。 **.socket からの呼び出し毎に fork するような形で実体が作られて実行されます。

このファイルは systemctl --user enable コマンドで自動的に $HOME/.config/systemd/user/ に配置されます。

[Unit]
Description = Simple TCP server (implementation)

[Service]
Environment = LANG=C
ExecStart = /bin/bash /home/USER/implementation.bash
StandardInput = socket
StandardOutput = journal
StandardError = journal

[Install]
WantedBy = server.socket

[Install]WantedBy = は、呼び出し元である server.socket を指定します。

StandardInput は、 implementation.bash への標準入力のソースを指定しています。
socket となっていれば **.socket で受け付けた接続の入力を STDIN として渡すことができます。

StandardOutput および StandardErrorimplementation.bash の出力をどこに返すかという指定です。
socket となっていれば **.socket で受け付けた接続に返します。 journal となっていれば journalctl で見ることができます。
その他出力先の指定は systemd.exec をご覧ください。複数同時は指定できないようです。

実装部

今回は bash で実装しました。socketからの入力は **.serviceStandardInput を経由して標準入力に渡されますので read で読み込むことができます。

このファイルは [email protected]ExecStart で指定されています。

$HOME/implementation.bash
#!/bin/bash

read INPUT
echo "Your input is $INPUT"
echo -n "Today is "
date
exit 0

systemd へ登録する

3つのファイルの配置が終わったら systemctl を使って systemd に登録します。start するのは .socket のみです。

$ systemctl --user enable $PWD/[email protected]
$ systemctl --user enable $PWD/server.socket
$ systemctl --user start server.socket

journalctl で確認してみます。

$ journalctl -n 1
-- Logs begin at Thu 2019-11-07 21:17:45 JST, end at Fri 2019-11-08 00:00:30 JST. --
Nov 08 00:00:30 raspberrypi systemd[506]: Listening on Simple TCP server (listener).

テスト

$ echo "hello!" | nc 127.0.0.1 18080
$ journalctl -n 4
-- Logs begin at Thu 2019-11-07 21:17:45 JST, end at Fri 2019-11-08 00:01:39 JST. --
Nov 08 00:01:39 raspberrypi systemd[506]: Started Simple TCP server (implementation) (127.0.0.1:50772).
Nov 08 00:01:39 raspberrypi bash[8284]: Your input is hello!
Nov 08 00:01:39 raspberrypi bash[8284]: Today is Fri Nov  8 00:01:39 JST 2019
Nov 08 00:01:39 raspberrypi systemd[506]: [email protected]:18080-127.0.0.1:50772.service: Succeeded.

[email protected] や実装部の変更の反映

[email protected]server.socket からの新規接続のタイミングで都度読み込まれます。そのため [email protected] の変更は次回呼び出しから即適用されます。実装部も同様です。

ここから先はどうでもいい話です。

サーバの実装方法について

TCP/UDPやUnix Domain Socketといった待ち受けをするサーバを構築する方法は、概ね以下の通りです。

  • listen(2) を利用
    • 待ち受け部、実装部を包括して実現する自己完結モデル
  • inetd(8) を利用
    • 待ち受け部を inetd が担当し、実装部は STDIN/STDOUT を介して実行されるプログラムとなる分業モデル

今回の systemd によるサーバの実現は inetd(8) のモダン実装ということになります。

listen(2) を利用する場合

たとえば Ruby で書く場合はこんな感じです。極力 implementation.bash と合わせてみました。

require "socket"
server = TCPServer.open("127.0.0.1", 18080)
while true
  socket = server.accept
  Thread.start(socket) do |s|
    while buffer = socket.gets
      socket.puts "Your input is #{buffer}"
      socket.puts "Today is #{Time.now}"
    end
    socket.close
  end
end
server.close

考察

listen(2) を利用する場合は何から何まで自力実装が可能であるため、細かい実装やケイパビリティ、速度面でのメリットが得られやすい。その代わり「実装しなければならない」という問題もある。

今回の systemd.socket を利用する場合は、例えば MaxConnectionsPerSource といった設定があるため、それを利用するだけで良いが、どの程度受けられるかは未知数であるため、要検証。

どのような時に必要となるの?

いわゆるプロセス間通信を容易に実現する方法ということになります。これにより、「最初は定時起動する要件だけだったけど、他からの割込みで起動する要件を追加したい」に対しても容易に増やすことできます。まさにマイクロサービス的なことが可能となるわけです。

あとがき

ファイルが多いのが難点だけど、慣れると結構便利。
一番のポイントは、全部ファイル名を同じにして、拡張子だけが異なる状態が管理しやすい。

さて、、、仕事するか。

EoT