Perl デーモン・プログラミング

misima 漢詩音韻分析仕様を整理したついでに,そのサーバ misimakansiserver の Perl プログラム構造についてメモを残しておく。これはいわゆるデーモン daemon (ギリシア神話のダイモーン=守護神に由来する) である。デーモンはサーバソフトウェア構造のなかでも古典的な形態のひとつである。システム開始時に起動され,システムが停止するまでメモリに常駐して,トリガー発生に応じて仕事をし,それ以外は眠りながら,動き続ける。sendmail (メール配送),apache (HTTP コンテンツ提供) など UNIX 系システムの基本的サービスソフトウェアはたいていデーモン構造を有するプログラムである。要するに,今日は Perl によるデーモン作成のメモ。

標準的デーモン

一般的なサーバソフトウェアは,起動したらクライアントからの要求を受け付けるソケットを開設し,通信実務処理用の子プロセスを複数生成し,並列で要求を処理させ,要求のないときはソケットをリスンしてウェイトしている,というものだろう。デーモンとして稼働するには,ここでいくつかお決まりの振舞いをしなければならない。デーモンはメモリに常駐しずっと動き続けるため,メモリリークなどの不具合があるとシステム全体に悪影響を与えてしまうリスクがある。Daemon(守護神)にも Damon(悪魔)にもなる,などとうまいことを言う人がいた。

とくに,起動する端末等の親プロセスとの関係を完全に断ち切ることがデーモンとして必要である。子プロセスは親とファイル等のリソースを共有し,子がリソースを掴んだまま終了しないと,親はリソースを解放できずずっと待ちぼうけを喰らわされてしまったりする。以下にデーモンとして行うべき標準的段取りを示す。これらは Perl 言語に限った要件ではなく,どんな言語で書こうが同じである。もともと UNIX C 言語で研ぎ澄まされたのは言うまでもない。

非プロセスグループリーダのプロセスを生成する
自分 (daemon) を起動した端末プロセス(親プロセス)との関係を断つために,子プロセス(己のコピー)を生成して,自分は終了する。以降は生成された子プロセスが daemon となるための支度をする。この子はこの時点では親のプロセスグループに従属している。
プロセスグループリーダとなって,自分を制御端末から切り離す
setsid システムコールを発行し,自身がプロセスグループリーダとなる。これにより端末(親の親)から独立することになる。
カレント・ディレクトリをルートに移動する
プログラムは起動したときのディレクトリをカレント・ディレクトリとしてリソースの一部とする。プログラムが当該ディレクトリをファイルの読み書き等で掴んだままだと,システムが停止するときそのディレクトリを擁するファイルシステムのアンマウントができなくなる可能性がある。そのため chdir('/') を発行してカレント・ディレクトリをルートに変更しておく。
umask を設定し,端末ユーザのマスク引継ぎをリセットする
子はリソースを親から継承する。ファイルの umask (ファイルを作成するとき権限をオフにするマスクビットパターン。たとえば八進数 o022umask とすると,o666 の書込権限ビット部がオフの権限で — o644: グループユーザ,他ユーザ読出可・書込不可で — ファイルが作成される) も同様である。親の umask によっては子プロセスのファイル操作に差し障りが出る可能性があるため,改めて umask システムコールで設定する。
STDIN, STDOUT, STDERR 他すべてのファイルをクローズする
親から引き継いだファイルをクローズする。特に端末に結びついた STDIN, STDOUT, STDERR を閉じておかないとプログラムの出力が予期せず端末に出てしまったり,親が停止できなくなったりする可能性がある。
STDIN を /dev/null に,STDOUT, STDERR をログファイルに接続する
一度クローズした標準出力をログファイルに繋ぎ変えておく。
シグナルの取扱いを指定する
PIPE, ALRM などシグナルを受けたときの処理を記述する。サーバの実行目的によってシグナルの扱いを決める。SIGPIPE は,子プロセスがクライアントとのソケット入出力の途中でエラーが出たりすると発行されるもので,ネットワークプログラムでは頻繁に発生するため,これを無視するようにしておかないと,親のマネージャプロセスまで終了させられてしまう。

必ずしもこれらすべてが必須というわけではない。当然システム設計の課題に応じて何が必要かは決定される。とはいえ,このうち,プロセス生成と親の終了,制御端末からの分離,ファイルクローズ,シグナルへの配慮は最低限の段取りではなかろうか。

