バックアップやヘルスチェックのスクリプトを書いていると、こういった場面に頻繁にぶつかります。
シェルスクリプトでは
&演算子でジョブをバックグラウンドに送れますが、「どのジョブが失敗したかを把握したい」「無制限に起動して負荷を上げたくない」「長時間かかるジョブを一定時間で強制終了したい」という設計上の問題が次々に出てきます。この記事ではシェルスクリプトの並列ジョブ設計を体系的に解説します。バックグラウンドプロセスの起動とPID管理から、
waitコマンドを使った終了コードの正確な回収方法、タイムアウトキル設計と並列数の上限制御まで、実機の出力例を交えて説明します。
この記事のポイント
・& でバックグラウンド起動し $! でPIDを配列管理するのが基本
・wait $pid の返り値が終了コード。引数なしのwaitだと失敗を見逃す
・タイムアウトはSIGTERM→SIGKILLの2段階+trapクリーンアップが安全設計
・並列数上限は起動前にPID配列の長さで制御するセマフォパターンが実用的
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
なぜシェルスクリプトで並列実行が必要になるのか
逐次実行の限界は、対象が増えるほど顕著になります。たとえばサーバー5台にSSHしてuptimeを確認する場合、1台あたり2秒かかるとすれば合計10秒です。これが30台になれば1分、100台なら3分以上かかります。ところが並列実行にすれば、接続がどれだけ増えても実行時間は「最も遅い1台の時間」に収まります。同じ100台でも2秒前後で終わります。
バックアップスクリプトでも同じです。/var/log、/etc、/homeを順番にtarで固めていくと、ディスクI/Oが1本しか走らないまま順番待ちが続きます。並列化すれば3つのアーカイブが同時に進み、I/Oを効率よく使えます。
シェルスクリプトで並列実行を実現するアプローチはいくつかありますが、この記事では
&演算子とwaitコマンドの組み合わせに絞ります。xargs -PオプションやGNU parallelも有効ですが、終了コードの細かい制御やクリーンアップ処理を組み込む場合は、直接PIDを管理する方法が柔軟性で勝ります。
バックグラウンドプロセスの起動とPID管理
1. & でバックグラウンドに送る
コマンドの末尾に&を付けると、そのコマンドはバックグラウンドで実行されます。親シェルはすぐに次のコマンドへ進みます。#!/bin/bash # バックグラウンド起動の基本例 sleep 5 & echo "sleep 5 のPID: $!" sleep 3 & echo "sleep 3 のPID: $!" echo "両方を起動済み。waitで待ちます..." wait echo "全ジョブ終了"
sleep 5 のPID: 14821 sleep 3 のPID: 14822 両方を起動済み。waitで待ちます... 全ジョブ終了
$!は直前にバックグラウンドに送ったプロセスのPIDを保持しています。これを変数に保存するのがPID管理の第一歩です。引数なしの
waitは全バックグラウンドジョブが終わるまで待ちますが、どのジョブが失敗したかは分かりません。失敗を検知するには次のPID配列管理を使います。2. PIDを配列で管理する
複数のジョブを起動する場合は、PIDを配列に格納して後でまとめて管理します。#!/bin/bash declare -a pids=() declare -a targets=() # 対象サーバーを配列で定義 servers=("web01" "web02" "web03" "db01" "cache01") for server in "${servers[@]}"; do # SSHで各サーバーのuptimeを確認(バックグラウンドで起動) ssh -o ConnectTimeout=5 "$server" "uptime" > "/tmp/result_${server}.txt" 2>&1 & pids+=("$!") targets+=("$server") echo "起動: server=${server} PID=${pids[-1]}" done echo "全${#pids[@]}ジョブを起動しました。待機中..."
declare -a pids=()で空の配列を宣言し、バックグラウンド起動のたびにpids+=("$!")でPIDを追加します。targets配列にサーバー名を並列して記録しておくと、後でどのPIDがどのサーバーに対応するかを追えます。waitコマンドで終了コードを正確に回収する
1. wait $pid で終了コードを取得する
waitコマンドにPIDを渡すと、そのプロセスの終了を待って終了コードを$?に返します。#!/bin/bash # 終了コードが異なる2つのジョブを並列実行 bash -c "exit 0" & pid1=$! bash -c "exit 2" & pid2=$! wait "$pid1" echo "pid1の終了コード: $?" # => 0 wait "$pid2" echo "pid2の終了コード: $?" # => 2
pid1の終了コード: 0 pid2の終了コード: 2
2. 全ジョブの終了コードをまとめて回収する
PIDとターゲットを同じインデックスで対応させ、全ジョブの成否を判定します。#!/bin/bash declare -a pids=() declare -a targets=() fail=0 servers=("web01" "web02" "web03") for server in "${servers[@]}"; do ssh -o ConnectTimeout=5 "$server" "uptime" > "/tmp/result_${server}.txt" 2>&1 & pids+=("$!") targets+=("$server") done # PIDとターゲットをインデックスで紐付けて回収 for i in "${!pids[@]}"; do wait "${pids[$i]}" status=$? if [[ $status -ne 0 ]]; then echo "[FAIL] ${targets[$i]} : 終了コード $status" >&2 fail=1 else echo "[OK] ${targets[$i]}" fi done exit $fail
${!pids[@]}で配列のインデックスを展開します。いずれかのジョブが失敗した場合はfail=1にしておき、最後にexit $failでスクリプト全体の終了コードに反映させます。【注意】waitとサブシェルの関係
waitは現在のシェルが直接起動した子プロセスにしか使えません。パイプ(|)やコマンド置換($())の中で起動したプロセスはサブシェルの子になるため、親シェルからwaitで回収できません。バックグラウンドジョブは必ずパイプ・サブシェルの外で起動してください。タイムアウトキル設計
並列ジョブで厄介なのが、ハングアップするジョブです。SSHの接続先がフリーズした場合、waitは永遠に戻ってきません。タイムアウト処理を設計に組み込む方法を2つ紹介します。1. timeout コマンドを使う(シンプル)
システムにtimeoutコマンドがある場合(RHEL 9 / Ubuntu 24.04 では標準搭載)、ジョブの起動時に組み合わせるのが最もシンプルです。#!/bin/bash declare -a pids=() declare -a targets=() servers=("web01" "web02" "web03") for server in "${servers[@]}"; do # 10秒でタイムアウトしてSIGTERMを送信 timeout 10 ssh -o ConnectTimeout=5 "$server" "uptime" > "/tmp/result_${server}.txt" 2>&1 & pids+=("$!") targets+=("$server") done fail=0 for i in "${!pids[@]}"; do wait "${pids[$i]}" status=$? if [[ $status -eq 124 ]]; then echo "[TIMEOUT] ${targets[$i]} : 10秒で応答なし" >&2 fail=1 elif [[ $status -ne 0 ]]; then echo "[FAIL] ${targets[$i]} : 終了コード $status" >&2 fail=1 else echo "[OK] ${targets[$i]}" fi done exit $fail
timeoutがタイムアウトした場合の終了コードは124です。これをwaitの後で検知することで、タイムアウトか通常の失敗かを区別できます。2. SIGTERM→SIGKILL の2段階エスカレーション
SIGTERMを受け取っても終了しないプロセスに備え、一定時間後にSIGKILLで強制終了する番犬プロセスを並走させます。#!/bin/bash # 番犬関数: 指定秒後にSIGTERM、さらに3秒後にSIGKILLを送る watchdog() { local pid=$1 local timeout_sec=$2 sleep "$timeout_sec" if kill -0 "$pid" 2>/dev/null; then echo "[WATCHDOG] PID=$pid にSIGTERMを送信" >&2 kill -TERM "$pid" 2>/dev/null sleep 3 if kill -0 "$pid" 2>/dev/null; then echo "[WATCHDOG] PID=$pid にSIGKILLを送信" >&2 kill -KILL "$pid" 2>/dev/null fi fi } # 100秒かかるジョブ(例: 応答しないSSH)を起動 sleep 100 & job_pid=$! # 5秒後にタイムアウトキルする番犬を起動 watchdog "$job_pid" 5 & watchdog_pid=$! # ジョブの完了を待つ wait "$job_pid" job_status=$? # 番犬が不要になったら終了させる kill "$watchdog_pid" 2>/dev/null wait "$watchdog_pid" 2>/dev/null echo "ジョブ終了コード: $job_status"
[WATCHDOG] PID=15042 にSIGTERMを送信 ジョブ終了コード: 143
3. trapでスクリプト終了時に子プロセスをクリーンアップする
スクリプトがCtrl+CやSIGTERMで終了した場合、バックグラウンドジョブが孤立プロセスとして残ることがあります。trapでクリーンアップ処理を登録しておきましょう。#!/bin/bash declare -a child_pids=() # スクリプト終了時(正常終了・Ctrl+C・kill問わず)に子プロセスをクリーンアップ cleanup() { echo "クリーンアップ中..." >&2 for pid in "${child_pids[@]}"; do kill -TERM "$pid" 2>/dev/null done # 子プロセスが終了するまで最大5秒待つ local deadline=$(( $(date +%s) + 5 )) while (( $(date +%s) < deadline )); do local alive=0 for pid in "${child_pids[@]}"; do kill -0 "$pid" 2>/dev/null && alive=1 done (( alive == 0 )) && break sleep 0.5 done # タイムアウトした子プロセスはSIGKILLで強制終了 for pid in "${child_pids[@]}"; do kill -KILL "$pid" 2>/dev/null done } # スクリプトの先頭で登録しておく trap cleanup EXIT
trap cleanup EXITを最初に宣言しておけば、スクリプトがどのように終了してもcleanup関数が呼び出されます。SIGTERMを直接捕捉したい場合はtrap cleanup EXIT TERM INTのように追記します。trapシグナルの種類(EXIT/ERR/SIGTERM/SIGHUP)や
set -eとの組み合わせパターンを体系的に学びたい方は、シェルスクリプト設計の講座も参考にしてください。並列数の上限制御(セマフォパターン)
バックグラウンドジョブを無制限に起動すると、サーバーのCPU・ネットワーク帯域・SSH接続数の上限に達します。並列数に上限を設けるパターンを実装します。#!/bin/bash MAX_JOBS=4 declare -a pids=() # 実行中ジョブが MAX_JOBS 未満になるまで待つ wait_for_slot() { while (( ${#pids[@]} >= MAX_JOBS )); do local new_pids=() for pid in "${pids[@]}"; do if kill -0 "$pid" 2>/dev/null; then # まだ動いていれば残す new_pids+=("$pid") else # 終了済みなら終了コードを回収 wait "$pid" 2>/dev/null fi done pids=("${new_pids[@]}") # スロットが空くまで少し待つ (( ${#pids[@]} >= MAX_JOBS )) && sleep 0.1 done } # 10台のサーバーを最大4並列で処理 for server in web{01..10}; do wait_for_slot echo "起動: $server" ssh "$server" "uptime" > "/tmp/${server}.txt" 2>&1 & pids+=("$!") done # 残りのジョブをすべて待つ for pid in "${pids[@]}"; do wait "$pid" done echo "全処理完了"
wait_for_slotは実行中ジョブがMAX_JOBS個に達していたら、完了したジョブをpids配列から除去してスロットが空くまでポーリングします。これにより常に最大4ジョブが並列動作する設計になります。MAX_JOBSの適切な値はシステムの種類によって異なります。SSHによるリモートコマンドは接続数(/etc/ssh/sshd_configのMaxStartups)を考慮し、ファイルI/Oが主体の処理はCPUコア数を目安にするとよいでしょう。実践例:複数サーバーへのSSH並列ヘルスチェック
これまでの要素を組み合わせた実践的なスクリプトです。5台のサーバーに対して並列でヘルスチェックを実行します。#!/bin/bash # parallel-healthcheck.sh — 複数サーバーの並列ヘルスチェック # 動作確認環境: Rocky Linux 9.4 / Bash 5.1 readonly SERVERS=("web01" "web02" "web03" "db01" "cache01") readonly TIMEOUT_SEC=10 readonly TMPDIR_BASE="/tmp/healthcheck_$$" declare -a pids=() declare -a targets=() # 一時ディレクトリ作成とクリーンアップ登録 mkdir -p "$TMPDIR_BASE" cleanup() { for pid in "${pids[@]}"; do kill -TERM "$pid" 2>/dev/null done wait "${pids[@]}" 2>/dev/null rm -rf "$TMPDIR_BASE" } trap cleanup EXIT # 各サーバーへのチェック処理 check_server() { local server=$1 local outfile="$TMPDIR_BASE/${server}.txt" timeout "$TIMEOUT_SEC" ssh -o ConnectTimeout=5 -o BatchMode=yes "$server" "uptime && df -h / | tail -1" > "$outfile" 2>&1 } # 全サーバーをバックグラウンドで起動 for server in "${SERVERS[@]}"; do check_server "$server" & pids+=("$!") targets+=("$server") done # 結果を集計 fail=0 for i in "${!pids[@]}"; do wait "${pids[$i]}" status=$? server="${targets[$i]}" if [[ $status -eq 124 ]]; then printf "[TIMEOUT] %-12s : %d秒で応答なし " "$server" "$TIMEOUT_SEC" >&2 fail=1 elif [[ $status -ne 0 ]]; then printf "[FAIL] %-12s : 終了コード %d " "$server" "$status" >&2 fail=1 else printf "[OK] %-12s " "$server" sed 's/^/ /' "$TMPDIR_BASE/${server}.txt" fi done exit $fail
[OK] web01 16:12:05 up 23 days, 4:42, 1 user, load average: 0.08, 0.12, 0.10 /dev/sda1 50G 18G 33G 35% / [OK] web02 16:12:05 up 23 days, 4:41, 2 users, load average: 0.15, 0.11, 0.09 /dev/sda1 50G 22G 29G 44% / [TIMEOUT] db01 : 10秒で応答なし [OK] web03 16:12:06 up 21 days, 1:34, 1 user, load average: 0.02, 0.03, 0.05 /dev/sda1 50G 31G 20G 62% / [OK] cache01 16:12:06 up 15 days, 9:03, 0 users, load average: 0.00, 0.00, 0.00 /dev/sda1 20G 8G 13G 38% /
$$を一時ディレクトリ名に使うことで複数の並行実行によるファイル名衝突を避けており、trap cleanup EXITで終了時に一時ファイルが確実に削除されます。トラブルシュート
wait が常に終了コード127を返す
終了コード127は「コマンドが見つからない」を意味します。バックグラウンドジョブの中でPATHが引き継がれていない可能性があります。スクリプト冒頭でPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binを明示的に設定してください。cronから実行する場合に特に起こりやすいトラブルです。wait に引数なしで呼ぶと失敗を見逃す
引数なしのwaitは全バックグラウンドジョブの終了を待ちますが、返す値は「最後に終了したジョブの終了コード」だけです。途中のジョブが失敗しても検知できません。失敗を検知するには必ずwait $pidの形でPIDを指定して個別に呼び出してください。バックグラウンドジョブが終了しない
主な原因は3つです。①SSHのKeepAliveが効いておらず接続がハングしている(-o ServerAliveInterval=5 -o ServerAliveCountMax=3を追加)、②標準入力が端末に接続されたまま読み待ちになっている(</dev/nullでNullデバイスに接続)、③処理がSIGPIPEを無視して書き続けている(timeoutコマンドで外側から打ち切る)。trap cleanup EXIT が実行されない
kill -9(SIGKILL)で終了させた場合、trapは呼び出されません。SIGKILLはカーネルが直接プロセスを終了させるためシェルに制御が戻らないからです。外部から終了させる場合はkill -TERMかkill -INTを使ってください。子プロセスのwaitで "No such process" エラーが出る
同じPIDに対してwaitを2回呼び出した場合に発生します。PID配列から回収済みのエントリを削除する実装でない場合に起こりやすいトラブルです。上述のセマフォパターン内のwait "$pid" 2>/dev/nullのように、エラー出力を抑制しておくと余計なメッセージを防げます。まとめ
シェルスクリプトの並列ジョブ設計の要点をまとめます。| やりたいこと | 実装方法 |
|---|---|
| バックグラウンドで起動してPIDを記録 | コマンド & ; pids+=("$!") |
| 個別ジョブの終了コードを取得 | wait $pid ; status=$? |
| タイムアウトキル(シンプル) | timeout 秒数 コマンド & |
| SIGTERM→SIGKILLの2段階 | 番犬プロセス(watchdog関数)を並走させる |
| スクリプト終了時に子プロセスを片付ける | trap cleanup EXIT でkill処理を登録 |
| 並列数を上限以内に制限する | 起動前にPID配列の長さをチェックするwait_for_slot関数 |
| タイムアウトを判定する | timeoutコマンドの終了コード124を検知する |
並列ジョブ設計の核心は「PIDを配列で持ち、
wait $pidで個別に終了コードを回収する」設計パターンです。引数なしのwaitでは失敗したジョブを特定できません。実運用スクリプトでは必ず個別にwait $pidで結果を回収し、失敗を見逃さない設計にしてください。trapによるクリーンアップとタイムアウトキルを組み合わせることで、ハングアップや割り込み終了が発生しても孤立プロセスを残さない堅牢なスクリプトになります。
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 前のページへ:シェルスクリプトのsystemd深層連携設計|EnvironmentFileとWatchdogSecとsd_notifyでサービス品質を高める方法
- この記事の属するカテゴリ:シェルスクリプトへ戻る

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