$shibayu36->blog;

クラスター株式会社のソフトウェアエンジニアです。エンジニアリングや読書などについて書いています。

Server::Starterから学ぶhot deployの仕組み

以前http://tech.naver.jp/blog/?p=1369の記事を読んだのだけれど、それまでにprocessの知識が無かったりして、まったく理解できませんでした。そこでWorking with UNIX ProcessesやServer::Starterの中身を呼んでようやくhot deployの仕組みを理解できた(気になっている)ので、Server::Starterの実装を追いながら、それをまとめてみます。

hot deployとは

hot deployとは「再起動の時にリクエストの処理を続けながら、変更の内容を反映するための手段」です。
通常serverをrestartさせるときは、stop -> startの流れになると思いますが、この場合stopしてから、start出来るまでの期間にリクエストを処理できない期間が発生します。その期間なしにdeployする仕組みがhot deployと呼ばれています。

Server::Starterとは

Server::Starterとはserverを簡単にhot deploy出来るようにするためのcpan moduleです(kazuhoさん作)。以下のリンクが参考になります。

Server::Starterの動き

以下のコマンドがどのような動きをするか、図を使いながら追ってみます。

start_server --port 8080 -- plackup -s Starman

start_server自体のprocessが出来る

コマンド実行しているので当然ですが、start_serverのprocessが出来ます。
f:id:shiba_yu36:20120506101920j:plain

start_serverがsocketを作成する

実際のserver programを実行する前にstart_serverがsocketを生成します。
さらっと書きますが、この部分はかなり重要です。なぜならhot deployするためには関係するserver processでsocketを共有しなければならず、start_serverでsocketを作ることによってそれが実現可能になるからです。
f:id:shiba_yu36:20120506102442j:plain

start_serverがserver programをfork & execする

socketを作った後は、自分自身のprocessをforkして、その子プロセス上で指定されたprogramをexecします。今回の場合、指定されたprogramは

plackup -s Starman

です。
この時重要なのが、自身をforkするということは環境変数やfile descripterが引き継がれるということです。これにより、各server programへsocketを引き継いだり、どのfile descripterがsocketかということをserver programへ通知したりしています。
f:id:shiba_yu36:20120506124139j:plain

server programが実行される

start_serverの子プロセスにserver programがexecされ、serverが起動します。今回はstarmanが起動され、さらにリクエスト処理のための子プロセスがforkされ、start_serverから引き継がれたsocketを利用し、acceptをすることで、リクエストを待ち受けています。
f:id:shiba_yu36:20120506124140j:plain

ここまででserver programの起動は終了です。この時にpsを実行すると以下のようなプロセスが立ち上がっていることが分かります。

6900 ttys002    0:00.10 /Users/shibazaki/perl5/perlbrew/perls/perl-5.8.9/bin/perl /Users/shibazaki/perl5/perlbrew/perls/perl-5.8.9/bin/start_server --port 8080 -- plackup -s Starman
6901 ttys002    0:00.56 starman master
6902 ttys002    0:00.00 starman worker
6903 ttys002    0:00.00 starman worker
6904 ttys002    0:00.00 starman worker
6905 ttys002    0:00.00 starman worker
6906 ttys002    0:00.00 starman worker

HUPを受け取ったら新しいserver programを立ち上げる

ここからはhot deployの流れです。
Server::StarterはHUPを受け取ったらhot deployを実行するようになっています。そのため、まずHUPを受け取ったら既存のプロセスはそのままで、新しいserver programを立ちあげます。
f:id:shiba_yu36:20120506125651j:plain
ごちゃごちゃしてしまったので矢印を省略していますが、全てのプロセスでsocketが共有され、同じsocketを利用し、新しいserver programを起動しています。これが古いserver programと新しいserver programが同時に動く仕組みです。

新しいserver programが起動したら古いserver programを停止する

新しいserver programが正常に立ち上がったら、start_serverのprocessが古いserver programに対し、killを実行します。
f:id:shiba_yu36:20120506132237j:plain
これにより完全に新しいprogramにupdateされます。

この手順により、新しいserver programが立ち上がるまでの間は古いserver programが処理をしてくれるので、処理が出来ない期間がなくなっていることが分かりますね。

Server::Starterのコードを追ってみる

https://github.com/kazuho/p5-Server-Starter
さて上の流れを踏まえて、Server::Starterのコードを追ってみます。Server::Starterはlib/Server/Starter.pmのstart_serverと_start_workerがメインのコードです。

socketを生成する

