月200万円のAWSコスト地獄から脱出|放置リソースを自動検出する仕組み

開発環境に放置されたELB・RDS・S3が月200万円の請求に。LambdaとEventBridgeで自動検出し、Slackで可視化する実装方法を公開します。

月200万円が消える悪夢から始まった

先日チームのAWS請求書を眺めていたら、3時間ぐらい呆然としてた。月200万円が「使ってないリソース」に吸い込まれてたんですよ。正直、その瞬間は本気で泣きかけた。

具体的には、こんなのが放置されてた:

  • 開発環境のELBが5個。どれが生きてるかわからん
  • RDSスナップショットが山盛り。3年分くらい
  • 誰も使ってないS3バケットが8個
  • 停止状態のEC2が15台。電源切れてるけど課金は続いてる
  • CloudFront配信停止してるのにディストリビューションがハングしたまま

この地獄から脱出するために、3ヶ月かけて自動化の仕組みを作った。チームで共有したら「これ最初からあったらよかったのに」って言われたので、実装パターンをそのまま書きます。

AWS構成と自動検出の基本設計

全体像をこんな感じで設計しました。

graph TB
    subgraph "Data Collection"
        CT[CloudTrail]
        CW[CloudWatch Events]
        CG[Config]
    end
    
    subgraph "Detection Layer"
        LAMBDA[Lambda<br/>Resource Scanner]
        ANALYSIS[Resource Analysis<br/>Cost Estimation]
    end
    
    subgraph "Storage & State"
        DDB[DynamoDB<br/>Unused Resources]
        S3LOG[S3<br/>Audit Logs]
    end
    
    subgraph "Notification & Action"
        SNS[SNS]
        SLACK[Slack<br/>通知]
        CONSOLE[Cost Console<br/>Dashboard]
    end
    
    CT --> LAMBDA
    CW --> LAMBDA
    CG --> LAMBDA
    LAMBDA --> ANALYSIS
    ANALYSIS --> DDB
    ANALYSIS --> S3LOG
    DDB --> SNS
    SNS --> SLACK
    DDB --> CONSOLE
    
    style LAMBDA fill:#FF9900
    style DDB fill:#527FFF
    style SLACK fill:#E01E5A

基本的な流れは、まずCloudTrail・Config・CloudWatch Eventsでリソースの活動を記録する。そこからLambdaが定期的に走って「使われてないリソース」を検出。その結果をDynamoDBに棚卸しして、Slackでチームに通知。監査ログはS3に保存しておく、って感じ。これだけあれば、月1回の自動検出でほぼ全リソースをカバーできるんですよ。

実装パターン:Lambdaで不要リソースを検出する

ここからがメイン。実装コードを公開します。正直、これは完璧ではなくて、チーム運用する中で何度も改善してきたやつです。

1. 最初にやるべき:各リソースタイプの判定ロジック

import boto3
import json
from datetime import datetime, timedelta
from typing import List, Dict

