ちょっと話題の記事

shellスクリプトで学ぼう!プログラミングがちょっと上手になる(かも)Tips集!!

プログラミングのコツをshell(bash)スクリプトを通して掴んでみましょう!
2020.05.25

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは(U・ω・U)
AWS事業部の深澤です。

さて皆さん、いざプログラミングをしようとしてもなかなかテストがしにくいとか、良い書き方ないかな〜って感じたりしませんか?出来上がったソースコードを数ヶ月後の自分が読んでみて、「あれ?この処理って何してるんだっけ??」って思うのもよくあることです。また中には実際にスクリプトをbatch処理で使おうとしているけど、どんな風に書いたら良いか悩んでいる方とかいらっしゃるのではないでしょうか。今回は僕が運用の中で学んだ「こう書くと良いんじゃない」というTipsを書いて見ました!今回はshellスクリプトを採用しています。

環境

  • Amazon Linux 2 AMI (HVM), SSD Volume Type
  • カーネル
    • 4.14.173-137.229.amzn2.x86_64
  • Bashバージョン
    • GNU bash, version 4.2.46(2)-release (x86_64-koji-linux-gnu)

注意

  • 本ブログはあくまでTips集です。いろんな方法を知ってもらうことやプログラミングの初心者の方に気付きを与えるためのものであるので決してベストプラクティスを伝えるブログではないことをご了承下さい。
  • 今回ご紹介する書き方は必ずしもベストであるとは限りません。ご紹介するうちの、どれか一つでも役に立ってもらえればという気持ちで書いています。
  • こちらで書いているコードは全てBash向けの話となっています。

環境を整えましょう!

どんなプログラミングもそうですが、まずは開発を行いやすいように自身の環境を整えることが大切になります。エディターやIDEは色々と人によって好み等もありますが僕はvscodeを採用しています。そして以下のプラグインを導入して開発環境を整備しました。

  • Remote SSH
    • https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh
  • Bash Beautify
    • https://marketplace.visualstudio.com/items?itemName=shakram02.bash-beautify
  • Bash Debug
    • https://marketplace.visualstudio.com/items?itemName=rogalmic.bash-debug

それぞれのインストール方法や使い方については今回は割愛しますが、このようにプログラミングにおいて環境を整えることはとても大切です。

処理は外出ししよう!

shellスクリプトでは以下のようにすることで関数を定義できます。

関数名 () {
 処理
}

適宜使うことでコードが読みやすくなったり、テストがしやすくなったりします。サンプルコードを用意しましょう。例えば「apacheのプロセスを確認して、落ちていたらslack通知しつつ再起動、それでも起動しなかったら再度slack通知する」といった要件があったとしましょう。そこでその要件に応える次のようなコードを用意しました。

#!/bin/bash

# Check the httpd process
if ((2 > `ps aux | grep \/usr\/sbin\/httpd | wc -l`)) ; then
    # httpd is down. Send a message to slack.
    curl -XPOST  \
    --data-urlencode "payload={\"attachments\": [{\"text\": \"[TEST] Apache is not running.\nTry restart.\"}]}" \
    https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
    
    # Restart action.
    sudo systemctl restart httpd
    
    # Check the httpd process
    if ((2 > `ps aux | grep \/usr\/sbin\/httpd | wc -l`)) ; then
        # Can't start httpd
        curl -XPOST  \
        --data-urlencode "payload={\"attachments\": [{\"text\": \"[TEST] Apache is not running.\"}]}" \
        https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
    fi
fi

まずはapacheのプロセスが起動しているかを確認します。

# Check the httpd process
if ((2 > `ps aux | grep \/usr\/sbin\/httpd | wc -l`)) ; then

psコマンドを実行してその行数を見るだけという簡素な作りです。しっかりやるならPIDを確認したりともう少ししっかりしたプロセスチェックをすべきですが、ここはシンプルにいきましょう。次にslackへ通知を行います。

    curl -XPOST  \
    --data-urlencode "payload={\"attachments\": [{\"text\": \"[TEST] Apache is not running.\nTry restart.\"}]}" \
    https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

こんな感じで通知が飛んできます。

そしてapacheの再起動を試みます。

    # Restart action.
    sudo systemctl restart httpd

