結論

Graceful shutdown は「SIGTERM を受けたら、新規の受付を止め、処理中のリクエストを終わらせ、接続とリソースを閉じてから終了する」止め方です。実装の芯は3つで、シグナルを受け取れる位置(PID 1)にいること、処理中の完了を待つ猶予を持つこと、そしてロードバランサーから外れてから止めることです。

この記事でわかること

  • JDK 21 標準の HttpServer と Node 22 の http.Server で、遅いリクエスト中に kill -TERM を送っても落ちない実装と、そのログ原文
  • 猶予切れ・SIGKILL・docker stop の10秒・nginx の quit と stop・PID 1 で SIGTERM が捨てられる様子を実測した表
  • Kubernetes の preStop と terminationGracePeriodSeconds、Spring Boot の server.shutdown=graceful の公式の決まりと、DB 接続プール・ジョブ・コンシューマの止め順の判断表

2026年11月4日更新|JDK 21.0.12・Node.js 22.23.4・nginx 1.24.0・Docker Engine 29.9.2 で実行確認

止め方の流れは、どの言語・どの基盤でも同じ形です。違うのは「誰が SIGTERM を送るか(docker stop・kubelet・systemd)」と「猶予が何秒か」だけです。

flowchart TD
    A[SIGTERM を受ける] --> B[listen を止める<br>新規接続を受けない]
    B --> C{処理中の<br>リクエストは?}
    C -->|0件| E[接続・DB プール・<br>ジョブを閉じる]
    C -->|残っている| D[完了を待つ<br>最大「猶予」秒]
    D -->|全部終わった| E
    D -->|猶予切れ| F[残りの接続を閉じて終了<br>上の基盤が SIGKILL]
    E --> G[exit 0]

よく調べられることの答えを先にまとめます。

知りたいこと 答え 詳しい節
graceful shutdown とは 新規受付を止め、処理中を終えてから閉じる止め方。SIGTERM が合図 Graceful shutdownとは
SIGTERM と SIGKILL の違い TERM は受け取って処理できる(終了コード 143)。KILL は即死(137)で何も実行できない SIGTERM・SIGKILL・終了コード
Java での書き方 shutdown hook で server.stop(猶予秒)。listen を閉じ、処理中の完了を待つ Java(JDK 標準)での実装と実測
Node での書き方 process.on('SIGTERM') で server.close()、猶予後に closeAllConnections() Node.js での実装と実測
docker stop は何秒待つ SIGTERM の後 10 秒で SIGKILL。--stop-timeout で変える docker stop の実測
SIGTERM が届かない PID 1 がシェルか、ハンドラの無い PID 1。exec 形式か --init PID 1 問題
kubernetes sigterm / preStop preStop → SIGTERM → 既定 30 秒で SIGKILL。LB からの切り離しは並行 Kubernetes の終了の順序
spring boot graceful shutdown 3.4 以降は既定で有効。猶予は spring.lifecycle の timeout-per-shutdown-phase(既定 30 秒) Spring Boot の graceful shutdown
止めたのに 502 が出る LB から外れる前に listen を閉じている。先に readiness を落として待つ listen を閉じた瞬間に起きること

