费用护栏¶
一句话
按量付费(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 查实际费用¶
- 数据有几小时〜约 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 后
destroy08-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)"))