シェルスクリプトの実装に潜む 4 つの罠

こんにちは。サーバーサイドエンジニアの伊藤です。

この記事は Enigmo Advent Calendar 2021 の 14 日目の記事です。

みなさんはシェルスクリプトを実装する機会はどのくらいの頻度でありますでしょうか?

私は社内ツールや個人で利用するちょっとしたツールを作成する際に、シェルスクリプトを実装することがあります。 とはいえ、普段の業務では Ruby on Rails を用いて実装をすることが多いので、 シェルスクリプトを実装する機会自体はそれほど多いものではありません。

その為、シェルスクリプト実装時に割と基本的な罠に陥ってしまうことがありました。 

そこでこの記事ではシェルスクリプトの実装に潜む 4 つの罠を共有したいと思います。

1. shebang の記載漏れ

# bad

echo "FOO"

# good

#!/usr/bin/env bash
echo "FOO"

ファイルの 1 行目に shebang を記載することで、プログラムを実行するシェルを明示的に指定することが推奨されます。 というのも、シェルによってはサポートされている機能が異なることがあるため、 スクリプトを実行するシェルを明示的に指定することで意図しない挙動を避けることができます。

ここで 1 つの疑問が発生しました。

シェルスクリプトにおいてshebang の記述は下記の通り複数ありますが、好ましい記述方はどれなのでしょうか?

#!/bin/bash
#!/usr/bin/env bash

一般的には#!/usr/bin/env bash の形式を利用することが推奨されるようです。

絶対パス等で指定をした場合、実行環境によっては指定の場所に実行可能なコマンドが存在しない可能性が考えられます。 上記の方法で記載すると、 $PATH から実行可能なコマンド(この場合 bash)を捜査してくれるのでポータビリティという点でメリットがあります。

さて、少し話が逸れてしまったので閑話休題、 2 つ目の罠に話を移します。

2. 適切な set options を指定しない

# bad

#!/usr/bin/env bash
echo "FOO"

# good

#!/usr/bin/env bash
set -eu
echo "FOO"

-e (errexit)

こちらのオプションを指定することで、シェルスクリプトの途中でエラーが発生した場合その場で直ちにスクリプトを終了します。

大抵のケースではエラーが発生した場合、その場でスクリプトを終了したいことが多いと思いますので有効にしておくことが推奨されます。

ちなみに、エラーを許容したい場合には下記のように記述することで該当のコマンドのみエラーを許容することが可能です。

command1 || true
echo "This line is executed anyway"

-u (nounset)

こちらは変数宣言に関するオプションです。

デフォルトでは宣言していない変数を利用した場合、空文字として扱われてしまいます。 その為、typo 等で未宣言の変数を利用したとしても特にエラーにはならないので、意図しない挙動を招くことがあります。 そういったケースを避けるためこちらのオプションを有効にしておくことをお勧めします。

3. 1行で宣言と代入を行う

# bad
export BAR="$(command2)"

# good
BAR="$(command2)"

export BAR

上記のスクリプトでは command2 の結果が無視されてしまいます。 set -e を指定している場合、本来エラーが発生した箇所でスクリプトは終了します。 ただ、上記のケースでは command2 の結果に関わらず後続の処理が実行されてしまいます。

その為、代入と宣言の箇所を分けることが推奨されます。

4. Double quotes で変数を囲わない

# bad
file="sample file.txt"
ls $file

DIR="$(dirname $0)/.."

# good
file="sample file.txt"
ls "$file"

DIR="$(dirname "$0")/.."

double quotes を利用しないと該当の文字列中にスペースやタブ等を含むケースで、意図しない結果を招くことがあります。 例えば、上記のようにファイル名にスペースを含むケースだと、double quotes で該当の変数を囲わない場合スペースによって samplefile.txt という 2 つの単語に分割されてしまいます。 結果として、 sample file.txt というファイルではなく samplefile.txt を引数に ls コマンドが実行されてしまいます。

このような、意図しない挙動を避ける為、シェルスクリプトでは変数を double quotes で囲むことが推奨されます。

さて、ここまでシェルスクリプトの実装に潜む 4 つの罠について記載してきました。 シェルスクリプトを実装したことがある開発者にとっては、これらのどれもが一度は経験してきた問題なのではないでしょうか?

これらの問題が一般的ではある一方で、全てをマニュアルで人間が気をつけることは難しく労力を要する作業かと思います。 ということで、 shellcheck というツールを利用することをお勧めします。

shellcheck とは?

シェルスクリプト用の静的 Lint ツールです。 こちらを利用することで、上記で説明してきたエラー(罠)を簡単に検知することが可能です。

コマンドを実行すると、下記の通り問題のある箇所を明瞭に指摘してくれます。

>>> shellcheck ./tmp.sh

In ./tmp.sh line 1:
set -eu
^-- SC2148 (error): Tips depend on target shell and yours is unknown. Add a shebang or a 'shell' directive.


In ./tmp.sh line 5:
echo ${baz}
     ^----^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean: 
echo "${baz}"

For more information:
  https://www.shellcheck.net/wiki/SC2148 -- Tips depend on target shell and y...
  https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...

さて、これだけでも記憶を頼りにマニュアルで全てを確認することに比べ、 簡便になったとは思います。

しかし、こちらのコマンドを修正の度に毎度手動実行することは非常に骨の折れる作業であり、非人間的です。 ということで、CI として設定して自動で実行しましょう。

今回はサンプルとして昨年の Advent Calendar 2020 の記事に記載した自作の OATHTOOL ラッパーコマンドshellcheck を CI として追加してみます。

こちらのレポジトリは GitHub 上に存在するので、今回は GitHub Actions を用いて設定していきます。

設定ファイルは下記。

# .github/workflows/main.yml
name: CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  shellcheck:
    name: Shellcheck
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: Run ShellCheck
      uses: ludeeus/action-shellcheck@master
      with:
       scandir: './bin'

shellcheck は既に GitHub Action としてマーケットプレースに存在するので、設定自体は簡単です。 上記のように設定ファイルを 1 つ追加するだけで設定は完了です。

github.com

無事に CI として shellcheck が走るようになりました。

最後まで読んでいただきありがとうございました。

明日の記事の担当は検索エンジニアの伊藤 明大さんです。お楽しみに。

参考


株式会社エニグモ すべての求人一覧

hrmos.co