How to initialize POSIX shell script
#posixismadvent この記事はPOSIX原理主義Advent Calendarの15日目だ。
POSIX原理主義によるシェルスクリプトの実行環境の最善と思われる初期化方法を解説する。結論としては,シェルスクリプトの冒頭に常に以下のコードを記述すれば,安全でより互換性が高くなる。
set -eu
umask 0022
export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"
Introduction
POSIX原理主義の解説書である「Windows/Mac/UNIX すべてで20年動くプログラムはどう書くべきか」(以下「すべてで20年動く」と略記)のp. 37「1-9 環境変数などの初期化」において,シェルスクリプトの環境変数などの初期化方法として以下のコードが記述されている。
set -u
umask 0022
PATH='/usr/bin:/bin'
IFS=$(printf ' \t\n_'); IFS=${IFS%_}
export IFS LC_ALL=C LANG=C PATH
初めて見たときはとても参考になった。しかし,POSIX規格について勉強しているとこの方法に疑問を持つようになった。
- PATH変数がPOSIXに規定のない
/usr/bin:/bin
で決め打ち。 - IFSの初期化方法が汚い。
- ロケールである
LC_ALL
とLANG
が重複。
シェルスクリプトの初期化は重要なテーマだ。というのも,環境変数を変えられてしまうとプログラムの挙動が変わってしまうというのは脆弱性の元だからだ。特に,サーバーサイドのシェルスクリプトを書く場合にクリティカルとなる。
POSIX規格を徹底的に見直すことで,より望ましい初期化方法を発見した。
set -eu
umask 0022
unset IFS
export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"
それぞれの意味の確認もこめて説明してく。
set -eu
このコマンドでは,2個のオプションを有効にしている。
開発中のデバッグのしやすさと,予想外の事態での安全性を考えて,基本的にはこれらのオプションを有効にしておいたほうがいいだろう。
ただし,set -e
を付けるかどうかは議論がわかれる。というのも,正常な処理だがexitステータスに非0を返す場合がありうるからだ。例えば,grepコマンド。検索フォームから入力されたデータを受け付けてgrepコマンドで検索を書ける場合,何もヒットしなくてもそれは正常だ。
filer1 | filter2 | ... | grep '(HOGEHOGE)'
しかし,grepコマンドでは何もヒットしなければ,失敗扱いにしてしまい,set -e
を有効にしていればそこで強制終了してしまう。grepコマンドの最後に| cat
などの無害なコマンドをおけば,set -e
の強制終了は免れることはできる。しかし,これを理由に余計なコードを書くのは本末転倒となる。おそらく,「すべてで20年動く」の著者の松浦さんはこの点を懸念してset -e
を記載しなかったのだろう。
そのため,set -e
については以下の方針をとるのがよいと考えた。
- 安全・デバッグのため,常に
set -e
を付ける。 - 必要に応じて,
set -e
は外す。
2017-01-22追記開始
やはり,set -e
は常に付けるべきだと考えなおした。理由は以下2点だ。
- grepコマンドの挙動として,ヒットしなければエラーにするのが自然。
()
でサブシェルとしてグループ化したときにexit 1
で実行元も終了させるため。
まず,1点目はgrepコマンドの挙動に関する理由だ。grepコマンドはパターンをファイルから検索するコマンド。検索はパターンの存在/不在を期待して行うので,あれば成功,なければ失敗とするのが自然な挙動だ。検索フォームで検索をかけて見つからなかったときにエラーにしてほしくないというのは,検索フォームの一部の実装者の都合の話であって,「検索」自体にまで拡大して適用すべき考えではない。
実際に,以下のように文字列がヒットするかどうかの条件判定にも使われるし,それを想定したような-q
オプションまである。
echo "$STR" | grep -q target && echo ok
grepの検索時の終了ステータスが,みつからなくてもエラーにならない場合,常に標準出力が空かどうかの判定が必要になる。これは都合が悪い。
2点目はより実務的な理由だ。シェルではコマンドをグループ化させるときに波括弧と{}
と丸括弧()
が使える。丸括弧を使えば,そのグループはサブシェルとして実行させる。サブシェルとなるため,変数のスコープを限定でき,親への影響を減らせるので役に立つ。しかし,サブシェルであるがゆえに,exit
の挙動が波括弧と異なる。
例えば,エラーが発生したときにメッセージを表示するような関数・コマンドを書くとき,以下のように最後にexit 1
でエラーにして終了させる。
exit_try_help()(
MESSAGE="Try command --help"
echo "$MESSAGE" >&2
exit 1
)
波括弧で囲っている場合,親が実行元となるのでそのままプログラム全体が終了となる。しかし,丸括弧で囲っている場合,そのサブシェルだけが終了する。エラー処理の意図として,そこでプログラム全体を終了させなくてはならない。しかし,丸括弧で囲っている場合は,さらに自分で関数の終了ステータスをみてexit
するコードが必要になる。
始めから波括弧で囲っておけばよいという話かもしれないが,丸括弧を使えば変数のスコープを限定できるので,できれば活用したい。このときに,set -e
を適用させておけば,例え丸括弧でサブシェルとして起動したとしても,関数の終了ステータスを見て親も終了させることができる。
上記2点の理由から,set
コマンドのオプションには-u
だけでなく-e
も常につけるべきと判断した。
2017-01-22追記終了
2017-01-29追記開始
では,grepの検索結果として0件があり得る場合はどうするばいいか?その場合は,上記で記載した通り,エラー処理を書けばいい。検索結果が0件の場合に何もしたくなければ,文字通り何もしないコマンド:
を実行すればいい。
filter1 | filter2 | grep || :
| cat
を最後に付ける場合は,常に無関係な内容が標準出力に流れてしまい,やり方としてよくない。これに対して,|| :
を付ける場合は,失敗したときにだけしか影響を与えず,文字通り何も処理を行わないので,grepに限らずあらゆるコマンドに適用できる。
このset -e
については1点注意すべきことがある。それは,bash 3系では機能しない場面があることだ。
例えば,以下のようにパイプで繋いだ上でのwhile
などの制御構造内でexit 1
した場合や,()
や{}
でグループ化した中でexit 1
場合に,set -e
の機能により親シェルが終了しない。
#!/bin/bash
set -e
echo 1 | while read line; do
exit 1 # not exit
echo 'echo 1 | while'
done
while [ $((i+=1)) -lt 3 ]; do
exit 1 # exit
echo 'while'
done
exit 1 ## exit
echo 'echo 1 | exit 1'
(exit 1) ## not exit
echo 'echo 1 | (exit 1)'
echo 1 | { exit 1; } ## not exit
echo 'echo 1 | { exit 1; }'
{ exit 1; } ## exit
echo '{ exit 1; }'
これはbash 3系固有の挙動であり,3.0と3系最新の3.2.57で確認できた。この他のdashやzsh,kshでは問題なかった。
この現象は以下で最初に報告された。
bash3のバグ発見。次のシェルスクリプト
— リッチー大佐の中の人 (@col_richie) 2017年1月25日
#! /bin/bash -e
echo 1 | while read n; do
echo begin
exit 1
echo end
done
echo $?
を実行すると本来実行されてはならぬecho $?が実行されるぞ。
この原因については,以下のツイートで解説されている。
@col_richie @akanehara @akanehara bashのman pageの-eの項をよく読むと書いてあります。3までは「simple commandが非0を返した時」とあるのでバグではなく仕様です。4はwhileなどの複合文も含むよう動作が変わっています。
— Hiroki Sato (@Hiroki_Sato) January 27, 2017
@col_richie @akanehara ちなみに「simple commandに限る」という仕様はSUSv3です。SUSv4で全てのコマンドに変更されました。bash以外の実装は、昔からほとんどが全てのコマンドを対象にしてると思います。
— Hiroki Sato (@Hiroki_Sato) January 28, 2017
@col_richie @akanehara POSIXの場合、コマンドは規格が先にあって実装が作られたわけではなくて、実装があって規格化したものが多いので、単にbashが準拠から外れないように考慮した結果かも知れません。simpleの定義はSUSにあるので読んでみてください。
— Hiroki Sato (@Hiroki_Sato) January 28, 2017
@Hiroki_Sato @akanehara simple commandsの記述も発見 https://t.co/QTAlAjboar
— リッチー大佐の中の人 (@col_richie) January 29, 2017
要約すると
(オプション引数|リダイレクト文字列)*(|や&&や||や;等の制御演算子)*$
ということか。大変参考になった。
感謝します。
@Hiroki_Sato @akanehara simple commandsのこの定義に基づけば、set -eを有効にした親シェルで”| while〜exit 1〜done”は捕捉できず、シェル関数”foo() (〜)”はできる理由も納得。シェル関数はコマンドの様に呼ぶからか。
— リッチー大佐の中の人 (@col_richie) January 29, 2017
bash 3ができたのは2000年代前半であり,現在のPOSIXのベースとなっている2008年の改訂内容(POSIX:2008,SUSv4相当)とは異なっている。当時のPOSIX規格(POSIX:2004,SUSv3相当)では,set
の-e
オプションはsimple commandが失敗したときに作用することとされている。
なお,POSIX:2008ではこのsimple commandsの縛りがなくなっており,単にcommandsとされている。
パイプを使って,制御構造やグループ化コマンドを使うと,simple commandsの条件から外れるため,-e
の効果を受けなくなったのだと思われる。
このbash 3のset -e
の挙動をカバーするなら,以下3点のアプローチが考えられる。
set -e
を使わない(exit 1)
を諦める- 制御構造や複合文(
()
,{}
)でのexit 1
に注意する
1と2は確実だが,それだとbash 3のためだけに利便性が損なわれる。3の方法に従い注意するのがよいだろう。また,set -e
だけに頼るのではなく,クリティカルな部分ではきちんと専用のエラー処理・終了処理を書くべきだろう。
2017-01-29追記終了
また,set
コマンドはシェルのオプションとしても指定できるので,shebangに以下のように記述してもよい。
#!/bin/sh -eu
umask 0022
umaskコマンドの実行自体に異論はない。このコマンドで新しく生成するファイルのアクセス権を変更できる。事前に作成するファイルのアクセス権を設定しておかないと,アクセス権を変更するまでの間にわずかに第三者にファイルの改ざんを許すことになってしまう。
Ubuntu 16.04では0002となっていた。
umask
0002
echo a >a.dat
umask 0022
echo b >b.dat
ls -l
-rw-rw-r-- 1 senooken 2 2016-12-14 21:42 a.dat -rw-r--r-- 1 senooken 2 2016-12-14 21:42 b.dat
Ubuntu 16.04のデフォルト状態(0002
)では,自分の作成したファイルであっても同じグループのユーザーが勝手に書き込むことができてしまう。umask 0022
で書き込みできるのは自分だけにしたほうが無難だろう。
また,個人情報のように機密性の高い情報を扱う場合は,umask 0077
のようによりアクセス制限を厳しくすることを検討してもよいだろう。
unset IFS
IFS変数は,forやread,$@
,$*
などの区切り文字を指定する。何かと重宝する。例えば,IFS=,
として,カンマ区切りのデータをforで1個ずつ処理することができる。IFS変数の期待される初期値は <space> <tab> <newline>だ。
「すべてで20年動く」の本では以下のようにしてIFSを初期化していた。
IFS=$(printf ' \t\n_'); IFS=${IFS%_}
また,POSIX規格で例示される初期化方法は,以下の通り素直に文字を記入している。
IFS='
'
# The preceding value should be <space><tab><newline>.
# Set IFS to its default value.
「すべてで20年動く」のやり方はごちゃごちゃしすぎている。また,POSIX規格のサンプルは改行をそのまま入れるため見た目が悪い。
両者のアプローチの通り,直接IFS変数に値を入れてももちろんいい。しかし,POSIX規格でIFS変数の値は未設定なら標準の値(スペース,タブ,改行)が設定されることが保証されている。
よって,単純に以下のようにIFSを解除するだけで,POSIX規格に準拠して安全にIFS変数を初期化できる。
unset IFS
同じ結果が得られるのなら,よりシンプルなこの方法が優れているだろう。
2016-12-16追記:
他の指摘を受けて改めてIFS変数についてPOSIX規格を確認していたら,見落としを発見した。
つまり,IFS変数はシェルの起動時にスペース,タブ,改行で自動で初期化されることがPOSIX規格で保証されるので,そもそもスクリプトでの初期化は不要だった。
export LC_ALL='C'
「すべてで20年動く」のp. 41-43「1-16 ロケール」で解説されているように,一部のコマンドはロケール系環境変数(Internationalization Variables)の値によって挙動が変化してしまう。例えば,LANG=ja_JP.UTF-8
であれば,dateコマンドの出力結果が日本語となったり,joinやsortコマンドの列区切りに全角空白が加わってしまう。これを防ぐために,ロケールを固定する。
LC_から始まるロケール変数は時間や金額などいくつも存在するが,一括で設定できる3種類の変数が存在する。
- LANG
- LC_ALL
- LANGUAGE
この内,LANGとLC_ALLはPOSIXで定義されており,LANGUAGEはGNU gettextで定義されている。LANGUAGEコマンドは主にコマンドのヘルプの言語などで使われる。LANGとLC_ALLよりも優先順位が高い。
これらの変数は以下の関係がある。
つまり,基本的に以下の順番の優先順位となる。
- LANGUAGE
- LC_ALL
- LANG
LC_ALLやLANGの値がCの場合のときだけ,例外的にLANGUAGEよりもLC_ALLやLANGの値が優先される。
それぞれの根拠は以下の通り。
ロケールの値には以下で記載されている通り,C
またはPOSIX
を指定することで,POSIXロケールとなりPOSIX規格で規定される標準的なコマンドの振る舞いにできる。
「すべてで20年動く」のp. 43ではGNU gettextのLANGUAGE変数の設定を上書きするために,LANGとLC_ALLの値にCを設定しないといけないと勘違いしている。実際のところ,マニュアルや規格を確認すればわかる通り,単にLC_ALL='C'
と記述するだけで全てのロケールをPOSIXロケールに設定できる。
また,このp. 43とp. 37ではもう一つ誤りがある。それはexportコマンドにより変数を環境変数として再設定している点だ。POSIX規格に書かれている通り,IFS,LC_ALL,PATH変数は環境変数の値を初期値としてもつただのシェル変数だ。
したがって,設定を有効にするためにexportコマンドを実行する必要はなく,単に値を変更すれば即座に適用される。
2016-12-16追記:
LC_ALLとPATH変数についてはこれは間違いだった。exportコマンドにより,これらは環境変数として設定しなければならない。
理由はシェルスクリプトの起動元(#!/bin/sh
)である,shが環境変数としてこれらの変数を参照しているからだ。
特に,LC_ALL変数はほとんどのPOSIX準拠コマンドが環境変数として参照している。
例えば,dateコマンドのENVIRONMENT VARIABLESのセクションに環境変数としてLC_ALLやLANGを参照している。実際に以下のようなコマンドを実行すると環境変数が優先されていることがわかる。
#!/bin/sh
export LANG=ja_JP.UTF-8
date
LC_ALL=C
date
(date)
sh -c "date"
2016年 12月 16日 金曜日 21:41:26 JST 2016年 12月 16日 金曜日 21:41:26 JST 2016年 12月 16日 金曜日 21:41:26 JST 2016年 12月 16日 金曜日 21:41:26 JST
ここで,LC_ALL=C
をexport LC_ALL=C
に変えるだけで,ロケールがサブシェルなどにも反映される。
2016年 12月 16日 金曜日 21:41:49 JST Fri Dec 16 21:41:49 JST 2016 Fri Dec 16 21:41:49 JST 2016 Fri Dec 16 21:41:49 JST 2016
したがって,LC_ALLはシェル変数ではなく環境変数として設定しなければならない。
また,PATH変数もshだけでなく,typeコマンドのように一部のコマンドはPOSIX規格で環境変数として参照している。多くの環境ではシェル変数としてPATH変数に値を代入した場合にサブシェル(シェルスクリプト内でのsh -c
など)にも引き継がれるのだが,FreeBSDのshでは引き継がれない(FreeBSD 11.0で確認)。exportによりPATHを環境変数としてやればきちんと反映される。
export PATH="$(command -p getconf PATH):$PATH"
PATH変数の初期化は極めて重要だ。例えば,.bashrcの最後にunset PATHを追記されたり,lsという名前でrmコマンドが実行されるなどのコマンド名は見慣れているが中身が全く違う悪意あるプログラムのPATHを先頭に持ってこられたりしたら致命的な問題となる。
「すべてで20年動く」p. 37「1-9 環境変数などの初期化」においては以下のように決め打ちで指定されている。
PATH='/usr/bin:/bin'
これには以下2点の問題がある。
/usr/bin:/bin
はPOSIXで未保証/usr/bin:/bin
以外にインストールされているPOSIX準拠コマンドが利用不可能
/usr/bin
や/bin
にPOSIX準拠コマンドが全て存在しているとは限らない。POSIXではこれらのディレクトリは規定されていないからだ。また,/usr/bin:/bin
で固定化してしまうと,システム管理者やユーザーが追加でインストールしたPOSIX準拠コマンドを利用できない。例えば,bcコマンドは標準でインストールされてないOSがあり,システム管理者に/usr/local/bin
などにインストールされていることも考えられる。
したがって,「すべてで20年動く」のPATH変数の初期化方法はよろしくない。
それではどうするのが最善か?実はこのPATH変数を安全に完全に初期化する方法がある。それはcommandコマンドを使う方法だ。初期化するコードは以下となる。
PATH="$(command -p getconf PATH):$PATH"
上記コードは以下の手順でPATH変数を設定している。
この方法が優れている理由は以下3点だ。
決め打ちでPATH変数を初期化しない場合,なんらかのコマンドにより標準的なPATH変数の値を取得する必要がある。その目的として使えるコマンドとしてgetconfが存在する。このことは,bashクックブックの「レシピ14.3 安全な $PATHの設定」にも書かれている。
getconfコマンドは設定値を取得するコマンドであり,引数の最大値ARG_MAXなどシステム設定値を取得できる。ただし,getconfコマンドは外部コマンドなので,まずgetconfコマンドを安全に起動する必要がある。そのために,command -p
を使う。commandコマンドは-vか-Vオプションをつけなければ,引数に渡されたコマンドを実行するコマンドだ。
1個目の利点だが,commandコマンドはPOSIXで規定されるBuilt-In Utilities(組み込みコマンド)だ。そのため,仮にPATH変数が空でも実行できる。また,POSIXの「2.9 Command Search and Execution – Shell Command Language」を見ればわかる通り,組み込みコマンドは外部コマンドよりも検索優先順位が高い。そのため,同名コマンドがPATHに存在していても必ず本物が優先される。
なお,初期化で既に利用したumaskもbuilt-In utilitiyであり,setとunsetはspecial built-in utility(特殊組み込みコマンド)であり,安全に実行できることが保証されている。
2点目の利点だが,commandコマンドのオプション-pは,標準コマンドの存在が保証されるPATHの標準値からコマンド検索を実行する。したがって,command -p
により実行されるコマンドは安全であることが保証される。これにより,getconfコマンドを安全に起動することができる。
3点目の利点だが,この方法はcommandコマンドのEXAMPLESで例示されておりPOSIX規格公認のPATH変数の初期化方法とみなせる。実際のところ,getconfコマンドはXSHで規定されるようなシステム値(何かの最大値であったり最小値)の取得はできるが,こうした環境変数の取得については「The implementation may add other local values.」とあるように実装依存のところがあった。しかし,POSIX規格で例示されているので安心して利用できる。
commandコマンドのEXAMPLESで例示では,より安全にPATHを設定するためにunalias
やunset -f command
なども書かれている。しかし,サブシェルで起動した時点でaliasや関数は初期化されるので,シェルスクリプトの冒頭で書くのならこれらは不要だ。
なお,冒頭でset -u
を有効にしたため,万が一PATH変数が設定されていない環境でエラーが出ないように以下のように未定義の場合は空文字を返すようにした。
export PATH="$(command -p getconf PATH):${PATH:-}"
これは,set -u
より前にPATHを初期化すれば不要になる。個人的に,umaskやsetの方がシェルそのものの挙動に影響を与え,影響範囲が大きいと判断してこの順番にした。
2016-12-19追記
当初はset -uで万が一PATHが未定義の場合でもエラーで落ちないように${PATH:-}としていたが,やはり通常通りの$PATHとした。
export PATH="$(command -p getconf PATH):$PATH"
理由は以下4点。
- 見栄えが悪い。
set -u
で落ちないように,ガードするのは本末転倒。- PATH変数が未定義の場合は異常事態なので,そこで止まってほしい。
- PATH変数が未定義の場合,sh起動時に実装依存で標準値が設定されていることが多く,未定義なことが考えにくい。
シェルスクリプトの初期化は今後何度も見ることになるため,きれいなコードを維持したい。set -u
で落ちることを防ぐという,本末転倒な理由のために汚いコードを残すのは忍びない。だから,POSIX規格で書かれている通りのコードにした。
Conclusion
以上のことから,POSIX原理主義によるシェルスクリプトでは,常に以下のコードを初期化のために冒頭で記述することが望まれる。
set -eu
umask 0022
export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"
例えば,以下のように関数にまとめてしまってもよいだろう。
#!/bin/sh
init(){
set -eu
umask 0022
export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"
}
main()(
init
# command
)
main
「徹底的に調べ尽くす」という自分のPOSIX原理主義を実践できた。自分一人ではここまでのことを思いつけなかった。最初にサンプルがあり,そこに疑問をもってPOSIX規格を徹底的に読み込むことで今回の結論が得られた。今回の結論は「巨人の肩の上に立つ」ことで得られたものであり,叩き台として最初に例を挙げてくださった「すべてで20年動く」の著者である松浦さんに感謝したい。