RIカバレッジ分析を自動化したら月120万円削減できた話【実装コード付き】

「余らせているRI」と「On-Demandで払い続けている費用」が同時発生していた経験、ありませんか?3年間の試行錯誤から生まれたカバレッジ分析の自動化実装を公開します。

3年間、RIを「なんとなく」買い続けていた話

正直に言うと、2年前まで僕はRIをかなり感覚で買っていた。「このEC2インスタンスは常に使ってるから1年RIで買おう」「なんか先月のEC2費用高かったからConvertible RIを追加しよう」——そういうノリで購入してきた結果、カバレッジが乱立して管理しきれなくなり、気がついたら有効活用されていないRIが複数存在するという最悪の状態になっていた。

きっかけは昨年のFinOpsレビューで発覚した衝撃の数字だ。月間のOn-Demand費用の中に、本来ならRIで賄えるはずだったEC2費用が約60万円含まれていた。一方で購入済みのRIのうち、カバレッジ率が60%を切っているものが全体の30%近くあった。つまり「余らせているRI」と「On-Demandで払い続けている費用」が同時に発生していたわけで、これは完全に設計の失敗だった。

その後3ヶ月かけて分析基盤を整え、段階的な購入戦略を構築した結果、現在では月間コミットメント費用を20%削減しながらOn-Demand比率も40%削減することができた。合計で月約120万円の削減に至った。なお以前「RIを3年間失敗し続けた話と、月160万円削減できた購入戦略」でも触れたが、あちらは戦略概要の話で、今回はカバレッジ分析の自動化実装にフォーカスする。

カバレッジ分析の実態と自動化設計

まず最初に整理しなければならなかったのが「現状把握」だ。AWSコンソールのCost Explorerで確認できる「RI Coverage」レポートは確かに見られるが、サービス横断で見たり、過去トレンドを自動追跡したりするには正直力不足だった。うちのチームは当時、EC2・RDS・ElastiCache・Redshift・OpenSearchと5種類のサービスでRIを運用していたので、手動管理は現実的じゃなかった。

そこで以下のような自動分析システムを構築した。

graph TB
    subgraph DataCollection["データ収集層"]
        CE[Cost Explorer API]
        CUR[Cost & Usage Report]
        S3_CUR[S3 CUR バケット]
    end

    subgraph Processing["処理層"]
        Lambda1[RI Coverage Aggregator\nLambda]
        Glue[Glue ETL Job\nCUR正規化]
        Athena[Athena\nアドホック分析]
    end

    subgraph Storage["ストレージ層"]
        S3_Result[S3 分析結果]
        DDB[DynamoDB\nRI台帳]
        TS[Timestream\nカバレッジ時系列]
    end

    subgraph Notification["通知・可視化層"]
        EventBridge[EventBridge\n毎日6:00 JST]
        Grafana[Grafana Dashboard]
        Slack[Slack Webhook]
        SNS[SNS Alert]
    end

    subgraph Action["購入判断層"]
        Lambda2[Recommendation Engine\nLambda]
        QuickSight[QuickSight\n購入サジェスト]
    end

    CE --> Lambda1
    CUR --> S3_CUR
    S3_CUR --> Glue
    Glue --> Athena
    Lambda1 --> S3_Result
    Lambda1 --> DDB
    Lambda1 --> TS
    EventBridge --> Lambda1
    TS --> Grafana
    S3_Result --> Lambda2
    Lambda2 --> QuickSight
    Lambda2 --> Slack
    DDB --> SNS

このシステムの中核はLambdaで動くRI Coverage Aggregatorだ。毎朝6時にEventBridgeがトリガーして、前日のカバレッジデータをサービスごとに集計し、Timestreamに投入する。コードの核心部分はこんな感じ。

import boto3
import json
from datetime import datetime, timedelta
from decimal import Decimal

ce_client = boto3.client('ce', region_name='us-east-1')
timestream_client = boto3.client('timestream-write', region_name='ap-northeast-1')

SERVICES = [
    'Amazon Elastic Compute Cloud - Compute',
    'Amazon Relational Database Service',
    'Amazon ElastiCache',
    'Amazon Redshift',
    'Amazon OpenSearch Service',
]

def get_ri_coverage(service: str, start: str, end: str) -> dict:
    """サービスごとのRIカバレッジを取得"""
    response = ce_client.get_reservation_coverage(
        TimePeriod={'Start': start, 'End': end},
        GroupBy=[{
            'Type': 'DIMENSION',
            'Key': 'INSTANCE_TYPE'
        }],
        Filter={
            'Dimensions': {
                'Key': 'SERVICE',
                'Values': [service]
            }
        },
        Granularity='DAILY',
        Metrics=['CoverageHours', 'CoverageNormalizedUnits']
    )
    return response

