にゃははー

はへらー

C++ AdventCalendar 2012 9日目 「Boost.AsioでGraceful Restart」

C++ Advent Calendar 2012 - PARTAKE の9日目です。

今回は Boost.Asio で Graceful Restart してみたいと思います。
Unix依存コードなのでWindows環境はわからないです。あしからず。まぁWinで鯖とか...

Graceful Restart とは

Graceful(ぐれーすふる, 優雅な) Restart とはサーバーアプリケーションなどで稀に実装されることがあるdaemonの再起動方法です。有名所だと Apache httpd なんかは実装しています。停止と再起動 - Apache HTTP サーバ

手順

通常の Restart の場合、

  1. 旧プロセスがシグナル等を受け取る
  2. 直ちに全てのセッションをぶった切る
  3. 旧プロセスが停止
  4. 新プロセスが1から立ち上がる

という手順を踏みます。その瞬間にどれだけのクライアントが接続しようともです。

これに対して Graceful Restart は

  1. 新プロセスが立ち上がる
  2. 旧プロセスがシグナル等を受け取る
  3. 旧プロセスが新プロセスにディスクリプタを転送する
  4. 新プロセスがacceptを開始
  5. 旧プロセスがacceptを停止
  6. 旧プロセスの全ての通信が終了したら旧プロセスを停止

という手順になります。

利点と欠点

Graceful Restartの利点としては

  • 既に接続を確立したセッションは閉じられない
    • 新プロセスが走り始めても依然として旧プロセスは処理を続けます
  • accept されない瞬間が無い
    • Restartは旧プロセスがぶった切ってから新プロセスが立ち上がって accept 開始するまでの間に deadtime が存在します
    • 一方 Graceful Restart では新プロセスと旧プロセスが同時に accept する瞬間がある為 deadtime がありません
  • accept されない瞬間が無いということは隙をついて他のプロセスが bind することが出来ない
    • まぁそんな事されることは無いと思いますが

欠点としては

  • 旧プロセスと新プロセスとで整合性を保つ必要がある
    • 旧プロセスの全てのセッションが終了するまで新旧両プロセスが並列に走っているのでIPC等で互いにデータをやり取りする必要があります。

といっても旧プロセス側は最接続要求見たいなコマンドを実装して、クライアントに速やかに新しい方に移行するように促せばそこまで厳しく同期しなくてもいいかもしれないです。まぁアプリケーションに依りますが。

どうやって実現される?

ソケットプログラミングをやったことある人ならばわかるかと思いますが、一旦誰かが bind しているポートを別のプロセスが 再bind することができません。つまり単純に複数プロセス立ち上げるだけでは実現できません。

ところで複数プロセスでセッションを捌いているプログラムは起動プロセスが一通り bind したところで規定数まで fork することで実現されています。fork されたプロセスでは親プロセスが開いたディスクリプタは共有されているわけです。
つまり、元来 Unix系システム ではプロセス間でリソースを共有することが想定されているわけで、先天的に共有できるのであれば後天的にも共有できて欲しいわけです。

手順のところでさらっと書いたディスクリプタの転送というのは当にこの後天的な共有です。当然ですが、ただ適当にディスクリプタの値を転送するのではダメです。
ディスクリプタの転送には Unixドメインソケット の 補助メッセージ を使います。Man page of UNIX

Boost.Asio と組み合わせる

今回の目的は Boost.Asio 管理下にある acceptor を別のプロセスに転送することです。しかしリファレンスを眺める限り 補助メッセージ を扱うことが出来ないようなのでここは native_handle を直接叩きます。
注意してもらいたいのは転送するものは各ソケットではなく acceptor というところです。bind,listen,accept しているソケットは acceptor が持っているので、accept,async_accept で得られたソケット等を転送しても無意味です。

ディスクリプタ転送

転送するディスクリプタを target_fd 、転送に使う Unixドメインソケット のディスクリプタunix_fdとすると、ディスクリプタ送信側は以下の様なコードになります。

constexpr auto buflen = CMSG_SPACE(sizeof(int));
char cmsgbuf[buflen];

cmsghdr *cmsg = static_cast<cmsghdr *>(static_cast<void *>(cmsgbuf));
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
*static_cast<int *>(static_cast<void *>(CMSG_DATA(cmsg))) = target_fd;

boost::initialized<msghdr> msg_;
msghdr &msg = msg_;
msg.msg_control = cmsgbuf;
msg.msg_controllen = buflen;

sendmsg(unix_fd, &msg, 0);

同様に受信側は次のようなコードです。

constexpr auto buflen = CMSG_SPACE(sizeof(int));
char cmsgbuf[buflen];

boost::initialized<msghdr> msg_;
msghdr &msg = msg_;
msg.msg_control = cmsgbuf;
msg.msg_controllen = buflen;
msg.msg_flags = MSG_WAITALL;

