一時ファイルを mktemp と trap で片付けるつもりが、並列で中身が混ざる、cleanup が 2 回走る、変数が空になる、パスがコードとして実行される。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 本を手で動かして最後まで通すだけでは見えない。
元のスクリプトは、本文を /tmp/prp.tmp.md という決まった名前に書いていた。1 本ずつ使っている間は問題が出ない。2 本を並列に 30 回ずつ呼ぶと、60 回の出力のうち 54 回に、もう片方の本文が混ざった。
どちらの実行も同じファイルに書き、同じファイルを読むので、書いてから読むまでの間に相手が上書きする。
mktemp は実行のたびに別の名前を作る。ここでファイルでなくディレクトリにしたのは、中に置くファイルの名前を .md で終えたかったからだ。エディタ (nvim) が拡張子を見て Markdown として開くので、名前の末尾は変えたくない。ディレクトリの名前だけを使い捨てにすれば、ファイル名は自由に決められる。
dir=$(mktemp -d)
printf '%s\n' "$body" > "$dir/body.md"
作ったディレクトリは、終わったら消す必要がある。途中で失敗しても消えるように、trap で EXIT に後片付けを登録した。ここから先の穴は、この trap の書き方で踏んだものだ。
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 の応答などを一時ディレクトリに置くなら、残ったときに何が残るかまで考えておく。
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 だけに登録する。
スクリプトを 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つの場面と対処法」にも書いた。
穴 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 に同じ形で値が入れば、消す対象を外から変えられる。
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 つの穴を避けた形はこうなる。
#!/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' でエスケープしてから埋める |
後片付けは、並列に動かしたとき、途中で止めたとき、外から来た値を渡したときに確かめる。