Skip to content

필사 모드: Connection refused と timeout の違い — TCP レベルで原因を絞り込む方法

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — 同じ「接続失敗」なのにメッセージが二種類あります

デプロイ直後にヘルスチェックが失敗しました。あるPodはこう言います。

dial tcp 10.0.3.11:8080: connect: connection refused

隣のPodはこう言います。

dial tcp 10.0.3.11:8080: i/o timeout

多くのチームがこの二つを「とにかく接続できない状況」としてひとまとめに扱います。そして、どちらの場合もセキュリティグループとファイアウォールのルールから調べ始めます。ここが最も時間を浪費する地点です。二つのメッセージはTCPレベルで正反対の事実を述べており、したがって見るべき場所も正反対です。

この記事では、二つのエラーがそれぞれどのようなパケット交換の結果なのか、そしてその事実から原因の候補をどこまで削り落とせるのかを扱います。コマンドと出力はそのままコピーして比較できるよう、実際の形で載せています。

Connection refused は RST を受け取ったという意味です

connect() システムコールが ECONNREFUSED を返す条件は一つだけです。こちらが送った SYN に対して、相手が RST フラグの立った TCP セグメントを返してきた場合です。

リスナーのないポートに SYN が届くと、カーネルはアプリケーションとは無関係に即座に RST を作って送り返します。これは TCP 仕様が要求する動作であり、プロセスが死んでいてもカーネルが生きている限り起こり続けます。

# リスナーがないポート — 往復時間のうちに即座に終わる
time nc -vz 10.0.3.11 8080
nc: connect to 10.0.3.11 port 8080 (tcp) failed: Connection refused

real    0m0.004s
user    0m0.001s
sys     0m0.002s

ここで重要なのは失敗そのものではなく、かかった時間です。4ミリ秒は往復時間です。こちらの SYN が 10.0.3.11 まで届き、そのホストが答えを作ってこちらに返してきました。つまりルーティング、セキュリティグループ、ネットワークポリシー、NAT まで、経路上のすべての関門をすでに通過したという証拠です

そのため、残る原因の候補は宛先ホストの内側へと絞られます。

  • プロセスが死んでいるか、まだ起動していません。
  • プロセスは生きていますが、別のポートにバインドされています。
  • プロセスが 0.0.0.0 ではなく 127.0.0.1 にだけバインドされています。
  • 宛先がロードバランサーや kube-proxy で、その背後に正常なバックエンドが一つもありません。

ここでよくある誤解を一つ正しておく必要があります。「connection refused が出たのだからファイアウォールが塞いだのだ」という説明はたいてい間違いです。実務で使うファイアウォールはほぼ常に DROP ポリシーです。AWS のセキュリティグループ、Kubernetes の NetworkPolicy、クラウドファイアウォールは、ルールにないトラフィックを黙って捨てます。捨てられたパケットには応答がないので、refused ではなく timeout として現れます。refused を見てファイアウォールを漁るのは、すでに通過した関門をもう一度確認する作業です。

例外は、明示的に拒否を応答するよう設定した場合です。

# このルールは RST を返すので refused のように見える
sudo iptables -A INPUT -p tcp --dport 8080 -j REJECT --reject-with tcp-reset

# このルールは応答がないので timeout になる
sudo iptables -A INPUT -p tcp --dport 8080 -j DROP

ローカルの iptables/nftables を直接使う環境なら、REJECT ルールがあるかどうかを一度確認すれば済みます。それ以外では、refused をプロセスの問題として扱うほうがほぼ常に正解です。

Timeout は SYN に誰も答えなかったという意味です

タイムアウトは情報がはるかに少ない失敗です。こちらが SYN を送り、決められた回数だけ再送し、その間 SYN-ACK も RST も ICMP も返ってきませんでした。カーネルが知りうる事実は「応答がない」だけです。

# パケットが黙って捨てられるポート — カーネル既定の再試行を使い切る
time nc -vz 10.0.3.11 9090
nc: connect to 10.0.3.11 port 9090 (tcp) failed: Connection timed out

