投稿日:
更新日:

Shell Script ベストプラクティス

Authors

目次

banner.png

はじめに

Shell Script は UNIX 系システムにおいて高度な自動化を実現するための非常に強力なツールで、トイル(Toil)の撲滅 に繋がります。

トイルとは、反復的で非創造的な作業のことを指します。 これには、例えば、手動でのシステムのスケーリングや、エラーのトラブルシューティング、ルーチンメンテナンス等が含まれます。 トイルを特定し、それを自動化することで、エンジニアはより創造的なタスクやプロジェクトに焦点を合わせることができます。

O'Reilly の Site Reliability Engineering 本によれば、トイルを判別する方法として、次のような基準が挙げられています。

oreilly-site-reliability-engineering.png
基準概要
1. 手作業である自動 or 半自動スクリプトでも手動で起動する場合はトイルに該当
2. 繰り返し発生する数回ではなく、定期的・継続的に発生
3. 自動化が可能スクリプトやツールで代替できる
4. 戦術的である想定外の対応や技術的負債の対応等、戦略とは無関係なタスク
5. 長期的な価値が無い一時的な解決策で、サービスの品質や拡張性に貢献しない
6. サービスの成長と共に増える利用者やトラフィックの増加に比例して作業が肥大化する( O(N)O(N)

上記の項目に該当する作業は、Shell Script による自動化によって効率よく軽減することが可能です。

今回のブログでは、Shell Script を使った効率的な自動化の実践方法と、トイルを削減するベストプラクティスについて、Google から提供されている Shell Style Guide を参考に紹介したいと思います。

google-shell-style-guide.png

Shell Script とは

Shell Script とは、UNIX/Linux 等の OS において Shell 上で実行するコマンドを、1 行ずつ記述・実行できる「自動化されたプログラムファイル」のことです。

主に bash(Bourne Again Shell)等の Shell 環境で動作し、システム操作を自動化できます。

構造と基本的な構文

シェルスクリプトの基本構成は以下のようになっています。

#!/bin/bash

echo "Hello, world!" # ← 実行するコマンド

#!/bin/bash を Shebang(シバン)と呼び、実行 Shell をカーネルに命令します。

スクリプトファイル(例:myscript.sh)に上記の内容を書き込み、以下のように実行します。

$ sudo chmod +x myscript.sh # 実行権限を付与
$ ./myscript.sh             # Shell Script を実行

シェルスクリプトでできること

シェルスクリプトは、主に次のような処理に用いられます。

用途具体例
ファイル操作ファイルの作成・コピー・削除・移動
システム管理ユーザ管理、サービスの監視や起動・停止
バックアップ・メンテナンス処理一定期間毎にログを保存・古いログを削除
ネットワークタスクping, curl, scp を自動で定期実行
バッチ処理一括ユーザ登録、CSV データ処理
CI/CD の自動化(DevOps)デプロイスクリプト(ビルド → テスト → 配信)

なぜ Shell Script で自動化するのか

適切に設計された Shell Script によって、日々の手作業を排除し、ヒューマンエラーを減らし、作業の再現性を確保できます。

  • コマンドラインツールとの高い親和性
  • ファイル操作、サービス制御、ログ処理等に適している
  • 学習コストが低い
  • 多くの UNIX 系システムでデフォルトサポートされている

また、Google では以下のような理由から、Shell Script を書く場合は必ず Bash(#!/bin/bash)を使用するようです。

  • 全てのマシンに Bash が標準搭載されている
  • Bash 独自機能(配列・正規表現・[[ ]] 構文等)を活用できる
  • POSIX 互換を意識する必要が無い

スクリプトの基本構成

  • 他のユーティリティの呼び出しが主な役割(ラッパスクリプト等)
  • 100 行以下で完結できる
  • 高速性が求められない場合

複雑な制御フローや大規模なロジックとなった場合は、Go や Python 等、より構造化された言語への移行が推奨されています。

また、セキュリティ上の理由から Shell Script での SUID/SGID の利用を禁止し、代わりに sudo を使用することが推奨されています。

ここで、SUID(Set User ID)/ SGID(Set Group ID)とは、Unix 系の OS における特殊なファイル実行権限の一種です。 SUID/SGID は強力な特権を持つため、誤用すると深刻なセキュリティ問題を引き起こします。 さらに、権限昇格(Privilege Escalation)の原因となる脆弱性の温床にもなります。

Shell Style Guide by Google

Google の Style Guide で紹介されている Tips をいくつか紹介したいと思います。

コメントルール

ファイル冒頭

スクリプトの先頭に簡単な説明コメントを記述します。

#!/bin/bash
#
# Oracle データベースのバックアップ処理を実行するスクリプト

関数コメント

関数コメントは次の形式で記述します。

#######################################
# 実行する処理の説明
# Globals:
#   使用/書き換えするグローバル変数
# Arguments:
#   関数が受け取る引数
# Outputs:
#   標準出力または標準エラーに出力するもの
# Returns:
#   関数が返す値(戻り値)
#######################################

実装に関するコメント

複雑な処理や読みづらいロジックにはコメントを追加します。

TODO コメント

改善予定箇所には、書いた本人の名前付きで TODO を追記します。

# TODO(@ren510dev): エラーハンドリングを追加する

書式とフォーマット

インデント

インデントは 2 スペースで統一 し、タブは使用しません。

行の長さ制限

1 行は 80 文字以内が原則 で、長い文字列やコマンドはヒアドキュメントや改行 \ を使って複数行に分割します。

cat <<END
これは長めの文字列を
複数行で記述する例です。
END

パイプ処理の書き方

1 行に収まらない場合は、パイプ毎に改行します。

command1 \
  | command2 \
  | command3

制御構文の書き方

thendo は同じ行に記述し、fidone は縦に揃えます。

if [[ condition ]]; then
  action
else
  other_action
fi

コーディング実践ルール

変数の展開と引用

  • 必ず "${var}" のようにクォートしてバグを回避します。
  • $* の代わりに "$@" を使用します。
  • 特殊変数($?, $#, $$ 等)も基本はクォートします。

[[ ]] の使用

条件判定には、パス展開や空白分割を防げるため [...] の代わりに [[...]] を使用します。

if [[ -n "${var}" ]]; then
  echo "値あり"
fi

文字列テスト

文字列が空かどうかのテストには -z または -n を使用します。

if [[ -z "${my_var}" ]]; then
  echo "空です"
fi

配列の使用

引数や要素のリストには配列を使用します。

また、文字列結合でのリスト生成は非推奨です。

declare -a files=(file1.txt file2.txt)
mycommand "${files[@]}"

関数と変数の命名規則

関数名

『スネークケース(小文字 + アンダースコア)』を使用します。

hello_world() {
  echo "Hello, world!"
}

グローバル変数・定数

『すべて大文字 + アンダースコア』で記述します。

また、readonlyexport を使って明示的に定義します。

readonly OUTPUT_PATH="/tmp/result.txt"
export DB_USER="admin"

ローカル変数

関数内では local を使ってスコープを限定します。

my_function() {
  local name="tanaka"
  echo "${name}"
}

コマンド実行とエラーハンドリング

コマンド実行後には結果コードで成功/失敗を明示的に判定します。

if ! cp "src.txt" "dest.txt"; then
  echo "コピーに失敗しました" >&2
  exit 1
fi

パイプ処理では PIPESTATUS を使って各工程のステータスを確認できます。

tar cf - . | (cd /tmp && tar xf -)
if (( PIPESTATUS[0] != 0 || PIPESTATUS[1] != 0 )); then
  echo "パイプエラー"
fi

一貫性を保つことの重要性

Shell Script に限らず、コーディングをする上で最も重要なのは「一貫性」です。 異なるコーディングスタイルが混在すると、保守の手間やバグの原因に繋がります。

  • 同じスクリプト内でスタイルを統一する
  • チームでスタイルガイドを共有する
  • 自動検査ツール(Shell Check 等)を導入する

例えば、VSCode では shell-format のようなフォーマッタツールも用意されています。

foxundermoon-shell-format.png
{
  "[shell script]": {
    "editor.tabSize": 2,
    "editor.defaultFormatter": "foxundermoon.shell-format",
    "editor.formatOnSave": true
  },
  "shellcheck.enable": true
}

推奨される書き方

Sreake の記事を参考に、Shell Script で推奨される書き方(Tips)をいくつかまとめたいと思います。

エラーハンドリング

1. エラーで即座に停止

set -e を使用することでスクリプトを、その時点で即座に終了させることができます。

#!/bin/bash

set -e
set -o pipefail

# このスクリプトはエラーが発生したらすぐに停止します
echo "Starting the script"
non_existent_command # この行でスクリプトは停止します
echo "This line will never be executed"

set -e を使用すると、スクリプトはエラーが発生した時点で即座に停止します。

また、set -o pipefail を追加することでパイプラインの一部でエラーが発生した場合も確実にスクリプトを停止させることができます。

2. トラップを使用したクリーンアップ

function cleanup() {
  echo "Cleaning up temporary files..."
  rm -f /tmp/tempfile_$$
}

trap cleanup EXIT

# スクリプトの主要な処理
echo "Creating temporary file..."
touch /tmp/tempfile_$$
# ... その他の処理 ...

trap コマンドを使用することで、スクリプトが終了する際(正常終了でもエラー終了でも)に特定の処理を実行することができます。 これは一時ファイルの削除等のクリーンアップ処理に特に有用です。

3. 構造化ログの実装

何が起こったのかを知るためにはログが重要になります。 構造化ログを実装することで、イベントの詳細を効率的に記録し、分析できます。 日時、ログレベル、メッセージを含む一貫したフォーマットを使用し、JSON 等の機械可読形式で出力することで、ログの検索や集計が容易になります。

log() {
  local level="$1"
  shift
  echo "$(date '+%Y-%m-%d %H:%M:%S') [$level] $*"
}

# 使用例
log INFO "Script started"
log DEBUG "Processing file: $filename"
log ERROR "Failed to connect to the database"

構造化ログを使用することで、ログの解析や問題の追跡が容易になります。

4. 再実行可能なスクリプトを書く

冪等性のあるコードを書くことが重要です。 これは、スクリプトを何度実行しても、同じ結果が得られることを意味します。

例えば、ファイルの作成やディレクトリの設定等、システムの状態を変更する操作を行う場合、既に目的の状態になっているかどうかを最初に検証します。 これにより、不要な処理を避け、エラーを防ぐことができます。

また、スクリプトの途中で失敗した場合でも、再実行時に問題なく続きから処理を行えるようになります。 冪等性を意識することで、より信頼性の高い、メンテナンスしやすいスクリプトを作成することができます。

  • ディレクトリの作成をする時
create_directory() {
  if [ ! -d "$1" ]; then
    mkdir -p "$1" && echo "Directory $1 created"
  else
    echo "Directory $1 already exists"
  fi
}

# 使用例
create_directory "/path/to/my/directory"

この関数は、ディレクトリが存在しない場合のみ作成を行います。 これにより、スクリプトを何度実行しても安全です。

  • パッケージのインストールをする時
install_package() {
  if ! dpkg -l | grep -q "^ii  $1"; then
    apt get install -y "$1"
    echo "Package $1 installed"
  else
    echo "Package $1 is already installed"
  fi
}

# 使用例
install_package "nginx"

この関数は、パッケージがまだインストールされていない場合のみインストールを行います。

パフォーマンスの最適化

1. ループの最適化

スクリプトでのループ最適化には、一般に以下の 2 つの方法があります。

  • 方法 1:seq コマンドを使用
for i in $(seq 1 1000000); do
  echo $i >/dev/null
done
  • 方法 2:Bash の算術式を使用
for ((i = 1; i <= 1000000; i++)); do
  echo $i >/dev/null
done

seq コマンドと Bash の算術式はケースバイケースでパフォーマンスが変動する可能性があるため、ループの最適化には以下の点を考慮することが重要です。

  • 実環境での測定の重要性
  • コンテキストと具体的なユースケースの考慮
  • 大規模データを扱う際の別アプローチ(例:ストリーミング処理)の検討
  • ループ構造だけでなく、ループ内の処理も含めた全体的な最適化

一部のケースでは、Bash の算術式よりも、seq コマンドの方がパフォーマンスが良いとされる例も紹介されています。

これは次のような理由によるものと考えられます。

  • seq コマンドの最適化された実装
  • Bash の算術演算の相対的な遅さ
  • メモリとプロセス生成のトレードオフ
  • システムの特性(CPU キャッシュ、メモリ管理等)

スクリプトの最適化は非常に複雑で、直感に反する結果をもたらすことがあります。 常に具体的なユースケースに基づいてベンチマークを取り、その結果に基づいて最適化を行うことが重要です。

2. パイプラインの使用

大きなファイルを処理する場合、パイプラインを使用することでメモリ使用量を抑えつつ効率的に処理することができます。

# ファイルを1行ずつ読み込んで処理
while IFS= read -r line; do
  echo "Processing: $line"
done <large_file.txt

# パイプラインを使用した効率的な処理
cat large_file.txt | while IFS= read -r line; do
  echo "Processing: $line"
done

セキュリティの考慮

1. 入力のサニタイズ

サニタイズとは、ユーザまたは外部データを安全に処理できるようにクリーンな状態に変換(無害化)することです。

ユーザ入力を処理する際は、常に入力をサニタイズし、潜在的な悪意のある入力を防ぐことが重要です。

read -p "Enter a filename: " filename

# 危険な方法(ユーザ入力をそのまま使用)
# cat $filename

# 安全な方法(入力をサニタイズ)
if [[ $filename =~ ^[A-Za-z0-9._-]+$ ]]; then
  cat "$filename"
else
  echo "Invalid filename"
  exit 1
fi

2. 変数の適切な引用

変数を適切に引用することで、予期せぬ動作や潜在的なセキュリティリスクを回避できます。

filename="My Document.txt"

# 悪い例(スペースを含むファイル名で問題が発生する可能性がある)
rm $filename

# 良い例(変数を適切に引用することで、スペースを含むファイル名でも正しく動作する)
rm "$filename"

3. 最小権限の原則

最小権限の原則(PoLP:Principle of Least Privilege) とは、ユーザやアプリケーション、システムが特定のタスクを遂行するために必要なアクセス権限のみを付与するセキュリティ対策です。

スクリプトが root 権限で実行される場合、必要最小限の操作のみを root で行い、それ以外は一般ユーザ権限で実行するようにします。

# 一時的に権限を下げる
if [[ $EUID -eq 0 ]]; then
  original_user=$(logname)
  sudo -u $original_user bash <<EOF
        # 権限を必要としない操作をここで実行
        echo "Running with reduced privileges"
EOF
  # 権限が必要な操作はここで実行
  echo "Running with elevated privileges"
else
  echo "This script must be run as root"
  exit 1
fi

4. 一時ファイルの安全な作成

mktemp コマンドを使用すると、安全に一時ファイルを作成できます。

また、trap コマンドを組み合わせることでスクリプト終了時に確実に一時ファイルを削除できます。

# mktemp を使用して安全に一時ファイルを作成
temp_file=$(mktemp)

# スクリプトの終了時に一時ファイルを削除
trap 'rm -f "$temp_file"' EXIT

# 一時ファイルを使用した処理をここに記述

クロスプラットフォームの考慮

1. 可搬性のある Shebang

#!/usr/bin/env bash

#!/bin/bash の代わりに #!/usr/bin/env bash を使用することで、異なるシステムでも bash のパスを正しく特定できます。

2. コマンドの挙動やオプション解釈に気をつける

Shell(sh, bash, zsh, fish 等)や、コマンドの種別(Bash 組み込みコマンド等)で、文字コードの解釈が異なる場合があります。 特に常用される echo, awk, sed コマンドは BSD 系 と GNU 系で実行時の文字解析・構文解析が異なるため、注意が必要です。

例えば、echo コマンドは、改行の解釈が macOS(BSD 版)と Linux(GNU coreutils 版)で異なります。

# BSD 版
echo "Line1\nLine2"
Line1
Line2

# GNU coreutils 版
echo "Line1\nLine2"
Line1\nLine2 # ← 改行されない

coreutils 版は -e で改行を明示します。

echo -e "Line1\nLine2"
Line1
Line2

-e オプションは BSD 版では使用できません。

echo -e "Line1\nLine2"
-e Line1 # ← -e オプションはサポートされていない
Line2

対応策として、POSIX に準拠した printf コマンドを使用することで、Bash や FreeBSD、busybox 等すべての環境で一貫した動作を保証します。

このように、コマンドによってはオプションの指定方法で挙動が変わるものがあるため、実行環境を考慮して実装することが重要です。

3. OS 依存の処理

#!/usr/bin/env bash

case "$(uname -s)" in
Linux*) machine=Linux ;;
Darwin*) machine=Mac ;;
CYGWIN*) machine=Cygwin ;;
MINGW*) machine=MinGw ;;
*) machine="UNKNOWN:${unameOut}" ;;
esac

