コンテンツにスキップ

コストのガードレール

ひとことで

従量課金(PAYG)のアカウントには費用の上限がなく、予算(Budget)もメールを送るだけでサービスは止めません。そのため 3 層で守ります: ① 予防:コードを書く段階で課金リソースが作れないようにする(validation、直書き)。② 検知:費用が発生したらすぐ気づく(予算アラート、API で実際の費用を確認)。③ 損失の抑止:異常時に自動で止める(アラーム / 予算 → Function がサイトを非公開にする)。

このページは Phase 0、Phase 8、Phase 10 と、以前の「Always Free ガードレール」のページをまとめたものです。

1. まず構成要素を知る

OCI AWS ひとことで
Always Free 無料利用枠(一部は 12 か月限定) OCI の無料枠は期限なし。ただし超えた分は通常どおり課金される
PAYG(Pay As You Go) 同じ 従量課金のアカウント。クレジットカードが紐づいていて、上限はない
コンパートメント(Compartment) (近い)リソース・グループ / アカウントの分割 費用もこの単位で集計される。演習リソースはすべて tf-lab に置き、単独で予算を設定しやすくする
予算(Budget)+ アラート・ルール AWS Budgets 金額 + ルール(実際 / 予測の費用がいくらを超えたら通知)。通知するだけで、サービスは止めない
コスト分析 / Usage API Cost Explorer 実際にいくら使ったかを見る(数時間〜約 1 日の遅れがある)
モニタリングのアラーム(Monitoring Alarm) CloudWatch Alarm メトリック(例:リクエスト数)がしきい値を超えたら発火
通知(Notifications、トピック) SNS アラームをメール、Function などに送る
イベント(Events) EventBridge サービスが出すイベント(例:予算アラート)→ トピックなどに転送
ファンクション(Functions) Lambda 呼ばれたときだけ動くプログラム。実行時間で課金
OCIR ECR コンテナ・レジストリ。Function のプログラムはイメージにしてここに置く

2. しくみ:なぜ「予算を設定した」だけでは足りないのか

事実 結果
PAYG には費用の上限がない シェイプを 1 つ書き間違えたり、大量のアクセスを受けたりすると、費用は増え続ける
予算はメールを送るだけで、サービスは止めない 夜中にメールが来ても対応が間に合わない
予算の評価はリアルタイムではない(このリポジトリの記録では数時間〜約 1 日) 気づいたときにはすでに使われている
オブジェクト・ストレージにはレート制限がない 公開サイトに大量のリクエストが来ると、リクエスト料 / 転送料もそれに比例して増える(Denial of Wallet)

そのため 3 層のどれも欠かせません:

flowchart LR
  A["① 予防<br/>無料の構成だけ使う<br/>コードに書き込む"] --> B["② 検知<br/>予算アラート<br/>実際の費用を確認"]
  B --> C["③ 損失の抑止<br/>サイトを自動停止"]

静的サイトのリスクは主にお金

静的サイトにはサーバー側のプログラム、DB、フォームがないため、SQL インジェクションや XSS などは成り立ちません。本当のリスクは大量のリクエストを受けたときの費用です:オブジェクト・ストレージの API リクエストは月 5 万回まで無料で、超えると 1 万回あたり約 $0.0034。アウトバウンド転送量は月 10TB まで無料で、超えると GB 単位で課金されます。

3. 第 ① 層:予防

3.1 Always Free のルール

リソース ルール 守らなかった場合
リージョン(Region) ホーム・リージョン(Home Region、ap-osaka-1)だけを使う Always Free の Compute はホーム・リージョンにしか作れない
Compute A1.Flex は合計 4 OCPU / 24GB、または E2.1.Micro を最大 2 台 超えた分は従量課金
ブロック・ボリューム(Block Volume) ブート・ボリュームも含めて合計 200GB ブート・ボリュームは最小 50GB → 実質 VM は最大 4 台
ロード・バランサ(Load Balancer) flexible、帯域 min = max = 10Mbps、1 つだけ デフォルト値やその他の帯域は課金対象
Autonomous DB is_free_tier = true 漏れると有償の ADB になる
OKE type = "BASIC_CLUSTER" ENHANCED はコントロール・プレーンが時間課金
オブジェクト・ストレージ(Object Storage) 20GB、月 5 万リクエスト 超えると課金

