本文讲云项目的可观测性与成本治理,覆盖日志字段与保留期、告警规则、Dashboard 分层、预算预警、成本分摊标签。
环境:Terraform 1.9.5 + AWS provider 5.60,CloudWatch Logs/Alarm、AWS Budgets、Cost Allocation Tags,AWS CLI v2。
起因:上月账单比前月高 47%,没人说得清是哪个服务涨的;线上 502 挂了 26 分钟才被发现。
一、五个资产的边界
云上项目上线后最容易欠的两笔账:出问题看不见,花超了说不清。五个资产各管一段,缺哪段都会在故障单或账单上体现出来。
| 资产 | 解决什么 | 落地资源 | 缺了的后果 |
|---|---|---|---|
| 日志 | 事后定位根因 | CloudWatch Logs 日志组 + 指标过滤 | 出事只能靠猜 |
| 告警 | 主动发现故障 | Metric Alarm + SNS 主题 | 用户比你先知道 |
| Dashboard | 3 秒看清全局 | CloudWatch Dashboard | 每次排查从零拼图 |
| 预算 | 花钱前拦住 | AWS Budgets 预测型通知 | 月底才看账单 |
| 标签 | 成本能分摊到服务 | Cost Allocation Tags + SCP | 账单只剩一个总数 |
二、整体链路
观测链路和成本链路共用一套标签和一个通知主题,两条链在 SNS 汇合。

