【実務・中級編】 ICMPベースのtraceroute実装(Windows tracert) – トラブルシューティング&ネットワーク運用監視実践ガイド

夜中の3時、データセンターの監視モニターに赤々と浮かび上がるアラート。
「主要APIサーバーからのレスポンスタイムが急激に悪化、一部タイムアウト発生」。

こういう修羅場で、君たちは真っ先に何を叩く?
「とりあえず ping だろ」――そう答えた若手エンジニアの背中を、私は幾度となく叩き直してきた。ping はただ「生きているか死んでいるか」を教えるだけの優しい相棒に過ぎない。パケットがどのルーターのどのバッファで迷子になり、どのプロバイダの敷居をまたぐ時にドロップしているのかを知りたいなら、私たちが頼るべきは traceroute だ。

だが、ここで一つ、インフラエンジニアなら絶対に知っておかなければならない「OSの残酷な罠」がある。
LinuxやmacOSが使う traceroute は、デフォルトではUDPやTCPパケット(あるいはParis traceroute)を投げる。しかし、私たちが日常的に使い慣れているWindowsの tracert は、なんと ICMP Echo Request(いわゆるpingのパケット) をベースにその足跡を刻んでいくのだ。

この「Windows版 tracert の特異な挙動」と「パケットの裏側の真実」を、今日はみっちり叩き込んでやろう。

—

1. なぜWindowsの tracert はICMPを使うのか?

ネットワークの教科書を開けば、traceroute の基本原理はこう書かれている。
「IPパケットのTTL(Time To Live)フィールドを1から順番にインクリメントしながら送信し、途中のルーターがTTL切れを検知して送り返してくる ICMP Time Exceeded メッセージをキャッチする」と。

これはWindowsでもLinuxでも変わらない。しかし、「何を包んで(カプセル化して)送るか」 が違う。

  • Linux/macOS版 traceroute: 使われなくなった高番ポートあてのUDPデータグラム、あるいはTCP SYNパケットを飛ばすのがデフォルト。
  • Windows版 tracert: ピュアな ICMP Echo Request (Type 8, Code 0) を飛ばす。

なぜWindowsはICMPを選ぶのか? 歴史的経緯もあるが、要するに「ファイアウォールやセキュリティアプライアンスの裏をかきやすい」という実用上の理由が大きい。多くの企業ネットワークやクラウドのセキュリティグループでは、未知のUDPポートスキャンや怪しいTCPパケットは厳しくドロップされるが、「pingに応答する」ためのICMP Echoは比較的許可されているケースが多い。だからこそ、Windowsの tracert はどんな環境からでも「そこそこルートを暴き出せる」万能なツールとして設計されたのだ。

しかし、この「ICMPベース」であることが、のちにクラウド環境や厳格なステートフル・ファイアウォールを相手にしたとき、奇妙なパケットロスの幻影を見せる原因になる。そのメカニズムを次のセクションで解き明かそう。

—

2. パケットが駆け抜ける通信フロー(シーケンス)の裏側

頭の中で、自分の手元にあるWindows端末から tracert 8.8.8.8 を叩いた瞬間を想像してほしい。ネットワークのワイヤー上では、次のようなドラマが繰り広げられている。

[Windows Client]                     [Router A (Hops 1)]             [Google DNS (8.8.8.8)]
       |                                     |                                 |
       |--- ICMP Echo (TTL=1) -------------->| (TTL切れ!破棄)                 |
       |<-- ICMP Time Exceeded --------------|                                 |
       |    (来自Router AのIP)                |                                 |
       |                                     |                                 |
       |--- ICMP Echo (TTL=2) -------------->|-------------------------------->| (宛先到達!)
       |<-- ICMP Time Exceeded --------------|                                 |
       |    (来自Router BのIP)                |                                 |
       |                                     |                                 |
       |--- ICMP Echo (TTL=3) ------------------------------------------------>|
       |<-- ICMP Echo Reply (Type 0) ------------------------------------------|

ここで重要なのは、Windowsの tracert は、1つのTTL値に対してデフォルトで3回のICMP Echo Requestを送信するという仕様だ。画面上には 1 ms <1 ms 1 ms のように3つの往復時間が表示されるのは、このためである。

パラメーターの現実:実務で使えるオプション

Windowsのコマンドプロンプトで tracert /? と叩けば出てくるが、現場で本当に役立つパラメーターは限られている。

:: ホスト名への名前解決を行わず、純粋にIPアドレスだけで高速にトレースする (-d)
:: DNSの逆引きタイムアウトでイライラする現場では必須のテクニックだ
tracert -d 8.8.8.8

:: 最大ホップ数を指定する (-h 最大ホップ数)
:: 無駄に遠いホップまで調べてタイムアウトを待つ時間を削る
tracert -h 15 192.168.1.1

:: タイムアウト時間をミリ秒で指定する (-w タイムアウトms)
:: デフォルトは4000ms(4秒)だが、海外の遠隔サーバーを叩くときは少し延ばすと精度が上がる
tracert -w 2000 8.8.8.8

—

3. コードで再現する:PythonによるICMP Tracerouteの自作

「ツールに頼るな、仕組みを理解しろ」というのが我がNOCの鉄則だ。Windowsの tracert がやっていることを、Pythonの socket ライブラリを使って泥臭く再現してみよう。RAWソケットを叩くため、実行には管理者権限(Linuxならroot、Windowsなら管理者としての実行)が必要になる。

