inetd の仕組みを見てみる

inetd や xinetd (以下 inetd) はインターネットサービスをデーモン化するのに共通している処理を担い、ほとんどの時間をアイドル状態で過ごすその手のサービスに必要なリソースを節約する役割を果たします。

inetd のひとつ面白いところは、inetd でサービス化したいプログラムの標準入力/標準出力がクライアントソケットの入出力に接続されるところです。例えば daytime 相当のサービスを自分で作ろうと思った場合

#!/usr/local/bin/perl
# daytime.pl
use strict;
use warnings;
use DateTime;
use IO::Handle;

STDOUT->autoflush(1);
STDOUT->printf(
    "%s\n",
    DateTime->now(time_zone => 'Asia/Tokyo')
);

と標準出力に時刻を出力するプログラムを書いて、以下のような xinetd.conf を用意してプログラムのパスとポートを指定する。

service daytime-perl
{
        disable         = no
        type            = UNLISTED
        id              = daytime-perl-stream
        socket_type     = stream
        protocol        = tcp
        user            = root
        wait            = no
        port            = 12001
        server          = /home/naoya/bin/daytime.pl
}

するとこれだけで、telnet 12001 すると時刻が返ってきて、ちゃんと daytime 相当のサービスとしてこのスクリプトを公開することができます。echo サーバーとかも同じくで、

#!/usr/local/bin/perl
# echo.pl
use strict;
use warnings;
use IO::Handle;

STDOUT->autoflush(1);

while (my $line = STDIN->getline) {
    STDOUT->printf($line);
}

で OK。

UNIXネットワークプログラミング〈Vol.1〉ネットワークAPI:ソケットとXTI を読んでいたところ、321ページ「デーモンプロセスとinetdスーパーサーバ」のところに inetd の動作の概要が載っていました。キモになるのは

  • 複数のサービスをまとめて面倒を見るにあたって、それぞれのサービス用のリスニングソケットを select(2) で多重化する
  • 接続があると fork して子を作り、その子を exec して本体のプログラム(daytime.pl や echo.pl に相当するもの) を実行する
  • exec する前にソケットディスクリプタを 0, 1, 2 に dup2(2) する

というところです。特に最後のところですね。exec でプログラムを切り替えてもファイルディスクリプタはそのまま継承するので、あらかじめ dup でソケットと標準入出力をつなげておいて exec することで、exec したあとのプログラムの標準入出力が接続ソケットとのやりとり相当になるという。なるほどー。

と、inetd の動作の裏側が結構面白かったので Perl で実装してみました。inetd ならぬ pnetd。仕様としては

  • inetd のコアの動作である上記三点を実装
  • 実行ユーザーを切り替えたり、inetd 自身がデーモンになったりするための処理は省略
  • inetd.conf 相当のものはコード内にハッシュで定義する。シンプルにプログラムのパスとポートだけ
  • UDP を処理しようとすると複雑になるので、TCP だけ
  • IO多重化やシグナル処理は POE で

という風にしました。

#!/usr/local/bin/perl
use strict;
use warnings;
use Class::Inspector;
use IO::Socket;
use POSIX qw/WNOHANG/;
use POE qw/Sugar::Args/;

my @services = (
    { port => 12001, path => "./daytime.pl" },
    { port => 12002, path => "./echo.pl" },
);

POE::Session->create(
    inline_states  => {
        _start => sub { sweet_args->kernel->yield('server_start') },
    },
    package_states => [ main => Class::Inspector->methods('main') ],
    heap           => { services => \@services },
);

POE::Kernel->sig(CHLD => sub { while (waitpid(-1, WNOHANG) > 0 ) {} } );
POE::Kernel->sig(INT =>  sub { POE::Kernel->stop });
POE::Kernel->run;

sub server_start {
    my $poe = sweet_args;
    for my $service (@{$poe->heap->{services}}) {
        my $listen = IO::Socket::INET->new(
            Listen    => SOMAXCONN,
            LocalPort => $service->{port},
            Proto     => 'tcp',
            Reuse     => 1,
        ) or die $@;
        $poe->kernel->select_read(
            $listen, handle_accept => $service->{path}
        );
    }
}

