跳转至

费用护栏

一句话

按量付费(PAYG)的账号没有费用上限,Budget 也只发邮件、不会停服务。所以要分三层防: ① 预防:写代码时就建不出收费资源(validation、写死);② 发现:花了钱尽快知道(Budget 告警、用 API 查实际费用);③ 止损:异常时自动停掉(Alarm / Budget → Function 把网站改成私有)。

本页整理自 Phase 0、Phase 8、Phase 10 和原来的"Always Free 护栏"页。

1. 先认识这些组件

OCI AWS 一句话
Always Free Free Tier(部分是 12 个月限定) OCI 的免费额度永久有效,但超出部分照常收费
PAYG(Pay As You Go) 同左 按量付费的账号。绑了信用卡,没有上限
Compartment (近似)资源组 / 账号划分 费用也按它汇总。练习资源都放在 tf-lab,方便单独设 Budget
Budget + 告警规则 AWS Budgets 金额 + 规则(实际 / 预测费用超过多少时通知)。只通知,不停服务
Cost Analysis / Usage API Cost Explorer 看实际花了多少(有几小时〜约 1 天的延迟)
Monitoring Alarm CloudWatch Alarm 指标(例:请求数)超过阈值时触发
Notifications(Topic) SNS 把告警发给邮件、Function 等
Events EventBridge 服务发出的事件(例:Budget 告警)→ 转给 Topic 等
Functions Lambda 被调用时才运行的程序,按运行时间计费
OCIR ECR 容器镜像仓库。Function 的程序打成镜像放在这里

2. 原理:为什么"设了 Budget"还不够

事实 后果
PAYG 没有费用上限 写错一个 shape、被人刷流量,费用会一直涨
Budget 只发邮件,不停服务 半夜收到邮件也来不及处理
Budget 的评估不是实时的(本仓库的记录是几小时〜约 1 天) 发现时已经花出去了
Object Storage 没有限流 公开网站被大量请求时,请求费 / 流量费跟着涨(Denial of Wallet)

所以三层缺一不可:

flowchart LR
  A["① 预防<br/>只用免费规格<br/>写进代码"] --> B["② 发现<br/>Budget 告警<br/>查实际费用"]
  B --> C["③ 止损<br/>自动停网站"]

静态网站的风险主要在钱上

静态网站没有服务器程序、DB、表单,SQL 注入、XSS 之类无从下手。真正的风险是被大量请求时的费用:Object Storage 的 API 请求每月 5 万次免费,超出后每 1 万次约 $0.0034;出站流量每月 10TB 免费,超出按 GB 计费。

3. 第 ① 层:预防

3.1 Always Free 的规则

资源 规则 不遵守的后果
Region 只用 Home Region(ap-osaka-1) Always Free 的 Compute 只能建在 Home Region
Compute A1.Flex 合计 4 OCPU / 24GB,或 E2.1.Micro 最多 2 台 超出按量收费
Block Volume boot volume 也算,合计 200GB boot volume 最小 50GB → 实际最多 4 台 VM
Load Balancer flexible、带宽 min = max = 10Mbps、1 个 默认值或其他带宽要收费
Autonomous DB is_free_tier = true 漏掉就是收费的 ADB
OKE type = "BASIC_CLUSTER" ENHANCED 的 control plane 按小时收费
Object Storage 20GB、每月 5 万次请求 超出收费

计算资源的细节见 计算和负载均衡。

3.2 把规则写进代码

光靠记忆不可靠:

# 只允许免费的 shape:写错时 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 的 shape:VM.Standard.A1.Flex 或 VM.Standard.E2.1.Micro。"
  }
}
  • 能写成 validation 的写成 validation:shape、LB 带宽
  • "写错就收费"的值不做成变量,直接写死:ADB 的 is_free_tier = true、OKE 的 type = "BASIC_CLUSTER"。做成变量就有被改的可能
  • 练习完就 terraform destroy;下一个 Phase 要用额度时,先删掉上一个

4. 第 ② 层:发现

4.1 Budget 告警(实际的设置)

云 对象 金额 告警规则
OCI Compartment tf-lab 1,000 日元 / 月 预测超过 80 日元;实际超过 300 日元 → 也触发自动停网站
OCI 整个 tenancy 1,000 日元 / 月 实际超过 80%
AWS 整个账号 20 USD / 月 实际 50% / 100%、预测 80% / 100%
  • 两层都设:tf-lab 管练习资源,tenancy 整体兜底(练习之外的地方有费用也能发现)
  • OCI 的 Budget 每个 compartment 只能有 1 个

