ALB の 502 対策で keepalive タイムアウトを長くする理由を、パケットキャプチャで確認してみた
はじめに
ALB の背後にサーバーを置くとき、「ターゲット側の keepalive タイムアウトを ALB のアイドルタイムアウトより長くする」という設定がよく挙げられます。AWS のドキュメントにも推奨として記載があり、短いと 502 が発生し得ると説明されています [1]。
ただ、大小関係だけを見ても、なぜそれで 502 になるのかは実感しにくいところです。短いと何が起きて、長いと何が防げるのか。ALB + EC2(Nginx)で両方の設定を再現し、パケットキャプチャで確認してみました。
ALB とターゲット間の接続
ALB とターゲットの間の TCP 接続は、HTTP keep-alive で使い回されます。どちらの側にもアイドルタイムアウトがあり、先にタイムアウトした側が接続を閉じます。
| 設定項目 | デフォルト | |
|---|---|---|
| ALB 側 | 接続アイドルタイムアウト | 60 秒 [1] |
| ターゲット側(Nginx) | keepalive_timeout |
75 秒 [2] |
デフォルトでは ALB(60 秒)が先に閉じるため問題になりません。ターゲット側を ALB より短く設定した場合に、先に閉じるのがターゲット側になります。
TCP 接続の閉じ方
TCP では、接続を閉じたい側が FIN というパケットを送ります。
受け取った側も FIN を返し、双方が確認し合って接続が終了します。
ターゲット → ALB FIN (もうこの接続は使いません)
ALB → ターゲット FIN (了解しました)
ターゲット → ALB ACK → 接続終了
これが正常な終了手順です。ALB は FIN を受け取った時点でその接続を使わなくなり、次のリクエストでは新しい接続を張ります。
問題になるのは、ターゲットが FIN を送った直後に、ALB が同じ接続へリクエストを送ってしまった場合です。ターゲットから見れば、すでに閉じた接続にデータが届くことになります。
検証の方針
ターゲット側を ALB より短くすると、先に接続を閉じるのはターゲット側になります。この状態で 502 が発生するなら、ターゲット側のキャプチャで次が観測できるはずです。
- ターゲットが FIN を送信する
- その直後、同じ接続に ALB からリクエストが届く
- ターゲットが RST を返す
- ALB のアクセスログに 502 と
target_status_code = -が記録される - ターゲットのアクセスログには、そのリクエストが存在しない
この 5 点を確認します。あわせて、502 にならなかった接続との差分も比較します。
検証環境
CloudShell(負荷) → ALB(HTTP:80) → EC2 1 台(Nginx)
| 項目 | 設定 |
|---|---|
| ALB | HTTP リスナー(ポート 80)、接続アイドルタイムアウト 60 秒(デフォルト) |
| ALB アクセスログ | S3 に出力 |
| ターゲット | EC2 1 台(Amazon Linux 2023 / Nginx 1.30.4) |
| ターゲットグループ | HTTP:80、登録は 1 台のみ |
Nginx の keepalive_timeout は、ALB のアイドルタイムアウトより短くするため 5 秒にしています。
検証方法
負荷のかけ方
CloudShell から curl で負荷をかけます。
ALB=<ALB の DNS 名>
for i in $(seq 1 334); do
seq 30 | xargs -P30 -I{} curl -s -o /dev/null -w '%{http_code}\n' http://$ALB/
sleep $((4850 + RANDOM % 301))e-3
done | sort | uniq -c
30 並列でリクエストを送信し、次のバーストまで 4.85〜5.15 秒あける処理を、合計 1 万リクエスト程度になるまで繰り返します。
今回の事象は、接続が閉じられる瞬間と次のリクエストが重なったときにのみ発生します。
| 間隔 | リクエストが届いた時点の接続 | 結果 |
|---|---|---|
| 2 秒(短い) | まだ生きている | Nginx がタイマーをリセットし、そのまま処理される |
| 約 5 秒 | ちょうど閉じられる瞬間 | 検証したい状況 |
| 30 秒(長い) | すでに閉じられ、ALB も認識済み | 新しい接続が確立され、正常に処理される |
つまり、リクエスト間隔を Nginx の keepalive_timeout に合わせる必要があります。
間隔に幅を持たせているのは、固定値だと境界の手前か後ろのどちらかに偏り続けるためです。
パケットキャプチャ
EC2 側で tcpdump を実行しておきます。
sudo nohup tcpdump -i any -nn -w /tmp/cap.pcap 'tcp port 80' >/dev/null 2>&1 &
負荷の終了後にキャプチャを停止し、RST を返した接続を検索します。
sudo pkill tcpdump
sudo tcpdump -r /tmp/cap.pcap -nn 'tcp[tcpflags] & tcp-rst != 0 and src host <EC2 のプライベート IP>'
ALB はターゲットへの接続ごとにエフェメラルポートを使用するため、このポート番号が接続 1 本の識別子になります。ポート番号を指定すれば、その接続の一連のやり取りを確認できます。502 にならなかった接続も、ポート番号を差し替えるだけで同じように追えます。
sudo tcpdump -r /tmp/cap.pcap -nn -tttt 'port <ポート番号> and not host 169.254.169.254'
169.254.169.254 を除外しているのは、インスタンス自身がインスタンスメタデータサービス(IMDS)へ接続する際に、同じ番号のエフェメラルポートを再利用することがあるためです。
検証A: ターゲット側を短くした場合(Nginx 5 秒 / ALB 60 秒)
パケットキャプチャを取得しながら負荷をかけました。
6926 200
4 502
CloudShell のセッションが途中で切断され、6,930 リクエストで終了しています。ただし 502 が 4 件発生しており、パケットを追うには十分なのでこのデータで確認します。
08:20:16.636292 Out IP 10.0.2.xxx.80 > 10.0.18.xxx.40722: Flags [R], seq 2078144241, win 0, length 0
08:28:48.356358 Out IP 10.0.2.xxx.80 > 10.0.7.xxx.32260: Flags [R], seq 3545897069, win 0, length 0
08:32:44.125752 Out IP 10.0.2.xxx.80 > 10.0.18.xxx.17938: Flags [R], seq 4085173520, win 0, length 0
08:36:49.837149 Out IP 10.0.2.xxx.80 > 10.0.18.xxx.19796: Flags [R], seq 3017906713, win 0, length 0
RST を返した 4 本と、返さなかった接続を比較します。
キャプチャはターゲット(EC2)側で取得しているため、Out はターゲットから ALB への送信、In は ALB からターゲットへの受信です。
502 になった接続(ポート 17938)

