シェルスクリプトで「ビルドスクリプト」を作る時に便利なテクニック - 2012-10-11 - ククログ

ククログ

株式会社クリアコード > ククログ > シェルスクリプトで「ビルドスクリプト」を作る時に便利なテクニック

シェルスクリプトで「ビルドスクリプト」を作る時に便利なテクニック

プログラムの種類によっては、そのまま実行できるものと、実行できるようにするために「ビルド」が必要なものとがあります。Cなどのコンパイルが必要な言語で書かれたプログラムは当然ビルドが必要ですし、コンパイルが不要な言語であっても、インストーラパッケージを作るというビルド作業が必要な場合はあります。

ビルド作業の自動化のためのツールとしてmakeなどがありますが、そこまで本格的な事をやる必要がない場合は、シェルスクリプトで「ビルドスクリプト」を作るのが手軽でおすすめです。この記事では、そのような場合に役立つシェルスクリプトのテクニックを4つご紹介します。

  • エラーの気付きやすさとデバッグのしやすさを高める
  • メッセージに色を付ける
  • シェル関数をライブラリにする
  • 一時的に作業ディレクトリの中に入る

エラーの気付きやすさとデバッグのしやすさを高める

はじめに紹介するテクニックは問題が発生した時に気づきやすくするためのテクニックです。

コマンドの実行に失敗したらその場で終了する

シェルスクリプトを使って簡易的なビルドスクリプトを作る時には、「途中のどこかで失敗したらすべての処理を中断する」ということをやりたくなるものです。そうしないと、途中でエラーが起こっているのにビルド処理が最後まで走ってしまい、「見た目だけはきちんとしているけれども実は壊れている」という中途半端な生成物ができてしまうからです。

中途半端な生成物になってしまっているかどうかは、実際にそれをインストールする・動かすなどの操作をしてみないと分かりません。それに対して、そもそもエラー発生時にビルド処理が中断されていれば、「最終生成物ができていない」ということ自体から容易にトラブルの発生に気がつけます。

エラー発生時に全体の処理を中断する最も簡単なやり方は、シェルの内部コマンドの set を使う方法です。bashやzshなどのsh互換のシェルでは、 set -e とすると、途中のコマンドのどれか1つでも失敗した1時点で自動的にシェルスクリプトの実行が終了するようになります。これは、個々のコマンド列について command || exit 1 などと書いても同じような効果を得られます。

#!/bin/sh
# set-e-test.sh

echo "Run 'cp /etc/hosts /root/' without 'set -e'"
cp /etc/hosts /root/

set -e
echo "Run 'cp /etc/hosts /root/' with 'set -e'"
cp /etc/hosts /root/

echo "Finished"

実行してみましょう。

% chmod +x set-e-test.sh
% ./set-e-test.sh
Run 'cp /etc/hosts /root/' without 'set -e'
cp: cannot stat `/root/hosts': Permission denied
Run 'cp /etc/hosts /root/' with 'set -e'
cp: cannot stat `/root/hosts': Permission denied

最初のcpは失敗しても実行が継続しますが、2つめのcpは失敗したらそこで終了しています。これは「Finished」が出力されていないことからわかります。

失敗したコマンドが何だったのかを知る

ただ、この方法には1つ問題があります。それは、「どのコマンドの実行に失敗したのか」が分からないという事です。

通常、シェルスクリプトでは実行したコマンドの実行結果は出力されますが、どのようなコマンドを実行したのかまでは出力されません。単に「Permission denied.」とだけ出ても、何が原因でそのエラーが発生したのかが分からないと、デバッグは非常に困難です2

シェルの内部コマンドの set を使うと、この問題を解消できます。set -x3とすると、シェルスクリプトの中で実行したコマンド列そのものが標準エラー出力に出ます。

#!/bin/sh
# set-x-test.sh

echo "Run 'cp /etc/hosts /root/' without 'set -x'"
cp /etc/hosts /root/

set -x
echo "Run 'cp /etc/hosts /root/' with 'set -x'"
cp /etc/hosts /root/

実行してみましょう。

% chmod +x set-x-test.sh
% ./set-x-test.sh
Run 'cp /etc/hosts /root/' without 'set -x'
cp: cannot stat `/root/hosts': Permission denied
+ echo Run 'cp /etc/hosts /root/' with 'set -x'
Run 'cp /etc/hosts /root/' with 'set -x'
+ cp /etc/hosts /root/
cp: cannot stat `/root/hosts': Permission denied

