ロックと並列安全|flock / PID / 一時DIR

運用と安全設計

この記事の狙い

並列実行される Bash スクリプトで二重起動や同時書き込みを防ぐためのロック設計をまとめます。
flock を中心に、PID ファイル一時ディレクトリロック(mkdir 原子性)タイムアウト/再試行、**掃除(trap)**まで、実務で使える最小実装を提示します。

前提と対象

  • Bash 4+(set -Eeuo pipefail 推奨)
  • Linux(flock(1) は util-linux 由来)。macOS では flock が無い環境があるため、代替として mkdir ロックや Homebrew 導入を検討

TL;DR(最小実装・コピペ可)

#!/usr/bin/env bash
set -Eeuo pipefail

# 単一実行ロック(排他・タイムアウト10秒)
lock_file="/tmp/myjob.lock"
exec {lfd}>"$lock_file"
if ! flock -x -w 10 "$lfd"; then
  echo "another instance running" >&2
  exit 0       # 競合は非エラー扱いでもよい
fi
trap 'flock -u "$lfd"; rm -f "$lock_file"' EXIT

# クリティカルセクション
do_work

ポイント

  • ファイル記述子に対する flock が堅い(exec {fd}>"file"flock "$fd"
  • trap ... EXIT で必ず解放&掃除
  • タイムアウトは -w SEC、非ブロックは -n

ロック方式の選択指針(どれを使う?)

方式強み注意点典型用途
flock(排他/共有)シンプル・高速・FD連動一部OSにない/NFSは非推奨単一実行、ファイル更新の直列化
PIDファイル(noclobber依存コマンド少陳腐化対応が必要デーモン化・長期ジョブ
mkdir ロック(lockdir)NFSでも比較的安全片付け必須ディレクトリ単位の排他
原子的書き換え(tmp→mv競合を無視できる同期は別途必要出力の完成性保証

flock パターン集

A) ファイル記述子でロック(推奨)

lock() {
  local path="$1" mode="${2:-exclusive}" wait="${3:-0}" fd
  exec {fd}>"$path"
  case "$mode" in
    exclusive) flock -x ${wait:+-w "$wait"} "$fd" ;;
    shared)    flock -s ${wait:+-w "$wait"} "$fd" ;;
  esac || return 1
  echo "$fd"    # 返すFD番号を呼び元が保持
}

# 使用例
lfd="$(lock /tmp/report.lock exclusive 5)" || { echo "busy" >&2; exit 0; }
trap 'flock -u "$lfd"; rm -f /tmp/report.lock' EXIT

B) コマンド/ブロックに一時的にかける

# ファイルを開かず、ファイルパスに対してコマンドを包む
flock -x -w 10 /tmp/my.lock -- some_command --arg

# ブロックにかけたい(サブシェルで一括)
flock -x /tmp/my.lock -- bash -c '
  set -Eeuo pipefail
  critical_section
'

C) 共有ロック(読み取り側)

# ライターは -x、リーダーは -s として両立
exec {rfd}<>"/tmp/cache.lock"
flock -s "$rfd"
read_only_query
flock -u "$rfd"

D) キー別ロック(レコード単位の直列化)

key="$1"
hash="$(printf '%s' "$key" | sha1sum | cut -c1-16)"
lock="/tmp/myapp.$hash.lock"
exec {lfd}>"$lock"; flock -x "$lfd"
# key に紐づく処理

PID ファイル方式(シンプルだが陳腐化に注意)

E) noclobber を使った単一実行

pidfile="/tmp/myjob.pid"
umask 077
if ( set -o noclobber; : >"$pidfile" ) 2>/dev/null; then
  printf '%s\n' "$$" >"$pidfile"
  trap 'rm -f "$pidfile"' EXIT
else
  echo "running? pid=$(cat "$pidfile" 2>/dev/null || echo '?')" >&2
  exit 0
fi
  • noclobber により既存ファイル上書きを防止(原子的作成)
  • 陳腐化対策:PID の生存確認を入れるとより堅牢
if kill -0 "$(cat "$pidfile")" 2>/dev/null; then
  echo "still alive"; exit 0
else
  echo "stale pidfile; removing" >&2
  rm -f "$pidfile"
fi

mkdir ロック(NFS でも効きやすい)

F) ディレクトリ作成の原子性を利用

lockdir="/tmp/my.lockdir"
if mkdir "$lockdir" 2>/dev/null; then
  trap 'r=$?; rm -rf "$lockdir"; exit $r' EXIT
else
  echo "busy" >&2; exit 0
fi

# クリティカルセクション
  • mkdir存在しないときのみ成功(原子的)
  • NFS 環境では flock よりもこちらが有効な場合が多い
  • 障害終了で残骸が残るため、期限付きクリーンを設けると良い
# 5分以上前の lockdir は陳腐とみなす例
[ -d "$lockdir" ] && find "$lockdir" -maxdepth 0 -mmin +5 -exec rm -rf {} +