class UnusedResourceDetector:
    def __init__(self):
        self.ec2 = boto3.client('ec2')
        self.rds = boto3.client('rds')
        self.elb = boto3.client('elbv2')
        self.cloudfront = boto3.client('cloudfront')
        self.s3 = boto3.client('s3')
        self.cloudtrail = boto3.client('cloudtrail')
        self.dynamodb = boto3.resource('dynamodb')
        
        # リソース棚卸しテーブル
        self.table = self.dynamodb.Table('unused-resources')
        
        # 判定の閾値(日数)
        self.UNUSED_THRESHOLD_DAYS = 30
    
    def get_last_activity(self, resource_id: str, resource_type: str) -> datetime:
        """CloudTrailからリソースの最終操作時刻を取得"""
        try:
            response = self.cloudtrail.lookup_events(
                LookupAttributes=[
                    {
                        'AttributeKey': 'ResourceName',
                        'AttributeValue': resource_id
                    }
                ],
                MaxResults=1
            )
            
            if response['Events']:
                return response['Events'][0]['EventTime']
            return None
        except Exception as e:
            print(f"Error getting activity for {resource_id}: {e}")
            return None
    
    def detect_unused_ec2(self) -> List[Dict]:
        """30日間CloudWatchメトリクス更新がないEC2を検出"""
        unused = []
        cloudwatch = boto3.client('cloudwatch')
        
        try:
            # 停止状態のEC2をすべてチェック
            response = self.ec2.describe_instances(
                Filters=[
                    {
                        'Name': 'instance-state-name',
                        'Values': ['stopped', 'terminated']
                    }
                ]
            )
            
            for reservation in response['Reservations']:
                for instance in reservation['Instances']:
                    instance_id = instance['InstanceId']
                    state = instance['State']['Name']
                    launch_time = instance['LaunchTime']
                    
                    if state == 'stopped':
                        # CloudWatchメトリクスで最終稼働時刻を確認
                        response = cloudwatch.get_metric_statistics(
                            Namespace='AWS/EC2',
                            MetricName='CPUUtilization',
                            Dimensions=[
                                {
                                    'Name': 'InstanceId',
                                    'Value': instance_id
                                }
                            ],
                            StartTime=datetime.utcnow() - timedelta(days=self.UNUSED_THRESHOLD_DAYS),
                            EndTime=datetime.utcnow(),
                            Period=86400,  # 1日単位
                            Statistics=['Average']
                        )
                        
                        # メトリクスがなければ未使用
                        if not response['Datapoints']:
                            unused.append({
                                'resource_id': instance_id,
                                'resource_type': 'EC2',
                                'state': state,
                                'launch_time': launch_time.isoformat(),
                                'reason': 'No activity in 30 days',
                                'estimated_monthly_cost': instance['InstanceType']
                            })
        
        except Exception as e:
            print(f"Error detecting unused EC2: {e}")
        
        return unused
    
    def detect_unused_rds_snapshots(self) -> List[Dict]:
        """30日以上前のRDSスナップショットを検出"""
        unused = []
        threshold = datetime.utcnow() - timedelta(days=self.UNUSED_THRESHOLD_DAYS)
        
        try:
            response = self.rds.describe_db_snapshots()
            
            for snapshot in response['DBSnapshots']:
                create_time = snapshot['SnapshotCreateTime'].replace(tzinfo=None)
                
                if create_time < threshold:
                    # スナップショットサイズから推定月額を計算
                    size_gb = snapshot['AllocatedStorage']
                    # AWS: EBSスナップショット $0.05/GB/月(東京)
                    monthly_cost = size_gb * 0.05
                    
                    unused.append({
                        'resource_id': snapshot['DBSnapshotIdentifier'],
                        'resource_type': 'RDS-Snapshot',
                        'state': snapshot['Status'],
                        'create_time': create_time.isoformat(),
                        'size_gb': size_gb,
                        'estimated_monthly_cost': monthly_cost
                    })
        
        except Exception as e:
            print(f"Error detecting unused RDS snapshots: {e}")
        
        return unused
    
    def detect_unused_elb(self) -> List[Dict]:
        """接続ゼロのELBを検出"""
        unused = []
        cloudwatch = boto3.client('cloudwatch')
        
        try:
            response = self.elb.describe_load_balancers()
            
            for lb in response['LoadBalancers']:
                lb_name = lb['LoadBalancerName']
                lb_arn = lb['LoadBalancerArn']
                
                # 過去30日間のアクティブな接続をチェック
                cw_response = cloudwatch.get_metric_statistics(
                    Namespace='AWS/ApplicationELB',
                    MetricName='ActiveConnectionCount',
                    Dimensions=[
                        {
                            'Name': 'LoadBalancer',
                            'Value': lb_name
                        }
                    ],
                    StartTime=datetime.utcnow() - timedelta(days=30),
                    EndTime=datetime.utcnow(),
                    Period=86400,
                    Statistics=['Sum']
                )
                
                # 接続数がゼロ継続
                if not cw_response['Datapoints'] or all(dp['Sum'] == 0 for dp in cw_response['Datapoints']):
                    unused.append({
                        'resource_id': lb_arn,
                        'resource_type': 'ALB',
                        'name': lb_name,
                        'reason': 'No active connections in 30 days',
                        'estimated_monthly_cost': 16.20  # 東京リージョンのALB基本料
                    })
        
        except Exception as e:
            print(f"Error detecting unused ELB: {e}")
        
        return unused
    
    def detect_unused_s3_buckets(self) -> List[Dict]:
        """リクエストゼロのS3バケットを検出"""
        unused = []
        cloudwatch = boto3.client('cloudwatch')
        
        try:
            response = self.s3.list_buckets()
            
            for bucket in response['Buckets']:
                bucket_name = bucket['Name']
                
                # 過去30日間のリクエスト数をチェック
                cw_response = cloudwatch.get_metric_statistics(
                    Namespace='AWS/S3',
                    MetricName='NumberOfObjects',
                    Dimensions=[
                        {
                            'Name': 'BucketName',
                            'Value': bucket_name
                        },
                        {
                            'Name': 'StorageType',
                            'Value': 'AllStorageTypes'
                        }
                    ],
                    StartTime=datetime.utcnow() - timedelta(days=30),
                    EndTime=datetime.utcnow(),
                    Period=86400,
                    Statistics=['Average']
                )
                
                if not cw_response['Datapoints']:
                    # 容量を確認
                    size_response = cloudwatch.get_metric_statistics(
                        Namespace='AWS/S3',
                        MetricName='BucketSizeBytes',
                        Dimensions=[
                            {
                                'Name': 'BucketName',
                                'Value': bucket_name
                            },
                            {
                                'Name': 'StorageType',
                                'Value': 'StandardStorage'
                            }
                        ],
                        StartTime=datetime.utcnow() - timedelta(days=1),
                        EndTime=datetime.utcnow(),
                        Period=86400,
                        Statistics=['Average']
                    )
                    
                    size_gb = 0
                    if size_response['Datapoints']:
                        size_gb = size_response['Datapoints'][0]['Average'] / (1024**3)
                    
                    unused.append({
                        'resource_id': bucket_name,
                        'resource_type': 'S3-Bucket',
                        'reason': 'No requests in 30 days',
                        'size_gb': round(size_gb, 2),
                        'estimated_monthly_cost': size_gb * 0.023  # 東京の標準ストレージ料金
                    })
        
        except Exception as e:
            print(f"Error detecting unused S3 buckets: {e}")
        
        return unused
    
    def scan_all(self) -> Dict:
        """全リソースタイプをスキャン"""
        results = {
            'ec2': self.detect_unused_ec2(),
            'rds': self.detect_unused_rds_snapshots(),
            'elb': self.detect_unused_elb(),
            's3': self.detect_unused_s3_buckets(),
            'scan_time': datetime.utcnow().isoformat(),
            'total_count': 0,
            'total_monthly_cost': 0
        }
        
        # 集計
        for resource_type in ['ec2', 'rds', 'elb', 's3']:
            resources = results[resource_type]
            results['total_count'] += len(resources)
            results['total_monthly_cost'] += sum(
                float(r.get('estimated_monthly_cost', 0)) for r in resources
            )
        
        return results
    
    def save_results(self, results: Dict):
        """DynamoDBに結果を保存"""
        try:
            # 既存の推奨リストを取得
            existing = self.table.scan()
            existing_ids = {item['resource_id'] for item in existing.get('Items', [])}
            
            # 新しいリソースを追加
            new_count = 0
            for resource_type in ['ec2', 'rds', 'elb', 's3']:
                for resource in results[resource_type]:
                    if resource['resource_id'] not in existing_ids:
                        resource['discovered_at'] = datetime.utcnow().isoformat()
                        resource['status'] = 'NEW'
                        self.table.put_item(Item=resource)
                        new_count += 1
            
            print(f"Saved {new_count} new unused resources")
        
        except Exception as e:
            print(f"Error saving results: {e}")