def get_ri_utilization(service: str, start: str, end: str) -> dict:
    """購入済みRIの利用率を取得"""
    response = ce_client.get_reservation_utilization(
        TimePeriod={'Start': start, 'End': end},
        GroupBy=[{
            'Type': 'DIMENSION',
            'Key': 'SUBSCRIPTION_ID'
        }],
        Filter={
            'Dimensions': {
                'Key': 'SERVICE',
                'Values': [service]
            }
        },
        Granularity='DAILY',
    )
    return response

def calculate_coverage_risk(coverage_pct: float, utilization_pct: float) -> str:
    """カバレッジと利用率からリスクレベルを判定"""
    if coverage_pct < 50 and utilization_pct > 90:
        return 'HIGH'  # RI不足。追加購入を検討すべき
    elif coverage_pct > 80 and utilization_pct < 60:
        return 'WASTE'  # RI余剰。MarketplaceでのRI売却を検討
    elif coverage_pct < 70:
        return 'MEDIUM'  # 改善余地あり
    else:
        return 'OK'

def lambda_handler(event, context):
    today = datetime.now()
    yesterday = today - timedelta(days=1)
    start = yesterday.strftime('%Y-%m-%d')
    end = today.strftime('%Y-%m-%d')
    
    results = []
    for service in SERVICES:
        try:
            coverage_data = get_ri_coverage(service, start, end)
            utilization_data = get_ri_utilization(service, start, end)
            
            # サービス全体の集計カバレッジ
            total_coverage = coverage_data.get('Total', {})
            coverage_pct = float(
                total_coverage.get('CoverageHours', {}).get('CoverageHoursPercentage', 0)
            )
            
            # 全サブスクリプションの平均利用率
            utilizations = utilization_data.get('UtilizationsByTime', [])
            avg_utilization = 0.0
            if utilizations:
                total_util = utilizations[0].get('Total', {})
                avg_utilization = float(
                    total_util.get('UtilizationPercentage', 0)
                )
            
            risk = calculate_coverage_risk(coverage_pct, avg_utilization)
            
            results.append({
                'service': service,
                'date': start,
                'coverage_pct': coverage_pct,
                'utilization_pct': avg_utilization,
                'risk': risk,
            })
            
            # Timestreamへ書き込み
            write_to_timestream(service, start, coverage_pct, avg_utilization, risk)
            
        except Exception as e:
            print(f"Error processing {service}: {str(e)}")
    
    # リスクがHIGHまたはWASTEのものをSlack通知
    alerts = [r for r in results if r['risk'] in ('HIGH', 'WASTE')]
    if alerts:
        send_slack_alert(alerts)
    
    return {'statusCode': 200, 'body': json.dumps(results)}

def write_to_timestream(service, date, coverage, utilization, risk):
    """Timestreamへカバレッジ指標を書き込む"""
    timestream_client.write_records(
        DatabaseName='ri-analytics',
        TableName='coverage-metrics',
        Records=[{
            'Dimensions': [
                {'Name': 'service', 'Value': service},
                {'Name': 'risk_level', 'Value': risk},
            ],
            'MeasureName': 'coverage_pct',
            'MeasureValue': str(coverage),
            'MeasureValueType': 'DOUBLE',
            'Time': str(int(datetime.strptime(date, '%Y-%m-%d').timestamp() * 1000)),
            'TimeUnit': 'MILLISECONDS',
        }]
    )

このコード、実は最初のバージョンはもっとシンプルだったんだけど、RDSのMulti-AZインスタンスやElastiCacheのクラスターモードで正規化ユニットの計算がバラバラになってハマった。カバレッジはCoverageHoursではなくCoverageNormalizedUnitsで見ないと実態と乖離するケースがある——というのが最初の失敗で学んだことだ。地味にここで半日溶かした。

購入戦略の設計:4つの原則

分析基盤を整えてからわかったのが、「いつ何を買うか」の判断基準が曖昧だったということだ。2026年時点のRI市場は、以前に比べて選択肢が増えた分、判断が難しくなっている。Savings Plansとの使い分けも含めて、うちのチームが現在採用している4原則を共有する(なおSavings Plansとの使い分けについては「Savings Plans vs Reserved Instances|2026年AWSコスト最適化判断基準」が詳しいのでそちらも参照してほしい)。

原則1:ベースライン・スパイクの分離

24時間365日確実に稼働するインスタンスだけをRIの対象にする。ピーク時のスパイク分はSavings PlansかSpotで吸収する設計だ。

xychart-beta
    title "EC2稼働時間の分布(改善前後比較)"
    x-axis ["00:00", "03:00", "06:00", "09:00", "12:00", "15:00", "18:00", "21:00"]
    y-axis "稼働インスタンス数" 0 --> 100
    line [45, 42, 44, 78, 95, 92, 88, 60]
    line [45, 42, 44, 45, 45, 45, 45, 45]

