Bashスクリプトの異なるシステム間での可搬性を確保する

GNU、BSD、BusyBoxの違いに対応した移植性の高いBashスクリプトを、Linux、macOS、CI環境で記述する方法。

異なるシステム間でのBashスクリプトの移植性を確保する

ラップトップ、Linuxサーバー、CIランナーで動作するBashスクリプトを書くのは、見た目以上に難しいものです。Bashスクリプトの移植性は、小さな違いによって壊れることがよくあります。Linuxでは動作するsed -iフラグがmacOSでは失敗する、GNU coreutilsにしか存在しないdateオプション、あるいは/bin/bashがテストしたバージョンであると仮定したスクリプトなどです。

核心的な難しさは、Bashが環境の一部に過ぎないということです。Linuxは通常GNUユーティリティを搭載しています。macOSはBSD系のユーティリティを搭載しています。BusyBoxベースのコンテナは、より少ないオプションでより小さな実装を提供する場合があります。スクリプトは、何を必要とするかを明確にする必要があります。

このガイドは、厳密にPOSIX準拠のshスクリプトではなく、Bashスクリプトに焦点を当てています。真の/bin/sh移植性が必要な場合は、Bash独自の構文を完全に避け、dashなどのシェルでテストしてください。

明確なシェル契約から始める

意図に合ったシバンを使用してください。スクリプトがBashを必要とする場合は、次のように明示します。

#!/usr/bin/env bash

/usr/bin/env$PATHを通じてBashを見つけます。これは、ユーザーが/binの外部に新しいBashをインストールしている場合に便利です。本番ホストでインタプリタのパスを固定する必要がある場合は、そのパスを文書化して強制してください。

Strictモードは多くのミスを早期にキャッチしますが、魔法ではありません。

#!/usr/bin/env bash

set -euo pipefail
IFS=$'\n\t'

これらのオプションは役立ちますが、注意点があります。

  • -e: 多くの単純なコマンドがゼロ以外のステータスを返した場合に終了します。
  • -u: 未設定の変数をエラーとして扱います。
  • pipefail: パイプライン内のいずれかのコマンドが失敗した場合、パイプライン全体を失敗させます。

予期される失敗は明示的に処理してください。

if ! grep -q "ready" "$log_file"; then
    echo "Service is not ready yet"
fi

自分のBashバージョンを把握する

ターゲットシステムにないBashの機能に誤って依存しないでください。macOSは歴史的に/bin/bashに古いBashを搭載しており、多くのLinuxディストリビューションは新しいバージョンを搭載しています。

注意して使用すべき機能は次のとおりです。

  • 連想配列。
  • ** などの高度なグロブ。
  • <(command) などのプロセス置換。
  • 新しいパラメータ展開の動作。

最小限のBashバージョンが必要な場合は、スクリプトの先頭付近でチェックしてください。

if (( BASH_VERSINFO[0] < 4 )); then
    echo "This script requires Bash 4 or newer." >&2
    exit 1
fi

GNU、BSD、BusyBoxの違いに対応する

移植性に関する最大の問題は、Bash自体ではなく、外部コマンドから発生することがよくあります。

sed -i

GNU sedはバックアップ拡張子なしで-iを受け入れます。macOSのBSD sedは、-iの後に拡張子引数が必要です(空文字列であっても)。

file="data.txt"
pattern="s/error/success/g"

case "$(uname -s)" in
    Darwin)
        sed -i '' "$pattern" "$file"
        ;;
    *)
        sed -i "$pattern" "$file"
        ;;
esac

重要なスクリプトでは、一時ファイルに書き込んでから所定の場所に移動する方が安全なパターンです。これにより、インプレース編集の動作に依存することを完全に回避できます。

date

日付計算はシステムによって異なります。

目標 GNU date macOSのBSD date
30日前 date -d "30 days ago" +%Y%m%d date -v-30d +%Y%m%d

スクリプトで複雑な日付計算が必要な場合は、Pythonなどの一貫した依存関係を使用するか、macOSにGNU coreutilsをインストールしてgdateを明示的に呼び出してください。date -dが存在することを暗黙のうちに想定しないでください。

grepfindxargs

可能な限り、広くサポートされているオプションに固執してください。

  • egrepに依存する代わりにgrep -Eを使用してください。
  • PCREサポート付きのGNU grepを確認しない限り、grep -Pは避けてください。
  • GNU実装とBSD実装で異なるfind述語に注意してください。
  • サポートされている場合は、ファイル名にnull区切りのパイプラインを優先してください。
find "$root" -type f -name '*.log' -print0 | xargs -0 rm -f

依存関係とパスを管理する

通常のコマンド検索には$PATHを使用しますが、作業を開始する前に必要なツールをチェックしてください。

check_dependency() {
    if ! command -v "$1" >/dev/null 2>&1; then
        echo "Error: required command '$1' not found." >&2
        exit 1
    fi
}

check_dependency jq
check_dependency curl

whichよりもcommand -vを優先してください。これはBashのシェル組み込み関数であり、スクリプト内でより予測可能な動作をします。

意図的に単語分割を行いたい場合を除き、変数は引用符で囲んでください。

cp "$source_file" "$target_dir/"

これはProject Files/report.txtのようなパスにとって重要であり、予期しない入力によるワイルドカード展開からも保護します。

一時ファイルを安全に使用する

一時的な作業にはmktempを使用してください。シンプルで移植性の高いパターンは、一時ディレクトリを1つ作成し、その中にファイルを置くことです。

tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT

tmp_file="$tmp_dir/output.txt"
some_command > "$tmp_file"

シングルクォートで囲まれたtrapは、$tmp_dirがトラップ実行時まで展開されないようにします。変数はまだスコープ内にあるため、クリーンアップは正しいディレクトリを削除します。

改行コードとファイルシステムの大文字小文字の区別に注意する

Windowsで編集されたスクリプトは、CRLF改行コードを使用する場合があります。一般的な症状は次のとおりです。

/usr/bin/env: bash\r: No such file or directory

エディタを設定してシェルスクリプトをLF改行コードで保存するか、ビルドプロセスでdos2unixを実行してください。

また、ほとんどのLinuxファイルシステムはデフォルトで大文字小文字を区別しますが、macOSのデフォルトのAPFS設定は多くの場合、大文字小文字を区別しないことに注意してください。スクリプトがConfig.ymlに書き込み、後でconfig.ymlを読み取る場合、Macでは動作してもLinuxでは失敗する可能性があります。

サポートするシステムでテストする

移植性を確認する最良の方法は、小さなテストマトリックスです。

  • GNUユーティリティを使用したLinux。
  • BSDユーティリティを使用したmacOS。
  • スクリプトがAlpineやBusyBox環境で実行される場合の最小限のコンテナ。

ShellCheckも実行してください。すべてのプラットフォームの問題をキャッチできるわけではありませんが、ユーザーが気付く前に、多くの引用符、未定義変数、脆弱なコマンドパターンをキャッチします。

まとめ

Bashスクリプトの移植性は、前提条件を明示的にすることから生まれます。シェルを選択し、依存関係をチェックし、変数を引用符で囲み、必要な場合を除いてGNU専用フラグを避け、ユーザーが実行するのと同じオペレーティングシステムでテストしてください。LinuxとmacOSを使用した小さなCIマトリックスは、自動化が本番環境に到達する前に、ほとんどの移植性バグをキャッチします。