Skip to content
Published on

Shell基礎から高度な運用まで:エンジニアのための実践Shellガイド

Authors
  • Name
    Twitter

はじめに

サーバーにSSH接続し、CI/CDパイプラインを作成し、ログを分析し、デプロイスクリプトを実行する。エンジニアの一日はShellの上で始まり、Shellの上で終わる。しかし意外にも多くの開発者が、Shellの基本的な動作原理を深く理解しないまま「動くコマンド」を繰り返しているだけだ。

本記事では、Bash/Zshの基本文法から出発し、パイプライン、プロセス置換、シグナルハンドリング、パフォーマンス最適化まで、エンジニアが知るべきShell技法を実践例を中心に解説する。


1. Shellの選択:Bash vs Zsh vs Fish

項目BashZshFish
標準搭載ほとんどのLinuxディストリmacOS(Catalina以降)別途インストール
POSIX互換ほぼ完全ほぼ完全非互換
自動補完基本レベルプラグインで強力組み込み最強
スクリプト互換標準Bash互換モード対応独自文法
プロンプトカスタムPS1直接編集Oh My Zsh / Powerlevel10k組み込み設定
推奨用途サーバースクリプト、CI/CDローカル開発環境個人ターミナル

実践原則: サーバースクリプトは#!/usr/bin/env bashで作成し、ローカルのインタラクティブシェルにはZshを使う。


2. 基本:変数・条件分岐・繰り返し

2.1 変数宣言とスコープ

# ローカル変数(現在のシェルのみ)
APP_NAME="my-service"

# 環境変数(子プロセスに引き渡し)
export DB_HOST="db.prod.internal"

# readonly - 誤った上書きを防止
readonly CONFIG_PATH="/etc/app/config.yaml"

# 変数デフォルト値パターン
: "${LOG_LEVEL:=info}"          # 未設定時にinfoを代入
: "${TIMEOUT:?TIMEOUT環境変数は必須}"  # 未設定時にエラー終了
echo "${USER:-unknown}"         # 未設定時にunknownを出力(代入なし)

2.2 条件文パターン

# 文字列比較 - [[ ]] を使用(Bash/Zsh拡張)
if [[ "$ENV" == "production" ]]; then
  echo "プロダクションモード"
elif [[ "$ENV" =~ ^(staging|dev)$ ]]; then
  echo "非プロダクション環境: $ENV"
else
  echo "不明な環境"
fi

# ファイルテスト
[[ -f /etc/hosts ]]   # ファイル存在
[[ -d /var/log ]]     # ディレクトリ存在
[[ -r "$file" ]]      # 読み取り権限
[[ -s "$file" ]]      # ファイルサイズが0より大きい
[[ "$f1" -nt "$f2" ]] # f1がf2より新しい

# 算術比較 - (( )) を使用
if (( retries > 3 )); then
  echo "リトライ上限超過"
fi

2.3 繰り返しパターン