オプションなどを省いて注釈を付けると、次のとおりです。
08:32:39.122732 In Flags [S] ALB が接続を確立
08:32:39.122758 Out Flags [S.]
08:32:39.124404 In Flags [.]
08:32:39.124477 In GET / HTTP/1.1 1 回目のリクエスト
08:32:39.124486 Out Flags [.]
08:32:39.124515 Out HTTP/1.1 200 OK ← ターゲットの最後の応答
08:32:39.126123 In Flags [.]
(5 秒間アイドル)
08:32:44.124598 Out Flags [F.] ← ターゲットが FIN を送り、接続を閉じた
08:32:44.125734 In GET / HTTP/1.1 ← その 1.136 ms 後、ALB がリクエストを送ってきた
08:32:44.125752 Out Flags [R] ← すでに閉じているので RST を返す
08:32:44.126302 In Flags [F.]
08:32:44.126317 Out Flags [R]
最後の応答(08:32:39.124515)から FIN(08:32:44.124598)まで 5.000083 秒。keepalive_timeout 5s が設計どおりに発火しています。
問題はその 1.136 ミリ秒後です。ターゲットはすでに接続を閉じているにもかかわらず、ALB から GET / HTTP/1.1 が届いています。ターゲットはこのリクエストを読まずにソケットを閉じているため、TCP スタックが RST を返します。この GET に対する 200 OK はどこにもありません。
502 にならなかった接続(ポート 31304 / 2004)