この再起動でダメだったら再度slack通知ですね。

    # Check the httpd process
    if ((2 > `ps aux | grep \/usr\/sbin\/httpd | wc -l`)) ; then
        # Can't start httpd
        curl -XPOST  \
        --data-urlencode "payload={\"attachments\": [{\"text\": \"[TEST] Apache is not running.\"}]}" \
        https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
    fi

このshellスクリプトですが、関数を使って書き直すことでいくつかメリットが生まれます。if分岐とslack送信処理(curl)に注目して下さい。

# Check the httpd process
if ((2 > `ps aux | grep \/usr\/sbin\/httpd | wc -l`)) ; then
    curl -XPOST  \
    --data-urlencode "payload={\"attachments\": [{\"text\": \"[TEST] Apache is not running.\nTry restart.\"}]}" \
    https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

この処理は重複していますね。例えば今回の要求は「再起動してダメだったらSlack通知」ですが、仕様が変わって「5回は再起動したい」となったら同じ処理を5回書かないといけません。さらにコードを改善したり修正したい場合には同じ修正を重複している分、繰り返さなくてはなりません。slack送信処理も同じですね。仕様が変わると変更がややこしそうです。これを次のように修正することで重複した処理をまとめることができます。

#!/bin/bash

# Check the httpd process
is_disable_httpd() {
    if ((2 < `ps aux | grep \/usr\/sbin\/httpd | wc -l`)) ; then
        true
    else
        false
    fi
}

# Send a message to slack
send_to_slack(){
    local message=${1}
    curl -XPOST  \
    --data-urlencode "payload={\"attachments\": [{\"text\": \"${message}\"}]}" \
    https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
}

if is_disable_httpd ; then
    # httpd is down. Send a message to slack.
    send_to_slack "[TEST] Apache is not running.\nTry restart."
    
    # Restart action.
    sudo systemctl restart httpd
    if is_disable_httpd ; then
        # Can not start httpd
        send_to_slack "[TEST] Apache is not running."
    fi
fi

もしApacheのヘルスチェックロジックやslack送信処理を修正したくなっても1箇所を修正すれば大丈夫になりました。send_to_slack関数ですが、引数でメッセージを受け取れるようにしてあります。その時、受け取った引数を目的応じた変数名に入れ直すと非常に読みやすくなります。is_disable_httpd関数ですが、このようにtrueかfalse(0か1)の値のことをbooleanと呼び、このbooleanを返す関数(メソッド)の命名はisもしくはcanなどから始めることが多いです。

さらに上記のコードに繰り返し文を組み合わせればもっとスマートに記述でき、かつヘルスチェックの回数など仕様変更が起きやすそうな箇所にも柔軟に対応できますね。

#!/bin/bash

# Check the httpd process
is_disable_httpd() {
    if ((2 < `ps aux | grep \/usr\/sbin\/httpd | wc -l`)) ; then
        true
    else
        false
    fi
}

# Send a message to slack
send_to_slack(){
    local message=${1}
    curl -XPOST  \
    --data-urlencode "payload={\"attachments\": [{\"text\": \"${message}\"}]}" \
    https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
}

# Number of repetitions
repetitions=2

i=0
while :
do
    i=`expr 1 + ${i}`
    if is_disable_httpd ; then
        echo "Apache is OK"
        break
    fi
    send_to_slack "[TEST] Apache is not running.\nTry restart."
    sudo systemctl restart httpd
    if (( $i == ${repetitions})); then
        send_to_slack "[TEST] Apache is not running."
        exit 1
    fi
done

さらにさらに、メインロジックを関数でまとめるとそれぞれのメソッド別にテストが非常にしやすくなります。

#!/bin/bash

# Check the httpd process
is_disable_httpd() {
    if ((2 < `ps aux | grep \/usr\/sbin\/httpd | wc -l`)) ; then
        true
    else
        false
    fi
}

# Send a message to slack
send_to_slack(){
    local message=${1}
    curl -XPOST  \
    --data-urlencode "payload={\"attachments\": [{\"text\": \"${message}\"}]}" \
    https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
}

main() {
    # Number of repetitions
    repetitions=2
    i=0
    while :
    do
        i=`expr 1 + ${i}`
        if is_disable_httpd ; then
            echo "Apache is OK"
            break
        fi
        send_to_slack "[TEST] Apache is not running.\nTry restart."
        sudo systemctl restart httpd
        if (( ${i} == ${repetitions})); then
            send_to_slack "[TEST] Apache is not running."
            exit 1
        fi
    done
}

