グレースフルシャットダウン入門|SIGTERMの扱いとコネクションドレインで安全に停止する

kento_morota 12分で読めます
社内のAI・IT活用、技術がわかる相手に無料相談。 相談 →
目次

デプロイのたびに数件だけ502や接続リセットのエラーが出る。docker stop がなぜか毎回10秒かかる。決済処理の途中でコンテナが落ちて、二重請求の問い合わせが来た。こうした「止めるときの事故」は、起動の仕組みはよく理解していても、停止の仕組みを意識していないときに起こります。

この記事では、プロセスを止めるときに何が起きているのかをシグナルの仕組みから説明し、処理中のリクエストを完了させてから終了する「グレースフルシャットダウン」をNode.js・Go・Pythonでどう実装するか、DockerとKubernetesでそれを機能させるために何を設定すべきかを解説します。プロセスやkillコマンドの基本操作はLinuxのプロセス管理入門で扱っていますので、本記事では「アプリ側でどう受け止めるか」に集中します。

SIGTERM・SIGKILL・SIGHUPの違い

「お願い」と「強制」の違い

シグナルはカーネルがプロセスに送る通知です。停止に関わる主なものを整理します。

シグナル番号意味アプリで捕捉できるか主な送り手
SIGTERM15「終了してください」という依頼できる(後片付けの機会がある)kill、systemctl stop、docker stop、Kubernetes
SIGINT2端末からの割り込みできるCtrl+C
SIGHUP1本来は端末切断。デーモンでは設定再読み込みの慣習できるnginx -s reload など
SIGKILL9即時強制終了できない(カーネルが直接殺す)kill -9、猶予時間切れのdocker/Kubernetes

重要なのは、SIGTERMは「依頼」であり、受け取ったプロセスが何をするかはアプリ次第だという点です。ハンドラを実装していなければデフォルト動作で即座に終了し、処理中のリクエストは途中で切れます。そしてどの実行環境も「SIGTERMを送って一定時間待ち、まだ生きていればSIGKILL」という二段構えで動きます。グレースフルシャットダウンとは、この猶予時間の中で後片付けを終える設計のことです。

後片付けで何をすべきか

  1. 新しいリクエストの受付をやめる(リッスンソケットを閉じる)。
  2. ロードバランサーのヘルスチェックに「不健全」を返し、振り分けから外してもらう。
  3. 処理中のリクエストが完了するまで待つ(コネクションドレイン)。
  4. DBコネクションプールやメッセージキューの接続を閉じる。
  5. それでも終わらなければ、自分で決めたタイムアウトで諦めて終了する。

3と5の間に自前のタイムアウトを置くのがポイントです。実行環境の猶予時間より短く設定しておけば、SIGKILLで無理やり殺されるのではなく、自分の制御下で終了できます。

Node.js・Go・Pythonでのハンドラ実装

Node.js(http.Server)

server.close() は新規接続の受付を止め、既存接続の完了を待ちます。ただしKeep-Aliveでアイドル状態の接続は「既存接続」として残り続け、closeが完了しない原因になります。Node.js 18.2以降にある closeIdleConnections() を併用してください。

const server = app.listen(3000);
let shuttingDown = false;

function shutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log(`${signal} received, draining connections...`);

  // 新規受付停止 + 処理中リクエストの完了を待つ
  server.close(async () => {
    await db.end();           // プール解放
    process.exit(0);
  });
  server.closeIdleConnections();   // Keep-Aliveのアイドル接続を閉じる

  // 猶予時間より短い自前タイムアウト(K8sのデフォルト30秒より短く)
  setTimeout(() => {
    console.error('Forced shutdown after timeout');
    process.exit(1);
  }, 20_000).unref();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

// ヘルスチェックはドレイン中に503を返し、LBから外れる
app.get('/healthz', (req, res) => res.status(shuttingDown ? 503 : 200).end());

Go(net/http)

Goは標準ライブラリに Shutdown が用意されており、コンテキストで待機上限を渡す設計が定石です。

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()

srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatal(err)
    }
}()

<-ctx.Done() // シグナル待ち
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
    log.Printf("forced shutdown: %v", err)
}

Python(ワーカープロセス)

GunicornやUvicornはSIGTERMを受けるとワーカーに処理完了を待たせる仕組みを持っています(Gunicornの graceful_timeout はデフォルト30秒)。自前で書くのは、キューを消費するワーカーやバッチのような長時間処理です。「今のジョブを終えたら抜ける」フラグ方式が基本です。

import signal, time

stop_requested = False

def handle_term(signum, frame):
    global stop_requested
    stop_requested = True   # ここで重い処理をしない。フラグを立てるだけ

signal.signal(signal.SIGTERM, handle_term)
signal.signal(signal.SIGINT, handle_term)

while not stop_requested:
    job = queue.get(timeout=1)
    if job is None:
        continue
    process(job)       # ジョブ単位は中断しない(冪等でないなら特に)
    job.ack()