確認環境:Ubuntu 24.04.5 LTS(2 CPU)の上で、JDK 21.0.12.1(OpenJDK、Ubuntu)の com.sun.net.httpserver と Node.js 22.23.4 の http.Server で同じ振る舞いのサーバーを書き、127.0.0.1 のポートで動かしました。遅いリクエスト(6〜8 秒)を curl 8.5.0 で送っている途中に kill -TERM・kill -KILL を送り、サーバーのログ・curl の終了コード・プロセスの終了コードを記録しています。nginx 1.24.0(Ubuntu の .deb を作業用フォルダーに展開)は自分の設定ファイルで 127.0.0.1:18230 に起動し、Node のサーバーへ中継する構成で -s quit・-s stop・worker_shutdown_timeout を試しました。Docker Engine 29.9.2 は、ホストのネットワーク設定に触れないよう --iptables=false --bridge=none と専用の data-root で自分用の dockerd を起動し、dash と静的リンクした C のプログラム(gcc 13.3.0)だけを入れた 3.3MB のイメージ(docker import で作成)で docker stop・--stop-timeout・--init を実測しました。PID 1 の挙動は Docker のほかに unshare --pid でも確かめています。Kubernetes は動かせないため、終了の順序・preStop・terminationGracePeriodSeconds は Kubernetes 公式ドキュメントの範囲、Spring Boot は Maven Central に接続できないため公式ドキュメントとソースコードの範囲で書いています。図は mermaid 11.16.0 で描画を確かめました(確認日 2026年11月4日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Graceful shutdownとは|SIGTERMで落とさず止める実装と実測」

Graceful shutdownとは(何を止めて、何を待つか)

この節では、graceful shutdown が守るものと、守るために必要な3つの条件が分かります。

Graceful shutdown(グレースフルシャットダウン)は、プロセスを「今すぐ消す」のではなく「受けた仕事を終えてから退く」止め方です。Web サーバーなら、処理中のリクエストに応答を返し終えてから接続を閉じ、DB 接続プールやバックグラウンドのジョブも後始末してから終了します。デプロイのたびに数件のリクエストが「Empty reply from server」や 502 で失敗しているなら、この止め方ができていないのが原因であることがほとんどです。

143SIGTERM で終わった終了コード128 + 15。Java・Node・C で同じ
10秒docker stop が SIGKILL するまで実測 10.07 秒。Kubernetes は 30 秒
0件フック有りで落ちたリクエストフック無しは 2 秒で curl (52)

必要な条件は3つです。どれか1つでも欠けると、残りを完璧に書いても落ちます。

1SIGTERM を受け取れる位置にいる

コンテナでは PID 1 に届く。シェル経由で起動していると、シェルが受けて終わりになる。ハンドラの無い PID 1 は SIGTERM を捨てる。

2処理中の完了を待つ猶予がある

アプリの猶予(stop(10)・server.close の待ち)は、基盤の猶予(docker の 10 秒・k8s の 30 秒)より短くする。長いと SIGKILL で結局落ちる。

3LB から外れてから listen を閉じる

listen を閉じた瞬間、新規接続は Connection refused。LB がまだ振り分けていれば 502 になる。先に readiness を落とし、数秒待つ。

SIGTERM・SIGKILL・終了コードの実物(143・137・timeout)

この節では、SIGTERM と SIGKILL でプロセスに何が起き、終了コードがどうなるかが分かります。

SIGTERM(15)は「終わってほしい」という依頼で、プロセスはハンドラで受け取って好きな後始末ができます。SIGKILL(9)はカーネルが直接プロセスを消すため、ハンドラは動かず、ログの1行も書けません。シェルから見た終了コードは 128 + シグナル番号で、TERM なら 143、KILL なら 137 です。docker stop や Kubernetes は「TERM を送って猶予だけ待ち、残っていれば KILL」という2段構えなので、終了コードを見れば「猶予内に終われたか」が分かります。

coreutils の timeout は、この2段構えを手元で再現する一番小さな道具です。静的リンクした C のプログラム app(1件の仕事に N 秒かかり、SIGTERM を受けると今の仕事を終えてから exit 0 する)で確かめました。

$ timeout -s TERM -k 3 5 ./app 15      # 5秒後に TERM、さらに3秒待って KILL
app start pid=6479 work=15s handler=1
job 1 start
Killed
exit code: 137  elapsed 8.007 s          # 仕事が15秒なので猶予3秒では終われず KILL

$ timeout -s TERM -k 10 5 ./app 4       # 仕事が4秒なら猶予内に終わる
job 2 start
  SIGTERM received, finishing job 2 first
job 2 done
app exit 0 (graceful)
exit code: 124  elapsed 8.004 s          # timeout 自身は「時間切れだった」ことを 124 で伝える
シグナル 番号・終了コード プロセスにできること 送り手の例
SIGTERM 15・143 ハンドラで受けて後始末。無視もできる kill、docker stop、kubelet、systemctl stop
SIGINT 2・130 SIGTERM と同じ扱いにするのが普通 端末の Ctrl+C
SIGQUIT 3・131 既定はコアダンプ。nginx は graceful の合図に使う nginx -s quit、Ctrl+\
SIGKILL 9・137 何もできない。ハンドラは呼ばれない kill -9、docker kill、猶予切れの基盤、OOM killer
SIGHUP 1・129 設定の再読み込みに使うことが多い nginx -s reload、端末の切断

シグナルの一覧と kill コマンドの使い方は、関連記事「Linux killコマンドの使い方|シグナル一覧とkill -9・pkill」で扱っています。

Java(JDK 標準 com.sun.net.httpserver)での実装と実測

この節では、ライブラリ無しの JDK 21 だけで graceful shutdown を実装した全コードと、遅いリクエスト中に SIGTERM を送った結果が分かります。

JVM は SIGTERM・SIGINT・SIGHUP を受けると shutdown hook を実行してから終了します。hook の中で HttpServer.stop(delay) を呼ぶだけで、listen の停止・処理中の完了待ち・接続のクローズまでが片付きます。公式の Javadoc は stop(int delay) を次のように定義しています。

Stops this server by closing the listening socket and disallowing any new exchanges from being processed. The method will then block until all current exchange handlers have completed or else when approximately delay seconds have elapsed (whichever happens sooner). Then, all open TCP connections are closed, the background thread created by start() exits, and the method returns.

コード(GracefulServer.java)

import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class GracefulServer {
    static final AtomicInteger inFlight = new AtomicInteger();

    public static void main(String[] args) throws Exception {
        int port = Integer.parseInt(args[0]);
        int graceSec = Integer.parseInt(args[1]);      // stop(delay) に渡す猶予秒

        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
        ExecutorService pool = Executors.newFixedThreadPool(8);
        server.setExecutor(pool);

        server.createContext("/slow", ex -> {
            inFlight.incrementAndGet();
            int sec = Integer.parseInt(ex.getRequestURI().getQuery().replace("sec=", ""));
            Thread.sleep(sec * 1000L);                 // 遅い処理のつもり
            byte[] body = ("done after " + sec + "s\n").getBytes(StandardCharsets.UTF_8);
            ex.sendResponseHeaders(200, body.length);
            try (OutputStream os = ex.getResponseBody()) { os.write(body); }
            inFlight.decrementAndGet();
        });

        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            log("SIGTERM received -> server.stop(" + graceSec + ")  in-flight=" + inFlight.get());
            server.stop(graceSec);   // 1) listen を閉じる 2) 処理中の完了を最大 graceSec 秒待つ 3) 接続を閉じる
            pool.shutdown();         // 4) 続けてワーカーと DB プールなどを閉じる
            try { pool.awaitTermination(graceSec, TimeUnit.SECONDS); } catch (InterruptedException ignored) {}
            log("shutdown hook finished");
        }, "shutdown-hook"));
        server.start();
    }
}

実験は3通りです。いずれも /slow?sec=6(または 8)を curl で送り、2 秒後に kill -TERM(または -KILL)を送りました。