これを次のようにします。

$ source httpd_health_check.sh

sourceコマンドを実行すると実行中のshellプロセス内で指定したファイルの内容(関数の定義)を実行してくれます。普通に実行するとサブプロセスでshellスクリプトが実行されて終了してしまうのですが、shellプロセス内で実行しますので実行した結果(関数の定義)がそのまま現状のシェルプロセス内に残ります。これで関数ごとに呼び出して動作をテストできます。

$ send_to_slack "ElastiCacheおじさん"
ok

つらつらと上から順番に実行するshellスクリプトだとこのように部分的に切り出して動作を確認することが非常に難しくなりますが、このように適度に関数に切り出してあるとテストがしやすいです。実際に実行する際にはmain関数を呼び出す処理を末尾に書くことを忘れないようにしましょう。

#!/bin/bash

# Check the httpd process
is_disable_httpd() {
〜〜省略〜〜
}

# Send a message to slack
send_to_slack(){
〜〜省略〜〜
}

main() {
〜〜省略〜〜
}

main  #★ここ

さて、sourceで関数をコンソールから呼び出せるのであればそれぞれ別ファイルにしてもできますよね。is_disable_httpd関数とsend_to_slack関数を別ファイルにして次のようにします。

#!/bin/bash

source is_disable_httpd.sh
source send_to_slack.sh

main() {
    # Number of repetitions
    repetitions=2
    i=0
    while :
    do
        i=`expr 1 + ${i}`
        if is_disable_httpd ; then
            echo "Apache is OK"
            break
        fi
        send_to_slack "[TEST] Apache is not running.\nTry restart."
        sudo systemctl restart httpd
        if (( $i == $repetitions)); then
            send_to_slack "[TEST] Apache is not running."
            exit 1
        fi
    done
}

main

この時、is_disable_httpd.shとsend_to_slack.shは実行shellスクリプトと同じディレクトリにいることが条件になるので、次のような処理を最初に入れて実行されるディレクトリを固定すると良いでしょう。

#!/bin/bash

cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source is_disable_httpd.sh
source send_to_slack.sh

main() {
〜〜省略〜〜
}

main

このようにある程度の処理を行えるまとまりをモジュールライブラリと呼んだりします。大きなプログラムであればあるほど、ある程度の処理で分割してモジュールやライブラリとして切り出すのは非常に有効です。もし全ての処理を1ファイルにまとめた場合、行数が数十行であればいいですが、数百行、数千行になってくるとそのコードをテストするのは大変です。エラーが出た場合には、どこでエラーが出ているのか数百行、数千行の中から探さなくてはなりません。ですが、関数ごとに処理が分割できていたり、モジュールやライブラリとして切り出していれば簡単に切り出してテストが行えますし原因の特定も用意になります。これは切り出し方こそ違ど他のプログラミング言語でも有効と考えます。

関数への値の渡し方

send_to_slack関数を思い出して下さい。先ほどは引数として関数に値を渡しましたね。簡単に書くと次のようなカタチです。

#!/bin/bash

echo_func() {
  echo "$1"
}

echo_func hello

次のようにすることで標準出力から受け取ることも可能です。

#!/bin/bash

echo_func() {
  while read str ; do
    echo ${str}
  done
}

cat "hello" | echo_func

結果は同じです。便利なところはファイルからの標準出力を受け取れるところです。例えば次のような内容が記載されたdata.txtというファイルがあったとします。

$ cat data.txt
いぬ
じょにえる
ElastiCacheおじさん

この出力結果は以下のshellスクリプトを実行した時と同じ出力になります。

#!/bin/bash

cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

echo_func() {
  while read str ; do
    echo ${str}
  done
}

cat data.txt | echo_func

このように行毎に繰り返し処理が行えます。例えばcurlとかをAPIに対して実行して複数行のデータが取得できた際なんかにも便利ですね。是非引数で渡す以外にも標準出力で渡すテクニックも知っておくと応用がしやすいかと思います。

ログ