計算リソースの詳細は コンピュートとロード・バランサ を参照してください。

3.2 ルールをコードに書き込む

記憶に頼るのは危険です:

# 無料のシェイプだけを許可:書き間違えたら plan の段階でエラー(02-compute/variables.tf、抜粋)
variable "instance_shape" {
  type    = string
  default = "VM.Standard.A1.Flex"
  validation {
    condition     = contains(["VM.Standard.A1.Flex", "VM.Standard.E2.1.Micro"], var.instance_shape)
    error_message = "Always Free のシェイプのみ指定できます:VM.Standard.A1.Flex または VM.Standard.E2.1.Micro。"
  }
}
  • validation にできるものは validation にする:シェイプ、LB の帯域
  • 「間違えると課金される」値は変数にせず、直書きする:ADB の is_free_tier = true、OKE の type = "BASIC_CLUSTER"。変数にすると変更される可能性が生まれる
  • 演習が終わったらすぐ terraform destroy。次の Phase で枠が必要なときは、先に前の Phase を削除する

4. 第 ② 層:検知

4.1 予算アラート(実際の設定)

クラウド 対象 金額 アラート・ルール
OCI コンパートメント tf-lab 1,000 円 / 月 予測が 80 円を超えたとき。実際が 300 円を超えたら → サイトの自動停止も発動
OCI テナンシ全体 1,000 円 / 月 実際が 80% を超えたとき
AWS アカウント全体 20 USD / 月 実際 50% / 100%、予測 80% / 100%
  • 2 段階で設定する:tf-lab は演習リソースを見張り、テナンシ全体は最後の砦(演習以外の場所で費用が出ても気づける)
  • OCI の予算はコンパートメントごとに 1 つだけ

4.2 API で実際の費用を確認する

python tools/cost.py --days 7     # OCI Usage API:サービス + SKU ごとに集計。コンソールを開かなくてよい
  • データには数時間〜約 1 日の遅れがあり、当日の数字は不完全
  • AWS:aws ce get-cost-and-usage --granularity MONTHLY --group-by Type=DIMENSION,Key=SERVICE

実際に確認して見つかった意外なこと 2 つ。どちらも「無料だと思っていた / 計算済みだと思っていた」ものです:

見つかったこと 教訓
OCI DevOps のビルド・ランナーは従量課金だった(デフォルト 2 OCPU / 8GB、SKU Standard - E4 (DevOps service Build Run))。最初は個別の課金項目はないと思っていたが、あとで請求に出てきた(1 日 0.61 円) 「無料に見える」サービスにも課金 SKU があることがある → 実際の請求で確認する
AWS のドメインは $16 / 年だが、実際の請求は $17.60 = 2,807 円 予算を立てるときに消費税(10%)と為替を計算に入れていなかった

5. 第 ③ 層:損失の抑止(サイトの自動停止)

Phase 8 の設計:サイトが大量にアクセスされたとき、または tf-lab の実際の費用が 300 円を超えたとき、Function が自動でサイトのバケットを非公開にします。

flowchart TB
  req["大量のリクエスト"] --> bucket["サイトのバケット"]
  bucket -->|"リクエスト数のメトリック"| alarm["モニタリングのアラーム<br/>1 時間 > 10,000 回"]
  budget["予算<br/>実際 > 300 円"]
  alarm --> topic["通知トピック"]
  budget -->|"イベント"| topic
  topic --> mail["メール"]
  topic --> fn["Function"]
  fn -.->|"NoPublicAccess に変更"| bucket