+」から始まる行がset -xが出力している行です。コマンドを実行する前にコマンド列を出力しています。

もっとスマートに!

ということで、set -exとすると「コマンドの実行に失敗したらその場で終了する」と「どのコマンドで失敗したかを知る」という2つのことを実現できるのですが、これには1つデメリットがあります。set -xすると、echoのように失敗することがないコマンドなども含めてすべてのコマンド列が出力されるようになるため、出てくる情報が多すぎるのです。多すぎる出力は重要な情報を埋もれさせてしまうので、できれば慎みたいものです。

要するに、「コマンドの実行に失敗したらその場で終了する」と「どのようなコマンド列が実行されたかを表示する」という2つのことを、全部のコマンド列でやるのではなく、特定のコマンド列4に対してだけやれるようにしたい、ということになります。setコマンドの影響はそのシェルスクリプト全体に及んでしまうので、個々のコマンド列のレベルで同じような目的を達成するには、何か別の方法を使わなくてはなりません。

前述しましたが、command || exit 1とすると、commandが失敗した時にだけexit 1が実行されてそこでスクリプトの実行が中断される、という挙動にできます。シェルスクリプト全体ではなく、個々のコマンド毎に挙動を変更できるのでやりたいことに近くなります。ただ、これだけだと「どのようなコマンド列の実行に失敗したのか」を知ることができません。両方の望みを同時に叶えるためには、シェル関数を定義する必要があります。

シェル関数にすることには、もう1つメリットがあります。それは、そのコマンド列が「失敗しても継続するものなのかどうか」を判別しやすくなるという点です。コマンド列の最後に|| exit 1を付けた場合、行末を見ないと、コマンドが失敗したらどうなるのかということを判別できません。行の長さはまちまちなので、これでは余計に判別が困難です。それに対して、シェル関数にしておくと、関数名 commandという風に行頭に情報が表れるため、ざっとスクリプト全体を眺めた時に、どれが失敗しても大丈夫でどれがそうでないのかという事が分かりやすくなります。

例えば、関数名CHECKとすると以下のような見た目になります。

# 行末を見て判断
mkdir /usr/local/bin || exit 1 # 失敗してはダメ
mkdir /usr/local/bin           # 失敗してもよい

# 行頭を見て判断
CHECK mkdir /usr/local/bin     # 失敗してはダメ
mkdir /usr/local/bin           # 失敗してもよい

シェル関数の定義例

前述のような事情から、run commandと書くと「コマンドの実行に失敗したらその場で終了する」と「どのようなコマンド列が実行されたかを表示する」が実現されるようなシェル関数runを定義して利用しています。ここでのrunが前述のCHECK相当の関数です。

# run.sh
run() {
  "$@"
  result=$?
  if [ $result -ne 0 ]
  then
    echo "Failed: $@ [$PWD]" >&2
    exit $result
  fi
  return 0
}

このように使います。

#!/bin/bash
# run-sample.sh
source ./run.sh

run echo "Start"
run cp /etc/hosts /root
run echo "Finished"

実行するとこうなります。

