TL;DR

  • TCP には「まだいますか?」と尋ねる信号がありません。接続とは、両端がそれぞれ持つ状態にすぎません。相手が黙って消えたとき(NATがマッピングを捨てた、無線リンクが切れた、マシンの電源が落ちた)、手元のカーネルが気づくのは、自分が送ったものへの返事が十分長く来なかったときだけです。
  • そのため、見え方は自分が何をしているかで変わります。僕の実験環境(Linux 6.12、カーネルの既定値)では、読むだけのソケットは、60秒観察してもエラーなしでブロックしたままでした。書くソケットは write() がすぐ成功し、その後、再送の諦め時間である938秒(約15.6分)で ETIMEDOUT になりました(既定の15回再送について、man ページは13〜30分としています)。
  • SO_KEEPALIVE はアイドルの接続なら相手の消失を検知できます(2秒/1秒/3回に調整して4.6秒)。しかし、未確認の送信データがあるあいだは何もしません。一番効いてほしい場面です。TCP_USER_TIMEOUT は、その場面に上限を設けます(5秒の設定で5.5秒)。アプリケーションのハートビートは、相手のアプリケーションまで確認できる唯一の仕組みで、ping 間隔1秒、期限3秒の設定で、無音から3.0秒で検知しました。
  • 古典的な「半開」(相手が状態を失ったが、再び到達できる状況)は、次の書き込みで RST が返って自然に解消します。難しいのは、自然には解消しない、黙ったままのブラックホールです。
  • すべて、107行の Python ファイル1つで再現できます。root は不要で、非特権のユーザー名前空間とネットワーク名前空間を使います。

背景:「接続が死んだ」とは何か

長寿命の TCP 接続(メッセージストリーム、データベースセッション、WebSocket)は、NAT の背後に置かれることが多く、モバイルでは、来たり切れたりする無線リンクの先にもあります。経路上の何かが何も言わずに死ぬと、アプリケーションは接続が健在だと信じ続けます。

RFC 9293 §3.5.1 は、**半開(half-open)**接続を定義しています。片方の端が相手に知らせずに接続を閉じた、または中断した場合や、障害や再起動でメモリを失い、両端の状態がずれた場合です。そのような接続は、どちらの向きでもデータを送ろうとすると、自動的にリセットされます。状態が残っている側がデータを送り、状態を失った側が RST を返すからです。

これは教科書的なケースで、しかも穏やかなほうです。つらいのは黙ったブラックホールです。相手に向かうパケットは捨てられ、FIN も RST も ICMP エラーも、何も返ってきません。マッピングをタイムアウトさせた NAT は、どちらもありえます。RFC 5382 は、実装に委ねています。NAT が生きている接続を放棄するとき、端点に TCP RST を送ってもよく、黙って放棄してもよい(MAY)とされています。マッピングのない後続のパケットについても、黙って捨てるか RST を返すかは実装に任されています。つまり、通知されると仮定してはいけません。

この記事の中心の問いは次のとおりです。自分のプログラムは、気づくまでにどれだけかかるのか。誤検知を出さずに、その時間を短くするにはどうするか。

カーネルが知り得ない理由

TCP の仕事は、確実な配送です。送信側から見ると、パケットロスと相手の死は、どちらも「ACKが来ない」という同じ現象です。筋の通った方針は、指数バックオフで再送し、いずれ諦めることだけです。RFC 9293 §3.8.3 は、2つのしきい値 R1(経路を疑い始める)と R2(接続を閉じる)を定めています。R2 は少なくとも100秒に相当する値にする(SHOULD)ことと、アプリケーションが接続ごとに R2 を設定できなければならない(MUST)ことが書かれています。再送タイマー自体は上限までバックオフします。RFC 6298 §2 は、最大 RTO を置いてもよいが60秒以上とするよう定めており、Linux は上限を120秒(TCP_RTO_MAX_SEC)と定義しています。

Linux の R2 は、tcp_retries2 という sysctl です。tcp(7) によると、既定値は15で、再送タイムアウトによって約13〜30分に相当します。さらに、RFC 1122 が定める下限の100秒は、「一般に短すぎるとみなされている」とも書かれています。