この一連の手続きを経たのちに,さらに複数の子プロセスを起動してこの子たちにサーバの目的である処理を行わせるわけである。端末から子プロセスとして起動され,自分も子プロセスを生み,その子プロセスに続きをさせて自分は死に,子プロセスはマネージャとなってたくさんの子を生んで仕事をする。プログラムを書いていると,いま書いているコードを実行するのがどの子なのかクラクラして来ることがある。

上記のデーモンの振舞いはお約束のようになっていて,Perl でもこの一連の約束事を肩代わりしてくれる様々なデーモン構築モジュールが公開されている。Proc::DaemonApp::Daemon といったモジュールである。気に入ったものを択んで用いれば,より簡便にデーモンを構築できるだろう。

実際の仕事をする子プロセスの生成と管理のため,misimakansiserver では Parallel::Prefork モジュールを使っている。これは日本人プログラマによる CPAN コントリビューションのようである。実務を行うワーカープロセスのプレフォーク(要求が来てからではなくあらかじめ子プロセスを起動しておくこと。プロセス生成は計算コストを要するため,クライアント要求の前にプロセス起動が完了しているとクライアントからみたパフォーマンスとして有効である),シグナル処理,子プロセスの終了待受け・後始末などのコードを簡易に書くことが出来る。

デーモンのサンプルコード

デーモンのサンプルコード daemontest を掲げておく。デーモンとして起動し,実務処理を行うプロセスを 5 プレフォークし,クライアントから受信したデータの長さをクライアントに返すだけの,他愛のないサーバである。デーモンのモデルとしては一通り実現していると思う。引数に -t を指定して起動すると,稼働中の daemontest を停止する。cpan -i Parallell::Prefork として,Parallel::Prefork モジュールをあらかじめインストールしておく。

ホームディレクトリ直下に実行ログ・ファイル daemontest.log 及び PID ファイル daemontest.pid が作成される。PID ファイルに書き出されるのはマネージャプロセスのプロセス ID であり,これに対し SIGTERM を送るとワーカープロセスともどもサーバは正常終了する(これを Graceful shutdown ということがある)。

サーバとして目的とする処理を行うのは「実務処理」とコメントした部分である。中核は「クライアント要求処理」とコメントを付した while ループ。ソケットから読んだデータを処理する独自ルーチンを書けばよい。

#!/usr/bin/perl -w
# -*- coding: utf-8; mode: cperl; -*-
# daemon test
# 2014 (c) isao yasuda, All Rights Reserved.
 
use strict;
use IO::Socket::INET;                # Berkeley Socket
use Parallel::Prefork;               # マルチプロセス管理
use POSIX;                           # POSIX互換
 
# 定数
my $logfile = "$ENV{"HOME"}/daemontest.log"; # log file
my $pidfile = "$ENV{"HOME"}/daemontest.pid"; # pid file
my $ph;                              # pid file handle
my $PORT = 36000;                    # Listen ポート番号
my $worker = 5;                      # プレフォーク・ワーカープロセス数
 
# -t オプション指定で停止
if (defined $ARGV[0] && $ARGV[0] eq '-t' && -f $pidfile) {
    open($ph, "<", $pidfile) || die "$pidfile: $!\n";
    my $rpid = <$ph>;         # manager process id
    close($ph);
    # 起動中マネージャプロセスに SIGTERM 発行
    kill('TERM', $rpid) || unlink $pidfile;
    print "$0 $rpid Server terminated.\n";
    exit;
}
 
# PID ファイルが存在していれば,すでに起動済みとする
(-f $pidfile) && die "$0 already running.\n";
 
#
# デーモン生成
#
 
# 1. 非プロセスグループリーダのプロセスを生成
my $pid = fork();
if ($pid > 0) { exit; }       # 親プロセスを終了させる
elsif ($pid < 0) { die "fork failed: $!\n"; }
 
# 2. プロセスグループリーダとなって,制御端末と関係を断つ
POSIX::setsid() || die "setsid failed: $!\n";
 
# 3. ルートディレクトリに移動
chdir('/');
 
# 4. umask を設定し,ログインユーザのマスク引き継ぎを回避
umask(0);
 
# 5. STDIN, STDOUT, STDERR 他すべてのファイルをクローズ
my $fdmax = POSIX::sysconf(&POSIX::_SC_OPEN_MAX);
foreach (0 .. $fdmax) { POSIX::close($_); }
 
# 6. PID ファイルを作成
my $mngpid = $$; # manager process id
open($ph, ">", $pidfile) || die "PID file $pidfile: $!\n";
print $ph "$mngpid";
close($ph);
 
