Skip to content

필사 모드: プロセスとシグナル完全ガイド: 終了しない理由を最後まで追跡する

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに

デプロイスクリプトから kill を送ったのにプロセスが死にません。kill -9 を送ってもリストに残ったままです。端末を閉じたらバックグラウンドジョブも一緒に死にました。コンテナを停止したらアプリケーションがデータを書いている途中で切れました。

4 つとも同じ知識が欠けていることから生じる問題です。シグナルがプロセスにどう配送され、誰がそれを無視でき、どの状態ではそもそも配送されないのかという知識です。

この記事はシグナルの一覧を暗記するためのものではありません。シグナルがプロセスグループとセッション、制御端末、親子関係と絡み合って生み出す実際の運用状況を順番に扱います。最後には、アプリケーションとコンテナでグレースフルシャットダウンを設計する方法まで続きます。

前提は Linux カーネル 5.x 以上、x86-64 および ARM アーキテクチャです。シグナル番号はアーキテクチャごとに異なります。この記事の番号は x86/ARM 基準であり、Alpha、MIPS、SPARC などでは別の値を使います。スクリプトでは常に番号ではなく名前を使うほうが安全です。


1. プロセスの状態 — ps の STAT 一文字

psSTAT 列は、そのプロセスに何が起きているかを一文字で要約します。

ps -eo pid,ppid,stat,wchan:24,etime,args --sort=-pcpu | head -20
コード意味実務上の意味
R実行中または実行待ちCPU を使っているか、もらうために並んでいる
S割り込み可能な待機正常な待機。シグナルで起こせる
D割り込み不可の待機SIGKILL もすぐには届きません。多くはディスクや NFS
Zゾンビすでに死んでいて親が回収していない状態
T停止中SIGSTOP または SIGTSTP を受けた
tデバッガによる停止ptrace 中
Iアイドルのカーネルスレッド無視してよい

付加文字も情報を与えます。s はセッションリーダー、+ はフォアグラウンドプロセスグループ、l はマルチスレッド、< は高い優先度、N は低い優先度です。

D 状態が見えたら シグナルでは解決しません。カーネルが何を待っているのかを確認してください。

sudo cat /proc/1234/stack
sudo cat /proc/1234/wchan; echo
cat /proc/1234/status | grep -E '^State|^SigQ|^SigPnd|^SigBlk|^SigIgn|^SigCgt'

SigPnd は待機中のシグナル、SigBlk はブロックされたシグナル、SigIgn は無視に設定されたシグナル、SigCgt はハンドラが登録されたシグナルのビットマスクです。16 進のビットマスクなので読むのは面倒ですが、「このプロセスは SIGTERM を捕捉しているのか」を確認する確実な方法です。


2. シグナル表 — 番号、既定動作、用途

man ページ signal(7) に定義された標準シグナルのうち、運用で実際に使うものです。番号は x86/ARM 基準です。

シグナル番号既定動作実務での用途
SIGHUP1Term設定の再読み込みが慣例。元は端末の切断
SIGINT2Termキーボード割り込み
SIGQUIT3Coreコアダンプを誘発。JVM はスレッドダンプを出力
SIGKILL9Term捕捉もブロックも無視もできない
SIGUSR110Termアプリケーション定義。nginx はログを開き直す
SIGUSR212Termアプリケーション定義。nginx はバイナリを入れ替える
SIGPIPE13Term読み手のいないパイプに書き込んだとき
SIGTERM15Term丁重な終了要求。既定の kill シグナル
SIGCHLD17Ign子が終了・停止・再開した
SIGCONT18Cont停止したプロセスを再開
SIGSTOP19Stop捕捉もブロックも無視もできない
SIGTSTP20Stop端末から入力された停止

既定動作の意味は Term(終了)、Core(終了後にコアダンプ)、Ign(無視)、Stop(停止)、Cont(再開)です。

man ページが明示している最も重要な一文はこれです。SIGKILL と SIGSTOP は捕捉もブロックも無視もできません。残りのシグナルはすべて、アプリケーションが扱い方を変えられます。だからこそ kill -TERM が効かないプログラムが存在するのであり、それはバグではなく設計かもしれません。

SIGPIPE はパイプラインでよく問題になります。

yes | head -1