データを待っているだけなら、再送するものがなく、タイマーがありません。これが一番たちの悪いケースで、はしごの最初の段です。

はしご:仕組みを1段ずつ足す

各段で仕組みを1つずつ足します。段ごとに実験環境のスクリプトのシナリオが1つあり(全文は、あとの実験環境の節に載せます)、以下の時間は計算ではなく計測です。

段0:何もしない、読むだけ

クライアントがリクエストを送り、返事を待ってブロックしています。そこで相手側を切り離します。実験環境では次のとおりです。

[    0.00s] peer lost (all packets dropped)
[   60.06s] still blocked after 60 s: no data, no EOF, no error

ss -tno で見ると、ソケットは ESTAB で、タイマーがまったくありません。再送するものも、プローブするものもなく、カーネルが起きる理由がないのです。観察は60秒で止めましたが、終わる理由はありません。読み取りだけのクライアントは、他にどんな設定をしていても、自前のタイムアウトが必要です。

段1:何もしない、ただし書く

今度は、相手が消えた後にクライアントが100バイトを書きます。write() は送信バッファにコピーするだけなので、すぐ成功します。そのあとは、再送タイマーが引き継ぎます。ss -tno では、バックオフが timer:(on,<残り時間>,<再送回数>) として見えます。

[    0.00s] peer lost (all packets dropped)
[    0.00s] write() returned: the bytes only reached the send buffer
[   30.01s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,22sec,7)
[  120.02s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,1min31sec,9)
[  240.04s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,1min33sec,10)
[  360.05s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,1min34sec,11)
[  480.07s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,1min35sec,12)
[  600.08s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,1min35sec,13)
[  720.10s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,1min36sec,14)
[  840.11s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,1min37sec,15)
[  930.13s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,7.464sec,15)
[  938.42s] recv() failed: errno=110 Connection timed out

(抜粋です。完全なログから約30秒ごとの ss のサンプルを選び、空白を詰めてあります。)このマシンでは、938秒(約15.6分)後に ETIMEDOUT で失敗しました。man ページの既定の15回再送と整合しますが、正確な値は RTO、つまり経路の往復時間に左右されるので、実際のネットワークでは変わります。カーネルのドキュメントに、照合できる値があります。既定の tcp_retries2 が15のとき、最小 RTO の200msから倍々に増え、上限の120秒で頭打ちになる接続の仮想的なタイムアウトは924.6秒です(倍々に増える10回の待ちが合計204.6秒、上限120秒の待ちが6回)。この値は下限で、TCP はこれを超えた最初の RTO で諦めます(ip-sysctl)。938秒はそのすぐ上にあり、整合します。

段2:アイドル接続の SO_KEEPALIVE

keepalive は、アイドル接続のために設計されました。RFC 9293 §3.8.4 では実装は任意(MAY-5)で、既定はオフ、間隔の既定は「2時間以上」と定められています。Linux の既定値は、アイドル7200秒、プローブ間隔75秒、プローブ9回です(tcp(7))。アイドルで死んだ接続は、2時間に加えて「約11分」後に切れます。この既定値は、素早い失敗ではなく、後始末のためのものです。ソケットごとに TCP_KEEPIDLE、TCP_KEEPINTVL、TCP_KEEPCNT で調整できます。実験環境で2秒/1秒/3回にすると、次のようになりました。

[    4.59s] recv() failed: errno=110 Connection timed out

プローブは、アイドル2秒後から始まります(最後の通信は相手が消える0.5秒前なので、そこから数えます)。返事のないプローブが3回続くと、カーネルは諦めます。つまり、おおよそ「アイドル時間 + プローブ回数 × 間隔 − すでに経過したアイドル時間」です。プローブを3回にする理由も大切です。RFC 9293 は、keepalive の実装が、特定のプローブへの無応答を接続の死と解釈してはならない(MUST NOT)としています。データを含まない ACK は、確実には再送されないからです。

段3:同じ keepalive、ただし未確認のデータがある場合