sub handle_accept {
    my $poe = sweet_args;
    my $listen = $poe->args->[0];
    my $path   = $poe->args->[2];
    my $con    = $listen->accept or die $!;

    if (my $pid = fork) {
        $con->close;
    } else {
        $_->close for $listen, *STDOUT, *STDIN, *STDERR;

        ## like dup2(2)
        open STDOUT, '>&', $con or die $!;
        open STDERR, '>&', $con or die $!;
        open STDIN,  '<&', $con or die $!;
        exec $path;
    }
}

思ったよりも短く書くことができました。このスクリプトを起動して実行するとデーモンが立ち上がります。telnet で 12001、12002 をそれぞれ叩いて daytime や echo がサービスできているかどうか、確認してみます。

% telnet localhost 12001
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
2007-07-31T19:58:05
Connection closed by foreign host.

% telnet localhost 12002
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Naoya Ito
Naoya Ito
^]
telnet> q
Connection closed.

ちゃんと動いてますね。

コードの断片を解説します。

my @services = (
    { port => 12001, path => "./daytime.pl" },
    { port => 12002, path => "./echo.pl" },
);

pnetd で動かすサービスはこんな感じでハッシュで定義。パスには使うスクリプトを。先に作った二つです。pnetd.pl と同じディレクトリに置きました。

POE::Kernel->sig(CHLD => sub { while (waitpid(-1, WNOHANG) > 0 ) {} } );

pnetd はクライアントからの接続を受け取ると fork するので、子をリープするために SIGCHLD で waitpid を。ただ、このコードを追加すると終了するのに SIGTINT を二回送らなくちゃいけなくなってしまう。POE 絡みでどこかミスってるかも。

sub server_start {
    my $poe = sweet_args;
    for my $service (@{$poe->heap->{services}}) {
        my $listen = IO::Socket::INET->new(
            Listen    => SOMAXCONN,
            LocalPort => $service->{port},
            Proto     => 'tcp',
            Reuse     => 1,
        ) or die $@;
        $poe->kernel->select_read(
            $listen, handle_accept => $service->{path}
        );
    }
}

スクリプトが起動するとすぐ server_start イベントが呼ばれます。そのハンドラ。サービスの数分のリスニングソケットを作って POE::Kernel の select_read() で select(2) して accept(2) を監視します。クライアントから接続があると、handle_accept イベントが起動されます。

sub handle_accept {
    my $poe = sweet_args;
    my $listen = $poe->args->[0];
    my $path   = $poe->args->[2];
    my $con    = $listen->accept or die $!;

    if (my $pid = fork) {
        $con->close;
    } else {
        $_->close for $listen, *STDOUT, *STDIN, *STDERR;

        ## like dup2(2)
        open STDOUT, '>&', $con or die $!;
        open STDERR, '>&', $con or die $!;
        open STDIN,  '<&', $con or die $!;
        exec $path;
    }
}

handle_accept イベントのハンドラでは、fork → dup → exec 相当の処理を行います。Perl で dup2(2) 相当のことを行うには、open() の '>&' や '<&' を使えば OK です。

まとめ

  • inetd / xinetd の動作は面白いです。
  • select(2) で複数サービスのリスニングソケットを多重化して fork + exec で任意のプログラムを起動します。
  • exec する前に dup(2) しておいて、標準入出力とクライアントのソケットを結び付けています。
  • 同様の処理を Perl で書いてみました。

inetd はずいぶん古くから使われている超枯れたデーモンですが、その実装の裏側をのぞいてみることでネットワークプログラミングのちょっとしたテクニックを垣間見ることができました。

UNIXネットワークプログラミング〈Vol.1〉ネットワークAPI:ソケットとXTI

UNIXネットワークプログラミング〈Vol.1〉ネットワークAPI:ソケットとXTI