はじめに

生成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
・
・
・

envenv コマンドという形で実行すると、環境変数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 -e
  • set -u
  • set -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 + Ckillコマンドでスクリプトを止めた場合は、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 130exit 143で終了し、EXITcleanupが実行される。

EXITはシェルスクリプト終了時のこと。他のシグナルはman 7 signalで確認できる。

EXIT以外のtrapコマンドで使うことのできるシグナルの一覧はtrap -lkill -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

SIGKILLSIGSTOPtrapコマンドで使うことはできないようだ。

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 コマンド名だと、シェル関数を無視して、コマンドを実行する。 エイリアスが設定されがちなコマンドに対して、元のコマンドを使ってほしいときに良さそうだ。