上の折れ線がRI対象のベースライン(常時45台)で、スパイク分はSavings PlansとSpotで吸収する。以前はピーク時の台数でRI購入していたので、深夜帯に大量のRIが余るという状態だった。改善前後でこれだけ違う。

原則2:インスタンスファミリー単位での検討

Convertible RIを活用するなら、インスタンスタイプ単体ではなくファミリー単位でカバレッジを考えたほうがいい。m7i.largeとm7i.xlargeで別々にRIを持つより、m7iファミリーでまとめてConvertible RIを持つほうが柔軟だ。個人的にはこの発想の転換が一番効いたと思っている。

# インスタンスファミリー別のカバレッジ分析
def analyze_by_family(coverage_data: dict) -> dict:
    """インスタンスファミリー単位でカバレッジを集計"""
    family_coverage = {}
    
    for group in coverage_data.get('CoveragesByTime', []):
        for item in group.get('Groups', []):
            instance_type = item['Keys'][0]  # e.g., 'm7i.large'
            family = instance_type.rsplit('.', 1)[0]  # e.g., 'm7i'
            
            coverage_hours = float(
                item['Coverage']['CoverageHours']['CoverageHoursPercentage']
            )
            on_demand_hours = float(
                item['Coverage']['CoverageHours']['OnDemandHours']
            )
            reserved_hours = float(
                item['Coverage']['CoverageHours']['ReservedHours']
            )
            
            if family not in family_coverage:
                family_coverage[family] = {
                    'total_on_demand': 0,
                    'total_reserved': 0,
                    'instance_types': []
                }
            
            family_coverage[family]['total_on_demand'] += on_demand_hours
            family_coverage[family]['total_reserved'] += reserved_hours
            family_coverage[family]['instance_types'].append(instance_type)
    
    # ファミリー全体のカバレッジ率を計算
    for family, data in family_coverage.items():
        total = data['total_on_demand'] + data['total_reserved']
        if total > 0:
            data['family_coverage_pct'] = (
                data['total_reserved'] / total * 100
            )
        else:
            data['family_coverage_pct'] = 0.0
    
    return family_coverage

原則3:段階的購入(Laddering)

一度に大量のRIを購入するのは危険だ。うちのチームでは「RI Laddering」と呼んでいる段階的購入を採用している。月次で前月のカバレッジレポートを確認し、カバレッジが70%以下のサービスについて不足分の50%だけをその月に購入する。翌月また確認して調整する——という繰り返しだ。最初は物足りなく感じるかもしれないが、3ヶ月でほぼ適正水準に落ち着くことが経験上わかった。

フェーズ購入量の目安期間目的
Phase 1不足分の50%1ヶ月目ベースライン確立
Phase 2残り不足分の70%2ヶ月目カバレッジ向上
Phase 3残余調整3ヶ月目最適化
定常運用月次レビューで微調整以降維持管理

原則4:有効期限の分散(Staggering)

これが地味に一番効いた施策かもしれない。購入するRIの有効期限を意図的にバラバラにする。全部が同じタイミングで切れると、更新検討・購入のコストと判断負荷が一気に集中する。うちのチームは1月・4月・7月・10月の年4回に分散させて管理している。これを導入してから、「今月RI更新ラッシュで死にそう」という状況がなくなった。

実際の分析ダッシュボードと改善結果

3ヶ月の実装を経て得られた数字をまとめる。

xychart-beta
    title "RI改善前後のコスト比較(月次・万円)"
    x-axis ["2025-09", "2025-10", "2025-11", "2025-12", "2026-01", "2026-02", "2026-03"]
    y-axis "費用(万円)" 0 --> 600
    bar [520, 535, 280, 270, 260, 255, 250]
    line [520, 535, 420, 400, 390, 385, 395]

棒グラフがRI最適化後の実費用、折れ線が最適化しなかった場合の推計費用だ。2025-11から段階的購入を開始し、2026年1月以降は安定して月120〜130万円の削減を達成している。

改善前後のカバレッジ比較はこちら。

サービス改善前カバレッジ改善後カバレッジRI利用率(改善後)
EC252%81%88%
RDS48%79%91%
ElastiCache71%83%85%
Redshift34%72%83%
OpenSearch22%68%79%

OpenSearchが一番酷かった。もともとRI自体あまり活用されていなかったサービスで、ほぼOn-Demandで全額払っていたレベルだ。正直、OpenSearch Reserved InstancesはEC2ほど知名度がないせいか、チームメンバーも存在を把握していなかったくらいで、発見したときは「なんでこれ誰も使ってないの」と軽く絶望した。