A. hook 無し:2 秒で落ちる

[ 0.318s] listening on 127.0.0.1:18201 pid=25392 grace=10s hook=false
[ 0.954s] request start  /slow?sec=6 (in-flight=1)
java exit code: 143
--- curl
curl: (52) Empty reply from server
HTTP 000 time=2.041027s

B. hook 有り・猶予 10 秒・リクエスト 6 秒:落ちない

[ 0.181s] listening on 127.0.0.1:18202 pid=29102 grace=10s hook=true
[ 1.044s] request start  /slow?sec=6 (in-flight=1)
[ 2.958s] SIGTERM received -> server.stop(10)  in-flight=1
[ 7.176s] request done   /slow?sec=6 (in-flight=0)
[ 7.184s] server.stop returned after 4222 ms, in-flight=0
[ 7.192s] shutdown hook finished
java exit code: 143
--- slow curl
done after 6s
HTTP 200 time=6.193348s
--- 猶予中に別の curl で新規接続
curl: (7) Failed to connect to 127.0.0.1 port 18202 after 0 ms: Couldn't connect to server

C. hook 有り・猶予 3 秒・リクエスト 8 秒:猶予切れで接続が閉じられる

[ 2.907s] SIGTERM received -> server.stop(3)  in-flight=1
[ 5.934s] server.stop returned after 3004 ms, in-flight=1
[ 8.934s] shutdown hook finished
java exit code: 143
--- slow curl
curl: (52) Empty reply from server
HTTP 000 time=5.001452s
実験 処理中のリクエスト TERM から終了まで 終了コード
A. hook 無し curl (52) で失敗(2.0 秒) 約 0 秒 143
B. 猶予 10 秒・処理 6 秒 HTTP 200(6.19 秒) 4.2 秒(残りの処理ぶん) 143
C. 猶予 3 秒・処理 8 秒 curl (52) で失敗(5.0 秒) 6.0 秒(stop 3 秒+pool 3 秒) 143
K. kill -KILL curl (52) で失敗(2.0 秒) 0.016 秒 137
  • B でも終了コードは 143 です。hook が正常に終わっても、JVM は「SIGTERM で終了した」ことを 143 で返します。監視で「0 以外は異常」としているなら、143 は除外するか、hook の最後で System.exit(0) を呼ばない設計(hook の中で exit は呼べません)を前提に基盤側で扱います。
  • B の「猶予中の新規接続」が Connection refused になっている点が、後の節の 502 の正体です。
  • C で server.stop(3) が返った後もハンドラのスレッドは sleep を続けており、hook が終わると JVM ごと消えています。猶予はアプリの最長処理時間より長く、基盤の猶予より短く取ります(例:処理の上限 20 秒・アプリ 25 秒・Kubernetes 40 秒)。

