「バックアップスクリプトがディレクトリごとに逐次実行しているせいで、朝までに終わらない」
シェルスクリプトで運用を自動化していると、こうした「待ち時間をなんとかしたい」場面に必ず出くわします。逐次実行では対象が増えるほど線形に時間がかかり、10台のサーバーなら10倍のリードタイムが生じます。
この記事では、bashの
&(バックグラウンド実行演算子)と wait コマンドを使った並列処理設計を解説します。基本的な仕組みから、複数サーバーへの同時SSH実行・最大同時実行数の制御・失敗ジョブの特定・競合状態の防止まで、実務で使えるパターンをまとめました。RHEL 9.4 / Ubuntu 24.04 LTS の bash 5.x 環境を想定しています。この記事のポイント
・コマンド末尾に & を付けるとバックグラウンド実行になり、waitで全完了を待てる
・$! で直前の子プロセスのPIDを取得し、個別にwait/killで管理できる
・最大同時実行数は active カウンタと wait -n(bash 4.3以降)の組み合わせで制御できる
・wait -n -p 変数名(bash 5.1以降)で完了PIDを取得し、失敗したジョブを個別に特定できる
・並列書き込みの競合にはflockを、子プロセスのエラー検出には wait $pid の戻り値を使う
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
なぜシェルスクリプトに並列処理が必要なのか
サーバー運用では「複数の対象に同じ処理を行う」タスクが頻繁に発生します。・5台の構成サーバーにパッケージアップデートをかける
・30個のディレクトリをそれぞれ圧縮してS3にアップロードする
・監視対象20サービスの死活確認を毎分行う
これらを逐次実行すると、1回の処理に5秒かかるコマンドを10台に実行するだけで50秒かかります。並列化すれば理論上は5秒程度で済みます。
bashには並列処理のための組み込み機能が揃っています。外部ツール(GNU parallel等)を使わなくても、
& と wait の組み合わせで多くのユースケースに対応できます。ただし、対象が50台・100台規模になると「全部を一斉に流す」だけでは次のリソース枯渇問題が起きます。
| 問題 | 起きること |
|---|---|
| ネットワーク帯域の飽和 | 100台へ同時rsyncすると上流スイッチのポートが詰まる |
| CPU・ディスクI/Oの飽和 | コア数を超えるプロセスが起動するとコンテキストスイッチ過多になる |
| プロセス数の上限 | ulimit -u(最大プロセス数)に達するとfork()が失敗する |
| APIのrate limit | 100リクエストを同時に投げてHTTP 429が返ってくる |
&とwaitの基本(バックグラウンド実行の仕組み)
1. &でコマンドをバックグラウンドへ送る
コマンドの末尾に& を付けると、そのコマンドはバックグラウンドで起動し、シェルは即座に次の行へ進みます。#!/bin/bash # 通常の逐次実行(10秒かかる) sleep 5 sleep 5 # バックグラウンド実行(約5秒で終わる) sleep 5 & sleep 5 & wait echo "両方の sleep が完了した"
& でバックグラウンド化したコマンドは、シェルから独立した子プロセスとして動作します。コマンドを起動した直後にシェルはジョブ番号とPIDを表示します。$ sleep 5 & [1] 14823 $ sleep 5 & [2] 14827 $ jobs [1]- Running sleep 5 & [2]+ Running sleep 5 &
[1] がジョブ番号、14823 がPID(プロセスID)です。2. waitで子プロセスの完了を待つ
wait(引数なし)は、シェルが起動したすべての子プロセスが終了するまで待機します。並列実行後に「全部終わったら次へ進む」という設計の基本形です。#!/bin/bash # 3つのコマンドを並列実行して全完了を待つ rsync -a /data/dir1/ backup01:/backup/dir1/ & rsync -a /data/dir2/ backup01:/backup/dir2/ & rsync -a /data/dir3/ backup01:/backup/dir3/ & wait echo "全バックアップ完了: $(date)"
wait(引数なし)には次の3つの限界があります。・どのジョブが先に終わったか分からない
・失敗した(終了ステータス非0)ジョブを個別に拾えない(最後に終了したジョブのステータスしか返さない)
・全部終わるまでブロックするため、途中でジョブを追加投入できない
これらを解決するのが、後述の
$! による個別管理と wait -n によるワーカープール設計です。3. $!でPIDを取得して個別管理する
$! は直前にバックグラウンドへ送ったコマンドのPIDを保持するシェル変数です。PIDを配列に保存しておくことで、子プロセスを個別に管理できます。#!/bin/bash PIDS=() rsync -a /data/dir1/ backup01:/backup/dir1/ & PIDS+=($!) # PIDをPIDS配列に追加 rsync -a /data/dir2/ backup01:/backup/dir2/ & PIDS+=($!) rsync -a /data/dir3/ backup01:/backup/dir3/ & PIDS+=($!) # 個別に終了ステータスを取得する for PID in "${PIDS[@]}"; do wait "$PID" echo "PID $PID: 終了ステータス $?" done
wait $PID は指定した子プロセスの終了ステータスを返します。逐次実行の $? と同じ感覚で使えるため、「どの子プロセスが失敗したか」を個別に確認できます。実務で使う並列処理パターン
1. 複数サーバーへのSSH同時実行
複数台への一斉展開やヘルスチェックは、並列処理の最も典型的なユースケースです。#!/bin/bash set -euo pipefail SERVERS=(web01.example.com web02.example.com db01.example.com) PIDS=() echo "=== 並列ヘルスチェック開始: $(date) ===" for SERVER in "${SERVERS[@]}"; do ssh -o ConnectTimeout=10 "${SERVER}" "hostname && uptime" & PIDS+=($!) done # 全サーバーの完了を待つ echo "--- 出力を待機中 ---" wait echo "=== 全サーバーの確認完了 ==="
=== 並列ヘルスチェック開始: Thu Jul 31 10:14:22 JST 2026 === --- 出力を待機中 --- web02.example.com 10:14:23 up 42 days, 3:15, 1 user, load average: 0.12, 0.08, 0.05 web01.example.com 10:14:23 up 156 days, 12:33, 2 users, load average: 0.05, 0.03, 0.01 db01.example.com 10:14:24 up 89 days, 7:41, 1 user, load average: 0.33, 0.28, 0.25 === 全サーバーの確認完了 ===
2. ファイルを並列処理するループ設計
大量のファイルを処理するバッチスクリプトは、forループ内で& を付けるだけで並列化できます。#!/bin/bash # ログファイルを並列で圧縮してアーカイブディレクトリへ移動する LOGDIR="/var/log/app" ARCHDIR="/backup/logs/$(date +%Y%m)" mkdir -p "${ARCHDIR}" PIDS=() for LOGFILE in "${LOGDIR}"/*.log; do [[ -f "${LOGFILE}" ]] || continue ( gzip -c "${LOGFILE}" > "${ARCHDIR}/$(basename "${LOGFILE}").gz" && rm -f "${LOGFILE}" ) & PIDS+=($!) done # 全ファイルの処理完了を待ち、失敗をカウントする FAILED=0 for PID in "${PIDS[@]}"; do wait "${PID}" || FAILED=$((FAILED + 1)) done if [[ $FAILED -gt 0 ]]; then echo "ERROR: ${FAILED}件のファイルで圧縮に失敗しました" >&2 exit 1 fi echo "全ログファイルの圧縮完了: ${ARCHDIR}"
( ... ) でコマンドをグループ化し、複数コマンドを1つのバックグラウンドジョブとして扱っている点がポイントです。wait "${PID}" || FAILED=$((...)) で個別の失敗を集計しています。3. 最大同時実行数を制御する設計(ワーカープール)
対象が100個・1,000個になると、すべてを一度にバックグラウンド実行するのは危険です。ロードアベレージの急上昇やメモリ枯渇を招きます。「常に最大N個だけ実行する」ワーカープールパターンを実装します。並列化したいと考えているエンジニアの方は、make -j で並列ビルドするのと同じ思想でシェルスクリプトを設計できます。
#!/bin/bash # bash 4.3以降(RHEL 8=bash 4.4、RHEL 9=bash 5.1、Ubuntu 24.04=bash 5.2 で利用可能) set -uo pipefail MAX_PARALLEL=5 # 最大同時実行数 active=0 # 現在実行中のジョブ数 process_file() { local FILE="$1" # ここにファイルの処理を記述する gzip -k "${FILE}" echo "処理完了: ${FILE}" } for FILE in /data/files/*.csv; do [[ -f "${FILE}" ]] || continue # 実行中のジョブ数がMAX_PARALLELに達したら1つ完了するまで待つ if [[ $active -ge $MAX_PARALLEL ]]; then wait -n # bash 4.3以降: 任意の1子プロセスが終わるまでブロック (( active-- )) fi process_file "${FILE}" & (( active++ )) done # 残りの全ジョブを待つ wait echo "全ファイルの処理完了"
wait -n(bash 4.3以降)は任意の子プロセスが1つ終わるまで待つコマンドです。sleepポーリングが不要なため、空きができた瞬間に次のジョブを投入でき、常にMAX_PARALLELに近い並列数を維持できます。RHEL 8以降・Ubuntu 20.04以降では標準で使用できます。古い環境(RHEL 7 / bash 4.2)では
wait -n が使えないため、後述のトラブルシュートセクションで代替手段を解説します。$ bash --version GNU bash, バージョン 5.1.8(1)-release (x86_64-redhat-linux-gnu) # wait -n が使えるかどうかの確認方法 $ bash -c 'sleep 0 & wait -n; echo $?' 0
4. wait -n -p(bash 5.1以降)で失敗ジョブを特定する
bash 5.1で追加されたwait -n -p 変数名 は、完了したジョブのPIDを指定した変数に格納します。PIDとジョブ名をあらかじめマッピングしておけば、失敗したジョブを名前付きで特定できます。#!/bin/bash # bash 5.1以降(RHEL 9、Ubuntu 22.04以降で利用可能) set -uo pipefail MAX_PARALLEL=5 SERVERS=(sv01 sv02 sv03 sv04 sv05 sv06 sv07 sv08 sv09 sv10) declare -A PID_TO_SERVER # PID -> サーバー名のマッピング FAILED_SERVERS=() active=0 log() { echo "[$(date '+%H:%M:%S')] $*"; } logerr() { echo "[$(date '+%H:%M:%S')] $*" >&2; } wait_one() { local finished_pid status wait -n -p finished_pid # 完了PIDをfinished_pidに格納(bash 5.1以降) status=$? if [[ -n "${PID_TO_SERVER[$finished_pid]+x}" ]]; then local server="${PID_TO_SERVER[$finished_pid]}" if [[ $status -ne 0 ]]; then FAILED_SERVERS+=("") logerr "FAIL ${server} (pid=${finished_pid}, exit=${status})" else log "OK ${server} (pid=${finished_pid})" fi unset "PID_TO_SERVER[$finished_pid]" fi (( active-- )) } for server in "${SERVERS[@]}"; do if [[ $active -ge $MAX_PARALLEL ]]; then wait_one fi rsync -az /etc/app.conf "deploy@${server}:/etc/app.conf" & pid=$! PID_TO_SERVER[$pid]="$server" log "START ${server} (pid=${pid})" (( active++ )) done while [[ $active -gt 0 ]]; do wait_one done if [[ ${#FAILED_SERVERS[@]} -gt 0 ]]; then logerr "失敗サーバー(${#FAILED_SERVERS[@]}台): ${FAILED_SERVERS[*]}" exit 1 fi log "全台配布完了(計 ${#SERVERS[@]} 台)"
[14:22:01] START sv01 (pid=18421) [14:22:01] START sv02 (pid=18422) [14:22:01] START sv03 (pid=18423) [14:22:01] START sv04 (pid=18424) [14:22:01] START sv05 (pid=18425) [14:22:04] OK sv01 (pid=18421) [14:22:04] START sv06 (pid=18430) [14:22:05] FAIL sv03 (pid=18423, exit=255) [14:22:05] START sv07 (pid=18431) ... [14:22:11] 失敗サーバー(1台): sv03
並列処理のトラブルと対処法
1. 競合状態(レースコンディション)をflockで防ぐ
複数のバックグラウンドプロセスが同じファイルに書き込むと、出力が混在して壊れることがあります。競合状態(レースコンディション)と呼ばれるこの問題は、flock コマンドでロックを取得してから書き込むことで防げます。#!/bin/bash LOGFILE="/var/log/parallel-result.log" LOCKFILE="/var/lock/parallel-result.lock" write_result() { local SERVER="$1" local RESULT="$2" # flockでロックを取得してから書き込む(タイムアウト: 10秒) flock --timeout 10 "${LOCKFILE}" bash -c "echo "[$(date '+%Y-%m-%d %H:%M:%S')] ${SERVER}: ${RESULT}" >> "${LOGFILE}"" } SERVERS=(web01 web02 web03 app01 app02) for SERVER in "${SERVERS[@]}"; do ( RESULT=$(ssh "${SERVER}" "uptime" 2>&1) write_result "${SERVER}" "${RESULT}" ) & done wait
2. 子プロセスの終了ステータスを正確に取得する
wait(引数なし)は全子プロセスを待ちますが、終了ステータスは最後に終了した子プロセスのものだけを返します。どのサーバーで失敗したかを特定するには、PIDを配列に保存して個別に wait $PID を呼ぶ設計が必須です。#!/bin/bash SERVERS=(web01.example.com web02.example.com db01.example.com app01.example.com) declare -A SERVER_PIDS # 連想配列でサーバー名とPIDを対応させる for SERVER in "${SERVERS[@]}"; do ssh -o ConnectTimeout=10 "${SERVER}" "systemctl is-active nginx" & SERVER_PIDS["${SERVER}"]=$! done FAILED_SERVERS=() for SERVER in "${SERVERS[@]}"; do PID="${SERVER_PIDS[$SERVER]}" if wait "${PID}"; then echo "OK: ${SERVER} - nginx 起動中" else echo "ERROR: ${SERVER} - nginx が停止または応答なし" >&2 FAILED_SERVERS+=("${SERVER}") fi done if [[ ${#FAILED_SERVERS[@]} -gt 0 ]]; then echo "要対応サーバー: ${FAILED_SERVERS[*]}" >&2 exit 1 fi echo "全サーバーの nginx 正常稼働を確認"
OK: web01.example.com - nginx 起動中 OK: web02.example.com - nginx 起動中 ERROR: db01.example.com - nginx が停止または応答なし OK: app01.example.com - nginx 起動中 要対応サーバー: db01.example.com
3. bash 4.2(RHEL 7)で最大同時実行数を制御する代替手段
wait -n が使えない bash 4.2 環境では、jobs -p で実行中のジョブ数を数えながらポーリングする方法が代替になります。#!/bin/bash MAX_PARALLEL=5 # 実行中のジョブ数を返す関数 count_jobs() { jobs -p | wc -l } for FILE in /data/files/*.csv; do [[ -f "${FILE}" ]] || continue # 実行中ジョブ数がMAXに達したら100msごとに確認する while [[ $(count_jobs) -ge $MAX_PARALLEL ]]; do sleep 0.1 done gzip -k "${FILE}" & done wait echo "全処理完了"
jobs -p は現在のシェルのバックグラウンドジョブのPIDを1行ずつ出力します。wc -l で行数を数えることで実行中のジョブ数を把握できます。wait -n と比べてsleepを挟むポーリングになるため効率は落ちますが、互換性が高い方法です。4. タイムアウト付き並列実行
SSH実行でリモートホストが無応答になった場合、wait が永遠にブロックするリスクがあります。-o ConnectTimeout オプションをSSHに付けるのが最も確実な対策です。コマンド全体にタイムアウトを設けるには timeout コマンドと組み合わせます。#!/bin/bash SERVERS=(web01.example.com web02.example.com db01.example.com) PIDS=() for SERVER in "${SERVERS[@]}"; do # timeout: コマンド全体に30秒の上限を設ける # SSH: 接続確立に最大10秒 timeout 30 ssh -o ConnectTimeout=10 -o BatchMode=yes "${SERVER}" "df -h /" & PIDS+=($!) done FAILED=0 for i in "${!PIDS[@]}"; do PID="${PIDS[$i]}" SERVER="${SERVERS[$i]}" wait "${PID}" STATUS=$? if [[ $STATUS -eq 124 ]]; then echo "TIMEOUT: ${SERVER} - 30秒で応答なし" >&2 FAILED=$((FAILED + 1)) elif [[ $STATUS -ne 0 ]]; then echo "ERROR: ${SERVER} - 終了ステータス ${STATUS}" >&2 FAILED=$((FAILED + 1)) fi done [[ $FAILED -eq 0 ]] && echo "全サーバー正常" || exit 1
timeout コマンドは制限時間を超えた場合に終了ステータス 124 を返します。これを wait $PID の戻り値で確認することで、タイムアウトと通常のエラーを区別できます。5. set -eとwait -nの干渉を避ける
set -e(errexit)が有効な状態でワーカープールを動かすと、wait -n が失敗ジョブの終了ステータス(非0)を受け取った瞬間にスクリプト全体が停止することがあります。active カウンタの更新漏れも起きやすくなります。ワーカープール設計では
set -e を外して、終了ステータスは wait -n の戻り値を明示的に確認する設計を推奨します。# NG: set -e と wait -n を組み合わせると失敗ジョブ検出時にスクリプトが停止する #!/bin/bash set -euo pipefail # OK: ワーカープールでは -e を外して終了ステータスを明示的に確認する #!/bin/bash set -uo pipefail # -e は使わない FAILED=0 active=0 for FILE in /data/files/*.csv; do [[ -f "${FILE}" ]] || continue if [[ $active -ge $MAX_PARALLEL ]]; then wait -n status=$? (( active-- )) [[ $status -ne 0 ]] && FAILED=$((FAILED + 1)) fi gzip -k "${FILE}" & (( active++ )) done wait
6. wait -nが機能しない:サブシェルとジョブ管理の境界
wait -n はそのシェルの子プロセスしか待てません。サブシェル(( ... ))の中で起動したバックグラウンドジョブは、親シェルから wait で待てないことがあります。ジョブの投入と wait -n の呼び出しは必ず同じシェル層で行ってください。# NG: サブシェル内でバックグラウンド実行して親から wait -n しようとしている ( rsync -a /data/ backup:/ & ) wait -n # サブシェルの子プロセスは親から待てない # OK: 投入と wait -n を同じシェル層で行う rsync -a /data/ backup:/ & pid=$! wait -n # 同じシェルで投入したジョブを正しく待てる
本記事のまとめ
| やりたいこと | 設計パターン |
|---|---|
| コマンドを並列で起動する | コマンド & PIDS+=($!) |
| 全子プロセスの完了を待つ | wait |
| 子プロセスを個別に待ち終了ステータスを得る | wait $PID |
| 最大同時実行数を制御する(bash 4.3以降) | wait -n と active カウンタを組み合わせる |
| 完了PIDを取得して失敗ジョブを特定する(bash 5.1以降) | wait -n -p 変数名 |
| 最大同時実行数を制御する(bash 4.2以前) | jobs -p | wc -l でポーリングして制御する |
| 並列書き込みの競合を防ぐ | flock --timeout N ロックファイル bash -c "書き込みコマンド" |
| SSH無応答によるブロックを防ぐ | timeout 秒数 ssh -o ConnectTimeout=N ホスト コマンド & |
| タイムアウト終了を判定する | wait $PID の戻り値 124 はタイムアウトを示す |
| ワーカープールでset -eの干渉を避ける | set -e を外して終了ステータスを明示的に確認する |
シェルスクリプト講座を見る >>
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 次のページへ:シェルスクリプトの起動前チェック設計|依存コマンド・環境変数・ディレクトリを自動検証する実装パターン
- 前のページへ:シェルスクリプトでデプロイを自動化する方法|ロールバック・排他制御・通知を組み込んだリリース設計
- この記事の属するカテゴリ:シェルスクリプトへ戻る

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