層 発動条件 速さ 役割
リクエスト数 サイトのバケットへのリクエストが 1 時間に 10,000 回を超える(通常は 20〜50 回) 約 3.5 分(実測) メイン
予算 tf-lab の当月の実際の費用が 300 円を超える 遅い(予算自体に遅延がある) 最後の砦:リクエスト数では見えない費用(転送料、ほかのリソース)
手動 07-site で terraform apply "-var=site_public=false" 即時 緊急時

復旧は 07-site で terraform apply するだけです。Terraform が NoPublicAccess をドリフトとして扱い、公開に戻します。

# 第 1 層のアラーム(08-guard/alarm.tf、抜粋)
#   ディメンション名 resourceDisplayName は list_metrics で調べたもので、推測ではない
resource "oci_monitoring_alarm" "site_requests" {
  namespace        = "oci_objectstorage"
  query            = "AllRequests[1h]{resourceDisplayName = \"${var.site_bucket_name}\"}.sum() > ${var.request_threshold}"
  severity         = "CRITICAL"
  pending_duration = "PT1M"
  message_format   = "ONS_OPTIMIZED"   # メールは人が読む形式に。Function が受け取るのは JSON のまま
  destinations     = [oci_ons_notification_topic.guard.id]
}

Function の判断:

受け取ったメッセージ 動作 理由
アラーム発火(OK_TO_FIRING) 公開を停止
アラーム回復(FIRING_TO_OK / RESET) スキップ スキップしないと、手動でサイトを復旧したあとにまた止められる
予算のイベント イベントの内容は見ず、予算の実際の費用を自分で確認し、300 円以上のときだけ停止 ほかの予算や予測費用のルールのイベントも届く。イベントの JSON 構造は公開されたサンプルが見つからない
空のメッセージ(手動の呼び出し) 公開を停止 緊急時

設計のポイント:

  • Function の権限は最小:サイトのバケットの設定の変更(use buckets + バケット名で限定)と、予算の読み取りだけ(セキュリティ を参照)
  • 既存の予算はデータ・ソースで読み、import しない:import すると、08-guard を destroy したときに Phase 0 で作った予算まで削除される。アラート・ルールを追加するだけにする
  • しきい値を下げたテストは -var だけで行い、コードは変えない:-var=request_threshold=50

6. AWS 側:定額料金プラン

Phase 10 の AWS 版サイトは、CloudFront の定額料金プランの Freeを使い、そもそも上限を設けています:

従量課金 Free($0 / 月)
月の枠 1TB / 1,000 万リクエスト(期限なしの無料枠) 100GB / 100 万リクエスト
超えたとき 通常どおり課金。上限なし 課金されない。超過が続くと配信性能が下がる
攻撃を受けたとき 攻撃のトラフィックにも課金される WAF / DDoS 対策でブロックしたリクエストは使用量に含まれない
WAF 別料金 含まれる(ルール最大 5 個)

このリポジトリでは WAF ルールを追加しています:同じ IP から 5 分間に 500 回以上でブロック、さらにマネージド・ルール・グループを 3 つ。

OCI と AWS は同じ問題を、違う考え方で解いています:OCI は「検知したら自動で止める」、AWS は「超えてもお金がかからない」。

7. 設計判断の一覧

判断 採用 不採用 理由
課金リソースを作らせない validation + 直書き 記憶に頼る / 変数にする plan の段階でエラーになる。直書きした値は誰にも変えられない
アラートの範囲 tf-lab + テナンシの 2 段階の予算 1 つだけ設定 演習以外の場所で費用が出ても気づける
第 1 層の発動 モニタリングのリクエスト数(数分) 予算だけに頼る 予算には数時間〜1 日の遅れがある
損失の抑止の動作 バケットを非公開にする バケット / リソースを削除する 復旧は terraform apply 1 回だけで、データも失わない
予算イベントの扱い Function が実際の費用を自分で確認 イベントの内容で判断 イベントの構造が不確か。ほかのルールのイベントが来ても安全
既存の予算 データ・ソースで読み + アラート・ルールを追加 import import すると destroy で削除される
AWS の転送料 定額料金プランの Free + WAF 従量課金 + アラーム 超えても課金されないので、損失の抑止を自分で作る必要がない