# 7. STDIN を /dev/null に,STDOUT, STDERR をログファイルに接続する
open(STDIN, "<", "/dev/null");
open(STDOUT, ">>", $logfile);
open(STDERR, ">>", $logfile);
binmode STDOUT, ":utf8";
binmode STDERR, ":utf8";
 
# 8. PIPE, ALRM シグナルをブロック: たんに無視。
#    TERM, HUP, QUIT, INT は Parallel::Prefork のところで。 
$SIG{PIPE} = $SIG{ALRM} = 'IGNORE';
 
#
# サーバソケット,プレフォークプロセスの生成
#
 
sub MaxReqs { 100 }; # 子プロセスの要求処理数(この数だけ処理すると再起動する)
 
# ソケット生成
my $listen_sock = IO::Socket::INET->new
    (LocalPort => $PORT,
     Type      => SOCK_STREAM,
     Proto     =>'tcp',
     Listen    => 5) || die "* Error occured: $!\n";
 
# Prefork マネージャオブジェクト生成
my $pm = Parallel::Prefork->new
    ({ max_workers  => $worker, # ワーカープロセスの個数
       trap_signals =>
       { TERM =>'TERM',         # SIGTERM を受信したら子プロセスに SIGTERM
         HUP  =>'TERM',         # SIGHUP 以下同様
         INT  =>'TERM',
         QUIT =>'TERM' },
     });
 
#
# 実務処理
#
 
print "* Daemon Server Started. Prefork Workers: $worker.\n";
print "* Max Requests per Child: " . MaxReqs . "\n";;
 
# SIGTERM を捕捉するまでマネージャプロセスは動き続ける
while ($pm->signal_received ne 'TERM') {
 
    print "* Manager Process ID: " . $mngpid . "\n";
    load_config();            # 設定を読み込み(実質何もしていない)
    $pm->start && next;       # ワーカープロセス生成処理
 
    # ワーカープロセス
    my $reqs = MaxReqs;
    print "* $$ Child Process Started.\n";
    # SIGTERM を補足したら reqs (リクエスト処理数) に 0 をセットする
    $SIG{TERM} = sub { $reqs = 0; print "* $$ Caught SIGTERM\n"; };
 
    # クライアント要求処理
    # - MaxReqs だけ実行したら再起動する
    # - SIGTERM を捕捉したら reqs が 0 にセットされるためループを抜けて終了する
 
    while ($reqs-- > 0) {
 
        # クライアントからのリクエストを受信
        my $client = $listen_sock->accept();
        defined $client || last;
        my $readbuf;
        my $clip = $client->peerhost();
        print "- $$ Client Query Proccessing Started.\n";
        binmode $client, ":utf8";
        defined ($client->recv($readbuf, 2000)) ||
            die "- $$ recv error: $!\n";
        utf8::decode($readbuf);
 
        # クライアントからのデータをもとに処理を実行
        print "- $$ Client: $clip; Received Text:\n$readbuf";
        my $sendtext = "$$ your text size: " . length($readbuf) . "\n";
 
        # 処理結果をクライアントに送信
        print $client "$sendtext\n";
        $client->flush();
        $client->close();
        print "- $$ Client Query Proccessing Ended.\n";
 
    }
 
    print "* $$ Child Process Ended. ($reqs)\n";
    $pm->finish;                        # ワーカープロセスの終了処理
 
}
 
# 終了処理
$pm->wait_all_children;                 # 子プロセス終了を待ち受け
$listen_sock->close();
unlink $pidfile;                        # PID ファイルを削除
print "* Server Teminated.\n";
 
# コンフィグレーションのロード
sub load_config {
    print "* Loading configuration.\n";
}

試験クライアント

以下は,daemontest に接続する簡単なテストクライアント testclient である。標準入力から読んだデータをサーバに送信し,サーバから受信した電文を表示する。

 
#!/usr/bin/perl -w
# -*- mode: cperl; coding: utf-8; -*-
# daemon test client
# 2014 (c) isao yasuda, All Rights Reserved.
 
use strict;
use IO::Socket::INET;         # Berkeley Socket
use Getopt::Std;              # Command line option
 
my $host = 'localhost';       # 接続先
my $port = 36000;             # ポート番号
my $senddata;                 # 送信データ
 
binmode STDOUT, ":utf8";      # 標準出力に UTF-8 文字を書く
 
# コマンドラインオプション s: server; p: port
my %opts = ('s' => '0', 'p' => '0');
Getopt::Std::getopts('s:p:', \%opts) ||
    die "Usage: $0 [ -S server -P port ]";
