0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CloudCost-Bonsai: 実戦的クラウドコスト最適化ベストプラクティスまとめ

0
Posted at

eyecatch

CloudCost-Bonsai: 実戦的クラウドコスト最適化ベストプラクティスまとめ 〜アーキテクチャと実装の深層〜

TOAI System テクニカルエバンジェリスト (TOAI10) 、および CTO 室です。

これまでのパイプラインにおいて、バックエンド実装、アーキテクチャ設計、実機検証、セキュリティ監査、データアナリティクス、そしてデベロッパーアドボカシーの各専門部門が積み上げてきた知見を総結集し、小規模〜中規模開発チームのインフラエンジニアが明日から現場でそのまま活用できる「実装のベストプラクティス」を体系化しました。

この記事では、現実のAWS/GCPのAPI制限、IAM権限の泥沼、そして深夜の障害対応に直面するエンジニアのための実用的な技術ガイドとして、コスト最適化スクリプトの裏側にあるアーキテクチャの選定理由や、物理法則(メモリ、ネットワーク)に立ち向かうための設計思想を深く解説します。


1. 現場の「地雷」を防ぐ実装ベストプラクティスとアーキテクチャ設計原則

クラウドコスト最適化スクリプトを運用する際、多くのチームが「全自動削除の罠」や「APIレートリミット(Throttling)」で痛い目をミドルウェア層やCI/CD上で見ています。これらの地雷を踏まないためには、システムの根本的なアーキテクチャを見直す必要があります。

ベストプラクティス 1: 「全自動削除」を捨て、2段階コミット(DryRun原則)を採用する

背景: 「使われていないリソースを自動で削除する」というスクリプトをCronに直結させると、タグの付け忘れや一時的なAPIの不整合により、本番のDB用EBSやDR用スナップショットが誤削除されるインシデントに直結します。

アーキテクチャ的考察:
削除処理はシステム状態への「非可逆な副作用」をもたらします。これを防ぐため、CloudCost-Bonsaiでは状態取得(Read)と状態変更(Write/Delete)を分離するCQRS(コマンドクエリ責務分離)的なアプローチを採用しています。

実装指針:

  1. 検出スクリプトは「読み取り(Describe)」と「JSONマニフェスト出力(DryRun)」のみに徹する。
  2. 実際の削除処理は、人間がマニフェストをレビューし、承認した上で別スクリプトに渡す2段階方式(Two-Phase Commit)を強制する。

ベストプラクティス 2: ページネーションとジェネレータによるメモリ保護(OOM回避)

背景: 数万件のEBSボリュームやスナップショットのメタデータを一度にオンメモリのリストとして展開すると、コンテナ環境(ECS / K8s)のOOM Killerによってスクリプトが強制終了されます。

アーキテクチャ的考察:
Pythonのリスト内包表記などで全件をメモリにロードする実装は、リソースがスケールした際に空間計算量が $O(N)$ となり破綻します。これを $O(1)$ に保つため、遅延評価(Lazy Evaluation)を用いたストリーミング処理が不可欠です。

実装指針:

  • boto3 のページネーター(get_paginator)とPythonのジェネレータ(yield)を組み合わせ、チャンクサイズ(例: PageSize=50)単位でストリーミング処理を行う構造を徹底する。

ベストプラクティス 3: 指数バックオフと事前IAMシミュレーションによるフェイルセーフ

背景: 大規模環境でのスキャン時に ThrottlingException を踏み抜いてスクリプトがハングアップしたり、必要なIAM権限が欠落していて「無駄なリソースは0件です」と誤認させる偽陽性(False Negative)が発生します。

アーキテクチャ的考察:
クラウドプロバイダーのAPIは分散システムであり、レートリミットは自己防衛機構として必ず存在します。クライアント側でのリトライ制御(Jitter付き指数バックオフ)は、ネットワークの輻輳を避けるための必須パターンです。

実装指針:

  • API呼び出し部分には必ず指数バックオフ(Exponential Backoff)とジッターを組み込んだリトライラッパーを適用する。
  • 実行前に simulate_principal_policy 等を用いたIAM事前シミュレーションを行い、権限不足時は即座に致命的エラー(Critical Exit)として終了させる。

2. コア実装スニペット集(すぐに使える実戦コード)

以下は、前述のベストプラクティスをすべて統合した、本番稼働対応のセキュアなスキャン・ガードレールモジュールの実装例です。

① タイムアウト制御とコネクション保護(botocore.config.Config)