まったく同じオプションを設定し、相手が消えた後に100バイトを書きます。keepalive が汎用の生存確認なら、今回も約4.6秒かかるはずです。実際は違います。

[    0.00s] peer lost (all packets dropped)
[    0.00s] write() returned: the bytes only reached the send buffer
[    1.01s] ss: ESTAB 0 100 10.0.0.1:52196 10.0.0.2:9000 timer:(on,660ms,2)
[   30.11s] ss: ESTAB 0 100 10.0.0.1:52196 10.0.0.2:9000 timer:(on,22sec,7)
[  120.47s] ss: ESTAB 0 100 10.0.0.1:52196 10.0.0.2:9000 timer:(on,1min30sec,9)
[  300.15s] ss: ESTAB 0 100 10.0.0.1:52196 10.0.0.2:9000 timer:(on,33sec,10)
[  600.27s] ss: ESTAB 0 100 10.0.0.1:52196 10.0.0.2:9000 timer:(on,1min35sec,13)
[  930.48s] ss: ESTAB 0 100 10.0.0.1:52196 10.0.0.2:9000 timer:(on,7.108sec,15)
[  938.42s] recv() failed: errno=110 Connection timed out

keepalive タイマーの出番は来ません。接続を握っているのは再送タイマーで、結果は938秒と、段1と同じです。これは仕様どおりの動作で、癖ではありません。「Keep-alive packets MUST only be sent when no sent data is outstanding」(RFC 9293 §3.8.4)と書かれています。keepalive はアイドル接続をカバーしますが、書き込み中の接続はカバーしません。

段4:TCP_USER_TIMEOUT

TCP_USER_TIMEOUT(Linux 2.6.37以降)は、RFC 9293 が求める接続ごとの R2 にあたります。送信したデータが未確認のまま、あるいはバッファのデータが未送信のまま残ってよい最大時間をミリ秒で指定し、超えると ETIMEDOUT で接続を閉じます。5000msに設定し、同じ相手の消失、同じ書き込みで試します。

[    1.01s] ss: ESTAB 0      100  10.0.0.1:57044  10.0.0.2:9000 timer:(on,660ms,2)
[    2.01s] ss: ESTAB 0      100  10.0.0.1:57044  10.0.0.2:9000 timer:(on,1.304sec,3)
[    3.01s] ss: ESTAB 0      100  10.0.0.1:57044  10.0.0.2:9000 timer:(on,300ms,3)
[    4.02s] ss: ESTAB 0      100  10.0.0.1:57044  10.0.0.2:9000 timer:(on,1.400sec,4)
[    5.02s] ss: ESTAB 0      100  10.0.0.1:57044  10.0.0.2:9000 timer:(on,396ms,4)
[    5.46s] recv() failed: errno=110 Connection timed out

再送は通常のスケジュールで行われます(man ページによると、このオプションは再送のタイミングには影響しません)。動くのは、諦める時点だけです。判定は再送のタイミングで行われるので、きっかり5.000秒ではなく、5秒より少し後になります。man ページには、keepalive が有効なときは、TCP_USER_TIMEOUT が keepalive の失敗で接続を閉じるタイミングを上書きする、とも書かれています。

カバーしないものは、アイドルの読み取り側です。このオプションが対象にするのは、送信したデータが未確認のまま残ることです。実験環境では、5000msのユーザータイムアウトを設定したアイドルの読み取り側は、60秒後もエラーなしでブロックしたままで、段0とまったく同じでした。

逆の面もあります。短いユーザータイムアウトは、短時間の障害なら生き延びられた接続まで切ってしまいます。man ページも、タイムアウトを長くすると、TCP 接続が「エンドツーエンドの接続性がない長い期間を生き延びられる」と書いています。

段5:アプリケーションのハートビート

カーネルの仕組みはどれも、相手側のプログラムが生きていて応答できるかを確認しません。固まったプロセスの下でも、健全なカーネルはすべてにACKを返します。また、両側で別々に TCP を終端するミドルボックスは、死んだバックエンドの代わりに応答できます。完全な確認は、アプリケーションのプロトコルにしかありません。小さなメッセージを定期的に送り、期限内に何らかの受信バイトがあることを要求します。