コスト最適化の全体像としては、「月額500万円の請求書を見て動いた。AWS費用を30%削減した3ヶ月の実装記録」でも似たような話が出てくるので興味があれば読んでみてほしい。アプローチはプロジェクト規模によって変わるが、根本の考え方は共通している。

AWS構成図:RI分析基盤の全体像

今回構築した分析基盤のAWS構成を整理しておく。

graph TB
    subgraph Management["管理アカウント(Payer)"]
        CE[Cost Explorer API]
        CUR_Bucket["S3: CURバケット\n(管理アカウント)"]
        CE --> CUR_Bucket
    end

    subgraph Analytics["分析アカウント"]
        subgraph VPC_Analytics["VPC: 10.1.0.0/16"]
            subgraph AZ_A["ap-northeast-1a"]
                Lambda_Agg["Lambda\nRI Coverage Aggregator\nメモリ: 512MB / タイムアウト: 5min"]
                Lambda_Rec["Lambda\nRecommendation Engine\nメモリ: 256MB / タイムアウト: 3min"]
            end

            subgraph AZ_C["ap-northeast-1c"]
                Glue["Glue ETL Job\nCUR正規化\nG.1X / 2 DPU"]
                Athena["Athena\nCURクエリ\nWorkgroup: ri-analytics"]
            end

            subgraph DataStore["データストア"]
                S3_Result["S3\nri-analysis-results"]
                DDB["DynamoDB\nri-inventory\nPAY_PER_REQUEST"]
                TS["Timestream\nri-analytics DB\n保持: 7日(メモリ) / 1年(磁気)"]
            end
        end

        EventBridge["EventBridge\nSchedule: cron(0 21 * * ? *)"]
        SNS_Alert["SNS\nri-cost-alert"]
        QuickSight["QuickSight\nRI購入ダッシュボード"]
    end

    subgraph External["外部連携"]
        Slack["Slack Webhook\n#aws-cost-alert"]
        Grafana["Grafana Cloud\nTimestreamデータソース"]
    end

    CUR_Bucket -->|"CURファイル配信"| Glue
    Glue --> S3_Result
    S3_Result --> Athena
    EventBridge -->|"毎日 06:00 JST"| Lambda_Agg
    Lambda_Agg --> CE
    Lambda_Agg --> DDB
    Lambda_Agg --> TS
    Lambda_Agg --> S3_Result
    Lambda_Agg -->|"アラート"| SNS_Alert
    SNS_Alert --> Slack
    S3_Result --> Lambda_Rec
    Lambda_Rec --> QuickSight
    TS --> Grafana

Lambdaはプライベートサブネットに置いて、Cost Explorer APIはVPCエンドポイント経由でアクセスしている。Timestreamへの書き込みもVPCエンドポイントを使っているので、データ転送費は最小限だ。この辺りの設計は「月80万円のデータ転送費をVPCエンドポイント導入で削減した実装記録」が参考になると思う。

まとめ

3年間RI購入を感覚でやり続けて痛い目を見た経験から辿り着いた、2026年時点での実践的な知見をまとめる。

1. カバレッジと利用率は必ずセットで見る
カバレッジだけ高くても利用率が低ければRIが無駄になる。両方を毎日自動集計する仕組みを最初に作ることが全ての基本だった。

2. 段階的購入(Laddering)は地味だけど効く
一括購入の誘惑に勝つのが大事。不足分の50%ずつ3ヶ月かけて購入するだけで、適正水準への収束がかなり早くなった。

3. インスタンスファミリー単位でConvertible RIを活用する
インスタンスタイプ個別のStandard RIよりも、ファミリー単位でConvertible RIを束ねる方が柔軟に対応できる。特にEC2はアーキテクチャ変更が頻繁に起きるので重要だ。

4. 有効期限の意図的な分散(Staggering)
全RIが同じタイミングで切れると判断負荷が集中する。年4回に分散させるだけで運用が格段に楽になった。

5. OpenSearch・ElastiCacheのRIは意外と見落とされる
EC2・RDSはケアされていても、OpenSearchやElastiCacheのRIは後回しにされがちだ。サービス横断で定期的にレビューする仕組みを入れると発見が早い。うちのチームはこれで数十万円規模の無駄を発掘できた。


まずCost Explorerで現在のRIカバレッジレポートを全サービスで確認してみてほしい。カバレッジが70%を切っているサービスがあれば、今月中に不足分の半分だけRI購入する計画を立てるところから始めるのがおすすめだ。自動化は後からでも追いつける。まず「現状把握」が先だ。

皆さんのチームはRIとSavings Plans、どんな比率で使ってますか?特にOpenSearchやElastiCacheのRI活用事例はあまり情報が多くないので、知見があればぜひ教えてほしい。

U

Untanbaby

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

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

関連記事