echo "Running on $machine"

if [ "$machine" = "Linux" ]; then
  # Linux 固有の処理
elif [ "$machine" = "Mac" ]; then
  # macOS 固有の処理
fi

uname コマンドを使用して実行環境を判別し、OS 固有の処理を適切に分岐させることができます。

テストとデバッグ

1. ユニットテストの導入

シンプルなユニットテストを導入することで、スクリプトの信頼性を向上させることができます。

# テスト対象の関数
add() {
  echo $(($1 + $2))
}

# テスト関数
test_add() {
  result=$(add 2 3)
  if [ "$result" -eq 5 ]; then
    echo "Test passed"
  else
    echo "Test failed"
  fi
}

# テストの実行
test_add

2. デバッグモード

環境変数 DEBUG を設定することで、スクリプトの実行過程を詳細に追跡することができます。

# デバッグモードを有効にする
if [ "$DEBUG" = "true" ]; then
  set -x
fi

# スクリプトの主要な処理
echo "Performing main operation"
# ... その他の処理 ...

# デバッグモードを無効にする
if [ "$DEBUG" = "true" ]; then
  set +x
fi

バージョン管理ツールとの統合

1. GitHub Webhook の活用

GitHub の pre-commit フックを使用して、コミット前に自動的に Shell Script の構文チェックと静的解析を行うことができます。

  • .git/hooks/pre-commit に配置