[    3.00s] peer lost
[    6.01s] 3 s without any inbound byte -> close and reconnect

実験環境では、クライアントが1秒ごとに ping を送り、何かを受信したら生存の証拠とみなします。相手は3.00秒に消え、クライアントは6.01秒、つまり3.0秒後に諦めました。最悪の場合、検知にかかる時間は、無音の期限に ping 間隔1つを足した程度です(ループが間隔ごとに1回しか確認しないため)。この時間はカーネルの再送状態とは無関係です。確認の主体はソケットのイベントではなく、プログラム内のタイマーだからです。

おまけの段:教科書的な半開

相手が再起動した(あるいは接続を忘れた)あとで経路が復旧すると、最初の書き込みで RST が届きます。実験環境では、パケットを捨てる、サーバーに接続を破棄させる、ネットワークを戻す、という順で再現できます。

[    0.00s] peer lost (all packets dropped)
[    0.31s] network is back, but the peer has no such connection any more
[    5.32s] client idle 5 s, nothing noticed: True  | ss: ESTAB 0      0     10.0.0.1:37880     10.0.0.2:9000
[    5.32s] write() returned: the bytes only reached the send buffer
[    5.32s] recv() failed: errno=104 Connection reset by peer

アイドルのクライアントは、アイドルである限り何も気づかず、送信した瞬間にすぐ知ります。RFC 9293 §3.5.1 の予告どおりです。ここまでの段が扱ってきたのは、リセットではなく沈黙だということも、この例から分かります。リセットは簡単なほうのケースです。

仕組みの比較

仕組み検知できるもの検知できないもの検知までの時間(実験環境)コスト
何もしないなし黙った障害すべて読み取り:60秒以内には検知せず。書き込み:938秒(約15.6分)なし
SO_KEEPALIVE(調整済み)アイドル接続での相手の消失未確認データのある接続すべて4.6秒(アイドル2秒、1秒×3回)間隔ごとに小さなパケット数個。ソケットごとの設定
TCP_USER_TIMEOUT未確認データがあるときの相手の消失アイドルの読み取り側5.5秒(5000ms)通信量の増加なし。短くすると、短い障害でも接続が切れる
アプリケーションのハートビート相手の消失、相手の停止、ブラックホール。アイドルでも通信中でも経路上で測れないもの3.0秒(ping 1秒、無音の期限3秒)間隔ごとに双方向で小さなメッセージ1つ

これらは重ねて使えます。正しさのためにハートビート、書き込みを素早く失敗させるために TCP_USER_TIMEOUT、いなくなったクライアントを回収するためにサーバー側で keepalive、という組み合わせです。

NAT はどこに関わるか

NAT は接続ごとにマッピングを持ち、アイドル期間のあとで捨てます。その期間より長くアイドルだと、次のパケットは、あなたを覚えていない NAT にぶつかります。RFC 5382 REQ-5 は、NAT が接続の生死を判断できない場合、確立済み接続のアイドルタイムアウトを「2時間4分未満にしてはならない(MUST NOT)」としています。その理由は、既定の keepalive の計算です。アプリケーションが既定の頻度(2時間ごと)で keepalive パケットを送れば、NAT は接続が生きていると受動的に判断できます。追加の4分は、飛行中のパケットが NAT を通過するための余裕です。

これは RFC に従う NAT への要件です。すべての NAT やキャリアグレードのゲートウェイが従う保証はなく、実際のネットワークのタイムアウトは測っていません。そのため、数字は挙げません。設計への示唆は次のとおりです。

  • アイドルタイムアウトは、不明でネットワークごとに異なるものとして扱います。ハートビートの間隔は設定可能にして、経路上の最短のタイムアウトとして想定する値より十分短くします。
  • TCP_KEEPIDLE の既定値の2時間は、積極的な NAT に対してはまったく守ってくれない値です。keepalive パケットでマッピングを保つなら、アイドル時間を下げてください。
  • ハートビートは、マッピングを保つための通信も兼ねます。カーネルの機能が使えても、ハートビートを走らせる理由がもう1つ増えます。