real    2m7.213s
user    0m0.002s
sys     0m0.003s

2分7秒という数字は後で計算してみます。いま重要なのは原因の候補です。

  • 経路上のファイアウォールやセキュリティグループがパケットを捨てています。
  • 宛先 IP が間違っていて、そのアドレスを使うホストがそもそも存在しません。
  • ルーティングが誤っていて、パケットが見当違いの場所へ行きました。
  • 応答経路が非対称で、SYN-ACK がこちらに戻ってこられません。
  • 宛先は生きていてリスナーもありますが、accept キューがあふれて SYN が捨てられています。

最後の項目が最も見落とされやすいケースです。

リスナーがあるのに timeout になる場合

ハンドシェイクが終わった接続は accept キューに積まれ、アプリケーションが accept() で取り出します。アプリケーションが遅くてこのキューが満杯になると、Linux は既定の設定で新しい SYN を黙って捨てます。クライアントから見るとポートが閉じているのと区別がつかず、ただし refused ではなく timeout として見えます。

ss -ltn 'sport = :8080'
State   Recv-Q  Send-Q   Local Address:Port   Peer Address:Port
LISTEN  129     128            0.0.0.0:8080         0.0.0.0:*

リスニングソケットにおいて Recv-Q は現在の accept キュー長、Send-Q はキューの最大サイズです。129 が 128 を超えたということは、キューがすでに飽和状態だという意味です。累積カウンターで確認するとさらに確実です。

nstat -az | grep -E 'ListenOverflows|ListenDrops'
TcpExtListenOverflows           18437              0.0
TcpExtListenDrops               18437              0.0

この値が増えているなら、原因はネットワークではなくアプリケーションのスループットです。ファイアウォールをいくら漁っても出てきません。

残りのエラーメッセージが指し示す地点

connect 系の失敗は、実はもう四種類あります。それぞれ異なる層で発生するので、メッセージを正確に読むだけで半分は終わります。

メッセージerrno実際に起きたことパケットは出たかまず見る場所
Connection refusedECONNREFUSED宛先が RST を返した出て応答も受け取った対象ホストのプロセスとバインドアドレス
Connection timed outETIMEDOUTSYN 再送を使い切るまで無応答出たが応答なしファイアウォール、セキュリティグループ、ルーティング、accept キュー
No route to hostEHOSTUNREACHICMP host unreachable 受信、または ARP 応答なし同じサブネットまでしか出ない対象ホストの電源、ARP テーブル、REJECT
Network is unreachableENETUNREACHルーティングテーブルにその宛先への経路がない出られないip route、特に IPv6 経路の有無
Name or service not knownEAI_NONAME名前解決の段階で失敗し、ソケットすら開いていない対象へは一つも出ていないnsswitch.conf、resolv.conf、/etc/hosts

二つを強調しておきます。

一つめ、Network is unreachable はカーネルのルーティングテーブルに経路がなく、パケットがインターフェースの外へ出ることすらできなかった状態です。最近このエラーの圧倒的多数は IPv6 です。DNS が AAAA レコードを返したのにホストに IPv6 のデフォルト経路がなければ、アプリケーションはまず IPv6 を試して即座に失敗します。

ip -6 route show default
# 出力が空なら AAAA の結果では接続を試みられない

getent ahosts api.example.com
2606:4700:4700::1111 STREAM api.example.com
1.1.1.1              STREAM

二つめ、Name or service not known は TCP の問題ではありません。対象サーバーへはパケットが一つも出ていません。この場合、ファイアウォールのログをいくら見ても何もないのが正常です。

tcpdump で SYN と RST を目で確認する

推論に確信が持てないときは、パケットを直接見るのが最も速い方法です。ハンドシェイクだけ見ればよいので、フィルターは狭く取ります。

sudo tcpdump -nn -i any "host 10.0.3.11 and tcp port 8080"

拒否される場合の出力です。