# ファイルリスト走査 - globを使用(lsのパースは禁止!)
for f in /var/log/*.log; do
  [[ -f "$f" ]] || continue
  echo "処理中: $f ($(wc -l < "$f") 行)"
done

# Cスタイルfor
for (( i=0; i<10; i++ )); do
  curl -s "http://api.local/health" > /dev/null && break
  sleep 1
done

# while + read - ファイル/コマンド出力を1行ずつ処理
while IFS=',' read -r name email role; do
  echo "ユーザー作成: $name ($role)"
done < users.csv

# 無限ループ + 脱出条件
while true; do
  status=$(curl -s -o /dev/null -w '%{http_code}' http://api/health)
  [[ "$status" == "200" ]] && break
  sleep 5
done

3. パイプライン詳解

3.1 パイプラインの基本原理

パイプ(|)は前のコマンドのstdoutを後のコマンドのstdinに接続する。各コマンドは別々のサブシェルで同時に実行される。

# 接続IP Top 10
awk '{print $1}' /var/log/nginx/access.log \
  | sort \
  | uniq -c \
  | sort -rn \
  | head -10

# pipefail - パイプライン途中の失敗を検知
set -o pipefail
curl -s "$URL" | jq '.items[]' | wc -l
# curlが失敗した場合、パイプライン全体の終了コード != 0

3.2 プロセス置換(Process Substitution)

2つのコマンドの出力をファイルのように別のコマンドに渡す。

# 2つのサーバーのパッケージリストを比較
diff <(ssh server1 'rpm -qa | sort') <(ssh server2 'rpm -qa | sort')

# 2つのAPIレスポンスを比較
diff <(curl -s api-v1/users | jq -S .) <(curl -s api-v2/users | jq -S .)

# tee + プロセス置換:1つのストリームを複数の宛先に同時送信
cat access.log \
  | tee >(grep 'ERROR' > errors.log) \
  | tee >(awk '{print $1}' | sort -u > unique_ips.txt) \
  | wc -l

3.3 高度なリダイレクションパターン

# stderrのみキャプチャ
errors=$(command 2>&1 1>/dev/null)

# stdout + stderrの両方をファイルへ
command &> output.log        # Bash 4+
command > output.log 2>&1    # POSIX互換

# Here String
grep "pattern" <<< "$variable"

# ファイルディスクリプタの活用
exec 3>/tmp/audit.log         # FD 3を開く
echo "作業開始: $(date)" >&3
do_something
echo "作業完了: $(date)" >&3
exec 3>&-                     # FD 3を閉じる

4. 関数とエラーハンドリング

4.1 関数定義パターン

# 防御的関数構造
log() {
  local level="${1:?level必須 (INFO|WARN|ERROR)}"
  local message="${2:?message必須}"
  printf '[%s] [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$level" "$message" >&2
}

retry() {
  local max_attempts="${1:?}"
  local delay="${2:?}"
  shift 2
  local attempt=1

  until "$@"; do
    if (( attempt >= max_attempts )); then
      log ERROR "コマンド失敗 ($max_attempts回試行): $*"
      return 1
    fi
    log WARN "リトライ $attempt/$max_attempts (${delay}s後): $*"
    sleep "$delay"
    (( attempt++ ))
  done
}

# 使用例
retry 5 3 curl -sf http://api.internal/health

4.2 安全なスクリプトヘッダー

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

# set -e: コマンド失敗時に即座に終了
# set -u: 未定義変数使用時にエラー
# set -o pipefail: パイプライン途中の失敗を検知
# IFS: 単語分割の基準を改行・タブに制限

# クリーンアップトラップ
cleanup() {
  local exit_code=$?
  rm -f "$TMPFILE"
  log INFO "終了 (exit code: $exit_code)"
  exit "$exit_code"
}
trap cleanup EXIT
trap 'log ERROR "行 $LINENOでエラー発生"; exit 1' ERR

TMPFILE=$(mktemp)

5. テキスト処理パイプライン

5.1 ツール比較表

ツール用途速度複雑さ
grepパターンマッチ・フィルタ非常に速い
sedストリーム編集・置換速い
awkフィールドベース処理・集計速い
jqJSON処理速い
yqYAML処理普通
cut/paste単純なフィールド抽出・結合非常に速い
xargs標準入力を引数に変換速い

5.2 実践例

# 1. ログから5xxエラーリクエストパス Top 10
awk '$9 ~ /^5[0-9]{2}$/ {print $7}' access.log \
  | sort | uniq -c | sort -rn | head -10

# 2. JSON APIレスポンスから特定フィールド抽出 + CSV変換
curl -s https://api.example.com/users \
  | jq -r '.[] | [.id, .name, .email] | @csv'

# 3. YAML設定のイメージタグ一括変更
yq -i '.spec.template.spec.containers[].image |= sub("v1\\.2\\.3", "v1.2.4")' \
  k8s/deployment.yaml

# 4. 大規模ログの並列検索(xargs + grep)
find /var/log -name '*.log' -mtime -1 -print0 \
  | xargs -0 -P4 grep -l 'OutOfMemoryError'

# 5. CSVの3列目の合計
awk -F',' '{sum += $3} END {printf "合計: %.2f\n", sum}' sales.csv

6. シグナルハンドリングとプロセス管理

6.1 主要シグナル

シグナル番号デフォルト動作用途
SIGHUP1終了デーモン設定リロード
SIGINT2終了Ctrl+C
SIGQUIT3コアダンプCtrl+\
SIGKILL9強制終了トラップ不可
SIGTERM15終了正常終了要求
SIGUSR110ユーザー定義ログレベル変更など
SIGSTOP19一時停止トラップ不可

6.2 Graceful Shutdownパターン

#!/usr/bin/env bash
set -euo pipefail

RUNNING=true
CHILD_PID=""

shutdown() {
  log INFO "終了シグナル受信、graceful shutdown開始"
  RUNNING=false
  if [[ -n "$CHILD_PID" ]]; then
    kill -TERM "$CHILD_PID" 2>/dev/null || true
    wait "$CHILD_PID" 2>/dev/null || true
  fi
}

trap shutdown SIGTERM SIGINT

while $RUNNING; do
  process_job &
  CHILD_PID=$!
  wait "$CHILD_PID" || true
  CHILD_PID=""
  sleep 5
done

log INFO "正常終了"

6.3 ジョブコントロール

# バックグラウンド実行 + 完了待機
build_frontend &
pid1=$!
build_backend &
pid2=$!

wait "$pid1" "$pid2"
echo "ビルド完了"

# nohup - セッション終了後も実行を継続
nohup long_task.sh > /var/log/task.log 2>&1 &
disown

# timeout - コマンド実行時間の制限
timeout 30s curl -s http://slow-api.com/data

7. 配列と連想配列

# インデックス配列
servers=("web01" "web02" "web03" "db01")
echo "サーバー数: ${#servers[@]}"
echo "最初の要素: ${servers[0]}"
echo "全体: ${servers[@]}"

# 配列スライス
web_servers=("${servers[@]:0:3}")

# 配列に追加
servers+=("cache01")

# 連想配列(Bash 4+)
declare -A service_ports
service_ports=(
  [nginx]=80
  [api]=8080
  [redis]=6379
  [postgres]=5432
)

for svc in "${!service_ports[@]}"; do
  echo "$svc -> ${service_ports[$svc]}"
done

# 配列で安全なコマンド構成
curl_opts=(
  -s
  --max-time 10
  --retry 3
  -H "Authorization: Bearer $TOKEN"
  -H "Content-Type: application/json"
)
curl "${curl_opts[@]}" "$API_URL"

8. 高度なパターン

8.1 サブシェル vs コマンドグループ

# サブシェル () - 別プロセス、親の変数は変更されない
(cd /tmp && tar czf backup.tar.gz /var/data)
# カレントディレクトリは変更されない

# コマンドグループ {} - 現在のシェルで実行
{
  echo "=== システム情報 ==="
  uname -a
  free -h
  df -h
} > system_report.txt

8.2 動的変数名(nameref)

# Bash 4.3+ nameref
setup_db() {
  local -n result=$1  # nameref
  result="postgresql://localhost:5432/app"
}

setup_db DB_URL
echo "$DB_URL"  # postgresql://localhost:5432/app

8.3 並列実行パターン

# GNU parallelによる並列処理
cat server_list.txt | parallel -j10 'ssh {} "df -h / | tail -1"'

# xargs並列
find . -name '*.png' -print0 \
  | xargs -0 -P$(nproc) -I{} convert {} -resize 50% resized/{}

# wait + 配列で並列制御
pids=()
for host in web0{1..5}; do
  deploy.sh "$host" &
  pids+=($!)
done

failed=0
for pid in "${pids[@]}"; do
  wait "$pid" || (( failed++ ))
done
echo "デプロイ完了: 失敗 ${failed}件"

9. パフォーマンス最適化チェックリスト

項目遅いパターン速いパターン
ループ内の外部コマンドfor f in ...; do cat "$f" | grep ...; donegrep -r ... /path/
サブシェル多用result=$(echo "$var" | sed ...)result="${var//old/new}"
不要なパイプcat file | grep patterngrep pattern file
ソート後ユニークsort | uniqsort -u
大ファイルの行数cat file | wc -lwc -l < file
ファイル存在確認ls /path/file 2>/dev/null[[ -f /path/file ]]
文字列からの抽出echo "$s" | cut -d. -f1"${s%%.*}"(Parameter Expansion)

Parameter Expansionの主要パターン

file="/var/log/nginx/access.log"

echo "${file##*/}"    # access.log(パスを除去)
echo "${file%.*}"     # /var/log/nginx/access(拡張子を除去)
echo "${file%%/*}"    # (空文字列、最初の/の前)
echo "${file%.log}.bak"  # /var/log/nginx/access.bak