TCPのハーフオープン状態や、プロバイダ側のサイレントドロップによる無限ブロックを防ぐため、Socketレベルのタイムアウトを明示的に設定します。

from botocore.config import Config

# 無制限待機によるハングアップを防ぐ堅牢なコンフィグ
SECURE_BOTO_CONFIG = Config(
    region_name='ap-northeast-1',
    retries={
        'max_attempts': 5,
        'mode': 'standard'
    },
    connect_timeout=5,
    read_timeout=15
)

② 入力値サニタイズと正規表現バリデーション

IAMロールの権限昇格やパストラバーサルを防ぐため、マニフェストファイルから読み込んだリソースIDの厳格な型・フォーマット検証を行います。

import re
import logging

logger = logging.getLogger("CostBonsaiSecurity")

RESOURCE_ID_PATTERNS = {
    'EBS_Volume': re.compile(r'^vol-[0-9a-f]{8,17}$'),
    'EBS_Snapshot': re.compile(r'^snap-[0-9a-f]{8,17}$'),
}

def validate_resource_id(resource_type: str, resource_id: str) -> bool:
    """
    マニフェストに含まれるResource IDのフォーマットを検証し、
    パストラバーサルや不正なインジェクションを防ぐ。
    """
    pattern = RESOURCE_ID_PATTERNS.get(resource_type)
    if not pattern or not pattern.match(resource_id):
        logger.critical(f"SECURITY ALERT: Invalid resource ID format -> '{resource_id}' for type '{resource_type}'")
        return False
    return True

③ ストリーミング形式によるEBSボリュームスキャン

ジェネレータを活用し、APIのレスポンスが届いた順に後続処理へデータを流し込むことで、メモリ使用量を一定に保ちます。

def stream_unattached_ebs_volumes(ec2_client):
    """
    メモリ肥大化(OOM)を防ぐため、ページネーターとジェネレータでストリーミングする。
    """
    paginator = ec2_client.get_paginator('describe_volumes')
    page_iterator = paginator.paginate(
        Filters=[{'Name': 'status', 'Values': ['available']}],
        PaginationConfig={'PageSize': 50}
    )
    
    for page in page_iterator:
        for vol in page.get('Volumes', []):
            yield {
                'ResourceID': vol['VolumeId'],
                'ResourceType': 'EBS_Volume',
                'SizeGB': vol['Size'],
                'VolumeType': vol['VolumeType'],
                'CreateTime': vol['CreateTime'].isoformat(),
                'EstimatedMonthlyWasteUSD': vol['Size'] * 0.10
            }

④ 【追記】IAM権限の事前シミュレーションモジュール

処理の途中で権限エラーが発生しデータが中途半端に破損するのを防ぐため、実行前に実行ロールのポリシーを評価する「Fail-Fast」アプローチです。

import boto3

def verify_iam_permissions(iam_client, action_names: list, resource_arn: str = "*"):
    """
    SimulatePrincipalPolicyを用いて、現在の実行ロールが必要なアクションを実行可能か事前検証する。
    """
    sts_client = boto3.client('sts')
    caller_identity = sts_client.get_caller_identity()
    current_arn = caller_identity['Arn']
    
    response = iam_client.simulate_principal_policy(
        PolicySourceArn=current_arn,
        ActionNames=action_names,
        ResourceArns=[resource_arn]
    )
    
    for eval_result in response['EvaluationResults']:
        if eval_result['EvalDecision'] != 'allowed':
            return False, eval_result['EvalActionName']
    
    return True, None

# 使用例:
# ok, missing_action = verify_iam_permissions(iam_client, ['ec2:DescribeVolumes', 'ec2:DescribeSnapshots'])
# if not ok:
#     logger.critical(f"Missing required IAM permission: {missing_action}")
#     sys.exit(1)

3. コア検知スクリプト実装 (cost_bonsai_scanner.py)

以下は、AWS(Boto3)を対象に、放置されたEBSスナップショット、使われていない(アタッチされていない)EBSボリューム、およびオーバースペックな遊休インスタンスを安全にスキャンするPythonスクリプトの実装例です。

#!/usr/bin/env python3
"""
CloudCost-Bonsai: AWS Resource Waste Scanner
実務環境でのIAM権限不足やAPIスロットリングを考慮した堅牢なスキャンエンジン。
"""

import logging
import time
import random
import sys
try:
    import boto3
    from botocore.exceptions import ClientError, BotoCoreError