ログです。ログを出すようにしましょう。先ほどのスクリプトですが、echoで出力している箇所がありますね。

    while :
    do
        i=`expr 1 + $i`
        if is_disable_httpd ; then
            echo "Apache is OK" #★ここ
            break
        fi
        send_to_slack "[TEST] Apache is not running.\nTry restart."
        sudo systemctl restart httpd
        if (( $i == $repetitions)); then
            send_to_slack "[TEST] Apache is not running."
            exit 1
        fi
    done

これは普通にコンソールから実行する分には表示されますが、例えば何らかのバックグラウンド処理(cron等)であればecho(標準出力)されたものはどこにも出ないかもしれません(正確にはcronの場合はメール通知が設定されていれば出力されますが見易くはないと思います)。もし何らかの原因でshellスクリプトが正常に実行されなかった場合、その原因を究明することはログなしでは難しいでしょう。
※本ブログは所謂Batch処理のような単体サーバで実行されるようなケースを想定しています。コンテナ運用のようなTwelve-Factor Appの原則に従う場合にはログは標準出力(インスタンス内にファイルを持たない)を検討すべきでしょう。

echoを使ってファイルに出力しても良いですが、loggerというコマンドがあります。

$ logger --help

Usage:
 logger [options] [<message>]

Enter messages into the system log.

せっかくログを出力するコマンドがあるのでこれを使いましょう。普通に使うとシステムログに出力されてしまうのですが、これは避けるべきです。ログを出力しないよりは良いですが、システムログには他のOSログが大量に混ざっているのでこのshellスクリプトのログだけ探すのは大変です。独自のログ出力を行うべきでしょう。

ログ出力で考えるべきこと

今回は実行するshellスクリプトのディレクトリにlogを出力するケースを考えます。まずはどこに出力するかですね。いろんな考え方がありますが、logはlogでどこかにまとまっていた方が後ほど調査しやすいです。例えばサーバ内でファイルにログを出力する場合、logsディレクトリのような形でログが集まっていると確認がしやすいかと思います。今回はshellスクリプトを実行したスクリプトのディレクトリにlogsというディレクトリを作ることとします。次のような処理を書いておくとlogsディレクトリがあれば作成し、なければ何もしないといった処理ができます。

#!/bin/bash

if [ ! -e logs ]; then
    mkdir logs
fi

ログファイルは上書きされてしまうと困ります。set -Cを書いておくだけでリダイレクト($ echo hoge > text.txt)による上書き事故を防いでくれます。必須ではないですが、書いておくと良いでしょう。それと一緒に出力先ファイルを定義します。後にコードを読む人間のことを考えて適宜コメントも残しましょう。コードを開発した本人も1週間もすれば細かいコードのことは忘れてしまうものです。

#!/bin/bash
set -C

if [ ! -e logs ]; then
    mkdir logs
fi

# Create a log file
# format: yyyymmdd_hhmmss.log
log_path="./logs/`date "+%Y%m%d_%H%M%S"`.log"
touch $log_path

ログレベル

さてログにはログレベルという考え方があります。言語や運用によっていろんなログレベルがありますのでここで詳しい話は割愛しますが、ログはログレベルとセットで出力すべきです。ログは出来るだけいろんな情報を出力した方が良いのですが、ログレベルがないと大量のログの中からどれを見たら良いかが非常に検索し辛くなります。イメージがうまくわかない方は「そうなのか」程度で一旦次に進みましょう。

ドライバー関数

ログのフォーマットは非常に仕様変更が発生しやすいです。どんな情報が欲しくなるかは運用と共に変わってきます。またログのフォーマットがログの各行によって違ったら困ってしまいます。どれも同じフォーマットで出力したいです。こういった時には関数を用意すると便利でしたね。今回は以下のように定義しました。

#!/bin/bash
set -C

if [ ! -e logs ]; then
    mkdir logs
fi

log_path="./logs/`date "+%Y%m%d_%H%M%S"`.log"
touch $log_path

logdriver(){
    loglevel=${1}
    msg=${2}
    
    # log format: time [loglevel] log message.
    echo "`date +"%Y/%m/%d %H:%M:%S"` [${loglevel}] ${msg}" >> $log_path
}

ではこれを例のmainスクリプトに組み込んでみましょう!先ほどechoは1箇所だけでしたが、実際には他の箇所にも書いた方が良いでしょう。以下に個人的な好みで書いてみましたので参考にしていただければ幸いです。