8. 検証

証明したいこと 方法 結果
エンドツーエンド:リクエストが増えると自動で止まる しきい値を一時的に 50 にし、curl で 100 回アクセス 約 3.5 分後に Function が呼ばれる → サイトが 404
復旧後にまた止められない(逆方向) サイトを復旧 → しきい値を戻す → アラームが回復 Function はスキップ(FIRING_TO_OK)、サイトは 200 のまま
復旧は apply だけで済む 07-site で terraform apply NoPublicAccess -> ObjectReadWithoutList → 200
予算の層:枠を超えていなければ止めない(逆方向) Function を直接呼び出し、模擬の予算イベントを渡す スキップ(実際の費用 0 < 300)→ 予算を読めており、権限もネットワークも通っている
実際の費用 tools/cost.py Phase 0〜6 はすべて 0 円。その後、DevOps のビルド・ランナーの費用が出てきた(1 日 0.61 円)

テストできていない部分

本物の予算アラートのイベントは、実際の費用が 300 円を超えないと届きません。そのため予算の層のエンドツーエンドはテストしていません。Function は最初に受け取ったイベントの原文をログに書くので、そのときに確認します。

9. ハマりどころ

現象 原因 対策
復旧したサイトがまた止まる アラーム回復(FIRING_TO_OK)のメッセージでも Function が呼ばれる Function で FIRING_TO_OK / RESET をスキップ
ほかの予算のアラートでもサイトが止まる イベントの JSON 構造が調べられず、イベント・ルールはイベント・タイプだけで絞り込んでいる → すべての予算・すべてのルールのイベントが届く Function はイベントの内容を見ず、実際の費用を自分で確認
予算を新規作成 → 400-LimitExceeded 1 つのコンパートメントに予算は 1 つだけ データ・ソースで既存のものを読み、アラート・ルールだけを追加
oci_budget_budgets を display_name で検索したら 2 つ返ってきた この引数では絞り込まれない filter ブロックを使う
アラート・ルールで毎回 plan に freeform_tags の差分が出る API がアラート・ルールのタグを保存しない アラート・ルールにはタグを書かない。apply のあとは必ずもう一度 plan
アラームのメールが長い JSON message_format = "PRETTY_JSON" ONS_OPTIMIZED(変わるのはメールだけで、Function が受け取るのは JSON のまま)
DevOps のビルドは無料だと思っていた ビルド・ランナーは従量課金 tools/cost.py で実際の SKU を確認
ドメインの実際の費用が予算より多い 消費税と為替を計算していなかった 予算を立てるときに含める

10. 確認問題

1. 予算アラートを設定すれば、使いすぎることはありませんか?

いいえ。予算はメールを送るだけでサービスを止めず、評価にも数時間〜1 日の遅れがあります。「予防」(validation、直書き)と「損失の抑止」(自動停止)を組み合わせる必要があります。

2. ADB の is_free_tier を変数にせず直書きするのはなぜですか?

間違えると課金される値を変数にすると、変更される可能性が生まれます(tfvars の書き間違い、ほかの人による上書き)。コードに直書きすれば、変更するにはコードを変えてレビューを通す必要があります。

3. 第 1 層に予算ではなく、リクエスト数のアラームを使うのはなぜですか?

予算には数時間〜1 日の遅れがあり、大量のアクセスを受けたときには間に合いません。リクエスト数のメトリックは数分で反映され、実測では約 3.5 分で停止できました。

