bashのtrapで一時ファイルを片付ける4つの落とし穴

一時ファイルを mktemp と trap で片付けるつもりが、並列で中身が混ざる、cleanup が 2 回走る、変数が空になる、パスがコードとして実行される。4 つの穴と、それぞれをどう直したかを解説する。

bashのtrapで一時ファイルを片付ける4つの落とし穴

本記事はプロモーションを含みます。

はじめに: 並列に呼んだら、一時ファイルの中身が混ざった

一時ファイルの後片付けで踏んだ 4 つの穴と、それぞれの直し方の一覧

本文を一時ファイルの Markdown に書き出してから処理する自作スクリプトを、2 本並列で呼んだら、片方の出力にもう片方の本文が混ざった。一時ファイルの名前を固定していたのが原因だった。

実行ごとの一時ディレクトリと trap ... EXIT で後片付けする形に直したが、trap の書き方で、さらに 3 つの穴を踏んだ。結論を先に書く。

穴起きること直し方
一時ファイルの名前を固定する並列の実行どうしで中身が混ざるmktemp -d で実行ごとのディレクトリを作る
trap cleanup EXIT INT TERM と並べるINT で cleanup が走った後もスクリプトが続き、終了時にもう 1 回走るEXIT だけにする。Ctrl-C・TERM・HUP で止めても走る
単引用符の trap で関数の local を読む発火時には変数が消えていて、空になるか、終了コードが 1 に変わる登録するときに値を固定する
二重引用符で値を trap に埋める値の ' や ; がコードとして実行されるprintf -v q '%q' でエスケープしてから埋める

どれも、1 本を手で動かして最後まで通すだけでは見えない。

穴1: 名前を固定した一時ファイルは、並列の実行で混ざる

固定名の一時ファイルは並列の実行で共有されて本文が混ざる。mktemp -d なら実行ごとに分かれる

元のスクリプトは、本文を /tmp/prp.tmp.md という決まった名前に書いていた。1 本ずつ使っている間は問題が出ない。2 本を並列に 30 回ずつ呼ぶと、60 回の出力のうち 54 回に、もう片方の本文が混ざった。

どちらの実行も同じファイルに書き、同じファイルを読むので、書いてから読むまでの間に相手が上書きする。

直し方: mktemp -d で実行ごとのディレクトリを作る

mktemp は実行のたびに別の名前を作る。ここでファイルでなくディレクトリにしたのは、中に置くファイルの名前を .md で終えたかったからだ。エディタ (nvim) が拡張子を見て Markdown として開くので、名前の末尾は変えたくない。ディレクトリの名前だけを使い捨てにすれば、ファイル名は自由に決められる。

dir=$(mktemp -d)
printf '%s\n' "$body" > "$dir/body.md"

作ったディレクトリは、終わったら消す必要がある。途中で失敗しても消えるように、trap で EXIT に後片付けを登録した。ここから先の穴は、この trap の書き方で踏んだものだ。

穴2: EXIT に INT や TERM を並べると、cleanup が 2 回走る

trap cleanup EXIT だけで、Ctrl-C・TERM・HUP で止めても cleanup が走る。KILL だけは走らない

trap を書くとき、trap cleanup EXIT INT TERM HUP と signal を並べるべきか迷った。試すと、bash は EXIT だけで、Ctrl-C・TERM・HUP のどれで止めても後片付けを走らせる。終了コードは、signal で止まった値 (128 + signal の番号) のまま残る。

set -m   # 子をそれぞれのプロセスグループで動かし、Ctrl-C と同じくグループごと INT を送れるようにする
for s in INT TERM HUP; do
  bash -c 'trap "echo cleanup" EXIT; sleep 5' & p=$!
  sleep 0.3; kill -"$s" -- "-$p"; wait "$p"; echo "$s rc=$?"
done
# => cleanup / INT rc=130
# => cleanup / TERM rc=143
# => cleanup / HUP rc=129

kill -9 (SIGKILL) だけは、プロセスに処理の機会が無いので trap も走らない。trap で消す前提の一時ディレクトリは、KILL では残る。トークンを含む API の応答などを一時ディレクトリに置くなら、残ったときに何が残るかまで考えておく。

並べると何が起きるか

EXIT と INT を並べると、INT で cleanup が走った後もスクリプトが続き、終了時にもう 1 回走る

INT を trap に入れると、INT を受けたときにその処理が走る。ただし trap した signal ではシェルは終わらない。スクリプトは先へ進み、最後に EXIT の処理がもう 1 回走る。

set -m
bash -c 'trap "echo cleanup" EXIT INT; sleep 5; echo continued' & p=$!
sleep 0.3; kill -INT "$p"; wait "$p"
# => cleanup
# => continued
# => cleanup

Ctrl-C で止めたつもりのスクリプトが後続の処理を続け、後片付けは 2 回走る。後片付けが rm だけなら 2 回目は空振りで済むが、通知や API の呼び出しを含むなら 2 回送る。後片付けは EXIT だけに登録する。

穴3: 単引用符の trap で関数の local を読むと、発火時には消えている

単引用符の trap は発火時に変数を読む。EXIT の時点で関数の local は消えていて、終了コードも 1 に変わる

スクリプトを main() { ...; }; main "$@" の形に包み、一時ディレクトリのパスを local に置いた。trap は単引用符で書いた。