head が 1 行読んで終了すると yes は読み手のいないパイプに書き込むことになり、SIGPIPE で終了します。これが正常な動作です。ところが SIGPIPE を無視するよう設定したプログラムは代わりに EPIPE エラーを受け取るので、エラー処理をしていないと無限ループに陥る可能性があります。


3. kill、pkill、killall — 誰に送るのか

kill -l
kill -TERM 1234
kill -s TERM 1234
kill -9 1234
kill -0 1234

kill -0 はシグナルを送らず、配送可能かどうか(プロセスの存在と権限)だけを確認します。スクリプトでプロセスの生存確認に使うのに向いています。

名前で送るツールは 2 種類あり、動作が異なります。

pkill -TERM -f 'java.*myapp'
pkill -u appuser -TERM nginx
killall -TERM nginx
pgrep -a -f 'java.*myapp'
  • pgrep/pkill はパターンマッチングで、-f を付けるとコマンドライン全体と比較します。-f がなければプロセス名(通常 15 文字に切られた値)しか見ません。
  • killall は名前の完全一致です。注意すべきなのは、一部の Unix(Solaris など)では killall がまったく別の意味で動作するという点です。移植性を考えると pkill が安全です。

破壊的コマンドの警告: pkill -f はパターンが広いと意図しないプロセスまで殺します。特に root で実行するときが危険です。必ず pgrep で先に対象を確認し、同じパターンで pkill を実行してください

# 手順 1: 対象を確認する(何も kill しない)
pgrep -a -f 'java.*myapp'
# 手順 2: リストが正確なときだけ実行する
pkill -TERM -f 'java.*myapp'

プロセスグループ全体に送るには PID の前にマイナスを付けます。負の PID はプロセスグループ ID を意味します。

kill -TERM -12345

この形は子まで一度に片付けるときに便利ですが、グループ ID を間違えると広範囲に影響を与えます。特に kill -9 -1 は権限の及ぶすべてのプロセスに SIGKILL を送り、root で実行するとシステムを事実上停止させます。絶対に実行しないでください。


4. SIGTERM と SIGKILL — 猶予を設計する

正しい終了手順は常に 2 段階です。まず SIGTERM を送り、猶予時間を与え、それでも生きていれば SIGKILL を送ります

#!/usr/bin/env bash
set -euo pipefail
PID="$1"
TIMEOUT="${2:-30}"

kill -TERM "$PID" 2>/dev/null || exit 0
for _ in $(seq "$TIMEOUT"); do
  if ! kill -0 "$PID" 2>/dev/null; then
    echo "graceful shutdown complete"
    exit 0
  fi
  sleep 1
done
echo "timeout, escalating to SIGKILL" >&2
kill -KILL "$PID"

SIGKILL はアプリケーションに後片付けの機会を与えません。開いているファイルのバッファはフラッシュされず、トランザクションは中断され、一時ファイルやロックファイルは残ります。データベースやキューコンシューマーに SIGKILL を送るのは、事実上電源を抜くのと同じです。猶予時間を十分に与えるほうが常に良い選択です。

systemd サービスでは、この手順が設定として提供されています。

[Service]
KillSignal=SIGTERM
TimeoutStopSec=60
KillMode=mixed
SendSIGKILL=yes

KillMode=mixed はメインプロセスにのみ SIGTERM を送り、タイムアウト後の SIGKILL は cgroup のすべてのプロセスに送ります。子プロセスを自分で管理するアプリケーションに適しています。各ディレクティブの正確な意味と既定値は systemd のバージョンによって変わりうるので、インストールされているバージョンの systemd.kill のドキュメントを確認してください。

コンテナも同じモデルです。docker stop は SIGTERM を送り、既定の 10 秒を待ってから SIGKILL を送ります。Kubernetes は Pod スペックの終了猶予期間を使います。

docker stop --time 60 mycontainer
kubectl delete pod mypod --grace-period=60

コンテナでよく遭遇する罠があります。シェル形式(CMD myapp)で実行するとシェルが PID 1 になり、アプリケーションはその子になります。このとき SIGTERM はシェルに届き、シェルはそれを子に転送しません。結果としてアプリケーションは猶予なしに SIGKILL で死にます。exec 形式(CMD ["myapp"])を使うか、適切な init プロセスを入れる必要があります。