#!/bin/bash

cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source is_disable_httpd.sh
source logdriver.sh
source send_to_slack.sh

main() {
    logdriver info "Start the Apache health check."
    
    # Number of repetitions
    repetitions=2
    i=0
    while :
    do
        i=`expr 1 + ${i}`
        logdriver debug "Number of checks: ${i}"
        if is_disable_httpd ; then
            logdriver info "Apache is ok"
            break
        fi
        logdriver error "Apache is not running. Try restart..."
        logdriver debug "Response from slack: `send_to_slack \"Apache is not running.\nTry restart.\"`"
        sudo systemctl restart httpd
        logdriver info "restart command done."
        if (( ${i} == ${repetitions})); then
            logdriver error "Can not restart apache."
            logdriver debug "Response from slack: `send_to_slack \"Apache is not running.\"`"
            exit 1
        fi
    done
    logdriver info "Done."
}

main

実行すると次のようなログが出力されます。

$ ll logs/
-rw-rw-r-- 1 ec2-user ec2-user 396 May 14 14:35 20200514_143537.log
$ cat logs/20200514_143537.log
2020/05/14 14:43:42 [info] Start the Apache health check.
2020/05/14 14:43:42 [debug] Number of checks: 1
2020/05/14 14:43:42 [error] Apache is not running. Try restart...
2020/05/14 14:43:43 [debug] Response from slack: ok
2020/05/14 14:43:43 [info] restart command done.
2020/05/14 14:43:43 [debug] Number of checks: 2
2020/05/14 14:43:43 [info] Apache is ok
2020/05/14 14:43:43 [info] Done.

さて、ここでログレベルが役に立ちます。例えばあなたが運用をしていて、「処理が上手くいっていないみたいなんだけど調べて!」と言われた時、次のようにすればパッとまず何が起きたか調べられますね。

[ec2-user@ip-10-0-1-130 httpd_health_check]$ cat logs/20200514_143537.log | grep error
2020/05/14 14:35:37 [error] Apache is not running. Try restart...

細かい調査が必要になればdebug情報を調べればokです。もしくは結果どうなったか知りたければinfo情報のみ気にすればokです。

$ cat logs/20200514_144342.log | grep info
2020/05/14 14:43:42 [info] Start the Apache health check.
2020/05/14 14:43:43 [info] restart command done.
2020/05/14 14:43:43 [info] Apache is ok
2020/05/14 14:43:43 [info] Done.

ログレベルに応じてログ監視もやりやすくなります。「errorという文字列があればアラートを出す」といった処理もできるのです。本来のログはもっといろんな情報があって、ログからの調査やアラートももっといろんな方法があるのですが、ここでは割愛させて下さい。今回ログはサーバ内に出力しましたが、Cloudwatch logsなどログ監視がしやすくなるようなクラウドサービス上にログを出力するのも良いですね。ちなみにログをサーバ内に出力する場合はいつの間にか量が増えてディスクを逼迫しがちです。ログローテートを別途検討しましょう。

エラーチェック

お次はエラーチェックです。どんなプログラミング言語でも処理が成功したか失敗したかを確認することはとても重要で、いわゆるtry catchで例外処理が発生したりすると発生した例外ごとに対応方法をコードに記載しておきます(エラーハンドリングって言います)。といってもshellスクリプトでtry catchは難しいです(というかできないと思ってます)。しかし、この考え方は重要です。shellスクリプトでも出来るだけエラーが発生した際の処理を書いておくと良いでしょう。先ほどのコードなのですが、肝心なところでチェックが漏れている場所があります。

#!/bin/bash

cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source is_disable_httpd.sh
source logdriver.sh
source send_to_slack.sh

main() {
    logdriver info "Start the Apache health check."
    
    # Number of repetitions
    repetitions=2
    i=0
    while :
    do
        i=`expr 1 + ${i}`
        logdriver debug "Number of checks: ${i}"
        if is_disable_httpd ; then
            logdriver info "Apache is ok"
            break
        fi
        logdriver error "Apache is not running. Try restart..."
        logdriver debug "Response from slack: `send_to_slack \"Apache is not running.\nTry restart.\"`"
        sudo systemctl restart httpd #★ここ
        logdriver debug "restart command done."
        if (( ${i} == ${repetitions})); then
            logdriver error "Can not restart apache."
            logdriver debug "Response from slack: `send_to_slack \"Apache is not running.\"`"
            exit 1
        fi
    done
    logdriver info "Done."
}

