bashスクリプトのエラー処理のベストプラクティス

データ処理バッチでシェルスクリプトは便利

データ処理などでバッチプログラムを書くことは多い。Pythonなどのプログラム言語を使って全部記述する方法もあるし、最近ではGUIのワークフローを描けるツールも出てきている。

ただシェルスクリプトは依然として強い。シェルスクリプトは概して動作が高速で、イレギュラー処理に対しても柔軟に対応できる。gcloudやawscliなどのコマンドを使って記述できるので、できないことはない。機能がなければコマンドをインストールすることも可能。困ったときにも確実にゴールにたどり着くメリットがある。プログラム言語だとライブラリの出来に依存するし、ワークフロー系のツールは機能が実装されていないと詰む。イレギュラー処理を扱えない場合がある。

便利なツールが出てきている時代ではあるが、シェルスクリプトを覚えておくのはおすすめである。バッチ処理ではエラーハンドリングが必須だが、bashではエラーハンドリングが難しい。そこでこの記事ではエラー処理を含めたbashスクリプトのベストプラクティスを紹介する。

必要な処理の流れ

エラー処理の要件

  1. 初期処理(init)
  2. メイン処理(main)
    2.1. エラーが発生したらエラー処理(catch)
    2.2. 任意のエラーを発生させることができる(raise)
    2.3. ループ内処理ではエラーが発生しても次のループに進むだけ(continue)
  3. エラーの有無を問わずクリーンアップ処理(finally)
init()
try {
  :
} catch(e) {
  :
  if () raise();
  :
} finally {
  :
}

になるようなものをbashで実装する。

実装方法

シェルのエラー時の挙動をsetコマンドで指定する

スクリプトの冒頭で

set -e -o pipefail

を実行する。setはシェル自体の挙動の設定を行うコマンド。この中でエラー関連の引数は

-e: エラーが発生したら(exit statusが0以外だったら)スクリプトの実行を終了する
-o pipefail: パイプラインの途中でエラーが発生してもスクリプトの実行を終了する

エラー処理まわりの3つの関数を設定

  • raise(): エラーを発生させる。コマンドがエラーを出さない場合でも任意の条件でエラー扱いにするための関数。出力するエラーメッセージを引数として受け取る。
  • catch(): エラー時の処理。いわゆるcatch。エラーの原因個所(行番号、関数/コマンド名)を特定できるようにするための引数を受け取る。
  • finally(): エラーの有無にかかわらず、最後に実行する関数。必ず最後に行う処理がなければ省略してもいい。

以下はテンプレートとして使っていい(finally()はケースバイケースになるので必要に応じて実装)

# エラーを発生させる。exitでなくreturnで戻り値として1を返すことでエラートラップ処理に渡せるようにする。
function raise() {
  echo $1 1>&2
  return 1
}

# エラー時の処理。グローバル変数としてエラーバッファを用意しておく
err_buf=""
function err() {
  # Usage: trap 'err ${LINENO[0]} ${FUNCNAME[1]}' ERR
  status=$?
  lineno=$1
  func_name=${2:-main}
  # ログに出力するエラー。ここだけ書き換えればいい
  err_str="ERROR: [`date +'%Y-%m-%d %H:%M:%S'`] ${SCRIPT}:${func_name}() returned non-zero exit status ${status} at line ${lineno}"
  echo ${err_str} 
  err_buf+=${err_str}
}

# エラーの有無にかかわらず、最後に実行する関数
function finally() {
  :
}

エラー処理を適用する開始地点でtrapコマンドを実行

trapコマンドはシグナルが発生した時の挙動を指定するコマンド。エラーのシグナルが発生した時の挙動を指定すればエラーハンドリングが可能になる。コマンドを実行した後で挙動が適用されるようになrうので、エラー処理を行う対象のロジックの直前で実行する。という意味ではいわゆるtry()関数と同じ場所に差し込めばいい。

# エラー時の挙動を指定。エラーの発生個所(行番号と関数名)を引数としてエラー処理関数に送る。
trap 'err ${LINENO[0]} ${FUNCNAME[1]}' ERR

# エラーの有無にかかわらず最後に実行する(最後に実行する処理がない場合は不要)
trap finally EXIT                          

実際にエラーを発生させる

