コンテンツにスキップ

Phase 8:サイトの自動停止(モニタリング + ファンクション)

目標

  • 「悪意のある大量アクセス → 費用が増え続ける」(Denial of Wallet)を防ぐ
  • 異常時に自動でサイトの公開を停止し、同時にメールで通知する
  • OCI ファンクション(Functions、サーバーレス)と、モニタリング(Monitoring)/ 通知(Notifications)/ イベント(Events)の連携を体験する

なぜ必要か

静的サイト自体は「侵入」されにくい構成です。サーバー側のプログラム、DB、フォームがなく、バケットは匿名ユーザーには読み取り専用です。SQL インジェクションや XSS などの攻撃は成り立たず、WAF の効果も限定的です。

本当のリスクはお金です:

項目 Always Free 超過後
オブジェクト・ストレージ(Object Storage)の API リクエスト(ファイル 1 つのダウンロードで 1 回) 月 5 万回 1 万回あたり約 $0.0034
アウトバウンド転送量 月 10TB GB 単位で課金
  • PAYG アカウントには費用の上限がない。予算(Budget)はメールを送るだけで、サービスは止めない
  • オブジェクト・ストレージにはレート制限の機能がない

構成

flowchart LR
  req["大量のリクエスト"] --> bucket["バケット tflab-site"]
  bucket -->|"AllRequests メトリック"| alarm["モニタリングのアラーム<br/>1 時間 > 10,000"]
  budget["予算 TF-1000en<br/>tf-lab の実績 > 300 円"] -->|"イベント"| topic
  alarm --> topic["通知トピック"]
  topic --> mail["メール"]
  topic --> fn["Function site-guard"]
  fn -->|"NoPublicAccess"| bucket
層 条件 速さ 役割
第 1 層 サイトのバケットのリクエスト数が 1 時間で 10,000 回を超える(通常は 20〜50 回) 約 3.5 分(実測) メイン
第 2 層 tf-lab の当月の実際の費用が 300 円を超える 遅い(予算は約 24 時間ごとに評価) 保険:リクエスト数では見えない費用(転送料、tf-lab 内のほかのリソースなど)
手動 07-site で terraform apply "-var=site_public=false" 即時 緊急時

復旧:07-site で terraform apply → Terraform が NoPublicAccess をドリフトとして検出し、ObjectReadWithoutList に戻します。

08-guard/
├── network.tf     VCN + プライベート・サブネット + サービス・ゲートウェイ(Oracle サービスへの 443 送信のみ)
├── functions.tf   OCIR リポジトリ / アプリケーション / 実行ログ / Function
├── alarm.tf       トピック / メールのサブスクリプション / Function のサブスクリプション / アラーム(第 1 層)
├── budget.tf      既存の予算(データ・ソース)+ アラート・ルール / イベント・ルール / 予算を読むポリシー(第 2 層)
├── iam.tf         動的グループ + ポリシー
└── function/      func.py(Python + fdk)

Function とは

  • プログラムと実行環境(Python、依存パッケージ、最小限の Linux)をイメージにまとめ、OCIR(OCI のコンテナ・レジストリ)に置きます
  • 呼び出されると、OCI が OCIR からイメージを取得し、コンテナを起動して実行し、終わったら片付けます → 実行時間だけ課金(Always Free:月 200 万回、40 万 GB 秒)
  • 認証はリソース・プリンシパル(Resource Principal)(Phase 7 のビルド・パイプラインと同じく、API キーは発行しない)

Function(func.py)の判断:

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

権限

Allow dynamic-group tflab-guard-fn to use buckets in compartment tf-lab where target.bucket.name = 'tflab-site'
Allow service faas to use virtual-network-family in compartment tf-lab
Allow service faas to read repos in compartment tf-lab
Allow service cloudEvents to use ons-topics in compartment tf-lab

# 第 2 層:ルートに作成(予算はルート・コンパートメントにある)
Allow dynamic-group tflab-guard-fn to read usage-budgets in tenancy
  • Function は tflab-site の設定を読み書きできるだけです。バケットの削除、オブジェクトの読み書き、ほかのバケットへのアクセスはできません
  • faas(ファンクション・サービス自体):Function をサブネットにつなぎ、OCIR からイメージを取得する
  • cloudEvents:予算アラートのイベントをトピックに送る
  • read usage-budgets:Function が予算の実際の費用を読む(読み取りのみ)

手順と観察

  1. 1 回目の apply:ネットワーク / アプリケーション / OCIR リポジトリ / アラーム / 権限(14 個)。イメージがまだないので Function は作らない
  2. メールのサブスクリプション:確認メールのリンクをクリック → ACTIVE
  3. Cloud Shell で fn build → OCIR に docker push
  4. 2 回目の apply:Function + Function のサブスクリプション
  5. 第 2 層:Function 0.0.3 をビルドして push → apply(アラート・ルール / イベント・ルール / ポリシー / config / イメージ。作成 3、変更 2)