ポート 31304 の方を見ます。
08:16:55.991301 In Flags [S] ALB が接続を確立
08:16:55.991332 Out Flags [S.]
08:16:55.991435 In Flags [.]
08:16:55.991473 In GET / HTTP/1.1 1 回目のリクエスト
08:16:55.991478 Out Flags [.]
08:16:55.991565 Out HTTP/1.1 200 OK ← ターゲットの最後の応答
08:16:55.991679 In Flags [.]
(5 秒間アイドル)
08:17:00.996690 Out Flags [F.] ← ターゲットが FIN を送り、接続を閉じた
08:17:00.996945 In Flags [F.] ← ALB も FIN を返した
08:17:00.996967 Out Flags [.] ← 正常に終了
基本的には 502 の接続と同じです。違いは FIN の 0.255 ミリ秒後に届いたものが、リクエストではなく ALB からの FIN だったという 1 点だけです。
ALB は FIN を受け取ってこの接続を破棄し、次のリクエストでは新しい接続を張ります。
同じ観点で、502 になった 4 本と、ならなかった 10 本をすべて並べます。まず 502 になった 4 本です。すべて ALB からのリクエスト(GET) が届いています。
| ポート | 最後の応答 → FIN | FIN の後に ALB から届いたもの |
|---|---|---|
| 40722 | 5.000285 秒 | GET(0.051 ms 後) |
| 32260 | 5.000526 秒 | GET(0.092 ms 後) |
| 17938 | 5.000083 秒 | GET(1.136 ms 後) |
| 19796 | 4.999686 秒 | GET(1.719 ms 後) |
502 にならなかった 10 本です。すべて ALB からの FIN、つまり正常な終了応答です。
| ポート | 最後の応答 → FIN | FIN の後に ALB から届いたもの |
|---|---|---|
| 5004 | 5.003735 秒 | FIN(1.700 ms 後) |
| 38620 | 5.004649 秒 | FIN(1.720 ms 後) |
| 38628 | 5.000333 秒 | FIN(1.718 ms 後) |
| 31304 | 5.005125 秒 | FIN(0.255 ms 後) |
| 31568 | 5.004963 秒 | FIN(0.219 ms 後) |
| 31582 | 4.999889 秒 | FIN(0.232 ms 後) |
| 33354 | 5.000301 秒 | FIN(1.758 ms 後) |
| 31590 | 5.000897 秒 | FIN(0.213 ms 後) |
| 33342 | 5.000502 秒 | FIN(1.620 ms 後) |
| 2004 | 5.005126 秒 | FIN(1.754 ms 後) |
確認できたことは 3 点です。
- ターゲットの挙動は 14 本すべて同一。最後の応答から 4.9997〜5.0051 秒で FIN を送信しており、502 になった接続とならなかった接続で差がない
- FIN からの時間差も重なっている。正常側は 0.213〜1.758 ms、502 側は 0.051〜1.719 ms。違いは、その瞬間に ALB から届いたものが FIN だったかリクエストだったかだけ
- 502 になった 4 本はいずれも、ターゲットが閉じた後にリクエストが届いている。ドキュメントの記述と一致する。
ログでの見え方
件数の比較では、どのリクエストが欠落しているのかを特定できません。ALB が各リクエストに付与する X-Amzn-Trace-Id を Nginx 側でもログに出力し、リクエスト単位で突き合わせました。
log_format albtrace '$remote_addr [$time_local] "$request" $status trace=$http_x_amzn_trace_id';
access_log /var/log/nginx/access.log albtrace;
ALB アクセスログから 502 の Trace ID と、対照として 200 の Trace ID を取り出します。(実際はマスク無しで取得)
zcat *.gz | awk '$9==502' | grep -o 'Root=1-[a-f0-9-]*'
zcat *.gz | awk '$9==200' | grep -o 'Root=1-[a-f0-9-]*' | head -1
Root=1-6a8415c0-xxxxxxxxxxxxxxxxxxxxxxxx ← 502
Root=1-6a8417c0-xxxxxxxxxxxxxxxxxxxxxxxx ← 502
Root=1-6a8418ac-xxxxxxxxxxxxxxxxxxxxxxxx ← 502
Root=1-6a8419a1-xxxxxxxxxxxxxxxxxxxxxxxx ← 502
Root=1-6a8414f7-xxxxxxxxxxxxxxxxxxxxxxxx ← 200(対照)
この 5 つを Nginx のアクセスログで検索します。
sudo grep '<Trace ID>' /var/log/nginx/access.log
--- 6a8415c0(502)---
--- 6a8417c0(502)---
--- 6a8418ac(502)---
--- 6a8419a1(502)---
--- 6a8414f7(200)---
10.0.7.xxx [18/Aug/2026:08:16:55 +0000] "GET / HTTP/1.1" 200 trace=Root=1-6a8414f7-xxxxxxxxxxxxxxxxxxxxxxxx
| ALB アクセスログ | Nginx アクセスログ | |
|---|---|---|
| 200 のリクエスト | ある | ある |
| 502 のリクエスト | ある(target_status_code = -) |
ない |
200 のリクエストは両方に記録されているため、Nginx 側のログ設定の不備ではありません。502 の 4 件だけが記録されていない状態です。
ALB 側の target_status_code = -(ターゲットからステータスコードを受け取っていない)とも一致します。
検証B: ターゲット側を長くした場合(Nginx 10 秒 / ALB 5 秒)
ドキュメントの推奨どおり、ターゲット側のタイムアウトを ALB より長く設定して同じ負荷をかけます。
リクエスト間隔は短いほうのタイムアウトに合わせる必要があります。ALB を 60 秒のままにすると間隔も 60 秒必要になり、同じ試行回数を稼ぐのに 3 時間以上かかるため、ALB 側を 5 秒に下げました。
Nginx は 5 秒より長ければ値は問いません。ALB が先に閉じるので Nginx のタイマーは一度も発火しないためで、ここでは余裕を見て 10 秒にしています。
| 検証A | 検証B | |
|---|---|---|
| ALB アイドルタイムアウト | 60 秒 | 5 秒 |
Nginx keepalive_timeout |
5 秒 | 10 秒 |
| 先に閉じる側 | ターゲット | ALB |
| リクエスト間隔 | 約 5 秒 | 約 5 秒 |
| リクエスト数 | 6,930 | 6,000 |
負荷のかけ方は検証 A と同じ 30 並列で、サイクル数を 200 回(6,000 リクエスト)としました。こちらは最後まで完走しています。
6000 200
6,000 リクエストすべてが 200 で、502 は発生しませんでした。ターゲットが RST を返した接続も 0 件です。
接続 1 本の中身を見ると、閉じる側が入れ替わっています。