4.2 用 API 查实际费用

python tools/cost.py --days 7     # OCI Usage API:按服务 + SKU 汇总,不用开 Console
  • 数据有几小时〜约 1 天的延迟,当天的数字不完整
  • AWS:aws ce get-cost-and-usage --granularity MONTHLY --group-by Type=DIMENSION,Key=SERVICE

实际查出来的两件意外,都是"以为免费 / 以为算清楚了":

发现 教训
OCI DevOps 的 build runner 是按量计费的(默认 2 OCPU / 8GB,SKU Standard - E4 (DevOps service Build Run)),一开始以为没有单独的收费项,后来账单里出现了(一天 0.61 日元) "看起来免费"的服务也可能有收费 SKU → 用实际账单确认
AWS 的域名 $16 / 年,实际账单是 $17.60 = 2,807 日元 预算时没算消费税(10%)和汇率

5. 第 ③ 层:止损(自动停网站)

Phase 8 的设计:网站被大量访问、或者 tf-lab 的实际费用超过 300 日元时,Function 自动把网站 bucket 改成私有。

flowchart TB
  req["大量请求"] --> bucket["网站 bucket"]
  bucket -->|"请求数指标"| alarm["Monitoring Alarm<br/>1 小时 > 10,000 次"]
  budget["Budget<br/>实际 > 300 日元"]
  alarm --> topic["Notifications Topic"]
  budget -->|"Events"| topic
  topic --> mail["邮件"]
  topic --> fn["Function"]
  fn -.->|"改成 NoPublicAccess"| bucket
层 触发条件 速度 作用
请求数 网站 bucket 1 小时超过 10,000 次(平时 20〜50 次) 约 3.5 分钟(实测) 主力
Budget tf-lab 当月实际费用超过 300 日元 慢(Budget 本身有延迟) 兜底:请求数看不到的费用(流量费、其他资源)
手动 07-site 里 terraform apply "-var=site_public=false" 立刻 紧急时

恢复只要在 07-site 里 terraform apply:Terraform 把 NoPublicAccess 当作 drift,改回公开。

# 第 1 层的 Alarm(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) 跳过 不跳过的话,手动恢复网站后又会被停掉
Budget 事件 不看事件内容,自己查 Budget 的实际费用,≥ 300 日元才停 其他 Budget、预测费用的规则也会发事件进来;事件的 JSON 结构查不到公开示例
空消息(手动调用) 停止公开 紧急时

几个设计点:

  • Function 的权限最小:只能改网站 bucket 的设置(use buckets + bucket 名限定)、读 Budget(见 安全)
  • 已有的 Budget 用 data source 读,不 import:import 后 destroy 08-guard 时会把 Phase 0 建的 Budget 一起删掉。只往上面加告警规则
  • 阈值调低测试只用 -var,不改代码:-var=request_threshold=50

6. AWS 侧:固定价格方案

Phase 10 的 AWS 版网站用 CloudFront 的固定价格方案 Free 档,从根本上封顶:

按量计费 Free 档($0 / 月)
每月额度 1TB / 1000 万请求(永久免费额度) 100GB / 100 万请求
超出时 照常收费,没有上限 不收费,持续超出时降低分发性能
被攻击时 攻击流量也算钱 WAF / DDoS 挡掉的请求不算用量
WAF 另外收费 包含(最多 5 条规则)

本仓库加了 WAF 规则:同一 IP 5 分钟 500 次以上就挡掉,再加 3 个托管规则组。

OCI 和 AWS 解决的是同一个问题,思路不同:OCI 是"发现后自动停",AWS 是"超出也不收钱"。

7. 设计决策一览

决策 选了 没选 理由
防止建出收费资源 validation + 写死 只靠记忆 / 做成变量 plan 阶段就报错;写死的值没人能改
告警的范围 tf-lab + tenancy 两层 Budget 只设一个 练习之外的地方有费用也能发现
第 1 层的触发 Monitoring 的请求数(几分钟) 只靠 Budget Budget 有几小时〜1 天的延迟
止损的动作 把 bucket 改成私有 删掉 bucket / 资源 恢复只要一次 terraform apply,数据不丢
Budget 事件的处理 Function 自己查实际费用 按事件内容判断 事件结构不确定;别的规则的事件进来也安全
已有的 Budget data source 读 + 加告警规则 import import 后 destroy 会把它删掉
AWS 的流量费 固定价格方案 Free 档 + WAF 按量计费 + Alarm 超出不收费,不需要自己做止损

