🐈

bashスクリプトのベストプラクティスを調査した

2023/04/04に公開

はじめに

ポートのSREを担当している @taiki.noda です。

弊社で実施しているインフラ勉強会でbashスクリプトについて調査したので、今回はそれを紹介したいと思います。

まずなぜこの内容を調査したのかについて3点

  • bashでのシェルスクリプトは簡単に利用できるが、バグを生みやすく、可読性の低くなりやすい言語
  • 複雑な処理は他の言語(Go, Python等)を使った方が良いが、現状色々な箇所で使われているので、メンテナンス性の高い書き方等調べたい
  • 弊社で使われているシェルスクリプトの書き方を考慮した上で、社内で使えるテンプレートを作成・コーディング規約的なものを作ろうと思った

ファイルの構成

シバンは#!/bin/shではなく#!/bin/bash

  • 特にshを使うというわけではないなら、bashを明示する

ファイルの冒頭に用途・使い方を明記

#!/bin/bash
#
# Perform hot backups of Oracle databases.

関数を使用してコードをモジュール化する

  • 関数を使用すると、用途がわかりやすく、読みやすいコードが書ける
  • 汎用的に使いまわせる形で書ける
function my_func(){
    command1;
    command2;
}
my_func

コマンド、functionを作成するときはしっかりとコメントを記述する

  • 複雑な処理は読み解くことが困難なため、実行内容などをコメントする
  • そのスクリプト・コマンドの用途・使い方・利用上の注意点
  • 引数やオプションの定義

usage()関数を作成する

  • スクリプトの使い方をスクリプト内に記載する
function usage() {
cat <<EOS
Usage:  $0 <arg1> <arg2> <arg3>

 arg1  aaaaaaaa
 arg2  bbbbbbb
 arg3  ccccccc

EOS
  exit 1
}