5. PID 1、ゾンビ、孤児

プロセスが終了すると、カーネルは終了ステータスを残しておきます。親が wait を呼んでそれを回収して初めてプロセスのエントリが消えます。回収されるまでの状態がゾンビ(Z)です

ps -eo pid,ppid,stat,args | awk '$3 ~ /Z/'

ゾンビはメモリをほとんど使いませんが、PID を占有します。ゾンビが数千個たまると PID が枯渇し、新しいプロセスを作れなくなります。

cat /proc/sys/kernel/pid_max
ls /proc | grep -c '^[0-9]'

ゾンビは kill できません。すでに死んでいるからです。解決策は親に回収させるか、親を終了させてゾンビを PID 1 に引き取らせることです。PID 1 は孤児プロセスを引き取り、自動的に回収する責任を負っています。

親が先に死ぬと子は孤児になり、PID 1(またはサブリーパーとして登録されたプロセス)に引き取られます。ゾンビがたまり続けるなら、親プログラムが SIGCHLD を処理していないバグです。

コンテナでゾンビがたまる事例は特によくあります。アプリケーションが PID 1 として実行されているのに、そのアプリケーションが子を回収するように作られていない場合です。コンテナランタイムが提供する init オプションを使うのが標準的な解法です。

docker run --init myimage

6. プロセスグループ、セッション、そして端末が閉じるとき

端末を閉じるとなぜバックグラウンドジョブが死ぬのでしょうか。ここには 3 層の構造があります。

  • プロセスグループ: 1 つのパイプラインが通常 1 つのグループです。シェルのジョブ制御の単位です。
  • セッション: 1 回のログインが 1 つのセッションです。セッションリーダーが制御端末を持ちます。
  • 制御端末: 端末がなくなるとカーネルはセッションリーダーに SIGHUP を送り、セッションリーダー(たいていはシェル)は自分のジョブたちに SIGHUP を伝播します。

現在の構造はこう見ます。

ps -eo pid,ppid,pgid,sid,tty,stat,args | head -20
ps -o pid,pgid,sid,tty,args -p $$

端末が切れても生き残らせる方法はいくつもあり、それぞれ違います。

nohup ./long-job.sh > /var/log/long-job.log 2>&1 &
setsid ./long-job.sh > /var/log/long-job.log 2>&1 &
disown -h %1
systemd-run --user --unit=long-job ./long-job.sh
  • nohup は SIGHUP を無視するよう設定して実行します。依然として同じセッションに属します。
  • setsid新しいセッションを作成し、制御端末との関係そのものを断ち切ります。より確実です。
  • disown -h はすでに実行中のジョブを、シェルによる SIGHUP 伝播の対象から外します。
  • systemd-run はそもそも別のユニットとして実行し、セッションと無関係にします。運用サーバーで長時間の作業を回すときに最も安全な選択です。

SSH セッションで長い作業を回すなら tmuxscreen を使うのが実用的です。接続が切れてもセッションが維持され、あとで再度アタッチできます。


7. アプリケーションでシグナルを扱う

シェルスクリプトでは trap で処理します。

#!/usr/bin/env bash
set -euo pipefail

cleanup() {
  echo "cleaning up..."
  rm -f /tmp/myjob.lock
}
trap cleanup EXIT
trap 'echo "received SIGTERM"; exit 143' TERM
trap 'echo "received SIGINT"; exit 130' INT

while true; do
  sleep 1
done

trap ... EXIT はどの経路で終了しても実行されるので、後片付けのロジックを入れるのに適しています。終了コードの慣例は 128 にシグナル番号を足した値です。SIGTERM(15)で死ねば 143、SIGINT(2)なら 130 です。この慣例を知っていれば、systemctl status や CI のログで 143 を見て「正常な終了要求を受け取ったのだな」とすぐ読み取れます。

注意すべきなのは、sleep のような外部コマンドを実行している間、bash はトラップを即座に処理せずそのコマンドが終わってから処理するという点です。長い sleep を使うと反応が遅くなります。バックグラウンドで回して wait を使えば即座に反応します。

sleep 300 &
wait $!

timeout コマンドも便利です。

timeout 30 ./maybe-hangs.sh
timeout -s KILL 30 ./maybe-hangs.sh
timeout -k 10 30 ./maybe-hangs.sh