実験環境

ここまでの結果は、すべてこのスクリプトが出したものです。2つ目のネットワーク名前空間を作り、veth ペアでつなぎ、相手側の端を down にして相手を「失わせる」ので、相手に向かうパケットはすべて黙って捨てられます。必要なものは、Linux、Python 3、iproute2(ip、ss)、util-linux(unshare、nsenter)、非特権のユーザー名前空間が有効なことです。root は不要です。

#!/usr/bin/env python3
"""deadpeer.py - how long does a TCP client take to notice that its peer is gone?

Linux only. Needs unprivileged user namespaces (no real root). Usage:
    python3 deadpeer.py <scenario>
Scenarios: silent-read | keepalive-read | user-timeout-read | keepalive-write |
           user-timeout-write | default-write | heartbeat | reset-on-write
The peer is "lost" by taking its end of a veth pair down, so every packet to it
is dropped without any ICMP or RST - what a vanished NAT mapping or a dead radio
link looks like from the client.
"""
import os, select, signal, socket, struct, subprocess, sys, threading, time

PEER = ("10.0.0.2", 9000)

def sh(*cmd, ns=None):
    pre = ["nsenter", "-t", str(ns), "-n"] if ns else []
    return subprocess.run(pre + list(cmd), check=True, capture_output=True, text=True).stdout

def setup_lab():
    """Create the second network namespace and the veth pair. Returns the namespace's pid."""
    holder = subprocess.Popen(["unshare", "-n", "sleep", "3600"]); time.sleep(0.3)
    sh("ip", "link", "add", "c0", "type", "veth", "peer", "name", "s0")
    sh("ip", "link", "set", "s0", "netns", str(holder.pid))
    sh("ip", "addr", "add", "10.0.0.1/24", "dev", "c0"); sh("ip", "link", "set", "c0", "up")
    sh("ip", "addr", "add", "10.0.0.2/24", "dev", "s0", ns=holder.pid)
    sh("ip", "link", "set", "s0", "up", ns=holder.pid)
    mac = sh("ip", "-o", "link", "show", "s0", ns=holder.pid).split("link/ether ")[1].split()[0]
    # pin the neighbour entry, so a dead peer is not reported as an ARP failure ("No route to host")
    sh("ip", "neigh", "replace", PEER[0], "lladdr", mac, "dev", "c0", "nud", "permanent")
    return holder

SERVER = r'''
import socket, signal, struct, sys
echo = sys.argv[1] == "echo"
s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("10.0.0.2", 9000)); s.listen(); c, _ = s.accept()
def forget(*_):                      # close with RST ... which the lab will drop
    c.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)); c.close(); raise SystemExit
signal.signal(signal.SIGUSR1, forget)
while True:
    data = c.recv(65536)
    if not data: break
    if echo: c.sendall(b"PONG\n")
'''