except ImportError:
    print("Error: boto3 is not installed. Run 'pip install boto3' inside your virtualenv.", file=sys.stderr)
    sys.exit(1)

# ログ設定(現場でのデバッグ性を高めるため詳細に出力)
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger("CostBonsaiScanner")

def exponential_backoff_retry(func, *args, **kwargs):
    """
    AWS APIのレートリミット(Throttling)対策のためのリトライラッパー。
    """
    max_retries = 5
    base_delay = 1.0
    
    for attempt in range(max_retries):
        try:
            return func(*args, **kwargs)
        except ClientError as e:
            error_code = e.response.get("Error", {}).get("Code", "")
            if error_code in ["Throttling", "RequestLimitExceeded", "ServiceUnavailable"]:
                sleep_time = (base_delay * (2 ** attempt)) + (random.uniform(0, 1))
                logger.warning(f"AWS API Throttled ({error_code}). Retrying in {sleep_time:.2f} seconds... (Attempt {attempt + 1}/{max_retries})")
                time.sleep(sleep_time)
            else:
                logger.error(f"AWS ClientError encountered: {e}")
                raise
        except (BotoCoreError, Exception) as e:
            logger.error(f"Unexpected error in API call: {e}")
            raise
    logger.critical("Max retries exceeded for AWS API call.")
    raise Exception("API call failed due to persistent throttling.")

def scan_unattached_ebs_volumes(ec2_client):
    """
    アタッチ状態が 'available'(どのインスタンスにも紐づいていない)かつ、
    直近で作成されたわけではない無駄なEBSボリュームを検出する。
    """
    logger.info("Scanning for unattached EBS volumes...")
    wasted_volumes = []
    
    try:
        paginator = ec2_client.get_paginator('describe_volumes')
        for page in exponential_backoff_retry(paginator.paginate, Filters=[{'Name': 'status', 'Values': ['available']}]):
            for vol in page.get('Volumes', []):
                vol_id = vol['VolumeId']
                size = vol['Size']
                vol_type = vol['VolumeType']
                create_time = vol['CreateTime'].isoformat()
                
                # 概算コスト試算(gp2/gp3前提の月額目安:1GBあたり約0.1 USDとして計算)
                estimated_monthly_waste_usd = size * 0.10
                
                wasted_volumes.append({
                    'ResourceID': vol_id,
                    'ResourceType': 'EBS_Volume',
                    'SizeGB': size,
                    'VolumeType': vol_type,
                    'CreateTime': create_time,
                    'EstimatedMonthlyWasteUSD': estimated_monthly_waste_usd
                })
    except ClientError as e:
        # よくある現場の泥臭いエラー:IAM権限不足
        if e.response['Error']['Code'] == 'UnauthorizedOperation':
            logger.error("IAM権限不足: 'ec2:DescribeVolumes' の権限がありません。IAMポリシーを確認してください。")
        else:
            raise
            
    return wasted_volumes

def scan_orphan_snapshots(ec2_client):
    """
    親となるAMIやインスタンスがすでに存在しない、または自分自身がどのカスタムAMIからも
    参照されていない放置スナップショットを検出する。
    (※簡易実装として、オーナーが自アカウントのもので、タグ等で保護されていないものをリストアップ)
    """
    logger.info("Scanning for orphan EBS snapshots...")
    wasted_snapshots = []
    
    try:
        # セルフアカウントのスナップショットを取得
        response = exponential_backoff_retry(
            ec2_client.describe_snapshots,
            OwnerIds=['self']
        )
        
        for snap in response.get('Snapshots', []):
            snap_id = snap['SnapshotId']
            volume_size = snap['VolumeSize']
            description = snap.get('Description', '')
            
            # 「Keep」タグがついているものは除外する判定ロジック
            tags = {tag['Key']: tag['Value'] for tag in snap.get('Tags', [])}
            if tags.get('CostBonsaiProtect') == 'true':
                continue
                
            estimated_monthly_waste_usd = volume_size * 0.05  # スナップショット単価の概算
            
            wasted_snapshots.append({
                'ResourceID': snap_id,
                'ResourceType': 'EBS_Snapshot',
                'SizeGB': volume_size,
                'Description': description,
                'EstimatedMonthlyWasteUSD': estimated_monthly_waste_usd
            })
            
    except ClientError as e:
        if e.response['Error']['Code'] == 'UnauthorizedOperation':
            logger.error("IAM権限不足: 'ec2:DescribeSnapshots' の権限がありません。")
        else:
            raise
            
    return wasted_snapshots

