跳转至

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 告警的事件发到 Topic
  • read usage-budgets:Function 读 Budget 的实际费用(只读)

流程和观察

  1. 第 1 次 apply:网络 / Application / OCIR 仓库 / 告警 / 权限(14 个)。Function 还不建(镜像还没有)
  2. 邮件订阅:点确认邮件里的链接 → ACTIVE
  3. Cloud Shell 里 fn build → docker push 到 OCIR
  4. 第 2 次 apply:Function + Function 订阅
  5. 第 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"})