上排是故障响应链路,下排是花钱预警链路。两者的汇合点只有一个 SNS 主题,值班同学不用盯两个后台。
三、日志:先定字段,再定保留期
日志格式不统一,指标过滤就写不出来。业务日志统一输出单行 JSON,字段固定成下面七个。
json
{"ts":"2026-10-29T10:12:33.481Z","level":"ERROR","svc":"order-api","trace_id":"9f2c1a7e","route":"/api/v1/orders","status":502,"latency_ms":1830,"msg":"upstream timeout"}
| 字段 | 类型 | 必填 | 用途 |
|---|---|---|---|
ts |
ISO8601 | 是 | 跨服务对齐时间轴 |
level |
ERROR/WARN/INFO | 是 | 指标过滤的第一道条件 |
svc |
字符串 | 是 | 多服务共用一个日志组时区分来源 |
trace_id |
字符串 | 是 | 一次请求跨服务串起来 |
route |
字符串 | 是 | 定位到具体接口 |
status |
整数 | 是 | 5xx 比例的分母与分子 |
latency_ms |
整数 | 是 | P99 延迟的自算兜底 |
字段定完再谈保留期。保留期直接决定存储费,按环境分三档就够。
| 环境 | 日志组 | 保留天数 | 理由 |
|---|---|---|---|
| prod | /aws/app/order-api |
30 | 覆盖一个完整排障窗口 |
| staging | /aws/app/order-api-staging |
7 | 只验证发布后的短期行为 |
| dev | /aws/app/order-api-dev |
3 | 留三天够查联调问题 |
plain
resource "aws_cloudwatch_log_group" "app" {
name = "/aws/app/order-api"
retention_in_days = 30
kms_key_id = aws_kms_key.logs.arn
}
摄入费是存储费的 16 倍。按 us-east-1 刊例价,摄入 0.50 美元/GB,存储 0.03 美元/GB·月,20 GB/天的 INFO 日志一个月摄入 300 美元,存 30 天只要 18 美元。
INFO 不进 CloudWatch,只把 WARN/ERROR 送进去,INFO 落 S3 走生命周期,摄入费能压到 1/10。
落地方式是采集端做过滤,应用侧不用改代码:CloudWatch Agent 的 filters 用 exclude_at_match 正则丢掉 "level":"INFO" 行,进 S3 的那份由 Firehose 直投。
四、告警:三条就够
告警数量上去以后,值班会开始忽略通知。四条告警覆盖住入口、延迟、后台任务和数据断流,剩下的等真出事再补。
| 告警 | 指标(命名空间) | 阈值 | 评估 | 动作 |
|---|---|---|---|---|
| 5xx 突增 | HTTPCode_Target_5XX_Count(AWS/ApplicationELB) |
Sum > 20 / 5min | 1/1 | SNS 值班 |
| P99 延迟 | TargetResponseTime p99(AWS/ApplicationELB) |
> 2s | 3/3 | SNS 值班 |
| 任务失败 | JobFailure(自定义,由日志过滤产出) |
Sum > 3 / 5min | 1/1 | SNS + 工单 |
| 日志断流 | IncomingBytes(AWS/Logs) |
< 1024 / 5min | 2/2 | SNS 值班 |
前两条直接建在 ALB 的现成指标上,不用自己算。
plain
resource "aws_cloudwatch_metric_alarm" "alb_5xx" {
alarm_name = "prod-alb-5xx-high"
namespace = "AWS/ApplicationELB"
metric_name = "HTTPCode_Target_5XX_Count"
dimensions = { LoadBalancer = aws_lb.app.arn_suffix }
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 20
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [aws_sns_topic.oncall.arn]
ok_actions = [aws_sns_topic.oncall.arn]
}
P99 用 extended_statistic,和 statistic 二选一,同时写会报冲突。
plain
resource "aws_cloudwatch_metric_alarm" "alb_p99" {
alarm_name = "prod-alb-p99-slow"
namespace = "AWS/ApplicationELB"
metric_name = "TargetResponseTime"
dimensions = { LoadBalancer = aws_lb.app.arn_suffix }
extended_statistic = "p99"
period = 300
evaluation_periods = 3
threshold = 2
comparison_operator = "GreaterThanThreshold"
alarm_actions = [aws_sns_topic.oncall.arn]
}
自定义指标由日志过滤产出,一条过滤只出一个指标,字段名要和日志里的键一致。
plain
resource "aws_cloudwatch_log_metric_filter" "job_failed" {
name = "job-failed"
log_group_name = aws_cloudwatch_log_group.app.name
pattern = "{ $.level = \"ERROR\" && $.route = \"/job/settle\" }"
metric_transformation {
name = "JobFailure"
namespace = "App/OrderApi"
value = "1"
}
}
日志断流这条用 treat_missing_data = "breaching":没有数据点本身就是故障信号,不能按正常处理。
| 参数 | 取值 | 场景 |
|---|---|---|
notBreaching |
缺数据算正常 | 低峰期本来就没有 5xx |
breaching |
缺数据算告警 | 心跳、日志断流、定时任务 |
missing |
进 INSUFFICIENT_DATA | 新上线指标还没数据 |
告警阈值先按现状 P99 的 2 倍设,跑一周看误报率再收紧,一开始就按理想值设会天天响。
变更期要静默。部署前 disable-alarm-actions 关掉动作,部署后 enable-alarm-actions 打开,中间漏掉的告警会在下一个评估周期重新触发。
五、Dashboard:三块面板
一块面板回答一个问题:现在健康吗、慢在哪、花了多少。三块并排,值班扫一眼就有判断。
| 面板 | 指标 | 回答什么 |
|---|---|---|
| 流量与错误 | RequestCount、5XX、4XX 堆叠图 | 是故障还是流量变化 |
| 延迟分布 | p50/p90/p99 三条线 | 慢在平均值还是长尾 |
| 成本趋势 | 按 Service 分组的预估日花费 |
哪个服务在涨 |
plain
resource "aws_cloudwatch_dashboard" "main" {
dashboard_name = "prod-overview"
dashboard_body = jsonencode({
widgets = [{
type = "metric", x = 0, y = 0, width = 12, height = 6,
properties = {
title = "流量与错误"
region = "ap-northeast-1"
period = 300
view = "timeSeries"
stacked = true
metrics = [
["AWS/ApplicationELB", "RequestCount", "LoadBalancer", aws_lb.app.arn_suffix],
["AWS/ApplicationELB", "HTTPCode_Target_5XX_Count", "LoadBalancer", aws_lb.app.arn_suffix]
]
}
}]
})
}
region 必须显式写在 properties 里,缺了这块面板在跨区看板里是空白,且不报错。
六、预算:预测型比超支型有用
超支型通知在钱已经花完之后才响,预测型在当月跑到 80% 时就响,中间差出一周的处理时间。
plain
resource "aws_budgets_budget" "prod" {
name = "prod-monthly"
budget_type = "COST"
limit_amount = "800"
limit_unit = "USD"
time_unit = "MONTHLY"
cost_filter {
name = "TagKeyValue"
values = ["user:Environment$prod"]
}
notification {
comparison_operator = "GREATER_THAN"
threshold = 80
threshold_type = "PERCENTAGE"
notification_type = "FORECASTED"
subscriber_sns_topic_arns = [aws_sns_topic.oncall.arn]
}
}
| 通知类型 | 触发时机 | 适合 |
|---|---|---|
ACTUAL |
实际花费越过阈值 | 硬上限兜底 |
FORECASTED |
预测月末花费越过阈值 | 月中干预 |
CREDIT |
抵扣额度变化 | 有预付账单的账号 |
预算按标签过滤,标签没打全的账号预算会漏算,上线前先核对
cost_filter的标签键拼写。
七、标签:成本能不能分摊就看这一步
账单只有一个总数的时候,任何优化都无从下手。标签规范要在资源创建之前定,事后补标签的历史账单是补不回来的。
| 标签键 | 取值示例 | 必填 | 用途 |
|---|---|---|---|
Environment |
prod/staging/dev |
是 | 预算过滤、告警路由 |
Service |
order-api |
是 | 成本按服务分摊 |
Owner |
team-pay |
是 | 超支时找谁 |
CostCenter |
CC-1042 |
是 | 财务对账 |
ExpireAt |
2026-12-31 |
否 | 临时资源回收 |
只靠规范文档约束不住人,用 SCP 在组织层拦截缺标签的创建请求。
json
{"Version":"2012-10-17","Statement":[{"Sid":"DenyRunWithoutTags","Effect":"Deny",
"Action":["ec2:RunInstances","rds:CreateDBInstance"],
"Resource":"*",
"Condition":{"Null":{"aws:RequestTag/Service":"true","aws:RequestTag/Environment":"true"}}}]}
SCP 会连 Terraform 一起拦,default_tags 里必须把这两个键补上,否则 plan 通过、apply 被拒。
plain
provider "aws" {
default_tags {
tags = {
Environment = var.env
Service = "order-api"
Owner = "team-pay"
CostCenter = "CC-1042"
}
}
}
标签只在 Billing 控制台激活为成本分配标签之后才进账单,激活后约 24 小时生效,历史数据不会追溯。
八、告警闭环
告警响了之后要有人走完流程,否则告警只是噪音来源。