recvmsg(unix_fd, &msg, 0);

const cmsghdr *cmsg = static_cast<const cmsghdr *>(static_cast<const void *>(cmsgbuf));
target_fd = *static_cast<const int *>(static_cast<const void *>(CMSG_DATA(cmsg)));

補助メッセージを呼ばれる様に、実際には本体とメッセージがあるのですが、今回は何もメッセージはありません。実際のアプリケーションだとそのディスクリプタの情報とかをシリアライズして乗っけるといいかもしれないです。
あと、補助メッセージ部は整数配列が期待されているので、1つづつちまちま転送するのではなく複数のディスクリプタを一括して転送することが出来ます。

テストコード

今回テストに使ったコードを以下に置いておきました。ディスクリプタの送受信を行なっているのは server.cpp の graceful namespace 以下にある関数です。
Graceful Restart with Boost.Asio
コネクション張ったら100MBのゴミを引っ張ってきて終わりです。

このコードは実は微妙にダメで、新旧プロセスが並列に accept してる瞬間があることを保証していません。本来は新プロセスが accept 始めたことを通知してから旧プロセスを止めるべきですが、単なるテストだということと、acceptor 自体は閉じていないのでOSが connection を保存してくれているので今はさほど問題ではないです。

コンパイル

$ g++-4.8.0 -Wall -Wextra -pedantic -Wl,--as-needed -Wno-unused -O3 -pthread -std=gnu++11 -o server server.cpp -lboost_system  
$ mpicxx -cxx=g++-4.8.0 -Wall -Wextra -pedantic -Wl,--as-needed -Wno-unused -O3 -std=gnu++11 -o client client.cpp -lboost_system -lboost_serialization -lboost_mpi

でやりました。

受信側の注意点として、送られてくるディスクリプタプロトコルやら何やらがわからないのでそこはメッセージに載せるなりIPCするなりして適切な protocol で acceptor に assign する(server.cpp:123,149)必要があります。
今回はIPv6前提で書いたので直接 tcp::v6() を叩き込んでいます。

実機でテスト

実際に Graceful Restart できるか試してみました。

試した環境は

  • Intel Xeon X5570 x2 @2.93GHz (4x2/8x2, phys/logic cores)
  • 12GiB RAM
  • Mellanox ConnectX系 Infiniband 4x QDR (多分)

が6台ある感じのクラスタです。学生は自由に使えるのにみんな使わないので使いました。
ちなみに CPU Affinity control はめんどいのでやってないです。パフォーマンス測定というわけじゃないので。
ただ遅すぎてもいけないので接続はIPoIBです。10GbE NICが刺さってるのになんか1GbEで上がっているというゴミ環境のEthなんて使いたくないです。

ソフトウェアスタックは

  • GCC 4.8.0 20121118 (experimental)
    • GMP 5.0.5
    • MPFR 3.1.1
    • MPC 1.0
    • ISL 0.10
    • Cloog-ISL 0.17.0
  • Boost 1.52.0
  • mvapich2 1.8.1

です。

1台で server.cpp を立ちあげて残り5台で client.cpp を使って接続を試みる感じです。クライントを起動するのとかがめんどいので mvapich2 使っただけです。それ以上のことはやってないです。

動画とってyoutubeにでも上げるのがいいのかもしれないですがめんどいのでやらないです。皆さん適当な環境でやってみてください。

Restart

まずは通常の Restart です。以下がその出力です。
([]内はpidです。あとホスト名はいじりました。)

  • 鯖側
$ ./server ; rm test ; sleep 1 ; ./server
[7277] Total running workers: 16
[7277] All acceptor stopped
[7277] Terminate
[9710] Total running workers: 16
[9710] All acceptor stopped
[9710] Terminate

明示的にdeadtimeを1秒仕込んでいます。適当な時間経ったら最初のプロセスを C-c 等で止めて様子を見ます。

  • クライアント
$ mpirun -n 60 ./client fe80::223:7dff:ff95:a98d%ib0 1
Total MPI process: 60
xxx02[24/60]: read_some failed (asio.misc:2)
xxx05[26/60]: read_some failed (asio.misc:2)
xxx04[27/60]: read_some failed (asio.misc:2)
xxx03[28/60]: read_some failed (asio.misc:2)
xxx02[29/60]: connection failed (system:111)
xxx01[30/60]: connection failed (system:111)
xxx05[31/60]: connection failed (system:111)
xxx04[32/60]: connection failed (system:111)
xxx03[33/60]: connection failed (system:111)
xxx02[34/60]: connection failed (system:111)
xxx01[35/60]: connection failed (system:111)
xxx05[36/60]: connection failed (system:111)
xxx04[37/60]: connection failed (system:111)
xxx03[38/60]: connection failed (system:111)