14:02:11.104512 IP 10.0.2.7.54312 > 10.0.3.11.8080: Flags [S], seq 1829301744, win 62727, options [mss 8961,sackOK,TS val 913442 ecr 0,nop,wscale 7], length 0
14:02:11.104698 IP 10.0.3.11.8080 > 10.0.2.7.54312: Flags [R.], seq 0, ack 1829301745, win 0, length 0

二行で終わりです。[S] が出て [R.] が返ってきました。0.2ミリ秒で応答が来たということは、同じホストか、ごく近いという意味です。

タイムアウトする場合はこう見えます。

14:05:31.220114 IP 10.0.2.7.54390 > 10.0.3.11.9090: Flags [S], seq 402113877, win 62727, options [mss 8961,sackOK,TS val 1113209 ecr 0,nop,wscale 7], length 0
14:05:32.235880 IP 10.0.2.7.54390 > 10.0.3.11.9090: Flags [S], seq 402113877, win 62727, options [mss 8961,sackOK,TS val 1114225 ecr 0,nop,wscale 7], length 0
14:05:34.251874 IP 10.0.2.7.54390 > 10.0.3.11.9090: Flags [S], seq 402113877, win 62727, options [mss 8961,sackOK,TS val 1116241 ecr 0,nop,wscale 7], length 0
14:05:38.315876 IP 10.0.2.7.54390 > 10.0.3.11.9090: Flags [S], seq 402113877, win 62727, options [mss 8961,sackOK,TS val 1120305 ecr 0,nop,wscale 7], length 0

同じシーケンス番号の SYN が1秒、2秒、4秒間隔で繰り返されています。応答の行がまったくありません。

パケットキャプチャの権限がない環境なら、ソケットの状態だけでも判別できます。

ss -tanp state syn-sent
Recv-Q  Send-Q   Local Address:Port    Peer Address:Port   Process
0       1             10.0.2.7:54390       10.0.3.11:9090   users:(("curl",pid=31544,fd=5))

SYN-SENT 状態に数秒以上とどまっていて Send-Q が1なら、応答をもらえない SYN が一つ浮いているという意味です。refused ではこの状態が目に留まらないほど短く過ぎ去ります。

SYN 再送間隔からタイムアウトの長さを予測する

先ほどの2分7秒は偶然の数字ではありません。Linux は net.ipv4.tcp_syn_retries の値の回数だけ SYN を再送し、間隔を毎回2倍に伸ばします。

sysctl net.ipv4.tcp_syn_retries
net.ipv4.tcp_syn_retries = 6

最初の送信のあと1、2、4、8、16、32秒間隔で6回再送し、最後の再送のあとさらに64秒待ってから諦めます。合計は 1 + 2 + 4 + 8 + 16 + 32 + 64 = 127秒です。先ほどの実測値である2分7秒と正確に一致します。

この計算が実務で役に立つ理由は二つあります。

一つ、ちょうど127秒あたりで切れる失敗はカーネルが SYN を諦めたということなので、応答がまったくなかったという意味です。アプリケーションログのタイムアウト値が30秒なのに実際には127秒で失敗したなら、それはタイムアウト設定が効いていないという合図でもあります。

二つ、接続タイムアウトを短くしたいなら、カーネルの値を変えるよりアプリケーションやソケット単位で指定するほうが安全です。

# このホスト全体に適用 — 再送3回、およそ15秒
sudo sysctl -w net.ipv4.tcp_syn_retries=3

# ツール単位で制限するほうが副作用が少ない
curl --connect-timeout 3 -sS http://10.0.3.11:8080/health
nc -w 3 -vz 10.0.3.11 8080

macOS はこの動作が異なります。既定の接続タイムアウトが約75秒で sysctl の名前も違うため、ローカルの Mac で測った時間を Linux サーバー基準で解釈してはいけません。

コンテナと Kubernetes の 127.0.0.1 バインド事故

コンテナ環境における connection refused の半分は同じ原因です。プロセスがループバックにだけバインドされていることです。

ローカル開発では 127.0.0.1:8080 でうまく起動し、うまく接続できます。それをそのままコンテナに入れて Service を付けると、Pod は Running でプロセスも生きているのに、他の Pod からアクセスすると connection refused になります。