version="v1.2.3-rc1"
echo "${version#v}"       # 1.2.3-rc1
echo "${version%-*}"      # v1.2.3
echo "${version^^}"       # V1.2.3-RC1(大文字)
echo "${version,,}"       # v1.2.3-rc1(小文字)
echo "${#version}"        # 10(文字列長)

10. 実践スクリプトテンプレート

デプロイスクリプト

#!/usr/bin/env bash
set -euo pipefail

readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly APP_NAME="${1:?使用法: $0 <app-name> <version>}"
readonly VERSION="${2:?使用法: $0 <app-name> <version>}"
readonly DEPLOY_ENV="${DEPLOY_ENV:-staging}"
readonly LOG_FILE="/var/log/deploy/${APP_NAME}-$(date +%Y%m%d-%H%M%S).log"

# --- ログ ---
log()  { printf '[%s] [%-5s] %s\n' "$(date +%T)" "$1" "$2" | tee -a "$LOG_FILE" >&2; }
info() { log INFO "$1"; }
warn() { log WARN "$1"; }
die()  { log ERROR "$1"; exit 1; }

# --- 事前チェック ---
preflight() {
  info "事前チェック開始"
  command -v docker   >/dev/null || die "dockerがインストールされていません"
  command -v kubectl  >/dev/null || die "kubectlがインストールされていません"

  local context
  context=$(kubectl config current-context)
  [[ "$context" == *"$DEPLOY_ENV"* ]] || die "kubectlのcontext($context)$DEPLOY_ENVと一致しません"
  info "事前チェック合格 (context: $context)"
}