MPIプロセスが60なのは、80でやろうとしたところmvaphich2側がエラーを吐いたのでエラーのでなかった60にしました。

鯖側は pid:7277 がTerminateしてから pid:9710 が上がってきたのが分かると思います。この間にクライアント側ではエラーがだーっと流れてきました。
asio.misc:2 というのは boost::asio::error::misc_errors::eof のことです。つまりコネクションぶった切られたせいで予期しないEOFに到達してしまったわけです。
次に見える system:111 はこの環境では /usr/include/asm-generic/errno.h で ECONNREFUSED となっていました。acceptしてるのがいないので接続出来なかったということです。

Graceful Restart

次に本題、Graceful Restartです。

  • 鯖側
$ ./server & sleep 60h ; ./server
[1] 16539
[16539] Total running workers: 16

[16539] Receive graceful signal
[16892] Send graceful signal
[16539] All acceptor stopped
[16892] Total running workers: 16
[16539] Terminate
[16892] All acceptor stopped
[16892] Terminate
[1]+  Done                    ./server

先に起動するプロセスはバックグラウンドで起動してsleepしています。適当なタイミングで C-c してsleepから抜けると新しいプロセスが立ち上がって acceptor をもらってきます。

  • クライアント
$ mpirun -n 60 ./client fe80::223:7dff:ff95:a98d%ib0 1
Total MPI process: 60

見事にエラーは出ませんでした。これは古い方につないだクライントも新しい方につないだクライントも正しく転送がされたことを意味してます。

まとめ

Graceful Restart はサーバーのメンテナンスやバージョンアップに威力を発揮します。クライアント側に影響がでないで実際に動いてるバイナリを差し替えられるので更新のためにサービスを停止させる必要がありません。稼働率上がりますね。

Boost.Asioな要素はあまりない(というか全然話してない)ですが、今時サーバを1からソケットプログラミングしたくないので Boost.Asio 使って native_handle しましょう。

明日は @a_hisame さんで Boost.Graph関係らしいです。Boost.Graphは触ったこと無いのでわからないですがどういったことに使われているんでしょうか...

Appendix: IPv6

余談ですが今回は IPv6 のリンクローカルユニキャストアドレスを使って実験しました。ここで IPv4 とは違う問題に直面するので一応 appendix 程度に書いておきます。

IPv6のリンクローカルユニキャストアドレスは fe80::/10 という prefix も持っています。が、これは全てのNIC自動的に付与されるIPv6アドレスです。その上、このアドレスへのパケットはルーターがルーティングしてはいけません。つまり同一LAN上にしか配送されません。

例えばNICを2枚持っているノードが、ある fe80::xxxx に接続しにいこうとするとここで「マルチプレフィックス問題」と呼ばれる問題に直面します。
つまり接続したいノードが2枚あるうちのどちらのNICが属しているLAN上にいるのかがわからないのでパケットを送ることが出来ないのです。

ということでどのNICを使うかをクライント側では指定していました。

$ mpirun -n 60 ./client fe80::223:7dff:ff95:a98d%ib0 1

fe80::223:7dff:ff95:a98d%ib0 の部分です。 [IPv6アドレス]%[インターフェース名] になってます。

コード上だと

#include <sys/ioctl.h>
#include <net/if.h>

namespace detail {

unsigned long
scope_id_from_if(std::string if_)
{
    static asio::io_service ios;
    static tcp::socket s(ios, tcp::v6());
    ifreq req;
    strcpy(req.ifr_name, if_.c_str());
    if (ioctl(s.native_handle(), SIOCGIFINDEX, &req) < 0)
    {
        perror("ioctl");
        BOOST_THROW_EXCEPTION(std::system_error(errno, std::system_category()));
    }
    return req.ifr_ifindex;
}

} // namespace detail

asio::ip::address_v6
v6_from_string(std::string s)
{
    using asio::ip::address_v6;
    auto i = boost::find(s, '%');
    auto addr = address_v6::from_string(std::string(s.begin(), i));
    if (i != s.end())
    {
        const std::string if_(std::next(i), s.end());
        addr.scope_id(detail::scope_id_from_if(if_));
    }
    return addr;
}

こうなってます。残念ながらBoost.Asioもインターフェース名で指定する方法を用意してないので自分でとってきています。詳しい説明は Man page of IPV6 を読みませう。

通常のインターネットに出る事のできるIPv6アドレスの場合でも同様の問題が起きますが、どちらのネットワークも相互に到達できるのであれば適当なルーティングポリシーなりで送ることができるので問題になることは少ないです。
単なるクライアントならまず問題にはならないとは思いますが、NGN網の影響でマルチプレフィックス問題は割と有名になりましたし、そういったコードが書けるようになっとくとよいです。

# 補助メッセージとかscope_idとかのってパッチ書いたら需要あるだろうか。あるなら提案してもいいけど。