Ping を作ろう

こんにちは。プラットフォーム開発部の ebisawa です。よろしくお願いします。

今回はおなじみ ping のお話です。

たかが ping されど ping

このブログをご覧の方ならまず間違いなくご存知の ping コマンドですが、もちろんグリーのインフラ運用でも様々な場面で活躍しています。サービスのインフラを構成するサーバやネットワーク機器は、常に様々な方法で正常に稼働しているか監視されていますが、ping による死活監視は、中でも最も基本的で重要といえるものです。

そんな ping ですが、ping 相当のプログラムを書いてみたとか、どういう仕組みで動いているかまで理解する機会は少ないのではないかと思います。そこで、今回は ping について書いてみたいと思います。

なお、以下に出てくる例は、主に Debian GNU/Linux (lenny) マシンで試したものです。

目次
  • ping のしくみ
  • ping の作りかた
  • 作ってみた

ping のしくみ

ICMP エコーリクエスト

多くの ping の実装では、ICMP の Echo Request を利用しています。ICMP は、インターネットにおいて到達不能等のエラー通知等に用いられるプロトコルで、RFC792 等で定義されています。ICMP には、エコーと呼ばれる、宛先からの返答を要求する方法があります。これを利用したのがいわゆる ping です。

14:22:59.663727 IP 10.129.0.112 > 10.129.0.1: ICMP echo request, id 62558, seq 0, length 64
	0x0000:  4500 0054 11f4 0000 4001 5343 0a81 0070
	0x0010:  0a81 0001 0800 c3a3 f45e 0000 b38d 084c
	0x0020:  8f20 0a00 0809 0a0b 0c0d 0e0f 1011 1213
	0x0030:  1415 1617 1819 1a1b 1c1d 1e1f 2021 2223
	0x0040:  2425 2627 2829 2a2b 2c2d 2e2f 3031 3233
	0x0050:  3435                                   
14:22:59.729068 IP 10.129.0.1 > 10.129.0.112: ICMP echo reply, id 62558, seq 0, length 64
	0x0000:  4500 0054 11f4 0000 ff01 9442 0a81 0001
	0x0010:  0a81 0070 0000 cba3 f45e 0000 b38d 084c
	0x0020:  8f20 0a00 0809 0a0b 0c0d 0e0f 1011 1213
	0x0030:  1415 1617 1819 1a1b 1c1d 1e1f 2021 2223
	0x0040:  2425 2627 2829 2a2b 2c2d 2e2f 3031 3233
	0x0050:  3435                                   

tcpdump してみると上の例のようなパケットの流れが見られます。確かに ICMP エコーリクエストに対し、宛先ホストがリプライを返しています。

RFC792 によれば、エコーの場合の ICMP ヘッダ形式が次のように定義されています。この形式のヘッダに所定のコードをセットして送信すれば、エコーリクエスト (またはリプライ) になるわけです。なお、Data の領域には任意のデータをセットすることができます。(上のダンプ例では何かのデータが含まれていますね)

Echo or Echo Reply Message

    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |     Type      |     Code      |          Checksum             |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |           Identifier          |        Sequence Number        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |     Data ...
   +-+-+-+-+-

到達不能検知

宛先ホストや途中のネットワーク経路に問題があるときは、ICMP の “Destination Unreachable” メッセージにより異常を通知されることがあり、この場合は明らかに到達不能と判断できます。しかし、ICMP による通知は必ず受信できるわけではないため、最終的には、エコーリプライ受信待ちのタイムアウトにより、宛先へ到達できなかったと判断することになります。

RTT の計測

多くの ping の実装には、リプライの受信までにかかった時間 (ラウンドトリップタイム) を表示する機能があります。これは、リクエスト送信時刻を記録しておき、リプライ受信時の時刻との差を求めることで実現されます。

既存の ping の実装では、ICMP の Data 領域に送信時刻情報を保存するテクニックがよく使われています。Data 領域は、リプライメッセージにそのままコピーされるため、リプライ受信時に参照することができます。

上の ICMP エコーリクエストのダンプ結果のうち、Data 領域を改めてみてみます。

	0x0010:                                b38d 084c
	0x0020:  8f20 0a00 0809 0a0b 0c0d 0e0f 1011 1213
	0x0030:  1415 1617 1819 1a1b 1c1d 1e1f 2021 2223
	0x0040:  2425 2627 2829 2a2b 2c2d 2e2f 3031 3233
	0x0050:  3435     

1 オクテットごとにインクリメントされている、いかにもダミーデータという感じですが、よく見ると先頭の 8 オクテットだけ異なります。実はこの部分が送信時刻情報 (struct timeval) です。

ご存知の通り、インターネットではパケットの消失や配送順序の逆転が起こり得るため、送信時刻情報の管理が複雑になりがちですが、この場合は、単純に受信したパケットから送信時刻情報を取り出せばいいので、とてもシンプルに実装できます。

なお、上のパケットダンプ例は x86 32bit マシンで実行したものです。下のダンプ例は x86 64bit マシンで実行したものですが、上の例と微妙に異なるのがおわかりでしょうか。struct timeval のサイズの違いが見て取れます。

03:26:58.674303 IP 127.0.0.1 > 127.0.0.1: ICMP echo request, id 37930, seq 1, length 64
	0x0000:  4500 0054 0000 4000 4001 3ca7 7f00 0001
	0x0010:  7f00 0001 0800 3310 942a 0001 825b 104c
	0x0020:  0000 0000 d549 0a00 0000 0000 1011 1213
	0x0030:  1415 1617 1819 1a1b 1c1d 1e1f 2021 2223
	0x0040:  2425 2627 2829 2a2b 2c2d 2e2f 3031 3233
	0x0050:  3435                                   