% chmod +x run-sample.sh
% ./run-sample.sh
Start
cp: cannot stat `/root/hosts': Permission denied
Failed: cp /etc/hosts /root [/tmp]

コマンドの実行に失敗したときだけコマンド列を標準エラー出力に出しています。

それでは、run関数の中身を順番に説明します。

"$@"

$@は、シェルスクリプト自体に渡されたすべての引数を参照する変数です。シェル関数の中で参照した場合は、そのシェル関数に渡された引数を参照することになります。ここでは渡されたコマンド列そのものを実行しています。runには「実行したいコマンド」も含めた内容が渡されますので、$@はそのままコマンド列として実行することができます。単に$@とせずに、ダブルクォートで囲って"$@"としていることに注意してください。こうしないとスペース入りの引数が複数の引数に分割されてしまいます。

#!/bin/bash
# at-with-double-quote-test.sh

with_double_quote() {
  "$@"
}

without_double_quote() {
  $@
}

with_double_quote    ruby -e 'p(ARGV)' a "b c" d
without_double_quote ruby -e 'p(ARGV)' a "b c" d

このスクリプトを実行すると違いがわかります。

% chmod +x at-with-double-quote-test.sh
% ./at-with-double-quote-test.sh
["a", "b c", "d"]
["a", "b", "c", "d"]

ダブルクォートで囲んだ"$@"だと空白入りの引数"b c"がそのまま1つの引数として渡っています。一方、ダブルクォートで囲まない$@だと空白入りの引数が分割されています。

$@と同じような用途の変数で$*もありますが、引数をそのまま実行するという用途には"$@"が適切です5

result=$?

$?は、直前に実行したコマンド列の終了時のステータスコードを示す変数です。後でもう一度使うので、ここでは一旦別の名前の変数を定義しています。

if [ $result -ne 0 ]
then
  # ... # ステータスコードが0以外であった場合の処理
fi

ここでは、ステータスコードが0かどうかを判別しています。0であればコマンド列の実行に成功しており、それ以外であれば失敗です。[ A -ne B ]は整数型の値同士の比較で、両者が等しくなければ(not equal)結果が真になります。

echo "Failed: $@ [$PWD]" >&2
exit $result

ステータスコードで失敗と示されている場合には、実行したコマンド列とカレントディレクトリの位置6を出力します。その後、exitでスクリプトの実行を中断しています。この時、exitの引数に先の「本当に実行したかったコマンド列の終了時のステータスコード」を渡すことによって、このスクリプト自体が他のスクリプトから呼ばれている場合であっても、エラーの発生が呼び出し元のスクリプトに伝搬するようになります。

return 0

コマンド列の実行に成功していた場合はステータスコード0を返してシェル関数を抜けます。

ここで紹介したようなrun関数を使えば、コマンドの失敗を見つけやすくなるはずです。

メッセージに色を付ける

シェルスクリプトでは、出力を色分けするのもおすすめです。すべての出力が同じ色だと、コマンドの引数と結果とを見間違えたり、そのような見間違いを警戒して出力を読むスピードが低下したり、といった形でデバッグのしやすさが低下してしまいます。

先の「失敗したコマンド列を出力する」という話にも言えることですが、バグは発生しないものだ7という前提で考えてしまうと、エラー発生時の対策をなおざりにしてしまいがちです。そのような場合に備えた対策がなされていないと、デバッグに時間がかかって、余計に消耗してしまいます。そうではなく、人はミスをするものだ・バグは発生するものだ、という前提に立ってデバッグのしやすさに気を遣うようにすると、余計なストレスに悩まされずに済みます。

以下は、前述のシェル関数runの例について、どの部分が実際に実行されたコマンド列なのか、どの部分が作業ディレクトリのパスなのか、といったことを色分けして表示するようにした例です。

# run-with-color.sh

red=31
yellow=33
cyan=36

colored() {
  color=$1
  shift
  echo -e "\033[1;${color}m$@\033[0m"
}

run() {
  "$@"
  result=$?

  if [ $result -ne 0 ]
  then
    echo -n $(colored $red "Failed: ")
    echo -n $(colored $cyan "$@")
    echo $(colored $yellow " [$PWD]")
    exit $result
  fi

  return 0
}

このように使います。

#!/bin/bash
# run-with-color-sample.sh
source ./run-with-color.sh

run echo "Start"
run cp /etc/hosts /root
run echo "Finished"

実行するとこうなります。

% chmod +x run-with-color-sample.sh
% ./run-with-color-sample.sh
Start
cp: cannot stat `/root/hosts': Permission denied
Failed: cp /etc/hosts /root [/tmp]

色つき文字列の出力には、エスケープシーケンスを使用します。エスケープシーケンスを使うと、カラーコードを指定して、echoで出力される文字列に任意の色を付けることができます8

なお、上記のスクリプトはbash用スクリプトとして書かれていますが、bashでechoコマンドを実行した場合、初期状態ではエスケープシーケンスが無視されてしまいます9。ですので、エスケープシーケンスを明示的に有効化するために、coloredの中で明示的にecho -eとしています。dash用スクリプトとして実行する10場合は、dashのechoでは初期状態でエスケープシーケンスが有効なため、-eオプションを指定する必要はありません。

シェル関数をライブラリにする

元のシェル関数runは各シェルスクリプトにコピー&ペーストして使ってもまだ大丈夫な規模でしたが、色づけ表示するようにした例では、カラーコードの定義行なども含めるとそれなりの規模になってしまっています。このような場合は、コピー&ペーストするのではなく、ライブラリとして独立したファイルにしておいた方が便利でしょう。

