<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>Observability · 仁才德</title>
    <link>https://jared.lynskey.co.nz/zh-cn/tags/observability/</link>
    <description>关于软件工程与研发团队领导力的短文——绩效评估、招聘与团队扩张、CI/CD、DevOps 监控、智能体 AI。写于首尔。</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <copyright>© 2026 仁才德</copyright>
    <lastBuildDate>Thu, 22 Oct 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/zh-cn/tags/observability/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>我给两个产品用的 DevOps 监控工具，以及被我弃用的那些</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-22-monitoring-tools-two-products/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-22-monitoring-tools-two-products/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>DevOps</category>
      <category>Monitoring</category>
      <category>Observability</category>
      <category>Site Reliability</category>
      <category>Fly.io</category>
      <category>Django</category>
      <category>Cloudflare</category>
      <category>Sentry</category>
      <category>Crash Reporting</category>
      <category>Small Teams</category>
      <category>Tech Stack</category>
      <description>两个跑在 Fly.io 上的小产品，我的 DevOps 监控就是这些：由 Django 日志喂消息的聊天频道、存在对象存储里用 DuckDB 查询的日志归档、Fly 自带的指标、通过 Sentry SDK 发到我自己服务器的应用崩溃报告，再加每天早上一条汇总。这篇讲每个工具盯的是什么、花多少钱，以及我弃用了什么、不再相信什么，其中包括一个最后发现是 Cloudflare 自家探测请求造成的 22% 错误率。</description>
      <content:encoded><![CDATA[<p>我有两个小产品：一个我做的集市兼社区应用，一个给上门报价的手艺人用的估价应用，都跑在 Fly.io 最便宜的机器上。我的 DevOps 监控归结起来就五样：把 ERROR 日志发到一个聊天频道；把每一行日志都归档到对象存储里，用 DuckDB 查询；Fly 自带的 Prometheus 指标；手机上的崩溃报告，用 Sentry 的开源 SDK 发到我自己的 API；以及每天早上一条汇总。没有托管的 APM，生产环境没有链路追踪，也没有需要记着去打开的仪表板。唯一额外花钱的是日志归档，每月大约 2 美元。下面是完整清单，以及我弃用或不再信任的东西，其中一样让我追了好几天一个根本没有用户碰到过的故障。</p>

<h2 class="relative group">我说的监控是什么，以及规模有多小
    <div id="我说的监控是什么以及规模有多小" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%88%91%e8%af%b4%e7%9a%84%e7%9b%91%e6%8e%a7%e6%98%af%e4%bb%80%e4%b9%88%e4%bb%a5%e5%8f%8a%e8%a7%84%e6%a8%a1%e6%9c%89%e5%a4%9a%e5%b0%8f" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我说的 DevOps 监控指两件事：在别人告诉我之前就发现东西坏了，以及不靠猜就能查出原因。下面每个工具都按这两条来评价。</p>
<p>去年我写过一篇<a href="https://jared.lynskey.co.nz/zh-cn/posts/2025/2025-10-13-devops-monitoring/" >搭建开发者真正会用的监控</a>，那时主要考虑的是团队：小团队用云厂商自带的监控，服务上百个了再上托管的 APM。这篇是同一个思路里最小的那一端。两个产品都是 Django，跑在 Fly 的 shared-cpu-1x 机器上，<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-07-22-my-current-stack/" >我当前的技术栈</a>里写过，而且两边的开发者都只有我一个。如果说监控是一个产品、开发者是它的用户，那我这套监控的用户就只有一个人。九月我从 Fly 的指标里拉了一次基线，交易平台的 API 平均 CPU 占用是 1%。这不是企业级的技术栈，我也不打算把它包装成那样。</p>
<p>指标、日志、链路这三根支柱，我有两根。两个产品都没有分布式追踪：各自就是一套 Django 代码加一个数据库，没有服务边界让链路去跨。我平时看的链路只有一种，就是<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-01-cloudflare-warp-dev-network/" >我工作站</a>上反向代理的链路。</p>

<h2 class="relative group">清单
    <div id="清单" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%b8%85%e5%8d%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>工具</th>
					<th>盯的是什么</th>
					<th>保留还是弃用，为什么</th>
					<th>费用</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>团队聊天频道，由 Django 的日志 handler 推送</td>
					<td>API、WebSocket 服务器和 worker 的所有 ERROR；部署；集中出现的错误登录验证码</td>
					<td>保留。这是我不用别人提醒也会去看的唯一地方</td>
					<td>免费</td>
			</tr>
			<tr>
					<td>Fly 的日志转发器写到兼容 S3 的 bucket，用 DuckDB 查询</td>
					<td>交易平台各个应用的每一行日志</td>
					<td>保留。Fly 自己的缓冲只存几分钟，这里存全部，还能用 SQL 查</td>
					<td>每月约 2 美元，外加存储</td>
			</tr>
			<tr>
					<td>Fly 自带指标（Prometheus API）</td>
					<td>CPU、内存、突发额度、响应；托管 Postgres 的 exporter</td>
					<td>保留。是它证明了数据库卡顿不是我代码的问题</td>
					<td>Fly 自带</td>
			</tr>
			<tr>
					<td><code>/api/health/</code> 上的 Fly 健康检查（两个应用）</td>
					<td>某台机器该不该接流量</td>
					<td>用于路由，保留。当作“用户没事”的证据，弃用</td>
					<td>Fly 自带</td>
			</tr>
			<tr>
					<td>Sentry 的 SDK，DSN 指向我自己的 API</td>
					<td>手机上的应用崩溃</td>
					<td>SDK 保留。从没注册 sentry.io</td>
					<td>API 之外不额外花钱</td>
			</tr>
			<tr>
					<td>团队聊天里的早间汇总</td>
					<td>前一天的错误、使用量、搜索、聊天和看板</td>
					<td>保留。从 CI 的定时任务挪到了我的 Mac 上</td>
					<td>免费</td>
			</tr>
			<tr>
					<td>应用分析 SDK（两个应用）和搜索引擎的站长后台</td>
					<td>Web、iOS、Android 的使用情况；搜索引擎怎么看这个站</td>
					<td>保留，通过 API 读取</td>
					<td>免费</td>
			</tr>
			<tr>
					<td>Cloudflare 的 GraphQL 分析</td>
					<td>某个状态码或路径背后是谁</td>
					<td>保留，但相信它之前先按 user agent 分组</td>
					<td>免费套餐，每次查询一天</td>
			</tr>
			<tr>
					<td>一个托管 OTA 更新服务的仪表板</td>
					<td>手机上跑的是哪个 OTA 版本；崩溃回滚</td>
					<td>从一个什么都看不出来的后台列表换到了这里</td>
					<td>属于更新服务的一部分</td>
			</tr>
			<tr>
					<td>一个小的探测脚本</td>
					<td>部署或重启过程中失败的请求</td>
					<td>保留。直接打源站，缓存藏不住空档</td>
					<td>免费</td>
			</tr>
			<tr>
					<td><code>flyctl logs</code></td>
					<td>应用最近几分钟的输出</td>
					<td>当实时查看用，保留。当记录用，弃用</td>
					<td>Fly 自带</td>
			</tr>
			<tr>
					<td>Cloudflare Early Hints</td>
					<td>什么都不盯，它是个提速功能</td>
					<td>关掉了。整个 zone 的 504 全是它的探测请求</td>
					<td>免费</td>
			</tr>
	</tbody>
</table>
<p>表里没写“两个应用”的，都只说交易平台。估价应用的东西更少：Fly 的健康检查、日志和指标，iOS 上的应用分析，再加一个每天把数据库导出成 Parquet、我用 DuckDB 查询的任务。它没有崩溃报告，也没有告警频道，这是这篇里最大的缺口。（关于更新服务那一行要说明一下：做这个服务的公司付钱请我给他们的博客写文章。）</p>

<h2 class="relative group">错误送到我本来就在的地方
    <div id="错误送到我本来就在的地方" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e9%94%99%e8%af%af%e9%80%81%e5%88%b0%e6%88%91%e6%9c%ac%e6%9d%a5%e5%b0%b1%e5%9c%a8%e7%9a%84%e5%9c%b0%e6%96%b9" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>九月之前，未处理的 500、发送失败的推送通知、坏掉的 WebSocket 帧，唯一会出现的地方是 <code>flyctl logs</code>，而那条流没人会主动去读。现在由一个日志 handler 把 ERROR 及以上的每条记录都发到聊天频道，不管来自 API、WebSocket 服务器还是后台 worker。WARNING 故意不告警。</p>
<p>handler 在后台线程里发送，所以绝不会拖慢请求。它内部不写任何日志，所以不会递归。同一个错误最多每十分钟发一次，每小时上限 30 条，所以出故障时你读到的是一条消息，而不是一千条。这个频道里还有我想拿来和错误对照着看的东西：部署在开始、上线成功和失败的地方都会发消息，带上 commit 和执行人，OTA 更新和 <a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-14-web-releases-without-redeploying/" >Web 发布</a>也一样。某个账号短时间内集中输错登录验证码，也会记成 ERROR，所以有人在乱猜的话，会和故障出现在同一个地方。</p>
<p>十月来过一条告警，任何日志里都找不到对应的行。从那以后，每条告警都带上一个短的随机 id、主机名和进程的启动命令，同一行也写进那个进程自己的日志。找不到 id，就说明它来自一个不往那里写日志的进程，比如一个 SSH shell。频道里的所有消息也会进一条信息流，和我一起干活的编程 agent 可以读，所以在改代码的 agent 和我同时看到这些错误。</p>

<h2 class="relative group">日志：Fly 只存几分钟，归档存全部
    <div id="日志fly-只存几分钟归档存全部" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%97%a5%e5%bf%97fly-%e5%8f%aa%e5%ad%98%e5%87%a0%e5%88%86%e9%92%9f%e5%bd%92%e6%a1%a3%e5%ad%98%e5%85%a8%e9%83%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Fly 自己的缓冲只有几分钟的输出，大概最后一百行，盯部署够用，查昨天的事就没用了。所以第三个 Fly 应用跑着 Fly 的日志转发器，就是订阅了组织日志流的 Vector，我只改了它的 S3 sink：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">sinks</span><span class="p">.</span><span class="nx">aws_s3</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s2">&#34;aws_s3&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">bucket</span> <span class="p">=</span> <span class="s2">&#34;myapp-logs&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">compression</span> <span class="p">=</span> <span class="s2">&#34;gzip&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">key_prefix</span> <span class="p">=</span> <span class="s2">&#34;logs/{{fly.app.name}}/%F/&#34;</span>   <span class="c"># one folder per app per day</span>
</span></span><span class="line"><span class="cl">  <span class="nx">batch</span><span class="p">.</span><span class="nx">timeout_secs</span> <span class="p">=</span> <span class="mi">300</span>                  <span class="c"># a quiet app still lands its lines</span></span></span></code></pre></div></div>
<p>交易平台各个应用的每一行日志，都以 gzip 压缩的 JSON 落进一个兼容 S3 的 bucket。转发器是一台 256 MB 的 shared-cpu-1x 机器，每月大约 2 美元，存储费可以忽略。DuckDB 用一个宏直接就地读取这些文件，一天的数据一秒左右就出来：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="n">MACRO</span><span class="w"> </span><span class="n">logs</span><span class="p">(</span><span class="n">app</span><span class="p">,</span><span class="w"> </span><span class="k">day</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;*&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="k">TABLE</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">read_json_auto</span><span class="p">(</span><span class="s1">&#39;s3://myapp-logs/logs/&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">app</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39;/&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="k">day</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39;/*.log.gz&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                               </span><span class="n">union_by_name</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">timestamp</span><span class="p">,</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">logs</span><span class="p">(</span><span class="s1">&#39;myapp-api&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-09-06&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;% 500 %&#39;</span><span class="p">;</span></span></span></code></pre></div></div>
<p>我在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/" >缩容到零那篇</a>里测冷启动，用的就是这个宏：启动脚本每个阶段都会打一行，每行都带着机器 id，剩下的就是一个 <code>GROUP BY</code>。</p>
<p>所以同一条日志流按读者分成三层：归档保留 INFO 及以上（这也是生产环境的应用用 INFO 级别打日志的原因），实时查看脚本只显示警告和错误，聊天频道收 ERROR。不过三者都不知道是谁发的请求。访问日志里没有客户端 IP，也没有 user agent，这个得去 Cloudflare 查。</p>

<h2 class="relative group">不用自己搭的指标
    <div id="不用自己搭的指标" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%8d%e7%94%a8%e8%87%aa%e5%b7%b1%e6%90%ad%e7%9a%84%e6%8c%87%e6%a0%87" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Fly 给每个组织跑一个托管的 Prometheus，所以我自己不维护任何指标系统。有问题时直接查它的 API：每台机器的内存、CPU 和突发额度（共享 CPU 上 <code>fly_instance_cpu_balance</code> 很要紧），每个应用的响应数和并发数。有两件事我希望早点知道。认证必须是 <code>Authorization: FlyV1 &lt;token&gt;</code>，用 Bearer 头或只读 token 都会返回 &ldquo;something went wrong resolving organization&rdquo;。另外数据大约只保留十二天。</p>
<p>它在九月真正派上了用场。那时日志里一天好几次出现 &ldquo;Could not open a database connection&rdquo;，看上去完全就是应用的 bug。托管 Postgres 集群也在同一个 Prometheus 里，每次出现这个错误的那一分钟，主库的抓取耗时都从 80 毫秒左右跳到一到十秒，副本一切正常，Postgres 的启动时间也没变。Postgres 没重启过，是它底下的宿主机卡住了。我把时间戳发给了 Fly 的支持团队；十月初一次维护重启把主库挪到另一台宿主机之后，每天的卡顿就没了。</p>

<h2 class="relative group">手机上的崩溃
    <div id="手机上的崩溃" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%89%8b%e6%9c%ba%e4%b8%8a%e7%9a%84%e5%b4%a9%e6%ba%83" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>以前应用在手机上崩了，我能看到的地方一点痕迹都没有。最直接的答案是 Sentry，我只用了它的一半：应用里装的是 Sentry 的开源 SDK，但 DSN 指向我们自己 Django API 里的一个接口，这个接口接收 Sentry 的 envelope 格式，把崩溃归类，再通过同一个聊天 handler 告警。链路追踪、会话和回放都关掉了，只收错误。</p>
<p>没注册 sentry.io，是因为一天就几条报告，为此多一家掌握用户数据的厂商、隐私政策里多一行、再多一张账单，不值得。代价是符号化（symbolication），这块我还没补上。堆栈要么是压缩过的 JavaScript，要么是原始的 native 地址，所以一个 issue 能告诉我崩的是什么、哪个版本、哪台设备、用户崩之前做了什么，但说不出是源码的哪一行。</p>

<h2 class="relative group">唯一的仪表板是一条消息
    <div id="唯一的仪表板是一条消息" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%94%af%e4%b8%80%e7%9a%84%e4%bb%aa%e8%a1%a8%e6%9d%bf%e6%98%af%e4%b8%80%e6%9d%a1%e6%b6%88%e6%81%af" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>两个应用我都没做仪表板。去年那篇里写过一个检验标准：没人主动打开的仪表板，就是没证明自己的价值。自己送上门的消息不用打开，所以我最接近仪表板的东西，是每天早上发到团队聊天里的一条汇总。</p>
<p>开头是过去 24 小时的生产错误，从日志归档里读，而不是从聊天频道里读，因为告警限了频率，说不出一件事到底发生了多少次。数字、id 和地址先打码再归类，所以同一个失败出现一百次也只是一行加个计数，邮箱地址也不会流进频道。接着是失败的健康检查、昨天的使用量和上周同一天的对比（周日永远不会像周一），最近一周的搜索流量、等着回复的聊天、任务看板，还有开销。哪一部分加载不出来就照实写，汇总照样发出去。</p>
<p>通过 API 读应用分析数据，会让你对这些数字多一点怀疑。八月份，阿塞拜疆来了 121 次 Android 首次打开，随后是 119 次卸载。那个月的 Android 总数，我是扣掉这些来看的。</p>
<p>发布有自己的界面。以前 OTA 更新从我自己的服务器发的时候，我看一次发布的方式就是后台里一个带 &ldquo;is active&rdquo; 复选框的列表，根本不知道有多少台手机真的装上了。现在这个服务的仪表板能看到设备实际在跑哪个版本、安装情况如何，坏掉的 bundle 崩溃、SDK 退回上一个版本时还会显示这次崩溃回滚。<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-13-moving-to-codemagic-patch/" >迁移到托管更新服务的经过</a>我另外写过。</p>

<h2 class="relative group">弃用的，或者不再相信的
    <div id="弃用的或者不再相信的" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%bc%83%e7%94%a8%e7%9a%84%e6%88%96%e8%80%85%e4%b8%8d%e5%86%8d%e7%9b%b8%e4%bf%a1%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这里面没有一样是付费工具。大多是我一直当成信号、其实不是信号的东西，还有一个放错了地方的任务。</p>

<h3 class="relative group">按 user agent 分组之前的 Cloudflare 504 比例
    <div id="按-user-agent-分组之前的-cloudflare-504-比例" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%8c%89-user-agent-%e5%88%86%e7%bb%84%e4%b9%8b%e5%89%8d%e7%9a%84-cloudflare-504-%e6%af%94%e4%be%8b" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>九月，Cloudflare 面板上显示一天里有 22% 的请求以 504 失败，集中在应用每 30 秒轮询一次的那几个接口上，而健康检查全是绿的，应用自己的日志也说这些请求很快。我在上面花了好几天，发现机器冷启动要 25 到 33 秒、一天要冷启动 92 次，修好了，然后在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/" >缩容到零那篇</a>里把它写成了原因。</p>
<p>第二天早上，我把同样的 504 按 user agent 分组。9 月 5 日到 9 日之间的 7,344 个 504，全部来自 Cloudflare 自家的 Early Hints 探测请求（<code>bastion early hints</code> 和 <code>nginx-ssl early hints</code>），它们从源站拿不到响应，就被记成了 504。它们照搬真实请求的路径，所以都落在最忙的接口上，看起来和用户请求失败一模一样。应用、浏览器、curl 和搜索引擎爬虫一个 504 都没有。源站本来就不发 preload 头，Early Hints 什么作用都没起，于是我把它关了。</p>
<p>冷启动的优化并没有白做，部署和唤醒都因此变快了，但我以为自己在修的那个故障，从来没有落到任何用户身上。所以我最想分享的一条规则是：相信 CDN 的错误率之前，先按 user agent 分组，确认那不是 CDN 自己的机制产生的。在 Cloudflare 的免费套餐上，就是对 <code>httpRequestsAdaptiveGroups</code> 发 GraphQL 查询，一次查一天。下一次虚惊也是靠同一个查询解决的：有个轮询接口从每天 70 到 260 次请求涨到了 2,880 次。不是爬虫，是我自己的 Mac，测试完把应用开在 iOS 模拟器里忘了关。</p>

<h3 class="relative group">一路绿灯的健康检查
    <div id="一路绿灯的健康检查" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e8%b7%af%e7%bb%bf%e7%81%af%e7%9a%84%e5%81%a5%e5%ba%b7%e6%a3%80%e6%9f%a5" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>Fly 每 30 秒检查一次两个应用的 <code>/api/health/</code>，用结果决定哪些机器接流量，这件事它做得很好。但健康检查跑在已经起来的机器上，看不到机器起来之前发生的事：冷启动、代理前面的排队、根本没到达的请求。现在我只指望它做路由。部署时改用探测脚本，它在整个部署过程中持续打源站，只要有一次失败就以退出码 1 结束。它抓到过一次普通的 <code>flyctl deploy</code> 留下了 7.3 秒的空档；换成先唤醒一台备用机器的部署脚本之后，2,514 个请求里一个失败都没有。</p>
<p>健康检查自己还造成过两次故障。七月，Django 的 <code>SECURE_SSL_REDIRECT</code> 给 Fly 的明文 HTTP 探测回了 301，所有机器检查失败，所有人都收到了 503。八月，我把健康检查视图里的 <code>except Exception</code> 收窄了，结果 &ldquo;connection already closed&rdquo; 错误以 500 漏了出去，Fly 把机器摘掉了，而视图里的重试恰恰就是为这种情况准备的。现在这个检查只问一件事：这个进程能不能处理请求。数据库卡住时，两个应用也不会再被一起摘出路由。</p>
<p>早间汇总在计数上也学到了同样的教训。交易平台会把空闲机器挂起，被请求唤醒的机器在启动后几百毫秒时第一次检查会失败，下一次就通过了。把这些都算上，每天早上的汇总读起来都像出了故障。所以现在一分钟内重新通过的失败会单独列出，标注为机器唤醒和部署。</p>

<h3 class="relative group">CI 上的定时任务
    <div id="ci-上的定时任务" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ci-%e4%b8%8a%e7%9a%84%e5%ae%9a%e6%97%b6%e4%bb%bb%e5%8a%a1" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>早间汇总一开始是一个按 cron 定时跑的 CI workflow，不到一天就挪走了。它要读应用分析和搜索数据之后，需要的密钥就成了分析项目的服务账号密钥，这个密钥在那个项目里什么都能干，我不想把它放进 CI 的 secrets。现在由我 Mac 上的定时调度程序每 20 分钟运行一次，只在奥克兰时间早上八点以后、而且当天还没发过的时候才发，所以八点时在睡眠的 Mac 会在醒来时补发。workflow 留着当手动备用，只是不带应用分析和搜索那两部分。</p>

<h2 class="relative group">如果从头再来
    <div id="如果从头再来" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%a6%82%e6%9e%9c%e4%bb%8e%e5%a4%b4%e5%86%8d%e6%9d%a5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我会在第一个用户到来之前、而不是第一次故障之后，先搭好三样东西：把错误发到团队本来就在看的聊天频道，并且限流；一个能用 SQL 查询的对象存储日志归档；以及平台本来就在收集的指标。每月花几美元而已。应用上手机的那一周加上崩溃报告，不然手机上的崩溃谁也不会知道。等要查看的地方多到我干脆一个都不看了，再加早间汇总。</p>
<p>链路追踪，我会等到出现第二个服务、请求真的要跨服务时再上；托管的 APM，等到有一个需要共享仪表板、值班轮换、也有预算替别人付账单的团队时再说。去年那篇里提到的工具，是从那时候开始才回本的。在那之前，最有用的习惯不花钱：一个数字看起来很吓人时，先搞清楚是什么产生了它，再判断是什么坏了。这篇里的好几次虚惊，都出在监控本身，而不是产品。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-monitoring-tools-two-products/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
