はじめに
シェルスクリプトの終了処理を確実に行うテンプレコードの紹介です。POSIX 準拠で書いています。「POSIX 準拠」の意味は、POSIX 標準規格の内容から読み取れる各シェルの微妙な動作の違いに対応することです。えぇ、面倒でしたよ。ちゃんと読めば書いてありますが、ちゃんと読むのは大変です。なお、終了処理とはシェルスクリプトの実行中に作成した一時ファイルの片付けなどのことを指しています。
訂正: 記事公開時にトラップアクションの冒頭でシグナルの終了ステータスが取得できると書いていましたが、CTRL+Cを除いて取得できませんでした。記事の関連する内容を書き換え、正しく動作しない「テンプレコード(簡略版)」を削除しました。
テンプレコード
まず、面倒な話が嫌な人のために、テンプレコードです。コピーして(修正して)適当に使ってください。
# トラップアクション($1: シグナル名)
trap_action() {
cleanup
# 自分自身にシグナルを再送信してシェルスクリプトを終了する
trap - EXIT "$1"
kill -s "$1" $$ || exit 1
}
# 終了処理
cleanup() {
# 終了処理中に無視するシグナル(他に必要な場合は追加する)
trap '' HUP INT QUIT PIPE TERM
# ここに終了処理を書く
: TODO
}
# トラップするシグナル(他に必要な場合は追加する)
for i in HUP INT QUIT PIPE TERM; do
trap 'trap_action '"$i" "$i"
done
trap cleanup EXIT
# ここより下に実際のシェルスクリプトの内容を書く
:
:
自分自身にシグナルを再送信しているところが特徴的だと思いますが、その理由についての解説がこの記事の内容で、POSIX 準拠で作るにはこうしなければいけなかった部分です。
解説
POSIX に準拠させることの何がめんどくさいかと言うと、POSIX には「シェルによって動作が違う」と書いてあるけれども実際のシェルの動作は書いていないことです。ただでさえ標準規格書は専門的なもので初学者なんかが読むのは大変なものだというのに、標準規格書を読み込んで、実際のシェルでテストして、動作の違いに対応したコードを書くのは簡単ではありません。あるシェルでは動くけれども、他のシェルでは動かない場合はいくつもあります。そのようなシェルの動作の違いに詳しくなってもプログラミング力は向上しません。ただのシェルに詳しいだけの人です。こんなものはなくして普通に書けば動くようにしたいですね。そうしなければシェルスクリプトはいつまでも他の言語よりも移植性が低いと言われ続けます。終了処理についてはこの記事で解決できたのではないかと考えています。
trapに長いコードを書くのはやめよう
これは必須ではありませんが、次のような trap
コマンドの中に直接埋め込むコードは読みづらいです。これは実質的に eval
と同じなのである種の危険性もあります。
trap 'rm ...; rm ...' INT
単純ではない処理を行う場合には次のようにシェル関数にして、trap
コマンドではシェル関数を呼び出すだけにしましょう。
func() {
rm ...
rm ...
}
trap 'func' INT
シグナル番号は使うべきではない
POSIX ではシグナル番号は、オプションの XSI 拡張機能です。POSIX で規定されているように見えて、実際には移植性が保証されていない部分です。SUS (Single UNIX Specification) に準拠している macOS などでは、XSI 拡張機能に準拠しているはずですが、そうではない大半の Linux や BSD 系 Unix では XSI 拡張機能に準拠している保証はありません。まあ、実際には XSI 拡張機能として定義されているシグナル番号については準拠しているようですが、POSIX 準拠にこだわるのであればシグナル番号は使うべきではありません。
シグナル番号ではなくシグナル名を使っているところは、kill
コマンドと trap
コマンドです。kill
コマンドの話は後回しにします。trap
コマンドの使い方は以下のとおりです。POSIX trap についてはこちらを参照してください。
trap '' HUP INT QUIT PIPE TERM
ときたま見かけるコードには、次のように番号で指定されていますが、これが XSI 拡張機能の書き方です。POSIX 準拠を名乗るのであれば(擬似シグナル EXIT
に対応する 0
を除いて)シグナル番号で指定してはいけません。
trap '' 1 2 3 13 15
trapの処理をデフォルトに戻す方法
シグナル番号を使うべきではないと言ったばかりですが、シグナル番号に移植性がないと言うだけで、使ってはいけないわけではなく、シグナル番号を使った仕様も規定されています。シグナルの処理をデフォルトに戻すときは、普通はトラップアクションとして -
を指定して、後ろにシグナル名を書きます。
trap - シグナル名...
例: trap - INT
ただし最初の引数が符号なし10進数の場合はシグナル番号と解釈され -
を省略できます。と POSIX に書かれています。
trap シグナル番号...
例: trap 2
だから trap 2
や trap 2 3
などは書いてよいですが、trap INT
は書いたらダメなんです。なぜダメかと言うと、trap INT QUIT
では、QUIT シグナルが送信された時に INT
コマンドを実行するという意味になるからです。シェルによっては trap INT
(シグナル名が1個だけのとき)で INT シグナルの処理をデフォルトに戻せますが、シェルによっては戻せず、POSIX をちゃんと読めば標準化されていない書式であることがわかります。
シグナル名は大文字でSIGなし
たまに
trap '' SIGINT
や
trap '' int
のようなものを見かけますが、POSIX では拡張機能としてシェルは実装してもよいと明記されていますが、POSIX で標準化されているのは SIG なしの大文字だけです。
シグナル終了時の終了ステータスは128以上としか決まっていない
POSIX ではシグナルで終了した時の終了ステータスは、128 以上であるとしか決まっておらず、どのように表現すれば良いかは規定されていません。だいたいは
- 「プロセスを終了したとき」の終了ステータスは「シグナル番号 + 128」なのですが、
- 「シェルスクリプトの内部」では 256 以上の終了ステータスを扱うこともあります。
exit
で指定できる最大の値は 255 ではなく、$?
で取得できる値も 255 が最大ではありません。
の以下の太字部分をお読みください。
The exit status of a command shall be determined as follows:
- If the command is not found, the exit status shall be 127.
- Otherwise, if the command name is found, but it is not an executable utility, the exit status shall be 126.
- Otherwise, if the command terminated due to the receipt of a signal, the shell shall assign it an exit status greater than 128. The exit status shall identify, in an implementation-defined manner, which signal terminated the command. Note that shell implementations are permitted to assign an exit status greater than 255 if a command terminates due to a signal.
- Otherwise, the exit status shall be the value obtained by the equivalent of the WEXITSTATUS macro applied to the status obtained by the wait() function (as defined in the System Interfaces volume of POSIX.1-2024). Note that for C programs, this value is equal to the result of performing a modulo 256 operation on the value passed to _Exit(), _exit(), or exit() or returned from main().
上記の内容は POSIX.1-2024 で改定されたものであることに注意してください。それまでの内容は曖昧でした。Stéphane Chazelas による こちら https://unix.stackexchange.com/a/99134 の回答もお読みください。
kill -15や-TERMは使うべきではない
これもよく見かけますが、POSIX では XSI 拡張機能なので移植性は保証されていません。POSIX kill についてはこちらを参照してください。
SYNOPSIS
kill [-s signal_name] pid...
kill -l [exit_status]
[XSI] [Option Start]
kill [-signal_name] pid...
kill [-signal_number] pid...
[Option End]
SYNOPSIS にしっかり書いてあるように、-シグナル名
や -シグナル番号
は XSI 拡張機能なんです。まあ実際には使えるのですが、POSIX 準拠と言うからには使ってはなりません。ということで POSIX に準拠した書き方は次の書き方だけなんです。シグナル番号はありません。
kill -s TERM プロセスID...
# -s TERM は省略可能
kill -9 pid
とか見かけますが、kill -s KILL pid
と書きましょう。マジックナンバーは分かりづらいです。
ちなみに、Bourne シェルビルトインの kill
コマンドでは -s
オプションが使えなかったりしますが、これは Bourne シェルが POSIX 準拠ではないからです。Bourne シェルは、前世代 OS の Solaris 10 の /bin/sh
ぐらいでしか使われていないので切り捨てましょう。
シグナル終了時に EXIT は呼ばれないかもしれない
bash だけを対象にしていれば、EXIT
のトラップアクションはシェルスクリプトの終了時に確実に呼ばれます。しかし、すべてのシェルがそのような動きをするわけではありません。
つまり、EXIT
はシグナルで終了したときには呼び出されない(かもしれない)んです。
これもちゃんと POSIX に書かれています。というか「その事がちゃんと書いてないぞ!」ってツッコミ(参照)が入って、POSIX.1-2024 で追記されました。POSIX の仕様では、「EXIT
のトラップアクションはシェルが普通に正常に終了(exit
コマンドを含む)した時に呼び出される」が「シグナルで終了したときにも呼び出されるかもしれない (may occur)」と書いてあります。
ここで POSIX 慣れしてない人は、なんで「仕様をかっちり決めないんだよ!仕様なのに曖昧にするな!」と憤慨するところですが、違うんです。だって仕様を変更してしまったら互換性が保てないでしょ? そうしたら今までのシェルスクリプトが壊れてしまうじゃないですか? だからシェルの実装は今まで通りで変更する必要はなく、シェルスクリプトを書く人(つまり私)が POSIX 準拠する作業をやれよと言われたわけです。つまり両対応するのが POSIX に準拠するということなんです。まあ別に POSIX に言われる前から EXIT
の挙動が違うことは実際のシェルでテストしていて先に気づいていたので、私にとっては驚きはありませんでしたが。
シグナルでシェルスクリプトが終了した時に EXIT
が呼び出されるのは、ksh と bash と mksh です。おそらく ksh88 が最初に実装したのを bash が真似たのでしょう。EXIT
が呼び出されないのは Bourne シェルの仕様で、そっちを真似した ash 系のシェルではシグナルで終了した時に EXIT
は呼び出されません。
自分自身に kill して終了する
まず大前提の話をすると、理想的にはシグナルを受け取って終了処理を行って終了したとき、元のシグナルの終了ステータスでなければなりません。例えば PIPE シグナル(一般的には 141)でシェルスクリプトが終了したとき、それを別の終了ステータスに置き換えてしまったら、シェルスクリプトが PIPE シグナルで終了したことを検出できません。シグナルの本来の終了ステータスで終了するために、自分自身に kill
コマンドで元のシグナルを送信して終了しています。以下の部分です。
# 自分自身にシグナルを再送信してシェルスクリプトを終了する
trap - EXIT "$1"
kill -s "$1" $$ || exit 1
このようなコードにしている理由はシグナルが呼び出された時のシグナルの終了ステータスがわからないからです。コードを見ればわかるように、シグナルが呼び出されたときのシグナル名はわかります。でもそこから終了ステータスへの変換ができません。逆はできるんですよ。終了ステータスからシグナル名への変換は。これは kill -l 終了ステータス
をやれば OK です。POSIX で標準化されているとおりです。一応シグナル番号からのシグナル名への変換もできるので、ループで繰り返せばシグナル名からシグナル番号への変換テーブル作れますが、それではそのシグナル番号に対応するシグナルで終了したときの終了ステータスはなに?って話です。もしかしたら終了ステータスはこれでわかるんじゃないの?って思う人がいるかも知れません。
trap_action() {
echo $?
}
trap trap_action INT TERM
env sleep 10
# env を使用しているのは ksh93 では sleep が
# シェルビルインコマンドで挙動が異なるため
この方法でシグナルの終了ステータスが得られるのは CTRL+C だけです。POSIX にはシグナルのトラップアクションが呼び出された時に、$?
がシグナルの終了ステータスであることは記載されておらず、実際のシェルでは違いがあります。
シェル | CTRL+C |
---|---|
dash、bash、mksh、ksh88 (Solaris 10) | 130 (128 + 2) |
FreeBSD sh、NetBSD sh、OpenBSD sh | 130 (128 + 2) |
ksh93 (macOS、Solaris 11)、ksh93u+m | 258 (128*2 + 2) |
yash | 386 (128*3 + 2) |
ちなみに、yash の終了ステータスは特徴的ですが問題はありません。問題は ksh93 です。exit 258
を行った時の終了ステータスは 2 になるのでシグナルで終了したことがわかりません。
POSIX exit に書いてあることを読めば、256 以上のシグナルに対応する終了ステータスで終了すれば良いようではありますが、シグナル番号から終了ステータスへ変換する計算式はありません(シェル依存のはず)。bashの場合は、シグナルで終了した時に 128+N を使うとドキュメント化されています(参照)。
現実的には「シグナル番号 + 384 (128*3)」で終了すれば、おそらく問題ないと思うのですが、POSIX にはその保証がありません。ちなみに「シグナル番号 + 256 (128*2)」がだめなのは、多くの実装で 8 ビットの範囲で切り落とされるので、シグナルで終了したときの一般的な終了ステータスである「シグナル番号 + 128」にならないからです。
したがってより確実な方法として、自分自身に同じシグナル名でシグナルを送信することにしました。コードも短いですしね。もし kill
が失敗した場合はフォールバックとして終了ステータス1で終了しています。自分自身への kill
実行が失敗するとしたら、別の問題があるはずなので終了ステータス1でも問題ないでしょう。
あと、自分自身に kill
した理由として exit
に変更すると、zsh では EXIT
のトラップアクションが実行されます(理由はおそらく次項参照)。自分自身に kill
するコードが気持ち悪いのであれば、(終了ステータスはどうにかするとして)次のように置き換えれば良いと思います。cleanup
処理が二度行われないように、何もしない関数で再定義していますが、その他にもフラグで管理するのもありでしょう。
# trap - EXIT "$1"
# kill -s "$1" $$ || exit 1
cleanup() { :; } # zsh で cleanup が呼び出されても何もさせないため
exit 終了ステータス
zsh のシェル関数でのtrap EXITの仕様
zsh ではシェル関数の中で trap ... EXIT
を行うと、シェル関数を終了した時にトラップアクションが呼びだされます。
#!/usr/bin/env zsh
init_trap() {
trap cleanup EXIT INT
trap # trap状況の出力
}
cleanup() {
echo "cleanup"
}
init_trap
echo "==="
trap # trap状況の出力
echo before sleep
sleep 3
$ zsh ./zshtrap.sh
trap -- cleanup EXIT
trap -- cleanup INT
cleanup ← ここで cleanup 処理が呼び出されている!?
===
trap -- cleanup INT ← 「trap -- cleanup EXIT」が消えている!?
before sleep
← 最後には cleanup 処理が呼び出されない!?
なんでこんな仕様なのかよくわかりませんが、仕方ないのでシェル関数の外で trap
コマンドを実行しています。ちなみに setopt POSIX_TRAPS
を実行すると POSIX 準拠の動作に変更できるのでこの方法でも OK です。こういう POSIX に準拠していないシェルの動作があるから、POSIX だけを読んでいても移植性があるシェルスクリプトは書けないんですよね......
それでも・・・
シェル毎に動作の違いはある
このコードを使えば、どの POSIX シェルでも動きますが、違いがないわけではありません。それはトラップしていないシグナルを受け取ったときです。テンプレコードにはシェルスクリプトから処理したいであろうシグナルを書いていますが、もしこれ以外のシグナルを受け取ったとき、bash、ksh、mksh では EXIT
が呼び出されるので終了処理が行われますが、それ以外のシェルでは終了処理が行われません。したがってその他のシグナルに対応する場合は、そのシグナルをトラップする必要があります。
終了処理が行われないことはある
KILL
シグナルや STOP
シグナルはトラップできません。それに加えてシステムを強制的に終了させた場合は、終了処理なんてできるわけがないので、終了処理は正しく行われないことがあるという前提で作る必要があります。例えば次回起動時に、終了処理が行われていなければ復旧処理(不要なファイルなどの削除)を行うとかですね。 一時ファイルの作成であれば /tmp
ディレクトリを利用していれば、そんなに気にすることではないかもしれませんが。
さいごに
ということで、解説でごちゃごちゃ面倒だった話を書いていますが、最終的にはテンプレコードのようなシンプルで理解可能な形に持っていけたと思います。まあ、短時間で複数のシグナルが送信されたとき、処理が割り込まれるスキがあるような気もしているのですが、シェルの実装がどうなっているかに依存しますし、検証した限りでは問題なかったのでこのままにしています。あと古いシェルとかでは十分テストしていないので、問題が発覚したらこっそり修正します。
おまけで POSIX があっても各シェルの違いはなくならない大変さをわかっていただければと思います。「POSIX に準拠する」というのは、こういうことを考えながらプログラムすることなんです。シェルの数だけ動作に違いがあります。対象シェルは一つ(bash が現実的)に限定するのが簡単です。私は必要があるので多数の POSIX シェルを相手にしていますが、つくづく POSIX 準拠のシェルスクリプトは素人にはオススメできない世界だなと思います。