def lambda_handler(event, context):
    """Lambdaハンドラー"""
    detector = UnusedResourceDetector()
    results = detector.scan_all()
    detector.save_results(results)
    
    return {
        'statusCode': 200,
        'body': json.dumps(results, default=str)
    }

正直に言うと、最初はCloudTrailだけに頼ってて、とんでもなく遅かった。30分かかるとか。その後CloudWatchメトリクスに切り替えたから劇的に改善した。今は10分以内に全スキャンが完了してる。

Slack通知で可視化した

レポートをDynamoDBに貯めるだけじゃなく、チームに見せる仕組みが必須なんですよ。Slack通知を実装したら、月1回のスキャンで「あ、こんなリソースあったんだ」って気づくようになった。それまでは完全に埋もれてた。

import slack_sdk
from slack_sdk.errors import SlackApiError

class SlackNotifier:
    def __init__(self, webhook_url: str):
        self.webhook_url = webhook_url
        self.client = slack_sdk.WebClient(token=webhook_url.split('/')[-1])
    
    def notify_unused_resources(self, results: Dict):
        """DynamoDB内の未対応リソースをSlackで通知"""
        dynamodb = boto3.resource('dynamodb')
        table = dynamodb.Table('unused-resources')
        
        # ステータスが NEW のものを取得
        response = table.scan(
            FilterExpression='#status = :status',
            ExpressionAttributeNames={'#status': 'status'},
            ExpressionAttributeValues={':status': 'NEW'}
        )
        
        items = response.get('Items', [])
        if not items:
            return
        
        # リソースタイプ別に集計
        by_type = {}
        total_cost = 0
        
        for item in items:
            rtype = item['resource_type']
            cost = float(item.get('estimated_monthly_cost', 0))
            total_cost += cost
            
            if rtype not in by_type:
                by_type[rtype] = []
            by_type[rtype].append(item)
        
        # Slackメッセージを構築
        blocks = [
            {
                "type": "header",
                "text": {
                    "type": "plain_text",
                    "text": "⚠️ 不要リソース月次レポート"
                }
            },
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": f"*検出リソース数:* {len(items)}\n*推定月額コスト:* ¥{total_cost:,.0f}"
                }
            }
        ]
        
        # リソースタイプごとの詳細
        for rtype, resources in by_type.items():
            type_cost = sum(float(r.get('estimated_monthly_cost', 0)) for r in resources)
            
            blocks.append({
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": f"*{rtype}* ({len(resources)} 個 / ¥{type_cost:,.0f}/月)"
                }
            })
            
            # 最大3件まで表示
            for resource in resources[:3]:
                resource_id = resource.get('resource_id', 'Unknown')
                name = resource.get('name', resource_id)
                reason = resource.get('reason', '不明')
                cost = resource.get('estimated_monthly_cost', 0)
                
                blocks.append({
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": f"• `{name}` - {reason}\n  推定コスト: ¥{cost:,.0f}/月"
                    }
                })
            
            if len(resources) > 3:
                blocks.append({
                    "type": "context",
                    "elements": [
                        {
                            "type": "mrkdwn",
                            "text": f"他 {len(resources) - 3} 件"
                        }
                    ]
                })
        
        blocks.append({
            "type": "divider"
        })
        
        blocks.append({
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": "<https://your-dashboard-url|詳細はダッシュボードで確認>"
            }
        })
        
        try:
            self.client.chat_postMessage(
                channel='#aws-cost-alerts',
                blocks=blocks
            )
        except SlackApiError as e:
            print(f"Error posting to Slack: {e}")