-k はまず既定のシグナルを送り、指定した時間がさらに過ぎても生きていれば SIGKILL を送ります。先ほど作った 2 段階の終了スクリプトを 1 行で置き換えられます。


8. 死なないプロセスを階層ごとに追跡する

「死なない」という報告を受けたら、次の順序で絞り込みます。

段階 1 — 本当に生きているか。ゾンビならすでに死んでいます。

ps -o pid,ppid,stat,args -p 1234

段階 2 — シグナルは配送されたか。権限の問題で配送そのものが失敗していたかもしれません。

kill -0 1234; echo "exit=$?"

Operation not permitted が出たら権限の問題です。

段階 3 — プロセスがシグナルを捕捉しているか

grep -E '^SigIgn|^SigCgt|^SigBlk' /proc/1234/status

段階 4 — D 状態か。この場合シグナルは何の役にも立ちません。

cat /proc/1234/status | grep '^State'
sudo cat /proc/1234/stack

スタックに NFS やブロック層の関数が見えるなら、ストレージが応答するまで待つしかありません。マウントが切れた NFS なら、強制アンマウントが唯一の脱出口です。

sudo umount -f -l /mnt/nfsshare

破壊的コマンドの警告: umount -l(lazy)はマウントを名前空間から即座に切り離しつつ、使用中の参照は残します。進行中だった書き込みが失われる可能性があるので、最後の手段としてだけ使ってください。

段階 5 — 再起動後も繰り返すか。そうであれば、これはシグナルの問題ではなく、アプリケーションかストレージの構造的な問題です。


9. 優先度とリソース上限

プロセスを殺す代わりに抑制する方法もあります。

nice -n 10 ./batch-job.sh
renice -n 10 -p 1234
ionice -c 3 -p 1234
chrt -p 1234
  • nice の値は -20(最も高い優先度)から 19(最も低い)までです。負の値に下げるには root 権限が必要です。
  • ionice -c 3 はアイドル I/O クラスで、他のプロセスがディスクを使っていないときだけ I/O を行います。夜間バックアップや大容量コピー作業に設定しておくと、運用トラフィックへの影響が大きく減ります
  • chrt はリアルタイムスケジューリングポリシーを扱います。誤って使うとシステムを応答不能にしかねないので慎重さが必要です。

上限は ulimit と systemd のディレクティブで設定します。

ulimit -a
ulimit -n
cat /proc/1234/limits

systemd ユニットでは次のように設定します。

[Service]
LimitNOFILE=65535
LimitNPROC=4096
MemoryMax=2G
CPUQuota=200%

MemoryMax を超えると cgroup の OOM キラーがそのサービスの中だけでプロセスを殺します。システム全体の OOM に波及するのを防いでくれるので、メモリを多く使うサービスには必ず設定しておくほうがよいでしょう。使えるディレクティブと既定値は systemd のバージョンごとに異なるので、インストールされているバージョンの systemd.resource-control のドキュメントを確認してください。


クイズ: 理解度を確認しましょう

クイズ1: kill -9 を送ったのにプロセスがリストにそのまま残っています。何をまず確認しますか?

答え: プロセスの状態が Z(ゾンビ)なのか D(割り込み不可の待機)なのかを確認します

解説: この 2 つの状態は原因も対応もまったく異なります。

ps -o pid,ppid,stat,wchan:24,args -p 1234

Z ならすでに死んでいて親が回収していないだけなので、殺す対象がありません。親プロセスを処理して初めて消えます。D ならカーネルが I/O の完了を待っている最中であり、SIGKILL も即座には配送されません。待っているリソースを復旧させる必要があります。

sudo cat /proc/1234/stack
クイズ2: コンテナを停止したのに、アプリケーションが終了処理を実行できずに即座に切れます。原因の候補は?

答え: シェルが PID 1 になり SIGTERM を子に転送しないケースが代表的です

解説: Dockerfile でシェル形式によりコマンドを指定すると、/bin/sh -c の下でアプリケーションが実行されます。ランタイムが送る SIGTERM は PID 1 であるシェルが受け取り、シェルはそれを転送しません。猶予時間が過ぎると SIGKILL が cgroup 全体に適用され、アプリケーションは何の後片付けもできずに死にます。exec 形式に変えるか、init プロセスを入れる必要があります。

