
「リセラー配下が原因」は半分正しかった — 続編で覆った前提と、Savings Plans 検出への再設計
はじめに
前回の記事で、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.jsonにpayer_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つの反例が増えたわけです。
| アカウント | リセラー配下? | 乖離(実観測) |
|---|---|---|
| アカウントA | Yes | あり |
| アカウントB | Yes | あり |
| アカウントC | Yes | なし(反例) |
| アカウントD | Yes | なし(反例) |
| アカウントE | Yes | なし(反例) |
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 | 注意書き |
|---|---|---|
| アカウントA | true | あり |
| アカウントB | true | あり |
| アカウントC | false | なし |
| アカウントD | false | なし |
| アカウントE | false | なし |
実観測の乖離有無と完全一致。リセラー軸で出ていた3件のFalse Positiveが解消されました。result.json には savings_plan_info と payer_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/コミットログのドキュメンテーションについて、さらに学びたい方におすすめの書籍です。
この記事は役に立ちましたか?

この記事が、何かの整理につながったら
コーヒー1杯分の応援をもらえると嬉しいです。
あなたへのおすすめ
