はじめに

シェルスクリプトの基本構造や制御構文を習得した後、次のステップとして重要なのが「関数」「配列」「デバッグ手法」の理解です。これらを適切に活用することで、再利用可能で保守性の高いシェルスクリプトを作成できるようになります。

関数を使えばコードの重複を排除し、処理を論理的に分割できます。配列を使えば複数のデータを効率的に管理できます。そしてデバッグ手法を知っていれば、スクリプトの問題を素早く特定・解決できます。

本記事では、シェルスクリプトを実務レベルで活用するための実践テクニックを体系的に解説します。関数の定義・呼び出し・スコープの概念から、配列操作、ヒアドキュメント、デバッグ手法、そしてシェルスクリプトのベストプラクティスまでを網羅します。

動作確認環境

本記事のシェルスクリプトは以下の環境で動作確認しています。

項目 内容
OS Ubuntu 24.04 LTS
シェル bash 5.3
カーネル Linux 6.8

WSL2、VirtualBox上のLinux環境、macOS(zsh互換モード)、その他の主要ディストリビューション(AlmaLinux、Debian等)でも同様に動作します。

シェルスクリプトの関数

シェルスクリプトにおける関数は、処理をまとめて再利用可能にするための基本的な構造です。関数を活用することで、コードの可読性と保守性が大幅に向上します。

関数の基本構文

bashでは、関数を定義する方法が2つあります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#!/bin/bash

# 方法1: functionキーワードを使用
function greet {
    echo "こんにちは"
}

# 方法2: 関数名の後に()を使用(POSIX互換)
greet() {
    echo "こんにちは"
}

どちらの方法でも同じ動作をしますが、POSIX互換性を重視する場合は方法2を推奨します。

関数の呼び出し

関数を呼び出すには、関数名をコマンドのように記述します。

1
2
3
4
5
6
7
8
#!/bin/bash

say_hello() {
    echo "Hello, World!"
}

# 関数の呼び出し
say_hello

実行結果は以下のようになります。

1
Hello, World!

関数の定義位置に関する注意

シェルスクリプトでは、関数は呼び出す前に定義されている必要があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#!/bin/bash

# エラー: 関数が定義される前に呼び出している
# my_function  # これはエラーになる

my_function() {
    echo "関数が実行されました"
}

# 正しい: 定義後に呼び出し
my_function

関数の引数と戻り値

関数をより柔軟に使うためには、引数の受け渡しと戻り値の処理を理解する必要があります。

関数に引数を渡す

関数への引数は、スクリプト全体のコマンドライン引数と同様に $1$2$@ などの特殊変数でアクセスします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#!/bin/bash

greet_user() {
    local name="$1"
    local greeting="${2:-こんにちは}"
    echo "${greeting}${name}さん"
}

# 関数の呼び出し
greet_user "田中"
greet_user "佐藤" "おはよう"

実行結果は以下のようになります。

1
2
こんにちは、田中さん
おはよう、佐藤さん

関数内で使える特殊変数

関数内では以下の特殊変数を使用できます。

変数 説明
$1, $2, … 位置パラメータ(引数)
$@ すべての引数(個別の文字列として)
$* すべての引数(単一の文字列として)
$# 引数の個数
$FUNCNAME 現在の関数名

引数の個数をチェックする

関数に必要な引数が渡されているか確認することは、堅牢なスクリプトを書くために重要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/bin/bash