$host = $opts{'s'} if ($opts{'s'}); # サーバホスト
$port = $opts{'p'} if ($opts{'p'}); # ポート番号
 
# 標準入力から対象テキストを読んで送信データに連結
while (<STDIN>) { utf8::decode($_); $senddata .= $_; }
 
# サーバ接続
my $socket = IO::Socket::INET->new
    (PeerAddr => $host, PeerPort => $port, Proto => 'tcp') ||
    die "Cannot connect: $@\n";
binmode $socket, ":utf8";
print $socket $senddata . "\n"; # 送信
$socket->flush();
my $buf = <$socket>;            # 受信
$socket->close();
print $buf;                     # 受信データ出力

試験

端末から daemontest を起動し,testclient で接続してみる。kt.txt はサーバに送信するテキストデータである。処理ワーカープロセス ID とともに 33 というテキスト長が返却されたことがわかる。

isolde:/Users/isao % daemontest 
isolde:/Users/isao % cat kt.txt 
月濡雨霽逆三餘
夜半昏昏對故書
窻視暈洸膚皓透
須臾轉翳四圍虛
isolde:/Users/isao % testclient -s isolde -p 36000 < kt.txt 
97107 your text size: 33
isolde:/Users/isao % kill -HUP 97106
isolde:/Users/isao % kill -PIPE 97106
isolde:/Users/isao % daemontest -t
daemontest 97106 Server terminated.
isolde:/Users/isao % 

以下は,daemontest のログ出力。端末操作で 9 行目の kill -HUP に対する出力はログの 19-35 行目である。SIGHUP でコンフィグレーションを再ロードし(テストコードのなかでは何もしていないんだけど),ワーカープロセスを再起動する。仮にワーカープロセスが処理実行中だった場合は,当該処理が終了して次の要求処理ループに入る段階で停止するようになっている。わかりにくいが,SIGPIPE 送出に対しては,これを無視するようにデーモンがセットアップしているので,何も起こらない。daemontest -t でサーバはワーカープロセスすべてを停止させ自らも処理を終了する。

isolde:/Users/isao % tail -100 -f daemontest.log 
* Daemon Server Started. Prefork Workers: 5.
* Max Requests per Child: 100
* Manager Process ID: 97106
* Loading configuration.
* 97107 Child Process Started.
* 97108 Child Process Started.
* 97109 Child Process Started.
* 97110 Child Process Started.
* 97111 Child Process Started.
- 97107 Client Query Proccessing Started.
- 97107 Client: 192.168.1.9; Received Text:
月濡雨霽逆三餘
夜半昏昏對故書
窻視暈洸膚皓透
須臾轉翳四圍虛
 
- 97107 Client Query Proccessing Ended.
* Manager Process ID: 97106
* Loading configuration.
* 97108 Caught SIGTERM
* 97108 Child Process Ended. (0)
* 97107 Caught SIGTERM
* 97107 Child Process Ended. (0)
* 97109 Caught SIGTERM
* 97110 Caught SIGTERM
* 97109 Child Process Ended. (0)
* 97110 Child Process Ended. (0)
* 97111 Caught SIGTERM
* 97111 Child Process Ended. (0)
* 97119 Child Process Started.
* 97120 Child Process Started.
* 97121 Child Process Started.
* 97122 Child Process Started.
* 97123 Child Process Started.
* 97121 Caught SIGTERM
* 97121 Child Process Ended. (0)
* 97119 Caught SIGTERM
* 97119 Child Process Ended. (0)
* 97122 Caught SIGTERM
* 97120 Caught SIGTERM
* 97122 Child Process Ended. (0)
* 97120 Child Process Ended. (0)
* 97123 Caught SIGTERM
* 97123 Child Process Ended. (0)
* Server Teminated.

参考文献

UNIX ネットワーク・プログラミングの要諦,デーモンの動作原理とその必要性を詳しく解説した名著といえば,リチャード・スティーヴンスの『UNIX ネットワークプログラミング』だろう。あとの三冊も Perl 言語での実装について必ず参考になる良書である。

UNIXネットワークプログラミング〈Vol.2〉IPC:プロセス間通信
W. リチャード・スティーヴンス
ピアソンエデュケーション
Perlクックブック〈VOLUME1〉
トム・クリスチャンセン,ネイザン・トーキントン
オライリージャパン
Perlクックブック〈VOLUME2〉
トム・クリスチャンセン,ネイザン・トーキントン
オライリージャパン