シェルスクリプト実践 - 関数・配列・ファイル操作の応用テクニック

シェルスクリプト実践 - 関数・配列・ファイル操作の応用テクニック

この記事でできるようになること

  • 関数・ローカル変数・配列を使って処理を分割し、再利用できるスクリプトを書ける
  • `set -euo pipefail` と `trap` でエラー時に止まるスクリプトを書ける
  • バックアップ・ログ監視・ファイル整理のスクリプトを、破壊事故を避けて実行できる

前提知識(先に読むと理解しやすい記事)

シェルスクリプトの基礎をマスターしたら、次は実践的な高度技術です。この記事では関数・配列・ファイル操作・エラー処理を扱います。最後に、業務でそのまま使える実用例を 3 本紹介します。

用語の整理(この記事で使う言葉を先に定義します)

  • 関数: 処理に名前を付けてまとめたものです。同じ処理を何度も書かずに済みます
  • ローカル変数: 関数の中だけで有効な変数です。local を付けて宣言します。付けないとスクリプト全体で共有されるグローバル変数になります
  • 配列: 複数の値を 1 つの変数にまとめたものです。${arr[0]} のように番号で取り出します
  • 連想配列: 番号ではなく文字列をキーにする配列です。「ハッシュ」「辞書」も同じものを指します。Bash 4.0 以降で使えます
  • 終了コード: コマンドが終わるときに返す数値です。0 が成功、それ以外が失敗を意味します。「終了ステータス」「exit status」も同じものです
  • 厳格モード: set -euo pipefail のことです。失敗を見逃さずスクリプトを止めるための設定です
  • trap: 特定の出来事(エラー発生、スクリプト終了など)が起きたときに実行する処理を登録する仕組みです
  • サブシェル: 親のシェルから分岐して作られる別プロセスのシェルです。中で変数を変えても親側には戻りません

この記事でわかること

  • function_name() {} による関数定義と local を使ったスコープ管理
  • 配列の定義・要素アクセス・全展開・連想配列の使い方
  • ファイルの行単位読み込み・書き込み・CSV 処理の実装方法
  • set -euo pipefailtrap を使った堅牢なエラー処理
  • バックアップ・ログ監視・ファイル整理の実践スクリプト例

関数

結論: function_name() {}で関数を定義しlocalでスコープを限定、echoの結果を$(func)でキャプチャして返す。

関数の定義と呼び出し

基本的な関数

$1 は関数に渡された 1 つめの引数です。$2 なら 2 つめになります。

#!/bin/bash

greet() {
    echo "こんにちは、$1さん!"
}

greet "太郎"
greet "花子"
こんにちは、太郎さん!
こんにちは、花子さん!

戻り値のある関数

シェルの関数は、他の言語のように値を return できません。return で返せるのは 0〜255 の終了コードだけです。値を返したいときは echo で出力し、呼び出し側で $(関数名) を使って受け取ります。

#!/bin/bash

square() {
    local num=$1
    local result=$((num * num))
    echo $result
}

number=5
result=$(square $number)
echo "$number の平方は $result です"

複数引数の関数

#!/bin/bash

file_info() {
    local filepath=$1

    if [ -f "$filepath" ]; then
        echo "ファイル: $filepath"
        echo "サイズ: $(wc -c < "$filepath") bytes"
        echo "行数: $(wc -l < "$filepath") lines"
        echo "最終更新: $(stat -c %y "$filepath")"
    else
        echo "ファイル $filepath が存在しません"
        return 1
    fi
}

file_info "/etc/passwd"
file_info "nonexistent.txt"

高度な関数

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

#!/bin/bash

global_var="グローバル"

demo_scope() {
    local local_var="ローカル"
    global_var="変更されたグローバル"

    echo "関数内: local_var = $local_var"
    echo "関数内: global_var = $global_var"
}

echo "関数呼び出し前: global_var = $global_var"
demo_scope
echo "関数呼び出し後: global_var = $global_var"
echo "関数外: local_var = $local_var"  # 空になる

再帰関数

#!/bin/bash

factorial() {
    local n=$1

    if [ $n -le 1 ]; then
        echo 1
    else
        local prev=$(factorial $((n - 1)))
        echo $((n * prev))
    fi
}

for i in {1..5}; do
    result=$(factorial $i)
    echo "$i! = $result"
done

エラーハンドリング付き関数

#!/bin/bash

