自建日志搜索:为什么我们没用 CloudWatch Logs Insights
1. 为什么我们没有直接用 Logs Insights
我第一次看到这套日志搜索方案时,其实有点疑惑:AWS 已经有 CloudWatch Logs Insights,为什么还要自己搭一条 Firehose -> S3 -> EC2 -> ripgrep 的链路?
后来把代码和部署手册看完,答案反而很朴素。我们不是想重新做一个日志平台,只是想解决一个更窄的问题:开发排查线上问题时,能不能低成本地搜最近几天的历史日志,而且不要每次手一抖就把账单扫上去。
AWS 的计费模型决定了这件事不能只看“单价便宜”。CloudWatch 本身是按实际使用计费,Logs Insights 的查询成本按扫描数据量算。AWS 文档里的成本表把 Logs Insights 写成 $0.005/GB scanned,单看一 GB 确实不贵,但日志查询经常不是扫一 GB。
最容易出问题的是这种操作:
1 | fields @timestamp, @message |
这条语句本身没错。问题是时间范围一拉大,或者日志组一选多,它扫的就是一大块历史数据。一次排障可能连续改关键词、改时间、换日志组。每次查询都是一次新的扫描。
所以这套方案的出发点不是“CloudWatch 不好用”,而是:我们愿意接受几分钟延迟,用一台小 EC2 和一块 EBS,换一个更稳定的成本边界。
1.1 先说结论
最后落下来的链路是这样:
1 | CloudWatch Logs |
开发者入口也很简单:
1 | runway logs shell |
这里没有 Elasticsearch,没有 OpenSearch,没有单独的查询 API。说白了,就是把最近 7 天的日志变成一堆 gzip 文件,然后用 ripgrep 搜。
这听起来很土,但它刚好够用。
2. Firehose 只是搬运工
每个 app 会有一条单独的 Firehose stream,名字类似:
1 | coding-infra-logs-prod2-vibelive-backend |
一开始我以为可以所有 app 共用一条 stream,然后在 S3 里按 app 分目录。代码注释给出的理由很直接:那样要引入 dynamic partitioning,复杂度会马上上来。现在是一条 app 一条 stream,换来的好处是配置简单,告警也简单。
Firehose 的 buffer 配置是:
1 | buffering_size = 64 |
也就是 64MB 或 300 秒,哪个先到就写一次 S3。这个配置把延迟拉到了几分钟,但大幅减少了 S3 小文件数量。部署手册里估算端到端延迟大概 7 分钟:Firehose 300 秒,加上 EC2 上 cron 每分钟同步一次,再算一点处理时间。
这个取舍很清楚:它不适合实时 tail。实时日志还是走现有的 runway logs。这套只负责历史搜索。
2.1 S3 bucket 为什么只留一天
中间的 S3 bucket 叫 transit bucket。它不是长期归档,只是一个中转站。
Terraform 里给它配了几件事:
1 | server_side_encryption_configuration = "AES256" |
一天过期这件事很关键。EC2 如果挂了,Firehose 还会继续把日志写到 S3。没有生命周期,bucket 会一直涨。现在最坏情况是 EC2 挂太久会丢日志,但不会把中转 bucket 养成一个看不见的成本坑。
这不是完美方案。它只是把故障半径写清楚了。
3. EC2 cron 反过来拉 S3
EC2 是一台 t4g.small,挂一块 100GB gp3 EBS。它不暴露公网入口,开发者通过 SSM Session Manager 进去。
我比较喜欢这里的一个点:它没有让 Firehose 主动推到某个服务,也没有多加 Lambda。EC2 每分钟跑一次 cron,从 S3 把新对象拉下来,解析成功后再删掉 S3 对象。
关键脚本大概是这个意思:
1 | exec 9>/var/lock/sync-logs.lock |
这里有两个小细节比“架构图”更有价值。
第一个是 flock。cron 每分钟一次,但上一次不一定已经跑完。拿不到锁就退出,不排队,也不并发写同一批文件。
第二个是“解析成功才删除 S3”。S3 对象本身就是待处理队列。处理失败就留在原地,下次 cron 再试。没有消息队列,也没有状态表。
3.1 为什么没有用 Lambda
从技术上讲,Firehose 到 S3 后用 Lambda 处理也能做。但这个项目里有一个现实限制:运营账号挂了 DenyUnusedService policy,显式 deny 了 events:*、sns:*、sqs:*、scheduler:*、states:* 等服务。
这会影响很多常见的 serverless 拼法。比如定时调度、失败重试、消息缓冲、状态编排,一旦展开就会碰到这些 deny。
EC2 cron 不优雅,但它在这个约束下很稳定。它需要的东西少:S3 读删权限、CloudWatch Agent、SSM、EBS。出了问题也好查,直接看 /var/log/sync-logs.log。
4. Firehose 文件不是普通 gzip
这部分是我读代码时最容易误判的地方。
CloudWatch Logs subscription 发给 Firehose 的每条 record,本身就是 gzip 后的 JSON。Firehose 又把一批 record 外面再压一层 gzip。也就是说,S3 上的文件不是“一个 gzip 里有很多行日志”,而是:
1 | outer gzip |
parse-firehose.py 的处理方式很直接:
1 | with gzip.open(src) as f: |
它先解开外层 gzip,再在 bytes 里找 gzip magic number,也就是 0x1f 0x8b,把里面每条 record 拆出来,然后提取 logEvents[].message。
这个实现不花哨,但它是对 AWS 实际数据格式妥协后的结果。部署手册里也记了一笔:之前按 base64 去解,结果解出了 0 字节。真正落地的经验通常就长这样。
4.1 为什么按 app + 小时写 gzip
解析出来的日志会被写成这种文件:
1 | /logs/20260424/coding-infra-logs-prod2-vibelive-backend-2026-04-24-15.gz |
也就是一天一个目录,每个 app 每小时一个 gzip。
这里用到了 gzip 的一个特点:gzip 文件可以拼接。脚本会先把旧文件复制到临时文件,再把新的日志压成一个新的 gzip member 追加进去,最后 rename 回目标文件。
好处是文件数量受控。Firehose 可能一天写几百个对象,但到了 EBS 上,一个 app 一天就是 24 个文件左右。搜索时可以按日期、app、小时缩小范围。
按天合并会让单个文件太大。按分钟切又太碎。按小时是一个挺实用的中间点。
5. 搜索入口就是 SSM 加 ripgrep
runway logs shell 做的事情很少:
1 | aws ec2 describe-instances \ |
这里专门用了 bash --login。原因是默认 SSM shell 不会读 /etc/profile.d,而实例里有一个 runway-logs.sh,里面设置了提示符和 cdlogs:
1 | PS1='runway-logs$ ' |
进入以后就是普通 shell:
1 | cdlogs |
rg -z 可以直接搜 gzip。它不需要提前建索引,也不需要把文件解压到磁盘。对于“最近 7 天、几个 GB 到十几个 GB”的规模,这比上 OpenSearch 轻太多。
6. 成本模型:固定底盘换查询自由
部署手册里的月固定底盘估算约 $40:
1 | t4g.small 约 $10/月 |
这些数字会随 region 和 AWS 定价变化,所以我不会把它写成永远成立的价格。但模型是稳定的:自建方案主要是固定成本,Logs Insights 主要是按扫描量变化。
如果团队只是偶尔查一次日志,Logs Insights 当然省事。真正让我们犹豫的是排障时的查询习惯。人一急起来,不会每次都精确收窄时间范围和日志组。固定成本方案至少把“随手扫几次”的心理负担拿掉了。
这也是为什么我不觉得这套方案是在替代 CloudWatch。它是在替代“频繁、宽范围、临时排障”的那部分查询。
7. 这套方案的硬伤
先承认问题,比把方案写得很漂亮更有用。
第一个问题是单点。EC2 挂了,日志同步就停。S3 transit 只留一天,停久了会丢。代码里有 EC2 status alarm、磁盘 alarm 和 Firehose delivery alarm,但这仍然不是高可用系统。
第二个问题是时间分区。Firehose S3 prefix 用的是到达时间,不是 log event 时间。午夜附近的日志可能落到第二天目录。使用手册里专门提醒:查跨天问题时,要顺手查相邻日期。
第三个问题是启动依赖 GitHub release。AL2023 里没有现成的 ripgrep 包,cloud-init 直接从 GitHub 下载 ripgrep 15.1.0 的 aarch64 包。哪天 release 地址不可用,重建实例就会失败。
第四个问题是搜索能力有限。rg 很快,但它不是查询引擎。没有聚合,没有字段索引,没有权限隔离到 app 级别。复杂分析还是要回 CloudWatch Logs Insights,或者另起真正的日志分析系统。
这些硬伤都可以接受,因为目标本来就不是做通用日志平台。
8. 我会怎么复述这件事
如果让我用一句话讲这套方案,我会这么说:
我们把 CloudWatch Logs 里的应用日志异步复制到一台私有 EC2 的 EBS 上,按 app 和小时压成 gzip,用 SSM 登录后通过 ripgrep -z 搜最近 7 天。它牺牲了实时性和高可用,换来了固定成本、低维护和足够快的排障搜索。
这句话里每个词都对应一个取舍:
- “异步”说明有 7 分钟左右延迟。
- “私有 EC2”说明没有公网入口,靠 SSM。
- “gzip”说明省存储,也解释了为什么用
rg -z。 - “最近 7 天”说明它不是归档系统。
- “固定成本”说明它解决的是账单不确定性。
对我来说,这篇最大的收获不是学会了 Firehose,而是看到一个小系统怎么把边界收住。它没有追求通用,反而因此变得能落地。