はじめに
生成AIがこの世に現れてからは、シェルスクリプトの作成は生成AIに任せるようになってしまった。個人で使う上では、生成AIを信じて使えばよいかもしれない。
しかし、仕事では、「これなに?」って聞かれて、「チャッピーが作ったので、知りません!」というのは通用しないだろう。
シェルスクリプトについて知ったことを、ジャンルを問わず、この記事に残していこうと思う。
注意として、試したのは、bashのみであり、他のシェルでも動くかは分からない。
#!/usr/bin/env hogehoge
こういう書き方があることは知っていたが、これまで、シバンは#!/bin/bashみたいに直接パスを指定していた。生成AIにシェルスクリプトを書いてもらうと、ほぼほぼ、envでシバンを書く気がする。おそらく、もともと、envを使うのが主流なのだろう。
envコマンドを次のように環境変数を表示するコマンドとして使ったことがある人は多いだろう。
env
SHELL=/bin/bash
HISTCONTROL=ignoredups
HISTSIZE=1000
HOSTNAME=almalinux.local
GPG_TTY=/dev/pts/0
・
・
・
envをenv コマンドという形で実行すると、環境変数PATHを見て、コマンドを探し、先に見つかったコマンドを実行するようになる。
例えば、#!/usr/bin/env bashと書いた場合、PATHからbashを探して、最初に見つかったものを実行する。
そのため、bashが/bin/bashではなく/usr/local/bin/bashや/opt/homebrew/bin/bashにある環境でも、PATHが通っていればスクリプトを実行できる。
もちろん、envコマンドが/usr/binになければ、スクリプトは動かないのだが、一般には、envコマンドは/usr/binにあるようだ。
set -euo pipefail
この書き方は、生成AIを使うようになってから知った。setコマンドは引数無しで単体で使うと、envコマンドと似ているが、環境変数だけでなく、シェル変数やシェル関数の一覧を表示する。
set
BASH=/bin/bash
BASHOPTS=checkwinsize:cmdhist:complete_fullquote:expand_aliases:extquote:force_fignore:globasciiranges:globskipdots:histappend:hostcomplete:interactive_comments:login_shell:patsub_replacement:progcomp:promptvars:sourcepath
BASHRCSOURCED=Y
BASH_ALIASES=()
BASH_ARGC=([0]="0")
BASH_ARGV=()
BASH_CMDS=()
BASH_LINENO=()
BASH_LOADABLES_PATH=/usr/local/lib/bash:/usr/lib/bash:/opt/local/lib/bash:/usr/pkg/lib/bash:/opt/pkg/lib/bash:.
BASH_REMATCH=()
BASH_SOURCE=()
・
・
・
それで、set -euo pipefailについてだが、これはシバン直後のスクリプト冒頭に書く。
このコマンドを分解すると、次の三つになる。
set -eset -uset -o pipefail
set -e
例えば、次のようなシェルスクリプトtest.shを考えてみる。
#!/usr/bin/env bash
echo "コピー開始!"
cp ./sample.txt ./sample2.txt
echo "コピー完了!"
sample.txtがない場合でも、cpコマンドの後にあるコマンドが実行されてしまい、「コピー完了!」と出力されてしまう。
./test.sh
コピー開始!
cp: './sample.txt' を stat できません: そのようなファイルやディレクトリはありません
コピー完了!
このスクリプトの冒頭にset -eを書いてみる。
#!/usr/bin/env bash
set -e
echo "コピー開始!"
cp ./sample.txt ./sample2.txt
echo "コピー完了!"
このtest-set-e.shでは、sample.txtがない状態で実行すると、cpの後のechoが実行されない。
./test-set-e.sh
コピー開始!
cp: './sample.txt' を stat できません: そのようなファイルやディレクトリはありません
最後の「コピー完了!」が表示されなくなった。
このように、コマンドが失敗した場合に、スクリプト自体を止めたいときに、set -eを使う。
ただ、失敗しても問題ないコマンドがスクリプト内にあるときは困る。
そういうときは、以下のgrepのように、|| trueを付けて、成功扱いにするといいだろう。
#!/usr/bin/env bash
set -e
grep "ERROR" app.log || true
echo "ERROR が見つからなくても続行する"
あと、ifの条件として評価される場合は、エラーが発生しても止まらない。以下のような場合は、処理が継続する。
#!/usr/bin/env bash
set -e
if grep "ERROR" app.log; then
echo "ERROR が見つかりました"
else
echo "ERROR は見つかりませんでした"
fi
set -u
例えば、次のようなシェルスクリプトtest2.shを考えてみる。
#!/usr/bin/env bash
echo "test2.sh 開始!"
echo "$HOGEHOGE"
echo "test2.sh 完了!"
実行すると、次のようになる。
test2.sh 開始!
test2.sh 完了!
変数$HOGEHOGEが定義されていないため、空の行が表示されるのは当然だと思う。
このスクリプトにset -uを入れたtest2-set-u.shを作る。
#!/usr/bin/env bash
set -u
echo "test2.sh 開始!"
echo "$HOGEHOGE"
echo "test2.sh 完了!"
このスクリプトを実行すると、次のようになる。
test2.sh 開始!
./test2.sh: 行 6: HOGEHOGE: 未割り当ての変数です
set -uを入れると、未定義の変数が使われた場合、実行を停止し、後続の処理を止めてくれる。
今回、echoコマンドを例にしたので、set -uの良さが伝わりにくかったと思うが、これが危険なコマンドだったとき、良さを発揮する。
例えば、rm -rf "$TARGET_DIR"/*みたいなコマンドがあった場合、$TARGET_DIRが未定義であれば、rm -rf /*になってしまう。
set -o pipefail
例えば、次のようなパイプラインを使ったコマンドを考えてみる。
grep ERROR access.log | sort
access.logがあれば、「ERROR」がある行を出力し、その結果をソートする。
そして、$?でリターンコードを見てみると、成功の「0」が返ってくる。
access.logがない場合、次のようにエラー出力されると思う。
grep: access.log: そのようなファイルやディレクトリはありません
しかし、この場合でも、$?でリターンコードを見てみると、成功の「0」が返ってくる。
これは、sortのリターンコードが返ってきているためだ。つまり、パイプライン全体のリターンコードは最後に使ったコマンドのリターンコードになってしまう。
ここで、set -o pipefailを使うことで、パイプライン中のどこかのコマンドが失敗した場合、パイプライン全体も失敗扱いにできる。
set -o pipefail
grep ERROR access.log | sort
echo $?
2
パイプラインのどこかで失敗したのであれば、全体も失敗扱いにするのは自然な考えだと思う。基本的にset -o pipefailを使った方がいいと思う。
まとめ
set -euo pipefailを使うことによって、スクリプトが以下のようになる。
- エラーが起きたら、スクリプトを停止する。
- 未定義変数を使ったら、止める
- パイプラインの途中の失敗も検出する
trapコマンド
これも生成AIにシェルスクリプトを書いてもらったときに、初めて知った。
trapコマンドは、スクリプト終了時や特定のシグナルを受け取ったときに、指定した処理を実行するためのコマンドである。
trap 実行したい関数やコマンド シグナル名で使う。
例えば、何か処理をした後に、一時用に作ったディレクトリを削除したいとする。
そのとき、次のようなシェル関数cleanupをスクリプト内で作る。
cleanup() {
echo "後片付けします"
rm -rf "$TMP_DIR"
}
これをスクリプト内の最後に書いた場合、最後まで処理が進めばcleanupは実行される。
しかし、途中でエラーになったり、処理がハングして、Ctrl + Cやkillコマンドでスクリプトを止めた場合は、cleanupが実行されないことがある。
そういうときに、trapコマンドが役に立つ。
TMP_DIR="$(mktemp -d)"
cleanup() {
echo "後片付けします"
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
trap 'exit 130' INT # 128 + SIGINT(2)
trap 'exit 143' TERM # 128 + SIGTERM(15)
このように書いておくと、通常終了時にはEXITに登録したcleanupが実行される。 また、Ctrl + CによるINTシグナルや、killなどによるTERMシグナルを受けた場合は、それぞれexit 130、exit 143で終了し、EXITのcleanupが実行される。
EXITはシェルスクリプト終了時のこと。他のシグナルはman 7 signalで確認できる。
EXIT以外のtrapコマンドで使うことのできるシグナルの一覧はtrap -lやkill -lで確認できる。
trap -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
SIGKILLやSIGSTOPはtrapコマンドで使うことはできないようだ。
commandコマンド
やけに生成AIがcommand -vを使う気がする。 次のように、コマンドの場所や定義を出力する。
command -v ls
出力例:
alias ls='ls --color=auto'
シェルスクリプト内で、コマンドがなければ、別の処理をさせるみたいなことができる。
if ! command -v git >/dev/null 2>&1; then
echo "git が見つかりません" >&2
exit 1
fi
似たコマンドで、typeコマンドがある。
type ls
ls は `ls --color=auto' のエイリアスです
typeコマンドは、出力が人間向けのため、スクリプト内で使う場合はcommand -vの方が良いということだろう。
-vオプションを使わず、command コマンド名だと、シェル関数を無視して、コマンドを実行する。 エイリアスが設定されがちなコマンドに対して、元のコマンドを使ってほしいときに良さそうだ。
コメント