cleanup()   # 接続を閉じてから終了

ハンドラの中でDB操作やログ出力のような重い処理をしないのは、シグナルハンドラがどのタイミングで割り込むか予測できず、デッドロックの原因になるためです。

DockerのSTOPSIGNALとPID 1問題

docker stopが10秒待って強制終了する仕組み

docker stop はコンテナのPID 1に STOPSIGNAL(デフォルトSIGTERM)を送り、-t で指定した秒数(デフォルト10秒)待ってからSIGKILLを送ります。Docker Composeでは stop_grace_period で変更できます。ここで見落とされがちなのが、PID 1には通常のシグナルのデフォルト動作が適用されないというLinuxの仕様です。ハンドラを登録していないPID 1プロセスはSIGTERMを受け取っても何も起きず、10秒後にSIGKILLで殺されます。「アプリでハンドラを実装したのに効かない」場合、ほぼこの問題です。

典型的な原因はシェル形式のCMD

# NG: シェル形式。/bin/sh -c "node server.js" となり、PID 1はshになる
CMD node server.js

# OK: exec形式。nodeが直接PID 1になりシグナルを受け取れる
CMD ["node", "server.js"]

# entrypoint.shを使う場合は最後に exec で置き換える
#!/bin/sh
set -e
./migrate.sh
exec node server.js

# もしくは軽量initにシグナル転送を任せる
# docker run --init ... / Dockerfileで tini を使う

シェル形式では sh がPID 1になり、SIGTERMを子プロセスのnodeに転送しません。exec形式にするか、entrypointの末尾で exec を使ってプロセスを置き換えるのが定石です。Composeでの本番運用時の設定はDocker Compose本番環境での使い方も参考にしてください。

KubernetesのpreStopとterminationGracePeriodSeconds

「SIGTERM」と「振り分けからの除外」は同時に走る

KubernetesでPodが削除されるとき、「Serviceのエンドポイントから外す処理」と「コンテナへのSIGTERM送信」は並行して始まります。エンドポイント更新がkube-proxyやIngressコントローラーに伝わるまでには時間差があるため、SIGTERMを受けて即座にリッスンを止めると、まだ振り分けられてくるリクエストが接続拒否になります。これがローリングアップデート中にだけ数件エラーが出る典型的な理由です。

preStopで数秒待ってから止める

spec:
  terminationGracePeriodSeconds: 45   # デフォルトは30秒
  containers:
    - name: app
      lifecycle:
        preStop:
          exec:
            command: ["sh", "-c", "sleep 5"]   # 振り分け除外が伝播するのを待つ

preStopフックが終わってからSIGTERMが送られ、そこからアプリのドレインが始まります。preStopの待ち時間とアプリの自前タイムアウトの合計が terminationGracePeriodSeconds を超えないように設計してください。超えるとSIGKILLで殺されます。Readinessプローブが503を返すようにしておけば、除外はさらに確実になります。Kubernetesの基本概念はKubernetes入門で解説しています。

トラブル事例:デプロイ中に決済が二重実行された

症状

ECサイトの注文処理がデプロイ直後に二重登録される事象が、月に数回発生していました。ログを見ると、旧コンテナが決済APIを呼び出した直後に停止し、リトライした新コンテナが再度決済を呼んでいました。

原因

Dockerfileが CMD npm start というシェル形式で、PID 1がnpmとshになっていたため、SIGTERMがNodeプロセスに届いていませんでした。アプリのSIGTERMハンドラは正しく実装されていたものの一度も動いておらず、毎回10秒後のSIGKILLで、決済APIの応答を受け取る直前のリクエストが切られていたのです。

対処

  1. CMD ["node", "dist/server.js"] のexec形式に変更し、docker stop 直後にログへ「SIGTERM received」が出ることを確認。
  2. 決済リクエストに冪等キー(注文IDから生成)を付け、万一の再実行でも二重課金にならないようにした。
  3. ロードバランサーの登録解除待ち(AWS ALBならDeregistration delay)とアプリのタイムアウトを揃え、stop_grace_period を30秒に延ばした。

停止処理の改善と冪等性の確保は両輪です。グレースフルシャットダウンは「事故の確率を下げる」ものであり、SIGKILLや電源断は最終的に避けられないので、外部への副作用を伴う処理は再実行に耐える設計にしておく必要があります。ロードバランサー側の動きはロードバランサーとは?で解説しています。

まとめ

安全な停止は「SIGTERMを受けたらリッスンを閉じ、処理中を待ち、猶予内に自分で終了する」の3点に集約されます。実行環境ごとに確認すべき項目をまとめます。

  1. アプリ: SIGTERM/SIGINTのハンドラを実装し、自前のタイムアウトを猶予時間より短くする。
  2. Docker: CMDはexec形式、entrypointは末尾で execdocker stop が即座に終わるか確認する。
  3. Kubernetes: preStopで数秒待ち、preStop+ドレイン時間が terminationGracePeriodSeconds に収まるようにする。
  4. 設計: 外部副作用を伴う処理は冪等にし、強制終了されても壊れないようにする。