safe_mkdir() {
    local dir_path=$1

    if [ -z "$dir_path" ]; then
        echo "エラー: ディレクトリパスが指定されていません" >&2
        return 1
    fi

    if [ -d "$dir_path" ]; then
        echo "ディレクトリ $dir_path は既に存在します"
        return 0
    fi

    if mkdir -p "$dir_path" 2>/dev/null; then
        echo "ディレクトリ $dir_path を作成しました"
        return 0
    else
        echo "エラー: ディレクトリ $dir_path の作成に失敗しました" >&2
        return 1
    fi
}

safe_mkdir "/tmp/test_dir"
safe_mkdir "/root/forbidden"  # 権限エラーの例

配列

結論: arr=()で配列定義し${arr[0]}で要素取得、${arr[@]}で全展開、${#arr[@]}で要素数を取得できる。

配列の基本操作

配列の作成と要素アクセス

#!/bin/bash

fruits=("apple" "banana" "orange" "grape")
numbers=(1 2 3 4 5)

echo "最初の果物: ${fruits[0]}"
echo "3番目の数字: ${numbers[2]}"
echo "全ての果物: ${fruits[@]}"
echo "果物の数: ${#fruits[@]}"

配列への要素追加と削除

#!/bin/bash

colors=("red" "green" "blue")
echo "初期配列: ${colors[@]}"

colors+=("yellow")
echo "追加後: ${colors[@]}"

unset 'colors[1]'  # "green"を削除(クォートしないとファイル名として展開されることがある)
echo "削除後: ${colors[@]}"

echo "インデックス: ${!colors[@]}"
初期配列: red green blue
追加後: red green blue yellow
削除後: red blue yellow
インデックス: 0 2 3

unset した後は添字が飛びます(1 が欠番)。${#colors[@]} は 3 になりますが、${colors[1]} は空です。番号で回すと欠番で取りこぼすため、"${colors[@]}""${!colors[@]}" を使ってください。

連想配列(Bash 4.0以降)

#!/bin/bash

declare -A person

person["name"]="田中太郎"
person["age"]="30"
person["city"]="東京"

echo "名前: ${person["name"]}"
echo "年齢: ${person["age"]}"
echo "全キー: ${!person[@]}"
名前: 田中太郎
年齢: 30
全キー: city age name

連想配列のキーの並び順は保証されません。定義した順とは無関係な順序で返ります。順序が必要な場合は、キーの配列を別に持つか sort を通してください。

配列の実践的な使用例

#!/bin/bash

log_patterns=("/var/log/*.log" "/tmp/*.log" "$HOME/*.log")

for pattern in "${log_patterns[@]}"; do
    echo "パターン: $pattern"
    files=($pattern)

    if [ ${#files[@]} -gt 0 ] && [ -f "${files[0]}" ]; then
        for file in "${files[@]}"; do
            if [ -f "$file" ]; then
                size=$(wc -c < "$file")
                echo "  - $file ($size bytes)"
            fi
        done
    else
        echo "  - マッチするファイルなし"
    fi
done

ファイル操作

結論: while IFS= read -r lineでファイル行単位読み込み、ヒアドキュメントやmapfileで効率的に書き込める。

ファイルの読み書き

ファイル読み込みの様々な方法

#!/bin/bash

filename="data.txt"

# 方法1: while read を使用
while IFS= read -r line || [ -n "$line" ]; do
    echo "行: $line"
done < "$filename"

# 方法2: 配列への読み込み
mapfile -t lines < "$filename"
for i in "${!lines[@]}"; do
    echo "行 $((i+1)): ${lines[i]}"
done

ファイル書き込み

#!/bin/bash

output_file="output.txt"

echo "新しいファイル内容" > "$output_file"
echo "追加の行1" >> "$output_file"

cat << EOF >> "$output_file"
複数行のテキスト
2行目
3行目
EOF

cat "$output_file"

CSVファイルの処理

#!/bin/bash

csv_file="employees.csv"

cat << EOF > "$csv_file"
名前,年齢,部署
田中,30,開発部
佐藤,25,営業部
鈴木,35,管理部
EOF

tail -n +2 "$csv_file" | while IFS=',' read -r name age dept; do
    echo "社員: $name (${age}歳) - $dept"
done
社員: 田中 (30歳) - 開発部
社員: 佐藤 (25歳) - 営業部
社員: 鈴木 (35歳) - 管理部

この例はパイプの右側で変数を書き換えないため、サブシェルの問題は起きません。集計値を外に持ち出す場合はリダイレクトに変えてください。

ファイル操作の高度な例

#!/bin/bash

source_dir="/path/to/source"
backup_dir="/path/to/backup"

backup_files() {
    local src="$1"
    local dest="$2"

    if [ ! -d "$src" ]; then
        echo "エラー: ソースディレクトリが存在しません: $src"
        return 1
    fi

    mkdir -p "$dest"
    rsync -av --delete "$src/" "$dest/"
    echo "バックアップ完了: $src -> $dest"
}

log_file="/tmp/backup.log"
{
    echo "バックアップ開始: $(date)"
    backup_files "$source_dir" "$backup_dir"
    echo "バックアップ終了: $(date)"
} >> "$log_file" 2>&1

エラー処理

結論: set -euo pipefailで即座エラー終了・未定義変数エラーを有効化し、trapでエラー行番号を記録する。

エラー処理のベストプラクティス

set オプションによる厳密なエラー処理

set -euo pipefail は 3 つの設定をまとめて有効にします。どれも「失敗に気づかないまま処理を続ける」事故を防ぐためのものです。

#!/bin/bash

set -euo pipefail
# set -e: コマンドが失敗したら即座に終了
# set -u: 未定義変数を使用したらエラー
# set -o pipefail: パイプラインで一つでも失敗したらエラー

trap 'echo "エラーが発生しました: 行番号 $LINENO" >&2' ERR

echo "正常処理1"

手動エラーチェック

#!/bin/bash

process_file() {
    local filename="$1"

    if [ ! -f "$filename" ]; then
        echo "エラー: ファイル '$filename' が見つかりません" >&2
        return 1
    fi

    if [ ! -r "$filename" ]; then
        echo "エラー: ファイル '$filename' の読み取り権限がありません" >&2
        return 1
    fi

    local line_count
    if ! line_count=$(wc -l < "$filename" 2>/dev/null); then
        echo "エラー: 行数カウントに失敗しました" >&2
        return 1
    fi

    echo "ファイル '$filename' の行数: $line_count"
    return 0
}

if process_file "test.txt"; then
    echo "処理が正常に完了しました"
else
    echo "処理が失敗しました"
    exit 1
fi

ログ記録付きエラー処理

#!/bin/bash

LOG_FILE="/tmp/script.log"

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

handle_error() {
    local exit_code=$?
    local line_no=$1
    log "ERROR" "スクリプトがエラーで終了しました (終了コード: $exit_code, 行: $line_no)"
    exit $exit_code
}

trap 'handle_error $LINENO' ERR

log "INFO" "スクリプト開始"
log "INFO" "スクリプト終了"

実践的なスクリプト例

結論: バックアップ・ログ監視・ファイル整理の3つの実践例でset -euo pipefail・trap・配列の実戦活用を学べる。

例1: システムバックアップスクリプト

#!/bin/bash

set -euo pipefail

BACKUP_ROOT="/backup"
LOG_FILE="/var/log/backup.log"
RETENTION_DAYS=7
DATE=$(date +%Y%m%d_%H%M%S)

BACKUP_DIRS=(
    "/etc"
    "/home"
    "/var/www"
    "/usr/local/bin"
)

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}

cleanup() {
    log "古いバックアップの削除を開始"
    find "$BACKUP_ROOT" -type f -name "backup_*.tar.gz" -mtime +$RETENTION_DAYS -delete
    log "古いバックアップの削除完了"
}

main_backup() {
    local backup_file="$BACKUP_ROOT/backup_$DATE.tar.gz"
    log "バックアップ開始: $backup_file"
    mkdir -p "$BACKUP_ROOT"

    if tar -czf "$backup_file" "${BACKUP_DIRS[@]}" 2>/dev/null; then
        local size=$(du -h "$backup_file" | cut -f1)
        log "バックアップ成功: $backup_file (サイズ: $size)"
    else
        log "エラー: バックアップの作成に失敗しました"
        exit 1
    fi
}

if [ "$(id -u)" -ne 0 ]; then
    echo "このスクリプトはroot権限で実行してください"
    exit 1
fi

log "システムバックアップ処理開始"
main_backup
cleanup
log "システムバックアップ処理完了"

例2: ログ監視スクリプト

#!/bin/bash

set -euo pipefail

LOG_FILE="/var/log/syslog"
ALERT_PATTERNS=("ERROR" "CRITICAL" "FAILED")
CHECK_INTERVAL=10
LAST_CHECK_FILE="$HOME/.log_monitor_last_check"  # 固定の /tmp よりホームディレクトリが安全

send_alert() {
    local message="$1"
    local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[$timestamp] ALERT: $message" >> "/var/log/alerts.log"
    logger "LOG_MONITOR_ALERT: $message"
}

monitor_logs() {
    local last_position=0

    if [ -f "$LAST_CHECK_FILE" ]; then
        last_position=$(cat "$LAST_CHECK_FILE")
    fi

    local current_size=$(wc -c < "$LOG_FILE")

    if [ "$current_size" -gt "$last_position" ]; then
        local new_lines=$(tail -c +$((last_position + 1)) "$LOG_FILE")

        for pattern in "${ALERT_PATTERNS[@]}"; do
            if echo "$new_lines" | grep -q "$pattern"; then
                local matches=$(echo "$new_lines" | grep "$pattern")
                send_alert "検出されたパターン '$pattern': $matches"
            fi
        done

        echo "$current_size" > "$LAST_CHECK_FILE"
    fi
}

echo "ログ監視開始: $LOG_FILE"
while true; do
    monitor_logs
    sleep "$CHECK_INTERVAL"
done

例3: ファイル整理スクリプト

#!/bin/bash

set -euo pipefail

SOURCE_DIR="$HOME/Downloads"
ORGANIZE_ROOT="$HOME/Organized"
LOG_FILE="/tmp/file_organizer.log"

declare -A FILE_TYPES=(
    ["pdf"]="Documents/PDF"
    ["doc,docx"]="Documents/Word"
    ["jpg,jpeg,png,gif"]="Images"
    ["mp4,avi,mov"]="Videos"
    ["zip,rar,7z,tar,gz"]="Archives"
)

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}

get_file_type() {
    local filename="$1"
    local extension="${filename##*.}"
    extension=$(echo "$extension" | tr '[:upper:]' '[:lower:]')

    for types in "${!FILE_TYPES[@]}"; do
        if [[ ",$types," == *",$extension,"* ]]; then
            echo "${FILE_TYPES[$types]}"
            return 0
        fi
    done

    echo "Others"
}

organize_files() {
    log "ファイル整理開始: $SOURCE_DIR"

    if [ ! -d "$SOURCE_DIR" ]; then
        log "エラー: ソースディレクトリが存在しません: $SOURCE_DIR"
        exit 1
    fi

    local file_count=0

    while IFS= read -r -d '' file; do
        if [ -f "$file" ]; then
            local file_type=$(get_file_type "$(basename "$file")")
            local dest_dir="$ORGANIZE_ROOT/$file_type"
            mkdir -p "$dest_dir"

            if [ -e "$dest_dir/$(basename "$file")" ]; then
                log "スキップ(同名ファイルあり): $(basename "$file")"
                continue
            fi

            mv "$file" "$dest_dir/"
            log "移動完了: $(basename "$file") -> $file_type/"
            file_count=$((file_count + 1))
        fi
    done < <(find "$SOURCE_DIR" -maxdepth 1 -type f -print0)

    log "ファイル整理完了: $file_count 個のファイルを処理しました"
}

organize_files

mv は同名ファイルを黙って上書きする

移動先に同じ名前のファイルがあると、mv は確認せずに上書きします。上のスクリプトでは移動前に [ -e ... ] で存在を確認し、あればスキップしてログに残しています。

mv -n--no-clobber)でも上書きは防げますが、スクリプトに組み込む場合は注意が必要です。新しい GNU coreutils(9.x)はスキップ時に終了コード 1 を返すため、set -e と併用すると同名ファイルが 1 件あった時点でスクリプト全体が止まります。上書きせずに両方残したい場合は、-n を外して mv --backup=numbered を使ってください(--backup-n は同時に指定できません)。

初回実行時は mvecho mv に置き換えて実行し、どのファイルがどこへ動くかを先に確認することを勧めます。ファイル整理は一度実行すると元の配置に戻すのが困難です。

よくある間違いと落とし穴

結論: 配列展開は"${arr[@]}"・スコープはlocal・パイプ変数はリダイレクト・エラー連鎖防止が実践の落とし穴。

間違い1: 配列の誤った使用

NG(スペースで分割される)

files=("file1.txt" "file 2.txt" "file3.txt")
for file in $files; do    # スペース区切りで分割される
    echo $file
done

OK(正しい展開)

files=("file1.txt" "file 2.txt" "file3.txt")
for file in "${files[@]}"; do    # 配列として正しく展開
    echo "$file"
done

"${array[@]}" で配列全体を安全に展開する。

間違い2: 関数のスコープ理解不足

NG(グローバル変数が意図せず変更される)

counter=0

increment() {
    counter=$((counter + 1))    # グローバル変数を変更
    local result=$counter
    echo $result
}

increment
echo "Global counter: $counter"  # 意図しない変更

OK(適切なスコープ管理)

global_counter=0

increment() {
    local local_counter=$1
    local_counter=$((local_counter + 1))
    echo $local_counter
}

result=$(increment $global_counter)
global_counter=$result

間違い3: パイプラインでの変数変更の罠

NG(変数変更が反映されない)

パイプの右側はサブシェル(別プロセス)で実行されます。そこで変えた変数は、パイプを抜けると元に戻ります。

count=0

cat file.txt | while IFS= read -r line; do
    count=$((count + 1))
done

echo "Lines: $count"    # 0のまま(サブシェルで実行されるため)

OK(リダイレクションを使用)

count=0

while IFS= read -r line; do
    count=$((count + 1))
done < file.txt

echo "Lines: $count"    # 正しくカウントされる

# または
count=$(wc -l < file.txt)

間違い4: エラー処理の不備

NG(エラーが連鎖する)

cp source.txt backup.txt
rm source.txt               # コピーが失敗していても削除

curl -o data.json http://api.example.com/data
process_data data.json      # ダウンロード失敗でも処理続行

OK(堅牢なエラー処理)

set -euo pipefail

if cp source.txt backup.txt; then
    echo "バックアップ成功"
    rm source.txt
else
    echo "エラー: バックアップに失敗しました" >&2
    exit 1
fi

間違い5: セキュリティを考慮しない実装

NG(セキュリティ上の問題)

eval は渡された文字列をそのままコマンドとして実行します。利用者が rm -rf ~ と入力すれば、そのとおりに実行されます。予測できる名前の一時ファイルは、他の利用者に先回りして作られる余地を残します(シンボリックリンク攻撃)。コマンドラインに書いたパスワードは ps や履歴に残ります。

read -p "コマンドを入力: " user_command
eval $user_command                    # 任意のコマンド実行(危険)

temp_file="/tmp/script_data.txt"      # 予測可能なファイル名
echo "sensitive data" > $temp_file

mysql -u user -p'password123' -e "SELECT * FROM users"  # ps の出力とシェル履歴に残る

OK(セキュアな実装)

# 入力値の検証
read -p "ファイル名を入力: " filename
if [[ "$filename" =~ ^[a-zA-Z0-9._-]+$ ]]; then
    echo "処理: $filename"
else
    echo "エラー: 無効なファイル名" >&2
    exit 1
fi

# 安全な一時ファイル作成
temp_file=$(mktemp)
trap 'rm -f "$temp_file"' EXIT

# 認証情報は設定ファイルに置き、コマンドラインには出さない
# ~/.my.cnf(chmod 600 で保護)
#   [client]
#   user=appuser
#   password=...
mysql --defaults-extra-file="$HOME/.my.cnf" -e "SELECT * FROM users"

間違い6: パフォーマンスを考慮しない実装

NG(非効率な処理)

for file in /path/to/large/directory/*; do
    wc -l "$file"           # ファイルごとにプロセス起動
done

for i in {1..1000}; do
    current_time=$(date +%s)    # 毎回dateコマンド実行
    echo "Processing $i at $current_time"
done

OK(効率的な実装)

# バッチ処理で効率化
find /path/to/large/directory -name "*" -type f -exec wc -l {} +

# 一度だけ取得して再利用
start_time=$(date +%s)
for i in {1..1000}; do
    current_time=$((start_time + i))
    echo "Processing $i at $current_time"
done

ベストプラクティス

結論: 変数名の明確化・mktemp一時ファイル・入力値バリデーション・set -xデバッグが堅牢なスクリプトの基本。

コーディング規約

  • 変数名は意味のある名前を使います
  • 関数は 1 つの責任に絞ります
  • コメントには「なぜそうするか」を書きます
  • インデントを統一します(2 スペース推奨)

セキュリティ

  • 入力値の検証を必ず実施します
  • 一時ファイルは mktemp で作ります。名前が予測されないため、他者に先回りされません
  • 権限は最小限に設定します
  • パスワードやトークンをスクリプトに直接書きません

デバッグ

  • set -x を付けると、実行したコマンドが 1 行ずつ表示されます
  • 適切なログ出力を実装します
  • 段階的にテストしながら開発します
  • ShellCheck などの静的解析ツールを使います。引用符の付け忘れなど、事故につながる書き方を実行前に検出できます

まとめ

シェルスクリプトの実践技術を身につけると、効率的で信頼性の高い自動化が実現できます。破壊的な操作を含むスクリプトは、必ず --dry-run-print で対象を確認してから本実行してください。

  • 関数 でコードの再利用性と保守性を向上
  • 配列 で複雑なデータ処理を効率化
  • 適切なエラー処理 で堅牢なスクリプトを作成
  • 実践例 を参考に業務に応用

次に読む

この記事を共有

次の一手