タイムアウト・再試行・バックオフ

G) 汎用リトライヘルパ

retry_with_backoff() { # cmd...
  local tries="${TRIES:-5}" base="${BASE:-0.2}" i
  for ((i=1;i<=tries;i++)); do
    "$@" && return 0
    sleep "$(awk -v i="$i" -v b="$base" 'BEGIN{printf "%.2f", b*(2^(i-1))}')"
  done
  return 1
}

# ロック取得をリトライ
retry_with_backoff flock -n /tmp/q.lock -- true || { echo "busy"; exit 0; }

原子的書き換え(完成性の保証)

H) 書いてから mv(同一ファイルシステム内)

out="/var/app/data.json"
tmp="$(mktemp "${out}.XXXXXX")"
trap 'rm -f "$tmp"' RETURN
generate_json >"$tmp"
mv -f "$tmp" "$out"     # rename は原子的(同一FS)
trap - RETURN
  • ロックと組み合わせれば汚れた中間ファイルを他プロセスが読まない

並列起動時の実務ポイント

  • ログは stderr、結果は stdout(競合時の混入を防ぐ)
  • xargs -P&+wait -n で並列化する際、出力先は衝突させない(ファイルは per-job にし、最後に結合)
  • ロック粒度を定める:プロセス全体/ファイル単位/キー単位(細かすぎてもデッドロック誘発)
  • NFS 上では flock は不安定になりうる。mkdir ロックか、アプリ側のDB/キューを使う

“コピペ可”テストブロック(最小)

#!/usr/bin/env bash
set -Eeuo pipefail
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
lock="$TMP/t.lock"

# 1) flock 排他が効く
{
  exec {a}>"$lock"; flock -x "$a"; sleep 0.5; echo A
} &
sleep 0.1
{
  exec {b}>"$lock"; if flock -n "$b"; then echo "NG: should block"; exit 1; else echo B; fi
}
wait

# 2) mkdir ロック
ld="$TMP/ld"; mkdir "$ld" && echo L1 || { echo "NG mkdir"; exit 1; }
if mkdir "$ld" 2>/dev/null; then echo "NG lock"; exit 1; else echo L2; fi
rm -rf "$ld"

echo "PASS"

失敗しやすい点(アンチパターン)

  • ロックを取ったファイルと別のファイルを書き換える
    → 保護対象とロック対象を一致させる(同じディレクトリ階層で意味づけ)
  • trap を忘れて残骸ロック
    EXIT/RETURN で必ず掃除。異常系でも実行されるように
  • NFS で flock 頼み
    mkdir ロックへ切替、あるいはローカルに集約してから同期
  • タイムアウトなしで永遠待機
    -n-w を使い、待機→ログ→終了の方針を固定
  • PIDファイルの陳腐化未対策
    kill -0 $pid で生存確認。ダメなら** stale cleanup **

運用メモ(実務)

  • ロックファイルの場所/run/(揮発)または /var/lock/(権限注意)、一時なら /tmp/
  • 命名規則appname.resource.lock / appname.<hash(key)>.lock
  • 監視:ロックの滞留(長時間占有)をメトリクス化(stat -c %Y で時刻確認)
  • エラーポリシー:ロック取得失敗は非エラー終了(0)か専用コード(例: 75 “tempfail”)で区別

互換性と移植性

  • flock(1) は Linux 標準だが macOS では非搭載のことがある(Homebrew の util-linux 等で補完)。POSIX 互換を重視するなら mkdir ロックへ寄せる
  • NFS の flock は信頼できないことがある。NFSv4 でも運用と実装差があるため、共有 FS では mkdir

セキュリティと安全設計

  • ロックファイル・ディレクトリは パーミッション 600/700umask 077)で作成
  • ロック名に外部入力を直入れしない(ハッシュ化)
  • クリティカルセクションでは一時ファイル名に mktemp を使い、シグナルで掃除trap 'rm -f …' INT TERM EXIT

参考リンク

Bash玄

はじめまして!Bash玄です。

エンジニアとしてシステム運用に携わる中で、手作業の多さに限界を感じ、Bashスクリプトを活用して業務を効率化したのがきっかけで、この道に入りました。「手作業は負け」「スクリプトはシンプルに」をモットーに、誰でも実践できるBashスクリプトの書き方を発信しています。

このサイトでは、Bashの基礎から実践的なスクリプト作成まで、初心者でもわかりやすく解説しています。少しでも「Bashって便利だな」と思ってもらえたら嬉しいです!

# 好きなこと
- シンプルなコードを書くこと
- コマンドラインを快適にカスタマイズすること
- 自動化で時間を生み出すこと

# このサイトを読んでほしい人
- Bashに興味があるけど、何から始めればいいかわからない人
- 定型業務を自動化したい人
- 効率よくターミナルを使いこなしたい人

Bashの世界に一歩踏み出して、一緒に「Bash道」を極めていきましょう!

Bash玄をフォローする