並列実行したジョブの失敗を検知したい

複数のホストへのデプロイのような処理は、&でバックグラウンド実行すれば並列に走らせられる。すべての終了を待つには引数なしのwaitを使う。

#!/bin/bash
./deploy.sh web01 &
./deploy.sh web02 &  # 失敗する
./deploy.sh web03 &
wait
echo "wait: $?"

ところが引数なしのwaitの終了ステータスは常に0である。web02が失敗しても以下のように0を返す。

wait: 0

失敗を検知するには、ジョブごとにPIDを指定してwaitする。

wait PIDでジョブごとの終了ステータスを受け取る

waitにPIDを渡すと、そのジョブの終了ステータスをそのまま返す。

(sleep 1; exit 3) &
pid=$!
wait "$pid"
echo "status: $?"
status: 3

$!は直前にバックグラウンド実行したジョブのPIDが入る変数である。詳細は【Shell Script】長い処理を待っている間に別の処理を実行する を参照。

なお、子プロセスではないPIDをwaitに渡すと127を返す。

$ wait 999999
bash: wait: pid 999999 is not a child of this shell
$ echo $?
127

失敗したジョブを特定する

PIDだけでは、どのジョブが失敗したのか分からない。連想配列でPIDと処理対象を対応付けておけば、失敗した対象を名前で報告できる。

#!/bin/bash
declare -A pids

for target in web01 web02 web03; do
  ./deploy.sh "$target" &
  pids[$!]=$target
done

failed=()
for pid in "${!pids[@]}"; do
  if ! wait "$pid"; then
    failed+=("${pids[$pid]}")
  fi
done

if [ ${#failed[@]} -gt 0 ]; then
  echo "失敗したホスト: ${failed[*]}" >&2
  exit 1
fi
echo "すべて成功"

web02のデプロイだけ失敗する状況で実行すると、失敗したホストを特定できる。

失敗したホスト: web02

waitは終了済みのジョブに対しても終了ステータスを返すため、ループ内で待つ順序は結果に影響しない。

set -eとの併用に注意する

【Shell Script】set -euo pipefailでエラーに強いシェルスクリプトを書く のようにset -eを有効にしている場合、waitが非ゼロを返した時点でスクリプトが終了する。

#!/bin/bash
set -e
./deploy.sh web02 &  # 失敗する
wait "$!"
echo "ここには到達しない"

前述の例のようにwaitifの条件として実行すれば、set -eによる終了は発生しない。失敗したジョブをすべて集計してから終了できる。

wait -nでどれか1つの終了を待つ

wait -nは、いずれか1つのジョブが終了した時点で復帰し、そのジョブの終了ステータスを返す。すべてのジョブの終了を待たずに失敗を検知したい場合に使う。

#!/bin/bash
for target in web01 web02 web03; do
  ./deploy.sh "$target" &
done

for _ in web01 web02 web03; do
  if ! wait -n; then
    echo "いずれかのホストで失敗した" >&2
  fi
done

待つジョブが残っていない状態でwait -nを実行すると127を返す。ジョブの数だけループを回す形にしておけば、127と実際の終了ステータスの混同を避けられる。

wait -pで終了したジョブのPIDを取得する

wait -n単体では、返ってきた終了ステータスがどのジョブのものか分からない。bash 5.1以降では-p 変数名を併用すると、終了したジョブのPIDを指定した変数に格納する。終了した順に結果を報告できる。

#!/bin/bash
declare -A pids

for target in web01 web02 web03; do
  ./deploy.sh "$target" &
  pids[$!]=$target
done

while [ ${#pids[@]} -gt 0 ]; do
  wait -n -p pid "${!pids[@]}"
  status=$?
  echo "${pids[$pid]} が終了 (status: $status)"
  unset "pids[$pid]"
done

処理済みのPIDを連想配列から削除しつつ、残りのPIDをwait -nに渡している。web02が最初に終了して失敗した場合、以下のように出力される。

web02 が終了 (status: 1)
web01 が終了 (status: 0)
web03 が終了 (status: 0)

並列度を制限する

対象が数百件ある場合、すべてを同時に起動すると負荷が高すぎる。実行中のジョブ数が上限に達している間wait -nで待てば、同時実行数を一定に保てる。

#!/bin/bash
max=2

for i in 1 2 3 4 5; do
  while (( $(jobs -pr | wc -l) >= max )); do
    wait -n
  done
  ( echo "start task$i"; sleep 1; echo "end task$i" ) &
done
wait
echo "all done"

jobs -pはジョブのPIDのみを表示し、-rは実行中のジョブに絞り込む。2件ずつ実行される様子が確認できる。

start task1
start task2
end task1
end task2
start task3
start task4
end task3
end task4
start task5
end task5
all done

bashのバージョンによる違い

waitのオプションはbashのバージョンによって使えるものが異なる。

機能必要なバージョン
wait PIDすべてのバージョン
wait -n4.3以降
wait -n PID ... で指定したジョブのみを待つ5.1以降
wait -p 変数名5.1以降

bash 5.0以前のwait -nにPIDを指定した場合、指定していないジョブの終了でも復帰してしまう。以下のスクリプトは、p1のみを待っているつもりでもp2の終了ステータスを受け取る。

#!/bin/bash
(sleep 4; exit 5) & p1=$!
(sleep 1; exit 6) & p2=$!
wait -n "$p1"
echo "status: $?"

bash 5.1以降ではp1の終了ステータスである5を返す。

status: 5

bash 5.0以前では、先に終了したp2の終了ステータスである6を返す。

status: 6

また、macOS標準の/bin/bashはバージョン3.2でありwait -nを使えない。

$ /bin/bash -c 'sleep 1 & wait -n'
/bin/bash: line 0: wait: -n: invalid option
wait: usage: wait [n]

zshのwait-nに対応していない。

$ zsh -c 'sleep 1 & wait -n'
zsh:wait:1: job not found: -n

macOSでは、Homebrewで導入したbash 5系を使うか、wait PIDでジョブごとに待つ方法を選ぶ。

参考