ソケットを開いたサーバーの既定値は運用値ではない
一言でいうと
http.createServerのデフォルト値は、「ローカルでちゃんと起動する」ことに合わせてあります。前段(ロードバランサー)の後ろで、遅いクライアントや大きな本文、
デプロイのシグナルに耐えなければならない本番サーバーは、そのデフォルト値のうち5か所を自分で決める必要があります。本文サイズ、ヘッダー・リクエストの時間制限、
keep-aliveの時間、エラーレスポンス、終了の順序です。
なぜこれが必要だったのか
前のモジュールのハンドラーは、{status, body}を返す関数でした。契約はそこで決まります。ところが、その関数をソケットにつないだ瞬間に、
契約の外の出来事が起きます。クライアントがヘッダーを1秒に1行ずつ送ってきます。本文が10MBだと書いて入ってきます。前段が1分間アイドルにしておいた
接続でリクエストを送ってきます。デプロイツールがSIGTERMを送ってきます。この4つはハンドラーのテストでは一度も出てきませんが、本番では毎日出てきます。
Node 22のデフォルト値を測ってみると、次のとおりです(2026-11-05、Node 22.11で実測)。keepAliveTimeoutは5秒、headersTimeoutは60秒、
requestTimeoutは300秒で、本文サイズの制限はありません。そして時間制限のチェックは、connectionsCheckingInterval(デフォルト30秒)ごとに
1回行われます。headersTimeoutを3秒にしても、チェック周期を縮めなかったサーバーは、10秒経っても遅い接続を切りませんでした。
値だけ変えて測らなければ、直したつもりになってしまいます。
どう動くのか
| 仕組み | 何を防ぐか | どこで決めるか |
|---|---|---|
| 本文の上限 | 大きな本文によるメモリ・時間の消耗。ヘッダーのContent-Lengthで先に拒否し、サイズを明かさないchunkedは数えながら受け取る | ハンドラー(自前で) |
| headersTimeout | ヘッダーをだらだら引き延ばす接続(slowloris) | createServerのオプション |
| requestTimeout | 本文をだらだら引き延ばす接続。レスポンスを作る時間は含まない | createServerのオプション |
| keepAliveTimeout | 前段より先に閉じることで起きる「閉じられる接続に送る」競合 | serverのプロパティ |
| エラーレスポンス | プロセス全体が落ちる(投げたエラーを誰も受け取らない)、またはレスポンスにスタック・秘密情報が載って出ていく | リクエストハンドラーのcatch |
keep-aliveは向きが重要です。前段はサーバーへの接続をプールしておき、再利用します。サーバーが5秒で接続を閉じるのに、前段は60秒間 使えると信じていると、サーバーが閉じるまさにその瞬間に前段が送ったリクエストが切れ、ユーザーは502を目にします。だから閉じる側は 前段でなければならず、サーバーのkeepAliveTimeoutは前段のアイドル時間より長くなければなりません。
終了は順序です。SIGTERMを受け取ったら、(1)新しい接続を受け付けず(server.close())、(2)アイドル状態の接続を閉じ、(3)処理中の
リクエストには最後まで応答しつつ、その接続はレスポンス後に閉じ(Connection: close)、(4)すべて終わったら自ら0で終了します。(5)締め切りを過ぎたら
残りの接続を切ります。オーケストレーターのSIGKILLが来る前に、自分で。ここに落とし穴が1つあります。server.close()は
その瞬間にアイドルだった接続だけを閉じます。そのとき処理中だったkeep-alive接続はレスポンスを終えた後も開いたままなので、closeのコールバックは永遠に来ず、
前段はその接続でリクエストを送り続けます。実測では、そのようなプロセスはSIGKILLされるまで生きていました。
現場での姿
「デプロイのときだけ502がいくつか出る」というよくある報告は、たいてい3つのうちのどれかです。SIGTERMですぐにprocess.exitするコード(処理中のリクエストが
切れる)、締め切りを短くしすぎたグレースフルシャットダウン(もっとも長いリクエストより短い)、そして上で述べた開いたままのkeep-alive接続(終わらずにSIGKILL)です。
3つともログには「502」の1行として同じに見え、見分ける手がかりは古いプロセスがSIGTERMの後いつ、どのように終わったかです。
Kubernetesではエンドポイントの削除とSIGTERMが同時に始まるので、preStopで数秒持ちこたえる仕組みを追加しますが、それはこの
ラボの範囲外の話です。
本文と時間制限には、攻撃よりも先に事故として出会います。モバイルアプリのリトライのバグが同じ10MBのアップロードを何度も送り直してサーバーのメモリが
乱高下したり、回線の悪い地域のクライアントが接続を半分開いたまま数百個残してソケットが枯渇したりします。このとき「フレームワークがうまく防いでくれるだろう」
という期待は、たいてい外れます。Expressのbody-parserにはデフォルトで100KBの上限がありますが、標準のhttpには何もなく、時間制限はどちらもNodeの
デフォルト値をそのまま引き継ぎます。
エラーレスポンスも同じです。throw1回でプロセスが丸ごと落ちるサーバー、それを防ごうとunhandledRejectionをログに残すだけにしたら
今度はリクエストが永遠に応答を受け取れなくなったサーバー(前のモジュールのインシデント)、そして500レスポンスにerr.stackをそのまま載せてDBのパスワードを見せてしまった
サーバーは、どれも実在します。
次のラボですること
/root/work/srv/server.mjs1つを6つのステップにわたって育てます。採点ツールはそのファイルを空きポートで直接起動し、本物のソケットで突きます。
ヘッダーを0.5秒ごとに1行ずつ送り、10MBと書いて1KBだけ送り、接続を7秒アイドルにした後で再利用し、処理中にSIGTERMを送ります。
最後のステップはインシデントです。3つのサービスがデプロイのたびに502を返し、原因はサービスごとに異なります。