まずは手元の環境で docker stop の所要時間を計ってみてください。10秒ちょうどかかるなら、SIGTERMは届いていません。デプロイ時のエラーゼロを目指したインフラ設計や、コンテナ運用の見直しはHarmonic Societyのシステム開発・インフラ支援でご相談いただけます。

#グレースフルシャットダウン#SIGTERM#シグナル#デプロイ

Harmonic Society

この記事の内容、自社の業務でも活かせそうですか?

ローカルLLM・AI・クラウドなどの技術導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK。まずは30分の無料相談から。売り込みはしません。

共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

関連記事

Related / 9 articles

  1. プログラミング

    DDoS攻撃の仕組みと対策入門|レイヤー別の防御とCDN・クラウドの活用

    DDoS攻撃をボリューム型・プロトコル型・アプリ層に分けて仕組みを解説し、自前サーバーで防げない理由、CloudflareやAWS Shieldの標準防御、オリジンIPの隠し方、レートリミットとBot対策、攻撃を受けたときの初動、費用が跳ね上がるDenial of Walletへの備えまでわかります。

  2. プログラミング

    WAFとは?仕組み・導入パターン・誤検知対策|Webアプリを攻撃から守る実践ガイド

    WAFがファイアウォールやIDSと何が違うのか、シグネチャとマネージドルールの仕組み、Cloudflare WAF・AWS WAF・ModSecurityの比較、フォーム送信がブロックされる誤検知の調査と例外設定、ログ監視、WAFが代替できないことまで実践的に解説します。

  3. プログラミング

    セキュリティヘッダー入門|CSP・HSTS・X-Frame-Optionsの設定と効果を実践解説

    CSP・HSTS・X-Frame-Options・X-Content-Type-Optionsなど主要セキュリティヘッダーが防ぐ攻撃と、CSPのReport-Onlyからの段階導入、nonce/hash、HSTS preloadの不可逆リスク、Nginx・Next.js・Astroでの設定例、確認方法を解説します。

  4. プログラミング

    クラウドの通信費(Egress)入門|データ転送量課金の仕組みと転送コストを抑える設計

    クラウドの「受信無料・送信有料」の原則、AZ間・リージョン間・インターネット向けの単価差、NATゲートウェイ処理料の罠、CDNで転送量を減らす方法、バックアップやログ転送の見落とし、請求書で転送料を特定する手順を解説。想定外の請求を防げます。

  5. プログラミング

    秘密情報をGitに入れない仕組み|.gitignore・git-secrets・履歴から漏れた鍵の削除

    APIキーや.envをGitにコミットしてしまう典型経路と、.gitignore・.env.exampleの運用、pre-commitでのgitleaks検知、GitHub secret scanningの活用、漏れた鍵の無効化と履歴書き換え(git filter-repo)の手順を解説。仕組みで再発を防げます。

  6. プログラミング

    開発・ステージング・本番環境の分離設計|環境差分をなくす構成とアクセス制御

    開発・ステージング・本番それぞれの目的と、構成をコードで揃える方法、環境別の設定注入、本番データを使わないテストデータ戦略、ステージングの保護(Basic認証・IP制限・noindex)、コストを抑える運用までを解説。環境差分による本番障害を防げます。

  7. プログラミング

    ngrok・Cloudflare Tunnelでローカルを公開|Webhook開発とデモ環境の作り方

    NAT内のローカル環境にStripeやLINEのWebhookを届けるトンネリングの仕組みを解説。ngrok・Cloudflare Tunnel・localtunnelの比較、固定ドメインと認証、リクエスト検査、公開時のセキュリティ、自宅サーバー公開への応用までわかります。

  8. プログラミング

    ローカル開発環境のHTTPS化|mkcert・hostsファイル・自己署名証明書の正しい使い方

    ローカル開発をHTTPS前提にすべき理由(Secure Cookie・Service Worker・OAuth)と、mkcertでローカルCAを作りhostsで独自ドメインを割り当ててVite・Next.js・Dockerで使う手順を解説。証明書警告を無視する癖の危険も理解できます。

  9. プログラミング

    localhost・0.0.0.0・127.0.0.1の違い|ポートとUnixソケットを理解して「つながらない」を解決

    localhost・127.0.0.1・0.0.0.0の意味の違い、Dockerで外から接続できない原因、host.docker.internal、ポート競合の調べ方、Unixソケットの利点と権限、1024未満ポートの制約を解説。「つながらない」を仕組みから解決できます。

Harmonic Society

「読んで終わり」にせず、自社の業務で試してみませんか?

AI・ローカルLLM・クラウドの導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK・売り込みなし。

無料・30分・オンラインOK|1営業日以内に返信します