socketを生成する前にpidファイルを作ったり、logfileを作ったりするところがあるのですが、そこは省いて、socket生成のコードです。

sub start_server {
    # ...

    # start listening, setup envvar
    my @sock;
    my @sockenv;
    for my $port (@$ports) {
        my $sock;
        if ($port =~ /^\s*(\d+)\s*$/) {
            $sock = IO::Socket::INET->new(
                Listen    => Socket::SOMAXCONN(),
                LocalPort => $port,
                Proto     => 'tcp',
                ReuseAddr => 1,
            );
        } elsif ($port =~ /^\s*(.*)\s*:\s*(\d+)\s*$/) {
            $port = "$1:$2";
            $sock = IO::Socket::INET->new(
                Listen    => Socket::SOMAXCONN(),
                LocalAddr => $port,
                Proto     => 'tcp',
                ReuseAddr => 1,
            );
        } else {
            croak "invalid ``port'' value:$port\n"
        }
        die "failed to listen to $port:$!"
            unless $sock;
        fcntl($sock, F_SETFD, my $flags = '')
                or die "fcntl(F_SETFD, 0) failed:$!";
        push @sockenv, "$port=" . $sock->fileno;
        push @sock, $sock;
    }
    my $path_remove_guard = Scope::Guard->new(
        sub {
            -S $_ and unlink $_
                for @$paths;
        },
    );
    for my $path (@$paths) {
        if (-S $path) {
            warn "removing existing socket file:$path";
            unlink $path
                or die "failed to remove existing socket file:$path:$!";
        }
        unlink $path;
        my $sock = IO::Socket::UNIX->new(
            Listen => Socket::SOMAXCONN(),
            Local  => $path,
        ) or die "failed to listen to file $path:$!";
        fcntl($sock, F_SETFD, my $flags = '')
            or die "fcntl(F_SETFD, 0) failed:$!";
        push @sockenv, "$path=" . $sock->fileno;
        push @sock, $sock;
    }
    $ENV{SERVER_STARTER_PORT} = join ";", @sockenv;

    # ...
}

portとかが指定されてたらそれからsocketを作る、pathが指定されていたらそのファイルからsocketを作るってだけですね。
あとはfcntlを呼んでるのが特徴的です。これはMan page of FCNTLを見ると、flagを0にしておくとexecveを実行してもファイルディスクリプタがオープンされたままになるって書いてあるので、それが理由なような気がします。あまりちゃんと確かめていません。

signal handlerを設定する

HUPなどを受け取った時にいろいろ動作させるために、handlerを設定しています。ここでは@signal_receivedにどんなsignalが来たか入れているだけです。その時の処理は他に書かれています。
また$SIG{PIPE} = 'IGNORE'している理由は、サービス終了のお知らせあたりに書いてありました。

sub start_server {
    # ...

    # setup signal handlers
    $SIG{$_} = sub {
        push @signals_received, $_[0];
    } for (qw/INT TERM HUP/);
    $SIG{PIPE} = 'IGNORE';

    # ...
}

server programをfork execする

server programをfork & execしている場所は_start_workerというメソッドです。

sub start_server {
    # ...

    $current_worker = _start_worker($opts);

    # ...
}

sub _start_worker {
    my $opts = shift;
    my $pid;
    while (1) {
        $ENV{SERVER_STARTER_GENERATION}++;
        $pid = fork;
        die "fork(2) failed:$!"
            unless defined $pid;
        if ($pid == 0) {
            # child process
            { exec(@{$opts->{exec}}) };
            print STDERR "failed to exec $opts->{exec}->[0]:$!";
            exit(255);
        }
        print STDERR "starting new worker $pid\n";
        sleep $opts->{interval};
        if ((grep { $_ ne 'HUP' } @signals_received)
                || waitpid($pid, WNOHANG) <= 0) {
            last;
        }
        print STDERR "new worker $pid seems to have failed to start, exit status:$?\n";
    }
    $pid;
}

まず最初の$pid = forkの部分で、forkされます。子プロセスはif ($pid == 0)の部分で、ここでexecされているのが分かります。親プロセスはその後interval秒だけ待って、子プロセスがエラー終了していないかどうか確認し、pidを返します。そんなに難しくないですね。
ここで気をつけたいことは、親プロセスが確認するのは、interval秒内に「子プロセスがエラー終了していないかどうか」を確認するだけということです。このへんの弊害はServer::Starter の --interval オプションは大切 - 北海道苫小牧市出身の初老PGが書くブログに書いてありました。