docker run --init myimage
docker stop --time 60 mycontainer

アプリケーションが実際に SIGTERM ハンドラを登録しているかも確認してください。

grep SigCgt /proc/1/status
クイズ3: SSH で接続してデプロイスクリプトを実行中に接続が切れました。スクリプトが中断されないようにするには何を使うべきだったでしょうか?

答え: setsid、systemd-run、tmux のいずれかでセッションから切り離して実行すべきでした

解説: 端末がなくなるとカーネルはセッションリーダーに SIGHUP を送り、シェルがそれをジョブたちに伝播します。nohup は SIGHUP を無視させますが、依然として同じセッションに属します。より確実なのは新しいセッションを作る方法です。

setsid ./deploy.sh > /var/log/deploy.log 2>&1 &
systemd-run --unit=deploy-2026-08-15 ./deploy.sh
tmux new -s deploy

運用サーバーなら systemd-run が最も良い選択です。ログがジャーナルに残り、状態を systemctl status で照会できるからです。

クイズ4: ゾンビプロセスが時間とともに増えていきます。どんな実際の危険があり、何を直すべきですか?

答え: PID 枯渇の危険があり、親プログラムが子を回収していないことが原因です

解説: ゾンビはメモリをほとんど使いませんが PID スロットを占有します。pid_max に達すると新しいプロセスをまったく作れなくなり、システムは事実上停止します。

ps -eo pid,ppid,stat,args | awk '$3 ~ /Z/' | head
cat /proc/sys/kernel/pid_max

親 PID を確認し、そのプログラムが子の終了を処理するように直すのが根本的な解決です。応急処置として親を再起動すれば、ゾンビたちは PID 1 に引き取られて回収されます。

クイズ5: シェルスクリプトに SIGTERM のトラップを仕掛けたのに、反応が 10 秒ずつ遅れます。なぜでしょうか?

答え: 外部コマンドの実行中、bash はそのコマンドが終わってからトラップを処理するからです

解説: sleep 10 が実行されている間にシグナルが届くと、bash は sleep が返ってからトラップハンドラを実行します。即座に反応させるにはバックグラウンドで回して wait を使います。

sleep 10 &
wait $!

wait はシグナルが来ると即座に返るので、トラップがすぐ実行されます。終了コードの慣例も一緒に覚えてください。SIGTERM で終了すると 143、SIGINT なら 130 です。

クイズ6: 夜間バッチが運用トラフィックの応答時間を壊しています。バッチを殺さずに緩和するには?

答え: nice で CPU 優先度を、ionice で I/O 優先度を下げます

解説: バッチ処理は遅く終わっても構いませんが、運用リクエストはそうではありません。優先度を下げれば、リソースが余っているときだけバッチが進みます。

renice -n 19 -p 1234
sudo ionice -c 3 -p 1234

ionice -c 3 はアイドルクラスで、他のプロセスがディスクを使っていないときだけ I/O を行います。根本的には systemd ユニットでリソース制御をかけておくほうがよいでしょう。

[Service]
Nice=19
IOSchedulingClass=idle
CPUQuota=50%

おわりに

シグナルは単純に見えますが、プロセスグループ、セッション、制御端末、cgroup、コンテナランタイムが幾重にも重なっています。だからこそ「なぜ死なないのか」という問いの答えは、毎回別の階層から出てきます。

実務で覚えておくべきことは 3 つです。SIGKILL と SIGSTOP だけが絶対であり、残りはアプリケーションが自由に扱えますD 状態ではどんなシグナルも効かないので、ストレージを見る必要があります終了は常に SIGTERM と猶予時間から始め、SIGKILL は最後の手段です

最後の一文は、特にデータを扱うサービスで重要です。終了猶予時間を 10 秒に設定して運用していて、大量トランザクションの最中に SIGKILL が飛べば、その日の障害は終了手順が作ったものです。


参考資料


関連記事

현재 단락 (1/216)

デプロイスクリプトから `kill` を送ったのにプロセスが死にません。`kill -9` を送ってもリストに残ったままです。端末を閉じたらバックグラウンドジョブも一緒に死にました。コンテナを停止した...

작성 글자: 0원문 글자: 11,330작성 단락: 0/216