運用の既定値とグレースフルシャットダウン — 本物のソケットで測る
目標
/root/work/srv/server.mjs1つを育てながら、前段の後ろで働くサーバーが自分で決めなければならない5つのこと(入力の範囲、エラーレスポンス、
本文サイズ、時間制限、keep-alive)と、SIGTERMで終了する順序を、本物のソケットで測ってみます。最後のステップはインシデントです。
なぜ重要なのか
ハンドラーのテストにはソケットがないので、遅いクライアントにも、大きな本文にも、前段の接続の再利用にも、デプロイのシグナルにも出会いません。ところが本番の 502・メモリの急増・「デプロイのときだけ起きるエラー」は、すべてその隙間から生まれます。しかもこれらの設定は、値を書くことと実際に効くことが違います。 チェック周期のせいで遅れて切れる時間制限や、レスポンスを終えた接続を残しておくclose()を、このラボで実際に測ってみます。
採点ツールは皆さんのserver.mjsを空きポート(PORT環境変数)で直接起動してテストし、終わったら片付けます。起動しておいたサーバーが古いコードでも、
採点には影響しません。自分で試すときはPORT=8081 node /root/work/srv/server.mjs &で起動し、kill %1で片付けてください。
ステップ
- PORTで待ち受け、/healthz・/work(0–5000の整数のみ)・404に応答する
- /boom・/boom-asyncが例外を投げても500 JSON、本文に秘密情報・スタックなし、プロセスは生き続ける
- POST /echoの本文上限64KiB: まずヘッダーで、chunkedは数えながら413
- ヘッダーの引き延ばしは5秒・本文の引き延ばしは10秒で切るが、3秒かかる正常なリクエストは切らない
- keepAliveTimeoutを前段のアイドル時間(60秒)より長く
- SIGTERM: 待ち受け停止、アイドル接続を閉じる、処理中は最後まで、0で終了、締め切り4秒
- インシデントnd-deploy: デプロイのたびに502、サービスごとに原因が違う
参考
- ソケットを直接扱うときは、
python3 -c 'import socket; ...'やnode -eでヘッダーを1行ずつ書いてみてください。curlはヘッダーを補正してしまいます。 - よくある間違い:
err.messageをそのままレスポンスに載せる、Content-Lengthだけを信じてchunkedを忘れる、時間制限の値だけ変えてチェック周期をそのままにする、server.close()のコールバックだけを待つ。 - セッションは保存されません。Podが終了すると
/root/workも消えます。
PORTで待ち受け、入力をふるいにかけるサーバー
/root/work/srv/server.mjsを作ります。ポートは環境変数PORTから読んで127.0.0.1で待ち受け(採点ツールが空きポートを選んで直接起動します)、GET /healthzは200と{"status":"ok"}、GET /work?ms=<n>はnミリ秒待ってから200と{"done": n}を返します。msが0–5000の整数でなければ(負数・小数・文字・空の値)400、存在しないパスはJSONの404です。すべてのレスポンスはJSONです。
方向: サーバーが受け取る値は、すべて外から来たものです。ms=99999999をそのまま待つと、リクエスト1つがサーバーを捕まえてしまいます。 ツール: node:httpのcreateServer・listen(port, host)、new URL(req.url, 'http://local').searchParams、process.env.PORT。 具体: レスポンスはwriteHead(status, {content-type, content-length})とend(body)を1つの関数にまとめておくと、後のステップでヘッダーを1つ足すのが簡単になります。自分で起動してみるなら、PORT=8081 node server.mjs &の後にcurl -s 127.0.0.1:8081/work?ms=300です。
投げたエラーでプロセスを落とさず、秘密も漏らさない
わざと失敗するパスを2つ追加します。GET /boomはハンドラーでthrow new Error('db password=hunter2 failed at /srv/app/db.js')し、GET /boom-asyncはawaitを1回挟んだ後に同じ形のエラーを投げます。どちらのリクエストも500と{"error": "..."}のJSONで応答しなければならず、レスポンス本文にはエラーメッセージ・スタック・ファイルパスがあってはいけません。代わりに標準エラー出力(console.error)には残し、プロセスは動き続けなければなりません。
方向: リクエストハンドラーが返したPromiseが拒否されたら、それを受け取る者が必要です。誰も受け取らなければ、Node 22はプロセスを終了します。 ツール: route(req, res).catch(...)、res.headersSent、console.error(err.stack)。 具体: catchの時点ですでにヘッダーを送っていたらレスポンスは直せないので接続を切り(res.destroy())、そうでなければ固定の文言で500を送ります。err.messageをレスポンスに入れた瞬間、このステップは不合格になります。メッセージにパスワードが入っているからです。
本文サイズを受け取る前に、そして受け取りながら制限する
POST /echoを追加します。JSONの本文を受け取って200と{"echo": <received-json>}で返しますが、本文の上限は64KiB(65536バイト)です。ヘッダーのContent-Lengthが上限を超えたら本文を待たずに413、サイズを明かさないTransfer-Encoding: chunkedの本文は受け取ったバイト数を数えて上限を超えた瞬間に413、壊れたJSONは400です。60KBの本文は受け取らなければなりません。
方向: 拒否は早いほど安く済みます。ヘッダーだけで拒否できるものを本文をすべて受け取ってから拒否すると、メモリと時間をすでに差し出した後です。 ツール: req.headers['content-length']、for await (const chunk of req)、ステータスコードを持つエラークラス(例: HttpError)を投げてステップ02のcatchで振り分ける。 具体: 413を送るときはConnection: closeを付けてください。残りの本文が同じ接続で入り続けますが、その接続を次のリクエストに使うことはできません。テスト: curl -s -H 'Content-Length: 10485760' -d '{}' ...はcurlがヘッダーを補正してしまうので、採点ツールのようにpython3のsocketでヘッダーを直接書いてみてください。
遅いクライアントに接続を握られないように
ヘッダーを送り切らずにだらだら引き延ばす接続は5秒(headersTimeout)、ヘッダーは送ったが本文をだらだら引き延ばす接続はリクエスト全体で10秒(requestTimeout)前後で、サーバーが切らなければなりません。採点ツールは0.5秒ごとにヘッダー1行・本文1バイトを送りながらいつ切れるかを測り、同時にGET /work?ms=3000が200で終わるかも見ます(レスポンスを作る時間はクライアントのせいではありません)。ヘッダーの引き延ばしは3–7.5秒、本文の引き延ばしは8–13秒で切れなければなりません。
方向: 値を入れたのに時間どおりに切れないなら、値ではなく「いつチェックするか」を疑ってください。 ツール: http.createServer({ headersTimeout, requestTimeout, connectionsCheckingInterval }, handler)、python3のsocketでヘッダーを1行ずつ送りながらtime.time()で測る。 具体: Nodeはこの2つの時間をconnectionsCheckingInterval(デフォルト30000ms)ごとに1回見ます。5秒の制限に30秒の周期なら、最大35秒まで持ちこたえます。周期を1秒程度にしてください。
keep-aliveは前段より長く
このサーバーの前にはロードバランサーがあり、そのアイドル接続時間は60秒です。サーバーのkeepAliveTimeoutをそれより長く(65000ms以上)設定します。採点ツールはリクエストを実際に受け取ったサーバーオブジェクトの値を読み、接続を7秒アイドルにした後、同じ接続で2つ目のリクエストを送ってみます(デフォルト値の5秒なら、すでに閉じています)。前のステップの時間制限(headersTimeoutは5秒・requestTimeoutは10秒)はそのままでなければならず、通常のレスポンスにConnection: closeを付けてはいけません。
方向: 接続を閉じる側が誰であるべきかから決めてください。前段は、サーバーが閉じた接続をその瞬間には知りません。 ツール: server.keepAliveTimeout、レスポンスヘッダーのKeep-Alive: timeout=、curl -vで同じ接続の再利用(Re-using existing connection)を見る。 具体: keepAliveTimeoutとheadersTimeoutは別の時計です。前者はレスポンス後にアイドル状態でいる時間、後者は新しいリクエストのヘッダーを受け取る時間です。一方を伸ばしたからといって、もう一方を緩めないでください。
SIGTERMでグレースフルに終了する
SIGTERMを受け取ったら新しい接続を受け付けず、アイドル状態のkeep-alive接続はすぐに閉じ、処理中のリクエストには最後まで応答した後にその接続も閉じ、すべて終わったら自ら終了コード0で終了します。4秒以内に終わらないリクエストがあれば、残りの接続を切って終了します(3.5–5.2秒)。採点ツールは、アイドル接続1つと1.5秒かかるリクエスト1つをかけたままSIGTERMを送り、本文をだらだら引き延ばす(自分では終わらない)アップロードで締め切りも確認します。
方向: server.close()が何を閉じて何を残すのか、実際に測ってみてください。コールバックがいつ来るかが鍵です。 ツール: process.on('SIGTERM', ...)、server.close(cb)、server.closeIdleConnections()、server.closeAllConnections()、レスポンスヘッダーのConnection: close、setTimeout(...).unref()。 具体: close()はその瞬間にアイドルだった接続だけを閉じます。そのとき処理中だったkeep-alive接続はレスポンス後も開いたままなので、コールバックが来ません。終了処理中はレスポンスにConnection: closeを付けて、レスポンスと一緒に閉じるようにしてください。締め切りのタイマーはunrefしないと、それ1つのせいでプロセスが残ってしまいます。
インシデント — デプロイのたびに502
incident nd-deployを実行するとインシデントが発生します。前段(ndproxy、127.0.0.1:8080)の後ろにある3つのサービスcatalog・checkout・profileは、それぞれ2つのインスタンス(-a・-b)で動いており、デプロイ(ndctl deploy <service>)のたびに前段が502を返します。サービスごとに/srv/nd-deploy/<service>/server.mjsのSIGTERM処理を直して再起動し(ndctl restart <instance>)、3つのサービスすべてでデプロイ中も502が出ないようにします。前段の設定とプロセスには手を触れず、keep-aliveは維持します。/root/incident/nd-deploy/report.txtにcatalog: <code>、checkout: <code>、profile: <code>の3行と、サービスごとにその原因を示す記録(/var/log/nd/deploy.log・proxy.log)の1行をevidence-catalog: <line>、evidence-checkout: <line>、evidence-profile: <line>として書きます。原因はサービスごとに個別に決まっており、同じ原因が2つあることもあります。異なる原因コードで3回間違えるとインシデントが回収され、出し直す必要があります。コードは次のいずれかです: drain-too-short(締め切りが処理中のリクエストより短い)、exit-on-sigterm(SIGTERMですぐに終了する)、idle-left-open(レスポンスを終えた接続を開いたままにしている)、keepalive-shorter(keep-aliveが前段より短い)。
方向: 502の行は3つとも同じに見えます。見分けるのは、古いプロセスがSIGTERMの後いつ、どのように終わったかです。すぐに、少し後に、最後まで終わらず強制的に、のどれかです。 ツール: /var/log/nd/deploy.logのインスタンスの行(exit= · after · SIGKILL)、proxy.logのreused=、インスタンスのログ、サービスコードのprocess.on('SIGTERM'の部分、直した後にndctl deploy <service>で測り直す。 具体: ステップ06で組み立てた終了順序とサービスごとに比べて、欠けているものを探してください。デプロイツールの遅いリクエストは800msで(採点時はインスタンスごとに0.5秒・1.3秒に変えて、「決めておいた時間だけ待って終了する」を見分けます)、SIGTERMの3秒後にSIGKILLします。keep-aliveを切ったり、毎回のレスポンスにConnection: closeを付けたりして回避するのは修正ではありません(採点ツールが前段の接続の再利用を見ます)。