main() {
  local tmp; tmp=$(mktemp -d)
  trap 'rm -rf "$tmp"' EXIT
  ...
}
main "$@"

trap の文字列は、登録したときでなく発火したときに読まれる。単引用符の中の $tmp も、発火したときに展開される。EXIT が発火するのはスクリプトの終わりで、そのとき main はもう抜けていて、local の tmp は無い。

  • set なし: $tmp は空になり、何も消えない
  • set -u だけ: unbound variable のエラーが出る。終了コードは元の値のまま残る
  • set -euo pipefail: 元の終了コードが 0 でも 3 でも、1 に変わる
set -euo pipefail
g() { local tmp=/x; trap 'echo "$tmp"' EXIT; }
g; exit 3
# => tmp: unbound variable、終了コードは 1

最後の形が厄介で、終了コードで成否を分けている呼び出し元 (CI や、終了コードを見て分岐する別のスクリプト) から見ると、成功したはずの実行も失敗に見え、失敗の種類を表す終了コードも 1 に潰れる。

local で終了コードが消える件は「bashのset -eが効かない5つの場面と対処法」にも書いた。

穴4: 二重引用符で値を埋めると、値がコードとして実行される

二重引用符で値を埋めた trap は、値の引用符とセミコロンをコードとして読み直し、パスの一部が実行される

穴 3 を避けるには、登録したときに値を展開すればよい。二重引用符で書けば、$tmp は trap を張った時点で文字列に埋まる。

ただしこの形は、自動のセキュリティレビューで指摘を受けた。使い捨ての worktree のパスを trap に埋めていた箇所で、パスにはブランチ名やリポジトリ名など、外から来た文字列が入る。

trap の文字列は、発火したときにコードとして読み直される。登録時に埋めた値に ' や ; が入っていると、それはそのままシェルの構文になる。

cleanup() { echo "cleanup [$1]"; }
wt=$1
trap "cleanup '$wt'" EXIT
$ bash inj.sh "/tmp/x'; echo INJECTED; '"
cleanup [/tmp/x]
INJECTED
inj.sh: 行 1: : command not found

値の中の echo INJECTED が実行された。echo で済んでいるのは例だからで、後片付けの rm -rf に同じ形で値が入れば、消す対象を外から変えられる。

直し方: printf -v q ‘%q’ でエスケープしてから埋める

printf -v q '%q' でエスケープしてから埋めると、値は 1 つの引数のまま渡る

bash の printf の %q は、値をシェルの入力として読み直せる形に引用する。これで値を固定してから埋める。

cleanup() { echo "cleanup [$1]"; }
wt=$1
printf -v q '%q' "$wt"
trap "cleanup $q" EXIT
$ bash ok.sh "/tmp/x'; echo INJECTED; '"
cleanup [/tmp/x'; echo INJECTED; ']

値は 1 つの引数のまま cleanup に渡り、何も実行されない。登録時に固定しているので、main の local が消えた後でも値は残る。

どの書き方を選ぶか

単引用符・二重引用符・%q の 3 つを比べると、次のようになる。

書き方値を読む時点関数の local値に ' ; が入ると
trap 'cleanup "$tmp"' EXIT発火時消えている安全
trap "cleanup '$tmp'" EXIT登録時使えるコードとして実行される
printf -v q '%q' "$tmp"; trap "cleanup $q" EXIT登録時使える安全

単引用符の形は、変数がグローバルなら安全で読みやすい。関数の中で張るなら %q の形にする。二重引用符で値を直に埋める形は、値が自分で書いたリテラルだけだと言い切れる場合を除いて使わない。

4 つの穴を避けた形はこうなる。

4 つの穴を避けた後片付けの形。Ctrl-C でも exit 3 でもディレクトリは消え、終了コードは残る
#!/usr/bin/env bash
set -euo pipefail

cleanup() { rm -rf -- "$1"; }

main() {
  local dir q
  dir=$(mktemp -d)
  printf -v q '%q' "$dir"
  trap "cleanup $q" EXIT
  printf '%s\n' "$1" > "$dir/body.md"
  # ... body.md を使う処理
}
main "$@"

この形で、exit 3 で抜けた場合は終了コード 3 のまま、Ctrl-C で止めた場合は 130 で、どちらも一時ディレクトリが消えることを確かめた。

後片付けが手元の環境に届いてしまう別の穴 (trap より後ろで unset した変数、空の変数で rm -rf が /tmp を消す件) は「テストの後片付けが手元の環境を壊す4つの穴」に書いた。

trap、signal、引用の規則は、シェルスクリプトの基本の範囲だ。書き方を体系的に押さえ直すなら次の本がよい。

新しいシェルプログラミングの教科書 (三宅英明、電子書籍) 画像: 楽天市場

新しいシェルプログラミングの教科書 (三宅英明、電子書籍)

まとめ

穴直し方
一時ファイルの名前を固定するmktemp -d で実行ごとのディレクトリを作る。ファイル名は中で自由に付ける
EXIT に INT・TERM を並べるEXIT だけにする。KILL では走らないことを前提にする
単引用符の trap で local を読む登録時に値を固定する。set -euo pipefail では終了コードが 1 に変わる
二重引用符で値を埋めるprintf -v q '%q' でエスケープしてから埋める

後片付けは、並列に動かしたとき、途中で止めたとき、外から来た値を渡したときに確かめる。

参考