さてここまでの部分でserver programを起動する部分は終わりです。

SIGHUPを受け取り、新しいserver programを起動する & 古いserver programをkillする

上でserver programを起動した後、次のコードでstart_serverのプロセスは待ち状態になっています。

sub start_server {
    # ...

    while (1) {
        my @r = wait3(! scalar @signals_received);
        # ...
    }

    #...
}

wait3(1)を実行すると、子プロセスが終了する、もしくはシグナルを受け取った時にwait待ち状態から抜け出します。またwait3(0)を実行すると待ち状態には移行せずに子プロセスの状態を知ることが出来ます。それにより、子プロセスが勝手に死んだり、signalを受け取ったり、受け取っていたりした時にこの部分を抜け出すことができ、それぞれの処理を行うことができるわけです。

さてそのあと、server programを起動する部分は以下の部分です。

sub start_server {
    # ...

    while (1) {
        my @r = wait3(! scalar @signals_received);

        # ...

        for (; @signals_received; shift @signals_received) {
            if ($signals_received[0] eq 'HUP') {
                print STDERR "received HUP, spawning a new worker\n";
                $old_workers{$current_worker} = $ENV{SERVER_STARTER_GENERATION};
                $current_worker = _start_worker($opts);
                $update_status->();
                print STDERR "new worker is now running, sending $opts->{signal_on_hup} to old workers:";
                if (%old_workers) {
                    print STDERR join(',', sort keys %old_workers), "\n";
                } else {
                    print STDERR "none\n";
                }
                kill $opts->{signal_on_hup}, $_
                    for sort keys %old_workers;
            } else {
                goto CLEANUP;
            }
        }

        # ...
    }

    # ...
}

この部分のif ($signals_received[0] eq 'HUP')の中でserver programを起動 & 古いserver programをkillしています。流れとしては

  1. %old_workersに現在実行中のprocessのpidを記録しておく(keyとして)
  2. _start_workerを実行し、新しいserver programをfork & execする。この部分で新しいserver programがstartするまで、待ち状態になる
  3. 新しいserver programが起動されたら、%old_workersに記録されているpidに対してkillする

です。
この手順により、HUPを受け取った時にhot deployをするようになっています。仕組みを理解してコードを読んでみると簡単ですね。

補足 : server programをServer::Starterに対応する

補足としてserver programをServer::Starterに対応させる方法も見てみます。
Server::Starter - a superdaemon for hot-deploying server programs - metacpan.orgに書いてありますが、あえてコードを読みながら見てみます。

上のsocketを生成する部分のコードにも書いてありますが、まずsocketの情報が環境変数のSERVER_STARTER_PORTに書き込まれます。

sub start_server {
    # ...
    $ENV{SERVER_STARTER_PORT} = join ";", @sockenv;
    # ...
}

このプロセスがforkされるので、この環境変数とsocketを管理するfile descriptorがserver programに引き継がれます。そのため、server programはこのSERVER_STARTER_PORTをparseしてsocketを取得し、そのsocketを利用して、acceptを実行すればよいことになります。
さらにlib/Server/Starter.pmの中にserver_portsというメソッドがあって、

sub server_ports {
    die "no environment variable SERVER_STARTER_PORT. Did you start the process using server_starter?",
        unless $ENV{SERVER_STARTER_PORT};
    my %ports = map {
        +(split /=/, $_, 2)
    } split /;/, $ENV{SERVER_STARTER_PORT};
    \%ports;
}

これを見るとSERVER_STARTER_PORTのparseをしてくれていることが分かります。
以上のことからserver programをServer::Starterに対応するには、sever_portsというメソッドを利用して、CPANの以下のsampleのコードになることが分かります。

use Server::Starter qw(server_ports);

my $listen_sock = IO::Socket::INET->new(
    Proto => 'tcp',
);
$listen_sock->fdopen((values %{server_ports()})[0], 'w')
    or die "failed to bind to listening socket:$!";

while (1) {
    if (my $conn = $listen_sock->accept) {
        ....
    }
}

最後に

さて今回はServer::Starterの例を見ながら、hot deployの仕組みについて調べてみました。
この時にWorking with UNIX Processesに書かれている内容が非常に参考になりました。Working with UNIX Processes を読んだ - @kyanny's blogでも紹介されていますが、プロセスについて、さわりの部分を非常によく解説してくれていて、知っている人でも復習になるし、読んでみて損はないと思いました。

またこのあたりについては、まだそんなに詳しくないので、何か間違いなどあれば@shiba_yu36まで教えて下さい。