ping の作り方

要するに ICMP echo メッセージを送受信すれば ping 的なプログラムになります。そこで、ICMP パケットを送受信するための socket API の使い方を見ていきたいと思います。

TCP でも UDP でもない ICMP は、raw socket と呼ばれる特殊な socket を使って送受信します。raw socket を用いると、プロトコルヘッダまで含めて送受信することができます。

基本的な手順は次の通りです。

  • raw socket 生成
  • ICMP エコーリクエストパケットを構築
  • sendto() 等でリクエストを送信
  • recvfrom() 等でリプライを受信
  • 受信したパケットを分析

socket の作成

    s = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);

まずはおなじみ socket() システムコールです。type として SOCK_RAW を指定します。普段はほとんど使わない 3 番目の引数ですが、raw socket の場合は明示的に指定する必要があります。今回は ICMP を扱いたいので IPPROTO_ICMP となります。

raw socket の生成には root 権限が必要です。どんなパケットでも送信できてしまうので、誰にでも許可するわけにはいかないということなのでしょう。

なお、IP ヘッダまで含めて自前で用意するかをオプションで選択できます。setsockopt() で IP_HDRINCL をオンにすると、自前で用意したものを送信できます。今回は特に必要がないので、オフのままです。オフの場合は、ICMP ヘッダ以降のデータを用意すればよいことになります。

ICMP パケットの生成

ICMP エコーリクエストを生成します。とりあえず、RTT の計算機能は考えないことにして、Data 部分は空にします。所定の形式でヘッダ部のみ作ればよいことになります。

#define ICMP_ECHO_REQ   8
#define ICMP_ECHO_REP   0

typedef struct {
    uint8_t   icmpe_type;
    uint8_t   icmpe_code;
    uint16_t  icmpe_csum;
    uint16_t  icmpe_id;
    uint16_t  icmpe_seq;
} icmp_echo_t;

int
set_icmp_echo_header(void *buf, int len, int echo_seq, int echo_id)
{
    icmp_echo_t *icmpe;

    if (len < sizeof(*icmpe))
        return -1;

    memset(buf, 0, len);
    icmpe = (icmp_echo_t *) buf;
    icmpe->icmpe_type = ICMP_ECHO_REQ;
    icmpe->icmpe_code = 0;
    icmpe->icmpe_csum = 0;
    icmpe->icmpe_id = htons(echo_id);
    icmpe->icmpe_seq = htons(echo_seq);
    icmpe->icmpe_csum = checksum(buf, sizeof(*icmpe));

    return sizeof(*icmpe);
}

このような感じで普通に作れば ok です。

複数のエコーリクエストを送信したとき、リプライがどのリクエストに対応したものか判断するため、sequence の値は送信するたびにインクリメントするようにします。

自分が送信したリクエストに対するリプライかどうか判定するため、他に使われていなそうな値を id にセットします。同じホスト上で、同時に他の ping が実行されていた場合に困らないようにしましょう。ここでは、乱数を生成して使うのがおすすめです。

なお、リプライメッセージの送信元は、リクエストの送信先と異なる場合があります。このリクエストに対するリプライかの判定に、id の値を用いる方法も考えられます。

チェックサムは、1の補数の和のさらに1の補数を求める独特の方法で計算します。

ICMP エコーリクエストの送受信

普通に sendto() や recvfrom() を使ってデータを送受信します。

受信したパケットの解析

パケットを受信したら、受信したメッセージが、自分が送信したエコーリクエストに対するリプライか確かめる必要があります。

raw socket からの受信データは、送信時と異なり、常に IP ヘッダが含まれています。受信した ICMP ヘッダは、先頭の IP ヘッダの続きに存在しているので、まずは IP ヘッダのサイズを調べます。

RFC791 によれば、IP ヘッダは次のような形式です。

    0                   1                   2                   3   
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |Version|  IHL  |Type of Service|          Total Length         |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |         Identification        |Flags|      Fragment Offset    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |  Time to Live |    Protocol   |         Header Checksum       |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                       Source Address                          |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Destination Address                        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                    Options                    |    Padding    |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

IP ヘッダは、オプションヘッダの有無により長さが可変するので、IHL フィールドの値からヘッダ長を計算する必要があります。IP ヘッダ長がわかれば ICMP ヘッダの位置がわかります。ICMP ヘッダの type, code, sequence, id 等の値を参照して、全て適切な値であれば、間違いなく自分が送信したリクエストに対するリプライだと判断できます。

具体的には次のような感じにするとよいでしょう。

int
read_icmp_echo_header(int *echo_seq, int *echo_id, void *buf, int len)
{
    int ihl;
    icmp_echo_t *icmpe;

    /* IP header length */
    ihl = (*((uint8_t *) buf) & 0x0f) * 4;
    icmpe = (icmp_echo_t *) (buf + ihl);

    if (len < ihl + sizeof(icmp_echo_t))
        return -1;
    if (icmpe->icmpe_type != ICMP_ECHO_REP || icmpe->icmpe_code != 0)
        return -1;

    *echo_id = ntohs(icmpe->icmpe_id);
    *echo_seq = ntohs(icmpe->icmpe_seq);

    return 0;
}

作ってみた

というわけで、一連の流れさえ理解してしまえば、ping 相当のツールを作るのは簡単だということが理解いただけたのではないでしょうか。

よく使うツールだけに、利用状況にあわせて作られたものがあると大変便利で仕事もはかどります。

今回、実装のサンプルを兼ねてちょっとしたものを作ってみましたので、以下の場所で公開いたします。何かの参考にしていただけると幸いです。

http://github.com/ebisawa/ruby-bulkping

Author: ebisawa