Phase 8:网站自动停止(Monitoring + Functions)¶
目标¶
- 防止"恶意大量访问 → 费用一直涨"(Denial of Wallet)
- 异常时自动停止公开网站,同时发邮件通知
- 体验 OCI Functions(Serverless)和 Monitoring / Notifications / Events 的联动
为什么需要¶
静态网站本身很难被"攻破":没有服务器端程序、DB、表单,bucket 对匿名用户只读。SQL 注入、XSS 之类的攻击无从下手,WAF 的作用也有限。
真正的风险在钱上:
| 项目 | Always Free | 超出后 |
|---|---|---|
| Object Storage 的 API 请求(每下载一个文件算 1 次) | 每月 5 万次 | 每 1 万次约 $0.0034 |
| 出站流量 | 每月 10TB | 按 GB 计费 |
- PAYG 账号没有费用上限,Budget 只发邮件、不会停止服务
- Object Storage 没有限流功能
结构¶
flowchart LR
req["大量请求"] --> bucket["Bucket tflab-site"]
bucket -->|"AllRequests 指标"| alarm["Monitoring Alarm<br/>1 小时 > 10,000"]
budget["Budget TF-1000en<br/>tf-lab 实际 > 300 日元"] -->|"Events"| topic
alarm --> topic["Notification Topic"]
topic --> mail["邮件"]
topic --> fn["Function site-guard"]
fn -->|"NoPublicAccess"| bucket
| 层 | 触发条件 | 速度 | 用途 |
|---|---|---|---|
| 第 1 层 | 网站 bucket 的请求数 1 小时超过 10,000 次(平时 20〜50 次) | 约 3.5 分钟(实测) | 主力 |
| 第 2 层 | tf-lab 当月实际费用超过 300 日元 |
慢(Budget 约 24 小时评估一次) | 兜底:请求数看不到的费用(流量费、tf-lab 里的其他资源等) |
| 手动 | 07-site 里 terraform apply "-var=site_public=false" |
立刻 | 紧急时 |
恢复:07-site 里 terraform apply → Terraform 把 NoPublicAccess 当作 drift,改回 ObjectReadWithoutList。
08-guard/
├── network.tf VCN + 私有 subnet + Service Gateway(只出站 443 到 Oracle 服务)
├── functions.tf OCIR 仓库 / Application / 执行日志 / Function
├── alarm.tf Topic / 邮件订阅 / Function 订阅 / Alarm(第 1 层)
├── budget.tf 现有 Budget(data source)+ 告警规则 / Events 规则 / 读 Budget 的 Policy(第 2 层)
├── iam.tf Dynamic Group + Policy
└── function/ func.py(Python + fdk)
Function 是什么¶
- 程序 + 运行环境(Python、依赖包、最小的 Linux)打包成镜像,放进 OCIR(OCI 的镜像仓库)
- 被调用时,OCI 从 OCIR 拉镜像、启动容器、执行,做完就收回 → 只按运行时间计费(Always Free:每月 200 万次、40 万 GB 秒)
- 认证用 Resource Principal(和 Phase 7 的 Build Pipeline 一样,不发 API Key)
Function(func.py)的判断:
| 收到的消息 | 动作 | 理由 |
|---|---|---|
告警 OK_TO_FIRING(触发) |
停止公开 | |
告警 FIRING_TO_OK / RESET(恢复) |
跳过 | 不跳过的话,手动恢复网站后又会被停掉 |
Budget 事件 → 自己查 TF-1000en 的实际费用,≥ 300 日元 |
停止公开 | 不看事件内容(JSON 结构查不到公开示例) |
| Budget 事件 → 实际费用 < 300 日元 | 跳过 | 预测费用的规则、其他 Budget 的告警也会进来 |
| 其他事件 | 跳过 | |
| 空(手动调用) | 停止公开 | 紧急时 |
权限¶
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 层:建在 root(Budget 在 root compartment)
Allow dynamic-group tflab-guard-fn to read usage-budgets in tenancy
- Function 只能读 / 改
tflab-site的设置:删不了 bucket,读写不了对象,碰不到其他 bucket faas(Functions 服务本身):把 Function 接到 subnet、从 OCIR 拉镜像cloudEvents:Budget 告警的事件发到 Topicread usage-budgets:Function 读 Budget 的实际费用(只读)
流程和观察¶
- 第 1 次 apply:网络 / Application / OCIR 仓库 / 告警 / 权限(14 个)。Function 还不建(镜像还没有)
- 邮件订阅:点确认邮件里的链接 →
ACTIVE - Cloud Shell 里
fn build→docker push到 OCIR - 第 2 次 apply:Function + Function 订阅
- 第 2 层:Function 0.0.3 build & push → apply(告警规则 / Events 规则 / Policy / 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 层:直接调用,模拟的 Budget 事件 | 跳过(actual_spend: 0、threshold: 300)→ 能读到 Budget(Policy、网络都通)。冷启动 40 秒 |
| 第 2 层:0.0.3 直接调用,空消息 | 404(3 秒)→ 第 1 层的路径没坏 → 07-site apply 恢复 |
-
真正的 Budget 事件要实际费用超过 300 日元才会来 → 端到端测不了。第一次来的时候 Function 会把事件原文写进日志
-
阈值调低只用
-var=request_threshold=50,不改代码 - Function 的日志:Log
tflab-guard-invoke(service = functions、category = invoke)。Logging Search 有 1〜2 分钟延迟
坑¶
Cloud Shell 默认是 ARM
uname -m → aarch64。在 ARM 上 build 的镜像跑不了 x86 的 Function(shape = "GENERIC_X86")。
→ Cloud Shell 的 Actions → Architecture → x86_64 切换后再 build。
告警邮件是 JSON
message_format = "PRETTY_JSON" → 邮件也是 JSON。
→ ONS_OPTIMIZED:只对 EMAIL 订阅生效,邮件变成人读的格式,Function 收到的仍是 JSON(实测确认)。
每个 compartment 只能有 1 个 Budget
新建网站专用的 Budget → 400-LimitExceeded, 1 budgets already exist in target compartment。tf-lab 已经有 Phase 0 手动建的 TF-1000en。
→ 用 data source 读 TF-1000en,只往上面加告警规则。不用 import:import 后 destroy 08-guard 时会把这个 Budget 一起删掉。
→ 失败时已经建好的 Events 规则马上删掉了:当时的 Function 不过滤 Budget 事件,现有 Budget 的告警会把网站停掉。
Budget 告警会把所有 Budget、所有规则的事件都送进来
Events 规则只能按 eventType 过滤 → 其他 Budget、同一个 Budget 的预测费用规则的告警也会进来。最初想按 budgetId / alertRuleId 过滤,但事件的 JSON 结构查不到公开示例(字段在不在、叫什么都不确定)。
→ Function 不看事件内容,收到 Budget 事件就自己调 Budget API(get_budget),actual_spend ≥ 300 日元才停。哪个规则发的事件都一样安全。
→ 没设置 GUARD_BUDGET_ID / GUARD_THRESHOLD(第 2 层关闭)时跳过。
oci_budget_budgets 的 display_name 不过滤
display_name = "TF-1000en" → 还是返回了 2 个 Budget(one() 报错)。
→ 用 filter { name = "display_name" ... } 块(provider 侧过滤)。
切换 Cloud Shell 架构后 docker push 403
StatusCode: 403, "":切换架构会换一台机器,之前 docker login 的凭证没有了。→ 重新 docker login(不用重新 build)。
Cloud Shell 的 docker 是 Podman
docker version → 4.9.4-rhel。用法一样,但 push 完成后不显示 digest:(最后是 Writing manifest to image destination)。
OCIR 和 git 的用户名不一样
git(OCI DevOps):<tenancy 名>/<用户名>;OCIR(docker login):<namespace>/<用户名>。密码都是 Auth Token。
其他
- OCIR 仓库事先用 Terraform 建在
tf-lab(不建的话,第一次 push 时会自动建在 root compartment) oci_functions_function的image已弃用 →source_details { source_type = "CONTAINER_IMAGE" }(值从 SDK 的FunctionSourceDetails查)- checkov 的 CKV_OCI_16 / 19:没有入站规则时也会判定失败 → 写明理由后 skip
- Monitoring 的指标名和维度:先用
list_metrics确认(AllRequests、resourceDisplayName),不靠猜 oci_events_rule里面的actions块已弃用 →action(terraform providers schema -json确认)
完整代码¶
08-guard/alarm.tf
# ------------------------------------------------------------
# Phase 8:盯着网站 bucket 的请求数 → 超过阈值就通知 + 调用 Function
# Budget(费用)有约 1 天延迟 → 用 Monitoring 的 Object Storage 指标(几分钟内)
# ------------------------------------------------------------
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:bucket 收到的全部 HTTP 请求(匿名的网站访问 + 上传等)
# 维度 resourceDisplayName = bucket 名(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 层:Budget(实际费用)→ Events → Topic → 同一个 Function
# 第 1 层(请求数的 Alarm)看不到的费用(流量费、tf-lab 里其他资源等)的兜底
# Budget 有延迟(文档:约 24 小时评估一次)→ 只是兜底,快的是第 1 层
#
# 每个对象 compartment 只能有 1 个 Budget(新建 → 400-LimitExceeded)
# → 用 Phase 0 手动建的 TF-1000en(对象 = tf-lab),只往上面加一条 ACTUAL 告警规则
# → 不 import:import 后 destroy 08-guard 会把 TF-1000en 一起删掉;data source 只读
#
# 事件的 JSON 结构查不到(alertRuleId 在不在不确定)→ Function 不看事件内容,
# 收到 Budget 事件就自己调 Budget API:actual_spend >= GUARD_THRESHOLD 才停
# (TF-1000en 原有的 FORECAST 规则、root 的 Budget 的事件进来也只是查一次就 skip)
# ------------------------------------------------------------
# Budget 在 root compartment(tenancy)
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 块在 provider 侧过滤
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)→ 告警(邮件 + Events 的事件)
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
}
# Budget 告警的事件 → 告警 Topic(→ 邮件 + Function)
# eventType:docs.oracle.com「Services that Produce Events」的 Budgets(com.oraclecloud.budgets.createtriggeredalert)
# 事件在 Budget 所在的 root compartment 发出 → 规则也建在 root
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(provider schema 确认)
actions {
action {
action_type = "ONS"
is_enabled = true
topic_id = oci_ons_notification_topic.guard.id
}
}
freeform_tags = local.common_tags
}
# Function 读 Budget 的 actual_spend
# Budget 在 root compartment → in tenancy 授权、policy 也建在 root(只读)
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:把网站 bucket 改成 NoPublicAccess(停止公开)
调用方:Notification Topic ← ① Monitoring 告警(请求数) ② Events(Budget 告警,第 2 层)/ 手动 invoke
认证:Resource Principal(Dynamic Group tflab-guard-fn:只能 use tflab-site 这个 bucket + read usage-budgets)
恢复:07-site 里 terraform apply(Terraform 当作 drift 改回 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"}
# Budget 告警的事件(Events → Topic → 这里)
# tenancy 里所有 Budget、所有告警规则(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"] # Application 的 config
# 告警消息是 JSON(ONS_OPTIMIZED 也只改邮件的格式);Budget 的事件也是 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"})