シェルスクリプトで子プロセスをまとめて終了させる設計|プロセスグループkillとtrap EXITを組み合わせたクリーンアップ方法

宮崎智広 この記事の監修:宮崎智広(Linux実務・教育歴20年以上・受講者3,100名超)
HOME > Linux技術 リナックスマスター.JP(Linuxマスター.JP) > シェルスクリプト > シェルスクリプトで子プロセスをまとめて終了させる設計|プロセスグループkillとtrap EXITを組み合わせたクリーンアップ方法

「スクリプトをkillしたのに、バックグラウンドのコマンドがずっと動き続けている…」

cronジョブを強制停止したあと、孤立したsleepやcurlが残っているのを見たことはないでしょうか。シェルスクリプトで並列処理を組んだり、外部コマンドをバックグラウンドで呼び出したりすると、親プロセスを終了させても子プロセスが取り残されてしまいます。

この記事では、Linuxのプロセスグループを使って「子プロセスをまとめて終了させる設計」を解説します。
trap EXITとkill 0を組み合わせた基本パターンから、並列ジョブ・timeoutとの連携まで、RHEL 9 / Ubuntu 24.04 LTSで動作確認した実装例を紹介します。

この記事のポイント

・kill 0でプロセスグループ全体を一括終了できる
・trap EXITと組み合わせると異常終了時も確実にクリーンアップできる
・並列バックグラウンドジョブも含め全子プロセスを漏れなく終了させる
・timeoutコマンドとの組み合わせでタイムアウト時のゴミプロセスも消せる


「このままじゃマズい」と感じていませんか?
参考書を開く気力もない、同年代に取り残される不安——
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

子プロセスが残ってしまう理由

シェルスクリプトの親プロセスを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では、シェルスクリプト設計を含む実務レベルの技術を少人数ハンズオンで学べます。

>> セミナー詳細はこちら

無料メルマガで学習を続ける

Linuxの実践スキルをメールで毎週お届け。
登録は30秒、解除もいつでも可。

登録無料・いつでも解除できます

暗記不要・1時間後にはサーバーが動く

3,100名以上が実践した「型」を無料で公開中

プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。

姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら

Linux無料マニュアル(図解60P) 名前とメールで30秒登録
宮崎 智広

この記事を書いた人

宮崎 智広(みやざき ともひろ)

株式会社イーネットマーキュリー代表。現役のLinuxサーバー管理者として20年以上の実務経験を持ち、これまでに累計3,100名以上のエンジニアを指導してきたLinux教育のプロフェッショナル。「現場で本当に使える技術」を体系的に伝えることをモットーに、実践型のLinuxセミナーの開催や無料マニュアルの配布を通じてLinux人材の育成に取り組んでいる。

趣味は、キャンプにカメラ、トラウト釣り。好きな食べ物は、ラーメンにお酒。休肝日が作れない、酒量を減らせないのが悩み。最近、ドラマ「フライトエンジェル」を観て涙腺が崩壊しました。