if __name__ == '__main__':
    print("=== CloudCost-Bonsai Resource Scanner Starting ===")
    # リージョンは環境変数等から取得、ここではus-east-1を例示
    try:
        ec2 = boto3.client('region', region_name='us-east-1') # プレースホルダー
        # 実際の運用ではセッションから取得
        session = boto3.Session()
        ec2_client = session.client('ec2', region_name='ap-northeast-1')
        
        volumes = scan_unattached_ebs_volumes(ec2_client)
        snapshots = scan_orphan_snapshots(ec2_client)
        
        total_waste = sum(item['EstimatedMonthlyWasteUSD'] for item in volumes + snapshots)
        
        print("\n--- Scan Result Summary ---")
        print(f"Unattached Volumes Found: {len(volumes)}")
        print(f"Orphan Snapshots Found: {len(snapshots)}")
        print(f"Total Estimated Monthly Waste: ${total_waste:.2f} USD")
        print("---------------------------")
        
    except Exception as e:
        logger.critical(f"Scan aborted due to critical error: {e}")
        sys.exit(1)

4. 永続的な保守・運用設計(メンテナンス・アップデートプラン)

クラウドインフラの自動化ツールにおいて最大の敵は、**「クラウドベンダーのAPI仕様変更」および「Pythonエコシステムのバージョンアップ(Boto3/Google Cloud SDKの破壊적変更)」**です。コードを書いて終わりではなく、環境の変化に追従するための運用ポリシーを定義します。

  1. 依存関係の厳格なバージョンピン留め
    • requirements.txt や pyproject.toml において boto3==1.34.22 のようにマイナーバージョンまで固定し、予期せぬ破壊的変更が本番ジョブを直撃するリスクを排除します。また、Dependabot / Snykによる日次の脆弱性スキャンを常時稼働させます。
  2. CI/CD環境におけるコントラクトテスト(moto モックの活用)
    • GitHub Actions等のパイプラインにおいて、実際のAWSアカウントに接続せずとも moto ライブラリを用いたモックサーバー上でAPIスキーマの変更テストを自動実行し、デシリアライゼーションエラーを事前に検知します。
  3. LLMアシスタントの関心の分離(Presentation Layer Only)
    • 決定論的なコスト判定ロジックと、検出されたJSONマニフェストから経営陣向けサマリーを生成するLLM(Ollama等)の処理を完全に分離します。基幹ロジックにAIのハルシネーションや挙動変化が影響を及ぼすリスクを構造的に遮断します。

5. 現場の「泥臭い失敗ログ」とトラブルシューティングの共有

自動化スクリプトの開発・運用において直面する生々しいトラブル事例とその解決策です。

失敗ケース1: 「深夜の自動削除スクリプトが IAM 権限エラーで全落ちした件」

  • 事象: テスト環境のクリーンアップ用に組んだスクリプトをCronで回したところ、初回実行時は成功したものの、数日後に ClientError: An error occurred (UnauthorizedOperation) when calling the DeleteVolume operation が発生し、スクリプトが途中で異常終了した。
  • 原因: 担当者がIAMロールに ec2:Describe* は付与していたものの、肝心の ec2:DeleteVolume および ec2:DeleteSnapshot の権限が欠落していた。また、エラーハンドリングが不十分だったため、例外発生時に後続処理がストップし、アラート通知すら飛ばなかった。
  • 対策: CloudCost-Bonsaiでは、実行前に必要なIAM権限の静的チェック(前述の simulate_principal_policy を用いた検証)を走らせるバリデーション機能をビルトインし、Fail-Fastな設計としています。

失敗ケース2: 「タグ付け運用ルール未徹底によるスナップショット誤削除の危機」

  • 事象: 「古いスナップショットを一括削除する」という単純なスクリプトを適用した際、手動でバックアップを取っていたデータベースのポイントインタイムリカバリ用スナップショットまで一網打尽に消しかけた。
  • 原因: スクリプトが「作成日」という単一のメトリクスのみに依存し、ビジネスドメインの文脈(それが一時的なバックアップか、法規制対応の永続保管データか)を評価できなかったため。
  • 対策: スクリプト側に保護タグ(CostBonsaiProtect=true)のハードコードチェックを義務付け、タグがないリソースは「警告(Warning)」として出力し、削除対象から自動的に除外するセーフティネットを実装。インフラのコード化において、「タグは単なるメタデータではなく、システムへの制御フラグである」という原則を徹底しました。

TOAI System — Code for Value, Time for Engineers.

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?