「スクリプトをkillしたのに、バックグラウンドのコマンドがずっと動き続けている…」
cronジョブを強制停止したあと、孤立したsleepやcurlが残っているのを見たことはないでしょうか。シェルスクリプトで並列処理を組んだり、外部コマンドをバックグラウンドで呼び出したりすると、親プロセスを終了させても子プロセスが取り残されてしまいます。
この記事では、Linuxのプロセスグループを使って「子プロセスをまとめて終了させる設計」を解説します。
trap EXITとkill 0を組み合わせた基本パターンから、並列ジョブ・timeoutとの連携まで、RHEL 9 / Ubuntu 24.04 LTSで動作確認した実装例を紹介します。
この記事のポイント
・kill 0でプロセスグループ全体を一括終了できる
・trap EXITと組み合わせると異常終了時も確実にクリーンアップできる
・並列バックグラウンドジョブも含め全子プロセスを漏れなく終了させる
・timeoutコマンドとの組み合わせでタイムアウト時のゴミプロセスも消せる
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
子プロセスが残ってしまう理由
シェルスクリプトの親プロセスをkillすると、なぜ子プロセスが残るのでしょうか。
Linuxのシグナルは、デフォルトでは送り先に指定したプロセスにしか届きません。kill 12345 としても、シグナルが送られるのはPID 12345のプロセスだけです。親が終了すると子プロセスはPPID(親プロセスID)が1(initまたはsystemd)に付け替えられ、孤立プロセスとして動き続けます。
以下のスクリプトで実際に確認できます。
#!/usr/bin/env bash # 子プロセスをバックグラウンドで起動して待機 sleep 300 & sleep 300 & echo "PID: $$" wait
このスクリプトを実行し、別のターミナルからpsで確認します。
$ ps -f --ppid 13100 UID PID PPID C STIME TTY TIME CMD tomohiro 13101 13100 0 10:15 pts/0 00:00:00 sleep 300 tomohiro 13102 13100 0 10:15 pts/0 00:00:00 sleep 300
ここで kill 13100 すると、sleep 2本はPPIDが1になって孤立します。
$ kill 13100 $ ps -f -p 13101,13102 UID PID PPID C STIME TTY TIME CMD tomohiro 13101 1 0 10:15 pts/0 00:00:00 sleep 300 tomohiro 13102 1 0 10:15 pts/0 00:00:00 sleep 300
PPIDが1に変わっているのが確認できます。子プロセスはsystemdの孤児として引き取られ、親スクリプトとの関係が切れた状態で動き続けます。cronのタイムアウトkillや timeout コマンドでも同じ現象が起きます。
Linuxのプロセスグループを理解する
この問題を解決するのが「プロセスグループ」という概念です。
Linuxでは、シェルからコマンドを実行すると、そのプロセスにはPGID(プロセスグループID)が割り当てられます。通常、シェルスクリプトを起動すると、スクリプト本体とその子孫プロセスは同じPGIDを持ちます。
$ ps -o pid,pgid,ppid,cmd -p 13100,13101,13102 PID PGID PPID CMD 13100 13100 13099 bash cleanup.sh ← 親: PID == PGID 13101 13100 13100 sleep 300 ← 子: 同じPGID 13102 13100 13100 sleep 300 ← 子: 同じPGID
このPGIDを使うと、プロセスグループ全体に一括でシグナルを送れます。
1. kill 0とkill -- -PGIDの使い方
プロセスグループ全体へシグナルを送るには、killのPIDにマイナスの値を指定します。
# 自分が属するプロセスグループ全体にSIGTERMを送る kill 0 # 明示的にPGIDを指定する場合($$ = 自分自身のPID) kill -- -$$
kill 0 は「自分と同じPGIDの全プロセスにシグナルを送る」という意味です。kill -- -(PGID) は特定のPGIDを持つプロセスグループに送ります。スクリプト本体の場合、通常はPID == PGIDなので kill 0 と kill -- -$$ は同じ効果になります。
ただし、シェルスクリプトがパイプやサブシェルを経由して起動された場合は別のPGIDを持つことがあります。確実を期すなら kill -- -$$ よりも以下のように自身のPGIDを動的に取得する方法が安全です。
MY_PGID=$(ps -o pgid= -p $$) kill -- -${MY_PGID} 2>/dev/null
trap EXITとkill 0を組み合わせた基本設計
子プロセスをまとめて終了させる最もシンプルな設計は、trap EXIT でクリーンアップ関数を登録し、その中で kill 0 を呼ぶパターンです。
1. 基本テンプレート
#!/usr/bin/env bash set -euo pipefail cleanup() { local exit_code=$? echo "[cleanup] 子プロセスをまとめて終了させます (exit_code=${exit_code})" # SIGTERMを無視してから kill 0 を発行(自分自身への再シグナルを防ぐ) trap '' TERM kill 0 2>/dev/null wait 2>/dev/null exit "${exit_code}" } trap cleanup EXIT TERM INT HUP echo "スクリプト開始: PID=$$" # バックグラウンドジョブを起動 sleep 300 & sleep 300 & wait echo "正常終了"
設計の要点が3つあります。
・trap '' TERM:SIGTERMを無視してから kill 0 を呼ぶことで、自分自身へのシグナルで処理が中断されるのを防ぎます
・kill 0 2>/dev/null:プロセスグループ全体にSIGTERMを送ります。子プロセスが存在しない場合のエラーを抑制するために 2>/dev/null を付けます
・wait 2>/dev/null:子プロセスが終了するまで待ちます。これを入れないと子が終了しきる前にスクリプトが抜けてしまいます
実際の動作を確認します。
$ bash cleanup.sh & スクリプト開始: PID=14200 $ kill 14200 [cleanup] 子プロセスをまとめて終了させます (exit_code=143) $ ps -f -p 14201,14202 PID TTY TIME CMD (出力なし。全プロセスが終了している)
killのあとにcleanup関数が実行され、子プロセスが残っていないことを確認できました。
2. EXIT・TERM・INT・HUPの使い分け
trapの対象シグナルを使い分けると、きめ細かい制御ができます。
・EXIT:スクリプトが終了するすべての経路で発火します。正常終了・set -e による中断・未処理のエラー、いずれでもcleanupを走らせたい場合はEXITだけで十分なことが多いです
・TERM:kill または systemctl stop からのSIGTERMを受け取ります
・INT:Ctrl+C(SIGINT)での割り込みを捕捉します
・HUP:端末が切断された場合のSIGHUPを捕捉します(cronやsshセッション切断で動くことがある)
SIGKILLだけはtrapできない仕様です。kill -9 で強制終了された場合はcleanupが走りません。これを避けるためにも、通常は kill(SIGTERM)だけで止められる設計にしておくことが重要です。
並列ジョブを安全に終了させる応用設計
バックグラウンドジョブ(&)を複数並べて並列処理する場面では、子プロセスのPIDを配列に記録しておくと細かい制御ができます。
#!/usr/bin/env bash set -euo pipefail CHILD_PIDS=() cleanup() { echo "[cleanup] 残存する子プロセスを終了します" for pid in "${CHILD_PIDS[@]}"; do kill "${pid}" 2>/dev/null && echo " killed: ${pid}" done wait 2>/dev/null } trap cleanup EXIT TERM INT HUP # 並列ジョブを起動し、PIDを記録 for i in {1..4}; do sleep 60 & CHILD_PIDS+=($!) # $! = 直前のバックグラウンドジョブのPID echo "started worker-${i}: PID=$!" done echo "全ジョブ待機中..." wait echo "全ジョブ完了"
CHILD_PIDS 配列にPIDを積んでいくため、どのプロセスをkillしたかログに残ります。障害調査時に「どのPIDが残っていたか」を追えるのが利点です。
サブシェル経由やパイプの中で起動したジョブは kill 0 で捕まらないケースがあります。そういった場合でも、PID配列方式なら確実に対象を指定してkillできます。
実践的なシェルスクリプト設計パターンをまとめて体系的に学びたい方は、シェルスクリプト設計の実践講座もご覧ください。
timeoutコマンドと組み合わせた自動タイムアウト設計
cronジョブを一定時間で自動停止させるには timeout コマンドを使いますが、timeoutが親スクリプトをSIGTERMで止めた場合に子プロセスが残るリスクがあります。trap EXIT と組み合わせると、タイムアウト時にも確実に子プロセスを終了できます。
#!/usr/bin/env bash # cron から呼び出すラッパースクリプト set -euo pipefail cleanup() { local exit_code=$? echo "[cleanup] $(date '+%Y-%m-%d %H:%M:%S') exit=${exit_code}" trap '' TERM kill 0 2>/dev/null wait 2>/dev/null exit "${exit_code}" } trap cleanup EXIT TERM INT # バックグラウンドで重い処理を起動 do_work() { curl -sf https://example.internal/api/run & rsync -a /data/ /backup/ & wait } # 60秒以内に完了しなければtimeoutがSIGTERMを送り、cleanupが走る timeout 60 bash -c '$(declare -f do_work); do_work'
crontabから使う場合は以下のようにシンプルに外からtimeoutをかぶせます。
# crontab 0 2 * * * /usr/bin/timeout 300 /opt/scripts/daily-backup.sh >> /var/log/backup.log 2>&1
トラブルシュート(シグナルが届かないケース)
1. execでコマンドを置き換えた場合
exec command を使うとシェルプロセス自体が別コマンドに置き換わります。trapはその時点で解除されるため、exec前にcleanupを手動で呼ぶ必要があります。
# NG: exec するとtrapが引き継がれない exec /usr/sbin/nginx # OK: exec前に子プロセスをクリーンアップしてからexec cleanup exec /usr/sbin/nginx
2. サブシェルの中でバックグラウンドジョブを起動した場合
パイプやサブシェル (...) の中で & を使って起動したプロセスは、外側のPGIDに属さない場合があります。
# このsleepは別PGIDになる可能性がある (sleep 300 &) # PIDを捕まえてCHILD_PIDS配列に積む (sleep 300 &) CHILD_PIDS+=($!) # サブシェルの最後のバックグラウンドPID
3. nohupで起動したプロセスはHUPが届かない
nohup で起動したプロセスはSIGHUPを無視するよう設定されています。そのため、cleanupの中で trap '' TERM; kill 0 をしてもnohupプロセスにはTERMが届きます(HUPは届かない)。kill 0 ではなく kill -- -PGID でSIGTERMを明示的に送ってください。
$ ps -o pid,pgid,cmd PID PGID CMD 15000 15000 bash cleanup.sh 15001 15000 nohup sleep 300 # kill 0 はSIGTERMをkill 0発行プロセスと同じPGIDに送る # nohupプロセスにもSIGTERMは届く(SIGHUPだけを無視している) kill 0 # ← この場合は機能する
本記事のまとめ
シェルスクリプトで子プロセスを確実に終了させる設計をまとめます。
| シチュエーション | 推奨する設計 |
|---|---|
| シンプルなバックグラウンドジョブ | trap cleanup EXIT; trap '' TERM; kill 0 |
| 並列ジョブのPID個別管理 | CHILD_PIDS+=($!) で配列管理しforでkill |
| Ctrl+C・SIGHUPも確実に捕捉 | trap cleanup EXIT TERM INT HUP |
| cronのタイムアウトkill | timeout コマンド + trap EXITで自動クリーンアップ |
| kill 0が自分自身に当たる対策 | cleanup内で trap '' TERM してから kill 0 |
| サブシェル経由で起動した子 | $! でPIDを捕まえCHILD_PIDS配列に積む |
「とりあえず動いたからOK」ではなく、スクリプトが終了するすべての経路でクリーンアップが走る設計にしておくことが、cronやsystemdで動かす本番スクリプトの品質を底上げします。特にバックグラウンドジョブを並列実行するスクリプトでは、終了時の子プロセス管理を最初から組み込んでおくのが鉄則です。
現場で通用する安全なLinuxサーバー構築の「型」を体系的に身につけたい方へ、
Linux Master Pro Seminarでは、シェルスクリプト設計を含む実務レベルの技術を少人数ハンズオンで学べます。
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 前のページへ:シェルスクリプトでtputを使ってターミナル出力を色分けする設計|ANSIコード直書きを避けて可搬性の高いカラー出力を実装する方法
- この記事の属するカテゴリ:シェルスクリプトへ戻る

無料メルマガで学習を続ける
Linuxの実践スキルをメールで毎週お届け。
登録は30秒、解除もいつでも可。
登録無料・いつでも解除できます