4. 手動でサイトを復旧したあと、アラーム回復のメッセージで Function がまた呼ばれると、どうなりますか?どう防ぎますか?

何もしなければ、Function がまたサイトを止めてしまいます。そのため Function は FIRING_TO_OK / RESET を受け取ったらスキップします。

5. すでに予算が 1 つあり、Terraform でアラート・ルールを追加したい。なぜ import しないのですか?

import するとその予算は Terraform の管理下に入り、そのディレクトリを destroy したときに一緒に削除されます。データ・ソースで読み、新しく追加するアラート・ルールだけを管理します。

6. 損失の抑止の動作が、バケットの削除ではなく「非公開にする」なのはなぜですか?

非公開にすればすぐにアクセスを止められ、データも失わず、復旧は terraform apply 1 回だけで済みます。削除すると再アップロードが必要で、誤って削除するおそれもあります。

7. AWS の定額料金プランと OCI の自動停止は、考え方がどう違いますか?

OCI:従量課金なので、異常を検知したら自動でサービスを止める。AWS の定額料金プラン:枠を超えても課金されず(配信性能が下がる)、WAF でブロックしたリクエストも使用量に含まれないので、損失の抑止を自分で作る必要がありません。

コード全文

08-guard/alarm.tf(トピック / サブスクリプション / アラーム)
# ------------------------------------------------------------
# Phase 8:サイトのバケットのリクエスト数を監視 → しきい値を超えたら通知 + Function を呼び出す
#   予算(Budget、費用)は約 1 日遅れる → モニタリング(Monitoring)のオブジェクト・ストレージのメトリック(数分以内)を使う
# ------------------------------------------------------------

resource "oci_ons_notification_topic" "guard" {
  compartment_id = var.compartment_ocid
  name           = "${var.project_name}-guard"
  description    = "Site request alarm -> email + site-guard function"

  freeform_tags = local.common_tags
}

# メール:apply 後に確認メールが届き、リンクをクリックすると有効になる(それまでは PENDING)
resource "oci_ons_subscription" "email" {
  compartment_id = var.compartment_ocid
  topic_id       = oci_ons_notification_topic.guard.id
  protocol       = "EMAIL"
  endpoint       = var.alert_email

  freeform_tags = local.common_tags
}

resource "oci_ons_subscription" "function" {
  count = var.function_image == "" ? 0 : 1

  compartment_id = var.compartment_ocid
  topic_id       = oci_ons_notification_topic.guard.id
  protocol       = "ORACLE_FUNCTIONS"
  endpoint       = oci_functions_function.site_guard[0].id

  freeform_tags = local.common_tags
}

# AllRequests:バケットが受けたすべての HTTP リクエスト(匿名のサイト・アクセス + アップロードなど)
#   ディメンション resourceDisplayName = バケット名(2026-10-28 に list_metrics で確認)
#   [1h]...sum():直近 1 時間の合計
resource "oci_monitoring_alarm" "site_requests" {
  compartment_id        = var.compartment_ocid
  metric_compartment_id = var.compartment_ocid
  display_name          = "${var.project_name}-site-requests"
  namespace             = "oci_objectstorage"
  query                 = "AllRequests[1h]{resourceDisplayName = \"${var.site_bucket_name}\"}.sum() > ${var.request_threshold}"
  severity              = "CRITICAL"
  is_enabled            = true
  pending_duration      = "PT1M"
  message_format        = "ONS_OPTIMIZED" # メールは人が読みやすい形式(EMAIL サブスクリプションにだけ適用)。Function が受け取るのは JSON のまま
  destinations          = [oci_ons_notification_topic.guard.id]

  body = "Requests to ${var.site_bucket_name} exceeded ${var.request_threshold}/hour. site-guard sets the bucket to NoPublicAccess. Restore: terraform apply in 07-site."

  freeform_tags = local.common_tags
}
08-guard/budget.tf(既存の予算 + アラート・ルール + イベント)
# ------------------------------------------------------------
# Phase 8 第 2 層:予算(実際の費用)→ イベント(Events)→ トピック → 同じ Function
#   第 1 層(リクエスト数のアラーム)では見えない費用(転送料、tf-lab 内のほかのリソースなど)の最後の砦
#   予算には遅延がある(ドキュメント:約 24 時間ごとに評価)→ あくまで保険。速いのは第 1 層
#
# 対象コンパートメントごとに予算は 1 つだけ(新規作成 → 400-LimitExceeded)
#   → Phase 0 で手動作成した TF-1000en(対象 = tf-lab)を使い、ACTUAL のアラート・ルールを 1 つ追加するだけ
#   → import しない:import すると 08-guard の destroy で TF-1000en も削除される。データ・ソースは読み取りのみ
#
# イベントの JSON 構造が調べられない(alertRuleId があるか不明)→ Function はイベントの内容を見ず、
#   予算のイベントを受け取ったら自分で予算 API を呼ぶ:actual_spend >= GUARD_THRESHOLD のときだけ停止
#   (TF-1000en の既存の FORECAST ルールやルートの予算のイベントが来ても、1 回確認してスキップするだけ)
# ------------------------------------------------------------