def main(scenario):
    holder = setup_lab()
    srv = subprocess.Popen(["nsenter", "-t", str(holder.pid), "-n", "python3", "-c", SERVER,
                            "echo" if scenario == "heartbeat" else "sink"])
    time.sleep(0.5)
    link = lambda state: sh("ip", "link", "set", "s0", state, ns=holder.pid)
    ss = lambda: " | ".join(l.strip() for l in sh("ss", "-tno", "dst", PEER[0]).splitlines()[1:]) or "(socket gone)"
    t0 = time.monotonic()
    def log(msg): print(f"[{time.monotonic() - t0:8.2f}s] {msg}", flush=True)
    def err(e): return f"errno={e.errno} {os.strerror(e.errno or 0)}"

    c = socket.socket()
    if scenario.startswith("keepalive"):          # probe after 2 s idle, every 1 s, give up after 3 misses
        c.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
        c.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 2)
        c.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 1)
        c.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
    if scenario.startswith("user-timeout"):
        c.setsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT, 5000)   # ms
    c.connect(PEER); c.sendall(b"hello"); time.sleep(0.5)
    t0 = time.monotonic()

    if scenario == "heartbeat":                       # application-level liveness (see the article)
        c.setblocking(False); last_rx = time.monotonic(); lost = False
        while True:
            if not lost and time.monotonic() - t0 >= 3: link("down"); lost = True; log("peer lost")
            try: c.send(b"PING\n")
            except BlockingIOError: pass
            if select.select([c], [], [], 1.0)[0]:
                try: data = c.recv(100)
                except OSError: data = b""
                if data: last_rx = time.monotonic()
            if time.monotonic() - last_rx > 3:
                log("3 s without any inbound byte -> close and reconnect"); break
    else:
        link("down"); log("peer lost (all packets dropped)")
        if scenario == "reset-on-write":              # the peer also forgets the connection, then comes back
            srv.send_signal(signal.SIGUSR1); time.sleep(0.3); link("up")
            log("network is back, but the peer has no such connection any more")
            idle = not select.select([c], [], [], 5)[0]
            log(f"client idle 5 s, nothing noticed: {idle}  | ss: {ss()}")
        if scenario.endswith("write") or scenario == "reset-on-write":
            c.sendall(b"x" * 100); log("write() returned: the bytes only reached the send buffer")
        stop = threading.Event()
        def sample():
            while not stop.wait(30 if scenario == "default-write" else 1): log("ss: " + ss())
        if scenario in ("default-write", "keepalive-write", "user-timeout-write"):
            threading.Thread(target=sample, daemon=True).start()
        wait = 60 if scenario in ("silent-read", "user-timeout-read") else 3600
        if select.select([c], [], [], wait)[0]:
            try: c.recv(1); log("recv() returned")
            except OSError as e: log(f"recv() failed: {err(e)}")
        else:
            log(f"still blocked after {wait} s: no data, no EOF, no error")
        stop.set()
    srv.kill(); holder.kill()

if __name__ == "__main__":
    if os.geteuid() != 0:                             # become root inside new user + network namespaces
        os.execvp("unshare", ["unshare", "-Urn", sys.executable] + sys.argv)
    main(sys.argv[1])

知っておくべき点が2つあります。1つ目は、近隣(ARP)エントリを固定していることです。そうしないと、相手の消失が、再送タイムアウトではなく、ARP の失敗による No route to host として報告され、別のものを測ってしまうからです。2つ目は、「相手を失う」ことをパケットの廃棄で表現している点です。これは、消えた NAT のマッピングや死んだ無線リンクの近似であり、実際の NAT の挙動やモバイル無線の状態は再現していません。

使い分け

  • 多数のアイドルなクライアント接続を抱えるサーバー: いなくなったクライアントを回収できるよう、自分で決めたアイドル時間と間隔で SO_KEEPALIVE を有効にします。RFC 1122 §4.2.3.6 も、まさにこの用途(クライアントがクラッシュしたとき、サーバーアプリケーションが無限に待ち続けて資源を使い続けてしまう場合)を挙げています。
  • 主に読む、長寿命の接続を持つクライアント(購読やプッシュのチャンネル): アプリケーションのハートビートが必須です。NAT の背後の読み取り側は、カーネルの機能ではカバーできず、keepalive の既定値は遅すぎます。
  • 素早く失敗すべき書き込み側: TCP_USER_TIMEOUT を足します。値は、起こりうる一時的な障害より長く選びます。
  • 短命のリクエスト/レスポンス接続: リクエストごとの期限を使います。ここで述べた仕組みは不要です。

