はじめに
AWS CLI を利用した運用シェルスクリプト(CloudShell などで手動実行する想定の短時間・小データ量のバッチ)は、インフラ運用において欠かせないツールです。しかし、少し処理が複雑になると以下のような課題にぶつかります。
- テストが書けない
- テストを頑張ろうとすると AWS CLI のモック化が辛い
- 作り方がそれぞれで属人化しやすい
結果、少し似たスクリプトが作られたり、人が入れ替わったさいにメンテナンスが止まったりと何かと負債化の道をたどりがちです。
複雑になり始めたら、例えば Python + boto3 のスクリプトなどで対応するのが正統な方法だとは思います。
しかし、本記事では、チームのスキルセットや実行環境の制約を考慮し「あえてBashで書く」ことを選択した上で、Functional Core, Imperative Shell(FCIS) の考え方を適用し、コードをレイヤー分けしてテスト容易性が高まらないかということを考えてみました。
また、今回は AWS アカウントがなくてもローカル端末で試せるよう、LocalStackの代替として注目を集めている軽量なローカルAWSエミュレータ「Floci」 を使用したIAMユーザー作成のハンズオンを交えて解説します。
課題:運用シェルスクリプトの何が辛いのか
- 課題1:AWS CLIの戻り値(JSON)の処理が煩雑で可読性が下がる
- 課題2:外部APIとの通信が混ざるため、ローカルでの単体テストが極めて困難
- 課題3:複雑なリトライ処理をシェルで書いてしまい、状態管理が破綻する
解決策:Functional Core, Imperative Shell (FCIS) の設計方針導入
「副作用のある処理(I/O)」と「純粋なロジック」を分離する FCIS の概念を、 Bash スクリプトに落とし込みます。スクリプト内の関数を以下の3層に分離します。
-
I/O層(Prefix:
io_) : AWS CLI を実行するだけ。テスト対象外 -
アダプタ層(Prefix:
adapter_) : JSON を TSV に変換。テスト対象 -
ロジック層(Prefix:
logic_) : TSV をもとに判定を行う。テスト対象
データによっては TSV で問題がある場合もありますが、今回はその点を許容して話を進めます。
実践
IAMユーザー作成スクリプトの実装
ごく単純な例として「IAMユーザーが存在するかチェックし、無ければ作成する」という冪等性を持ったスクリプト( iam_manager.sh )を、 FCIS に沿って実装します。
#!/usr/bin/env bash
set -euo pipefail
# ==========================================
# グローバル定数
# ==========================================
readonly EXIT_SUCCESS=0
readonly EXIT_FAILURE=1
readonly USER_FOUND=0
readonly USER_NOT_FOUND=1
# ==========================================
# 1. I/O層 (テスト対象外)
# ==========================================
io_aws_list_users() {
aws iam list-users --output json
}
io_aws_create_user() {
local user_name="$1"
aws iam create-user --user-name "$user_name" >/dev/null
}
# ==========================================
# 2. アダプタ層 (テスト対象)
# ==========================================
adapter_users_to_tsv() {
# JSONからUserNameだけを抽出し、フラットなテキストにする
jq -r '.Users[]? | [.UserName] | @tsv'
}
# ==========================================
# 3. ロジック層 (テスト対象)
# ==========================================
logic_is_user_exist() {
local target_user="$1"
while IFS=$'\t' read -r user_name; do
if [[ "$user_name" == "$target_user" ]]; then
return "$USER_FOUND"
fi
done
return "$USER_NOT_FOUND"
}
# ==========================================
# メイン処理(コントローラー)
# ==========================================
main() {
local target_user="${1:-test-user}"
local current_json
echo "[INFO] ユーザー($target_user) の状態を確認します..."
# 【前提チェック】
# 宣言と代入を分けることで、io層のエラーを確実に捕捉する
if ! current_json=$(io_aws_list_users); then
echo "[ERROR] IAMユーザー情報の取得に失敗しました。" >&2
return "$EXIT_FAILURE"
fi
# アダプタとロジックをパイプで繋いで判定
if echo "$current_json" | adapter_users_to_tsv | logic_is_user_exist "$target_user"; then
echo "[INFO] 既にユーザー($target_user)は存在します。作成をスキップします。"
return "$EXIT_SUCCESS"
fi
echo "[INFO] ユーザーが存在しないため、作成を実行します..."
# 【実行(更新系)】
if ! io_aws_create_user "$target_user"; then
echo "[ERROR] ユーザーの作成に失敗しました。" >&2
return "$EXIT_FAILURE"
fi
# 【事後チェック】
echo "[INFO] 更新後の状態を検証します..."
local updated_json
if ! updated_json=$(io_aws_list_users); then
echo "[ERROR] 更新後のユーザー情報の取得に失敗しました。" >&2
return "$EXIT_FAILURE"
fi
if echo "$updated_json" | adapter_users_to_tsv | logic_is_user_exist "$target_user"; then
echo "[SUCCESS] ユーザー($target_user) の作成を正常に確認しました。"
return "$EXIT_SUCCESS"
else
echo "[ERROR] コマンドは成功しましたが、ユーザーが反映されていません。" >&2
return "$EXIT_FAILURE"
fi
}
# source で外部から読み込まれたときには実行しない
# Python で言うところの if __name__ == __main__
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
batsによる単体テスト
この設計ではヒアストリング( <<< )を使って標準入力にダミーデータを流し込むだけで、ビジネスロジックのテストが書けます。
#!/usr/bin/env bats
setup() {
source ./iam_manager.sh
}
@test "adapter_users_to_tsv: JSONからUserNameが正しく抽出されること" {
local dummy_json='{
"Users": [
{"UserName": "alice", "UserId": "123"},
{"UserName": "bob", "UserId": "456"}
]
}'
run adapter_users_to_tsv <<< "$dummy_json"
[ "$status" -eq 0 ]
[ "${lines[0]}" = "alice" ]
[ "${lines[1]}" = "bob" ]
}
@test "logic_is_user_exist: ターゲットユーザーが存在する場合は0を返すこと" {
local dummy_tsv="alice
bob"
run logic_is_user_exist "alice" <<< "$dummy_tsv"
[ "$status" -eq 0 ]
}
@test "logic_is_user_exist: ターゲットユーザーが存在しない場合は1を返すこと" {
local dummy_tsv="alice
bob"
run logic_is_user_exist "charlie" <<< "$dummy_tsv"
[ "$status" -eq 1 ]
}
テストの実行結果は以下です。
$ bats test_iam_manager.bats
test_iam_manager.bats
✓ adapter_users_to_tsv: JSONからUserNameが正しく抽出されること
✓ logic_is_user_exist: ターゲットユーザーが存在する場合は0を返すこと
✓ logic_is_user_exist: ターゲットユーザーが存在しない場合は1を返すこと
3 tests, 0 failures
ハンズオン準備:Floci の立ち上げ
直近のアップデートでLocalStack は無償(Hobby)プランでもアカウント登録が必須となりました。今回はアカウント不要・軽量高速な代替ツールである Floci を使って、ローカルの localhost:4566 にモックのAWS環境を立ち上げます。
任意のディレクトリで以下の compose.yaml を作成し、起動してください。
# compose.yaml
services:
floci:
image: hectorvent/floci:latest
ports:
- "4566:4566"
volumes:
- ./data:/app/data
実行用の環境変数を設定しておきます。
export AWS_ENDPOINT_URL=http://localhost:4566
export AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
1回目の実行
$ ./iam_manager.sh
[INFO] ユーザー(test-user) の状態を確認します...
[INFO] ユーザーが存在しないため、作成を実行します...
[INFO] 更新後の状態を検証します...
[SUCCESS] ユーザー(test-user) の作成を正常に確認しました。
2回目以降の実行
$ ./iam_manager.sh
[INFO] ユーザー(test-user) の状態を確認します...
[INFO] 既にユーザー(test-user)は存在します。作成をスキップします。
おわりに:AI Agent 時代のコード生成プロンプトとして
レイヤードアーキテクチャ(層分け)は保守性やテスト容易性を高める一方で、各層を繋ぐための「ボイラープレートに近い冗長なコード」が増えるというデメリットがあります。特にプログラミングを本職としないインフラエンジニアにとって、これを最初からきれいに設計・整理するのは導入コストが高く、敬遠されがちでした。
しかし、現在は 生成AI Agent が存在します。
この「3層分離のルール」という明確な「型」さえドキュメント( AGENTS.md など)化してAIにインプットしておけば、面倒なボイラープレートの生成やテストコードの引き回しはすべてAIが正確にこなしてくれます(少し前の生成AIモデルだと bats はFew Shots を書くとよりよさそうでした)。
つまり、「人間が書くには少し冗長だが、AIにとっては再現性が高く、常に一定の高品質なコードを出力させやすいアーキテクチャ」 だからこそ、あえて今、Bashスクリプトにこの設計を導入する価値があると考えています。
シェルスクリプトはどうしても負債になりがちですが、適切にドメイン境界を引き、テスト可能な「型」を用意することで、ジュニアエンジニアの教育にも、将来のPython等への移行にも役立つ考え方の基盤を作ることができると考えます。