main

この星マークが付いているところなんですが、もしコマンドが正常に実行できていないとしたらどうでしょう?「apacheのプロセスを確認して、落ちていたらslack通知しつつ再起動、それでも起動しなかったら再度slack通知する」という要件を満たせていないですね。このように要件やshellスクリプトの実行に影響を及ぼすような箇所はエラーチェックを行った方が良いです。

終了コードを活用しよう

通常、shellはコマンドが失敗すると終了コードが変わります。終了コードは$?で確認できます。実際に見てみましょう。

$ sudo systemctl restart httpd
$ echo $?
0
$ sudo systemctl restart htttttt
Failed to restart htttttt.service: Unit not found.
$ echo $?
5
$ systemctl restart httpd
echo $?
1

失敗した理由別に値が変わっていますね。この値ごとに処理を考えるのがエラーハンドリングです。今回は再起動をしなければならないので、シンプルに終了コードが0以外は全て失敗とみなしましょう。次にsystemctlコマンドについて考えます。これはsystemdと呼ばれるもののコマンドでサーバ内で動いているプロセス(デーモン)の管理を助けてくれます。systemctlコマンドの細かい説明は省きますが、実態としては/usr/sbin/httpdコマンドのショートカットのようなものです。

$ sudo /usr/sbin/httpd -h
Usage: /usr/sbin/httpd [-D name] [-d directory] [-f file]
                       [-C "directive"] [-c "directive"]
                       [-k start|restart|graceful|graceful-stop|stop]
                       [-v] [-V] [-h] [-l] [-L] [-t] [-T] [-S] [-X]

何らかの原因でsystemctlコマンドが使えないだけでapacheの操作が行えなくなります。よってより直接的にapacheを操作できる/usr/sbin/httpdコマンドを直接実行した方が良いでしょう。これらを踏まえて次のようにコードを修正します。

   sudo /usr/sbin/httpd -k restart
    if ((0 < $?)) ; then
        logdriver error "Failed restart command..."
        exit 1
    fi
    logdriver info "restart command done."

このロジックが正しく動作するか気になりますね。それとapacheの再起動プロセスやエラーハンドリングは変更が入るかもしれません。こういう時は処理を外出ししましょう。

#!/bin/bash

cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source is_disable_httpd.sh
source logdriver.sh
source send_to_slack.sh
source restart_httpd.sh  #★ここ

main() {
    logdriver info "Start the Apache health check."
    
    # Number of repetitions
    repetitions=2
    # Number of seconds to wait after Apache restart
    wait_seconds=10

    i=0
    while :
    do
        i=`expr 1 + ${i}`
        logdriver debug "Number of checks: ${i}"
        if is_disable_httpd ; then
            logdriver info "Apache is ok"
            break
        fi
        logdriver error "Apache is not running. Try restart..."
        logdriver debug "Response from slack: `send_to_slack \"Apache is not running.\nTry restart.\"`"
        restart_httpd  #★ここ
        logdriver debug "waiting for the restart...: ${wait_seconds}sec"
        sleep ${wait_seconds}s
        if (( ${i} == ${repetitions})); then
            logdriver error "Can not restart apache."
            logdriver debug "Response from slack: `send_to_slack \"Apache is not running.\"`"
            exit 1
        fi
    done
    logdriver info "Done."
}

main

ちなみに今回は手っ取り早くsudoを付けてapache再起動コマンドを記述していますが、これはshellスクリプトを実行するユーザにapache再起動コマンドを実行できるような権限を付与した方が良いかと思います。

エラーが起きたら止まるようにしよう

通常shellスクリプトは失敗しても止まりません。先ほどのsystemctl状態で検証してみますが、sudoを消して実行してみます。

#!/bin/bash

cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source is_disable_httpd.sh
source logdriver.sh
source send_to_slack.sh