8. 验证

要证明的事 怎么做 结果
端到端:请求多了会自动停 阈值临时调到 50,curl 访问 100 次 约 3.5 分钟后 Function 被调用 → 网站 404
恢复后不会又被停掉(反向) 恢复网站 → 阈值改回 → 告警恢复 Function 跳过(FIRING_TO_OK),网站保持 200
恢复只要 apply 07-site 里 terraform apply NoPublicAccess -> ObjectReadWithoutList → 200
Budget 层:没超额度时不停(反向) 直接调用 Function,传模拟的 Budget 事件 跳过(实际费用 0 < 300) → 能读到 Budget,权限和网络都通
实际费用 tools/cost.py Phase 0〜6 全部 0 日元;之后出现 DevOps build runner 的费用(一天 0.61 日元)

没测到的部分

真正的 Budget 告警事件要实际费用超过 300 日元才会来,所以 Budget 层的端到端没测过。Function 会把第一次收到的事件原文写进日志,到时候再确认。

9. 坑

现象 原因 对策
恢复网站后又被停掉 告警恢复(FIRING_TO_OK)的消息也会调用 Function Function 里跳过 FIRING_TO_OK / RESET
别的 Budget 的告警也会把网站停掉 事件 JSON 的结构查不到,Events 规则只按事件类型过滤 → 所有 Budget、所有规则的事件都进来 Function 不看事件内容,自己查实际费用
新建 Budget → 400-LimitExceeded 每个 compartment 只能有 1 个 Budget data source 读现有的,只加告警规则
oci_budget_budgets 按 display_name 查,返回了 2 个 这个参数不过滤 用 filter 块
告警规则每次 plan 都有 freeform_tags 差异 API 不保存告警规则的 tag 告警规则不写 tag;apply 后一定再 plan 一次
告警邮件是一大段 JSON message_format = "PRETTY_JSON" ONS_OPTIMIZED(只改邮件,Function 收到的仍是 JSON)
以为 DevOps build 免费 build runner 按量计费 用 tools/cost.py 看实际的 SKU
域名的实际花费比预算多 没算消费税和汇率 预算时加上

10. 自测问题

1. 设了 Budget 告警,是不是就不会超支了?

不是。Budget 只发邮件、不停服务,而且评估有几小时〜1 天的延迟。要配合"预防"(validation、写死)和"止损"(自动停)。

2. ADB 的 is_free_tier,为什么写死而不做成变量?

写错就收费的值,做成变量就有被改的可能(tfvars 写错、别人覆盖)。写死在代码里,改它要改代码、要经过 review。

3. 为什么第 1 层用请求数的 Alarm,而不直接用 Budget?

Budget 有几小时〜1 天的延迟,被刷流量时来不及。请求数的指标几分钟就能反映,实测约 3.5 分钟就停掉了。

4. 手动恢复网站后,告警恢复的消息又调用了 Function,会发生什么?怎么防?

如果不处理,Function 会再次把网站停掉。所以 Function 收到 FIRING_TO_OK / RESET 时跳过。

5. 已经有一个 Budget,想用 Terraform 给它加告警规则,为什么不 import?

import 后这个 Budget 就由 Terraform 管理,destroy 那个目录时会把它一起删掉。用 data source 读它,只管理新加的告警规则。

6. 止损的动作为什么是「改成私有」,而不是删掉 bucket?

改成私有能马上停止访问,数据不丢,恢复只要一次 terraform apply。删掉的话要重新上传,而且容易误删。

7. AWS 的固定价格方案和 OCI 的自动停止,思路有什么不同?

OCI:按量计费,发现异常后自动停服务。AWS 固定价格方案:超出额度也不收费(降低分发性能),被 WAF 挡掉的请求也不算用量,所以不需要自己做止损。

完整代码

08-guard/alarm.tf(Topic / 订阅 / Alarm)
# ------------------------------------------------------------
# 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(已有 Budget + 告警规则 + Events)
# ------------------------------------------------------------
# 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:告警规则的 API 不保存 tag(建好后读回来是 {})→ 写了的话每次 plan 都出 drift
}

# 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(停网站的 Function)
"""
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"})
tools/cost.py(用 Usage API 查费用)
"""
用 Usage API 查费用(≒ Console 的 Cost Analysis)
  认证:~/.oci/config 的 [DEFAULT](和 Terraform 一样)
  范围:整个 tenancy(不只 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 要用 Home Region
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}")

# 按 service / 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)"))