長い待ち時間を呼び戻してしまう失敗

  • keepalive を調整して、書き込み側も守られると思う。 症状: keepalive を積極的にしても、詰まった書き込み側が失敗するまで数分かかる(段3)。対処: TCP_USER_TIMEOUT かアプリケーションの期限を使います。
  • keepalive のアイドル時間が NAT のアイドルタイムアウトより長い。 症状: しばらく静かだった後に接続が死に、そのままハングする。対処: ハートビートまたは keepalive のアイドル時間を、想定する最短のタイムアウトより短くし、デプロイごとに設定可能にします。
  • ハートビートを1回落としただけで接続の死と判断する。 TCP は失われたセグメントを再送するので、期限には数往復分の余裕を持たせます。期限のリセットは、期待した返事だけでなく、どんな受信バイトでも行います。
  • 期限が後続のティックで延びてしまう。 起点はプローブを送った時刻です。次のティックで期限をさらに先へ延ばしてはいけません。
  • 中継装置がハートビートに応答する。 プロキシがバックエンドの代わりに返事をすると、証明できたのはプロキシの生存であり、サービスの生存ではありません。エンドポイント自身が応答しなければならないメッセージで確認してください。
  • ETIMEDOUT をユーザーにとって致命的とみなす。 意味するのは、この接続が失われたことであって、サービスが落ちたことではありません。大量障害がサンダリングハード(一斉再接続)にならないよう、ジッター付きの指数バックオフで再接続します。
  • ユーザータイムアウトを短くしすぎる。 短時間の障害がすべて再接続に変わります。

自分で試す

  1. スクリプトを deadpeer.py として保存し、python3 deadpeer.py silent-read を実行します。1分ほどで「still blocked」の行が出ます。
  2. keepalive-read、続けて keepalive-write を実行して比べます。前者は数秒で終わり、後者は再送を続けます(ここでは ss のサンプルが1秒ごとに出ます)。数分かかるので、そのまま走らせるか、Ctrl-C で止めてください。
  3. user-timeout-write を実行し、5000 を変えて、諦める時点が追従することを確認します。
  4. heartbeat を実行し、1秒の ping 間隔と3秒の期限を変えて、実行前に新しい検知時間を予想してみてください。
  5. reset-on-write を実行し、アイドルのクライアントが書き込むまで何も気づかないことを確認します。
  6. シナリオの実行中に別の端末で ss -tno を実行し、タイマーの列を観察します。

計測したことと、していないこと

時間はすべて、Linux 6.12 のサンドボックス1台、既定の sysctl(tcp_retries2 が15、tcp_keepalive_* が7200/75/9)、RTT がミリ秒未満の仮想リンクでの値です。カーネルやネットワークが違えば値も変わります。遅い2つのケース(既定設定の書き込み側と、積極的な keepalive を設定した書き込み側)は、最終版のスクリプトでどちらも938.4秒で失敗しました。別に実行した旧版のスクリプトでは939.0秒でした。短いケースは各2〜4回実行し、ばらつきは最大で0.2秒ほどでした(TCP_USER_TIMEOUT のケースは5.44〜5.65秒)。 引用した RFC と man ページの該当箇所、include/net/tcp.h のカーネル定数と、カーネルの ip-sysctl ドキュメントにある tcp_retries2 の項目(上で触れた924.6秒の下限)は、原文で確認しました。試していないことは、実際の NAT のアイドルタイムアウト、モバイル無線の挙動、他のOS、IPv6、TLS や WebSocket のプロキシです。ミドルボックスが死んだバックエンドの代わりにハートビートへ応答しうるという点は、プロキシが接続を終端する仕組みからの推論で、測定したものではありません。

死んだ接続を、どれだけ生きていると信じるか

  • 相手の死について TCP が持つ証拠は沈黙だけで、カーネルがそれを集められるのは、未処理のものがあるときだけです。読み取り側には、シグナルがまったく来ません。
  • 書き込み側は、再送を諦める時間が過ぎてから失敗します。ここでは938秒(約15.6分)で、man ページでは既定で13〜30分です。keepalive では短縮できません。keepalive が動くのは、未確認のデータがないときだけだからです。
  • 書き込み側の上限には TCP_USER_TIMEOUT、サーバー側でアイドルなクライアントを回収するには keepalive、正しさが必要なものすべて(事前には分からない NAT のアイドルタイムアウトを含む)にはアプリケーションのハートビートを使います。
  • 長寿命の接続に問うべき一番大切なことは、「切断をどう検知するか」ではありません。「死んだ接続を生きていると信じてよい最長の時間は?」です。その時間を守らせる仕組みを選んでください。