#!/usr/bin/env bash

# Shell Script の構文チェック
for file in $(git diff --cached --name-only | grep '\\.sh$'); do
  if ! bash -n "$file"; then
    echo "Syntax error in $file"
    exit 1
  fi
done

# Shell Check による静的解析
if command -v shellcheck >/dev/null; then
  for file in $(git diff --cached --name-only | grep '\\.sh$'); do
    if ! shellcheck "$file"; then
      echo "Shell Check failed for $file"
      exit 1
    fi
  done
else
  echo "Shell Check not installed. Skipping."
fi

ケーススタディ

1. ログ解析とレポーティング

Apache のアクセスログを解析し、日次レポートを生成するスクリプトの例です。

このスクリプトは Apache のアクセスログを解析し、総アクセス数、ユニーク訪問者数、最もアクセスの多いページ等の情報を含む日次レポートを生成します。

#!/usr/bin/env bash

log_file="/var/log/apache2/access.log"
report_file="/tmp/apache_daily_report_$(date +%Y%m%d).txt"

# 総アクセス数
total_access=$(wc -l <"$log_file")

# ユニーク訪問者数
unique_visitors=$(awk '{print $1}' "$log_file" | sort -u | wc -l)

# 最もアクセスの多いページ
top_page=$(awk '{print $7}' "$log_file" | sort | uniq -c | sort -rn | head -n 1)

