- Authors
- Name
はじめに
サーバーにSSH接続し、CI/CDパイプラインを作成し、ログを分析し、デプロイスクリプトを実行する。エンジニアの一日はShellの上で始まり、Shellの上で終わる。しかし意外にも多くの開発者が、Shellの基本的な動作原理を深く理解しないまま「動くコマンド」を繰り返しているだけだ。
本記事では、Bash/Zshの基本文法から出発し、パイプライン、プロセス置換、シグナルハンドリング、パフォーマンス最適化まで、エンジニアが知るべきShell技法を実践例を中心に解説する。
1. Shellの選択:Bash vs Zsh vs Fish
| 項目 | Bash | Zsh | Fish |
|---|---|---|---|
| 標準搭載 | ほとんどの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 | フィールドベース処理・集計 | 速い | 高 |
jq | JSON処理 | 速い | 中 |
yq | YAML処理 | 普通 | 中 |
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 主要シグナル
| シグナル | 番号 | デフォルト動作 | 用途 |
|---|---|---|---|
SIGHUP | 1 | 終了 | デーモン設定リロード |
SIGINT | 2 | 終了 | Ctrl+C |
SIGQUIT | 3 | コアダンプ | Ctrl+\ |
SIGKILL | 9 | 強制終了 | トラップ不可 |
SIGTERM | 15 | 終了 | 正常終了要求 |
SIGUSR1 | 10 | ユーザー定義 | ログレベル変更など |
SIGSTOP | 19 | 一時停止 | トラップ不可 |
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 ...; done | grep -r ... /path/ |
| サブシェル多用 | result=$(echo "$var" | sed ...) | result="${var//old/new}" |
| 不要なパイプ | cat file | grep pattern | grep pattern file |
| ソート後ユニーク | sort | uniq | sort -u |
| 大ファイルの行数 | cat file | wc -l | wc -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は「知っていれば速く、知らなければ危険な」ツールだ。基本をしっかり固め、安全なパターンを習慣化すれば、どんなサーバー環境でも自信を持って問題を解決できるようになる。