# 予算はルート・コンパートメント(テナンシ)にある
data "oci_budget_budgets" "guard" {
  count = var.enable_budget_guard ? 1 : 0

  compartment_id = data.oci_identity_compartment.lab.compartment_id
  target_type    = "COMPARTMENT"

  # display_name 引数はサーバー側で絞り込まれない(実測で 2 つ返ってきた)→ filter ブロックでプロバイダー側で絞り込む
  filter {
    name   = "display_name"
    values = [var.guard_budget_name]
  }
}

locals {
  # one():2 つ以上ならエラー。0 個(名前の間違い)→ null → アラート・ルールに budget_id がなく、plan の時点でエラー
  guard_budget_id = var.enable_budget_guard ? one(data.oci_budget_budgets.guard[0].budgets).id : null
}

# 実際の費用(ACTUAL)が金額(ABSOLUTE)を超えた → アラート(メール + イベント)
resource "oci_budget_alert_rule" "site_guard" {
  count = var.enable_budget_guard ? 1 : 0

  budget_id      = local.guard_budget_id
  display_name   = "${var.project_name}-site-guard-actual"
  type           = "ACTUAL"
  threshold_type = "ABSOLUTE"
  threshold      = var.guard_budget_threshold
  recipients     = var.alert_email
  message        = "tf-lab actual spend exceeded the guard threshold. site-guard sets ${var.site_bucket_name} to NoPublicAccess."

  # freeform_tags は書かない:アラート・ルールの API はタグを保存しない(作成後に読むと {})→ 書くと毎回 plan に差分が出る
}

# 予算アラートのイベント → アラート用トピック(→ メール + Function)
#   eventType:docs.oracle.com「Services that Produce Events」の Budgets(com.oraclecloud.budgets.createtriggeredalert)
#   イベントは予算のあるルート・コンパートメントで発生する → ルールもルートに作る
resource "oci_events_rule" "budget_alert" {
  count = var.enable_budget_guard ? 1 : 0

  compartment_id = data.oci_identity_compartment.lab.compartment_id
  display_name   = "${var.project_name}-site-guard-budget"
  description    = "Budget alert -> ${oci_ons_notification_topic.guard.name}"
  is_enabled     = true
  condition = jsonencode({
    eventType = ["com.oraclecloud.budgets.createtriggeredalert"]
  })

  # 内側のブロック:旧名の actions は非推奨 → action(プロバイダーのスキーマで確認)
  actions {
    action {
      action_type = "ONS"
      is_enabled  = true
      topic_id    = oci_ons_notification_topic.guard.id
    }
  }

  freeform_tags = local.common_tags
}