# レポート生成
{
  echo "Apache Daily Report - $(date +%Y-%m-%d)"
  echo "=================================="
  echo "Total Accesses: $total_access"
  echo "Unique Visitors: $unique_visitors"
  echo "Most Accessed Page: $top_page"
} >"$report_file"

echo "Report generated: $report_file"

crontab を設定しておけば、毎日のレポート生成を自動化することができるため、トイルの削減に繋がります。

2. 定期的なバックアップ

特定のディレクトリにおいて、一定期間以内に作成・更新されたファイル(ここでは画像イメージ)を、探索し、.tar.gz 形式で指定ディレクトリに保存し、古いファイルを削除するスクリプトの例です。

#!/usr/bin/env bash

set -euo pipefail

readonly COMPRESS_PERIOD='+30' # 30日以内のファイルを対象
readonly DELETE_PERIOD='+120'  # 120日より古いファイルは削除
readonly IMG_DIR='/path/to/img' # ファイルが保存されている元ディレクトリ
readonly BACKUP_DIR='/path/to/img_backup' # .tar.gz バックアップファイルを保存するディレクトリ
readonly TEMP_DIR='/tmp/backup_workdir' # 一時的にファイルをまとめる作業用ディレクトリ

# バックアップファイル名(例: images_240501123000.tar.gz)
readonly TIMESTAMP
TIMESTAMP="$(date +'%y%m%d%H%M%S')"
readonly FILENAME="images_${TIMESTAMP}.tar.gz"