シェル関数を定義する内容のシェルスクリプトをrun.shのような名前で設置しておき、他のスクリプトからsource ./run.shなどとしてインポートすると、他のスクリプトの中でもrunを利用できるようになります。これは、実はrun関数の実行例の説明のときにすでに使っていたテクニックです。

一時的に作業ディレクトリの中に入る

ビルド対象が複数のモジュールに別れている時には、サブディレクトリに置いてある各モジュールをそれぞれビルドして、最後に上位のディレクトリで全体をビルドする、という順番で処理を行うことになります。

この時、うっかり以下のようなスクリプトを書いてしまうと、困った事になる場合があります。

main() {
  build_module module1
  build_module module2
}

build_module() {
  cd $1 && \
    ./build.sh && \
    cp $1.zip ../dist/ && \
    cd ..
}

main

cdで各モジュールのディレクトリに移動してそのモジュールごとのビルドスクリプトを実行し、できあがったファイルを最終出力のディレクトリにコピーした後、上位のディレクトリに戻るというスクリプトです。

このスクリプトの一番大きな問題点は、「モジュールのビルドに失敗した時に、カレントディレクトリが移動したままになってしまう」ということです。上記の例であれば、もしmodule1のビルドスクリプトの実行中にエラーが発生した場合、カレントディレクトリはmodule1のディレクトリのままで関数build_moduleを抜けてしまい、次のbuild_module module2に処理が進んでしまいます。そのため、ディレクトリが見つからなかったり、ファイルが見つからなかったり、期待していた内容と異なる内容のファイルが最終出力のディレクトリにコピーされてしまったり、といった予想外のトラブルが発生してしまう恐れがあります。

このような場合には、ディレクトリの移動を伴う一連のコマンドを(...)で括ると安全です。

build_module() {
  (cd $1 && \
    ./build.sh && \
    cp $1.zip ../dist/)
}

(...)の中には通常通りコマンド列を記述できますし、;を区切りとして使えば複数のコマンドを記述することもできます。この時ありがたいことに、(...)の内側でどれだけカレントディレクトリを移動しても、) を抜けた後は(に入る前のカレントディレクトリに強制的に戻されるという特性があります。この特性を利用すれば、複数のディレクトリに別れたサブモジュール群のビルド工程を含むビルドも安心して実行できます11

まとめ

以上、シェルスクリプトを使って簡易的なビルドスクリプトを記述する際に有用なテクニックを4つご紹介しました。

定型的な作業は、このようにして極力自動化しておくことが大事です。パッケージング1つとっても、単にファイルをZIPで固めるだけだから……といった感じで毎回手作業でやっていると、人為的なミスが入り込む余地がありますし、他の人に引き継ぐのも大変です12。また、そのうち作業が億劫になってきて、リリース自体が滞ってしまうことすらあります。

また、ミスを犯さないよう慎重になることは重要ですが、それとフットワークの重さとは別の話です。余計なことに多くの時間を取られるせいでミスが見過ごされてしまっては、元も子もありません。

ソフトウェアやサービスをリリースすることを、英語圏ではshipと表現することがあります13。皆さんも、この記事で紹介したテクニックなどを駆使してshippabilityを高く保ち、プロジェクトを常にshippableな状態にしておくことを心がけてください。

  1. 終了時のステータスコードが0以外になった

  2. シェルスクリプト自体のエラーであれば行番号が出ますが、外部コマンドではその限りではありませんし、コマンドによっては何もメッセージを出さずに終了時のステータスコードだけで異常を知らせるものもあります。

  3. 上記の -eと組み合わせるのであればset -ex

  4. 例えば、失敗する可能性がある、失敗すると影響が大きいコマンド列

  5. 'bash(1)'の「特殊パラメータ」の「*」と「@」の説明を読むと違いがわかります。

  6. デバッグ性を高めるため。

  7. 自分はミスをしない

  8. カラーコードに応じて文字の色を変えるのはシェルではなくGNOME端末などの端末側の機能です

  9. エスケープ文字も含めてそのまま出力されます。

  10. 例えばUbuntuでは#!/bin/shとするとdashが使われます。

  11. サブシェルで実行しているためです。この機能について興味がある人はサブシェルで調べてみてください。

  12. 他の人でなくても、自分自身が過去にやっていた作業をしばらくぶりにまたやるようになったという場合にも、手順が複雑だと、また覚え直すのに苦労することになります

  13. 出荷するという意味