回环箭头那一步是重点:每次误报都要落成阈值调整或过滤条件,否则同一条告警会一直响到被静音。
九、验收
部署完按下面四项核对,全部通过才算补齐。
- 日志组保留期与规划一致,
IncomingBytes有数据 - 四条告警状态为
OK,不是INSUFFICIENT_DATA - Dashboard 三块面板都有曲线,无空白块
- 预算出现在 Billing 控制台,标签已激活为成本分配标签
bash
aws cloudwatch describe-alarms --state-value INSUFFICIENT_DATA \
--query 'MetricAlarms[].AlarmName' --output table
aws logs describe-log-groups --query 'logGroups[].[logGroupName,retentionInDays]' --output table
aws budgets describe-budgets --account-id 123456789012 --query 'Budgets[].BudgetName'
latex
-----------------------------------------
| DescribeAlarms |
+---------------------------------------+
| prod-alb-p99-slow |
+---------------------------------------+
出现 INSUFFICIENT_DATA 说明指标还没上报,先查命名空间和维度,别急着调阈值。
十、排障
| 现象 | 根因 | 处理 |
|---|---|---|
告警长期 INSUFFICIENT_DATA |
指标名或维度拼错 | aws cloudwatch get-metric-statistics 手工取一次 |
| 日志组里没数据 | 实例缺 logs:PutLogEvents 权限 |
查 Agent 日志与实例角色策略 |
| 指标过滤不命中 | JSON 里字段名大小写不一致 | aws logs test-metric-filter 用真实日志样本验 |
| Budgets 金额偏小 | 资源缺标签,cost_filter 漏算 |
补标签后重算,历史账期无法修正 |
| Cost Explorer 看不到标签 | 未激活为成本分配标签 | Billing → Cost allocation tags 手动激活 |
十一、成本口径
这套配置本身的月度成本很低,大头在日志摄入,压住它就压住了整体。
| 项目 | 单价(us-east-1 刊例) | 优化手段 |
|---|---|---|
| 日志摄入 | 0.50 美元/GB | 只送 WARN/ERROR,INFO 走 S3 |
| 日志存储 | 0.03 美元/GB·月 | 保留期分环境,prod 30 天 |
| 自定义指标 | 0.30 美元/个·月,前 10 个免费 | 一条过滤只出一个指标,别按日志行建 |
| 告警 | 前 10 个免费 | 合并同类告警,按服务不按实例建 |
| Dashboard | 前 3 个免费 | 一块看板三块面板,不按团队拆 |
可观测性的成本结构是摄入 >> 存储 >> 告警,先砍摄入量,再谈保留期。