08:54:57.919641 In Flags [S] ALB が接続を確立
08:54:57.919663 Out Flags [S.]
08:54:57.921266 In Flags [.]
08:54:57.921267 In GET / HTTP/1.1 1 回目のリクエスト
08:54:57.921295 Out Flags [.]
08:54:57.921371 Out HTTP/1.1 200 OK ← ターゲットの最後の応答
08:54:57.922953 In Flags [.]
(5 秒間アイドル)
08:55:02.919655 In Flags [F.] ← ALB が FIN を送り、接続を閉じた
08:55:02.920179 Out Flags [F.] ← ターゲットも FIN を返した
08:55:02.921836 In Flags [.] ← 正常に終了
最後の応答から 4.998284 秒後、ALB 側から FIN が届いています。検証 A では同じ位置でターゲットが FIN を送っていました。Nginx の 10 秒に達する前に ALB が閉じるため、Nginx のタイマーは一度も発火しません。
検証 A ではターゲットが FIN を送っていましたが、こちらは ALB が送っています。
ALB は自分で閉じた接続を再利用しないため、取り違えが起こりません。
まとめ
| ALB | ターゲット | 先に閉じる側 | リクエスト数 | 502 | |
|---|---|---|---|---|---|
| 検証A | 60 秒 | 5 秒 | ターゲット | 6,930 | 4 |
| 検証B | 5 秒 | 10 秒 | ALB | 6,000 | 0 |
- ターゲット側が先に閉じる構成では 502 が発生し、ALB 側が先に閉じる構成では発生しませんでした
- 502 になった接続では、ターゲットが FIN を送った 0.05〜1.7 ミリ秒後に ALB からリクエストが届いていました。ターゲットの挙動は 502 にならなかった接続と変わらず、違いはその瞬間に届いたものが FIN かリクエストかだけです
- そのリクエストは ALB のログに
target_status_code = -として残る一方、ターゲット側のログには存在しません。アプリケーションから見れば、何も起きていないのと同じです - ターゲット側を長くしておけば、閉じるのは常に ALB 側になります。ALB は自分で閉じた接続を再利用しないため、この取り違えが原理的に起きません
おわりに
同じ設定(Nginx 5 秒 / ALB 60 秒)で複数回試しましたが、502 の発生数はばらついています。
あくまで今回の環境と負荷での結果であり、発生率として一般化できる数字ではありません。リクエストが届くタイミングと、ターゲットが接続を閉じるタイミングがミリ秒単位で噛み合うかどうかに左右されるためです。
言えるのは、「設定が逆転していても負荷試験で必ず観測できるとは限らない」ということです。502 が出なかったことは設定が正しいことの証明にはならないので、タイムアウトの大小関係そのものを確認しておくのが確実です。
参考になれば幸いです。
参考情報
[1] Application Load Balancer の属性を編集する - Elastic Load Balancing
[2] Module ngx_http_core_module - keepalive_timeout
クラスメソッドオペレーションズ株式会社について
クラスメソッドグループのオペレーション企業です。
運用・保守開発・サポート・情シス・バックオフィスの専門チームが、IT・AIをフル活用した「しくみ」を通じて、お客様の業務代行から課題解決や高付加価値サービスまでを提供するエキスパート集団です。
当社は様々な職種でメンバーを募集しています。
「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、クラスメソッドオペレーションズ株式会社 コーポレートサイト をぜひご覧ください。※2026年1月 アノテーション㈱から社名変更しました








