我有两个小产品:一个我做的集市兼社区应用,一个给上门报价的手艺人用的估价应用,都跑在 Fly.io 最便宜的机器上。我的 DevOps 监控归结起来就五样:把 ERROR 日志发到一个聊天频道;把每一行日志都归档到对象存储里,用 DuckDB 查询;Fly 自带的 Prometheus 指标;手机上的崩溃报告,用 Sentry 的开源 SDK 发到我自己的 API;以及每天早上一条汇总。没有托管的 APM,生产环境没有链路追踪,也没有需要记着去打开的仪表板。唯一额外花钱的是日志归档,每月大约 2 美元。下面是完整清单,以及我弃用或不再信任的东西,其中一样让我追了好几天一个根本没有用户碰到过的故障。
我说的监控是什么,以及规模有多小#
我说的 DevOps 监控指两件事:在别人告诉我之前就发现东西坏了,以及不靠猜就能查出原因。下面每个工具都按这两条来评价。
去年我写过一篇搭建开发者真正会用的监控,那时主要考虑的是团队:小团队用云厂商自带的监控,服务上百个了再上托管的 APM。这篇是同一个思路里最小的那一端。两个产品都是 Django,跑在 Fly 的 shared-cpu-1x 机器上,我当前的技术栈里写过,而且两边的开发者都只有我一个。如果说监控是一个产品、开发者是它的用户,那我这套监控的用户就只有一个人。九月我从 Fly 的指标里拉了一次基线,交易平台的 API 平均 CPU 占用是 1%。这不是企业级的技术栈,我也不打算把它包装成那样。
指标、日志、链路这三根支柱,我有两根。两个产品都没有分布式追踪:各自就是一套 Django 代码加一个数据库,没有服务边界让链路去跨。我平时看的链路只有一种,就是我工作站上反向代理的链路。
清单#
| 工具 | 盯的是什么 | 保留还是弃用,为什么 | 费用 |
|---|---|---|---|
| 团队聊天频道,由 Django 的日志 handler 推送 | API、WebSocket 服务器和 worker 的所有 ERROR;部署;集中出现的错误登录验证码 | 保留。这是我不用别人提醒也会去看的唯一地方 | 免费 |
| Fly 的日志转发器写到兼容 S3 的 bucket,用 DuckDB 查询 | 交易平台各个应用的每一行日志 | 保留。Fly 自己的缓冲只存几分钟,这里存全部,还能用 SQL 查 | 每月约 2 美元,外加存储 |
| Fly 自带指标(Prometheus API) | CPU、内存、突发额度、响应;托管 Postgres 的 exporter | 保留。是它证明了数据库卡顿不是我代码的问题 | Fly 自带 |
/api/health/ 上的 Fly 健康检查(两个应用) | 某台机器该不该接流量 | 用于路由,保留。当作“用户没事”的证据,弃用 | Fly 自带 |
| Sentry 的 SDK,DSN 指向我自己的 API | 手机上的应用崩溃 | SDK 保留。从没注册 sentry.io | API 之外不额外花钱 |
| 团队聊天里的早间汇总 | 前一天的错误、使用量、搜索、聊天和看板 | 保留。从 CI 的定时任务挪到了我的 Mac 上 | 免费 |
| 应用分析 SDK(两个应用)和搜索引擎的站长后台 | Web、iOS、Android 的使用情况;搜索引擎怎么看这个站 | 保留,通过 API 读取 | 免费 |
| Cloudflare 的 GraphQL 分析 | 某个状态码或路径背后是谁 | 保留,但相信它之前先按 user agent 分组 | 免费套餐,每次查询一天 |
| 一个托管 OTA 更新服务的仪表板 | 手机上跑的是哪个 OTA 版本;崩溃回滚 | 从一个什么都看不出来的后台列表换到了这里 | 属于更新服务的一部分 |
| 一个小的探测脚本 | 部署或重启过程中失败的请求 | 保留。直接打源站,缓存藏不住空档 | 免费 |
flyctl logs | 应用最近几分钟的输出 | 当实时查看用,保留。当记录用,弃用 | Fly 自带 |
| Cloudflare Early Hints | 什么都不盯,它是个提速功能 | 关掉了。整个 zone 的 504 全是它的探测请求 | 免费 |
表里没写“两个应用”的,都只说交易平台。估价应用的东西更少:Fly 的健康检查、日志和指标,iOS 上的应用分析,再加一个每天把数据库导出成 Parquet、我用 DuckDB 查询的任务。它没有崩溃报告,也没有告警频道,这是这篇里最大的缺口。(关于更新服务那一行要说明一下:做这个服务的公司付钱请我给他们的博客写文章。)
错误送到我本来就在的地方#
九月之前,未处理的 500、发送失败的推送通知、坏掉的 WebSocket 帧,唯一会出现的地方是 flyctl logs,而那条流没人会主动去读。现在由一个日志 handler 把 ERROR 及以上的每条记录都发到聊天频道,不管来自 API、WebSocket 服务器还是后台 worker。WARNING 故意不告警。
handler 在后台线程里发送,所以绝不会拖慢请求。它内部不写任何日志,所以不会递归。同一个错误最多每十分钟发一次,每小时上限 30 条,所以出故障时你读到的是一条消息,而不是一千条。这个频道里还有我想拿来和错误对照着看的东西:部署在开始、上线成功和失败的地方都会发消息,带上 commit 和执行人,OTA 更新和 Web 发布也一样。某个账号短时间内集中输错登录验证码,也会记成 ERROR,所以有人在乱猜的话,会和故障出现在同一个地方。
十月来过一条告警,任何日志里都找不到对应的行。从那以后,每条告警都带上一个短的随机 id、主机名和进程的启动命令,同一行也写进那个进程自己的日志。找不到 id,就说明它来自一个不往那里写日志的进程,比如一个 SSH shell。频道里的所有消息也会进一条信息流,和我一起干活的编程 agent 可以读,所以在改代码的 agent 和我同时看到这些错误。
日志:Fly 只存几分钟,归档存全部#
Fly 自己的缓冲只有几分钟的输出,大概最后一百行,盯部署够用,查昨天的事就没用了。所以第三个 Fly 应用跑着 Fly 的日志转发器,就是订阅了组织日志流的 Vector,我只改了它的 S3 sink:
[sinks.aws_s3]
type = "aws_s3"
bucket = "myapp-logs"
compression = "gzip"
key_prefix = "logs/{{fly.app.name}}/%F/" # one folder per app per day
batch.timeout_secs = 300 # a quiet app still lands its lines交易平台各个应用的每一行日志,都以 gzip 压缩的 JSON 落进一个兼容 S3 的 bucket。转发器是一台 256 MB 的 shared-cpu-1x 机器,每月大约 2 美元,存储费可以忽略。DuckDB 用一个宏直接就地读取这些文件,一天的数据一秒左右就出来:
CREATE MACRO logs(app, day := '*') AS TABLE
SELECT * FROM read_json_auto('s3://myapp-logs/logs/' || app || '/' || day || '/*.log.gz',
union_by_name := true);
SELECT timestamp, message FROM logs('myapp-api', '2026-09-06') WHERE message LIKE '% 500 %';我在缩容到零那篇里测冷启动,用的就是这个宏:启动脚本每个阶段都会打一行,每行都带着机器 id,剩下的就是一个 GROUP BY。
所以同一条日志流按读者分成三层:归档保留 INFO 及以上(这也是生产环境的应用用 INFO 级别打日志的原因),实时查看脚本只显示警告和错误,聊天频道收 ERROR。不过三者都不知道是谁发的请求。访问日志里没有客户端 IP,也没有 user agent,这个得去 Cloudflare 查。
不用自己搭的指标#
Fly 给每个组织跑一个托管的 Prometheus,所以我自己不维护任何指标系统。有问题时直接查它的 API:每台机器的内存、CPU 和突发额度(共享 CPU 上 fly_instance_cpu_balance 很要紧),每个应用的响应数和并发数。有两件事我希望早点知道。认证必须是 Authorization: FlyV1 <token>,用 Bearer 头或只读 token 都会返回 “something went wrong resolving organization”。另外数据大约只保留十二天。
它在九月真正派上了用场。那时日志里一天好几次出现 “Could not open a database connection”,看上去完全就是应用的 bug。托管 Postgres 集群也在同一个 Prometheus 里,每次出现这个错误的那一分钟,主库的抓取耗时都从 80 毫秒左右跳到一到十秒,副本一切正常,Postgres 的启动时间也没变。Postgres 没重启过,是它底下的宿主机卡住了。我把时间戳发给了 Fly 的支持团队;十月初一次维护重启把主库挪到另一台宿主机之后,每天的卡顿就没了。
手机上的崩溃#
以前应用在手机上崩了,我能看到的地方一点痕迹都没有。最直接的答案是 Sentry,我只用了它的一半:应用里装的是 Sentry 的开源 SDK,但 DSN 指向我们自己 Django API 里的一个接口,这个接口接收 Sentry 的 envelope 格式,把崩溃归类,再通过同一个聊天 handler 告警。链路追踪、会话和回放都关掉了,只收错误。
没注册 sentry.io,是因为一天就几条报告,为此多一家掌握用户数据的厂商、隐私政策里多一行、再多一张账单,不值得。代价是符号化(symbolication),这块我还没补上。堆栈要么是压缩过的 JavaScript,要么是原始的 native 地址,所以一个 issue 能告诉我崩的是什么、哪个版本、哪台设备、用户崩之前做了什么,但说不出是源码的哪一行。
唯一的仪表板是一条消息#
两个应用我都没做仪表板。去年那篇里写过一个检验标准:没人主动打开的仪表板,就是没证明自己的价值。自己送上门的消息不用打开,所以我最接近仪表板的东西,是每天早上发到团队聊天里的一条汇总。
开头是过去 24 小时的生产错误,从日志归档里读,而不是从聊天频道里读,因为告警限了频率,说不出一件事到底发生了多少次。数字、id 和地址先打码再归类,所以同一个失败出现一百次也只是一行加个计数,邮箱地址也不会流进频道。接着是失败的健康检查、昨天的使用量和上周同一天的对比(周日永远不会像周一),最近一周的搜索流量、等着回复的聊天、任务看板,还有开销。哪一部分加载不出来就照实写,汇总照样发出去。
通过 API 读应用分析数据,会让你对这些数字多一点怀疑。八月份,阿塞拜疆来了 121 次 Android 首次打开,随后是 119 次卸载。那个月的 Android 总数,我是扣掉这些来看的。
发布有自己的界面。以前 OTA 更新从我自己的服务器发的时候,我看一次发布的方式就是后台里一个带 “is active” 复选框的列表,根本不知道有多少台手机真的装上了。现在这个服务的仪表板能看到设备实际在跑哪个版本、安装情况如何,坏掉的 bundle 崩溃、SDK 退回上一个版本时还会显示这次崩溃回滚。迁移到托管更新服务的经过我另外写过。
弃用的,或者不再相信的#
这里面没有一样是付费工具。大多是我一直当成信号、其实不是信号的东西,还有一个放错了地方的任务。
按 user agent 分组之前的 Cloudflare 504 比例#
九月,Cloudflare 面板上显示一天里有 22% 的请求以 504 失败,集中在应用每 30 秒轮询一次的那几个接口上,而健康检查全是绿的,应用自己的日志也说这些请求很快。我在上面花了好几天,发现机器冷启动要 25 到 33 秒、一天要冷启动 92 次,修好了,然后在缩容到零那篇里把它写成了原因。
第二天早上,我把同样的 504 按 user agent 分组。9 月 5 日到 9 日之间的 7,344 个 504,全部来自 Cloudflare 自家的 Early Hints 探测请求(bastion early hints 和 nginx-ssl early hints),它们从源站拿不到响应,就被记成了 504。它们照搬真实请求的路径,所以都落在最忙的接口上,看起来和用户请求失败一模一样。应用、浏览器、curl 和搜索引擎爬虫一个 504 都没有。源站本来就不发 preload 头,Early Hints 什么作用都没起,于是我把它关了。
冷启动的优化并没有白做,部署和唤醒都因此变快了,但我以为自己在修的那个故障,从来没有落到任何用户身上。所以我最想分享的一条规则是:相信 CDN 的错误率之前,先按 user agent 分组,确认那不是 CDN 自己的机制产生的。在 Cloudflare 的免费套餐上,就是对 httpRequestsAdaptiveGroups 发 GraphQL 查询,一次查一天。下一次虚惊也是靠同一个查询解决的:有个轮询接口从每天 70 到 260 次请求涨到了 2,880 次。不是爬虫,是我自己的 Mac,测试完把应用开在 iOS 模拟器里忘了关。
一路绿灯的健康检查#
Fly 每 30 秒检查一次两个应用的 /api/health/,用结果决定哪些机器接流量,这件事它做得很好。但健康检查跑在已经起来的机器上,看不到机器起来之前发生的事:冷启动、代理前面的排队、根本没到达的请求。现在我只指望它做路由。部署时改用探测脚本,它在整个部署过程中持续打源站,只要有一次失败就以退出码 1 结束。它抓到过一次普通的 flyctl deploy 留下了 7.3 秒的空档;换成先唤醒一台备用机器的部署脚本之后,2,514 个请求里一个失败都没有。
健康检查自己还造成过两次故障。七月,Django 的 SECURE_SSL_REDIRECT 给 Fly 的明文 HTTP 探测回了 301,所有机器检查失败,所有人都收到了 503。八月,我把健康检查视图里的 except Exception 收窄了,结果 “connection already closed” 错误以 500 漏了出去,Fly 把机器摘掉了,而视图里的重试恰恰就是为这种情况准备的。现在这个检查只问一件事:这个进程能不能处理请求。数据库卡住时,两个应用也不会再被一起摘出路由。
早间汇总在计数上也学到了同样的教训。交易平台会把空闲机器挂起,被请求唤醒的机器在启动后几百毫秒时第一次检查会失败,下一次就通过了。把这些都算上,每天早上的汇总读起来都像出了故障。所以现在一分钟内重新通过的失败会单独列出,标注为机器唤醒和部署。
CI 上的定时任务#
早间汇总一开始是一个按 cron 定时跑的 CI workflow,不到一天就挪走了。它要读应用分析和搜索数据之后,需要的密钥就成了分析项目的服务账号密钥,这个密钥在那个项目里什么都能干,我不想把它放进 CI 的 secrets。现在由我 Mac 上的定时调度程序每 20 分钟运行一次,只在奥克兰时间早上八点以后、而且当天还没发过的时候才发,所以八点时在睡眠的 Mac 会在醒来时补发。workflow 留着当手动备用,只是不带应用分析和搜索那两部分。
如果从头再来#
我会在第一个用户到来之前、而不是第一次故障之后,先搭好三样东西:把错误发到团队本来就在看的聊天频道,并且限流;一个能用 SQL 查询的对象存储日志归档;以及平台本来就在收集的指标。每月花几美元而已。应用上手机的那一周加上崩溃报告,不然手机上的崩溃谁也不会知道。等要查看的地方多到我干脆一个都不看了,再加早间汇总。
链路追踪,我会等到出现第二个服务、请求真的要跨服务时再上;托管的 APM,等到有一个需要共享仪表板、值班轮换、也有预算替别人付账单的团队时再说。去年那篇里提到的工具,是从那时候开始才回本的。在那之前,最有用的习惯不花钱:一个数字看起来很吓人时,先搞清楚是什么产生了它,再判断是什么坏了。这篇里的好几次虚惊,都出在监控本身,而不是产品。