create_user() {
    if [[ $# -lt 2 ]]; then
        echo "エラー: ユーザー名とメールアドレスを指定してください" >&2
        echo "使用法: create_user <ユーザー名> <メールアドレス>" >&2
        return 1
    fi

    local username="$1"
    local email="$2"

    echo "ユーザー作成: ${username} (${email})"
    # 実際のユーザー作成処理...
    return 0
}

# 引数不足で呼び出し
create_user "taro"

# 正しい呼び出し
create_user "taro" "taro@example.com"

実行結果は以下のようになります。

1
2
3
エラー: ユーザー名とメールアドレスを指定してください
使用法: create_user <ユーザー名> <メールアドレス>
ユーザー作成: taro (taro@example.com)

関数の戻り値(return文)

bashの関数は return 文で終了ステータス(0-255の整数)を返します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#!/bin/bash

is_file_exists() {
    local file="$1"
    if [[ -f "$file" ]]; then
        return 0  # 成功(ファイルが存在する)
    else
        return 1  # 失敗(ファイルが存在しない)
    fi
}

# 戻り値を使用した条件分岐
if is_file_exists "/etc/passwd"; then
    echo "/etc/passwd は存在します"
else
    echo "/etc/passwd は存在しません"
fi

# 終了ステータスを直接確認
is_file_exists "/nonexistent"
echo "終了ステータス: $?"

実行結果は以下のようになります。

1
2
/etc/passwd は存在します
終了ステータス: 1

コマンド置換で値を返す

終了ステータス以外の値(文字列など)を返したい場合は、コマンド置換を使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#!/bin/bash

get_current_date() {
    date +"%Y-%m-%d"
}

calculate_sum() {
    local a="$1"
    local b="$2"
    echo $((a + b))
}

# コマンド置換で戻り値を取得
today=$(get_current_date)
echo "今日の日付: ${today}"

result=$(calculate_sum 10 20)
echo "計算結果: ${result}"

実行結果は以下のようになります。

1
2
今日の日付: 2026-01-08
計算結果: 30

ローカル変数とスコープ

シェルスクリプトでの変数のスコープを正しく理解することは、バグを防ぎ、保守性の高いコードを書くために不可欠です。

グローバル変数とローカル変数

デフォルトでは、シェルスクリプトの変数はすべてグローバルスコープを持ちます。関数内で定義した変数も、関数外からアクセスできてしまいます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#!/bin/bash

set_value() {
    value="関数内で設定"
}

value="初期値"
echo "関数呼び出し前: ${value}"

set_value
echo "関数呼び出し後: ${value}"

実行結果は以下のようになります。

1
2
関数呼び出し前: 初期値
関数呼び出し後: 関数内で設定

localキーワードによるローカル変数

local キーワードを使うことで、変数のスコープを関数内に限定できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/bin/bash

set_value() {
    local value="関数内で設定(ローカル)"
    echo "関数内: ${value}"
}

value="初期値"
echo "関数呼び出し前: ${value}"

set_value
echo "関数呼び出し後: ${value}"

実行結果は以下のようになります。

1
2
3
関数呼び出し前: 初期値
関数内: 関数内で設定(ローカル)
関数呼び出し後: 初期値

スコープの階層構造

入れ子になった関数呼び出しでは、ローカル変数は呼び出し元の関数からもアクセスできません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/bin/bash

outer_function() {
    local outer_var="外側の関数の変数"
    inner_function
}

inner_function() {
    # outer_varにはアクセスできない(未定義として扱われる)
    echo "inner_function内でouter_var: ${outer_var:-未定義}"
}

outer_function

実行結果は以下のようになります。

1
inner_function内でouter_var: 未定義

localを使うべき理由

関数内では原則として local を使用することを強く推奨します。

graph TB
    A[変数の宣言] --> B{関数内?}
    B -->|はい| C[localを使用]
    B -->|いいえ| D{他の場所で<br/>参照する?}
    C --> E[スコープが限定され<br/>安全]
    D -->|はい| F[グローバル変数<br/>として宣言]
    D -->|いいえ| G[local相当の<br/>一時変数として扱う]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#!/bin/bash

# 悪い例: グローバル変数の汚染
process_file_bad() {
    filename="$1"
    content=$(cat "$filename")
    # filenameとcontentがグローバルに漏れる
}

# 良い例: ローカル変数の使用
process_file_good() {
    local filename="$1"
    local content
    content=$(cat "$filename")
    echo "$content"
}

配列の操作

bashでは配列を使って複数の値を効率的に管理できます。ファイル一覧の処理やオプションの管理など、多くの場面で活用できます。

配列の宣言と初期化

配列を宣言・初期化する方法はいくつかあります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#!/bin/bash

# 方法1: 直接代入
fruits=("apple" "banana" "cherry")

# 方法2: インデックスを指定して代入
colors[0]="red"
colors[1]="green"
colors[2]="blue"

# 方法3: declare文で明示的に配列として宣言
declare -a numbers
numbers=(1 2 3 4 5)

echo "fruits: ${fruits[@]}"
echo "colors: ${colors[@]}"
echo "numbers: ${numbers[@]}"

実行結果は以下のようになります。

1
2
3
fruits: apple banana cherry
colors: red green blue
numbers: 1 2 3 4 5

配列の要素へのアクセス

配列の要素にアクセスする方法を確認しましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#!/bin/bash

languages=("bash" "python" "javascript" "go" "rust")

# 特定の要素にアクセス(インデックスは0から始まる)
echo "最初の要素: ${languages[0]}"
echo "3番目の要素: ${languages[2]}"

# すべての要素を取得
echo "すべての要素: ${languages[@]}"

# 要素数を取得
echo "要素数: ${#languages[@]}"

# 最後の要素にアクセス(bash 4.2以降)
echo "最後の要素: ${languages[-1]}"

実行結果は以下のようになります。

1
2
3
4
5
最初の要素: bash
3番目の要素: javascript
すべての要素: bash python javascript go rust
要素数: 5
最後の要素: rust

配列のスライス

配列の一部を取り出すスライス操作も可能です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/bin/bash

items=("a" "b" "c" "d" "e" "f")

# インデックス2から2つの要素を取得
echo "スライス(2から2個): ${items[@]:2:2}"

# インデックス3から最後まで
echo "スライス(3から最後): ${items[@]:3}"

# 最初から3つの要素
echo "スライス(最初から3個): ${items[@]:0:3}"

実行結果は以下のようになります。

1
2
3
スライス(2から2個): c d
スライス(3から最後): d e f
スライス(最初から3個): a b c

配列への要素の追加と削除

配列に対する追加・削除操作を確認します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash

# 配列の初期化
tasks=("task1" "task2" "task3")
echo "初期状態: ${tasks[@]}"

# 末尾に追加
tasks+=("task4")
echo "追加後: ${tasks[@]}"

# 特定位置に追加(インデックス10に代入)
tasks[10]="task10"
echo "インデックス10に追加: ${tasks[@]}"
echo "インデックス一覧: ${!tasks[@]}"

# 要素の削除
unset tasks[1]
echo "削除後: ${tasks[@]}"
echo "インデックス一覧: ${!tasks[@]}"

実行結果は以下のようになります。

1
2
3
4
5
6
初期状態: task1 task2 task3
追加後: task1 task2 task3 task4
インデックス10に追加: task1 task2 task3 task4 task10
インデックス一覧: 0 1 2 3 10
削除後: task1 task3 task4 task10
インデックス一覧: 0 2 3 10

配列のループ処理

配列の全要素に対してループ処理を行う方法を確認します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#!/bin/bash

servers=("web01" "web02" "db01" "cache01")

# for-inループで要素を処理
echo "=== サーバー一覧 ==="
for server in "${servers[@]}"; do
    echo "- ${server}"
done

# インデックス付きでループ
echo ""
echo "=== インデックス付き ==="
for i in "${!servers[@]}"; do
    echo "[${i}] ${servers[$i]}"
done

実行結果は以下のようになります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
=== サーバー一覧 ===
- web01
- web02
- db01
- cache01

=== インデックス付き ===
[0] web01
[1] web02
[2] db01
[3] cache01

連想配列(bash 4.0以降)

bash 4.0以降では、キーと値のペアを持つ連想配列を使用できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#!/bin/bash

# 連想配列の宣言(declare -Aが必須)
declare -A user_info

# 値の設定
user_info["name"]="田中太郎"
user_info["email"]="tanaka@example.com"
user_info["role"]="admin"

# 値の取得
echo "名前: ${user_info["name"]}"
echo "メール: ${user_info["email"]}"
echo "役割: ${user_info["role"]}"

# すべてのキーを取得
echo "キー一覧: ${!user_info[@]}"

# すべての値を取得
echo "値一覧: ${user_info[@]}"

# ループ処理
echo ""
echo "=== ユーザー情報 ==="
for key in "${!user_info[@]}"; do
    echo "${key}: ${user_info[$key]}"
done

実行結果は以下のようになります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
名前: 田中太郎
メール: tanaka@example.com
役割: admin
キー一覧: role email name
値一覧: admin tanaka@example.com 田中太郎

=== ユーザー情報 ===
role: admin
email: tanaka@example.com
name: 田中太郎

配列の実践例:ファイル一覧の処理

配列を使ったファイル処理の実践例を見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/bash

# カレントディレクトリのファイル一覧を配列に格納
files=(*.sh)

# ファイルが存在しない場合のチェック
if [[ "${files[0]}" == "*.sh" ]]; then
    echo "シェルスクリプトファイルが見つかりません"
    exit 0
fi

echo "見つかったシェルスクリプト: ${#files[@]}個"
echo ""

for file in "${files[@]}"; do
    if [[ -f "$file" ]]; then
        line_count=$(wc -l < "$file")
        echo "${file}: ${line_count}行"
    fi
done

ヒアドキュメント

ヒアドキュメント(Here Document)は、複数行のテキストをコマンドに渡すための便利な機能です。設定ファイルの生成やSQL文の実行など、多くの場面で活用できます。

ヒアドキュメントの基本構文

ヒアドキュメントは << に続けて終端文字列(デリミタ)を指定します。

1
2
3
4
5
6
7
#!/bin/bash

cat << EOF
これはヒアドキュメントです。
複数行のテキストを
そのまま出力できます。
EOF

実行結果は以下のようになります。

1
2
3
これはヒアドキュメントです。
複数行のテキストを
そのまま出力できます。

変数展開とヒアドキュメント

デフォルトでは、ヒアドキュメント内で変数展開が行われます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/bin/bash

name="田中太郎"
date_today=$(date +"%Y年%m月%d日")

cat << EOF
お知らせ

${name}様

本日(${date_today})の予定をお知らせします。
- 10:00 ミーティング
- 14:00 レビュー
EOF

実行結果は以下のようになります。

1
2
3
4
5
6
7
お知らせ

田中太郎様

本日(2026年01月08日)の予定をお知らせします。
- 10:00 ミーティング
- 14:00 レビュー

変数展開を無効にする

終端文字列をクォートで囲むと、変数展開が無効になります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#!/bin/bash

name="田中太郎"

# シングルクォートで囲む
cat << 'EOF'
変数 $name は展開されません。
コマンド置換 $(date) も展開されません。
EOF

echo "---"

# ダブルクォートでも同様
cat << "HEREDOC"
こちらも $name は展開されません。
HEREDOC

実行結果は以下のようになります。

1
2
3
4
変数 $name は展開されません。
コマンド置換 $(date) も展開されません。
---
こちらも $name は展開されません。

インデントを許容するヒアドキュメント

<<- を使用すると、行頭のタブ文字が削除されます。これにより、スクリプト内でインデントを揃えつつ、出力では余計な空白を除去できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/bin/bash

generate_config() {
    cat <<-EOF
	server:
	  host: localhost
	  port: 8080
	database:
	  name: myapp
	  user: admin
	EOF
}

generate_config

実行結果は以下のようになります。

1
2
3
4
5
6
server:
  host: localhost
  port: 8080
database:
  name: myapp
  user: admin

注意: <<- はタブ文字のみを削除します。スペースは削除されません。

ヒアドキュメントをファイルに書き込む

ヒアドキュメントとリダイレクトを組み合わせて、設定ファイルを生成できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/bin/bash

APP_NAME="MyApplication"
APP_PORT="3000"
DB_HOST="localhost"
DB_NAME="myapp_db"

# 設定ファイルを生成
cat << EOF > /tmp/app_config.txt
# アプリケーション設定ファイル
# 自動生成: $(date)

[application]
name = ${APP_NAME}
port = ${APP_PORT}

[database]
host = ${DB_HOST}
name = ${DB_NAME}
EOF

echo "設定ファイルを生成しました:"
cat /tmp/app_config.txt

ヒアストリング

1行の文字列を渡す場合は、ヒアストリング(<<<)が便利です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#!/bin/bash

# ヒアストリングで文字列を渡す
grep "error" <<< "info: started
error: failed to connect
info: retrying"

# 変数の内容を渡す
log_message="warning: disk space low"
cat <<< "$log_message"

実行結果は以下のようになります。

1
2
error: failed to connect
warning: disk space low

シェルスクリプトのデバッグ手法

シェルスクリプトの開発では、問題の原因を特定するためのデバッグが不可欠です。bashには強力なデバッグ機能が組み込まれています。

デバッグの全体像

シェルスクリプトのデバッグには複数のアプローチがあります。

graph TB
    A[シェルスクリプト<br/>デバッグ] --> B[実行トレース]
    A --> C[エラー検出]
    A --> D[静的解析]
    B --> B1[set -x]
    B --> B2[bash -x]
    C --> C1[set -e]
    C --> C2[set -u]
    C --> C3[set -o pipefail]
    D --> D1[ShellCheck]

set -x によるトレース

set -x は実行されるコマンドを表示する最も基本的なデバッグ機能です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/bin/bash

set -x  # トレースを有効化

name="テスト"
echo "開始: ${name}"

for i in 1 2 3; do
    echo "カウント: ${i}"
done

set +x  # トレースを無効化

echo "トレース終了"

実行結果は以下のようになります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
+ name=テスト
+ echo '開始: テスト'
開始: テスト
+ for i in 1 2 3
+ echo 'カウント: 1'
カウント: 1
+ for i in 1 2 3
+ echo 'カウント: 2'
カウント: 2
+ for i in 1 2 3
+ echo 'カウント: 3'
カウント: 3
+ set +x
トレース終了

コマンドラインからトレースを有効化

スクリプトを変更せずにトレースを有効にすることもできます。

1
2
3
4
5
# スクリプト全体をトレース
bash -x script.sh

# 詳細なトレース(変数展開前も表示)
bash -xv script.sh

set -e によるエラー時の即座終了

set -e は、コマンドが失敗(終了ステータスが0以外)した時点でスクリプトを終了させます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#!/bin/bash

set -e  # エラー時に即座終了

echo "ステップ1: 開始"
ls /etc/passwd  # 成功
echo "ステップ2: passwdファイル確認完了"

ls /nonexistent  # 失敗 → ここでスクリプト終了
echo "ステップ3: この行は実行されません"

実行結果は以下のようになります。

1
2
3
4
ステップ1: 開始
/etc/passwd
ステップ2: passwdファイル確認完了
ls: '/nonexistent' にアクセスできません: そのようなファイルやディレクトリはありません

set -u による未定義変数のエラー化

set -u は、未定義の変数を参照した場合にエラーとします。

1
2
3
4
5
6
7
8
9
#!/bin/bash

set -u  # 未定義変数をエラーに

defined_var="定義済み"
echo "定義済み変数: ${defined_var}"

# 未定義変数を参照 → エラー
echo "未定義変数: ${undefined_var}"

実行結果は以下のようになります。

1
2
定義済み変数: 定義済み
./script.sh: 行 9: undefined_var: 未割り当ての変数です

set -o pipefail によるパイプラインのエラー検出

デフォルトでは、パイプラインの終了ステータスは最後のコマンドのステータスになります。set -o pipefail を設定すると、パイプライン内のいずれかのコマンドが失敗した場合にエラーとなります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/bin/bash

# pipefailなしの場合
echo "=== pipefailなし ==="
cat /nonexistent 2>/dev/null | head -1
echo "終了ステータス: $?"

# pipefailありの場合
echo ""
echo "=== pipefailあり ==="
set -o pipefail
cat /nonexistent 2>/dev/null | head -1
echo "終了ステータス: $?"

実行結果は以下のようになります。

1
2
3
4
5
=== pipefailなし ===
終了ステータス: 0

=== pipefailあり ===
終了ステータス: 1

推奨されるデバッグ設定の組み合わせ

本番環境で使用するスクリプトには、以下の設定を冒頭に記述することを強く推奨します。

1
2
3
4
5
6
7
8
#!/bin/bash

set -euo pipefail

# または、より明示的に
set -e          # エラー時に終了
set -u          # 未定義変数をエラーに
set -o pipefail # パイプラインのエラーを検出

これにより、多くのバグを早期に検出できます。

デバッグ用の関数を作成する

デバッグ出力を制御するための関数を作成すると便利です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/bin/bash

DEBUG=${DEBUG:-0}

debug_log() {
    if [[ "$DEBUG" -eq 1 ]]; then
        echo "[DEBUG] $*" >&2
    fi
}

info_log() {
    echo "[INFO] $*"
}

error_log() {
    echo "[ERROR] $*" >&2
}

# 使用例
info_log "処理を開始します"
debug_log "詳細: 変数の値を確認中..."

if [[ -f "/etc/passwd" ]]; then
    debug_log "/etc/passwd が見つかりました"
    info_log "ユーザー情報を読み込みます"
else
    error_log "/etc/passwd が見つかりません"
fi

通常実行とデバッグモードでの実行結果を比較します。

1
2
# 通常実行
./script.sh
1
2
[INFO] 処理を開始します
[INFO] ユーザー情報を読み込みます
1
2
# デバッグモードで実行
DEBUG=1 ./script.sh
1
2
3
4
[INFO] 処理を開始します
[DEBUG] 詳細: 変数の値を確認中...
[DEBUG] /etc/passwd が見つかりました
[INFO] ユーザー情報を読み込みます

ShellCheckによる静的解析

ShellCheckは、シェルスクリプトの問題を静的に検出するツールです。

1
2
3
4
5
# インストール(Ubuntu/Debian)
sudo apt install shellcheck

# インストール(CentOS/RHEL)
sudo yum install shellcheck

使用例を見てみましょう。

1
2
3
4
5
6
7
#!/bin/bash
# problematic.sh

files=$(ls *.txt)
for f in $files; do
    cat $f
done

ShellCheckで解析すると、以下のような警告が表示されます。

1
shellcheck problematic.sh
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
In problematic.sh line 4:
files=$(ls *.txt)
        ^------^ SC2012: Use find instead of ls to better handle non-alphanumeric filenames.

In problematic.sh line 5:
for f in $files; do
         ^----^ SC2206: Quote to prevent word splitting/globbing, or split robustly with mapfile or read -a.

In problematic.sh line 6:
    cat $f
        ^-- SC2086: Double quote to prevent globbing and word splitting.

修正後のコードは以下のようになります。

1
2
3
4
5
6
#!/bin/bash
# fixed.sh

for f in *.txt; do
    [[ -f "$f" ]] && cat "$f"
done

シェルスクリプトのベストプラクティス

実務で保守性の高いシェルスクリプトを書くためのベストプラクティスを紹介します。

スクリプトのテンプレート

すべてのシェルスクリプトで使用できる推奨テンプレートを示します。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
#!/bin/bash
#
# スクリプト名: example_script.sh
# 説明: このスクリプトの目的を記載
# 使用法: ./example_script.sh [オプション] <引数>
# 作成日: 2026-01-08
# 更新日: 2026-01-08
#

set -euo pipefail

# グローバル変数(定数)
readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
readonly LOG_FILE="/tmp/${SCRIPT_NAME}.log"

# 関数定義
usage() {
    cat << EOF
使用法: ${SCRIPT_NAME} [オプション] <引数>

説明:
    このスクリプトの説明をここに記載します。

オプション:
    -h, --help      このヘルプを表示
    -v, --verbose   詳細出力モード
    -d, --dry-run   実際の処理を行わず、実行内容を表示

引数:
    <引数>          必須の引数の説明

例:
    ${SCRIPT_NAME} -v input.txt
    ${SCRIPT_NAME} --dry-run config.yml

EOF
}

log_info() {
    echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') $*"
}

log_error() {
    echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2
}

cleanup() {
    # 終了時のクリーンアップ処理
    log_info "クリーンアップを実行中..."
    # 一時ファイルの削除など
}

# trapでクリーンアップを登録
trap cleanup EXIT

# メイン処理
main() {
    local verbose=0
    local dry_run=0

    # オプション解析
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -h|--help)
                usage
                exit 0
                ;;
            -v|--verbose)
                verbose=1
                shift
                ;;
            -d|--dry-run)
                dry_run=1
                shift
                ;;
            -*)
                log_error "不明なオプション: $1"
                usage
                exit 1
                ;;
            *)
                break
                ;;
        esac
    done

    # 引数チェック
    if [[ $# -lt 1 ]]; then
        log_error "引数が不足しています"
        usage
        exit 1
    fi

    local input_file="$1"

    log_info "処理を開始します: ${input_file}"

    # ここにメイン処理を記述

    log_info "処理が完了しました"
}

# スクリプトのエントリーポイント
main "$@"

変数のクォート

変数は常にダブルクォートで囲むことを原則とします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/bash

filename="my file.txt"

# 悪い例: クォートなし(スペースで分割される)
# cat $filename  # エラーになる

# 良い例: クォートあり
cat "$filename"

# 配列の展開も同様
files=("file 1.txt" "file 2.txt")

# 悪い例
# for f in ${files[@]}; do

# 良い例
for f in "${files[@]}"; do
    echo "$f"
done

コマンド置換の推奨書式

コマンド置換には、バッククォートではなく $() を使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#!/bin/bash

# 悪い例: バッククォート(ネストしにくい、可読性が低い)
# date_str=`date +%Y-%m-%d`

# 良い例: $()構文
date_str=$(date +%Y-%m-%d)

# ネストも容易
dir_size=$(du -sh "$(pwd)" | cut -f1)
echo "カレントディレクトリのサイズ: ${dir_size}"

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

一時ファイルを作成する際は mktemp を使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/bash

set -euo pipefail

# 一時ファイルを安全に作成
tmp_file=$(mktemp)
tmp_dir=$(mktemp -d)

# trapでクリーンアップを登録
cleanup() {
    rm -f "$tmp_file"
    rm -rf "$tmp_dir"
}
trap cleanup EXIT

# 一時ファイルを使用
echo "処理中のデータ" > "$tmp_file"
cat "$tmp_file"

# スクリプト終了時に自動でクリーンアップされる

条件式の推奨書式

条件式には [[ ]] を使用します(bash拡張)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#!/bin/bash

string="hello"
number=42

# 悪い例: [ ](POSIX互換だが機能が限定的)
# if [ "$string" = "hello" ]; then

# 良い例: [[ ]](bash拡張、より安全で高機能)
if [[ "$string" == "hello" ]]; then
    echo "文字列一致"
fi

# パターンマッチも可能
if [[ "$string" == h* ]]; then
    echo "hで始まる文字列"
fi

# 正規表現マッチ
if [[ "$string" =~ ^[a-z]+$ ]]; then
    echo "小文字のみ"
fi

# 数値比較
if [[ "$number" -gt 40 ]]; then
    echo "40より大きい"
fi

エラーハンドリングのパターン

エラーを適切に処理するパターンを示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/bin/bash

set -euo pipefail

# パターン1: || を使ったエラーハンドリング
cd /some/directory || {
    echo "ディレクトリへの移動に失敗しました" >&2
    exit 1
}

# パターン2: if文でコマンドの成否を確認
if ! command -v docker &> /dev/null; then
    echo "Dockerがインストールされていません" >&2
    exit 1
fi

# パターン3: trapを使ったエラーハンドリング
error_handler() {
    local line_no="$1"
    local error_code="$2"
    echo "エラー発生: 行 ${line_no}, 終了コード ${error_code}" >&2
}
trap 'error_handler ${LINENO} $?' ERR

# パターン4: エラーを無視したい場合
rm -f /tmp/maybe_not_exists.txt || true

関数設計のガイドライン

関数を設計する際のガイドラインを示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#!/bin/bash

# 1. 関数名は動詞で始め、目的を明確に
create_backup() { :; }
validate_input() { :; }
send_notification() { :; }

# 2. 引数は検証する
process_file() {
    local file="${1:?ファイルパスが必要です}"

    if [[ ! -f "$file" ]]; then
        echo "ファイルが存在しません: $file" >&2
        return 1
    fi

    # 処理...
}

# 3. 単一責任の原則(1つの関数は1つの責務)
# 悪い例: 複数の責務を持つ
# do_everything() { validate; process; notify; cleanup; }

# 良い例: 責務を分割
validate_config() { :; }
process_data() { :; }
send_report() { :; }
cleanup_temp_files() { :; }

# 4. 副作用を最小限に
# グローバル変数の変更を避け、引数と戻り値で情報をやり取り
calculate_total() {
    local -a values=("$@")
    local total=0

    for v in "${values[@]}"; do
        total=$((total + v))
    done

    echo "$total"
}

result=$(calculate_total 10 20 30)
echo "合計: ${result}"

実践例:バックアップスクリプト

これまで学んだテクニックを活用した実践的なバックアップスクリプトを作成します。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
#!/bin/bash
#
# スクリプト名: backup.sh
# 説明: 指定ディレクトリのバックアップを作成する
# 使用法: ./backup.sh [-v] [-n] <source_dir> [backup_dir]
#

set -euo pipefail

# 定数
readonly SCRIPT_NAME="$(basename "$0")"
readonly DEFAULT_BACKUP_DIR="/tmp/backups"
readonly DATE_FORMAT="%Y%m%d_%H%M%S"

# グローバル変数
VERBOSE=0
DRY_RUN=0

# ログ関数
log_info() {
    echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') $*"
}

log_debug() {
    if [[ "$VERBOSE" -eq 1 ]]; then
        echo "[DEBUG] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2
    fi
}

log_error() {
    echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2
}

# 使用法の表示
usage() {
    cat << EOF
使用法: ${SCRIPT_NAME} [-v] [-n] <source_dir> [backup_dir]

説明:
    指定されたディレクトリのバックアップを作成します。
    バックアップは tar.gz 形式で圧縮されます。

オプション:
    -v, --verbose   詳細出力モード
    -n, --dry-run   実際のバックアップを行わず、実行内容を表示
    -h, --help      このヘルプを表示

引数:
    source_dir      バックアップ元ディレクトリ(必須)
    backup_dir      バックアップ先ディレクトリ(デフォルト: ${DEFAULT_BACKUP_DIR})

例:
    ${SCRIPT_NAME} /home/user/documents
    ${SCRIPT_NAME} -v /var/www /mnt/backup
    ${SCRIPT_NAME} --dry-run /etc

EOF
}

# ディレクトリの検証
validate_directory() {
    local dir="$1"
    local dir_type="$2"

    if [[ ! -d "$dir" ]]; then
        log_error "${dir_type}ディレクトリが存在しません: ${dir}"
        return 1
    fi

    if [[ ! -r "$dir" ]]; then
        log_error "${dir_type}ディレクトリに読み取り権限がありません: ${dir}"
        return 1
    fi

    log_debug "${dir_type}ディレクトリの検証OK: ${dir}"
    return 0
}

# バックアップ先ディレクトリの準備
prepare_backup_dir() {
    local backup_dir="$1"

    if [[ ! -d "$backup_dir" ]]; then
        log_info "バックアップディレクトリを作成します: ${backup_dir}"
        if [[ "$DRY_RUN" -eq 0 ]]; then
            mkdir -p "$backup_dir"
        fi
    fi

    if [[ "$DRY_RUN" -eq 0 ]] && [[ ! -w "$backup_dir" ]]; then
        log_error "バックアップディレクトリに書き込み権限がありません: ${backup_dir}"
        return 1
    fi

    return 0
}

# バックアップの作成
create_backup() {
    local source_dir="$1"
    local backup_dir="$2"

    local source_name
    source_name=$(basename "$source_dir")
    local timestamp
    timestamp=$(date +"$DATE_FORMAT")
    local backup_file="${backup_dir}/${source_name}_${timestamp}.tar.gz"

    log_info "バックアップを作成します"
    log_info "  ソース: ${source_dir}"
    log_info "  出力先: ${backup_file}"

    if [[ "$DRY_RUN" -eq 1 ]]; then
        log_info "[DRY-RUN] tar -czf \"${backup_file}\" -C \"$(dirname "$source_dir")\" \"${source_name}\""
        return 0
    fi

    # バックアップの実行
    if tar -czf "$backup_file" -C "$(dirname "$source_dir")" "$source_name"; then
        local file_size
        file_size=$(du -h "$backup_file" | cut -f1)
        log_info "バックアップが完了しました: ${backup_file} (${file_size})"
        return 0
    else
        log_error "バックアップの作成に失敗しました"
        return 1
    fi
}

# 古いバックアップの削除
cleanup_old_backups() {
    local backup_dir="$1"
    local source_name="$2"
    local keep_count="${3:-5}"

    log_debug "古いバックアップを確認中..."

    local -a old_backups
    mapfile -t old_backups < <(
        find "$backup_dir" -name "${source_name}_*.tar.gz" -type f |
        sort -r |
        tail -n +$((keep_count + 1))
    )

    if [[ ${#old_backups[@]} -eq 0 ]]; then
        log_debug "削除対象の古いバックアップはありません"
        return 0
    fi

    log_info "${#old_backups[@]}個の古いバックアップを削除します"

    for backup_file in "${old_backups[@]}"; do
        log_debug "削除: ${backup_file}"
        if [[ "$DRY_RUN" -eq 0 ]]; then
            rm -f "$backup_file"
        fi
    done
}

# メイン処理
main() {
    # オプション解析
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -h|--help)
                usage
                exit 0
                ;;
            -v|--verbose)
                VERBOSE=1
                shift
                ;;
            -n|--dry-run)
                DRY_RUN=1
                shift
                ;;
            -*)
                log_error "不明なオプション: $1"
                usage
                exit 1
                ;;
            *)
                break
                ;;
        esac
    done

    # 引数チェック
    if [[ $# -lt 1 ]]; then
        log_error "ソースディレクトリを指定してください"
        usage
        exit 1
    fi

    local source_dir="$1"
    local backup_dir="${2:-$DEFAULT_BACKUP_DIR}"

    # ソースディレクトリを絶対パスに変換
    source_dir=$(cd "$source_dir" && pwd)

    log_info "バックアップ処理を開始します"
    log_debug "VERBOSE=${VERBOSE}, DRY_RUN=${DRY_RUN}"

    # 検証
    validate_directory "$source_dir" "ソース" || exit 1
    prepare_backup_dir "$backup_dir" || exit 1

    # バックアップ実行
    create_backup "$source_dir" "$backup_dir" || exit 1

    # 古いバックアップの削除
    local source_name
    source_name=$(basename "$source_dir")
    cleanup_old_backups "$backup_dir" "$source_name" 5

    log_info "すべての処理が完了しました"
}

# エントリーポイント
main "$@"

このスクリプトを実行すると、以下のような出力が得られます。

1
./backup.sh -v /etc /tmp/backups
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[INFO] 2026-01-08 14:30:00 バックアップ処理を開始します
[DEBUG] 2026-01-08 14:30:00 VERBOSE=1, DRY_RUN=0
[DEBUG] 2026-01-08 14:30:00 ソースディレクトリの検証OK: /etc
[INFO] 2026-01-08 14:30:00 バックアップを作成します
[INFO] 2026-01-08 14:30:00   ソース: /etc
[INFO] 2026-01-08 14:30:00   出力先: /tmp/backups/etc_20260108_143000.tar.gz
[INFO] 2026-01-08 14:30:02 バックアップが完了しました: /tmp/backups/etc_20260108_143000.tar.gz (12M)
[DEBUG] 2026-01-08 14:30:02 古いバックアップを確認中...
[DEBUG] 2026-01-08 14:30:02 削除対象の古いバックアップはありません
[INFO] 2026-01-08 14:30:02 すべての処理が完了しました

まとめ

本記事では、シェルスクリプトの実践テクニックとして以下の内容を解説しました。

トピック 内容
関数 定義と呼び出し、引数の受け渡し、戻り値の処理
スコープ グローバル変数とローカル変数、local キーワードの重要性
配列 宣言、要素アクセス、ループ処理、連想配列
ヒアドキュメント 複数行テキストの扱い、変数展開の制御
デバッグ set -xset -eset -uset -o pipefail、ShellCheck
ベストプラクティス テンプレート、変数のクォート、エラーハンドリング

これらのテクニックを組み合わせることで、再利用可能で保守性の高いシェルスクリプトを作成できます。特に set -euo pipefail の設定と local キーワードの使用は、バグの早期発見と予防に非常に効果的です。

実務では、ShellCheckによる静的解析を開発フローに組み込み、コードレビューの一環としてスクリプトの品質を維持することをお勧めします。

参考リンク