if [[ $# != 3 ]]; then
      usage
fi

関数は定数のすぐ下にまとめる

  • 関数と関数の間に実行コードがあるとコードを追いかけるのが難しくなる

main関数を作成する

  • 短いscriptであれば不要だが、一つ以上の関数を含む長さのscriptの場合はmain関数を作成する
  • main関数を一番下に置いてスクリプトの最後でそれを呼び出すように記述する
  • main関数の実行をif [[ "${BASH_SOURCE[0]}" == "${0}" ]]; thenでブロックし、scriptを直接実行した時のみ処理を行うようにする

Tips

ShellCheck, shfmt を入れる

  • ShellCheck は、バグの要因になり得る記述を検知し警告してくれる、Shell の Linter
  • shfmt は、スクリプト内の記述を設定したルールにしたがって整形してくれる Formatter

install

% brew install shellcheck shfmt
% shellcheck ./sample.sh # shellcheck実行
% shfmt -w ./sample.sh # shfmt実行、整形して保存

VSCode Plugin

どちらもVSCodeの拡張機能が存在するので、VSCodeユーザの方は入れることをおすすめします。

シェルの文法をチェックする

  • 実行時に-nを付与することでシェルを実行せず文法チェックができる
# bash -n miss.sh #文法エラーあり
miss.sh: line 4: syntax error near unexpected token `done'
miss.sh: line 4: `done'

パイプで渡したコマンドの終了ステータスをみる

  • 通常$?では最後に実行したコマンドの終了ステータスしか見ることができない
  • パイプで渡したコマンドの終了ステータスが見たいときは、PIPESTATUSを見ればよい
# echo ${PIPESTATUS[@]}
0 0 0 0 0

スクリプトのカレントディレクトリ設定

  • 他の関連ファイル・ログ等を作成する場合に、このディレクトリを起点に色々相対パスで指定できる
SCRIPT_DIR=$(cd $(dirname $0);pwd)
LOG=$SCRIPT_DIR/tst.log

エラーメッセージは標準エラー出力に出力する

  • エラーメッセージと他のステータス情報を一緒に出力する関数を作成すると便利
function err() {
  echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')]: $*" >&2
}

if ! do_something; then
  err "Unable to do_something"
  exit 1
fi

より良い書き方

if文は[〜]ではなく[[〜]]を使う

  • [[の方が安全で機能が豊富
[[ は && が使える
[[ は || が使える
[[ は -e FILEが使える
[[ は -eq が使える
[[ は = でglob マッチが使える
[[ は =~ の 正規表現マッチが使える
[[ は < で文字列長さ比較ができる
  • グロビングや単語分割がないため、[[ ]]の中にある=の左辺をクオートする必要がない
  • 空の変数も正しく扱われる

コマンド置換は$(cmd)を使う

  • `cmd`と$(cmd)で2通りの記法があるが、$(cmd)で記述する
  • 理由は$(cmd)ならネストできる
  • あと `cmd`は見にくい
VAL=`echo "hoge"`
VAL=$(echo "hoge")

定数は読み取り専用にする

  • 想定外の値の上書きを防止するため、上書きする必要がない値は読み取り専用で定義する
  • bashでは以下で定義すると定数になる
declare -r val1
readonly val1
local -r val1
  • それぞれの違いは、declare -rlocal -rは関数内で定義した場合スコープが関数内で、readonlyはスコープがグローバルになる
  • 個人的には、グローバル定数をreadonlyで定義、ローカル定数をlocal -rで定義するとわかりやすいと思います。

変数名の命名

  • snake caseで命名する (例: use_this_format、OUTPUT_DIR)
  • グローバルな変数・定数は全て大文字(例: OUTPUT_DIR)、ファイルの先頭で宣言する
  • ローカル変数は小文字で先頭に_をつける (例: _use_this_format)

オプションはeuCをつける

#!/bin/bash

# 最初に設定
set -euC

echo "myscript"
  • シバンのところにも記述できるがbashコマンドで直接実行するときにオプションが機能しなくなるのでsetで指定する方が良い

-eオプション

  • スクリプト中のコマンドがエラーになった場合にスクリプトを停止する
    grep lsなどが0件になる場合に終了コード0以外になるので、一時的に-eを解除する必要がある

-uオプション

  • 未定義変数を使おうとするとエラー終了するようになる
  • 変数名のタイプミスや初期化わすれでの変数の利用などを防ぐことができる
  • 以下のようにデフォルト値を指定すれば回避できる
"${VAL1:-}"

-Cオプション

  • リダイレクトでのファイル上書き時にエラー終了するようになる
  • リダイレクトでファイルを生成するときに想定外に上書きしてしまうことを防ぐことができる
  • 以下のようにすれば回避できる
echo "Hello" >| file1.txt

配列変数を展開するときは「"」で必ず囲う

  • 値に半角スペースを含む場合に囲うかどうかで挙動が変わる
# 以下のようにコマンド実行した場合
# myfunc 100 200 "foo bar" 300

# 以下2つのfor文の結果が異なる

for i in $@; do echo $i; done
#=> 100
#=> 200
#=> foo
#=> bar
#=> 300

for i in "$@"; do echo $i; done
#=> 100
#=> 200
#=> foo bar
#=> 300

evalは使わない

  • なにが実行されたのかわかりにくい
  • 成功したのかどうかがわからない
  • 変数へ代入すると、その変数がなにであったかを確認することなく設定してしまう
# What does this set?
# Did it succeed? In part or whole?
eval $(set_my_variables)

# What happens if one of the returned values has a space in it?
variable="$(eval some_function)"

パイプ出力をwhileで読み込むより、プロセス置換やreadarrayを使用する

  • whileスコープ内で変数代入しても、ループを抜けると消えてしまう
  • パイプで接続された各々のコマンドはサブシェル(別プロセス)を作成して実行される
  • shell変数は一つのプロセス内でしか共有できないため、その内容を他のプロセスから見ることはできない
last_line='NULL'
your_command | while read -r line; do
  if [[ -n "${line}" ]]; then
    last_line="${line}"
  fi
done

# This will always output 'NULL'!
echo "${last_line}"
  • プロセス置換もサブシェルを作成するが、サブシェルからwhileにリダイレクトすることが可能
last_line='NULL'
while read line; do
  if [[ -n "${line}" ]]; then
    last_line="${line}"
  fi
done < <(your_command)

# This will output the last non-empty line from your_command
echo "${last_line}"
  • readarrayはbashの組み込みのコマンド
  • readarrayを使用して、コマンドの出力を配列に読み込み、ループする
last_line='NULL'
readarray -t lines < <(your_command)
for line in "${lines[@]}"; do
  if [[ -n "${line}" ]]; then
    last_line="${line}"
  fi
done
echo "${last_line}"

算術演算では(( ... ))$(( ... ))を使う

  • let, $[ ... ], exprは使用しない
  • <, >[[ ... ]]の中では数値比較ではなく、辞書式比較を行う
  • 数値比較には[[ ... ]]ではなく(( ... ))を用いる
  • シェルの組み込み演算はexprより何倍も速い
  • $(( ... ))内では${var}, $varは必要ない(varで良い)
# Do some complicated computations.
# Note that normal arithmetic operator precedence is observed.
hr=2
min=5
sec=30
echo $(( hr * 3600 + min * 60 + sec )) # prints 7530 as expected

ループないの変数名はループしている変数に似た名前をつける

for zone in "${zones[@]}"; do
  something_with "${zone}"
done

テンプレート

#!/bin/bash
#
# 例) MySQLのダンプファイル取得
# マスキング処理を実行
# S3にアップロード

# オプションを付与する
set -euC

# グローバル定数の定義 
readonly SCRIPT_DIR=$(cd $(dirname $0);pwd)
readonly GLOBAL_CONST="GLOBAL_CONST"

# 関数や変数ファイルの読み込み
source $SCRIPT_DIR/functions.sh

#
# 関数定義
#

# usage関数を作成
function usage() {
cat <<EOS
Usage:  $0 <arg1> <arg2> <arg3>

 arg1  aaaaaaaa
 arg2  bbbbbbb
 arg3  ccccccc

EOS
  exit 1
}

# 引数を受け取る関数を作成
function parse_args(){
  if [[ #? != 3 ]]; then
    usage
  fi
  readonly ARG1="$1"
  readonly ARG2="$2"
  readonly ARG3="$3"
}

# commandを実行
#
# $1: ローカル変数
# 出力: ~~~
function my_func() {
  # ローカル定数
  local -r _local_const="$1"
  command "$local_const"
  echo "~~~"
}

# main関数

function main(){
  local -r _arg1="$1"
  local -r _arg2="$2"
  local -r _arg3="$3"

  my_func "$_arg1"
}

# entry point
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  parse_args "$@"
  main "$ARG1" "$ARG2" "$ARG3"
fi

参考

bashスクリプトについてみていきました。
最初にも言ったのですが、やはり複雑な処理は他の言語に任せるのが良さそうです。
今後は他の言語を使った運用スクリプトなどもみていきたいと思いました。

参考になれば幸いです。

参考

Discussion