# Function が予算の actual_spend を読む
#   予算はルート・コンパートメントにある → in tenancy で許可し、ポリシーもルートに作る(読み取りのみ)
resource "oci_identity_policy" "guard_budget" {
  count = var.enable_budget_guard ? 1 : 0

  compartment_id = data.oci_identity_compartment.lab.compartment_id
  name           = "${var.project_name}-guard-budget"
  description    = "site-guard function reads budgets (actual spend)"

  statements = [
    "Allow dynamic-group ${oci_identity_dynamic_group.guard_fn.name} to read usage-budgets in tenancy",
  ]

  freeform_tags = local.common_tags
}
08-guard/function/func.py(サイトを止める Function)
"""
site-guard:サイトのバケットを NoPublicAccess にする(公開を停止)
  呼び出し元:通知トピック ← ① モニタリングのアラーム(リクエスト数) ② イベント(予算アラート、第 2 層)/ 手動 invoke
  認証:リソース・プリンシパル(動的グループ tflab-guard-fn:tflab-site バケットを use できるだけ + read usage-budgets)
  復旧:07-site で terraform apply(Terraform がドリフトとして ObjectReadWithoutList に戻す)
"""
import io
import json
import logging
import os

import oci
from fdk import response

# アラームの回復(FIRING_TO_OK)/ リセット(RESET)でもメッセージが来る → 何もしない
#   スキップしないと:サイトを手動で復旧したあと、アラーム回復のメッセージでまたサイトが止まる
IGNORE_TYPES = {"FIRING_TO_OK", "RESET"}

# 予算アラートのイベント(イベント → トピック → ここ)
#   テナンシ内のすべての予算・すべてのアラート・ルール(FORECAST など)のイベントが来る。イベントの JSON 構造も未検証
#   → イベントの内容は見ず、GUARD_BUDGET_ID の actual_spend を自分で確認し、GUARD_THRESHOLD 以上のときだけ停止
BUDGET_EVENT_PREFIX = "com.oraclecloud.budgets."


def handler(ctx, data: io.BytesIO = None):
    log = logging.getLogger()
    bucket = os.environ["BUCKET_NAME"]  # アプリケーションの config

    # アラームのメッセージは JSON(ONS_OPTIMIZED が変えるのはメールの形式だけ)。予算のイベントも JSON。手動 invoke なら空でもよい
    body = {}
    try:
        body = json.loads(data.getvalue() or b"{}")
    except (ValueError, AttributeError):
        pass
    alarm_type = body.get("type")
    event_type = body.get("eventType")

    if alarm_type in IGNORE_TYPES:
        log.info("skip: alarm type %s", alarm_type)
        return _json(ctx, {"action": "skip", "alarm_type": alarm_type})

    signer = oci.auth.signers.get_resource_principals_signer()

    if event_type:
        # 本物のイベントが初めて来たとき、ログを見れば JSON 構造がわかる
        log.info("event payload: %s", json.dumps(body)[:2000])
        if not event_type.startswith(BUDGET_EVENT_PREFIX):
            log.info("skip: not a budget event")
            return _json(ctx, {"action": "skip", "event_type": event_type})
        budget_id = os.environ.get("GUARD_BUDGET_ID")
        threshold = os.environ.get("GUARD_THRESHOLD")
        if not budget_id or not threshold:  # 第 2 層がオフ(未設定)→ 止めない
            log.info("skip: budget guard not configured")
            return _json(ctx, {"action": "skip", "event_type": event_type})
        budget = oci.budget.BudgetClient({"region": signer.region}, signer=signer).get_budget(budget_id).data
        actual = budget.actual_spend or 0
        log.info("budget %s: actual_spend=%s threshold=%s (computed %s)",
                 budget.display_name, actual, threshold, budget.time_spend_computed)
        if actual < float(threshold):
            return _json(ctx, {"action": "skip", "event_type": event_type,
                               "actual_spend": actual, "threshold": float(threshold)})

    client = oci.object_storage.ObjectStorageClient({"region": signer.region}, signer=signer)
    ns = client.get_namespace().data

    before = client.get_bucket(ns, bucket).data.public_access_type
    if before != "NoPublicAccess":
        client.update_bucket(
            ns, bucket,
            oci.object_storage.models.UpdateBucketDetails(public_access_type="NoPublicAccess"),
        )
    after = client.get_bucket(ns, bucket).data.public_access_type
    log.info("bucket %s: %s -> %s (alarm type %s, event %s)", bucket, before, after, alarm_type, event_type)
    return _json(ctx, {"action": "close", "bucket": bucket, "before": before, "after": after,
                       "alarm_type": alarm_type, "event_type": event_type})