テスト 結果
直接呼び出し、{"type": "FIRING_TO_OK"} スキップ、サイトは 200。コールド・スタート 48.6 秒
直接呼び出し、空のメッセージ サイトは 404(2.2 秒)
07-site で terraform apply ~ access_type = "NoPublicAccess" -> "ObjectReadWithoutList" → 200
エンドツーエンド:しきい値を一時的に 50 にし、curl で 100 回アクセス 約 3.5 分後に Function が呼ばれ(OK_TO_FIRING)→ 404
サイトを復旧 → しきい値を 10,000 に戻す → アラーム回復 Function はスキップ(FIRING_TO_OK)、サイトは 200 のまま
第 2 層:直接呼び出し、模擬の予算イベント スキップ(actual_spend: 0、threshold: 300)→ 予算を読める(ポリシーもネットワークも OK)。コールド・スタート 40 秒
第 2 層:0.0.3 を直接呼び出し、空のメッセージ 404(3 秒)→ 第 1 層の経路は壊れていない → 07-site の apply で復旧
  • 本物の予算イベントは実際の費用が 300 円を超えないと来ない → エンドツーエンドのテストはできません。最初に届いたとき、Function がイベントの原文をログに書きます

  • しきい値を下げるときは -var=request_threshold=50 だけで、コードは変えない

  • Function のログ:ログ tflab-guard-invoke(service = functions、category = invoke)。ログ検索(Logging Search)には 1〜2 分の遅延がある

ハマりどころ

Cloud Shell のデフォルトは ARM

uname -m → aarch64。ARM でビルドしたイメージは x86 の Function(shape = "GENERIC_X86")では動きません。 → Cloud Shell の Actions → Architecture → x86_64 で切り替えてからビルドします。

アラームのメールが JSON

message_format = "PRETTY_JSON" → メールも JSON になります。 → ONS_OPTIMIZED:EMAIL サブスクリプションにだけ適用され、メールは人が読みやすい形式になります。Function が受け取るのは JSON のままです(実測で確認)。

1 つのコンパートメントに予算は 1 つだけ

サイト専用の予算を新規作成 → 400-LimitExceeded, 1 budgets already exist in target compartment。tf-lab には Phase 0 で手動作成した TF-1000en がすでにあります。 → データ・ソースで TF-1000en を読み、アラート・ルールだけを追加します。import はしません:import すると、08-guard を destroy したときにこの予算も削除されてしまうからです。 → 失敗時に作成済みだったイベント・ルールはすぐに削除しました:当時の Function は予算のイベントを絞り込まないため、既存の予算のアラートでサイトが止まってしまうからです。

予算アラートはすべての予算・すべてのルールのイベントが入ってくる

イベント・ルールは eventType でしか絞れません → ほかの予算や、同じ予算の予測費用のルールのアラートも入ってきます。当初は budgetId / alertRuleId で絞るつもりでしたが、イベントの JSON 構造の公開例が見つかりません(フィールドがあるか、名前は何かも不明)。 → Function はイベントの内容を見ず、予算のイベントを受け取ったら自分で予算 API(get_budget)を呼び、actual_spend が 300 円以上のときだけ停止します。どのルールのイベントでも安全です。 → GUARD_BUDGET_ID / GUARD_THRESHOLD が設定されていない(第 2 層がオフ)ときはスキップします。

oci_budget_budgets の display_name で絞り込まれない

display_name = "TF-1000en" → それでも予算が 2 つ返ってきます(one() がエラー)。 → filter { name = "display_name" ... } ブロックを使います(プロバイダー側で絞り込み)。

Cloud Shell のアーキテクチャを切り替えると docker push が 403

StatusCode: 403, "":アーキテクチャを切り替えると別のマシンになり、以前の docker login の認証情報がなくなります。→ もう一度 docker login します(ビルドのやり直しは不要)。

Cloud Shell の docker は Podman

docker version → 4.9.4-rhel。使い方は同じですが、push の完了時に digest: は表示されません(最後は Writing manifest to image destination)。

OCIR と git ではユーザー名が違う

git(OCI DevOps):<テナンシ名>/<ユーザー名>。OCIR(docker login):<ネームスペース>/<ユーザー名>。パスワードはどちらも認証トークン(Auth Token)です。

その他

  • OCIR リポジトリは事前に Terraform で tf-lab に作る(作らないと、最初の push のときにルート・コンパートメントに自動作成される)
  • oci_functions_function の image は非推奨 → source_details { source_type = "CONTAINER_IMAGE" }(値は SDK の FunctionSourceDetails で確認)
  • checkov の CKV_OCI_16 / 19:受信ルールがない場合も失敗と判定される → 理由を書いて skip
  • モニタリングのメトリック名とディメンション:まず list_metrics で確認する(AllRequests、resourceDisplayName)。推測で書かない
  • oci_events_rule の内側の actions ブロックは非推奨 → action(terraform providers schema -json で確認)

コード全体

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 = local.common_tags
}

# 予算アラートのイベント → アラート用トピック(→ メール + 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
"""
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"})