以下のコードは、WindowsのICMPベースの挙動にインスパイアされた、教育的かつ実用的なトレーサーの実装例だ。

import socket
import struct
import time
import sys

def checksum(source_string):
    """
    ICMPパケットのチェックサムを計算する関数
    インターネットチェックサム(RFC 1071)の標準的な実装
    """
    sum = 0
    count_to = (len(source_string) // 2) * 2
    count = 0
    
    while count < count_to:
        val = source_string[count + 1].to_bytes(1, 'big')[0] * 256 + source_string[count].to_bytes(1, 'big')[0]
        sum += val
        sum &= 0xffffffff
        count += 2
        
    if count_to < len(source_string):
        sum += source_string[len(source_string) - 1].to_bytes(1, 'big')[0]
        sum &= 0xffffffff
        
    sum = (sum >> 16) + (sum & 0xffff)
    sum += (sum >> 16)
    answer = ~sum
    answer &= 0xffff
    answer = answer >> 8 | (answer << 8 & 0xff00)
    return answer

def icmp_traceroute(dest_name, max_hops=30, timeout=2.0):
    """
    ICMP Echo Requestを用いた簡易Traceroute実装
    """
    try:
        dest_addr = socket.gethostbyname(dest_name)
    except socket.gaierror as e:
        print(f"名前解決に失敗しました: {dest_name} ({e})")
        return

    print(f"traceroute to {dest_name} ({dest_addr}), {max_hops} hops max")

    icmp_protocol = socket.getprotobyname("icmp")
    
    for ttl in range(1, max_hops + 1):
        # 生ソケット(RAW Socket)の作成。ICMPを直接叩く
        recv_socket = socket.socket(socket.AF_INET, socket.SOCK_RAW, icmp_protocol)
        send_socket = socket.socket(socket.AF_INET, socket.SOCK_RAW, icmp_protocol)
        
        # ソケットのTTLを設定
        send_socket.setsockopt(socket.IPPROTO_IP, socket.IP_TTL, struct.pack('I', ttl))
        
        # タイムアウトの設定
        recv_socket.settimeout(timeout)
        # 受信用のバインド(任意のローカルIPからの受信を許可)
        recv_socket.bind(("", 0))
        
        # ICMP Echo Requestパケットの組み立て (Type=8, Code=0, Checksum=0, Identifier=12345, Sequence=ttl)
        # パケット構造の詳細はRFC 792を参照のこと
        packet_id = 12345 & 0xFFFF
        header = struct.pack("bbHHh", 8, 0, 0, packet_id, ttl)
        data = struct.pack("d", time.time()) # ペイロードにタイムスタンプを埋め込む
        
        # チェックサムの計算
        my_checksum = checksum(header + data)
        header = struct.pack("bbHHh", 8, 0, socket.htons(my_checksum), packet_id, ttl)
        packet = header + data
        
        # 送信時刻の記録
        send_time = time.time()
        try:
            send_socket.sendto(packet, (dest_addr, 1))
        except socket.error as e:
            print(f"{ttl}: 送信エラー ({e})")
            send_socket.close()
            recv_socket.close()
            continue
            
        hop_ip = None
        elapsed_time = 0
        
        while True:
            try:
                # パケットの受信待ち
                _, curr_addr = recv_socket.recvfrom(512)
                elapsed_time = (time.time() - send_time) * 1000 # ミリ秒に変換
                hop_ip = curr_addr[0]
                break
            except socket.timeout:
                # タイムアウトした場合は星印を表示して次へ
                break
                
        send_socket.close()
        recv_socket.close()
        
        if hop_ip:
            print(f"{ttl:2d}    {hop_ip}    {elapsed_time:.2f} ms")
            # 宛先IPに到達したらループを抜ける
            if hop_ip == dest_addr:
                print("トレース完了。")
                break
        else:
            print(f"{ttl:2d}    * * * タイムアウト")

if __name__ == "__main__":
    # 例としてGoogleパブリックDNSを叩く
    icmp_traceroute("8.8.8.8")

このコードを動かすと、OSがどれほどエレガントに、かつ泥臭くパケットの寿命(TTL)をコントロールしているかが皮膚感覚で理解できるはずだ。

—

4. 現場の教訓:ICMP Tracerouteが私たちを欺くとき

最後に、シニアとして君たちに一つ、現場でよくある「罠」を共有しておこう。

クラウド環境(AWSやAzureなど)や最新の次世代ファイアウォールを通過する通信に対してWindowsの tracert を実行すると、途中のルーターがすべて * * *(タイムアウト)になり、最後の宛先だけがポツンと応答を返すという現象に遭遇することがある。

これは障害ではない。セキュリティ機器がICMPのTTL切れパケットに対する応答(ICMP Time Exceeded)をレートリミットしているか、あるいは完全にブロックしているだけなのだ。
パケット自体は確実に宛先まで届いて処理されているにもかかわらず、途中の経路が見えない。こういうときに「経路がおかしい!」と慌ててルート変更を申請したりすると、上司やキャリアから冷たい視線を浴びることになる。

障害対応の基本は、目の前のツールの出力に振り回されることではなく、「そのツールが内部でどのようなパケットを生成し、ネットワーク機器がそれに対してどう振る舞う仕様になっているか」を解像度高くイメージすることだ。

さあ、コーヒーブレイクはここまでだ。アラートの続きを片付けに行こう。ネットワークの向こう側で、パケットたちが君のコマンドを待っている。

コメント

タイトルとURLをコピーしました