自建日志搜索:为什么我们没用 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
2
3
4
fields @timestamp, @message
| filter @message like /timeout/
| sort @timestamp desc
| limit 100

这条语句本身没错。问题是时间范围一拉大,或者日志组一选多,它扫的就是一大块历史数据。一次排障可能连续改关键词、改时间、换日志组。每次查询都是一次新的扫描。

所以这套方案的出发点不是“CloudWatch 不好用”,而是:我们愿意接受几分钟延迟,用一台小 EC2 和一块 EBS,换一个更稳定的成本边界。

1.1 先说结论

最后落下来的链路是这样:

1
2
3
4
5
6
7
CloudWatch Logs
-> subscription filter
-> Firehose
-> S3 transit bucket
-> EC2 cron
-> /logs on EBS
-> rg -z

开发者入口也很简单:

1
2
3
runway logs shell
cdlogs
rg -z "user_95c7d46e" /logs/

这里没有 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
2
3
buffering_size     = 64
buffering_interval = 300
compression_format = "GZIP"

也就是 64MB 或 300 秒,哪个先到就写一次 S3。这个配置把延迟拉到了几分钟,但大幅减少了 S3 小文件数量。部署手册里估算端到端延迟大概 7 分钟:Firehose 300 秒,加上 EC2 上 cron 每分钟同步一次,再算一点处理时间。

这个取舍很清楚:它不适合实时 tail。实时日志还是走现有的 runway logs。这套只负责历史搜索。

2.1 S3 bucket 为什么只留一天

中间的 S3 bucket 叫 transit bucket。它不是长期归档,只是一个中转站。

Terraform 里给它配了几件事:

1
2
3
server_side_encryption_configuration = "AES256"
block_public_access = true
lifecycle expiration = 1 day

一天过期这件事很关键。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
2
3
4
5
6
7
8
9
10
11
12
13
exec 9>/var/lock/sync-logs.lock
flock -n 9 || exit 0

aws s3 ls "s3://$BUCKET/" --recursive | awk '{print $4}' | while read -r s3_key; do
tmpfile=$(mktemp /tmp/sync-XXXXXXXX.gz)

if aws s3 cp "s3://$BUCKET/$s3_key" "$tmpfile" --quiet \
&& python3 /usr/local/bin/parse-firehose.py "$tmpfile" "$output_dir" "$filename"; then
aws s3 rm "s3://$BUCKET/$s3_key" --quiet
fi

rm -f "$tmpfile"
done

这里有两个小细节比“架构图”更有价值。

第一个是 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
2
3
4
outer gzip
-> inner gzip record
-> inner gzip record
-> inner gzip record

parse-firehose.py 的处理方式很直接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
with gzip.open(src) as f:
raw = f.read()

positions = []
i = 0
while i < len(raw) - 1:
if raw[i] == 0x1f and raw[i + 1] == 0x8b:
positions.append(i)
i += 2
else:
i += 1

for idx, pos in enumerate(positions):
end = positions[idx + 1] if idx + 1 < len(positions) else len(raw)
record = raw[pos:end].rstrip(b"\n")
obj = json.loads(gzip.decompress(record))

它先解开外层 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
2
3
4
5
6
7
8
aws ec2 describe-instances \
--filters Name=tag:runway:component,Values=log-search \
Name=instance-state-name,Values=running

aws ssm start-session \
--target <instance-id> \
--document-name AWS-StartInteractiveCommand \
--parameters command=bash --login

这里专门用了 bash --login。原因是默认 SSM shell 不会读 /etc/profile.d,而实例里有一个 runway-logs.sh,里面设置了提示符和 cdlogs

1
2
PS1='runway-logs$ '
cdlogs() { cd "/logs/$(date +%Y%m%d)"; }

进入以后就是普通 shell:

1
2
3
4
cdlogs
rg -z -C 3 "connection refused" .
rg -z "panic" /logs/20260424/coding-infra-logs-prod2-vibelive-backend-*.gz
zcat /logs/20260424/*.gz | jq 'select(.level=="ERROR")' | head -20

rg -z 可以直接搜 gzip。它不需要提前建索引,也不需要把文件解压到磁盘。对于“最近 7 天、几个 GB 到十几个 GB”的规模,这比上 OpenSearch 轻太多。

6. 成本模型:固定底盘换查询自由

部署手册里的月固定底盘估算约 $40

1
2
3
4
t4g.small        约 $10/月
100GB gp3 EBS 约 $8/月
Interface Endpoint 约 $22/月
Firehose/CWL 按量,通常 < $5/月

这些数字会随 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,而是看到一个小系统怎么把边界收住。它没有追求通用,反而因此变得能落地。