エラーが発生する可能性のある処理では最後に2>&1をつけるだけでいい(これがないとエラーメッセージを受け取れずにスクリプトが停止してしまうので原因調査ができない)

gsutil cp gs://mybucket/aaa.csv . 2>&1

コマンドがエラーを発生しない場合に任意のエラーを生成

if [ ! -d ${WORK_DIR} ]; then
  raise "Directory not found."
fi

この方法を使えばエラーの発生する可能性のあるコマンドを実行するたびにステータスコードやメッセージを取得する必要がない。既存のコードのメインロジックの前にsetコマンドやエラー関数、try処理を入れて、各コマンドの後に2&>1を追記するだけでエラーハンドリングが可能になる

ディレクトリの設定

スクリプト自体は直接コマンドラインやcronなどさまざまな環境から実行しても問題にならないようにする必要がある

cd `dirname ${BASH_SOURCE[0]}`
readonly ABS_BASE_PATH=`pwd`
readonly SCRIPT=${BASH_SOURCE[0]##*/}

ファイルを扱うことが多いため、作業用ディレクトリを設定してその中でファイルをやりとりするのがいい

mkdir -p ${WORK_DIR}
cd ${WORK_DIR}

具体的な処理の中で

処理の内容

具体的な処理を行うスクリプトを見ながら説明するが、以下の処理になる。

  1. SFTP先にある
  2. ZIPファイルに格納されたCSVファイルを展開し
  3. Google Cloud Storageのバケットにコピーして
  4. BigQueryで取り込み、
  5. クエリを実行する
  6. 最後にSlack完了メッセージを送る

ありがちな処理でそこまで単純というわけではない。留意すべき点は

  • SFTP接続はsshfsを使う→マウントとアンマウントが必要。スクリプト途中の処理に失敗してもアンマウントしなければならない
  • ZIPファイルの中に複数のファイルが圧縮されているのを解凍して中身のファイルごとにGCSに送る→ループ処理になる
  • CSVファイルは「テーブル名_日付.csv」という名前の形式
  • BigQueryへの取り込みはbq load
  • BigQueryでのクエリ実行はbq query

BigQueryやSlackの処理はよく使うものなのでスニペットにするといい

実際のbashプログラムを見ながら

#!/bin/bash

################################################################################
# 初期処理開始
################################################################################

# エラーハンドリングのために必要。
# -e: エラーが発生したら(exit statusが0以外だったら)スクリプトの実行を終了する
# -o pipefail: パイプラインの途中でエラーが発生してもスクリプトの実行を終了する
set -e -o pipefail

# 定数の定義
# 定数(変更できない)はreadonlyで定義する
readonly SSH_USER=username
readonly SSH_HOST=111.222.333.44
readonly SFTP_PATH="/home/username/daily_backup"
readonly MNT_PATH=mnt
readonly WORK_DIR=tmp
readonly GCP_PROJECT="my-project"
readonly BQ_DATASET="datalake"
readonly GCS_PATH="gs://my-bucket/load/" # trailing slash is required

# 引数チェック
if [ $# -ne 1 ]; then
  echo "引数(日数)を指定して下さい。"
  exit 1
fi

# ディレクト周りの初期化処理
# 直接コマンドラインやcronなどさまざまな環境から実行しても問題にならないようにする
## 実行スクリプトとパスの保管
readonly PID=$$
cd `dirname ${BASH_SOURCE[0]}`
readonly ABS_BASE_PATH=`pwd`
readonly SCRIPT=${BASH_SOURCE[0]##*/}
## 作業用ディレクトリを作る
mkdir -p ${WORK_DIR}
cd ${WORK_DIR}

################################################################################
# エラーハンドリング
################################################################################
# 任意のエラーを生成する関数。コマンドがエラーを出さない場合でもエラー扱いにするためのもの
# 引数はエラーメッセージ
function raise() {
  echo $1 1>&2
  return 1
}
# エラー時の処理。いわゆるcatch。エラーの原因個所を特定できるようにするための引数を受け取る
# 引数は行番号と関数(コマンド)名
err_buf=""
function err() {
  # Usage: trap 'err ${LINENO[0]} ${FUNCNAME[1]}' ERR
  status=$?
  lineno=$1
  func_name=${2:-main}
  err_str="ERROR: [`date +'%Y-%m-%d %H:%M:%S'`] ${SCRIPT}:${func_name}() returned non-zero exit status ${status} at line ${lineno}"
  echo ${err_str} 
  err_buf+=${err_str}
}
# エラーの有無にかかわらず、最後に実行する関数
function finally() {
  # マウントの解除
  if [ ${mounted} -eq 1 ]; then
    fusermount -u ${MNT_PATH} 2>&1
  fi
  # 作業用ディレクトリの中身を空にする
  cd ${ABS_BASE_PATH}
  rm -rf ${WORK_DIR}/*
  # エラーメッセージとタイムスタンプをSlackで送る。エラーがなければ「Succeeded.」の文字列を送る
  cat <<EOF | curl -Ss -X POST "https://hooks.slack.com/services/xxxxxxxxxxx/yyyyyyyyyyy/zzzzzzzzzzzzzzzzzzzzzzz" -d @- -o /dev/null
payload={
"username": "Slackで表示するユーザ名",
"text": "$(echo -e ${err_buf:-'Succeeded.'}) ($(date +'%Y-%m-%d %H:%M:%S'))"}
EOF
}

################################################################################
# メイン処理
################################################################################
# この記述以降でエラーハンドリングが有効になる。try関数のようなもの
trap 'err ${LINENO[0]} ${FUNCNAME[1]}' ERR # エラー時
trap finally EXIT                          # エラーの有無にかかわらず最後に実行する(最後に実行する処理がない場合は不要)

# 進捗を標準出力に出しておく(開始)
echo "`date +'%Y-%m-%d %H:%M:%S'` Started."

# ディレクトリのマウント
mounted=0
sshfs -C "${SSH_USER}@${SSH_HOST}:${SFTP_PATH}" ${MNT_PATH} -o idmap=user 2>&1
mounted=1

# エラーを捕捉する行はすべて最後に「 2>&1」をつける
# リモート(マウントしたディレクトリ)にあるファイルを解凍する例
7za e -aoa -oextracted ${MNT_PATH}/backup.zip 2>&1

# 解凍先ファイルのファイル名を一覧で取得し、ループを回す
for file in `find extracted -type f | sed 's!^.*/!!'`; do
  # エラーが発生してもスクリプトの実行を停止したくない処理がある場合
  # ループ処理で一つにエラーがあってもスクリプトの実行自体は停止しないで次の処理に飛ぶ例
  set +e
  trap "err; continue" ERR

  # アンダーバー区切りのファイル名から要素(テーブル名と日付)を抽出
  part=( `echo ${file} | tr -s '_' ' '`)
  table_name=${part[0]}
  table_date=${part[1]}

  # コマンド自体のエラーではないが任意のユーザ定義エラーを出す例
  if [ ${table_date} != `date -d "1 days ago" +%Y%m%d` ]; then
    raise "CSV file name is invalid: ${file}"
  fi

  # 転送
  gsutil -m cp -r -Z ${file} ${GCS_PATH} 2>&1
  # 取り込み
  bq --project=${GCP_PROJECT} load -q --source_format=CSV --skip_leading_rows=1 --encoding=UTF-8 --quote=\" --null_marker="" --allow_quoted_newlines --time_partitioning_expiration=-1 "${BQ_DATASET}.${table_name}\$${table_date}" ${GCS_PATH}${file} 2>&1
  # クエリの実行
  cat << EOF | bq query -q -n 0 --use_legacy_sql=False 2>&1
create or replace table \`${GCP_PROJECT}.${BQ_DATASET}.${table_name}_latest\` as
with t1 as (
  select * from \`${GCP_PROJECT}.${BQ_DATASET}.${table_name}\`
), t2 as (
  select *, max(_PARTITIONTIME) over(partition by ${table_name}_id) latest_PARTITIONTIME from t1
), t3 as (
  select
    * EXCEPT(_PARTITIONTIME)
  from t2
  where _PARTITIONTIME = latest_PARTITIONTIME
)
select * from t3
EOF
  cd ..
done

# エラーの扱いを元に戻す(エラーが発生したらスクリプトの実行が停止される)
set -e

# 進捗を標準出力に出しておく(終了)
echo "`date +'%Y-%m-%d %H:%M:%S'` Finished."

# 成功時、この後でfinally()の処理が実行される

データ周辺の技術 の記事一覧