#---------------------------
# 関数定義
#---------------------------

# 一時ディレクトリの初期化
function prepare_temp_dir() {
  rm -rf "${TEMP_DIR}"
  mkdir -p "${TEMP_DIR}"
}

# 対象ファイルの収集
function collect_target_files() {
  find "${IMG_DIR}" -type f -mtime "${COMPRESS_PERIOD}" -name '*.jpg' ! -name '*.gz' \
    -exec cp --parents {} "${TEMP_DIR}" \;

  echo "$(find "${TEMP_DIR}" -type f | wc -l) 件のファイルを収集しました。"
}

# アーカイブの作成
function create_archive() {
  mkdir -p "${BACKUP_DIR}"
  tar -czf "${BACKUP_DIR}/${FILENAME}" -C "${TEMP_DIR}" .
}

# パーミッションの設定
function set_permissions() {
  chmod -R 700 "${IMG_DIR}"
}

# 古いファイルの削除
function remove_old_files() {
  find "${IMG_DIR}" -type f -mtime "${DELETE_PERIOD}" -exec rm -v {} \;
}

# 一時ディレクトリのクリーンアップ
function clean_up() {
  rm -rf "${TEMP_DIR}"
}

function main() {
  echo "バックアップ開始:${TIMESTAMP}"
  prepare_temp_dir
  collect_target_files
  create_archive
  set_permissions
  remove_old_files
  clean_up
  echo "バックアップ完了"
}

main "$@"

こちらも、crontab を設定しておくことで、定期的なファイルのバックアップタスクを自動化することができます。

まとめ

今回のブログでは、Google の Style Guide を参考に Shell Script の推奨される書き方を紹介しました。

Shell Script による高度な自動化は、エラーハンドリング、パフォーマンス最適化、セキュリティ考慮、クロスプラットフォーム対応、テストとデバッグ、バージョン管理ツールとの統合等、多くの要素を考慮する必要があります。

これらのベストプラクティスとパターンを適用することで、より信頼性が高く、より保守・運用のしやすい効率的な自動化スクリプトを作成することができます。

参考・引用