def _json(ctx, obj):
    return response.Response(ctx, response_data=json.dumps(obj),
                             headers={"Content-Type": "application/json"})
tools/cost.py(Usage API で費用を確認)
"""
Usage API で費用を確認する(≒ コンソールのコスト分析)
  認証:~/.oci/config の [DEFAULT](Terraform と同じ)
  範囲:テナンシ全体(tf-lab だけでなく、どこで費用が出ても把握する)
  注意:データには遅延がある(数時間~1 日)。当日の数字は不完全

使い方:
  python tools/cost.py               直近 7 日(今日を含む)
  python tools/cost.py --days 30     直近 30 日
"""
import argparse
import collections
import datetime as dt
import sys

import oci

sys.stdout.reconfigure(encoding="utf-8", errors="replace")  # Windows の cp932 対策

config = oci.config.from_file(profile_name="DEFAULT")
config["region"] = "ap-osaka-1"  # Usage API はホーム・リージョンで呼ぶ
usage = oci.usage_api.UsageapiClient(config)

p = argparse.ArgumentParser(description="OCI cost summary (Usage API)")
p.add_argument("--days", type=int, default=7)
args = p.parse_args()

# DAILY 粒度では開始 / 終了とも UTC 0 時が必須。終了は含まれない → 明日の 0 時
end = dt.datetime.now(dt.timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + dt.timedelta(days=1)
start = end - dt.timedelta(days=args.days)

items = usage.request_summarized_usages(oci.usage_api.models.RequestSummarizedUsagesDetails(
    tenant_id=config["tenancy"],
    time_usage_started=start,
    time_usage_ended=end,
    granularity="DAILY",
    query_type="COST",
    group_by=["service", "skuName"],
)).data.items

print(f"period (UTC): {start:%Y-%m-%d} ~ {end - dt.timedelta(days=1):%Y-%m-%d}")
currency = next((i.currency for i in items if i.currency), "")

# 日ごとの合計
daily = collections.defaultdict(float)
for i in items:
    daily[i.time_usage_started.date()] += i.computed_amount or 0
print(f"\n--- daily total ({currency}) ---")
for d in sorted(daily):
    print(f"  {d}  {daily[d]:10.4f}")

# サービス / SKU ごとに集計(金額 0 も表示 → Always Free の使用量も見える)
total = collections.defaultdict(lambda: [0.0, 0.0, ""])
for i in items:
    t = total[(i.service or "-", i.sku_name or "-")]
    t[0] += i.computed_amount or 0
    t[1] += i.computed_quantity or 0
    t[2] = i.unit or t[2]
print(f"\n--- by service / SKU ({currency}) ---")
for (svc, sku), (amt, qty, unit) in sorted(total.items(), key=lambda kv: (-kv[1][0], kv[0])):
    mark = "!!" if amt > 0 else "  "
    print(f"{mark} {amt:10.4f}  {svc:22.22} {sku:55.55} {qty:12.2f} {unit}")

grand = sum(daily.values())
print(f"\ngrand total: {grand:.4f} {currency}" + ("   <-- 費用あり!" if grand > 0 else "   (0)"))