一覧に戻る
    「リセラー配下が原因」は半分正しかった — 続編で覆った前提と、Savings Plans 検出への再設計
    開発ラボ
    PRこの記事には広告が含まれています

    「リセラー配下が原因」は半分正しかった — 続編で覆った前提と、Savings Plans 検出への再設計

    30 分で読める

    はじめに

    前回の記事で、AWSコスト監視ダッシュボードの値がコンソールと約2倍違っていた件を書きました。仮説をひとつずつ潰していった結果、原因は「対象アカウントがAWSリセラー配下だった」という構造的な事実だった、という話でした。

    この記事はその続きです。当時の調査で「乖離あり = リセラー payer 配下」という相関がきれいに成立したように見えたので、その判定軸でダッシュボードに自動注意書きを出す機能を実装しました。CloudFormationのクロスアカウントロールに organizations:DescribeOrganization 権限を追加し、対象アカウント7つすべてに反映させ、コードもひと通り書いて、テストを書いて、PRも出しました。

    「これで明日の朝レポートには黄色枠で注意書きが出るはず」と思ってJenkinsを動かしたところ、想定外のことが起きました。

    実装した直後に、自分で立てた前提が反例にぶつかって崩れたんです。

    正直、書きながら少し恥ずかしいのですが、この「実装して動かしてみたら、自分の前提自体が間違っていた」という体験が、今回もっとも残しておきたい学びだったので、その経緯と再設計の過程を記録に残します。


    実装した機能のおさらい

    前回の調査で立てた仮説はこうでした。

    監視対象アカウントがAWSリセラーのpayer配下にある場合、コンソール「コストと使用状況」の当月値とCost Explorer APIの値が乖離する。

    この仮説に基づいて、コスト監視ジョブにこういう機能を入れました。

    • Organizations APIの DescribeOrganization を叩いてpayer情報を取得
    • payer_account_id != prod_account ならリセラー配下と判定
    • result.jsonpayer_info フィールドを出力
    • ダッシュボードのAWSコスト比較カードに、リセラー配下なら黄色枠の注意書きを表示

    判定ロジックはこんなイメージです(簡略版)。

    def _fetch_payer_info(self, config):
        target_account = config.get("prod_account")
        # ...AssumeRoleしてOrganizations API呼び出し...
        response = org_client.describe_organization()
        organization = response.get("Organization") or {}
        payer_account_id = str(organization.get("MasterAccountId") or "")
        is_reseller_account = (
            bool(payer_account_id)
            and payer_account_id != str(target_account)
        )
        return {
            "payer_account_id": payer_account_id,
            "organization_id": organization.get("Id"),
            "is_reseller_account": is_reseller_account,
            "reseller_name": KNOWN_RESELLER_PAYERS.get(payer_account_id),
        }

    CloudFormationテンプレートには、クロスアカウントロールに専用のポリシードキュメントを追加しました。

    - PolicyName: !Sub "${RoleNamePrefix}-organizations-policy-${Environment}"
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Action:
              - organizations:DescribeOrganization
            Resource: '*'

    これを監視対象7アカウントすべてにChange-set経由で適用しました。Replacement: False / RequiresRecreation: Never であることを確認した上で、本番からdev環境まで順次更新しています。3アカウント+4アカウントで合計7、すべて UPDATE_COMPLETE

    ここまでは順調でした。Jenkinsで動作確認をして、注意書きが期待通り表示されることを確認すれば、PRをマージしてクローズ、という流れで終わるはずだったんです。


    動作確認で前提が崩れた

    CloudFormation更新が終わってJenkinsを動かしたあと、最初に見つかったのは小さな違和感でした。

    === アカウントA (prod) ===
    {
        "MasterAccountId": "<リセラーのpayer ID>",
        "Id": "o-xxxxxxxxxx",
        ...
    }
    === アカウントB (prod) ===
    {
        "MasterAccountId": "<リセラーのpayer ID>",  # ← 同じ
        ...
    }
    === アカウントC (prod) ===
    {
        "MasterAccountId": "<リセラーのpayer ID>",  # ← !? 同じ
        ...
    }

    前回「直契約相当」と判定していたアカウントCが、実はアカウントA / Bと同じAWSリセラー配下だった。

    前回記事を書いたときの根拠はこうでした。

    アカウントコンソール乖離Master Account
    アカウントAありリセラーのpayer (Account-X)
    アカウントBあり同上
    アカウントCなし(別 / 直契約相当)

    ところが今回、describe-organization を実際に叩いてみたら、アカウントCも同じpayer配下だったわけです。前回は他チームから「アカウントCは別契約だ」という運用上の認識を聞いて、それをそのまま信じていました。実APIを叩いていなかった。

    これだけだと「ふーん、認識が違っていたのね」で済むのですが、より重要な含意がもうひとつ出てきます。

    アカウントCはリセラー配下なのに、コンソール乖離は観測されていない。

    つまり、前回記事で立てた 「乖離あり = リセラー payer 配下」 という相関が完全には成り立たない、ということです。リセラー配下でも乖離するアカウントと、しないアカウントが混在する。

    ここで一度、判定軸を整理してみました。

    実装したコードは、アカウントCに対しても「リセラー配下です」という注意書きを出してしまいます。これはFalse Positiveです。注意書きが出るのに実際にはコンソールと乖離していないので、見た人を混乱させます。

    このとき選択肢が3つありました。

    • A: 文言を弱めて「乖離する場合があります」にしておく(最小修正)
    • B: 設定でオプトアウトできる仕組みを追加する
    • C: そもそもの判定軸を見直す(再設計)

    実害が小さいのはAだったので、ひとまずAで進めました。文言は元から「乖離する場合があります(実コストはこの値)」と弱めてあったので、運用ドキュメントの判別表だけ訂正して、「リセラー配下でも乖離有無は混在する」と書き直しました。

    ここでこのままマージしていたら、たぶんいつかまた誰かが詰まったと思います。


    dev群でも同じ反例が出て、ようやく「これは設計の話だ」と気づいた

    A案で運用ドキュメントを直したあと、dev環境のアカウント群(アカウントD / E)でも describe-organization の挙動を確認しました。

    すると、こちらも全部同じpayer配下でした。にもかかわらず、運用上はアカウントD / Eで乖離は観測されていません。アカウントCに加えて、さらに2つの反例が増えたわけです。

    アカウントリセラー配下?乖離(実観測)
    アカウントAYesあり
    アカウントBYesあり
    アカウントCYesなし(反例)
    アカウントDYesなし(反例)
    アカウントEYesなし(反例)

    5アカウント中3アカウントで前提が崩れている。これはもう「文言を弱めて妥協」では済まない量です。注意書きの大部分がFalse Positiveになる。

    このときに、運用側の経験則として教えてもらった一言が決定打でした。

    「アカウントD / Eって Savings Plans 使ってないんだよね。だから乖離してないんじゃない?」

    そう言われてみると、たしかに乖離があるアカウントA / Bは過去にSavings Plansを購入している環境です。アカウントC / D / Eは、運用規模が小さくてSPを買うほどではない環境。

    「もしかして、判定軸はリセラー配下かどうかではなくて、Savings Plansが適用されているかどうか なのでは?」

    前回記事の検証で、アカウントAについて UnblendedCost == AmortizedCost を観測したことから、私は「SP/RIの影響はない」と切り捨てていました。しかし、よく考えるとこれは メンバーアカウント側から見たときの整合性 を確認しただけで、SP がpayer側に所有されてメンバーに割り当てられているケース までは検証できていない可能性があります。

    ようやく「これは仮説の質を上げる場面だ」と腰を据えなおしました。


    API で実証する — 仮説の確定

    経験則だけで設計を変えるのはリスクが高いので、CE APIで直接観測することにしました。

    aws ce get-cost-and-usage \
      --time-period Start=2026-05-01,End=2026-05-08 \
      --granularity MONTHLY \
      --metrics UnblendedCost \
      --filter '{
        "Dimensions": {
          "Key": "RECORD_TYPE",
          "Values": [
            "SavingsPlanCoveredUsage",
            "SavingsPlanNegation",
            "SavingsPlanRecurringFee"
          ]
        }
      }'

    SavingsPlanCoveredUsage / SavingsPlanNegation / SavingsPlanRecurringFee の3レコードタイプは、SPが適用されているメンバーアカウントでのみ非ゼロ値が入ります。SP未適用なら全て0、もしくは丸め誤差レベル($1e-12 など)になります。

    7アカウントに対して同じクエリを流した結果がこちらです。

    アカウントSP レコード合計(5/1〜5/7)乖離(実観測)
    アカウントA$112.73あり
    アカウントB$177.41あり
    アカウントC$0なし
    アカウントD$0なし
    アカウントE$0なし
    アカウントF-$1e-12(≒0)なし想定
    アカウントG-$1e-13(≒0)なし想定

    「SP適用あり = 乖離あり」が完全に符合しました。

    この瞬間、ようやく「ああ、これは前回の調査が半分しか正しくなかったんだな」と腹に落ちました。前回の結論である「リセラー配下が原因」は、たまたま観測したアカウントA / Bが両方ともSP適用ありだったから成り立っていた相関で、本当の因果は別のところにあった、ということです。

    リセラー配下であることそれ自体は、コンソール乖離の必要条件でも十分条件でもありませんでした。SP適用の有無のほうが、はるかに精度が高い。


    判定軸の再設計

    ここからは設計の作り直しです。やることは多いですが、やるべきことは明確でした。

    判定軸を is_reseller_account から savings_plan_info.active に切り替える。文言も「リセラー payer 配下のアカウントです」ではなく「Savings Plans が適用されているため乖離する場合があります」に変える。CSSクラス名も reseller-notice から cost-discrepancy-notice に直す。テストも全面的に書き直す。

    ひとつ迷ったのは、payer_info フィールドをどうするかです。判定からは外れるので削除してもよかったのですが、Reseller配下かどうか自体は運用情報として価値があります(経理側の管理単位とつき合わせるとき、誰がpayerかは結局知りたい)。なので、判定からは外すけれど運用情報として残す、という方針にしました。

    # 新しい判定: SP 適用検出ベース
    def _fetch_savings_plans_usage(self, config, context):
        """Cost Explorer から SP 利用状況を取得。失敗時は None。"""
        # AssumeRole 認証情報の選択(prod/dev 単独構成にも対応)
        target_account = config.get("prod_account") or config.get("dev_account")
        if not target_account:
            return None
        
        # ...省略: client構築...
        
        response = ce_client.get_cost_and_usage(
            TimePeriod={"Start": period_start, "End": period_end},
            Granularity="MONTHLY",
            Metrics=["UnblendedCost"],
            Filter={
                "Dimensions": {
                    "Key": "RECORD_TYPE",
                    "Values": SAVINGS_PLAN_RECORD_TYPES,
                }
            },
        )
        
        total = sum(
            float(item["Total"]["UnblendedCost"]["Amount"])
            for item in response.get("ResultsByTime", [])
        )
        
        return {
            "active": abs(total) > SAVINGS_PLAN_ACTIVE_THRESHOLD_USD,
            "monthly_amount": round(total, 6),
            "currency": "USD",
            "period": {"start": period_start, "end": period_end},
        }

    閾値の 0.01 USD は、丸め誤差で出てくる -$1e-12 などをfalseと判定するための保険です。後々別アカウントを追加したときに、丸め誤差で active=true がチラつくのが嫌だったので入れました。

    地味に手を入れたのが _fetch_payer_info の方で、dev単独構成のYAMLにも対応するように直しました。元の実装は prod_account を必須前提にしていたので、prod_account キーがないdev専用YAMLでは早期returnして payer_info が出ない状態になっていました。

    target_account = config.get("prod_account") or config.get("dev_account")
    if not target_account:
        return None
    
    # AssumeRole 用の認証情報も対応する prod/dev 系のキーを使い分ける
    if config.get("prod_account"):
        role_arn = config.get("role_arn")
        external_id = config.get("external_id")
    else:
        role_arn = config.get("dev_role_arn")
        external_id = config.get("dev_external_id")

    これは前提を変える前にも一度直していたのですが、結果的にSP検出機能でも同じ構造が必要になりました。設計の前提が変わっても、AssumeRole用の認証情報切り替えは共通基盤として機能するので、結果的にきれいな再利用になりました。


    動作確認 — 期待通りの表示

    再設計したコードをJenkinsで動かしてみたところ、期待通りの結果になりました。

    カードSP active注意書き
    アカウントAtrueあり
    アカウントBtrueあり
    アカウントCfalseなし
    アカウントDfalseなし
    アカウントEfalseなし

    実観測の乖離有無と完全一致。リセラー軸で出ていた3件のFalse Positiveが解消されました。result.json には savings_plan_infopayer_info の両方が出力されており、運用情報として payer_info も残っています。

    {
      "service": "AWS",
      "currency": "USD",
      "period": {"start": "2026-05-01", "end": "2026-05-07"},
      "current": {"cost": 1275.51},
      "savings_plan_info": {
        "active": true,
        "monthly_amount": 177.41,
        "currency": "USD",
        "period": {"start": "2026-05-01", "end": "2026-05-08"}
      },
      "payer_info": {
        "payer_account_id": "<masked>",
        "organization_id": "o-xxxxxxxxxx",
        "is_reseller_account": true,
        "reseller_name": "<masked>"
      },
      "status": "OK"
    }

    ここに至って、ようやく「マージしてよし」の状態になりました。当初の実装からトータルで4コミットの追加修正、ファイル7つに変更、コード+550行ほどの再設計でした。


    PRの中で「自分自身の前提を撤回する」ということ

    最終的にPRの本文も書き直しました。当初は「Reseller配下のアカウントに注意書きを出す」というタイトル・本文だったのですが、再設計後は「Savings Plans 適用アカウントに乖離注意書きを表示」に変えています。

    PR本文の冒頭にこんな段落を入れました。

    当初は Reseller payer 配下 で判定する設計でしたが、検証の結果、Savings Plansの適用有無が真の判定軸であることが判明し、redesignを行いました(経緯は下記)。

    これを書くのは、けっこう抵抗があります。「最初の設計が間違っていました」と公開で書くのは、まあ誰でも嫌です。でも、PR本文に「途中で前提を撤回した」と明記しておかないと、後でこのPRを参照した人が「リセラー配下を判定するロジック」だと誤解する可能性がある。そうなると未来の自分も含めて、また同じ落とし穴に落ちかねません。

    判定軸を直接「乖離あり/なし」ではなく「SP適用あり/なし」に置いた理由も、本文の検証データ表で示すようにしました。これで、未来の誰かが「なんでSP判定なの? もっと直接的にできないの?」と思ったときに、「リセラー判定で実害が出たから乗り換えた」という経緯まで遡れるはずです。


    学び — 仮説駆動の落とし穴と、検証してから redesign すること

    今回の作業で残った学びは、ざっと3つあります。

    「相関の素性」を確認する前に設計に組み込まない

    前回の調査で「リセラー配下 = 乖離」という相関を見つけたとき、それが「サンプル3件中3件成立」でしかなかったことを、私はあまり重く見ていませんでした。3件中3件と聞くと「100%だ」と感じてしまいますが、サンプル数が3だと相関の精度を担保するには弱すぎます。今回、サンプルが5件、7件と増えた瞬間に相関は崩れました。

    「相関の素性が確認できる前に、その相関を実装に焼き付けない」というのは、今回いちばん身に染みた学びです。実装してから前提が崩れると、コードを直すコストが想像以上に大きい(コード本体だけでなく、テスト、ドキュメント、CFN、レビュアーへの説明、PRタイトル全部直し、と波及します)。

    「経験則」をそのまま信じず、APIで再現可能な形に翻訳する

    今回、redesignの決め手になったのは「アカウントD / EはSP使ってないから乖離してないんじゃない?」という運用側の経験則でした。これがなかったらたぶん文言調整だけで止まっていました。

    ただ、経験則をそのまま実装に持ち込むのも危ないので、CE APIの RECORD_TYPE フィルタで自動判定できる形に翻訳してから組み込みました。経験則 → APIで観測可能 → コードで再現可能 という橋を渡せたのは、今回いちばんスムーズだった部分です。経験則は地図ではあるけれど、地図そのものを実装に貼り付けてはいけない、というのを改めて感じました。

    「前提の撤回」をコミットログとPR本文に残す

    今回、docs(monitoring): Reseller 配下でも乖離が混在することを反映 というコミットメッセージを途中で入れました。最初は「lint修正」みたいに小さく書こうかとも思ったのですが、ここで前提が崩れたという事実は、後から見たときに大きなマーカーになるので、あえて目立つメッセージにしています。

    そのあと feat(monitoring): 乖離注意書きの判定軸を Savings Plans 検出ベースに切替 を続けました。コミットログを見るだけで、「最初の設計→反例で前提が崩れた→再設計」というストーリーが追えるようにしたつもりです。

    未来の自分が同じ問題に出会ったとき、コミットログとPR本文だけで「あの時こう判断したのか」がわかる状態にしておきたい。これは前回の調査で troubleshooting.md を書いたときと同じ動機で、「結論の倉庫」ではなく「思考の地図」を残すことに、今回も助けられました。


    おわりに

    「リセラー配下が原因」という前回の結論は、半分正しくて半分間違っていた、というのが今回の総括になります。乖離があるアカウントが全部リセラー配下だったのは事実です。ただ、その逆——リセラー配下のアカウントが全部乖離していた——は事実ではありませんでした。

    正直なところ、前回記事を書いたときに私は「もうこの件は決着がついた」と思っていました。仮説を立てて、検証して、ドキュメントに残して、知見にした。次に同じ問題が来たら30分で解ける状態にした、と。

    ところが、その「決着」を前提にした実装が、動かしてみたら反例にぶつかって崩れた。これは技術の話としては「ありがちなパターン」なんですが、自分の中では結構な体験で、「決着がついたと思った瞬間に、再検証の窓は閉じている」という感覚が残りました。

    仮説駆動のいいところは、考えを前に進められる推進力が出ることです。でも、その推進力で実装まで進めてしまうと、前提が崩れたときに止まれません。サンプルがまだ少ない段階で立てた仮説は、「正しい結論」ではなく「次の検証の入り口」として扱う。そのくらいの距離感で持っていたほうが、たぶん長く走れる。

    今回のように、自分の前回の記事を引用しながら「あの結論は半分しか正しくなかった」と書く機会は、できれば多くないほうがいい——けれど、出てしまったときに「それは恥ずかしい話なので隠す」のではなく、「経緯ごと残す」ほうが、結果的には誰かの助けになるんじゃないかと思っています。

    今後、新しい監視対象を増やすときは、describe-organization でpayerを確認するチェックに加えて、CE APIでSPレコードの有無を観測するチェックも入れる予定です。違和感をきっかけに増えたチェック項目が、また増えました。


    関連書籍

    今回のような AWS 運用・設計判断のリトラクト・PR/コミットログのドキュメンテーションについて、さらに学びたい方におすすめの書籍です。

    この記事は役に立ちましたか?

    Coffee cup

    この記事が、何かの整理につながったら

    コーヒー1杯分の応援をもらえると嬉しいです。

    ※ これは応援とは別の話ですが、

    同じようなテーマを自分の文脈で整理したい場合は、 (文章だけだと詰まりやすい人向けに) 思考整理の壁打ちという形で対話の時間も取っています。

    対話の時間について