Node.js(http.Server#close・closeIdleConnections)での実装と実測

この節では、Node 22 の http.Server で graceful shutdown を実装する形と、keep-alive の接続がどう扱われるかが分かります。

Node では server.close() が「新規接続を止め、処理中が終わるまで待つ」役で、猶予切れの強制終了は server.closeAllConnections() を自分で呼びます。v19.0.0 から close() はアイドルな keep-alive 接続も閉じるようになりました(公式ドキュメントの変更履歴「The method closes idle connections before returning」)。それ以前の版では、ブラウザや LB が張ったままの keep-alive 接続が残り、close() のコールバックがいつまでも呼ばれない問題があったため、closeIdleConnections()(v18.2.0 追加)を併せて呼ぶ書き方が広まりました。22 では不要ですが、呼んでも害はありません。

const http = require('node:http');
const port = Number(process.argv[2]);
const graceMs = Number(process.argv[3]);
let inFlight = 0;

const server = http.createServer((req, res) => {
  const url = new URL(req.url, 'http://x');
  if (url.pathname === '/slow') {
    const sec = Number(url.searchParams.get('sec') ?? 5);
    inFlight++;
    setTimeout(() => { res.end(`done after ${sec}s\n`); inFlight--; }, sec * 1000);
    return;
  }
  res.end('ok\n');
});
server.listen(port, '127.0.0.1');

let closing = false;
const shutdown = (sig) => {
  if (closing) return;                 // 2回目のシグナルは無視
  closing = true;
  console.log(`${sig} received -> server.close()  in-flight=${inFlight}`);
  server.close(() => {                 // 1) listen を止め 2) アイドル接続を閉じ 3) 処理中の完了を待つ
    clearTimeout(timer);
    // ここで DB プール・キューの購読などを閉じる
    process.exit(0);
  });
  const timer = setTimeout(() => {     // 4) 猶予を過ぎたら残りを切って終わる
    console.log(`grace ${graceMs} ms expired -> closeAllConnections()`);
    server.closeAllConnections();
    process.exit(1);
  }, graceMs);
  timer.unref();
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

実験では、処理中の遅いリクエストに加えて、nc で「1 回リクエストしたあと黙っている keep-alive 接続」を 1 本張っておき、その接続がいつ切れるかも見ました。

# 猶予 10 秒・リクエスト 6 秒
[ 0.008s] listening on 127.0.0.1:18212 pid=30215 grace=10000ms hook=true keepAliveTimeout=5000ms
[ 1.002s] request start  /slow?sec=6 (in-flight=1)
[ 3.000s] SIGTERM received -> server.close()  in-flight=1
[ 7.002s] request done   /slow?sec=6 (in-flight=0)
[ 7.003s] server.close callback after 4003 ms
node exit code: 0
--- slow curl
done after 6s
HTTP 200 time=6.001444s
--- idle keep-alive 接続: SIGTERM の直後に EOF(nc は exit 0)

# 猶予 3 秒・リクエスト 8 秒
[ 2.981s] SIGTERM received -> server.close()  in-flight=1
[ 5.986s] grace 3000 ms expired -> closeAllConnections()  in-flight=1
node exit code: 1
--- slow curl
curl: (52) Empty reply from server
HTTP 000 time=5.003201s

server.close() がしてくれること

listen を止める(新規は Connection refused)

アイドルな keep-alive 接続を閉じる(v19 以降)

処理中のリクエストが終わるのを待ってコールバック

自分で書くこと

猶予のタイマーと closeAllConnections()

DB プール・Redis・キュー購読のクローズ

2 回目のシグナルの無視と、process.exit の呼び出し

  • ハンドラを付けない素の Node に SIGTERM を送ると、処理中でも即終了し(終了コード 143)、curl は 2.0 秒で (52) Empty reply from server でした。Java の A と同じです。
  • server.keepAliveTimeout の既定は 22.23.4 で 5000 ms でした。公式ドキュメントの開発版には既定を 65 秒へ変える変更が入っているため、版を上げるときは確かめます。
  • Express・Fastify・NestJS も内側は同じ http.Server です。app.listen() の戻り値を保持して close() を呼びます。

listen を閉じた瞬間に起きること(LB からの切り離しと 502)

この節では、アプリ側の graceful shutdown が完璧でも、ロードバランサー経由だと 502 が出る理由と直し方が分かります。

SIGTERM を受けて listen を閉じると、その瞬間から新規接続は「Connection refused」になります。LB やリバースプロキシがまだそのインスタンスへ振り分けていれば、その分は 502 Bad Gateway です。nginx の後ろに置いた Node のサーバー(graceful 実装済み)を SIGTERM で正しく止めたあと、nginx 経由でリクエストを送った結果です。

before stop: HTTP 200
--- backend に SIGTERM(正しく graceful に止まる)
backend exit 0
after stop #1: HTTP 502 time=0.000396s
after stop #2: HTTP 502 time=0.000338s
--- nginx の error.log
[error] 3563#3563: *3 connect() failed (111: Connection refused) while connecting to upstream,
  client: 127.0.0.1, request: "GET /ready HTTP/1.1", upstream: "http://127.0.0.1:18240/ready"

upstream を 2 台にして片方だけ止めると、nginx は最初の失敗で隣へ回し(proxy_next_upstream の既定)、利用者から見れば 200 でした。error.log には失敗と「一時的に外した」記録が残ります。

GET  #1: HTTP 200 time=0.007502s     # 1台目に失敗→2台目で成功。時間が少し長い
GET  #2: HTTP 200 time=0.001268s
POST #1: HTTP 200 time=0.001476s
[error] *1 connect() failed (111: Connection refused) while connecting to upstream, upstream: "http://127.0.0.1:18240/ready"
[warn]  *1 upstream server temporarily disabled while connecting to upstream, upstream: "http://127.0.0.1:18240/ready"

ただし、これは「接続すらできなかった」から安全に回せただけです。接続が確立してから落ちた(応答の途中で切れた)場合、nginx は POST など冪等でないリクエストを既定では再送しません。正しい順序は、listen を閉じる前に LB から外れることです。

1

readiness を失敗させる(または preStop で待つ)

SIGTERM を受けたら、まず /ready が 503 を返すようにし、LB がヘルスチェックで気づく時間(間隔×失敗回数)だけ待つ。Kubernetes では Pod が Terminating になった時点で EndpointSlice から外れ始めるため、preStop の sleep で伝搬を待つのが定石。

LB の間隔が 5 秒×2 回なら 10 秒以上

2

listen を閉じ、処理中を待つ

ここで初めて server.stop()/server.close()。待ち時間の上限は、アプリの最長処理時間を少し超える値。

猶予はアプリ < 基盤

3

DB プール・購読・ジョブを閉じる

HTTP が止まってから閉じる。先に閉じると、処理中のリクエストが DB に書けずに失敗する。

順序は後の判断表

4

exit 0

終了コードは基盤のログに残る。猶予切れなら 137(SIGKILL)になるので、監視で拾う。

137 が出たら猶予を見直す

liveness と readiness の分け方は、関連記事「ヘルスチェック設計|livenessとreadinessの違いと実装例」で扱っています。ローリング更新・ブルーグリーンのどこでこの手順が効くかは、関連記事「デプロイ方式の種類と違い|ブルーグリーン・カナリア・ローリング比較」で扱っています。

nginx の quit と stop の違い(worker_shutdown_timeout の実測)

この節では、nginx の graceful shutdown(QUIT)と fast shutdown(TERM)が処理中のリクエストをどう扱うかが分かります。

nginx は -s quit(SIGQUIT)が graceful shutdown、-s stop(SIGTERM)が fast shutdown です。公式ドキュメントは master への QUIT を「graceful shutdown」、TERM・INT を「fast shutdown」と定義し、graceful のときの worker を「Old worker processes close listen sockets and continue to service old clients. After all clients are serviced, old worker processes are shut down.」と説明しています。Node のサーバーへ中継する構成で、6 秒かかるリクエストの途中に送りました。

### nginx -s quit(6 秒のリクエスト中)
0.5s later: master alive? yes
31356     1 nginx: master process /dev/shm/t244/deb/root/usr/sbin/nginx -p ... -c .../nginx.conf
31358 31356 nginx: worker process is shutting down        # ps で状態が見える
master gone after 5.2 s
--- slow curl: done after 6s / HTTP 200 time=6.019419s
--- 新規接続: curl: (7) Failed to connect to 127.0.0.1 port 18230
--- error.log
signal 3 (SIGQUIT) received from 31373, shutting down
31358#31358: gracefully shutting down
31358#31358: exiting

### nginx -s stop(同じ条件)
0.5s later: master alive? yes
 3330     1 [nginx] <defunct>
master gone after 1.3 s
--- slow curl: curl: (52) Empty reply from server / HTTP 000 time=1.999410s
--- error.log
signal 15 (SIGTERM) received from 3344, exiting
3331#3331: exiting

graceful で待ち続けるのを避けるには、main コンテキストの worker_shutdown_timeout(1.11.11 以降。既定は無し)を置きます。公式の定義は「When the time expires, nginx will try to close all the connections currently open to facilitate shutdown.」です。worker_shutdown_timeout 3s; で 8 秒のリクエスト中に quit を送ると、次のようになりました。

master gone after 4.6 s
--- slow curl: curl: (52) Empty reply from server / HTTP 000 time=5.006058s
操作 シグナル 処理中のリクエスト 使いどころ
nginx -s quit QUIT 完了まで待つ(200)。新規は拒否 通常の停止・デプロイ
nginx -s stop TERM 即切断(curl 52) 緊急時だけ
nginx -s reload HUP 新しい worker を起こし、前の worker は graceful に終わる 設定変更
worker_shutdown_timeout — 時間が来たら開いている接続を閉じる 長い接続(WebSocket 等)の上限
  • Docker の公式 nginx イメージは STOPSIGNAL SIGQUIT を指定しているため、docker stop で graceful になります。自分でイメージを作るときは、同じように STOPSIGNAL で合わせます。
  • reload も「前の worker の graceful shutdown」です。WebSocket のような長い接続があると前の worker がいつまでも残るため、worker_shutdown_timeout で上限を決めます。

docker stop の実測(SIGTERM→10秒→SIGKILL・--stop-timeout・終了コード)

この節では、docker stop が実際に何秒待ち、終了コードがどう残るかが分かります。

docker stop は PID 1 に SIGTERM(または STOPSIGNAL)を送り、既定 10 秒待っても終わらなければ SIGKILL します。公式ドキュメントの定義は「The main process inside the container will receive SIGTERM, and after a grace period, SIGKILL.」で、猶予は「10 seconds for Linux containers, and 30 seconds for Windows containers」、docker stop -t か作成時の --stop-timeout で変えられます。3.3MB の自作イメージ(dash と C の app だけ)で確かめました。

===== D1: exec 形式 CMD ["/bin/app","4"](app が PID 1、ハンドラ有り)
docker stop took 2.060 s ; exit code: 0
  | job 1 done
  | app exit 0 (graceful)

===== D5: 仕事に 15 秒かかる app を既定の 10 秒で stop
docker stop took 10.11 s ; exit code: 137          # 猶予切れ → SIGKILL
  | job 1 start

===== D6: 同じ app を --stop-timeout 20 で起動して stop
docker stop took 13.06 s ; exit code: 0            # 仕事を終えてから退けた
  | job 1 done
  | app exit 0 (graceful)

===== D7: docker kill
docker kill took .0493 s ; exit code: 137
条件 stop にかかった時間 終了コード 最後のログ
exec 形式・ハンドラ有り・仕事 4 秒 2.06 秒 0 app exit 0 (graceful)
シェル形式 CMD(sh が PID 1) 10.07 秒 137 job 3 start(途中で消えた)
exec 形式・ハンドラ無し 10.07 秒 137 job 3 start
ハンドラ無し+--init 0.05 秒 143 job 1 start(即終了)
仕事 15 秒・既定 10 秒 10.11 秒 137 job 1 start
仕事 15 秒・--stop-timeout 20 13.06 秒 0 app exit 0 (graceful)
  • 137 が出たら「猶予切れ」か「OOM」のどちらかです。docker inspect の State.OOMKilled が false で 137 なら、猶予切れの SIGKILL です。
  • Compose では stop_grace_period、Dockerfile では STOPSIGNAL、Kubernetes では terminationGracePeriodSeconds が同じ役です。
  • exit 137 の切り分けは、関連記事「Docker Exited (137)の原因と直し方|OOMKilled」が近い題材です。

PID 1 問題(シェル形式・ハンドラ無し・--init/tini)の実測

この節では、「SIGTERM を送っているのに 10 秒待って 137 になる」ときの原因が、プロセスツリーで分かります。

PID 1 には特別な決まりがあります。カーネルは、ハンドラを登録していない PID 1 には既定動作のシグナル(SIGTERM を含む)を届けません。そのため「シェル形式の CMD で sh が PID 1」「ハンドラの無いプログラムが PID 1」のどちらも、docker stop の SIGTERM は捨てられ、10 秒後の SIGKILL で消えます。Docker を使わずに unshare --pid で PID 名前空間を作っても同じことが起きます。

### 1) PID 名前空間の PID 1 で、ハンドラ無しの node に SIGTERM
$ unshare -pf --mount-proc node graceful.js 18221 10000 nohook &
host-side node pid=31126 ; inside ns: pid=1
$ kill -TERM 31126 ; sleep 1
=> 1秒後も生きている(SIGTERM は捨てられた)
$ kill -KILL 31126        # SIGKILL は PID 1 にも効く

### 2) 同じ PID 1 でも、ハンドラ有りの node なら
[ 0.783s] SIGTERM received -> server.close()  in-flight=0
exit 0

### 3) sh -c 'node graceful.js ...; true' で包んで sh に SIGTERM
sh pid=31170 -> child: 31172 node
sh alive? no / node alive? yes pid=31172 ppid=1     # sh だけ死に、node は孤児になって動き続ける

Docker の中でも docker top で同じ構造が見えます。

--- シェル形式 CMD /bin/app 4; true
PID   PPID  COMMAND
6140  6115  /bin/sh -c /bin/app 4; true     # これが PID 1。SIGTERM はここで止まる
6154  6140  /bin/app 4

--- docker run --init
6196  6171  /sbin/docker-init -- /bin/app 4  # tini が PID 1 になり、子へシグナルを転送する
6209  6196  /bin/app 4

Dockerfile の CMD/ENTRYPOINT

やってはいけないCMD node server.js(シェル形式)、CMD npm start、起動スクリプトの最後で node server.js と書いて終わる
→
代わりにCMD ["node","server.js"](exec 形式)、スクリプトの最後は exec node server.js、どうしても包むなら --init か tini を ENTRYPOINT に

確かめ方:docker top <container> で PID 1 がアプリ本体(か tini)になっているか

  • --init(tini)はシグナルを転送し、ゾンビプロセスも回収しますが、アプリ側のハンドラが無ければ「即終了」になるだけです(上の表の 0.05 秒・143)。graceful にするのはあくまでアプリのハンドラです。
  • npm start は npm がシグナルを子へ転送する版としない版があり、転送しても exit code の扱いが変わります。本番の CMD は node を直接呼びます。
  • PID 1 を含む Docker の落とし穴は、関連記事「Dockerの落とし穴8選|レイヤ・latest・PID 1」で扱っています。

Kubernetes の終了の順序(preStop・terminationGracePeriodSeconds・readiness)

この節では、Pod が消えるときに kubelet とコントロールプレーンが何をどの順でするかが、公式の記述で分かります。

Kubernetes は動かせないため、この節は公式ドキュメント「Pod Lifecycle」「Container Lifecycle Hooks」の範囲です。要点は、「preStop → SIGTERM → 猶予(既定 30 秒)→ SIGKILL」という kubelet 側の流れと、「EndpointSlice からの削除(LB からの切り離し)はそれと並行して進む」という2本立てになっていることです。

  1. 1Pod に削除の期限が付き、Terminating になるkubectl の既定の猶予は 30 秒。「The default terminationGracePeriodSeconds setting is 30 seconds.」
  2. 2kubelet が preStop フックを実行するExec・HTTP・Sleep の3種類。「the hook must complete its execution before the TERM signal can be sent」
  3. 3各コンテナの PID 1 に TERM(STOPSIGNAL があればそれ)猶予のカウントダウンは preStop の前から始まっている。preStop が猶予を超えたら「a small, one-off grace period extension of 2 seconds」だけ延びる
  4. 4(2・3 と並行)EndpointSlice から外され、Service の振り分け先から消える終了中のエンドポイントは ready が false になる。伝搬には時間がかかる
  5. 5猶予が切れたら SIGKILL、その後 Pod オブジェクトが削除されるkubectl delete --grace-period=0 --force は kubelet の確認を待たずに API から消す

4 が 2・3 と並行なので、SIGTERM を受けた瞬間に listen を閉じると、まだ振り分けが止まっていない LB から 502 が出ます。これが preStop に数秒の sleep を置く理由です。設定の形は次のとおりです(Sleep アクションは公式の Hook handler の一覧に載っています。使える版は利用中のクラスターの公式ドキュメントで確かめます)。

spec:
  terminationGracePeriodSeconds: 40      # preStop + アプリの猶予 + 余裕
  containers:
  - name: app
    image: example/app:1.0
    lifecycle:
      preStop:
        exec:
          command: ["sh", "-c", "sleep 10"]   # EndpointSlice からの削除が伝わるのを待つ
    readinessProbe:
      httpGet: { path: /ready, port: 8080 }
      periodSeconds: 5
値 決め方 例
preStop の sleep EndpointSlice の伝搬+Ingress/LB が切り離すまでの時間 5〜15 秒
アプリの猶予 最長のリクエスト処理時間を少し超える値 20〜25 秒
terminationGracePeriodSeconds preStop + アプリの猶予 + 数秒。これより長い処理は SIGKILL される 40 秒
readiness の間隔×失敗回数 preStop の sleep より短くする 5 秒×1〜2 回

上流から下流までの時間の積み上げ方は、関連記事「タイムアウト設計の値の決め方|接続・読み取りの違いと多段の計算例」で扱っています。

Spring Boot の graceful shutdown(server.shutdown・timeout-per-shutdown-phase)

この節では、Spring Boot で graceful shutdown を有効にする設定と、猶予の決め方が公式の記述で分かります。

Maven Central に接続できないため、この節は Spring Boot 公式ドキュメント「Graceful Shutdown」とソースコードの範囲です。Spring Boot 3.4 のリリースノートは「Graceful shutdown of the embedded web server (Jetty, Rector Netty, Tomcat, or Undertow) is now enabled by default.」と述べており、以前の挙動に戻すには server.shutdown=immediate を指定します。3.3 以前では server.shutdown=graceful を明示します。

server:
  shutdown: graceful                       # 3.4 以降は既定。3.3 以前は明示する
spring:
  lifecycle:
    timeout-per-shutdown-phase: "20s"      # 処理中の完了を待つ上限。既定は 30s
項目 公式の記述 設計上の意味
有効化 3.4 以降は既定で有効。server.shutdown=immediate で無効 版を上げると挙動が変わる。停止時間が延びたと感じたらこれ
猶予 spring.lifecycle の timeout-per-shutdown-phase。Boot のソースでは Duration.ofSeconds(30) が既定 Kubernetes の 30 秒と同じなので、そのままだと先に SIGKILL され得る。短くするか k8s 側を延ばす
実行の位置 「SmartLifecycle Bean の停止の最も早いフェーズ」で行う HTTP の受付停止と完了待ちが先、DataSource などの破棄は後。順序は自動で守られる
新規リクエストの拒否 「Jetty, Reactor Netty, and Tomcat will stop accepting new requests at the network layer.」 listen を閉じるのと同じ。LB からの切り離しは別に要る
IDE での停止 適切な SIGTERM を送らない IDE では即時終了になることがある 手元で試すときは kill -TERM を使う
  • 自作の後始末(キューの購読停止など)を順序に組み込むには SmartLifecycle を実装し、getPhase() で Web サーバーより後に止まる値にします。
  • HikariCP など接続プールの close は Bean の破棄で行われます。graceful の待ちが終わってから閉じられるため、処理中のリクエストが DB を使えなくなることはありません。

DB 接続プール・バックグラウンドジョブ・メッセージコンシューマの止め方(判断表)

この節では、HTTP 以外の「止めにくいもの」をどの順で、どう止めるかが分かります。

順序は「入口を閉じる → 処理中を待つ → 出口を閉じる」です。HTTP の listen、キューの購読、スケジューラーの起動が入口、DB プール・Redis・外部 API のクライアントが出口にあたります。出口を先に閉じると、処理中の仕事が最後の一歩で失敗し、graceful の意味が無くなります。

対象 止める順 やること 猶予切れのとき
HTTP サーバー 1(入口) listen を閉じ、処理中の完了を待つ 残りの接続を閉じる。クライアント側のリトライに任せる
メッセージコンシューマ(SQS・Kafka・RabbitMQ) 1(入口) 新しい受信(poll)を止め、手元のメッセージだけ処理して ack/commit する ack しない。可視性タイムアウト後に再配信されるので、処理は冪等にしておく
スケジューラー・バッチ 1(入口) 新規の起動を止め、走っているジョブの完了を待つ。長いジョブはチェックポイント 再開できる単位で中断し、次回に続きから
WebSocket・SSE 2 クライアントへ close フレーム(1001 Going Away)を送り、再接続させる 切断。クライアントの再接続と再送で吸収
DB 接続プール・Redis・HTTP クライアント 3(出口) 上の全部が終わってから close。トランザクション中なら完了を待つ DB 側で接続が切れた扱い。未コミットはロールバックされる
1

入口を閉じる

listen・poll・スケジューラー。「もう新しい仕事は取らない」を最初に宣言する。

LB から外れた後で

2

処理中を数える

in-flight のカウンターを持ち、0 になるかタイムアウトまで待つ。ログに残す。

本記事の inFlight

3

出口を閉じる

DB プール・Redis・外部クライアント。ここで初めて close。

Spring は Bean 破棄で自動

4

猶予切れを設計する

切れたら何が起きるかを決めておく。冪等・再配信・再開できる単位。

137 を監視する

止める途中で切れた処理は、再配信されても結果が変わらない(冪等な)作りにしておくと、猶予切れの影響を小さくできます。

よくある失敗と直し方(症状→原因→直し方)

この節では、graceful shutdown を入れたつもりでも落ちるときの、症状からの切り分けが分かります。

症状(原文) 原因 直し方
docker stop が毎回 10 秒かかり、exit code 137 PID 1 がシェル、またはハンドラの無い PID 1 で SIGTERM が捨てられている exec 形式の CMD、exec で起動、--init。docker top で PID 1 を確かめる
curl: (52) Empty reply from server がデプロイ直後だけ出る ハンドラが無い。または猶予がアプリの処理時間より短い ハンドラを付け、猶予を「最長処理+α」に。猶予切れのログを出す
nginx の error.log に connect() failed (111: Connection refused) while connecting to upstream LB/プロキシから外れる前に listen を閉じた readiness を先に落とす。preStop の sleep。upstream を 2 台以上に
upstream prematurely closed connection while reading response header from upstream 応答の途中で接続が切られた。猶予切れの closeAllConnections か SIGKILL 猶予を延ばす。基盤の猶予 > アプリの猶予 > 最長処理 の順を守る
Node の server.close() のコールバックが呼ばれない v18 以前で keep-alive のアイドル接続が残っている。または WebSocket closeIdleConnections()、猶予後に closeAllConnections()。WebSocket は自分で close
Kubernetes で Pod が Terminating のまま 30 秒残る SIGTERM が届いていないか、preStop が長い。猶予切れで SIGKILL されている コンテナのログに「received」が出るか確かめる。terminationGracePeriodSeconds を見直す
Spring Boot 3.4 に上げたら停止に時間がかかる graceful が既定になり、処理中の完了を待っている 正常。短くするなら timeout-per-shutdown-phase。無効化は server.shutdown=immediate
IDE の停止ボタンでは hook が動かない IDE が SIGTERM ではなく SIGKILL 相当で止めている 端末から kill -TERM <pid> で試す

よくある質問

Q. graceful shutdown と graceful restart は違いますか?
restart は「止めてから起こす」のではなく、新しいプロセスを先に起こして前のプロセスを graceful に止めることを指します。nginx の -s reload(HUP)や USR2 によるバイナリ更新がそれで、前の worker は「Old worker processes close listen sockets and continue to service old clients.」の動きをします。

Q. SIGTERM のハンドラの中で何秒まで待ってよいですか?
上の基盤の猶予から preStop などの前処理を引いた残りより短くします。docker の既定は 10 秒、Kubernetes の既定は 30 秒、Spring Boot の既定も 30 秒です。基盤の猶予はアプリの猶予より長く設定し直すのが先です。

Q. SIGTERM を 2 回受けたらどうしますか?
2 回目は無視するか、即終了にするかを決めておきます。本記事の Node の例は無視しています。運用者が Ctrl+C を2回押したら即終了、というのも一つの設計です。

Q. 終了コードが 143 なのは失敗ですか?
SIGTERM で終わったという意味で、hook が正常に完了していても Java は 143 を返しました。Node で process.exit(0) を呼べば 0 になります。基盤が「0 以外は異常」と扱うかどうかを確かめます。137 だけは猶予切れか OOM なので調べます。

Q. 処理中のリクエストが無ければ即終了してよいですか?
LB からの切り離しが済んでいれば問題ありません。済んでいないと、終了直後に届いた新規接続が Connection refused になり 502 になります。「処理中が 0 件」と「LB から外れた」は別の条件です。

Q. 長いアップロード・ストリーミング・WebSocket はどうしますか?
猶予内に終わらない前提で、クライアントに再接続・再開の仕組みを持たせます。WebSocket は close フレーム(1001)を送って再接続させ、nginx 側は worker_shutdown_timeout で上限を決めます。

Q. SIGKILL されると DB はどうなりますか?
DB サーバー側で接続が切れた扱いになり、コミットされていないトランザクションはロールバックされます。コミット済みの変更は残ります。「コミットしたのにメッセージを送る前に死んだ」という不整合は graceful shutdown では防げないため、Outbox パターンや冪等な再送で扱います。

Q. systemd で動かしているときは?
systemctl stop は unit の KillSignal(既定 SIGTERM)を送り、TimeoutStopSec(既定 90 秒)を過ぎると SIGKILL します。考え方は docker stop と同じで、値が違うだけです。

Q. Python(uvicorn・gunicorn)や Go はどう書きますか?
uvicorn は SIGTERM を受けて自分で graceful に止まり(--timeout-graceful-shutdown)、gunicorn は --graceful-timeout(既定 30 秒)です。Go は http.Server.Shutdown(ctx) が listen の停止と完了待ちを行い、ctx のタイムアウトが猶予です。どれも本記事の流れと同じ構造です。

Q. テストで graceful shutdown を確かめるには?
本記事の手順そのままです。遅いエンドポイントを 1 つ用意し、curl を走らせている最中に kill -TERM を送り、curl の終了コードが 0 でサーバーの終了コードが 0 か 143 になることを CI で確かめます。timeout -s TERM -k 5 10 <起動コマンド> で基盤の 2 段構えも模せます。

まとめ

  • Graceful shutdown は「SIGTERM を受けたら、新規受付を止め、処理中を終え、出口を閉じてから exit」。JDK 標準の HttpServer.stop(delay) と Node の server.close()+closeAllConnections() で実装でき、遅いリクエスト中の kill -TERM でも 200 が返りました。
  • ハンドラが無いと、Java も Node も 2 秒で curl: (52) Empty reply from server。SIGKILL(137)は何も実行できません。
  • 猶予は「最長処理 < アプリの猶予 < 基盤の猶予」。docker stop は 10 秒、Kubernetes と Spring Boot は 30 秒が既定で、超えると SIGKILL(137)です。
  • PID 1 問題は実測で再現しました。シェル形式の CMD とハンドラ無しの PID 1 は SIGTERM を捨て、10 秒後に 137。exec 形式か --init、そしてアプリのハンドラが要ります。
  • listen を閉じると新規は Connection refused で、LB からは 502。先に readiness を落とす・preStop で待つ・それから止める、の順序が graceful の最後の 1 ピースです。

参考・出典

確認日はいずれも 2026年11月4日です。

  • Oracle「Java SE 21 API: HttpServer.stop(int delay)」:https://docs.oracle.com/en/java/javase/21/docs/api/jdk.httpserver/com/sun/net/httpserver/HttpServer.html
  • Node.js「HTTP: server.close / closeAllConnections / closeIdleConnections / keepAliveTimeout」:https://nodejs.org/api/http.html
  • nginx「Controlling nginx」(シグナルと graceful shutdown):https://nginx.org/en/docs/control.html
  • nginx「Core functionality: worker_shutdown_timeout」:https://nginx.org/en/docs/ngx_core_module.html#worker_shutdown_timeout
  • Docker「docker container stop」:https://docs.docker.com/reference/cli/docker/container/stop/
  • Kubernetes「Pod Lifecycle: Termination of Pods」:https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination
  • Kubernetes「Container Lifecycle Hooks」:https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/
  • Spring Boot「Graceful Shutdown」:https://docs.spring.io/spring-boot/reference/web/graceful-shutdown.html
  • Spring Boot「Spring Boot 3.4 Release Notes」(Graceful shutdown enabled by default):https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.4-Release-Notes
  • Spring Boot のソースコード「LifecycleProperties.java」(timeoutPerShutdownPhase の既定 30 秒):https://github.com/spring-projects/spring-boot/blob/main/core/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/context/LifecycleProperties.java

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Graceful shutdownとは|SIGTERMで落とさず止める実装と実測」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

成長のヒミツ、 ぜんぶ丸わかり