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 回目の apply:ネットワーク / アプリケーション / OCIR リポジトリ / アラーム / 権限(14 個)。イメージがまだないので Function は作らない
- メールのサブスクリプション:確認メールのリンクをクリック →
ACTIVE - Cloud Shell で
fn build→ OCIR にdocker push - 2 回目の apply:Function + Function のサブスクリプション
- 第 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"})