main() {
    logdriver info "Start the Apache health check."
    
    # Number of repetitions
    repetitions=2
    i=0
    while :
    do
        i=`expr 1 + $i`
        logdriver debug "Number of checks: $i"
        if is_disable_httpd ; then
            logdriver info "Apache is ok"
            break
        fi
        logdriver error "Apache is not running. Try restart..."
        logdriver debug "Response from slack: `send_to_slack \"Apache is not running.\nTry restart.\"`"
        systemctl restart httpd # ★ここ
        logdriver debug "restart command done."
        if (( $i == $repetitions)); then
            logdriver error "Can not restart apache."
            logdriver debug "Response from slack: `send_to_slack \"Apache is not running.\"`"
            exit 1
        fi
    done
    logdriver info "Done."
}

main

ログは次のように出ました。

2020/05/14 15:02:47 [info] Start the Apache health check.
2020/05/14 15:02:47 [debug] Number of checks: 1
2020/05/14 15:02:47 [error] Apache is not running. Try restart...
2020/05/14 15:02:48 [debug] Response from slack: ok
2020/05/14 15:02:48 [debug] restart command done.
2020/05/14 15:02:48 [debug] Number of checks: 2
2020/05/14 15:02:48 [error] Apache is not running. Try restart...
2020/05/14 15:02:48 [debug] Response from slack: ok
2020/05/14 15:02:48 [debug] restart command done.
2020/05/14 15:02:48 [error] Can not restart apache.
2020/05/14 15:02:48 [debug] Response from slack: ok

systemctlの再起動コマンドが失敗していますが、処理が続いているのが分かります。今回は特に問題ないですが、場合によっては処理が失敗しているのに後続処理が続いて思わぬ結果をもたらしてしまう恐れがあります。これはset -eをつけるとエラーが起きたら止まるようにすることができます。

#!/bin/bash
set -e

cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source is_disable_httpd.sh
source logdriver.sh
source send_to_slack.sh

main() {
〜〜省略〜〜
}

main

特別、エラーハンドリングをする予定がなければ、set -eを付けておいて、何か問題があればshellスクリプトが止まるようにしておいた方が良いでしょう。

なるべく環境が変わっても動くようにしよう

shellスクリプトは環境変化に弱いスクリプトです。それぞれのソフトウェア(コマンド)の寄せ集めですのでyum updateみたいなコマンドで挙動が壊れてしまう恐れがあります。どうしようもない部分はありますが、出来るだけ環境が変わっても動くように最低限のことには気をつけましょう。

シバン

先ほどからshellスクリプトの1行目に書いていたコードですが、シバン(shebang)と呼びます。これはどのインタプリンタで実行するか指定するものですが、shellにも色々ある(bash, ash, zsh等)ので、せめてどのshellで起動する前提なのかはコードに組み込んでおくと良いでしょう。

#!/bin/bash

lang変数

文字列処理をshellスクリプトで行うというのはよくある事ですが(今回扱ったapacheのヘルスチェックもgrepしてましたね)、実行環境によって設定してある言語設定が変わって文字列処理がうまく通らなくなるというのはよくある話です。例えばdateコマンドでも環境に設定されている言語が違うだけで次のように出力が変わってしまいます。

$ LANG=ja_JP.utf8
$ date
2020年  5月 21日 木曜日 13:47:59 UTC
$ LANG=en_US.UTF-8
$ date
Thu May 21 13:48:11 UTC 2020

LANGという環境変数で環境で使用する言語を固定できるので基本的に実行言語は固定しておいた方が良いでしょう。

環境固有のコマンドは避ける

例えばパッケージマネージャーとかですね。yumはredhat系ですしaptはdebian系のコマンドになります(shellスクリプトを違うOSで使用するというのは考えにくいですが…)。またjqコマンド等、よく使いますが標準でインストールされていないコマンドになります。こういったものは環境を変えたことによってスクリプトが予定外の挙動をしたりしますので要注意です。requireとして別途ドキュメントで管理するか、別途必要なパッケージと依存関係を解決してくれる仕組みを導入しましょう(他の言語だとこの辺りは楽ですね)。

最後に

いかがでしたでしょうか。今回はshell(bash)スクリプトを中心にTipsを扱いましたが、基本的な考え方は他言語のプログラミングでも活かせるところがあるかと思います。最初にも申し上げました通り、決してベストプラクティスだとは思っていませんが、どれか1つでも皆さんのプログラミングスキルに貢献できたなら嬉しいです。

以上、深澤(@shun_quartet)でした!