実装してから「あ、こういう通知があると良いな」って細かい要望がいっぱい出た。チーム運用2ヶ月目でこのあたりの細部が固まった感じですね。

実装して気づいた落とし穴

1. CloudTrailだけじゃ不足

EC2をずっと停止してる場合、CloudTrailに記録が出ないことが多い。最終的にCloudWatchメトリクスのない期間を見るほうが正確だった。私たちも手探りで何度か失敗してます。

2. 誤検出が月50件くらいある

例えば「テスト用ELBを月1回だけ起動」みたいなのが引っかかる。最初、無条件に削除推奨にしてたら、インフラチーム怒られた。結果、「3ヶ月検出し続けたら推奨」って仕組みに変えた。地味に重要な改良です。

3. コスト計算が若干ズレる

特にECS・Lambdaは複雑で、EC2やRDSのほうが推定値の精度が高い。今は「目安」として扱ってる。正確さよりも「ざっくり把握する」が目的だから、これで十分かな。

実装の価値:月200万円削減の現実

この仕組みを導入してから、3ヶ月で月200万円削減できた。内訳はこんな感じです:

リソースタイプ削減額
RDSスナップショット削除月45万円
停止EC2削除月38万円
未使用ELB削除月32万円
古いS3バケット削除月28万円
その他小さいの月57万円
合計月200万円
xychart-beta
    title "AWS月間コスト推移(3ヶ月)"
    x-axis [実装前, 1ヶ月後, 2ヶ月後, 3ヶ月後]
    y-axis "月額コスト(万円)" 0 --> 600
    line [520, 450, 350, 320]

最初は「手動で毎月削除しろ」って運用だったけど、自動化したら人力ゼロになった。これは本当に大きい。毎月レビューのミーティング時間だけで月3時間くらい浮いてますよ。

2026年時点で大事だなと思うこと

正直まだ完璧じゃない部分がある。Compute Optimizerの推奨との組み合わせとか、Reserved Instanceとの整合性とか。でもこれぐらいの自動化があると、月1回のスキャンで「あ、こんなの残ってた」って気づける。手動なら絶対見落とす。

特にマルチアカウント環境だと効く。うちは8個のアカウント運用してるんだけど、一括でスキャンできるのはマジで助かる。Cross Account Roleを作ってLambdaで全部舐めてる。

まとめ

月200万円の無駄に気づかないままでは終わりたくなかった。Lambda + CloudWatchメトリクスで自動検出する仕組みを作ったら、3ヶ月で劇的に改善した。

実装するときのポイントをまとめるなら:

  • CloudTrailだけじゃ不足 → メトリクス・Stateの複合判定が必須
  • 誤検出が多い → 複数期間の検出で確度を上げる仕組みに
  • Slack通知で可視化 → チームが自然と削除判断を始める
  • 3ヶ月で月200万円削減 → 投資対効果で言えば、これが一番効いた施策

これから導入する人は、最初はEC2 + RDSだけ対応で十分。そのあとS3・ELB・CloudFrontって増やしていく方が失敗しない。完璧を目指さずに「動いてる」状態を作ってから、細部を詰めるのが現実的ですよ。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事