# --- デプロイ ---
deploy() {
  info "$APP_NAME:$VERSION$DEPLOY_ENV デプロイ開始"

  kubectl set image "deployment/$APP_NAME" \
    "$APP_NAME=registry.internal/$APP_NAME:$VERSION" \
    --record

  info "ロールアウト待機中..."
  if ! kubectl rollout status "deployment/$APP_NAME" --timeout=300s; then
    warn "ロールアウト失敗、ロールバック実行"
    kubectl rollout undo "deployment/$APP_NAME"
    die "デプロイ失敗 → ロールバック完了"
  fi

  info "デプロイ成功"
}

# --- メイン ---
main() {
  mkdir -p "$(dirname "$LOG_FILE")"
  info "=== $APP_NAME $VERSION デプロイ ($DEPLOY_ENV) ==="
  preflight
  deploy
  info "=== デプロイ完了 ==="
}

main "$@"

まとめチェックリスト

  • スクリプト先頭にset -euo pipefailを宣言したか?
  • すべての変数をダブルクォート("$var")で囲んだか?
  • 外部入力(ユーザー入力、ファイル名)をそのままコマンドに渡していないか?
  • trapで一時ファイル・プロセスのクリーンアップを保証したか?
  • ループ内の不要な外部コマンド呼び出しを削減したか?
  • ShellCheck(shellcheck script.sh)で静的解析に合格したか?
  • POSIX互換が必要な環境でBash拡張文法を避けたか?

Shellは「知っていれば速く、知らなければ危険な」ツールだ。基本をしっかり固め、安全なパターンを習慣化すれば、どんなサーバー環境でも自信を持って問題を解決できるようになる。