理由は宛先アドレスにあります。kube-proxy が Service の ClusterIP を Pod IP に DNAT すると、届くパケットの宛先アドレスは 10.244.1.23 のような Pod IP です。ソケットは 127.0.0.1 にしか開いていないので、カーネルはこの宛先に合うリスナーを見つけられず RST を返します。

kubectl exec -it deploy/api -- ss -ltnp
State   Recv-Q  Send-Q   Local Address:Port   Peer Address:Port  Process
LISTEN  0       4096        127.0.0.1:8080         0.0.0.0:*      users:(("api",pid=1,fd=7))

正常ならこう出るはずです。

State   Recv-Q  Send-Q   Local Address:Port   Peer Address:Port  Process
LISTEN  0       4096          0.0.0.0:8080         0.0.0.0:*      users:(("api",pid=1,fd=7))
LISTEN  0       4096             [::]:8080            [::]:*      users:(("api",pid=1,fd=8))

Pod の中ではヘルスチェックが成功するという点が、この事故を長引かせます。kubectl exec で入って curl localhost:8080 を叩くとうまくいきます。宛先アドレスが 127.0.0.1 なのですから当然です。必ず Pod IP で確認しなければなりません。

POD_IP=$(kubectl get pod -l app=api -o jsonpath='{.items[0].status.podIP}')
kubectl exec -it deploy/api -- curl -sS --connect-timeout 2 "http://${POD_IP}:8080/health"
curl: (7) Failed to connect to 10.244.1.23 port 8080 after 1 ms: Connection refused

直す場所はアプリケーションの設定です。Node の Express は app.listen(8080) にしておけばすべてのアドレスに付きますが、app.listen(8080, '127.0.0.1') はそうではありません。Go は http.ListenAndServe(":8080", ...) が正しく、"localhost:8080" は誤りです。Spring Boot は server.address プロパティ、Rails は -b 0.0.0.0、Gunicorn は --bind 0.0.0.0:8000、Postgres は listen_addresses 設定です。Docker でポートマッピングを設定したのに接続できない事例も原因は同じです。

同じ理屈を裏返すと判別法になります。Pod IP では refused なのに Pod 内の localhost では成功するなら、原因はネットワークポリシーではなくバインドアドレスです。逆に Pod IP でも timeout なら、そこから NetworkPolicy と CNI を見るべきです。

Service を経由したときだけ refused になる、もう一つのケースも覚えておく価値があります。Endpoints が空だと kube-proxy は送り先がないので即座に拒否します。

kubectl get endpoints api
NAME   ENDPOINTS   AGE
api    <none>      12m

このときは Service の selector と Pod のラベルが食い違っているか、readinessProbe が失敗して準備できた Pod が一つもない状態です。

おわりに — refused は悪い知らせではありません

まとめると、判断の骨組みは短いものです。

Connection refused を見たなら、パケットは宛先まで行って戻ってきています。経路はすでに検証済みなので、ファイアウォールとルーティングは候補から消し、対象ホストのプロセスとバインドアドレス、ポート番号、バックエンドの有無を見ればよいのです。これは問題の範囲が一台のホストの中へ縮まったという意味であり、だからこそありがたい合図です。

Timeout を見たなら、何の情報も返ってきていません。ファイアウォール、セキュリティグループ、ルーティング、非対称経路、そして accept キューあふれまで広く開けておき、tcpdump で SYN が実際に出ているのか、再送が繰り返されているのかから確認すべきです。ここで127秒という数字を覚えておけば、カーネルが諦めたのか、アプリケーションのタイムアウトが先に効いたのかを即座に区別できます。

二つのメッセージを「接続失敗」とひとまとめにした瞬間、診断時間は何倍にも膨らみます。逆にこの一行さえ正確に読めば、確認すべき場所の半分以上が最初から消えてなくなります。

현재 단락 (1/114)

デプロイ直後にヘルスチェックが失敗しました。あるPodはこう言います。

작성 글자: 0원문 글자: 8,494작성 단락: 0/114