<?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>Cloudflare · 仁才德</title>
    <link>https://jared.lynskey.co.nz/zh-cn/tags/cloudflare/</link>
    <description>关于软件工程与研发团队领导力的短文——绩效评估、招聘与团队扩张、CI/CD、DevOps 监控、智能体 AI。写于首尔。</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <copyright>© 2026 仁才德</copyright>
    <lastBuildDate>Wed, 11 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/zh-cn/tags/cloudflare/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>笔记本就是服务器：用 Cloudflare Tunnel 把副业项目放上公网</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/self-host-behind-cloudflare-tunnel/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/self-host-behind-cloudflare-tunnel/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>Cloudflare Tunnel</category>
      <category>cloudflared</category>
      <category>Self-Hosting</category>
      <category>Expose Localhost</category>
      <category>Docker Compose</category>
      <category>Django</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Security</category>
      <category>Dev Costs</category>
      <description>Cloudflare Tunnel 是什么、安全性如何、要花多少钱？不开任何端口，让自己机器上的服务拿到真正的 HTTPS 主机名，再用 ingress 白名单决定公网能访问哪些路径，附 Docker Compose 配置。</description>
      <content:encoded><![CDATA[<p>我的每个想法都是这样开始的：在笔记本上跑起来了，能用，然后想让别人也看看。有很长一段时间，下一步总是一张云账单：一台机器、一个数据库、一个 bucket、一个指过去的域名，再为一个只有三个人会看的东西按月付费。对副业项目，或者一个正经项目的第一个月来说，这个顺序是反的：还不知道会不会有生产环境，就先为生产环境掏钱了。</p>
<p>这篇文章讲的是另一种顺序。服务继续跑在开发它的那台机器上，由 Cloudflare Tunnel 给它一个公开的 HTTPS 主机名。笔记本上没有任何东西对互联网监听：不做端口转发，不碰路由器设置，也不暴露 IP 地址。是机器主动连出去到 Cloudflare，Cloudflare 再把请求顺着这条连接送回来。Tunnel 本身免费，TLS 证书、DDoS 防护和缓存则随你多半已经有的 Cloudflare zone 一起附送。</p>
<p>例子是真实的。我的<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-07-22-my-current-stack/" >一个产品</a> 跑在 Fly.io 上，它的移动应用从后端获取 JavaScript 的 OTA 更新（自托管的 Expo Updates）。我想在自己的 Mac 上再跑一份更新服务器，给开发构建用，生产环境原封不动。下面的输出都来自那个下午，包括没跑通的那部分。</p>

<h2 class="relative group">Cloudflare Tunnel 到底是什么
    <div id="cloudflare-tunnel-到底是什么" 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="#cloudflare-tunnel-%e5%88%b0%e5%ba%95%e6%98%af%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>普通的服务器是等别人连进来的。要从互联网访问一台笔记本，你得在路由器上开端口、转发到笔记本，还得指望运营商给你一个公网 IPv4 地址（很多地方都不给，我这里也是）。然后，那个端口上监听的所有东西，整天都暴露在互联网上每一个扫描器面前。</p>
<p>Tunnel 把方向反过来。<code>cloudflared</code> 跑在你的应用旁边，主动向 Cloudflare 的边缘建立出站连接，一共四条，连到附近的两个数据中心。你 zone 里的一条 DNS 记录把主机名指向这个 tunnel。当请求打到这个主机名时，Cloudflare 终止 TLS，把请求顺着其中一条连接转发下来，<code>cloudflared</code> 再按你配置里写的交给对应的本地服务。</p>
<figure class="diagram">
  <div role="img" aria-label="请求经 HTTPS 到达 Cloudflare，经过它的规则，到达 tunnel 端点，再顺着 cloudflared 从你机器上建立的出站连接下来；cloudflared 按主机名和路径把它交给 API、edge，或者返回 404"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 520" aria-hidden="true" focusable="false">
<defs><marker id="tu-ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="d-head" d="M0,0 L10,5 L0,10 z"/></marker></defs>
<text class="d-title" x="160" y="60" text-anchor="middle" font-size="30">互联网</text>
<text class="d-title" x="560" y="60" text-anchor="middle" font-size="30">Cloudflare</text>
<text class="d-title" x="1020" y="60" text-anchor="middle" font-size="30">你的机器</text>
<rect class="d-box" x="40" y="200" width="240" height="100" rx="14"/>
<text class="d-label" x="160" y="245" text-anchor="middle" font-size="24">手机 / 浏览器</text>
<text class="d-sub" x="160" y="278" text-anchor="middle" font-size="18">ota.example.com</text>
<rect class="d-panel" x="400" y="110" width="320" height="300" rx="30"/>
<rect class="d-box" x="430" y="140" width="260" height="80" rx="14"/>
<text class="d-label" x="560" y="188" text-anchor="middle" font-size="22">TLS、重定向规则、WAF</text>
<rect class="d-box" x="430" y="290" width="260" height="80" rx="14"/>
<text class="d-label" x="560" y="338" text-anchor="middle" font-size="22">Tunnel 端点</text>
<rect class="d-group" x="820" y="110" width="400" height="360" rx="30"/>
<rect class="d-box" x="850" y="290" width="160" height="80" rx="14"/>
<text class="d-label" x="930" y="338" text-anchor="middle" font-size="22">cloudflared</text>
<rect class="d-box" x="1050" y="160" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="202" text-anchor="middle" font-size="20">api:8000</text>
<rect class="d-box" x="1050" y="290" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="332" text-anchor="middle" font-size="20">edge:8081</text>
<rect class="d-box" x="1050" y="380" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="422" text-anchor="middle" font-size="20">404</text>
<path class="d-line" d="M280,250 L428,190" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="350" y="200" text-anchor="middle" font-size="18">HTTPS</text>
<path class="d-line" d="M560,220 L560,288" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M848,330 L692,330" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="770" y="315" text-anchor="middle" font-size="16">出站 QUIC</text>
<text class="d-sub" x="770" y="360" text-anchor="middle" font-size="16">（不开端口）</text>
<path class="d-line" d="M1010,315 L1048,200" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M1010,330 L1048,325" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M1010,345 L1048,410" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="1020" y="500" text-anchor="middle" font-size="18">ingress 规则：主机名 + 路径白名单</text>
</svg>
</div><figcaption>机器只会往外连。Cloudflare 的 zone 规则在 tunnel 之前执行；ingress 规则决定什么能被访问到。</figcaption>
</figure>

<p>从安全角度看，关键在于路由器上没有任何入站规则，你的 IP 地址也从不出现在 DNS 里。有人扫描你家的网络连接，什么也找不到，因为上面根本没有东西在监听。</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%bb%8e%e5%a4%b4%e5%88%b0%e5%b0%be%e7%9a%84%e6%90%ad%e5%bb%ba" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>你需要一个托管在 Cloudflare 上的域名（免费套餐就够），以及装好 <code>cloudflared</code>（Mac 上 <code>brew install cloudflared</code>）。</p>
<p><strong>1. 登录一次。</strong> 这会打开浏览器，你选好 zone，它会保存一张证书，让这台机器可以在那个 zone 里创建 tunnel 和 DNS 记录：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cloudflared tunnel login</span></span></code></pre></div></div>
<p><strong>2. 创建 tunnel。</strong> 这个项目的所有东西我都放在单独的文件夹里，因为凭据文件就是 tunnel 的身份：谁拿到它，谁就能替你的主机名提供流量。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p ~/.cloudflared/myapp <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/.cloudflared/myapp
</span></span><span class="line"><span class="cl">cloudflared tunnel create --credentials-file ./creds.json myapp-local
</span></span><span class="line"><span class="cl">chmod <span class="m">600</span> creds.json</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Tunnel credentials written to ./creds.json. Keep this file secret. To revoke these credentials, delete the tunnel.
</span></span><span class="line"><span class="cl">Created tunnel myapp-local with id 1ade030c-…</span></span></code></pre></div></div>
<p><strong>3. 把主机名指向它。</strong> 每个主机名一条命令，创建一条指向 <code>&lt;tunnel-id&gt;.cfargotunnel.com</code> 的代理 CNAME：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cloudflared tunnel route dns myapp-local ota.example.com
</span></span><span class="line"><span class="cl">cloudflared tunnel route dns myapp-local checkin-local.example.com</span></span></code></pre></div></div>
<p><strong>4. 写 ingress 规则。</strong> 这是对安全最要紧的部分，所以下面单独一节来讲。</p>
<p><strong>5. 跑起来。</strong> 我的应用本来就跑在 Docker Compose 下，所以 <code>cloudflared</code> 只是同一个网络里多出来的一个容器。它按服务名访问应用，应用根本不需要在宿主机上发布端口：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># compose.tunnel.yml, used alongside the project&#39;s docker-compose.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cloudflared</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/cloudflared:2026.10.7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">tunnel --config /etc/cloudflared/config.yml run</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">${HOME}/.cloudflared/myapp:/etc/cloudflared:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">api, edge]</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose -f docker-compose.yml -f ~/.cloudflared/myapp/compose.tunnel.yml up -d cloudflared</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">INF Registered tunnel connection connIndex=0 … location=icn06 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=1 … location=icn07 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=2 … location=icn07 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=3 … location=icn06 protocol=quic</span></span></code></pre></div></div>
<p>四条连接连到首尔，笔记本就从外向内地上了互联网。<code>restart: unless-stopped</code> 让它在重启后、Docker 一启动就自动恢复。</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%9a%b4%e9%9c%b2%e8%b7%af%e5%be%84%e8%80%8c%e4%b8%8d%e6%98%af%e6%9c%8d%e5%8a%a1%e5%99%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这是我差点犯的错，我猜也是最常见的一个。最省事的配置只有一行：<code>hostname → http://localhost:8000</code>。这会把<strong>整个开发服务器</strong>都发布出去。</p>
<p>开发服务器不是为这个设计的。我的开着 <code>DEBUG=True</code>，任何错误都会显示完整的 traceback 和配置。它还关掉了认证（<code>AUTH_MODE=disabled</code>），任何 bearer token 都会让你以测试用户身份登录。这在 <code>localhost</code> 上完全合理，放到公网主机名上就是灾难。你的多半也有类似的东西：用默认密码的管理后台、灌测试数据的端点、debug toolbar，或者因为只有你自己在调用、所以信任调用方的 API。</p>
<p>所以 tunnel 配置是一份白名单。<code>cloudflared</code> 按顺序拿每个请求去匹配 ingress 规则，按主机名，也按路径上的正则表达式。第一条匹配的生效，最后一条兜住其余所有请求：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">tunnel</span><span class="p">:</span><span class="w"> </span><span class="l">myapp-local</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">credentials-file</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/cloudflared/creds.json</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="nt">ingress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># OTA: the two read-only endpoints the phone calls, and the files they link.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ota.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/api/expo-updates/(manifest|status)/$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://api:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ota.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/media/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://api:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Device check-ins, authenticated by each device&#39;s own key.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">checkin-local.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/c/$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://edge:8081</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Everything else, including /admin/ and the publish endpoint: 404 at the edge.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http_status:404</span></span></span></code></pre></div></div>
<p>发布端点就在同一台服务器上、同一个前缀下（<code>/api/expo-updates/import-update/</code>），但它不在列表里。管理后台和 API 的其余部分也不在。这些请求根本出不了 Cloudflare。</p>
<p>你不必盲信自己的正则；<code>cloudflared</code> 会告诉你一个 URL 匹配的是哪条规则：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cloudflared tunnel --config config.yml ingress rule https://ota.example.com/admin/</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/manifest/        service: http://api:8000
</span></span><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/import-update/   service: http_status:404
</span></span><span class="line"><span class="cl">https://ota.example.com/admin/                            service: http_status:404
</span></span><span class="line"><span class="cl">https://ota.example.com/media/expo-updates/x.js           service: http://api:8000
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/c/                      service: http://edge:8081
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/health                  service: http_status:404</span></span></code></pre></div></div>
<p>我会把这个检查写进脚本，每次改配置都跑一遍。只要几秒钟，而一条比你本意匹配得更多的正则，正是那种只会从别人那里得知的错误。</p>
<p>还有两条经验法则：</p>
<ul>
<li><strong>暴露出去的东西应该自己负责认证。</strong> OTA 端点提供的是公开的只读数据。设备 check-in 用每台设备自己的密钥签名。两者的安全都不依赖网络。如果你想给别人看一个没有登录的页面，就在那个主机名前面放 <a href="https://developers.cloudflare.com/cloudflare-one/policies/access/"  target="_blank" rel="noreferrer">Cloudflare Access</a>（50 个用户以内免费），而不是自己去做登录。</li>
<li><strong>别把 tunnel 当成开发流程里常驻后台的一部分。</strong> 有意识地启动它，只为你想要的那个主机名，用完就停。<code>docker compose stop cloudflared</code> 一条命令就把一切从互联网上撤下来。</li>
</ul>

<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%90%83%e6%8e%89%e6%88%91%e4%b8%bb%e6%9c%ba%e5%90%8d%e7%9a%84%e9%82%a3%e4%b8%aa%e9%87%8d%e5%ae%9a%e5%90%91" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Tunnel 连上了，规则也验证过了，我从外部试了一下：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/status/?…   301
</span></span><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/manifest/   301
</span></span><span class="line"><span class="cl">https://ota.example.com/admin/                       301
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/c/                 301</span></span></code></pre></div></div>
<p>每条路径都返回同一个 301，包括那些 tunnel 本该回 404 的。这说明请求根本没到 <code>cloudflared</code>。<code>Location</code> 头写的是 <code>https://www.example.com/</code>，再加上 <code>server: cloudflare</code> 头，事情就清楚了：是 Cloudflare 自己在应答。</p>
<p>这是我自己挖的坑。几个月前我加了一条 zone 级的重定向规则，“其他任何子域名 → www”，用来当<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/#the-spike-was-a-red-herring" >那些遍历编造出来的子域名的扫描器的黑洞</a>。重定向规则在 Cloudflare 的请求流水线里执行，早于请求被发往源站，而 tunnel 不过是一个源站。<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >同一条规则也拦住过我的预览环境</a>，让 Workers 根本见不到请求。修法和上次一样：在规则表达式里再加一个例外。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">… and not http.host in {&#34;ota.example.com&#34; &#34;checkin-local.example.com&#34;}</span></span></code></pre></div></div>
<p>如果你的 tunnel 主机名返回的是 Cloudflare 的重定向或错误页面，而不是你的应用，那在调试 tunnel 之前，先检查这个 zone 的 <strong>Rules → Redirect Rules</strong>、Page Rules 和所有 WAF 自定义规则。<code>cloudflared</code> 会记录它转发的每个请求，所以日志为空就说明请求根本没走到那一步。</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%ae%83%e5%81%9a%e4%b8%8d%e5%88%b0%e7%9a%84%e4%ba%8b" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li><strong>来自公网的 UDP。</strong> 公开的 tunnel 主机名只承载 HTTP(S)（外加 WebSocket 和 gRPC）。我产品的设备平时通过 UDP 上的 CoAP 做 check-in，这走不了 tunnel；连本地副本的设备会退回到 HTTPS check-in，每次要多花几秒钟。如果你的想法是游戏服务器或 VoIP，tunnel 就不是合适的工具。</li>
<li><strong>在线率。</strong> 这是你的笔记本。合上盖子，站点就挂了；Cloudflare 会显示一个 530 错误页。拿来做演示、十个人的内测或者一份 staging 副本都没问题，但不适合任何有人依赖的东西。</li>
<li><strong>编译进构建里的东西。</strong> 移动应用的更新 URL 是原生配置，所以只有用 tunnel URL 构建出来的包才会用它。我的商店构建依然连着生产环境，这也正是这套方案敢拿来试的原因。</li>
</ul>

<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%90%e6%9c%ac" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>免费套餐的 Cloudflare zone、tunnel、它的证书和 DNS 记录：0 美元。机器本来就开着。唯一真正的成本是，现在你自己就是运维值班团队。</p>
<p>它帮你省下的，是从“在我机器上能跑”到“值得花钱托管”之间的那个阶段。当天下午你就能把链接发给测试、设计师或者第一个客户。如果没人在意，你什么也没损失。如果有人在意，迁到真正的主机上也只是一处改动：把 CNAME 指到别处。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/self-host-behind-cloudflare-tunnel/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Cloudflare 504 错误其实不是故障：Early Hints 与 HTTP 103 详解</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/cloudflare-504-early-hints/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/cloudflare-504-early-hints/</guid>
      <pubDate>Wed, 04 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>Early Hints</category>
      <category>HTTP 103</category>
      <category>504 Gateway Timeout</category>
      <category>网站性能</category>
      <category>Preload</category>
      <category>可观测性</category>
      <category>调试</category>
      <description>Cloudflare 上记录了几千个 504 错误，却没有一个到达真实用户：它们全是 Early Hints 的缓存未命中。本文讲清 HTTP 103 的工作方式、如何分辨假 504 和真 504，以及解决问题的那一个响应头。</description>
      <content:encoded><![CDATA[<p>Cloudflare 的面板说我有四分之一的请求是 504。但没有一个到达过用户：它们是 Cloudflare Early Hints 功能的缓存未命中，被记录成 <code>ClientRequestSource = earlyHintsCache</code>、<code>OriginResponseStatus = 0</code> 的 504。这篇讲怎么把它们和真正的 504 分开，以及把未命中变成命中的那一个响应头。</p>
<p>我第一次碰到它们是在 9 月，<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/" >健康检查全绿、Cloudflare 面板上却有 22% 的请求在失败</a>，我关掉了 Early Hints，就此作罢。11 月再看同一个 zone，Early Hints 又被打开了，一天之内又多出了 2,600 个 504。这一次我想弄清楚它们到底是什么，以及正确的做法是再关一次这个功能，还是让它真正发挥作用。这个 zone 前面挂着一个 Fly.io 源站，它同时提供一个 Django API、一个 Expo Web 应用和一个用 Hugo 搭的营销网站。</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="#%e7%ae%80%e7%9f%ad%e7%9a%84%e7%ad%94%e6%a1%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li>这些 504 是 Cloudflare 的 Early Hints 缓存写下的，不是你的访客。在 Cloudflare 日志里，它们的 <code>ClientRequestSource = earlyHintsCache</code>。</li>
<li>这里的 504 表示缓存未命中：Cloudflare 没有为这个 URL 存下任何提示。命中则记录为 2xx。</li>
<li>它们根本不会到达你的源站，<code>OriginResponseStatus</code> 为 <code>0</code>。</li>
<li>Early Hints 依赖的是 <code>Link</code> HTTP 响应头，HTML 里的 <code>&lt;link rel=&quot;preload&quot;&gt;</code> 标签喂不了它。</li>
<li>把这个响应头发出去，在那些有资源可预加载的页面上，这些未命中就会变成命中，并开始返回真正的 <code>103 Early Hints</code> 响应。</li>
</ul>
<p>还有三件事值得先知道。只提示渲染关键的 CSS、脚本和字体，并且字体要保留 <code>crossorigin</code>。用 <code>curl -sv --http2</code> 验证，你应该在 <code>200</code> 之前看到 <code>103</code>。无论你做什么决定，都把 <code>earlyHintsCache</code> 排除在告警之外；如果你今后都不会发送这个响应头，就关掉 Early Hints，别用日志噪音为它买单。</p>

<h2 class="relative group">如何分辨假的 Cloudflare 504 和真的 504
    <div id="如何分辨假的-cloudflare-504-和真的-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="#%e5%a6%82%e4%bd%95%e5%88%86%e8%be%a8%e5%81%87%e7%9a%84-cloudflare-504-%e5%92%8c%e7%9c%9f%e7%9a%84-504" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>如果你用 Logpush 或 Instant Logs，先看两个字段。<code>ClientRequestSource</code> 对真实访客的请求是 <code>eyeball</code>，对 Early Hints 查询则是 <code>earlyHintsCache</code>。<code>OriginResponseStatus</code> 为 <code>0</code> 时，说明源站根本没有被联系过。</p>
<p>在 GraphQL Analytics API 里，同样的区分就是 <code>requestSource</code> 维度。现在每次 5xx 飙升，我在相信它之前都会先跑这个查询：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-graphql" data-lang="graphql"><span class="line"><span class="cl"><span class="kd">query</span><span class="w"> </span><span class="p">(</span><span class="nv">$zone</span><span class="p">:</span><span class="w"> </span><span class="nc">String</span><span class="p">!,</span><span class="w"> </span><span class="nv">$since</span><span class="p">:</span><span class="w"> </span><span class="nc">Time</span><span class="p">!)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="py">viewer</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="py">zones</span><span class="p">(</span><span class="py">filter</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">zoneTag</span><span class="p">:</span><span class="w"> </span><span class="nv">$zone</span><span class="w"> </span><span class="p">})</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="py">httpRequestsAdaptiveGroups</span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">limit</span><span class="p">:</span><span class="w"> </span><span class="nc">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">filter</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">datetime_geq</span><span class="p">:</span><span class="w"> </span><span class="nv">$since</span><span class="p">,</span><span class="w"> </span><span class="nc">edgeResponseStatus</span><span class="p">:</span><span class="w"> </span><span class="nc">504</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">orderBy</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="py">count_DESC</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">count</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">dimensions</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">requestSource</span><span class="w"> </span><span class="py">userAgent</span><span class="w"> </span><span class="py">clientRequestPath</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>按 User-Agent 分组，结论也一样。9 月时，Early Hints 查询显示为 <code>bastion early hints</code> 和 <code>nginx-ssl early hints</code>；11 月则是 <code>fl2 early hints</code> 和 <code>nginx-ssl early hints</code>（FL2 是 Cloudflare 新一代的代理）。24 小时内，这个 zone 的 2,631 个 504 全部来自这些 User-Agent，没有一个来自浏览器、应用、curl 或搜索引擎爬虫。</p>
<p>访客真正看到的 504，几乎总是来自 Cloudflare 之外，也就是你这一侧：挡在应用前面的代理或负载均衡器等不及放弃了。可能是 nginx，可能是云负载均衡器，也可能是等待休眠机器启动的平台代理。Cloudflare 自己的超时用的是别的状态码：<strong>522</strong> 表示连不上源站，<strong>524</strong> 表示连上了，但在 125 秒内（Enterprise 以下套餐的默认值）没有收到响应。</p>
<p>所以，如果你是因为 504 想调大 Cloudflare 的超时时间而搜到这里：504 背后的那个超时几乎从来都不是 Cloudflare 的。去检查 nginx 的 <code>proxy_read_timeout</code>、负载均衡器的空闲超时，或者平台唤醒机器需要多久。有个很有用的线索：如果请求总在远低于 125 秒的整数时间点失败，比如 30 秒或 60 秒，那是你自己代理的上限，不是 Cloudflare 的。（只有 Enterprise 套餐能把 Cloudflare 的 125 秒上限提高到最多 6,000 秒，而 Cloudflare 自己也把这当作权宜之计。）</p>

<h2 class="relative group">这些 504 到底是什么
    <div id="这些-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="#%e8%bf%99%e4%ba%9b-504-%e5%88%b0%e5%ba%95%e6%98%af%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>在开启了 Early Hints 的 zone 上，每个指向 HTML 页面或无扩展名 URL 的请求（在以 API 为主的 zone 上，大多数 API 调用都算在内）都会附带一次内部查询：Cloudflare 查看自己的缓存里有没有可以发送的提示。这次查询会单独记成一行，<code>ClientRequestSource = earlyHintsCache</code>。它不是终端用户的请求，也永远不会被发往你的源站。</p>
<p>那一行的状态码是缓存查询的结果，而不是错误：</p>
<ul>
<li>2xx 是命中。Cloudflare 存有这个 URL 的 <code>Link</code> 头，并把它作为 <code>103</code> 发给了浏览器。在我的 zone 上，命中同时以 <code>200</code> 和 <code>204</code> 出现。</li>
<li>504 是未命中。没有存下任何东西，所以没有什么可以提前发送。</li>
</ul>
<p>如果源站从不发送 Early Hints 所依赖的那个响应头，每次查询都会未命中，每个请求都会写下一行 504。在我做任何改动之前的三个小时里，这个 zone 记录了 173 次 Early Hints 查询，173 次全部未命中。自从这个功能打开以来，它一次提示都没有发出过。</p>
<p>再更正一下我 9 月那篇文章：我当时把它们描述为&quot;从 Fly 源站得不到响应&quot;的探测请求。这并不准确。它们根本不会去源站。这个 504 记录的是缓存未命中，而不是超时。</p>
<p>Cloudflare 在它的 <a href="https://developers.cloudflare.com/cache/advanced-configuration/early-hints/"  target="_blank" rel="noreferrer">Early Hints 文档</a>里简短地写明了这一点：记录为 <code>ClientRequestSource: earlyHintsCache</code> 的 Early Hints 查询是&quot;expected and benign&quot;（预期之内、无害的），200 是缓存命中，504 是未命中；在 Log Explorer 里可以用 <code>ClientRequestSource != &quot;earlyHintsCache&quot;</code> 把它们过滤掉。Cache Analytics 本来就不把它们算进去，但吓到我的那张 HTTP 流量图会。</p>

<h2 class="relative group">假 504 让我白白升级了服务器
    <div id="假-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="#%e5%81%87-504-%e8%ae%a9%e6%88%91%e7%99%bd%e7%99%bd%e5%8d%87%e7%ba%a7%e4%ba%86%e6%9c%8d%e5%8a%a1%e5%99%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这就是区分它们的意义所在。11 月 3 日，也就是我动笔写这篇文章的前一天，Cloudflare 记录了 3,292 个 504，而访客请求大约只有 13,300 个：表面错误率 25%。这个 API 跑在 Fly.io 的 <code>shared-cpu-1x</code> 机器上，这类机器靠突发积分（burst credit）运行：保证给它的 CPU 份额很小，想跑得更快就要消耗积分。8 月的一次压力测试显示，在持续负载下它会被限流到每秒大约 20 到 30 个请求。&ldquo;高峰时四分之一的请求超时&quot;看起来正是这种限流，于是我把 API 换成了 <code>shared-cpu-2x</code>。</p>
<p>可这些 504 全都是 Early Hints 缓存未命中。按 <code>requestSource</code> 拆开，再回头看源站自己的日志：</p>
<ul>
<li>10 月 28 日到 11 月 4 日，Cloudflare 的所有 5xx 都是 <code>earlyHintsCache</code> 查询，只有大约每天一个 525 例外（我追查的那个 525 来自一个安全扫描器）。</li>
<li>10 月 24 日到 11 月 4 日，源站的访问日志里一个 5xx 都没有，超过 10 秒的请求只有一个。</li>
<li>Fly 的指标显示，最忙的时候也只有每秒约 7 个请求，机器用掉的 CPU 从未超过一个核心的五分之一，离限流上限还远得很。</li>
</ul>
<p>换大机器什么也没修好，因为本来就没有坏。API 已经换回了 <code>shared-cpu-1x</code>。</p>
<p>不要根据一张没有按 request source 拆分的 5xx 图表来做容量决策。也要拿源站自己的日志对照一下：如果 Cloudflare 说四分之一的请求失败了，而你的服务器一条错误都没记，那在动服务器之前，先弄清楚 Cloudflare 到底在数什么。</p>
<p><em>2026 年 11 月 5 日更新：这一节的第一版说那一周 API 在 <code>shared-cpu-1x</code> 上真的垮了。其实并没有。上面的数字是我回头核查时发现的。</em></p>

<h2 class="relative group">HTTP 103 是错误吗？
    <div id="http-103-是错误吗" 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="#http-103-%e6%98%af%e9%94%99%e8%af%af%e5%90%97" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>不是。1xx 范围的状态码属于信息性响应，是服务器在正式响应之前可以发送的临时响应。RFC 8297 定义的 <code>103 Early Hints</code> 携带 <code>Link</code> 头，让浏览器在服务器还在生成页面的时候，就开始获取关键的 CSS、JavaScript 和字体，或者提前与其他源建立连接。最终的 <code>200</code> 随后在同一个请求里到达。</p>
<p>各浏览器的支持程度不一，但很安全。基于 Chromium 的浏览器（Chrome 103 起，以及 Edge）和较新的 Firefox 同时支持 <code>preload</code> 和 <code>preconnect</code> 提示；Safari（17 及以上）只使用 <code>preconnect</code>。浏览器只在 HTTP/2 和 HTTP/3 上处理 <code>103</code>。不认识它的浏览器只会像往常一样等 <code>200</code>，所以 <code>103</code> 对大多数访客是抢先一步，对其余访客则毫无影响。</p>

<h2 class="relative group">我的为什么是空的：Link 响应头与 <code>&lt;link rel=preload&gt;</code>
    <div id="我的为什么是空的link-响应头与-link-relpreload" 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%e7%9a%84%e4%b8%ba%e4%bb%80%e4%b9%88%e6%98%af%e7%a9%ba%e7%9a%84link-%e5%93%8d%e5%ba%94%e5%a4%b4%e4%b8%8e-link-relpreload" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这是我花时间最多的地方，因为它看起来本该已经生效了。</p>
<p>Early Hints 只缓存一样东西：带有 <code>rel=preload</code> 或 <code>rel=preconnect</code> 关系的 <strong><code>Link</code> HTTP 响应头</strong>。Cloudflare 从你平常的响应中（HTML 或无扩展名 URL 上的 <code>200</code>、<code>301</code> 或 <code>302</code>）读取它、存起来，然后在完整响应准备好之前，把它作为 <code>103</code> 重放给下一个访客。</p>
<p>我的页面其实已经有预加载了，只不过是写在 HTML <code>&lt;head&gt;</code> 里的标签：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_expo/static/css/global-7d77bd.css&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;style&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_expo/static/js/web/entry-fd7fa4.js&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;script&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/fonts/manrope-latin.woff2&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;font&#34;</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;font/woff2&#34;</span> <span class="na">crossorigin</span><span class="p">&gt;</span></span></span></code></pre></div></div>
<p>这些标签仍然有用，浏览器会按它们行事。但浏览器只有在收到 HTML 并开始解析之后才能看到它们，而这恰恰是 Early Hints 想要抢在前面的那一刻。Expo 导出和我用的 Hugo 主题都会自动写这些标签，却都不设置这个响应头。于是功能开着，预加载也声明了，两者却从未碰面。</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="#%e8%a7%a3%e5%86%b3%e5%8a%9e%e6%b3%95%e7%94%a8%e4%bd%a0%e6%89%8b%e4%b8%8a%e5%b7%b2%e6%9c%89%e7%9a%84%e6%95%b0%e6%8d%ae%e5%8f%91%e5%87%ba%e8%bf%99%e4%b8%aa%e5%93%8d%e5%ba%94%e5%a4%b4" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>最干净的办法，不是手工维护一份关键资源清单，每次打包哈希一变它就过时。每个页面已经用那些标签声明了自己想预加载什么。所以在返回页面时，把它们照抄进 <code>Link</code> 响应头就行。标准库的 <code>html.parser</code> 对带引号和不带引号的属性都能处理（Hugo 的压缩器会去掉引号），不用自己写正则：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">html.parser</span> <span class="kn">import</span> <span class="n">HTMLParser</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">PreloadLinks</span><span class="p">(</span><span class="n">HTMLParser</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="fm">__init__</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">hints</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">handle_starttag</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">tag</span><span class="p">,</span> <span class="n">attrs</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">a</span> <span class="o">=</span> <span class="nb">dict</span><span class="p">(</span><span class="n">attrs</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">tag</span> <span class="o">!=</span> <span class="s2">&#34;link&#34;</span> <span class="ow">or</span> <span class="s2">&#34;preload&#34;</span> <span class="ow">not</span> <span class="ow">in</span> <span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;rel&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">split</span><span class="p">()</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;href&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="cl">        <span class="n">hint</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;&lt;</span><span class="si">{</span><span class="n">a</span><span class="p">[</span><span class="s1">&#39;href&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&gt;; rel=preload&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;as&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="n">hint</span> <span class="o">+=</span> <span class="sa">f</span><span class="s2">&#34;; as=</span><span class="si">{</span><span class="n">a</span><span class="p">[</span><span class="s1">&#39;as&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;as&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="s2">&#34;font&#34;</span> <span class="ow">or</span> <span class="s2">&#34;crossorigin&#34;</span> <span class="ow">in</span> <span class="n">a</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">hint</span> <span class="o">+=</span> <span class="s2">&#34;; crossorigin&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">hints</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">hint</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">link_header</span><span class="p">(</span><span class="n">html</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">12</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span> <span class="o">=</span> <span class="n">PreloadLinks</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">feed</span><span class="p">(</span><span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;, &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="nb">list</span><span class="p">(</span><span class="nb">dict</span><span class="o">.</span><span class="n">fromkeys</span><span class="p">(</span><span class="n">parser</span><span class="o">.</span><span class="n">hints</span><span class="p">))[:</span><span class="n">limit</span><span class="p">])</span> <span class="ow">or</span> <span class="kc">None</span></span></span></code></pre></div></div>
<p>然后在 Django 的 HTML 响应上加上它：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="n">HttpResponse</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">content_type</span><span class="o">=</span><span class="s2">&#34;text/html&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">link</span> <span class="o">:=</span> <span class="n">link_header</span><span class="p">(</span><span class="n">html</span><span class="p">))</span> <span class="ow">and</span> <span class="s2">&#34;Link&#34;</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">response</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="p">[</span><span class="s2">&#34;Link&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">link</span></span></span></code></pre></div></div>
<p>有一个细节坑过我，值得记住：字体要保留 <code>crossorigin</code>。没有它的字体预加载会被当成另一个请求，字体会被下载两次，页面不但没变快，反而变慢了。</p>
<p>你不需要自己写 <code>103</code>，也不用在 ASGI、Granian 或 uvicorn 里添加任何临时响应的代码。Cloudflare 会根据你的响应头生成 <code>103</code>，你唯一要做的就是把响应头发出去。</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%9c%a8%e5%85%b6%e4%bb%96%e6%8a%80%e6%9c%af%e6%a0%88%e4%b8%8a" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>规则在哪里都一样：给 HTML 响应加上 <code>Link</code> 头，剩下的交给 Cloudflare。</p>
<ul>
<li>
<p>nginx，在返回 HTML 的 location 里：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">location</span> <span class="p">=</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kn">add_header</span> <span class="s">Link</span> <span class="s">&#34;&lt;/css/app.css&gt;</span><span class="p">;</span> <span class="kn">rel=preload</span><span class="p">;</span> <span class="kn">as=style&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">add_header</span> <span class="s">Link</span> <span class="s">&#34;&lt;/fonts/inter.woff2&gt;</span><span class="p">;</span> <span class="kn">rel=preload</span><span class="p">;</span> <span class="kn">as=font</span><span class="p">;</span> <span class="kn">crossorigin&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
</li>
<li>
<p>Laravel 配合 Vite 时已经有专门的中间件 <code>AddLinkHeadersForPreloadedAssets</code>，它会把 Vite 的预加载转换成 <code>Link</code> 头。针对它报告过的两个问题，正好就是上面那两个坑：它的 <code>modulepreload</code> 提示曾经不带 <code>crossorigin</code> 发出，导致浏览器可能丢弃它们并把脚本下载两次；在大页面上，这个响应头还曾超过 Apache 约 8 KB 的头部上限。请在你使用的版本上把这两点都确认一下。</p>
</li>
<li>
<p>其他任何环境：用一个 <code>Link</code> 头以逗号分隔，或者发多个 <code>Link</code> 头，两种方式都行。</p>
</li>
</ul>
<p>大家最容易过度的，是提示哪些资源。只提示渲染关键的 CSS、入口脚本和首屏用到的一两个字体，不要把每个 chunk 都塞进去。清单太长，浏览器就会先去取暂时用不到的东西，反而耽误了真正需要的。我把上限设为 12 个，而且这还是因为 Expo 导出已经替我筛选过预加载清单。</p>
<p>如果你不在 Cloudflare 后面，或者你的 CDN 不支持 Early Hints，<code>Link</code> 头也能帮上一点忙：浏览器可以在响应头一到达、开始解析正文之前就开始获取这些资源。但除非你的服务器或 CDN 主动发送，否则不会有 <code>103</code>。</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%e4%bd%95%e7%a1%ae%e8%ae%a4%e5%ae%83%e5%9c%a8%e7%94%9f%e6%95%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>用 <code>curl</code> 可以直接看到 <code>103</code>。用 HTTP/2 请求，只保留状态行和 <code>Link</code> 头：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ curl -sv --http2 -o /dev/null https://example.com/register 2&gt;&amp;1 | grep -E &#39;^&lt; (HTTP|link)&#39;
</span></span><span class="line"><span class="cl">&lt; HTTP/2 103
</span></span><span class="line"><span class="cl">&lt; link: &lt;/_expo/static/css/reset-f5d396.css&gt;; as=style; rel=preload, …
</span></span><span class="line"><span class="cl">&lt; HTTP/2 200
</span></span><span class="line"><span class="cl">&lt; link: &lt;/_expo/static/css/reset-f5d396.css&gt;; rel=preload; as=style, …</span></span></code></pre></div></div>
<p>先到的是 <code>103</code>，然后才是 <code>200</code>。Cloudflare 会调整提示里属性的顺序，这没有影响。不要指望每个请求都能拿到 <code>103</code>，我最初的三次里有两次拿到了。改动之后的第一次访问只是把响应头存下来，而当最终响应的头部已经就绪时，Cloudflare 会跳过 <code>103</code>。</p>
<p>然后回到上面的分析查询，用 <code>requestSource: &quot;earlyHintsCache&quot;</code> 过滤，按状态码和路径分组。在你修好的页面上，命中应该会逐渐取代未命中。</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%94%b9%e5%8f%98%e4%ba%86%e4%bb%80%e4%b9%88%e8%bf%98%e5%89%a9%e4%b8%8b%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>响应头上线几分钟后，这个 zone 第一次记录到了 Early Hints 命中，<code>curl</code> 也在网页上看到了真正的 <code>103</code> 响应。一个响应头同时解决了两个问题：功能开始生效，一部分&quot;504&quot;也随之消失。</p>
<p>剩下的问题是，JSON API 没有什么可预加载的。API 端点会继续未命中，继续写下 <code>504</code> 行；在以 API 为主的 zone 上，这些仍然占大多数。你有三个合理的选择。在有收益的页面上发送响应头，接受 API 路径继续记录未命中；功能生效，图表会安静下来，但不会完全安静。无论你做什么决定，都把 <code>earlyHintsCache</code> 从 5xx 告警和仪表盘中排除，因为内部的缓存查询永远不应该把人叫醒。或者，如果你今后都不会发送 <code>Link</code> 头，就关闭 Early Hints（在控制台的 Speed → Settings → Content Optimization，或者通过 API 修改 zone 的 <code>early_hints</code> 设置）；HTML 很小的单页应用可能本来就收益不大，关掉之后所有查询都会停止，噪音也会一起消失。</p>
<p>我选择保留它。现在网页能抢先一步，API 的未命中也已经从所有会触发告警的地方过滤掉了。这正是 9 月时我本该做的事，而不是把功能关掉。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/cloudflare-504-early-hints/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>GitHub Actions CI 里的 Docker Compose：我换成了什么，以及现在在跑的流水线</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-22-docker-compose-ci-github-actions/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-22-docker-compose-ci-github-actions/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>Docker</category>
      <category>DevOps</category>
      <category>Deployment</category>
      <category>Fly.io</category>
      <category>Cloudflare</category>
      <category>Django</category>
      <category>PostgreSQL</category>
      <category>Hugo</category>
      <category>Python</category>
      <description>我 2026 年在 GitHub Actions 上跑的 CI/CD：CI 里用服务容器取代 Docker Compose，缓存按每步耗时来选，部署全是脚本。</description>
      <content:encoded><![CDATA[<p>CI/CD 流水线，就是一次改动从 <code>git push</code> 走到生产环境的自动化路径：CI 在干净的机器上构建并测试每一次推送，CD 用一个可重复的步骤把通过的东西发布出去。在 GitHub Actions 上它是 <code>.github/workflows/</code> 下的一个 YAML 文件，我的流水线故意做得很薄：检查就是我在笔记本上敲的那些命令，每一种部署都是脚本或 CLI，离开 GitHub 也照样能跑。</p>
<p>如果你是来找「怎么在 GitHub Actions 里跑 Docker Compose」的：我这么干了两年，然后停了。现在 CI 把同样的 Postgres 和 Valkey 镜像声明为 <code>services:</code>，直接在 runner 上跑同样的 <code>uv run …</code> 和 <code>npm run …</code>；Compose 回归成开发者本地跑的东西。下面是原因，连同文件。</p>
<p>2024 年我写过一篇<a href="https://jared.lynskey.co.nz/zh-cn/posts/2024/2024-06-02-simplifyingcicd/" >用 Docker Compose 做 CI/CD，别把逻辑写进平台专属的 YAML</a>，意思是构建逻辑放进 Compose，CI 工具只负责调用它。两年后，我把自己真正用来发布的仓库全部翻了一遍。有工作流的仓库一共四个，提交者都只有我一个人，而这些工作流里，已经没有一个在调用 <code>docker compose</code> 了。原则留下来了，做法没有。下面是取而代之的东西：删减过的示例文件、GitHub 记录下的每一步耗时，以及那些没有 CI/CD 示意图期望的那么自动化的部分。</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%b7%91%e7%9a%84%e6%89%80%e6%9c%89%e6%b5%81%e6%b0%b4%e7%ba%bf%e4%b8%80%e5%bc%a0%e8%a1%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>流水线</th>
					<th>由谁触发</th>
					<th>CI 检查什么</th>
					<th>发布到哪里</th>
					<th>谁来发布</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>这个博客和一个客户的 Hugo 网站</td>
					<td>每次推送，任何分支</td>
					<td><code>hugo --gc</code> 能否构建</td>
					<td>Cloudflare Pages：<code>main</code> 是生产，其他分支得到预览 URL</td>
					<td>没人；推送就是发布</td>
			</tr>
			<tr>
					<td>我那个集市应用的 API</td>
					<td>一个部署脚本，手动运行</td>
					<td>每次推送到 <code>main</code> 时跑代码检查、类型检查和 Django 测试</td>
					<td>Fly.io，滚动部署</td>
					<td>我</td>
			</tr>
			<tr>
					<td>同一个应用的前端：Web、OTA 更新、商店构建</td>
					<td>几个发布脚本和本地商店构建，手动</td>
					<td>每次推送到 <code>main</code> 时跑 TypeScript 和 Jest</td>
					<td>对象存储、一个 OTA 更新服务，以及两家应用商店</td>
					<td>我</td>
			</tr>
			<tr>
					<td>pyhtml2text，我的开源 Python 包</td>
					<td>每次推送、Pull Request 和 tag</td>
					<td>15 个构建目标的 wheel，其中大部分会跑测试</td>
					<td>PyPI，打 tag 时</td>
					<td>tag</td>
			</tr>
	</tbody>
</table>
<p>用教科书的话说，两个 Hugo 网站是持续部署；而那个应用（我做的一个集市兼社区应用）是持续集成加上一条命令就能完成的发布，也就是持续交付：检查会告诉我哪里不对，但不会拦着我部署。这是有意为之。一次 OTA 更新会送到所有装着目标二进制版本的手机上，所以发布它应该是一个决定，而不是合并的副作用；而 API 是数据库迁移运行的地方。我另外两个 Django 应用，压根没有工作流。</p>

<h2 class="relative group">CI 这一半：一个工作流，两个 job
    <div id="ci-这一半一个工作流两个-job" 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-%e8%bf%99%e4%b8%80%e5%8d%8a%e4%b8%80%e4%b8%aa%e5%b7%a5%e4%bd%9c%e6%b5%81%e4%b8%a4%e4%b8%aa-job" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这个应用的 CI 只有一个文件，<code>.github/workflows/checks.yml</code>。10 月 11 日之前，连它都没有。下面是删减后的示例，看个大致样子：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># Lint, types and tests on every push and pull request to main.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Nothing here deploys.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Checks</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="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</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="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">checks-${{ github.ref }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">python</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">timeout-minutes</span><span class="p">:</span><span class="w"> </span><span class="m">30</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:17</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_USER</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;5432:5432&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">options</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          --health-cmd &#34;pg_isready -U myapp&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          --health-interval 5s --health-timeout 5s --health-retries 20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">valkey/valkey:8-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;6379:6379&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DEBUG</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;True&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DATABASE_URL</span><span class="p">:</span><span class="w"> </span><span class="l">postgresql://myapp:myapp@localhost:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">REDIS_URL</span><span class="p">:</span><span class="w"> </span><span class="l">redis://localhost:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PYTHON_JIT</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0&#34;</span><span class="w">   </span><span class="c"># match production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">enable-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">prune-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv sync --locked</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run ruff check .</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run mypy myapp/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run python manage.py test --parallel 4 --noinput</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="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">timeout-minutes</span><span class="p">:</span><span class="w"> </span><span class="m">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">defaults</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">run</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">working-directory</span><span class="p">:</span><span class="w"> </span><span class="l">frontend</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-node@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">node-version</span><span class="p">:</span><span class="w"> </span><span class="m">24</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">cache</span><span class="p">:</span><span class="w"> </span><span class="l">npm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">cache-dependency-path</span><span class="p">:</span><span class="w"> </span><span class="l">frontend/package-lock.json</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npm ci</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Expo writes this on `expo start` and it is gitignored,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># so a fresh checkout doesn&#39;t have it.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">printf &#39;/// &lt;reference types=&#34;expo/types&#34; /&gt;\n&#39; &gt; expo-env.d.ts</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npm run typecheck</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npx jest --ci</span></span></span></code></pre></div></div>
<p>两个 job 并行运行。到目前为止成功的 35 次运行里，应用 job 的中位数大约七分钟，Python job 大约十二分半。</p>
<p><code>services:</code> 这一块，就是 Compose 文件搬去的地方，下一节细说。环境变量只有寥寥几个，外加一个开关；其余的一切，都会回落到开发者 <code>docker compose up</code> 时拿到的开发默认值，所以测试不需要任何密钥。为了确认真是这样，我只用这套环境、别的什么都不带，跑了一部分测试。</p>
<p>CI 用的解释器和我的笔记本、生产镜像是同一个，也就是 uv 自己构建的 Python 3.15.0，因为 <code>uv sync</code> 会读 <code>.python-version</code>，并下载正是那个版本。（我真实的文件里还给 setup-uv 传了一个 <code>python-version-file</code> 输入，其实什么都没做：setup-uv v6 没有这个输入，每次运行都带着一条这样说的警告。写这篇文章时我才发现。）</p>
<p>解释器一致的好处，在工作流上线八天后就显出来了。10 月 19 日我打开了 3.15 的 JIT 编译器，开启后的第一次运行就有一个测试失败：一句再普通不过的 <code>yield from</code>，在每次保存都会经过的代码路径上，抛出了 <code>AttributeError: 'list' object has no attribute 'send'</code>。同一个提交关掉 JIT 就能通过。我的 Mac 是 arm64，从没碰到过它；而 runner 是 x86_64，和生产机器一样。JIT 现在在 CI 和 Dockerfile 里都重新关掉了，这个 bug 从没到过生产环境。这就是 python/cpython#155823，上游在 10 月 13 日、也就是 3.15.0 发布之后修掉了它。</p>
<p>其实第一次运行也失败了，原因更无聊。Expo 在 <code>expo start</code> 时会生成 <code>expo-env.d.ts</code>，而这个文件被 gitignore 了，所以我用过的每个工作副本里都有它，全新的检出里却没有。TypeScript 报了五个本地从没出现过的错误。补上生成这个文件的那一步，就修好了。</p>
<p><code>concurrency</code> 会在同一分支来了更新的推送时，取消之前的运行；到目前为止被取消的十二次运行，全都是这么结束的。另外还有一个提交前钩子（pre-commit），会跑代码检查，暂存了 Python 或 TypeScript 文件时再跑类型检查，所以大部分错误根本走不到推送那一步。</p>

<h2 class="relative group">Docker Compose 文件去哪儿了
    <div id="docker-compose-文件去哪儿了" 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="#docker-compose-%e6%96%87%e4%bb%b6%e5%8e%bb%e5%93%aa%e5%84%bf%e4%ba%86" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>下面是这个应用的 <code>docker-compose.yml</code> 里 CI 关心的那部分，同样是示例。真实文件里服务更多，环境变量也多得多。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">valkey/valkey:8-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;6379:6379&#34;</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="w">  </span><span class="c"># Host port 55432, not 5432, so it can&#39;t collide with a local Postgres.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:17-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_USER=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_PASSWORD=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_DB=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;55432:5432&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">postgres_data:/var/lib/postgresql/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD-SHELL&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;pg_isready -U myapp -d myapp&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">10</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="w">  </span><span class="nt">api</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">build</span><span class="p">:</span><span class="w"> </span><span class="l">.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py runserver 0.0.0.0:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">.:/app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8000:8000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">DATABASE_URL=postgresql://myapp:myapp@postgres:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">REDIS_URL=redis://valkey:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_started</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="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">postgres_data:</span></span></span></code></pre></div></div>
<p>把它和 <code>checks.yml</code> 里的 <code>services:</code> 放在一起看，是同样的两个镜像：Postgres 17（这里是 Alpine 版，CI 里是 Debian 版）和 <code>valkey/valkey:8-alpine</code>。现在两者的关系就是这样：Compose 文件是开发者跑的东西；CI 把同样的镜像声明为服务容器，然后直接在 runner 上跑开发者跑的那些命令，<code>uv run …</code> 和 <code>npm run …</code>。</p>
<p>和 2024 年那篇对照，我最在意的部分留了下来：CI 工具依然很薄，检查和部署都是我自己就能跑的命令。没留下来的，是让 CI 去调用 Compose。</p>
<p>这个应用的 CI 根本不构建 Docker 镜像；镜像在部署时由 Fly 的构建机构建一次，CI 只需要回答代码好不好，工具链有缓存的话，这只要六秒的安装。另外，Compose 文件会把工作副本 bind mount 进去、灌入种子数据，这些都不是一次测试运行该做的事。所以 CI 本来还得有一份自己的 Compose 文件，也就是同一组服务的第二份描述，靠手工保持同步。</p>
<p>还有，2024 年那个例子里有个我当时没发现的 bug。<code>docker compose build</code> 只构建镜像，从不运行服务的 <code>command</code>，所以那个文件里的 <code>npm test</code> 在 CI 里一次都没跑过，工作流不管测试过没过都是绿的。</p>
<p>那篇文章最担心的风险，也就是换 CI 供应商，始终没有发生。真正发生的迁移在另一头：这个博客和那个客户的网站换了托管，搬到了 Cloudflare Pages，博客这边只是新写了一个 45 行的工作流，因为构建本来就只是一条命令。至于我当年警告过的三百行工作流？我确实有一个，pyhtml2text 的。它之所以长，是因为 wheel 必须分别在 Linux、macOS 和 Windows 的 runner 上、为多种架构构建，也正因为如此，这是唯一一个平台专属 YAML 才是对的工具的活儿。</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="#%e7%bc%93%e5%ad%98%e5%85%88%e7%9c%8b%e6%af%8f%e4%b8%80%e6%ad%a5%e7%9a%84%e8%80%97%e6%97%b6" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>GitHub 记录了每一步的耗时，用 <code>gh api repos/&lt;owner&gt;/&lt;repo&gt;/actions/runs/&lt;run-id&gt;/jobs</code> 就能拿到。下面是这个应用最近一次成功的运行，10 月 20 日：</p>
<table>
	<thead>
			<tr>
					<th>Job</th>
					<th>步骤</th>
					<th>秒</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Python</td>
					<td>启动 Postgres 和 Valkey</td>
					<td>18</td>
			</tr>
			<tr>
					<td>Python</td>
					<td><code>uv sync --locked</code>，缓存已热</td>
					<td>6</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>mypy</td>
					<td>27</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>Django 测试</td>
					<td>761</td>
			</tr>
			<tr>
					<td>App</td>
					<td><code>npm ci</code>，缓存已热</td>
					<td>60</td>
			</tr>
			<tr>
					<td>App</td>
					<td>Jest</td>
					<td>333</td>
			</tr>
	</tbody>
</table>
<p>两个依赖缓存都开着，而且都是便宜的那部分。GitHub 为这个仓库保存的 uv 缓存只有 200 KB 左右，因为 setup-uv 会清掉 uv 重新下载就行的东西；npm 的接近 400 MB。时间花在测试上，而测试已经四路并行了。想让运行更快的话，我会去改测试套件，而不是缓存。</p>
<p>博客的情况正好相反。Hugo 处理过的图片放在被 gitignore 的 <code>resources/_gen</code> 里，所以每台新的 runner 都要把封面、正文图片和占位图全部重新编码一遍。下文的工作流用 <code>actions/cache</code> 把它们恢复回来，键是图片本身的哈希，对不上时退回到最新的旧缓存，因为 Hugo 只处理缺的部分。加缓存之前的最后八次运行，构建这一步要 33 到 76 秒；之后的 26 次是 6 到 20 秒，只有一次例外，94 秒。而且同一个提交还加了占位图和第二种卡片尺寸。</p>
<p>这个应用的镜像在 Fly 的构建机上构建，依赖文件先于代码复制进去，uv 的下载缓存放在 BuildKit 的缓存挂载里（<code>RUN --mount=type=cache,target=/root/.cache/uv</code>）。不过最大的改进并不是缓存。旧的 <code>.dockerignore</code> 是黑名单，漏掉了一个放额外 git worktree 的文件夹，每个 worktree 都带着自己的依赖和构建产物，结果每次部署都有 27.7 万个文件、7.7 GB 被传到构建机。改写成白名单之后，构建上下文大约 40 MB。这种写法的示例：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># Exclude everything, then let back in only what the Dockerfile COPYs.
</span></span><span class="line"><span class="cl">*
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">!pyproject.toml
</span></span><span class="line"><span class="cl">!uv.lock
</span></span><span class="line"><span class="cl">!manage.py
</span></span><span class="line"><span class="cl">!myapp
</span></span><span class="line"><span class="cl">!static
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># Rules match the whole relative path and the last match wins,
</span></span><span class="line"><span class="cl"># so these re-excludes apply inside the allowed directories too.
</span></span><span class="line"><span class="cl">**/__pycache__/
</span></span><span class="line"><span class="cl">**/*.pyc
</span></span><span class="line"><span class="cl">**/.DS_Store
</span></span><span class="line"><span class="cl">**/*.log</span></span></code></pre></div></div>

<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%af%86%e9%92%a5%e6%94%be%e5%9c%a8%e5%b0%bd%e9%87%8f%e5%b0%91%e7%9a%84%e5%9c%b0%e6%96%b9" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>密钥放在三个地方。GitHub Actions 的密钥用于工作流要做的事，两个 Hugo 仓库各自只有一个，<code>CLOUDFLARE_API_TOKEN</code>。Fly 的密钥是应用运行时的配置，用 <code>flyctl secrets set</code> 设置，从不经过 Actions。最后，我笔记本上被 gitignore 的 <code>.env</code> 里，放着部署脚本清 Cloudflare 缓存用的令牌。</p>
<p>PyPI 完全不需要密钥。pyhtml2text 用的是可信发布（trusted publishing）：PyPI 接受 GitHub 签发给工作流的短期 OIDC 令牌，所以发布 job 只申请 <code>id-token: write</code>，仓库的密钥页面是空的。没有可泄露的，也没有要轮换的。</p>
<p>令牌只给它需要的权限。这个应用在 GitHub 上的 Fly 令牌，服务于一个只列出机器的定时检查，所以是用 <code>fly tokens create readonly</code> 创建的。Pages 部署只申请 <code>contents: read</code> 和 <code>deployments: write</code>，别无其他。</p>
<p>输入一律经过 <code>env:</code>，绝不直接塞进脚本。OTA 工作流接收自由填写的发布说明。如果在 <code>run:</code> 里写成 <code>${{ inputs.notes }}</code>，文本会在 bash 读取之前原样贴进脚本，于是一条带引号和分号的说明就成了代码。作为环境变量传进去，它就始终只是数据。这种写法的示例：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Publish</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">OTA_API_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.OTA_API_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_PLATFORM</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.platform }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_ROLLOUT</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.rollout }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_NOTES</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.notes }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          args=(&#34;$INPUT_PLATFORM&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          [ -n &#34;$INPUT_ROLLOUT&#34; ] &amp;&amp; args+=(--rollout &#34;$INPUT_ROLLOUT&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          [ -n &#34;$INPUT_NOTES&#34; ] &amp;&amp; args+=(--notes &#34;$INPUT_NOTES&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          ./scripts/publish-ota.sh &#34;${args[@]}&#34;</span></span></span></code></pre></div></div>
<p>有些密钥干脆不放在 GitHub 上。有一份定时报告需要一个对应用登录后端有管理员权限的密钥，所以它从定时工作流搬到了我 Mac 上的一个定时任务。工作流仍然留着作为手动备用，只是会略过需要那个密钥的部分。</p>

<h2 class="relative group">部署：每次推送、靠脚本、按 tag
    <div id="部署每次推送靠脚本按-tag" 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%83%a8%e7%bd%b2%e6%af%8f%e6%ac%a1%e6%8e%a8%e9%80%81%e9%9d%a0%e8%84%9a%e6%9c%ac%e6%8c%89-tag" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">Hugo 网站每次推送都部署
    <div id="hugo-网站每次推送都部署" 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="#hugo-%e7%bd%91%e7%ab%99%e6%af%8f%e6%ac%a1%e6%8e%a8%e9%80%81%e9%83%bd%e9%83%a8%e7%bd%b2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>这是博客工作流的示例版本，省掉了第一次运行时创建 Pages 项目的那一步：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy to Cloudflare Pages</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="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</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="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">HUGO_VERSION</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0.165.0&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">PAGES_PROJECT</span><span class="p">:</span><span class="w"> </span><span class="l">my-site</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="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">deploy-${{ github.ref }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">deployments</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">submodules</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">peaceiris/actions-hugo@v3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">hugo-version</span><span class="p">:</span><span class="w"> </span><span class="l">${{ env.HUGO_VERSION }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">extended</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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="w">      </span><span class="c"># Processed images are gitignored; restore them so Hugo only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># re-encodes what is new. An older cache still helps.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/cache@v6</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">resources/_gen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">key</span><span class="p">:</span><span class="w"> </span><span class="l">hugo-resources-${{ hashFiles(&#39;content/**/*.jpg&#39;, &#39;content/**/*.png&#39;, &#39;assets/img/**&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">restore-keys</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">            hugo-resources-</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="w">      </span><span class="c"># A multilingual site has no root 404.html; without one, Pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># answers unknown paths with index.html and a 200.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          hugo --gc
</span></span></span><span class="line"><span class="cl"><span class="sd">          cp public/en/404.html public/404.html</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="w">      </span><span class="c"># ...</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="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/wrangler-action@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">apiToken</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.CLOUDFLARE_API_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">accountId</span><span class="p">:</span><span class="w"> </span><span class="l">${{ vars.CLOUDFLARE_ACCOUNT_ID }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">pages deploy public --project-name=${{ env.PAGES_PROJECT }} --branch=${{ github.ref_name }}</span></span></span></code></pre></div></div>
<p><code>--branch=${{ github.ref_name }}</code> 让 <code>main</code> 成为生产，其他每个分支都得到预览 URL。那个客户网站的工作流和它是同一个结构，只是在构建前多了一行 <code>rm -rf public</code>，因为那边的 <code>public/</code> 是提交进仓库的，不这样做的话，已删除的页面会一直留在上传内容里。</p>
<p>Hugo 的版本只在这里固定，别处都不固定。10 月份一次包管理器升级把我笔记本上的 Hugo 升到了 0.168，比主题支持的最新版本还高一个。部署一直停在 0.165，毫无察觉。</p>
<p>而构建本身就是测试。9 月 14 日，某个分支的预览部署连续失败了三次：那个分支上的一篇文章，用 Hugo 的 <code>ref</code> 链接到了一篇还是草稿的文章。CI 和生产一样，构建时不包含草稿，所以这个链接什么都指不到，Hugo 以 <code>REF_NOT_FOUND</code> 停了下来。在此期间，<code>main</code> 照常部署。</p>

<h3 class="relative group">应用的 API 靠脚本部署
    <div id="应用的-api-靠脚本部署" 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%ba%94%e7%94%a8%e7%9a%84-api-%e9%9d%a0%e8%84%9a%e6%9c%ac%e9%83%a8%e7%bd%b2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>部署脚本的核心部分，示例如下：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">IMAGE_LABEL</span><span class="o">=</span><span class="s2">&#34;deploy-</span><span class="k">$(</span>git rev-parse --short HEAD<span class="k">)</span><span class="s2">-</span><span class="k">$(</span>date +%Y%m%d%H%M%S<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Build first, then wake a spare machine, then roll out exactly that image.</span>
</span></span><span class="line"><span class="cl">flyctl deploy --config fly.toml --build-only --push --image-label <span class="s2">&#34;</span><span class="nv">$IMAGE_LABEL</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">curl -fsS --retry <span class="m">5</span> https://myapp.fly.dev/health &gt; /dev/null
</span></span><span class="line"><span class="cl">flyctl deploy --config fly.toml --image <span class="s2">&#34;registry.fly.io/myapp:</span><span class="nv">$IMAGE_LABEL</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
<p>以及 <code>fly.toml</code> 里对应的几行：</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">deploy</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="c"># max_unavailable only counts machines that are running,</span>
</span></span><span class="line"><span class="cl">  <span class="c"># which is why the script wakes one first.</span>
</span></span><span class="line"><span class="cl">  <span class="nx">strategy</span> <span class="p">=</span> <span class="s2">&#34;rolling&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">max_unavailable</span> <span class="p">=</span> <span class="mi">1</span></span></span></code></pre></div></div>
<p>镜像只构建一次，以提交和时间组成的标签推到 Fly 的镜像仓库，再按这个标签部署，所以滚动上线的正是构建出来的那个东西。</p>
<p>让它做到零停机的，是先唤醒一台备用机器。<code>max_unavailable = 1</code> 只计算正在运行的机器，而这个应用空闲时一台运行中的机器都不留（挂起的机器大约一秒就能恢复）。所以以前那台唯一醒着的机器，会和睡着的机器在同一批里被替换掉，身后没有一台热着的机器。9 月 28 日，我用探针持续请求源站：直接 <code>flyctl deploy</code> 时出现了 7 次超时和一段 7.3 秒的空白；而用脚本部署时，先构建、在滚动开始前就有一台机器醒着，2,514 个请求一个都没失败。</p>
<p>脚本的其余部分，会在错误告警所在的团队聊天频道里留下部署标记，这样一阵错误涌来时，可以对照刚刚改了什么；然后清掉 Cloudflare 缓存，再把机器预热一遍。Web 前端完全不经过镜像，这部分我写在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-14-web-releases-without-redeploying/" >前后端分开部署：不重新部署 API 也能发布 Web 前端</a>里。</p>
<p>迁移不是流水线的一个步骤。Fly 的 <code>release_command</code> 在我这里会卡住，所以由第一台用新镜像启动的机器拿锁并执行迁移，其他机器等着。因此每次迁移都必须兼容仍在运行的代码：一次部署里加列，下一次部署不再使用它，第三次再删掉。</p>
<p>这个脚本里没有哪一处非得在我的笔记本上不可，但有几个只在本地的文件把它留在了那里：Cloudflare 凭据，以及一份最后部署了什么的记录，全新的 runner 上不会有它们。它还会把未提交的改动也一起部署，并在聊天频道里说明这一点，而 Web 的发布脚本会拒绝不干净的工作区。</p>
<p>在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >每个分支一个预览环境</a>里我写过，这些事手工来做，只有你一个人时没问题；从第二个开发者开始，它就必须因为 git 里发生了什么而发生。这个应用现在还是我一个开发者。那篇文章里的分支环境工作流，还留在我当初搭建它们的那个分支上。</p>
<p>OTA 工作流只能手动运行，因为发布应该是一个决定，也因为在有部分发布（partial rollout）进行中时，更新服务会拒绝新的发布，换成推送触发的话，每次金丝雀发布都会撞上。这个工作流一次都没跑过。到目前为止，每一次 OTA 更新都是从我的笔记本上，通过工作流调用的同一个脚本发出去的。保持工作流轻薄的理由，一句话就是这个：不管发布从哪里开始，跑的都是同一个脚本。</p>

<h3 class="relative group">pyhtml2text 按 tag 发布
    <div id="pyhtml2text-按-tag-发布" 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="#pyhtml2text-%e6%8c%89-tag-%e5%8f%91%e5%b8%83" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>发布 job 如下，包名换成了通用的名字：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">release</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) || github.event_name == &#39;workflow_dispatch&#39; }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">linux, macos, windows, sdist]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">pypi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">https://pypi.org/p/mypackage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write      </span><span class="w"> </span><span class="c"># trusted publishing and signing</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">attestations</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/download-artifact@v8</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/attest@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">subject-path</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;wheels-*/*&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">prune-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Publish to PyPI</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv publish --trusted-publishing always &#39;wheels-*/*&#39;</span></span></span></code></pre></div></div>
<p>发布 job 会等所有构建 job 完成，只有 tag 才会真正发布；手动运行会构建并生成证明（attestation），但什么都不上传。<code>actions/attest</code> 记录每个 wheel 是由哪个工作流构建的，<code>uv publish --trusted-publishing always</code> 用 OIDC 令牌而不是密码来上传。这个包本身，我写在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-18-pyhtml2text/" >一个被关闭的 Pull Request、一个 fork 和一个 PyPI 包</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%ae%9a%e6%97%b6%e5%b7%a5%e4%bd%9c%e6%b5%81%e5%8f%aa%e6%98%af%e5%b0%bd%e5%8a%9b%e8%80%8c%e4%b8%ba" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这个应用有一个定时工作流，检查 Fly 启动的机器有没有超过基线，不检查的话，第一次发现会是在账单上。它靠故意失败来报警，因为定时运行失败时 GitHub 会给仓库所有者发邮件。</p>
<p>它设的是 <code>cron: '*/15 * * * *'</code>，一天 96 次。可过去两周里，它每天只跑了三到八次，两次运行之间间隔的中位数大约三小时四十分。GitHub 文档给出的唯一解释是负载：Actions 忙的时候，定时运行可能会延迟，忙到一定程度还可能被丢掉。对持续的扩容来说，几个小时足够了。对于必须在几分钟内报警的东西，GitHub 的 cron 不是监控。</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%e4%bc%9a%e9%97%ae%e4%b8%80%e6%9d%a1%e6%b5%81%e6%b0%b4%e7%ba%bf%e7%9a%84%e5%87%a0%e4%b8%aa%e9%97%ae%e9%a2%98" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>不管你是在搭新的，还是在审视现有的：</p>
<ol>
<li>如果 GitHub Actions 停摆一整天，你还能发布吗？不能的话，你的发布逻辑就在 YAML 里，2024 年那篇依然适用。</li>
<li>CI 跑的解释器、CPU 架构和数据库大版本，和生产一致吗？我的 CI 能抓到一个编译器 bug，只是因为 runner 和生产都是 x86_64，而我的笔记本不是。</li>
<li>全新的检出能通过吗？通不过的话，通常是那些自动生成、又被 gitignore 的文件在作怪。</li>
<li>一次运行的时间到底花在哪儿？加缓存之前，先看每一步的耗时。</li>
<li>每个密钥放在几个地方？它真的需要放在 GitHub 上吗？</li>
</ol>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-docker-compose-ci-github-actions/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>在 Fly.io 上监控 Django：我留下的 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>我怎样用每月约 2 美元监控 Fly.io 上的两个小 Django 应用：错误发到聊天频道、日志存在 S3 用 DuckDB 查询、Fly 自带指标、崩溃报告，以及我弃用了什么。</description>
      <content:encoded><![CDATA[<p>我有两个小产品：一个我做的集市兼社区应用，一个给上门报价的手艺人用的估价应用，都跑在 Fly.io 最便宜的机器上。我的 DevOps 监控归结起来就五样：把 ERROR 日志发到一个聊天频道；把每一行日志都归档到对象存储里，用 DuckDB 查询；Fly 自带的 Prometheus 指标；手机上的崩溃报告，用 Sentry 的开源 SDK 发到我自己的 API；以及每天早上一条汇总。</p>
<p>没有托管的 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>保留了，前提是让源站发出 <code>Link</code> 头。整个 zone 的 504 全是它的缓存未命中</td>
					<td>免费</td>
			</tr>
	</tbody>
</table>
<p>表里没写“两个应用”的，都只说交易平台。估价应用的东西更少：Fly 的健康检查、日志和指标，iOS 上的应用分析，再加一个每天把数据库导出成 Parquet、我用 DuckDB 查询的任务。它没有崩溃报告，也没有告警频道，这是这篇里最大的缺口。（关于更新服务那一行要说明一下：做这个服务的公司付钱请我给他们的博客写文章。）</p>

<h2 class="relative group">错误：把日志发到聊天频道的 logging handler
    <div id="错误把日志发到聊天频道的-logging-handler" 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%e6%8a%8a%e6%97%a5%e5%bf%97%e5%8f%91%e5%88%b0%e8%81%8a%e5%a4%a9%e9%a2%91%e9%81%93%e7%9a%84-logging-handler" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>九月之前，未处理的 500、发送失败的推送通知、坏掉的 WebSocket 帧，唯一会出现的地方是 <code>flyctl logs</code>，而那条流没人会主动去读。现在由一个日志 handler 把 ERROR 及以上的每条记录都发到聊天频道，不管来自 API、WebSocket 服务器还是后台 worker。WARNING 故意不告警。</p>
<p>handler 在后台线程里发送，所以绝不会拖慢请求。它内部不写任何日志，所以不会递归。同一个错误最多每十分钟发一次，每小时上限 30 条，所以出故障时你读到的是一条消息，而不是一千条。</p>
<p>这个频道里还有我想拿来和错误对照着看的东西：部署在开始、上线成功和失败的地方都会发消息，带上 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 的 log shipper 存到 S3，用 DuckDB 查询
    <div id="日志用-fly-的-log-shipper-存到-s3用-duckdb-查询" 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%97%e7%94%a8-fly-%e7%9a%84-log-shipper-%e5%ad%98%e5%88%b0-s3%e7%94%a8-duckdb-%e6%9f%a5%e8%af%a2" 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">指标：Fly 自带的 Prometheus
    <div id="指标fly-自带的-prometheus" 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%87%e6%a0%87fly-%e8%87%aa%e5%b8%a6%e7%9a%84-prometheus" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Fly 给每个组织跑一个托管的 Prometheus，所以我自己不维护任何指标系统。有问题时直接查它的 API：每台机器的内存、CPU 和突发额度（共享 CPU 上 <code>fly_instance_cpu_balance</code> 很要紧），每个应用的响应数和并发数。有两件事我希望早点知道。用 <code>fly tokens create</code> 创建的 token 要以 <code>Authorization: FlyV1 &lt;token&gt;</code> 传入；<code>Bearer</code> 只对 <code>flyctl auth token</code> 给出的个人 token 有效，而只读 token 会返回 &ldquo;something went wrong resolving organization&rdquo;。另外数据大约只保留两周（Fly 的说法是 15 天左右）。</p>
<p>它在九月真正派上了用场。那时日志里一天好几次出现 &ldquo;Could not open a database connection&rdquo;，看上去完全就是应用的 bug。托管 Postgres 集群也在同一个 Prometheus 里，每次出现这个错误的那一分钟，主库的抓取耗时都从 80 毫秒左右跳到一到十秒，副本一切正常，Postgres 的启动时间也没变。Postgres 没重启过，是它底下的宿主机卡住了。我把时间戳发给了 Fly 的支持团队；十月初一次维护重启把主库挪到另一台宿主机之后，每天的卡顿就没了。</p>

<h2 class="relative group">崩溃报告：指向我自己 API 的 Sentry SDK
    <div id="崩溃报告指向我自己-api-的-sentry-sdk" 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%b4%a9%e6%ba%83%e6%8a%a5%e5%91%8a%e6%8c%87%e5%90%91%e6%88%91%e8%87%aa%e5%b7%b1-api-%e7%9a%84-sentry-sdk" 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>接着是失败的健康检查、昨天的使用量和上周同一天的对比（周日永远不会像周一），最近一周的搜索流量、等着回复的聊天、任务看板，还有开销。哪一部分加载不出来就照实写，汇总照样发出去。</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 缓存查询（user agent 是 <code>bastion early hints</code> 和 <code>nginx-ssl early hints</code>），它们根本不会到达源站：这些行里的 504 记录的是一次缓存未命中，而不是超时。它们照搬真实请求的路径，所以都落在最忙的接口上，看起来和用户请求失败一模一样。应用、浏览器、curl 和搜索引擎爬虫一个 504 都没有。源站当时不发 <code>Link</code> 头，Early Hints 什么作用都没起，于是我先把它关了，后来才<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/cloudflare-504-early-hints/" >让它真正生效</a>。</p>
<p>冷启动的优化并没有白做，部署和唤醒都因此变快了，但我以为自己在修的那个故障，从来没有落到任何用户身上。</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>，用结果决定哪些机器接流量，这件事它做得很好。但健康检查跑在已经起来的机器上，看不到机器起来之前发生的事：冷启动、代理前面的排队、根本没到达的请求。现在我只指望它做路由。</p>
<p>部署时改用探测脚本，它在整个部署过程中持续打源站，只要有一次失败就以退出码 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>
    <item>
      <title>用 Cloudflare WARP 做内网穿透：把笔记本、手机和工作站连成一个私有网络</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-01-cloudflare-warp-dev-network/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-01-cloudflare-warp-dev-network/</guid>
      <pubDate>Thu, 01 Oct 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>WARP</category>
      <category>Zero Trust</category>
      <category>Cloudflare Tunnel</category>
      <category>Networking</category>
      <category>SSH</category>
      <category>DevOps</category>
      <category>AI Agents</category>
      <category>Ollama</category>
      <category>Mobile Development</category>
      <category>IPv6</category>
      <description>我用 Cloudflare WARP 加两条 Cloudflare Tunnel（Zero Trust），把笔记本、连着 5G 的手机和办公室里的 GPU 工作站放进同一个私有网络：固定的私有 IP，不开任何端口，dev server 挂在 Access 后面。</description>
      <content:encoded><![CDATA[<p>Cloudflare WARP 能把你自己的几台机器放进同一个私有网络，一个端口都不用开。在每台想访问的机器上跑一条 Cloudflare Tunnel（<code>cloudflared</code>），让每条隧道各自通告一个私有 IP 网段，再用 WARP 客户端把笔记本和手机注册进一个 Zero Trust 组织，split tunnel 设成 include 模式。之后，连着 5G 的手机直接连 <code>198.18.42.2</code>，就能落到办公室里的那台工作站上。下面是我的具体配置，以及路上踩过的四个坑。</p>
<p>我的工作环境是三台很少待在同一个地方的设备。一台 MacBook Air，我去哪它去哪。一台放在办公室、叫 devbox 的工作站，有块像样的 GPU，上面跑着 Ollama、几样微调工具、一个自带容器镜像仓库的小 Kubernetes 集群，还有我丢在那儿慢慢啃长任务的某个 agent。再就是我的手机：我做的应用真正被使用的地方就是它，而它一天里大部分时间连的是移动数据，不是 wifi。</p>
<p>很长一段时间里，这三台设备之间基本不通。笔记本在办公室局域网里能连上 devbox，出了办公室就不行。手机和笔记本连着同一个 wifi 时，能访问笔记本上的 dev server，前提是我记得绑定 <code>0.0.0.0</code>，还得手动敲进 DHCP 那天早上分给 Mac 的地址。工作站上所有有意思的东西，Ollama API、集群的仪表盘、链路追踪、镜像仓库，都绑在 localhost 或集群网络上，这是故意的，因为它们都不该出现在公网上。</p>
<p>这篇文章讲我怎么用 Cloudflare WARP 和两条 Cloudflare Tunnel 把它们连起来，这对我做 AI 功能和移动应用的方式带来了什么变化，还有那几个坏了却一声不吭的地方。</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%e7%9c%9f%e6%ad%a3%e6%83%b3%e8%a6%81%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>写成需求的话，很短：</p>
<ol>
<li>笔记本应该是个瘦客户端。凡是需要 GPU、大量内存或者要跑几个小时的东西，都应该在 devbox 上跑，而笔记本无论在办公室、在家还是在咖啡馆，都用同一种方式连过去。</li>
<li>手机应该能在任何地方、用移动数据访问我的 dev server 和 shell，而且不需要一个我得记着开关的 VPN。</li>
<li>我用来观察系统运行状态的东西，仪表盘、链路追踪、<code>docker info</code>、模型列表，应该只有我能访问，而且永远不该有公网 URL。</li>
<li>不开入站端口。办公室路由器不开，家里路由器不开，哪儿都不开。</li>
</ol>
<p>第四条排除了老办法，也就是端口转发加动态 DNS。它也排除了很多取巧的方案：devbox 和笔记本都在我控制不了的 NAT 后面，而笔记本每隔几小时就换一个 NAT。</p>

<h2 class="relative group">之前：一个装满 SSH 隧道的仓库
    <div id="之前一个装满-ssh-隧道的仓库" 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%b9%8b%e5%89%8d%e4%b8%80%e4%b8%aa%e8%a3%85%e6%bb%a1-ssh-%e9%9a%a7%e9%81%93%e7%9a%84%e4%bb%93%e5%ba%93" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>最早的版本是我自己写的一个小仓库，到现在它仍然是底层。它在笔记本上只解决一个问题：给通过 SSH 访问的东西起干净的名字，别的不管。dnsmasq 把 <code>*.test</code> 解析到 <code>127.0.0.1</code>，Caddy 按主机名路由到本地端口，用 <code>tls internal</code> 让浏览器满意，再由一组 autossh launch agent 保持 SSH 转发开着，断了就重连。主机名本身说明了东西在哪儿：</p>
<table>
	<thead>
			<tr>
					<th>后缀</th>
					<th>含义</th>
					<th>承载方式</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>*.local.test</code></td>
					<td>这台 Mac</td>
					<td>无，就是个 dev server</td>
			</tr>
			<tr>
					<td><code>*.ssh.test</code></td>
					<td>某台服务器</td>
					<td>一条 autossh 转发</td>
			</tr>
			<tr>
					<td><code>*.cloud.test</code></td>
					<td>托管在云上的应用</td>
					<td>托管平台自带的代理</td>
			</tr>
	</tbody>
</table>
<p>所以 <code>https://devbox-ollama.ssh.test/api/tags</code> 列出的是工作站上的模型，<code>https://api.local.test</code> 是我手上在做的那个应用的 API，跑在我自己的机器上。</p>
<p>用的是 <code>.test</code> 的子域名，而不是 <code>.ssh</code>、<code>.cloud</code> 这样新造的顶级域，也不是 <code>.local</code>。RFC 6761 把 <code>.test</code> 专门保留给这种用途，而 <code>.local</code> 归 mDNS 所有；占用它的话，Mac 上的 Bonjour 发现就会失效。dnsmasq 本来就通配了整个 <code>*.test</code>，所以多一级标签不花任何成本：一个指向 <code>127.0.0.1</code> 的 <code>/etc/resolver/test</code> 文件，此后再也不用改 DNS。</p>

<h3 class="relative group">给不存在的域名上 HTTPS
    <div id="给不存在的域名上-https" 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="#%e7%bb%99%e4%b8%8d%e5%ad%98%e5%9c%a8%e7%9a%84%e5%9f%9f%e5%90%8d%e4%b8%8a-https" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>这些名字全都走 HTTPS，证书浏览器也信任，对一个只存在于我笔记本上的主机名来说，这听起来不太可能。Caddy 靠它自带的内部证书颁发机构做到这一点。在某个站点上写 <code>tls internal</code>，Caddy 就会用本地 CA 给这个站点签证书，而不是去找 Let&rsquo;s Encrypt；安装脚本会跑一次 <code>caddy trust</code>，把 CA 的根证书放进 macOS 的系统钥匙串（Chrome 和 Safari 读这里），以及 Firefox 自己的证书库。从此 <code>https://anything.test</code> 打开时都不会有证书警告，普通的 <code>http://</code> 会重定向过去，而且没有任何东西离开这台机器。</p>
<p>这不只是为了好看。现在 Web 平台上有很多东西在纯 HTTP 下直接罢工：secure cookie、service worker、剪贴板 API、任何带 <code>SameSite=None</code> 的东西。在纯 <code>http://localhost</code> 上测 web 构建，会把那些到了真正 HTTPS 域名上才冒出来的 bug 藏起来。在 <code>https://app.local.test</code> 上测，它们就会在我的笔记本上现形。</p>
<p>一个站点块，每个服务除了端口都一样：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddyfile" data-lang="caddyfile"><span class="line"><span class="cl"><span class="gh">devbox-ollama.ssh.test</span>, <span class="gh">devbox-ollama.test</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">	<span class="k">tls</span> <span class="no">internal</span>
</span></span><span class="line"><span class="cl">	<span class="k">import</span> cors
</span></span><span class="line"><span class="cl">	<span class="k">reverse_proxy</span> <span class="n">127.0.0.1</span><span class="p">:</span><span class="mi">11435</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">		<span class="k">import</span> proxyhdr
</span></span><span class="line"><span class="cl">		<span class="k">transport</span> <span class="s">http</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">			<span class="k">keepalive</span> <span class="no">off</span>
</span></span><span class="line"><span class="cl">			<span class="k">dial_timeout</span> <span class="mi">5s</span>
</span></span><span class="line"><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>麻烦的部分都在那两个 snippet 里。<code>cors</code> 把调用方的 <code>Origin</code> 原样回传，而不是发 <code>*</code>，因为一旦涉及凭据，<code>*</code> 就不被允许；它还自己用 204 应答 <code>OPTIONS</code> 预检请求，上游永远不用操心。<code>proxyhdr</code> 把 <code>Host</code> 头改写成实际连接的地址。有些挑剔的后端，包括路由器管理页面和 Chrome 的远程调试端点，会校验 <code>Host</code>，否则就会把 <code>devbox-ollama.ssh.test</code> 当成一个从没听说过的名字拒掉。Ollama 自己的 origin 检查接受任意端口的 loopback，所以把 <code>Host</code> 改成 <code>127.0.0.1:11435</code> 就能过。</p>
<p><code>keepalive off</code> 是我原本肯定会搞错的那个。上游是 SSH 转发，网络一变，autossh 就会把它拆掉重建。开着 keep-alive 的话，Caddy 会一直攥着一条通往已经死掉的隧道的池化连接，在重载之前一直返回莫名其妙的 404。关掉之后每个请求都重新建连，在 loopback 上这点开销几乎测不出来。</p>
<p>每个服务也仍然响应它原来的扁平名字（<code>devbox-ollama.test</code>），所以启用后缀方案之前的书签还能用。</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="#%e8%8f%9c%e5%8d%95%e6%a0%8f" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>launchd 撑着十几条转发，我老在问的问题是&quot;它还活着吗？&quot;，而答案散在三个地方：<code>launchctl list</code>、<code>lsof</code> 和一次 <code>curl</code>。于是我写了个小小的菜单栏插件把它们归到一处，每个主机名前面有个圆点：</p>
<ul>
<li>🟢 服务通过隧道响应了请求</li>
<li>🟡 隧道是通的，但后面的服务没反应</li>
<li>🔴 本地端口上没有任何东西在监听，也就是隧道本身断了</li>
</ul>
<p>有用的是黄色这一档。只分绿和红的检查，区分不了&quot;SSH 转发断了&quot;和&quot;远端的 Ollama 崩了&quot;，而这两者的修法完全不同。插件不自己维护服务列表，而是读取和其他部分同一份代理配置和 SSH 配置，所以在那里加一个服务，菜单里也就有了。</p>
<p>第一版太贪了。它每十秒刷新一次，每个服务都要起好几个进程，一次运行大约七十个，macOS 立刻把它列进了&quot;耗电量大的应用&quot;。改成每次运行只取一次快照、每隔几分钟刷新之后，降到了大约一个核的 0.1%。一个状态灯要是费电，就帮了倒忙，所以开销最好一开始就算进去。</p>
<p>它有一个漏洞，恰恰是最要紧的那个。devbox 没有公网域名。它的 SSH 转发连的是一个 <code>192.168.x</code> 地址，所以只有笔记本在办公室局域网里时才管用。一离开办公室，菜单栏就变红，而且一直红着；我为了给笔记本减负而搬到工作站上的所有东西，恰好在我不坐在它旁边的时候全都够不着。</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%a4%e6%9d%a1%e9%9a%a7%e9%81%93%e6%96%b9%e5%90%91%e8%a6%81%e5%af%b9" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>解法分三部分，前两部分是 Cloudflare Tunnel。</p>
<p>Cloudflare Tunnel 就是 <code>cloudflared</code> 保持一条通往 Cloudflare 边缘的出站连接。Cloudflare 把请求沿着这条连接送回来，所以不管机器躲在多少层 NAT 后面，路由器上不用监听任何端口就能访问到它。笔记本连上酒店 wifi 它照样跟着走，CGNAT 也拦不住它。</p>
<p>我跑了两条，每台机器一条，原因值得说一句。显而易见的捷径是只在笔记本上跑一条隧道，再加一条 ingress 规则，指向办公室局域网里 devbox 的 SSH 端口。这样 devbox 上什么都不用装。可它也要经过笔记本，而笔记本正是那台会离开办公室的机器。它会恰好在它本该解决的那种情况下失效。devbox 不挪窝，所以由 devbox 自己保持通往边缘的连接。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># config.yml on devbox (runs as a systemd service)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">auto       </span><span class="w"> </span><span class="c"># prefer QUIC, fall back to HTTP/2 over TCP 443</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ha-connections</span><span class="p">:</span><span class="w"> </span><span class="m">4</span><span class="w">     </span><span class="c"># mains-powered, its job is to stay reachable</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="nt">warp-routing</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">connectTimeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</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="nt">ingress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ssh-devbox.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">tcp://127.0.0.1:22</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http_status:404</span></span></span></code></pre></div></div>
<p>用 <code>tcp://</code> 而不是 <code>ssh://</code>，是个很容易弄错的地方。<code>ssh://</code> 选的是 Cloudflare 在浏览器里渲染的终端，它会把数据流包装成网页用的格式；普通的 <code>ssh</code> 客户端从这里过，能收到 banner，然后死在密钥交换那一步。<code>tcp://</code> 才是 <code>cloudflared access ssh</code> 期待的那种纯字节管道，顺带还能承载 <code>scp</code>、<code>rsync</code>、走 SSH 的 git 和端口转发。</p>
<p>最后这一点，正好补上了 SSH 这一层的漏洞。现在 SSH 配置会按每次连接选路：</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-sshconfig" data-lang="sshconfig">Match originalhost devbox !exec &#34;nc -z -G 1 &lt;office-lan-ip&gt; 22 &gt;/dev/null 2&gt;&amp;1&#34;
    ProxyCommand cloudflared access ssh --hostname ssh-devbox.example.com ...</code></pre></div>
<p>在办公室局域网里，探测成功，SSH 直连，一跳到位。在其他任何地方探测失败，SSH 就走 Cloudflare，用 Access service token 认证，中间不用在浏览器里登录。这里是 <code>Match originalhost</code>，不是 <code>Match host</code>：<code>host</code> 在 <code>HostName</code> 替换之后才匹配，那时名字已经变成 IP 了，这个块永远不会触发。结果就是 <code>devbox-ollama.ssh.test</code> 在办公室、在家、在火车上都能用，菜单栏上的圆点一直是绿的。</p>
<p>笔记本上那条隧道方向相反。它给 Mac 上的 dev server 分配真正的公网 HTTPS 主机名：我手上在做的每个应用，都有 API、WebSocket 服务和 web 构建这三个。它们每一个都挡在 Cloudflare Access 后面，这一点后面还会讲，因为正是它让这件事变得安全，而不是鲁莽。</p>
<p>菜单栏也学会了认这两条隧道，而且没法靠探测来做。入站隧道没有本地端口可查，两个 connector 里有一个根本不在笔记本上跑，而最直接的测试，curl 一下 <code>ssh-devbox.example.com</code>，不管有没有 connector 挂着，都会从 Cloudflare 边缘拿到响应，所以隧道死了它也会显示绿色。launchd 也好不到哪去：<code>cloudflared</code> 所有边缘连接都断了，它照样显示&quot;loaded, exit 0&quot;。所以隧道这部分改成去问 Cloudflare API 每条隧道有没有健康的 connector，因为决定一次 <code>ssh</code> 能不能落地的就是它。如果连不上 API，这一整部分就干脆不显示，而不是画成红色。菜单是扫一眼就看的东西，那里的一次误报，比少一行的代价更大。</p>

<h2 class="relative group">第三部分：WARP 让它成为一个网络
    <div id="第三部分warp-让它成为一个网络" 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="#%e7%ac%ac%e4%b8%89%e9%83%a8%e5%88%86warp-%e8%ae%a9%e5%ae%83%e6%88%90%e4%b8%ba%e4%b8%80%e4%b8%aa%e7%bd%91%e7%bb%9c" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>带主机名的隧道对任何说 HTTPS 的东西都很好用，对能跑 <code>cloudflared</code> 的机器上的 SSH 也一样。手机不行。而且隧道主机名不是一个 TCP 端点：Cloudflare 边缘在上面只终结 HTTPS 和 WebSocket，所以 <code>ssh.example.com</code> 的 22 端口是关着的，在 Access 上怎么折腾都打不开。</p>
<p>这就是 WARP 的活了。Cloudflare One Agent（企业版 WARP 客户端，不是面向普通用户的&quot;1.1.1.1&quot;应用，那个加入不了组织）装在笔记本和手机上，注册进我的 Zero Trust 组织。每条隧道通告私有路由，运行 WARP 的设备就能直接对这些地址开一个普通 socket，落到正确的机器上。它不再是&quot;按名字访问这个服务&quot;，而是&quot;这几台机器和我在同一个网络里&quot;：</p>
<table>
	<thead>
			<tr>
					<th>地址</th>
					<th>机器</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>198.18.42.1</code> / <code>fd12:3456:789a::1</code></td>
					<td>MacBook Air</td>
			</tr>
			<tr>
					<td><code>198.18.42.2</code> / <code>fd12:3456:789a::2</code></td>
					<td>devbox</td>
			</tr>
			<tr>
					<td><code>198.18.42.3</code> / <code>fd12:3456:789a::3</code></td>
					<td>另一台 Mac</td>
			</tr>
	</tbody>
</table>
<p>这些地址是每台机器上的 loopback 别名，开机时创建，所以跟 DHCP 或者我身后是哪台路由器都没关系。<code>198.18.42.2</code> 就是 devbox，在办公室是，在家是，从连着 5G 的手机上也是。Mac 真正的局域网地址我每换一个地方就变一次；这个永远不变。</p>
<p>split tunnel 用的是 <strong>include 模式</strong>，里面只有这些路由（外加 WARP 自身需要的 Cloudflare 网段）。正是这一点让这个客户端能长期开着。默认是 exclude 模式，WARP 承载除一串私有网段之外的所有流量，于是手机发出的每个字节都要绕道 Cloudflare。这对电量和延迟有实打实的代价，大到我最后只会在需要时才打开它，那就失去意义了。在 include 模式下，发往我这三台机器的流量走 WARP，其他一切照常走原来的路径，完全不受影响。所以客户端就一直开着。笔记本上的 <code>warp-cli status</code> 整天都是 <code>Connected</code>，我根本不用去想它。</p>
<p>传输方式我选的是 MASQUE，也就是跑在 UDP 443 上的 HTTP/3，带 HTTP/2 回退，而不是跑在 UDP 2408 上的 WireGuard。有些运营商和访客网络会封 2408；几乎没人封 443。我组织的策略还给 MASQUE 打开了后量子密钥协商，这不是我特意去找的，但也没理由拒绝。</p>

<h2 class="relative group">为什么是 198.18，为什么还有 IPv6 地址
    <div id="为什么是-19818为什么还有-ipv6-地址" 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%ba%e4%bb%80%e4%b9%88%e6%98%af-19818%e4%b8%ba%e4%bb%80%e4%b9%88%e8%bf%98%e6%9c%89-ipv6-%e5%9c%b0%e5%9d%80" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这些地址看着有点怪，而我最初挑的那几个错得很有教育意义。</p>
<p>我第一次用的是 <code>10.10.0.1</code>，因为大家顺手就会选它。它在两种 split tunnel 模式下都不行。在 exclude 模式下，WARP 的默认列表排除了所有 RFC 1918 网段外加 <code>100.64.0.0/10</code>，所以客户端会在流量离开设备之前就把它丢掉。在 include 模式下，它会和我碰巧接入的任何真实 <code>10.x</code> 网络冲突；咖啡馆、联合办公空间和不少办公室用的正是这个网段。<code>198.18.0.0/15</code> 是 RFC 2544 的基准测试网段。它是保留地址，从不在公网上路由，不在 WARP 的排除列表里，普通网络也不会用它。IPv6 地址用 ULA（<code>fd00::/8</code>），理由相同。</p>
<p>为什么要有 IPv6，这个故事更有意思。v4 地址通了以后，我在手机上连着 wifi 测了一下，没问题，就当完事了。换到 5G 上，不通了。Agent 显示 <em>connected</em> ，隧道健康，Access 也没问题；wifi 上的测试已经证明了这些。问题在于现在的移动网络大多是纯 IPv6。手机根本没有原生 IPv4，是通过运营商的 464XLAT 转换器去访问那个 v4 字面地址的，而当 include 列表里只有一个 v4 前缀时，这个转换器位于隧道 <em>之外</em> 。流量就这么出了公网，而 <code>198.18.0.0/15</code> 在公网上不路由，于是被悄无声息地丢掉了。手机上没有报错，connector 的日志里什么也没有。</p>
<p>解法是给每台机器再配一个手机能原生通过 IPv6 访问的地址，并把它加进路由和 include 列表。现在我手机上的 SSH 客户端在移动数据下真正用的，就是 <code>fd12:3456:789a::</code> 这组地址。</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%8d%b8%e8%bd%bd%e6%8a%8a%e7%ac%94%e8%ae%b0%e6%9c%ac%e5%bd%93%e7%bb%88%e7%ab%af%e7%94%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>整套配置就是为这一部分存在的。</p>
<p><strong>模型和微调。</strong> Ollama 跑在 devbox 上，绑定 <code>127.0.0.1:11434</code>。办公室局域网里的其他机器都看不见它，更别说公网了。在笔记本上，它出现在 <code>https://devbox-ollama.ssh.test</code>，或者给需要端口的工具一个裸端口：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">OLLAMA_HOST</span><span class="o">=</span>127.0.0.1:11435 ollama list</span></span></code></pre></div></div>
<p>用 11435 而不是 11434 是故意的：11434 是 Ollama 自己的默认端口，要是笔记本哪天也跑了个本地 Ollama，两者就会抢这个端口，而我会根据谁先启动，悄悄地从&quot;本地&quot;那个拿到 devbox 的回答。</p>
<p>实际效果是，我做 AI 功能时，它调用的模型跑在工作站的 GPU 上，而不是笔记本上，不管我人在哪儿。评估跑批、给一大堆文档做 embedding、跑一次微调：都在 devbox 上跑，笔记本的风扇一声不响。做 agent 原型时，可以让它指向工作站上的本地模型，反复跑工具调用也不产生 API 账单，等循环调顺了再换成托管模型。</p>
<p><strong>长时间运行的 agent。</strong> 一个要跑一小时的 agent 会话，不该取决于我的笔记本盖子是不是一直开着。改在 devbox 上跑，笔记本就只是一扇看它的窗口：合上盖子，过会儿不管在哪儿 <code>ssh devbox</code>，或者从手机上连，它都还在。有隧道之前，这只在办公室里管用，而办公室恰恰是我最不需要它的地方。</p>
<p><strong>构建和集群。</strong> devbox 上的集群有自己的镜像仓库，所以镜像可以在工作站上构建和推送，就在它们运行的地方旁边，而不是用笔记本通过酒店 wifi 上传。集群仪表盘、ingress 仪表盘和镜像仓库的 UI，在笔记本上都是 <code>*.ssh.test</code> 主机名。</p>
<p><strong>移动端的开发循环。</strong> 手机和笔记本在同一个私有网络里，笔记本的 dev server 还有挂在 Access 后面的公网主机名。所以我可以带着应用的原生构建走出大楼，连着 5G，它照样和我桌上跑着的 API 通信。或者和 devbox 上那个通信，如果我把它留在那儿的话。在真机、真实移动网络上的反馈循环，以前意味着部署到共享的 staging 服务器。现在意味着保存一个文件。</p>
<p>这里有个坑，最好在你把它当成应用 bug 去调之前就知道。Access 也保护着 WebSocket 主机名，而原生的 WebSocket 客户端不是浏览器。它不带 <code>CF_Authorization</code> cookie，所以原生构建连到 WebSocket 服务的连接会在边缘被一个 403 拒掉，看起来跟你自己服务器报的错完全不像。浏览器标签页没问题，因为登录后它们已经有了那个 cookie。对任何不是浏览器的东西（原生应用、脚本、调用 dev API 的 agent），答案是 Access 的 <strong>service token</strong>，以两个请求头的形式发送：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">CF-Access-Client-Id:     &lt;client_id&gt;
</span></span><span class="line"><span class="cl">CF-Access-Client-Secret: &lt;client_secret&gt;</span></span></code></pre></div></div>
<p>这个 token 本身什么权限都不给。Access 只在策略里点了它名的应用上认它，所以它能触及的范围恰好就是那串 dev 主机名，一点不多。我把它的策略归在 <code>non_identity</code> 决策下，而不是 <code>allow</code>，这样在审计日志里它始终显示为一个持有者凭据，而不是某个登录进来的人。</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%85%ac%e7%bd%91%e6%b0%b8%e8%bf%9c%e7%9c%8b%e4%b8%8d%e5%88%b0%e7%9a%84%e6%95%b0%e6%8d%ae" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我想要的另一半是可观测性。能告诉你系统运行状况的东西，大多数本就不该公开，这没错。在 devbox 上，这些东西是：</p>
<ul>
<li>ingress 代理的仪表盘，以及装着它的链路追踪的追踪界面</li>
<li>管 Kubernetes 集群的仪表盘</li>
<li>容器镜像仓库的 UI（只读）</li>
<li>Ollama 的模型列表和当前加载了哪些模型</li>
<li><code>docker info</code> 和容器统计数据</li>
</ul>
<p>这些都没有公网主机名，以后也不会有。它们绑定在 loopback 或集群的 service IP 上，通过 SSH 转发访问，而这条转发本身又跑在隧道上。我手机上有个监控应用，经 WARP 通过 SSH 读取 <code>docker info --format '{{json .}}'</code>，所以不管在哪儿，我都能看到容器是不是都在跑，或者有没有什么东西把内存吃光了。</p>
<p>也正是这个应用让我发现了一个值得一提的小 bug。Mac 上那个隧道用的 sshd，还停留在我调试时设的 <code>LogLevel DEBUG2</code>。带 <code>-e</code> 时 sshd 把日志写到 stderr，而对一个会话来说，这个 stderr <em>就是</em> SSH 通道的 stderr。每条命令的输出后面都多了一行 <code>debug2: do_setup_env: set TMPDIR</code>，而手机应用会把两个流合并，于是拿到的是合法 JSON 后面跟着一行日志，解析失败，把整段显示成错误。改成 <code>LogLevel INFO</code> 就好了。调试完记得把日志级别改回去。</p>
<p>同样是刻意为之的，是我 <em>不</em> 放进这个网络的东西。旧的 autossh 隧道还能连到几台别人网络里的路由器，我帮忙照看着。它们没有公网主机名，也没有 WARP 路由。路由器管理页面挂在公网上，哪怕有登录挡着也是个隐患，而且那不是我的网络，轮不到我去扩大它的暴露面。</p>

<h2 class="relative group">Access 不是可选项
    <div id="access-不是可选项" 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="#access-%e4%b8%8d%e6%98%af%e5%8f%af%e9%80%89%e9%a1%b9" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Cloudflare Tunnel 的主机名，DNS 记录一建好就在公网上了。它前面没有防火墙；不开端口的全部意义就在这里。保护必须来自 Cloudflare 边缘，而这正是 Access 做的事。在访问者证明自己是谁之前，它根本不转发请求，所以没认证的扫描器永远到不了笔记本。这很重要，因为这些是 dev server：带着 settings 的调试模式报错页，一个敞开的 JavaScript bundler。这些主机名后面的东西，没有一样是为面对公网而做的。</p>
<p>策略只放行我自己的几个邮箱地址，别的一概不放，证明身份的方式有两种：邮件一次性 PIN 码和 SSO 单点登录。会话持续八小时，因为现实中的威胁是一台在咖啡馆里开着、带着一整天有效 cookie 的笔记本，而不是有人暴力破解 PIN 码。</p>
<p>SSH 被认证了两次，由两个互不知情的系统分别负责。Access 决定一条 TCP 流能不能到达 sshd；sshd 照样要我的密钥。丢了其中任何一个，机器都不会因此敞开。Mac 上的隧道 sshd 还信任 Cloudflare 的 SSH CA，所以 Access 可以用从我的 SSO 会话签发的短期证书让我登录，同时 <code>authorized_keys</code> 也照样能用，这样一份坏掉的 Access 配置也不会把我锁在自己的笔记本外面。</p>
<p>我最满意的是一个很小的部分。暴露发生在三件事同时成立的时候：一条 DNS 记录、一个在跑的 connector、一个已激活的 zone。它们在不同时间变为真，顺序也不由我控制；zone 会在 nameserver 改完之后过一阵子自己变成 active，那时 Mac 很可能正睡着。所以 launch agent 不直接跑 <code>cloudflared</code>。它先跑一个预检：从隧道自己的配置里读出主机名，问 Access API 哪些已经被覆盖，只要有一个没被覆盖就拒绝启动：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">HOSTNAMES</span><span class="o">=</span><span class="k">$(</span>grep -E <span class="s1">&#39;^\s*-\s*hostname:&#39;</span> <span class="s2">&#34;</span><span class="nv">$CONFIG</span><span class="s2">&#34;</span> <span class="p">|</span> sed -E <span class="s1">&#39;s/.*hostname:\s*//&#39;</span> <span class="p">|</span> sort -u<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">PROTECTED</span><span class="o">=</span><span class="k">$(</span>curl -fsS -H <span class="s2">&#34;Authorization: Bearer </span><span class="nv">$CLOUDFLARE_API_TOKEN</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="s2">&#34;https://api.cloudflare.com/client/v4/accounts/</span><span class="nv">$CLOUDFLARE_ACCOUNT_ID</span><span class="s2">/access/apps?per_page=100&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="p">|</span> python3 -c <span class="s1">&#39;import sys,json; print(&#34;\n&#34;.join(a.get(&#34;domain&#34;,&#34;&#34;) for a in json.load(sys.stdin)[&#34;result&#34;]))&#39;</span><span class="k">)</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;could not reach the Access API. refusing (fail closed).&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> h in <span class="nv">$HOSTNAMES</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    grep -qxF <span class="s2">&#34;</span><span class="nv">$h</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$PROTECTED</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;NOT starting: </span><span class="nv">$h</span><span class="s2"> has no Access app&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> cloudflared tunnel --config <span class="s2">&#34;</span><span class="nv">$CONFIG</span><span class="s2">&#34;</span> run</span></span></code></pre></div></div>
<p>launch agent 设了 <code>KeepAlive</code>，所以被拒之后它每 30 秒自己重试一次，Access 应用一建好，隧道就自己起来了。失败时关闭，代价是一次宕机。失败时敞开，代价是一次暴露。我很清楚这两件事里我更愿意解释哪一件。</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="#%e8%b8%a9%e8%bf%87%e7%9a%84%e5%9b%9b%e4%b8%aa%e5%9d%91" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>上面这些现在都能用了。一路走来，四个坑里有三个是同一个模式：坏掉的那个东西在任何地方都不报错，而我能看到的部分全都显示健康。</p>
<p><strong>路由和 include 列表是两份不同的列表。</strong> 隧道路由让 Cloudflare <em>愿意</em> 把某个地址的流量送到正确的 connector。WARP 的 include 列表才决定客户端一开始会不会 <em>发送</em> 它。devbox 从手机上连不通，就是因为它的路由已经存在，但 include 列表里还只有 Mac 的地址。手机在本地就把包丢了，connector 从没见到过任何流量，所有仪表盘都是绿的。每加一台新机器，都要把它的地址同时加进这两处。</p>
<p><strong>纯 IPv6 移动网络上的 IPv4 字面地址。</strong> 上面讲过了：wifi 上能用，5G 上失败，没有报错。如果一条 WARP 路由在 wifi 上能用、移动数据上不行，而 Agent 显示已连接，那是路由的问题，不是隧道的问题。</p>
<p><strong>cloudflared 后面的 macOS sshd。</strong> macOS 用 launchd 的 socket activation 启动 sshd，每个连接一个短命的 <code>sshd -i</code>。放在 cloudflared 后面时，banner 能到达客户端，但在客户端的标识字符串传回来之前，数据流就被拆掉了，所以每次登录都死在 <code>kex_exchange_identification</code>。我从两端都确认过，还用一个普通的 TCP 中继复现了出来，排除了 Cloudflare 的嫌疑。解法是另起一个独立的 <code>sshd -D</code>，监听 <code>127.0.0.1:2222</code> 和 WARP 的 loopback 地址，以我的用户身份运行，唯一的用途就是充当隧道的源站。系统设置里的远程登录原封不动。devbox 是 Linux，跑的是正常的常驻 sshd，从来没有这个问题。</p>
<p><strong>不复存在的配置字段。</strong> 旧的示例在隧道配置里写着 <code>warp-routing: enabled: true</code>。cloudflared 2026.8 会直接拒绝这个字段。只要这个块存在、且有路由指向这条隧道，WARP 路由就是开着的。至少这个报了错，跟前面三个比起来，简直称得上厚道。</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%ae%83%e6%94%b9%e5%8f%98%e4%ba%86%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>老实说，总结起来就是：我不再根据硬件在哪儿来决定在哪儿干活。笔记本是一副好键盘、一块好屏幕，续航很长，它不需要比这更多。工作站不管我在哪儿都替我扛重活。手机是在真实网络上的真实测试设备，而不是拴在办公室 wifi 上的东西。而我用来观察系统在干什么的那些部分，对我来说一点就开，对别人来说根本不存在。</p>
<p>这个规模下，它在 Cloudflare 免费的 Zero Trust 套餐以内。花掉的是时间，外加一份我很庆幸写了的 README，因为上面那些故障，每一个第二次遇到时都会是一个全新的谜。</p>
<p>如果你在考虑搭类似的东西，下面是我会拿来问你的配置的几个问题：</p>
<ol>
<li>有哪些东西跑在你的笔记本上，只是因为笔记本是你唯一随时能连上的机器？</li>
<li>你的手机能用移动数据访问你的 dev server 吗，而不只是在办公室 wifi 上？用 5G 测，别用 wifi。</li>
<li>你的仪表盘里，有哪些是公开的，只因为那是从外面看它们最省事的办法？每一个都可以改成私有路由。</li>
<li>如果明天某个隧道主机名起来了却没有对应的 Access 应用，什么会拦住它？如果答案是&quot;我会记得&quot;，那就把预检写出来。</li>
<li>你的私有地址所在的网段，是你去过的那些网络永远不会用的吗？</li>
</ol>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-01-cloudflare-warp-dev-network/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>每个分支一个预览环境：用 Fly.io 和 Cloudflare 终结 staging 争夺战</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/</guid>
      <pubDate>Sat, 19 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Fly.io</category>
      <category>Cloudflare</category>
      <category>DevOps</category>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>PostgreSQL</category>
      <category>Redis</category>
      <category>Django</category>
      <category>Database Migrations</category>
      <category>Branching</category>
      <category>Preview Environments</category>
      <description>怎样在 Fly.io 和 Cloudflare 上给每个 PR 一个预览环境：打一个标签，用分支名的哈希把共享的 Postgres、Redis、bucket 和主机名切开，PR 关闭就拆掉。</description>
      <content:encoded><![CDATA[<p>要给每个分支一个预览环境，我在 Fly.io 上没有把基础设施整套复制，而是共享着切开用。往 PR 上打一个 <code>env</code> 标签，GitHub Actions 工作流就跑一个脚本，脚本从分支名推导出一个名字（slug 加一段短哈希），盖在 Fly 应用、从模板克隆的 Postgres 数据库、Redis 键前缀、bucket 前缀、新生成的密钥和 Cloudflare 通配符下的主机名上。每次 push 都会更新它，PR 关闭或合并就拆掉，漏掉的由每晚的清理任务收拾，从此不用再问“现在有人在用 staging 吗？”。</p>
<p>想象一个小团队在做同一个应用。两个开发者也好，五个也好；不是分工不同的专家，而是各自在自己的分支上把一个功能从数据库迁移、经过 API、一直做到移动端界面，端到端地负责的人。这是运转团队的好方式，而从第二个开发者加入起，它就有一个反复出现的故障：&ldquo;现在有人在用 staging 吗？&ldquo;这条消息。</p>
<p>消息背后是一个做完了功能、想给测试或设计看、却看不了的开发者：staging 上跑着第二个开发者的分支，dev 上跑着第三个的，而那个分支两天前把登录搞坏了，之后就再没重新部署过。几个人，两个共享环境，一条聊天线程在负责排班。</p>
<p>这不是人的问题，是拓扑问题。每个开发者都把环境当作&quot;我的分支变成真的&quot;的地方，于是环境在替 merge 干活，一次一个分支，其他所有人排队等着。</p>
<p>我的技术栈在生产中的样子写在 <a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-07-22-my-current-stack/" >My Current Stack</a> 里：Fly.io 最便宜的机器上跑的 Django，连接池后面的 Postgres，给缓存和 Channels 层用的 Redis，独立的 WebSocket 应用，一个后台 worker，放文件的兼容 S3 的 bucket，一个托管的认证服务，前面挡着 Cloudflare，还有一个和这一切通信的移动应用。这篇文章讲的是我怎么把这套栈变成这样一种东西：任何分支，开发者往 PR 上打一个标签，几分钟后就拿到一份带 HTTPS 的、公开可访问的完整副本，不用求别人，也没人替他开通，而且不需要第二个 Postgres 集群、第二个 Redis 或第二个 bucket。基础设施保持共享。变化的是每一种共享资源都学会了按名字切片，而名字来自分支。这里面没有任何东西预设团队规模：两个开发者和二十个开发者用的是同一套设计，会增长的只有应用列表，而那由清扫器来处理。我在一个下午里把它搭起来，同时跑了三个分支，在两个没预料到的地方把它弄坏，又拆掉了；下面的输出都来自那次运行。</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%ba%e4%bb%80%e4%b9%88%e4%b8%80%e4%b8%aa%e5%85%b1%e4%ba%ab%e7%8e%af%e5%a2%83%e6%92%91%e4%b8%8d%e8%bf%87%e4%b8%80%e4%b8%aa%e5%bc%80%e5%8f%91%e8%80%85" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>共享的 dev 环境里烙着一个假设：所有人想看的&quot;当前代码版本&quot;只有一个。对单个开发者，或者对齐步推进同一条变更流的团队来说，这是对的。第二个开发者按另一个时间表开始做功能的那一刻，它就不对了；而在端到端负责制下，每个功能都是这样。从那以后，环境里的一切都成了争抢对象：</p>
<ul>
<li><strong>部署的代码。</strong> 最后部署的人赢。其他人的工作在重新部署前都看不见，而重新部署又会抹掉前一个人的。</li>
<li><strong>数据库 schema。</strong> 当每个开发者都拥有自己功能的数据模型时，咬得最狠的就是这一条。第一个分支加了一列。一小时后部署的第二个分支不知道有这列，ORM 开始在 insert 上报错。或者两个分支都有一个 <code>0042_*</code> 迁移，共享数据库落到一个和谁的迁移历史都对不上的状态，然后有人把它重置，顺手删掉了第三个开发者花一下午准备的测试数据。</li>
<li><strong>缓存。</strong> 一个分支改了序列化器，结果缓存到了另一个分支代码读取的同一个 Redis 键下。由此产生的 bug 报告莫名其妙，每个都要耗掉一上午。</li>
<li><strong>第三方集成。</strong> 一个 OAuth 回调，一个 webhook URL，一个推送证书。谁的分支部署着，谁就收到事件。</li>
<li><strong>测试人员。</strong> 他们只能测已经部署的东西，所以是按环境碰巧被谁占用的顺序、而不是功能就绪的顺序，一次测一个。</li>
</ul>
<p>团队的应对是预约制：一条聊天线程、一条置顶消息、一个 <code>/claim staging</code> 机器人。那是一把锁，而锁在唯一能展示你工作的地方上，就是锁在交付速度上。两个开发者的话，只是烦人。三个的话，顺利的一周还能忍。加一个第四人，或者某个功能因为要和设计师来回三天而一直霸着 staging，就忍不了了。</p>
<p>人们最先想到的办法是再加一个共享环境，&ldquo;dev2&rdquo; 或者 &ldquo;uat&rdquo;。这能争取几周。队伍只是变成了两条道。</p>
<p>真正的解法是意识到 dev 和 staging 从来就不是环境。它们是<em>某人分支的当前状态，附带一个 URL</em>。如果它们是这样的东西，那它们的正确数量就是团队此刻在意的分支数，正确寿命就是分支的寿命。</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%a8%a1%e5%9e%8b%e5%88%86%e6%94%af%e5%b0%b1%e6%98%af%e7%8e%af%e5%a2%83" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>目标状态是这样。</p>
<table>
	<thead>
			<tr>
					<th>环境</th>
					<th>数量</th>
					<th>由谁创建</th>
					<th>由谁销毁</th>
					<th>存活时间</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>分支环境（原来的&quot;dev&rdquo;）</td>
					<td>每个活跃分支一个</td>
					<td>分支的 PR 上的 <code>env</code> 标签，之后每次 push；没有 PR 的 spike，就手动跑同一个 <code>./scripts/env up</code></td>
					<td>删除分支，外加每晚的清扫器</td>
					<td>几天</td>
			</tr>
			<tr>
					<td>Staging</td>
					<td>每个发布候选一个</td>
					<td>从 <code>main</code> 打 tag 时由 CI</td>
					<td>同一镜像晋升到生产后由 CI</td>
					<td>几小时</td>
			</tr>
			<tr>
					<td>生产</td>
					<td>一个</td>
					<td>你，一次</td>
					<td>没人</td>
					<td>永远</td>
			</tr>
	</tbody>
</table>
<p>再看一遍表里谁创建、谁销毁：每一项要么是 git 事件，要么是时钟。git 就是控制平面。应该存在的环境集合直接由存在的分支集合决定，自动化唯一的工作就是让现实和它保持一致：PR 上的标签创建，之后每次 push 更新，关闭或合并 PR 销毁，每晚一个任务把前三者漏掉的收拾干净。也不是每个分支都会有环境：标签就是闸门，开发者点一下，所以备份用的 push 或机器人的依赖升级不花一分钱。没有人在开通环境。不是拿着工单的 DevOps 工程师，不是你在聊天里喊的机器人，也不是你每次都要提示一遍的 AI agent。第十个开发者的第一个分支拿到环境的方式，和第一个开发者的一模一样：PR 被打上了标签。如果还得去找一个人，那就没有扩展，只是队伍换了个地方排。</p>
<p>让它成立的规则只有一条：<strong>环境触及的每一种资源都按分支命名，且是确定性的。</strong> 不按开发者，不按工单，也不按那个没人开 PR 之前根本不存在的 PR 号。按分支，因为分支才是开发者实际打交道的东西，也因为名字确定，就意味着在同一分支上第二次运行 <code>./scripts/env up</code> 会找到已经建好的环境，而不是再建一个。</p>
<p>名字是给人看的 slug 加上保证唯一的短哈希：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">branch</span><span class="o">=</span><span class="k">$(</span>git rev-parse --abbrev-ref HEAD<span class="k">)</span>              <span class="c1"># feature/payments-retry</span>
</span></span><span class="line"><span class="cl"><span class="nv">slug</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;[:upper:]/_ &#39;</span> <span class="s1">&#39;[:lower:]---&#39;</span> <span class="p">|</span> tr -cd <span class="s1">&#39;a-z0-9-&#39;</span> <span class="p">|</span> cut -c1-20<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">hash</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> sha256sum <span class="p">|</span> cut -c1-6<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">ENV</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">slug</span><span class="p">%-</span><span class="si">}</span><span class="s2">-</span><span class="si">${</span><span class="nv">hash</span><span class="si">}</span><span class="s2">&#34;</span>                                 <span class="c1"># feature-payments-retr-3f9a1c</span></span></span></code></pre></div></div>
<p><code>feature-payments-retr-3f9a1c</code> 就是环境。这个字符串会成为 Fly 应用名、Postgres 数据库名（连字符换成下划线）、Redis 键前缀、bucket 的对象前缀、错误追踪工具里的 environment，以及主机名最左边的标签。知道分支名的任何人都能算出来。什么都不用查。</p>
<p>最后那句话就是设计本身。没有环境注册表，没有协调者。<code>env up</code>、<code>env down</code>、负责路由主机名的 Cloudflare Worker，以及每晚的清扫器，各自独立地从分支重新算出名字，靠构造保证一致。尤其是 Cloudflare，它什么都不协调：Worker 是一个从主机名到 Fly 应用的纯函数，它永远不知道某个环境被创建或销毁了。应用存在，请求就落地；不存在，Fly 用自己的错误来回答。</p>
<p>由此得出三件事。</p>
<p>第一，&ldquo;谁在用 staging&quot;不再是个问题。有 <code>feature-payments-retr-3f9a1c-dev.example.com</code>，有 <code>fix-login-redirect-8b21e0-dev.example.com</code>，各归在那个分支上的人所有。</p>
<p>第二，staging 从垃圾场变成了对生产的诚实彩排。它用即将上生产的那个镜像构建，对着几分钟前从生产形态快照克隆出来的数据库跑。那里能跑通，生产里剩下的唯一变量就是生产数据。</p>
<p>第三，每个开发者获得了权力，却不需要任何共享资源的 root。分支环境的爆炸半径就是那个分支环境。第四个新人入职第一天想往自己分支上部署什么都行，最坏的结果是自己的分支坏了。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">fly.io/dashboard/my-org</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_5259e860ae14c489.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_5259e860ae14c489.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_e5b19353521f0ce4.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="该组织的 Fly.io 控制台：三个功能分支应用（两个已 suspended），旁边是生产应用和共享的预览 Postgres、Redis"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_a766ad7bf75a9514.webp"></span></div>
  <figcaption>三个分支起来后的组织应用列表。三个里有两个在部署一分钟后就已经 suspended；共享的预览 Postgres 和 Redis 在旁边。下面那几行是生产应用。</figcaption>
</figure>


<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="#%e7%89%a9%e7%90%86%e4%b8%8a%e5%85%b1%e4%ba%ab%e9%80%bb%e8%be%91%e4%b8%8a%e9%9a%94%e7%a6%bb" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>&ldquo;每个分支一个环境&quot;的诱人版本是整套复制：每人一个 Postgres 集群、一个 Redis、一个 bucket。干净，但是笔坏买卖。一个 Fly Postgres 集群是一台机器加一个卷加一次恢复；一个 Redis 又是一台机器；你会为每个环境等上几分钟，并为十来个闲置数据库付钱。清扫器要清理的东西也从一种变成四种。</p>
<p>取而代之的是，每种共享服务为整个预览组织只配置一次，按小号生产的规格来，每个环境以 <code>$ENV</code> 为键分到一片。这是我想贴在墙上的表：</p>
<table>
	<thead>
			<tr>
					<th>资源</th>
					<th>共享的东西</th>
					<th>每个环境独有的</th>
					<th>靠什么保证</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>计算</td>
					<td>Fly 组织 <code>myapp-preview</code></td>
					<td>一个 Fly 应用 <code>myapp-$ENV</code>，一台机器</td>
					<td>Fly：应用是隔离单位</td>
			</tr>
			<tr>
					<td>密钥</td>
					<td>Fly 的按应用密钥存储</td>
					<td>应用自己的密钥；<code>SECRET_KEY</code>、<code>ENCRYPTION_KEY</code> 和 <code>ENV_TOKEN</code> 是按环境<em>生成</em>的，不是复制的</td>
					<td>Fly：密钥属于应用，机器只能读自己的</td>
			</tr>
			<tr>
					<td>Postgres</td>
					<td>一个 Fly Postgres 集群 <code>myapp-preview-pg</code></td>
					<td>一个数据库 <code>env_feature_payments_retr_3f9a1c</code>，从 <code>seed_template</code> 克隆</td>
					<td>Postgres：数据库是硬边界，不能跨库查询</td>
			</tr>
			<tr>
					<td>Redis</td>
					<td>一个共享的 Redis</td>
					<td>缓存和 Channels 层上的键前缀 <code>feature-payments-retr-3f9a1c:</code></td>
					<td>Django 缓存的 <code>KEY_PREFIX</code>，channel layer 的 <code>prefix</code></td>
			</tr>
			<tr>
					<td>文件</td>
					<td>一个兼容 S3 的 bucket <code>myapp-preview</code></td>
					<td>对象前缀 <code>feature-payments-retr-3f9a1c/</code></td>
					<td>存储后端的 <code>location</code>；预签名 URL 本来就限定到单个键</td>
			</tr>
			<tr>
					<td>主机名</td>
					<td>区域里已有的、已代理的 <code>*.example.com</code> 记录</td>
					<td>最左边的标签 <code>&lt;env&gt;-dev</code></td>
					<td><code>*-dev.example.com/*</code> 上的一条 Cloudflare Worker 路由</td>
			</tr>
			<tr>
					<td>认证</td>
					<td>认证服务上的一个 dev 项目</td>
					<td>无；用户身份是有意共享的（见下文）</td>
					<td>授权域名 <code>example.com</code></td>
			</tr>
			<tr>
					<td>错误与日志</td>
					<td>错误追踪工具，Fly 的日志</td>
					<td>所有东西都打上 <code>environment=$ENV</code> 标签</td>
					<td>配置</td>
			</tr>
	</tbody>
</table>
<p>其中两行值得多说一句。</p>
<p><strong>Postgres：用数据库，不用 schema。</strong> 切分单个集群的另一种做法是每个环境一个 schema，按连接设置 <code>search_path</code>。把 <code>-c search_path=...</code> 塞进连接的 <code>options</code> 里，Django 是能做到的，但事务模式下的连接池会丢弃启动参数，除非你专门告诉它别丢，而即便如此，池里的每个连接也终究只能属于一个环境。这是&quot;为什么我的查询打到了别的表&quot;这类 bug 的源头，而每个环境一个数据库根本不存在这个问题。数据库是硬边界，<code>CREATE DATABASE ... TEMPLATE</code> 几秒钟建一个，<code>DROP DATABASE</code> 把它删得一干二净。分支环境不走连接池，直接连 Postgres 的 5433 端口，和生产里的后台 worker 一样；连接池是在生产级并发下才值回票价的，而分支环境永远见不到那种并发。</p>
<p><strong>密钥：生成，不共享。</strong> 弄一套 dev 密钥复制到每个环境里当然省事。别这么干。<code>env up</code> 脚本为每个环境生成新的 <code>SECRET_KEY</code>（会话签名）、<code>ENCRYPTION_KEY</code>（所有静态加密的东西）和 <code>ENV_TOKEN</code>（移动端出示的 bearer 令牌，下文详述），只存在那个 Fly 应用的密钥存储里。这意味着一个分支的会话 cookie 在另一个分支上一文不值，泄露的预览令牌只能打开一个预览，开发者不特意去找就永远看不到这些值。如果你把密钥放在单独的密钥管理工具里而不只是 Fly 里，规则一样：路径是 <code>myapp/preview/$ENV/</code>，清扫器会连同环境一起删掉这条路径。真正跨环境<em>共享</em>的密钥只有共享基础设施本身的凭据（Postgres 管理员 URL、Redis URL、bucket 密钥），而这些放在 CI 里，不在任何环境里。</p>
<p>Redis 那一行最容易让人怀疑，所以做了测试。从一个环境里面设一个缓存键，从另一个读，再列出共享 Redis 里的所有键：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ fly ssh console -a myapp-feature-profile-tagl-fa8832 -C &#34;python manage.py shell -c \&#34;from django.core.cache import cache; cache.set(&#39;who-am-i&#39;, &#39;tagline-branch&#39;, 600)\&#34;&#34;
</span></span><span class="line"><span class="cl">$ fly ssh console -a myapp-feature-profile-webs-dcb2e7 -C &#34;python manage.py shell -c \&#34;from django.core.cache import cache; print(cache.get(&#39;who-am-i&#39;))\&#34;&#34;
</span></span><span class="line"><span class="cl">None
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ # every key in the shared Redis
</span></span><span class="line"><span class="cl">  feature-profile-tagl-fa8832:1:who-am-i
</span></span><span class="line"><span class="cl">  ...</span></span></code></pre></div></div>
<p>同一个 Redis，同一个键名，另一个环境什么都看不到。</p>
<p>这篇文章剩下的部分，就是把 <code>$ENV</code> 盖到那八行上的脚本，以及让它保持诚实的规则。</p>

<h2 class="relative group"><code>./scripts/env up</code>
    <div id="scriptsenv-up" 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="#scriptsenv-up" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这就是整个思路——一个开发者在自己分支上运行的脚本。CI 运行的是同一个脚本；没有会各自漂移的 CI 专用路径。笔记本上只需要 <code>fly</code> 和 <code>git</code>，别的都不要：SQL 通过 <code>fly ssh</code> 送到 Postgres 应用，环境的 Redis 键和 bucket 对象在销毁前由环境自己的机器删除，所以笔记本除了一个被 gitignore 的 env 文件之外，不持有任何共享 Redis 或 bucket 的凭据。下面只留了要紧的几行：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/env up|down|name|url: one environment per branch on shared preview infrastructure</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">ORG</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_ORG</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview</span><span class="si">}</span><span class="p">;</span> <span class="nv">PG_APP</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_PG_APP</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview-pg</span><span class="si">}</span><span class="p">;</span> <span class="nv">DOMAIN</span><span class="o">=</span><span class="si">${</span><span class="nv">BASE_DOMAIN</span><span class="k">:-</span><span class="nv">example</span><span class="p">.com</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">branch</span><span class="o">=</span><span class="si">${</span><span class="nv">BRANCH</span><span class="k">:-$(</span>git rev-parse --abbrev-ref HEAD<span class="k">)</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;main&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;main has staging, not a branch environment&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">slug</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;[:upper:]/_ &#39;</span> <span class="s1">&#39;[:lower:]---&#39;</span> <span class="p">|</span> tr -cd <span class="s1">&#39;a-z0-9-&#39;</span> <span class="p">|</span> cut -c1-20<span class="k">)</span><span class="p">;</span> <span class="nv">slug</span><span class="o">=</span><span class="si">${</span><span class="nv">slug</span><span class="p">%-</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">ENV</span><span class="o">=</span><span class="si">${</span><span class="nv">ENV_OVERRIDE</span><span class="k">:-</span><span class="si">${</span><span class="nv">slug</span><span class="si">}</span><span class="p">-</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> shasum -a <span class="m">256</span> <span class="p">|</span> cut -c1-6<span class="k">)</span><span class="si">}</span>   <span class="c1"># the sweeper passes the name</span>
</span></span><span class="line"><span class="cl"><span class="nv">APP</span><span class="o">=</span><span class="s2">&#34;myapp-</span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nv">DB</span><span class="o">=</span><span class="s2">&#34;env_</span><span class="si">${</span><span class="nv">ENV</span><span class="p">//-/_</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nv">HOST</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">-dev.</span><span class="si">${</span><span class="nv">DOMAIN</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">pg<span class="o">()</span> <span class="o">{</span> fly ssh console -a <span class="s2">&#34;</span><span class="nv">$PG_APP</span><span class="s2">&#34;</span> -q -C <span class="s2">&#34;psql postgres://postgres:</span><span class="si">${</span><span class="nv">PG_PASSWORD</span><span class="si">}</span><span class="s2">@localhost:5433/postgres -At -c \&#34;</span><span class="nv">$1</span><span class="s2">\&#34;&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">exists<span class="o">()</span> <span class="o">{</span> fly apps list --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -q <span class="s2">&#34;\&#34;Name\&#34;: *\&#34;</span><span class="nv">$APP</span><span class="s2">\&#34;&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">up<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;== </span><span class="nv">$branch</span><span class="s2"> -&gt; </span><span class="nv">$ENV</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  exists <span class="o">||</span> fly apps create <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span>                         <span class="c1"># idempotent</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="k">$(</span>pg <span class="s2">&#34;SELECT 1 FROM pg_database WHERE datname=&#39;</span><span class="nv">$DB</span><span class="s2">&#39;&#34;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;1&#34;</span> <span class="o">]]</span> <span class="o">||</span> pg <span class="s2">&#34;CREATE DATABASE </span><span class="nv">$DB</span><span class="s2"> TEMPLATE seed_template&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nv">have</span><span class="o">=</span><span class="k">$(</span>fly secrets list -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -o <span class="s1">&#39;&#34;Name&#34;: *&#34;[^&#34;]*&#34;&#39;</span> <span class="p">|</span> cut -d<span class="s1">&#39;&#34;&#39;</span> -f4 <span class="o">||</span> <span class="nb">true</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  gen<span class="o">()</span> <span class="o">{</span> grep -qx <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$have</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">=</span><span class="k">$(</span>openssl rand -hex 32<span class="k">)</span><span class="s2">&#34;</span><span class="p">;</span> <span class="o">}</span>   <span class="c1"># generated once, never rotated by a later `up`</span>
</span></span><span class="line"><span class="cl">  <span class="o">{</span> gen SECRET_KEY<span class="p">;</span> gen ENCRYPTION_KEY<span class="p">;</span> gen ENV_TOKEN
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;ENV_NAME=</span><span class="nv">$ENV</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nb">echo</span> <span class="s2">&#34;ALLOWED_HOSTS=</span><span class="nv">$HOST</span><span class="s2">,</span><span class="nv">$APP</span><span class="s2">.fly.dev&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;DATABASE_URL=postgres://postgres:</span><span class="si">${</span><span class="nv">PG_PASSWORD</span><span class="si">}</span><span class="s2">@</span><span class="si">${</span><span class="nv">PG_APP</span><span class="si">}</span><span class="s2">.flycast:5432/</span><span class="si">${</span><span class="nv">DB</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... plus the shared Redis URL and bucket credentials</span>
</span></span><span class="line"><span class="cl">  <span class="o">}</span> <span class="p">|</span> fly secrets import -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --stage
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  fly deploy -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --config fly.preview.toml --remote-only --ha<span class="o">=</span><span class="nb">false</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --build-arg <span class="nv">GIT_SHA</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>git rev-parse --short HEAD<span class="k">)</span><span class="s2">&#34;</span> --build-arg <span class="nv">GIT_BRANCH</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;https://</span><span class="nv">$HOST</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">down<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> <span class="o">=</span>~ ^myapp-<span class="o">[</span>a-z0-9-<span class="o">]</span>+-<span class="o">[</span>0-9a-f<span class="o">]{</span>6<span class="o">}</span>$ <span class="o">]]</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;refusing to destroy </span><span class="nv">$APP</span><span class="s2">&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> exists<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    fly machine start -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="o">||</span> <span class="nb">true</span>                 <span class="c1"># it may be suspended</span>
</span></span><span class="line"><span class="cl">    fly ssh console -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> -q -C <span class="s2">&#34;python manage.py teardown_env&#34;</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;warning: cleanup failed; the sweeper will catch it&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">    fly apps destroy <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --yes
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  pg <span class="s2">&#34;DROP DATABASE IF EXISTS </span><span class="nv">$DB</span><span class="s2"> WITH (FORCE)&#34;</span> <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span> in up<span class="o">)</span> up <span class="p">;;</span> down<span class="o">)</span> down <span class="p">;;</span> name<span class="o">)</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$ENV</span><span class="s2">&#34;</span> <span class="p">;;</span> url<span class="o">)</span> <span class="nb">echo</span> <span class="s2">&#34;https://</span><span class="nv">$HOST</span><span class="s2">&#34;</span> <span class="p">;;</span> <span class="k">esac</span></span></span></code></pre></div></div>
<p>演示时我是从笔记本上直接跑的，而不是 push，因为这三个分支是一次性的，我不想让它们留在仓库历史里，也想盯着输出一行行出来。团队里用的时候没人会敲这条命令：下文的标签工作流跑的就是这个脚本，接下来的输出就是会落在 Actions 日志里的那些。这是它第一次在承载这套工具本身的分支上运行时打印的内容：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ ./scripts/env up
</span></span><span class="line"><span class="cl">== feature/branch-environments -&gt; feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">New app created: myapp-feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">CREATE DATABASE
</span></span><span class="line"><span class="cl">Secrets have been staged, but not set on VMs. Deploy or update machines in this app for the secrets to take effect.
</span></span><span class="line"><span class="cl">==&gt; Building image
</span></span><span class="line"><span class="cl">image size: 139 MB
</span></span><span class="line"><span class="cl">✔ Machine 1a2b3c4d5e6f78 [app] update finished: success
</span></span><span class="line"><span class="cl">https://feature-branch-envir-c65cb4-dev.example.com
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ curl -s https://feature-branch-envir-c65cb4-dev.example.com/api/version/
</span></span><span class="line"><span class="cl">{&#34;environment&#34;: &#34;feature-branch-envir-c65cb4&#34;, &#34;branch&#34;: &#34;feature/branch-environments&#34;, &#34;commit&#34;: &#34;a1b2c3d4&#34;, &#34;region&#34;: &#34;syd&#34;}</span></span></code></pre></div></div>
<p>从命令到 URL 大约两分半，大部分是镜像构建。再提交一次后在同一分支上第二次 <code>up</code>，没有打印 &ldquo;New app created&rdquo;，也没有 &ldquo;CREATE DATABASE&rdquo;，暂存了同样的密钥名而没有重新生成已生成的那些，然后重新构建：同一个 URL，新的提交。</p>
<p>里面的几个选择各值得一句话。</p>
<p><strong><code>fly.preview.toml</code> 不是 <code>fly.toml</code>。</strong> 生产把 API、WebSocket 服务器和后台 worker 作为独立的 Fly 应用和进程组来跑，各按自己的指标伸缩，那正是技术栈那篇的全部论点。分支环境不需要伸缩；它需要的是一台机器。所以预览配置只有一个进程：容器的入口脚本多了一个预览模式，先跑迁移，再启动<em>一个</em>开着 WebSocket 的 ASGI 服务器。Channels 本来就在同一个 ASGI 应用里同时路由 HTTP 和 WebSocket 流量，所以一个进程就能服务整个应用。那个模式下同步视图按 thread-sensitive 方式运行，差不多一次一个请求——正是我在生产里放弃的那笔交易，而在这里恰恰合适，因为测试分支的人不会察觉。</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="c"># fly.preview.toml (the app name comes from `fly deploy -a`)</span>
</span></span><span class="line"><span class="cl"><span class="nx">app</span> <span class="p">=</span> <span class="s1">&#39;placeholder&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nx">primary_region</span> <span class="p">=</span> <span class="s1">&#39;syd&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">processes</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">app</span> <span class="p">=</span> <span class="s2">&#34;/app/entrypoint.sh preview&#34;</span>   <span class="c"># migrate, then one ASGI server for HTTP and WebSockets</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">env</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">DJANGO_SETTINGS_MODULE</span> <span class="p">=</span> <span class="s1">&#39;myapp.settings&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">DEBUG</span> <span class="p">=</span> <span class="s1">&#39;False&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">internal_port</span> <span class="p">=</span> <span class="mi">8000</span>
</span></span><span class="line"><span class="cl">  <span class="nx">force_https</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;suspend&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_start_machines</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">min_machines_running</span> <span class="p">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">  <span class="p">[[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">checks</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">    <span class="nx">path</span> <span class="p">=</span> <span class="s1">&#39;/healthz&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">checks</span><span class="p">.</span><span class="nx">headers</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">      <span class="nx">Host</span> <span class="p">=</span> <span class="s1">&#39;127.0.0.1&#39;</span>               <span class="c"># the app&#39;s own hostname isn&#39;t known to a static file</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[[</span><span class="nx">vm</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">size</span> <span class="p">=</span> <span class="s1">&#39;shared-cpu-1x&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">memory</span> <span class="p">=</span> <span class="s1">&#39;1gb&#39;</span></span></span></code></pre></div></div>
<p><strong><code>auto_stop_machines = &quot;suspend&quot;</code>，<code>min_machines_running = 0</code>。</strong> 午饭后没人打开过的分支环境应该一分钱不花。用 suspend 而不是 stop，得到的是我<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/" >追查 504</a>之后落到的约 3 秒恢复，而不是 30 秒冷启动，而在这里就算 30 秒也能接受。三个演示环境里有两个在部署完成一分钟后就已经在 <code>fly apps list</code> 里显示 <code>suspended</code> 了。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">fly.io/apps/myapp-feature-profile-tagl-fa8832</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d26550a0a30599da.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d26550a0a30599da.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d2f15e465075b4eb.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="一个分支应用的 Fly.io 概览：SYD 的一台 shared-cpu-1x 机器，一个进程组，自己的 *.fly.dev 主机名"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_381dc63e1f9d24c0.webp"></span></div>
  <figcaption>Fly 眼中的一个分支环境：一台机器，一个进程组，一个区域。没有任何东西值得伸缩。</figcaption>
</figure>

<p><strong><code>DEBUG = False</code>，和生产同一个 settings 模块。</strong> 分支环境是一个公开 URL。在所有对测试重要的方面（真 HTTPS、真 cookie、同样的健康检查）它都应该像生产，在所有可能伤到人的方面它都应该不像生产。差异不靠单独的预览 settings 文件，而是由 <code>env up</code> 设置的密钥承载：沙箱支付密钥、没有认证服务的管理员凭据，以及打开下面所有前缀的 <code>ENV_NAME</code>。</p>
<p><strong>一个设置切分一切。</strong> <code>ENV_NAME</code> 是 settings 里唯一新增的旋钮，应用在四个地方：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">ENV_NAME</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;ENV_NAME&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">_prefix</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">ENV_NAME</span><span class="si">}</span><span class="s2">/&#34;</span> <span class="k">if</span> <span class="n">ENV_NAME</span> <span class="k">else</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">CACHES</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;KEY_PREFIX&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">ENV_NAME</span>                           <span class="c1"># every cache key</span>
</span></span><span class="line"><span class="cl"><span class="n">CHANNEL_LAYERS</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;CONFIG&#34;</span><span class="p">][</span><span class="s2">&#34;prefix&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">ENV_NAME</span><span class="si">}</span><span class="s2">:asgi&#34;</span>  <span class="c1"># Channels group and channel names</span>
</span></span><span class="line"><span class="cl"><span class="n">STORAGES</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;OPTIONS&#34;</span><span class="p">][</span><span class="s2">&#34;location&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">_prefix</span><span class="si">}</span><span class="s2">media&#34;</span>      <span class="c1"># uploads</span>
</span></span><span class="line"><span class="cl"><span class="n">STORAGES</span><span class="p">[</span><span class="s2">&#34;staticfiles&#34;</span><span class="p">][</span><span class="s2">&#34;OPTIONS&#34;</span><span class="p">][</span><span class="s2">&#34;location&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">_prefix</span><span class="si">}</span><span class="s2">static&#34;</span></span></span></code></pre></div></div>
<p>漏掉 channels 那一处，两个分支的 WebSocket 服务器就会共用组名，一个环境里发的消息会出现在另一个环境里。那是要耗掉一天的那种 bug。</p>
<p>第五处是事后读共享 Redis 才发现的。504 那篇里的启动锁——让每个镜像只有一台机器跑迁移、其他机器等待的机制——原本是以镜像引用为键的。从同一个提交构建的两个分支环境有同一个镜像。第二个会找到第一个的 &ldquo;done&rdquo; 标记，然后跳过它自己对自己那个尚未迁移的数据库的迁移。现在环境名是键的一部分，键也住在前缀下面。这是共享基础设施设计会不断产出的那类问题：所有隐含地&quot;按部署&quot;的东西都得显式地变成按环境，而在你去看之前，你不知道什么是隐含的。</p>
<p><strong>数据库是克隆的，不是从空库迁移的。</strong> 下一节。</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%95%b0%e6%8d%ae%e5%ba%93%e5%87%a0%e7%a7%92%e5%85%8b%e9%9a%86%e5%87%ba%e6%9d%a5%e7%9a%84%e6%a8%a1%e6%9d%bf" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>分支环境的数据库有三种可能。和所有人共享——这正是把我们带到这里的东西。空库，从零迁移再用 fixture 填充：确定性强，但 fixture 从来比不上真实形态的数据，而且永远陈旧十八个月。或者克隆一份生产形态的快照：真实的行数，fixture 假定总是有值的那些列里真实的 null，在空表上瞬间完成、在真表上要跑四十分钟的迁移会现出原形。</p>
<p>选第三种，第二种保留为只在 CI 跑的检查。而让第三种快到可以每个分支都做一次的，是一个大多数人忘了存在的 Postgres 功能：</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="c1">-- Nightly, on the preview cluster. Restore last night&#39;s prod backup into seed_raw,
</span></span></span><span class="line"><span class="cl"><span class="c1">-- run the anonymiser, then freeze it as a template nobody can connect to.
</span></span></span><span class="line"><span class="cl"><span class="k">ALTER</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">seed_raw</span><span class="w"> </span><span class="k">RENAME</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">seed_template</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">UPDATE</span><span class="w"> </span><span class="n">pg_database</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">datistemplate</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">,</span><span class="w"> </span><span class="n">datallowconn</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">false</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">datname</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;seed_template&#39;</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="c1">-- Per branch, in the time it takes to copy the files:
</span></span></span><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">env_feature_payments_retr_3f9a1c</span><span class="w"> </span><span class="k">TEMPLATE</span><span class="w"> </span><span class="n">seed_template</span><span class="p">;</span></span></span></code></pre></div></div>
<p><code>CREATE DATABASE ... TEMPLATE</code> 是集群内的文件级复制。几个 GB 只要几秒，而不是 <code>pg_restore</code> 的几分钟。被克隆时模板不能有打开的连接，<code>datallowconn = false</code> 强制了这一点，这也是为什么夜间任务往 <code>seed_raw</code> 里构建、最后再改名，而不是直接往活着的模板上恢复。</p>
<p>演示里模板是手工建的，而不是来自生产 dump：用 <code>fly proxy</code> 打一条通向预览集群的隧道，<code>manage.py migrate</code>，然后用仓库自带的填充命令生成一份不大但贴近真实的数据。十九兆。三个分支起来时，集群里出现了它的三个克隆，每个 19 MB，每个不到一秒：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ fly ssh console -a myapp-preview-pg -C &#34;psql ... -c &#39;\l&#39;&#34;
</span></span><span class="line"><span class="cl">             datname             | template | size
</span></span><span class="line"><span class="cl">---------------------------------+----------+-------
</span></span><span class="line"><span class="cl"> env_feature_branch_envir_c65cb4 | f        | 19 MB
</span></span><span class="line"><span class="cl"> env_feature_profile_tagl_fa8832 | f        | 19 MB
</span></span><span class="line"><span class="cl"> env_feature_profile_webs_dcb2e7 | f        | 19 MB
</span></span><span class="line"><span class="cl"> seed_template                   | t        | 19 MB</span></span></code></pre></div></div>
<p>预览集群是一个非托管的 Fly Postgres 节点，<code>shared-cpu-1x</code> 带 1 GB 卷，每月大约两美元。分支环境通过私有网络的 <code>.flycast</code> 连它；没有任何东西是公开的。</p>
<p>匿名化不是可选项。生产数据在 <code>-dev</code> 主机名上可达的那一刻，它就必须不再是生产数据：姓名、邮箱、电话、地址、自由文本、支付引用、令牌，全部在改名之前覆盖掉。匿名化失败，前一晚的模板保留，有人会收到消息。这是件小活，也正是让整个方案能被回答隐私问卷的那个人接受的那件活。</p>
<p>入口脚本在每个镜像首次启动时运行 <code>migrate</code>，所以分支自己的迁移会叠加在模板之上。这是&quot;我的迁移在生产形态的数据上能不能跑&quot;的第一次诚实测试，而且发生在任何人打开 URL 之前。</p>

<h2 class="relative group">主机名：一个通配符、一个 Worker，以及文档没告诉我的两件事
    <div id="主机名一个通配符一个-worker以及文档没告诉我的两件事" 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%bb%e6%9c%ba%e5%90%8d%e4%b8%80%e4%b8%aa%e9%80%9a%e9%85%8d%e7%ac%a6%e4%b8%80%e4%b8%aa-worker%e4%bb%a5%e5%8f%8a%e6%96%87%e6%a1%a3%e6%b2%a1%e5%91%8a%e8%af%89%e6%88%91%e7%9a%84%e4%b8%a4%e4%bb%b6%e4%ba%8b" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>每个环境都需要一个公开的 HTTPS 主机名，而&quot;公开&quot;正是重点：拿着手机的测试、另一个网络里的设计师、不肯装 VPN 的产品经理。Fly 免费给每个应用一个 <code>myapp-$ENV.fly.dev</code>，光靠它也能用。但 <code>*.fly.dev</code> 主机名有三个问题：发出去难看，前面没有 Cloudflare 所以你在生产上的所有防护都不生效，而如果想放到自己的域名下，就得每个环境一条 DNS 记录加一张证书，清扫器又多一样要清的东西。</p>
<p>把这些都去掉的版本，是一个通配符加一个 Cloudflare Worker。我原计划用 <code>*.dev.example.com</code>。两件事挡住了它，而且在你做同样的计划之前，两件都值得知道。</p>
<p><strong>Universal SSL 只覆盖一层通配符。</strong> Cloudflare 的免费证书签发给 <code>example.com</code> 和 <code>*.example.com</code>。它不覆盖 <code>*.dev.example.com</code>；二级通配符需要每月 10 美元的 Advanced Certificate Manager。对 <code>feature-branch-envir-c65cb4.dev.example.com</code> 的第一个请求在我的任何东西运行之前就死于 SSL 握手失败。解法是把环境留在第一层标签里：<code>feature-branch-envir-c65cb4-dev.example.com</code>。它被已经存在的证书覆盖，也被已经存在的、已代理的 <code>*.example.com</code> 记录覆盖——就是我为<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/#the-spike-was-a-red-herring" >扫描杜撰子域名的爬虫</a>设的那个黑洞记录。所以一个环境完全不需要 DNS。</p>
<p><strong>Redirect Rules 在 Workers 之前执行。</strong> 路由配好之后，每个 <code>-dev</code> 主机名都在 Worker 没有运行的情况下 301 到了 www 首页。黑洞是一条区域级的重定向规则，&ldquo;其他任何子域名 → www&rdquo;，而 Cloudflare 的重定向阶段在 Workers 阶段之前执行。规则需要加一个子句：<code>and not ends_with(http.host, &quot;-dev.example.com&quot;)</code>。部署脚本在 <code>up</code> 时加上、<code>down</code> 时去掉，因为手工改过的规则正是会被遗忘的东西。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">dash.cloudflare.com/…/example.com/rules/redirect-rules/…/edit</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_4f6c3fdde94f28d6.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_4f6c3fdde94f28d6.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_38e5e373828b8373.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="Cloudflare 控制台里的重定向规则，表达式以 -dev 例外结尾"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_71d546da5b5668b6.webp"></span></div>
  <figcaption>脚本动过手之后的黑洞重定向。区域下的一切仍然弹回 www，只有以 <code>-dev.example.com</code> 结尾的主机名会漏过去交给 Worker。</figcaption>
</figure>

<p>DNS 这边就是那条本来就在的记录：</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">dash.cloudflare.com/…/example.com/dns/records</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_6d425bb8f7651010.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_6d425bb8f7651010.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_2c615b9e52444abd.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="按通配符过滤的区域 DNS 记录：一条区域通配符的已代理 A 记录，指向 192.0.2.1"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_952422604a6c8304.webp"></span></div>
  <figcaption>唯一涉及的 DNS 记录，而且早于这篇文章。一个指向占位地址的已代理通配符：Cloudflare 应答，接下来的事交给规则和 Workers。</figcaption>
</figure>

<p>这两件事解决后，路由就是 <code>*-dev.example.com/*</code> 上的一个 Worker：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="c1">// dev-router.js: &lt;env&gt;-dev.example.com -&gt; myapp-&lt;env&gt;.fly.dev
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">DEV_SUFFIX</span> <span class="o">=</span> <span class="s2">&#34;-dev.example.com&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">ENV_NAME</span> <span class="o">=</span> <span class="sr">/^[a-z0-9-]+-[0-9a-f]{6}$/</span><span class="p">;</span>   <span class="c1">// slug-hash, as scripts/env names them
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="k">default</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">async</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="nx">request</span><span class="p">.</span><span class="nx">url</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">url</span><span class="p">.</span><span class="nx">hostname</span><span class="p">.</span><span class="nx">endsWith</span><span class="p">(</span><span class="nx">DEV_SUFFIX</span><span class="p">))</span> <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="s2">&#34;not a dev hostname&#34;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">status</span><span class="o">:</span> <span class="mi">404</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">env</span> <span class="o">=</span> <span class="nx">url</span><span class="p">.</span><span class="nx">hostname</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="o">-</span><span class="nx">DEV_SUFFIX</span><span class="p">.</span><span class="nx">length</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">ENV_NAME</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">env</span><span class="p">))</span> <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="sb">`no such environment: </span><span class="si">${</span><span class="nx">env</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span> <span class="p">{</span> <span class="nx">status</span><span class="o">:</span> <span class="mi">404</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nx">url</span><span class="p">.</span><span class="nx">hostname</span> <span class="o">=</span> <span class="sb">`myapp-</span><span class="si">${</span><span class="nx">env</span><span class="si">}</span><span class="sb">.fly.dev`</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">upstream</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Request</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="nx">request</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">upstream</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Forwarded-Host&#34;</span><span class="p">,</span> <span class="nx">request</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s2">&#34;host&#34;</span><span class="p">)</span> <span class="o">??</span> <span class="s2">&#34;&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">upstream</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">out</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="nx">res</span><span class="p">.</span><span class="nx">body</span><span class="p">,</span> <span class="nx">res</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">out</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Robots-Tag&#34;</span><span class="p">,</span> <span class="s2">&#34;noindex, nofollow&#34;</span><span class="p">);</span>   <span class="c1">// a half-finished branch is not for search engines
</span></span></span><span class="line"><span class="cl">    <span class="nx">out</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Dev-Environment&#34;</span><span class="p">,</span> <span class="nx">env</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">out</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl"><span class="p">};</span></span></span></code></pre></div></div>
<p>Cloudflare 在通配符上终结 TLS，Worker 从标签算出 Fly 主机名，到机器那一跳由 Fly 自己的 <code>*.fly.dev</code> 证书覆盖。WebSocket 升级原样穿过 <code>fetch</code>。而由于路由在区域上，Cloudflare 为生产做的一切在这里都可用：WAF 规则、限速、bot 防护模式，以及 Access。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">dash.cloudflare.com/…/example.com/workers</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_bd62e960f954de34.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_bd62e960f954de34.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_9996f094fe4379cd.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="该区域的 Cloudflare Workers Routes 页面：一条路由 *-dev.example.com/*，绑定到 dev-router Worker"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_78420fc686a1e6e3.webp"></span></div>
  <figcaption>一个环境在 Cloudflare 侧的全部：一个路由模式，一个 Worker。这个控制台里任何地方都没有出现环境的名字。</figcaption>
</figure>

<p>三个分支同时在线，一个 Worker，以及每个主机名的应答：</p>
<table>
	<thead>
			<tr>
					<th>主机名</th>
					<th>HTTP</th>
					<th>路由到</th>
					<th><code>branch</code></th>
					<th><code>commit</code></th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>feature-branch-envir-c65cb4-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-branch-envir-c65cb4.fly.dev</code></td>
					<td><code>feature/branch-environments</code></td>
					<td><code>a1b2c3d4</code></td>
			</tr>
			<tr>
					<td><code>feature-profile-tagl-fa8832-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-profile-tagl-fa8832.fly.dev</code></td>
					<td><code>feature/profile-tagline</code></td>
					<td><code>e5f6a7b8</code></td>
			</tr>
			<tr>
					<td><code>feature-profile-webs-dcb2e7-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-profile-webs-dcb2e7.fly.dev</code></td>
					<td><code>feature/profile-website</code></td>
					<td><code>c9d0e1f2</code></td>
			</tr>
			<tr>
					<td><code>not-an-env-dev.example.com</code></td>
					<td>404</td>
					<td>无；Worker 拒绝了这个标签</td>
					<td></td>
					<td></td>
			</tr>
	</tbody>
</table>
<p>每个 200 都带着 Worker 加上的 <code>X-Robots-Tag: noindex, nofollow</code> 和 <code>X-Dev-Environment: &lt;env&gt;</code>。那个 404 是 Worker 自己给的，根本没问过 Fly。</p>
<p>Cloudflare Access 是把&quot;公开&quot;变成&quot;该到的人到得了&quot;的东西。在 <code>*-dev.example.com</code> 上建一个应用，策略是&quot;任何 @example.com 邮箱&rdquo;，浏览器访客看一次登录页，之后就再也不用想它。设计师、PM 和用笔记本的测试就都覆盖了。演示里我没加；那些环境带着 <code>noindex</code> 在线了一小时，上面除了种子数据什么都没有。</p>
<p>它覆盖不了移动端，因为原生应用完不成 Access 的登录跳转。两条路。Access 支持服务令牌（一对 <code>CF-Access-Client-Id</code> / <code>CF-Access-Client-Secret</code> 请求头）绕过登录，移动应用的开发菜单可以为 dev 主机名带上一对。或者更简单，为 <code>/api/</code> 加一条 Access 放行，让 Django 拒绝任何既没有已登录用户、也没有该环境 <code>ENV_TOKEN</code>——就是 <code>env up</code> 专门为这个环境生成的那个——的请求。无论哪种，移动端这边都是一个带两个字段的开发菜单页，基础 URL 和令牌，测试人员已经装好的应用现在就和那个分支通信了。不需要新构建。<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-02-22-self-hosted-ota-updates/" >空中更新（OTA）通道</a>继续指向应用构建时的那个；API 基础 URL 是运行时设置，不是构建时设置，本来就该如此。</p>
<p>认证是唯一有意共享的共享服务。身份不分环境；测试人员希望在每个分支上都用同一个账号登录。认证服务上一个授权域名里有 <code>example.com</code> 的 dev 项目覆盖所有 <code>-dev</code> 主机名，每个环境都对着它验证 ID 令牌。每个环境数据库里的用户表都是模板用户的副本，所以模板里有的账号，测试人员在哪儿都有。</p>

<h2 class="relative group">用 GitHub Actions 自动化预览环境：事件进来，环境出去
    <div id="用-github-actions-自动化预览环境事件进来环境出去" 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="#%e7%94%a8-github-actions-%e8%87%aa%e5%8a%a8%e5%8c%96%e9%a2%84%e8%a7%88%e7%8e%af%e5%a2%83%e4%ba%8b%e4%bb%b6%e8%bf%9b%e6%9d%a5%e7%8e%af%e5%a2%83%e5%87%ba%e5%8e%bb" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>上面的一切手工来做，只有你一个人时没问题。一旦有了第二个开发者，它就必须因为 git 里发生了什么而发生，而不是因为有人记得。整套自动化是四个工作流、一个仓库设置、一个规则集和一个 GitHub environment，其中每一个都把某个 git 事件映射到对开发者手工运行的同一个 <code>scripts/env</code> 的调用。没有会漂移的 CI 专用路径。</p>
<table>
	<thead>
			<tr>
					<th>Git 事件</th>
					<th>运行什么</th>
					<th>做什么</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>PR 被打上 <code>env</code> 标签</td>
					<td><code>branch-env.yml</code> → <code>scripts/env up</code></td>
					<td>创建该分支的环境，在 PR 上贴出 URL</td>
			</tr>
			<tr>
					<td>向已打标签的 PR 的分支 push</td>
					<td><code>branch-env.yml</code> → <code>scripts/env up</code></td>
					<td>原地更新</td>
			</tr>
			<tr>
					<td>标签被移除、PR 关闭或合并</td>
					<td><code>branch-env.yml</code> → <code>scripts/env down</code></td>
					<td>环境清理自己那一片，然后被销毁</td>
			</tr>
			<tr>
					<td>删除分支</td>
					<td><code>branch-env.yml</code> → <code>scripts/env down</code></td>
					<td>兜底：手动拉起、从来没开过 PR 的分支；仓库设置 <em>delete branch on merge</em> 让合并也触发它</td>
			</tr>
			<tr>
					<td>向其他任何分支 push</td>
					<td>什么都不做</td>
					<td>备份用的 push 或一个 spike，在有人想看之前不花一分钱</td>
			</tr>
			<tr>
					<td>向 <code>main</code> 的 pull request</td>
					<td><code>migrations-check.yml</code></td>
					<td>合入 <code>main</code> 后迁移图只有一个叶子；从空库能应用完整链条</td>
			</tr>
			<tr>
					<td>push <code>v*</code> 标签</td>
					<td><code>release.yml</code></td>
					<td>由标签生成 staging 环境；评审人批准后把同一镜像晋升到生产并销毁 staging</td>
			</tr>
			<tr>
					<td>每晚 02:00</td>
					<td><code>sweep-envs.yml</code> → <code>scripts/sweep-envs</code></td>
					<td>销毁分支已不存在或闲置一周的一切</td>
			</tr>
	</tbody>
</table>

<h3 class="relative group">标签、push 与 delete 工作流
    <div id="标签push-与-delete-工作流" 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%a0%87%e7%ad%bepush-%e4%b8%8e-delete-%e5%b7%a5%e4%bd%9c%e6%b5%81" aria-label="锚点">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/branch-env.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">branch environment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">types</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">labeled, unlabeled, synchronize, closed]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">delete</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">inputs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">branch</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">description</span><span class="p">:</span><span class="w"> </span><span class="nt">branch to bring up without a PR, required</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </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="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">env-${{ github.head_ref || github.event.ref || inputs.branch }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">FLY_API_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.FLY_PREVIEW_TOKEN }}  </span><span class="w"> </span><span class="c"># org-scoped deploy token; a separate preview org keeps it away from prod</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">PG_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.PREVIEW_PG_PASSWORD }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># ... the shared Redis URL and bucket credentials, the same way</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="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">up</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># the gate: a PR carrying the `env` label, or someone asking for a branch by hand</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      github.event_name == &#39;workflow_dispatch&#39; ||
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;pull_request&#39; &amp;&amp; github.event.action != &#39;closed&#39;
</span></span></span><span class="line"><span class="cl"><span class="sd">        &amp;&amp; contains(github.event.pull_request.labels.*.name, &#39;env&#39;))</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">ref</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${{ github.head_ref || inputs.branch }}&#34;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">BRANCH</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${{ github.head_ref || inputs.branch }}&#34;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/env up &amp;&amp; echo &#34;url=$(scripts/env url)&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">github.event_name == &#39;pull_request&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">marocchino/sticky-pull-request-comment@v2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">header</span><span class="p">:</span><span class="w"> </span><span class="l">env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">message</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Branch environment: ${{ steps.env.outputs.url }}&#34;</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="w">  </span><span class="nt">down</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># label removed, labelled PR closed or merged, or the branch itself deleted</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;pull_request&#39; &amp;&amp;
</span></span></span><span class="line"><span class="cl"><span class="sd">        ((github.event.action == &#39;closed&#39; &amp;&amp; contains(github.event.pull_request.labels.*.name, &#39;env&#39;)) ||
</span></span></span><span class="line"><span class="cl"><span class="sd">         (github.event.action == &#39;unlabeled&#39; &amp;&amp; github.event.label.name == &#39;env&#39;))) ||
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;delete&#39; &amp;&amp; github.event.ref_type == &#39;branch&#39;)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;${{ github.head_ref || github.event.ref }}&#34; scripts/env down</span></span></span></code></pre></div></div>
<p>闸门是 <code>env</code> 标签，这也是我最想据理力争的部分。没有闸门，每个分支都要花掉一个应用和一份数据库克隆：某人为了备份笔记本而 push 的分支、明天就会被 squash 的 spike、机器人凌晨三点开的依赖升级。所以光 push 什么都不发生，光开 PR 也什么都不发生。给 PR 加上 <code>env</code>，环境就起来；标签在的时候每次 push 都更新它；移除标签、关闭 PR 或合并，它就下线。选择加入仍然是一个 GitHub 事件，而不是向某个人提请求，而且这一下点击是由唯一知道有没有人要看的那个人做的。置顶评论就是用户界面：给 PR 打标签，一条带 URL 的评论出现，可以发给任何人，而且每次 push 都在原地更新，不会堆叠。还没有 PR 就想拉起来的分支交给 <code>workflow_dispatch</code>，其余的交给笔记本上的 <code>./scripts/env up</code>。</p>
<p>还有两种闸门值得知道。分支名过滤（<code>branches: ['feature/**', 'fix/**']</code>）不需要 PR，但每个功能分支都要付钱。&ldquo;所有非草稿 PR&quot;最自动，如果你的团队开 PR 开得晚、只在准备好时才开，那是正确的选择。我选标签，是因为它把花不花钱的决定交给了有上下文的人，也因为它可以收回而不用关掉任何东西。一个我喜欢的副作用：来自 fork 的 <code>pull_request</code> 运行拿不到 secrets，所以 fork 拉不起环境。</p>
<p><code>concurrency</code> 比看起来更重要。对同一分支的一连串快速 push 会取消之前的部署而不是让它们赛跑；对同一应用同时跑两个 <code>fly deploy</code>，结局是其中一个输掉。</p>
<p>有过 PR 的东西，不管合没合并，拆除都由 <code>closed</code> 负责。<code>delete</code> 事件是给用 <code>workflow_dispatch</code> 拉起来、最终也没开 PR 的分支兜底的，而它只在分支真的被删除时才触发。那是一个仓库设置，<em>Automatically delete head branches</em>，默认关闭。打开之后，点 Merge 会关闭 PR 并删除分支，两个事件中的任何一个单独都会跑 <code>env down</code>。合并按钮就是拆除。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">gh api -X PATCH repos/org/myapp -f <span class="nv">delete_branch_on_merge</span><span class="o">=</span>true</span></span></code></pre></div></div>
<p><code>FLY_PREVIEW_TOKEN</code> 是这个文件里危险的一行。它能创建和销毁应用，如果 <code>$APP</code> 变成了分支环境之外的任何东西，它就能销毁生产。两道防护，我两道都留着：令牌限定在一个单独的 Fly 组织里，从字面上就看不到生产组织（演示里为了省去给新组织绑定账单的步骤，我用了生产组织，只靠第二道防护）；以及 <code>down()</code> 拒绝任何不匹配 <code>slug-hash</code> 模式的应用名。</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="#%e5%88%86%e6%94%af%e7%8e%af%e5%a2%83%e6%97%a0%e6%b3%95%e6%9b%bf%e8%87%aa%e5%b7%b1%e5%81%9a%e7%9a%84%e6%a3%80%e6%9f%a5" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>一个分支只看得见自己的迁移，所以真正要紧的检查跑在 pull request 上，针对合入 <code>main</code> 之后的状态，也是 <code>main</code> 要求的唯一状态检查：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/migrations-check.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">migrations</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</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="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">check</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:18</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="nt">ci, POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">ci }</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;5432:5432&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">options</span><span class="p">:</span><span class="w"> </span>--<span class="l">health-cmd pg_isready --health-interval 5s --health-retries 10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DATABASE_URL</span><span class="p">:</span><span class="w"> </span><span class="l">postgres://postgres:ci@localhost:5432/ci</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SECRET_KEY</span><span class="p">:</span><span class="w"> </span><span class="l">ci-only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-python@v5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">python-version</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;3.14&#39;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">pip install -r requirements.txt</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Merge main into the branch (a conflict here fails the check too)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          git config user.email ci@example.com &amp;&amp; git config user.name ci
</span></span></span><span class="line"><span class="cl"><span class="sd">          git merge --no-edit origin/main</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">One leaf in the migration graph</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py makemigrations --check --dry-run</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">The full chain applies from nothing</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py migrate --noinput</span></span></span></code></pre></div></div>
<p>三十秒。正是它把 <code>0042</code> / <code>0042</code> 的碰撞从发布日的意外变成了第二个开发者 PR 上的一个红叉；下面的迁移一节会展示它真的触发。</p>

<h3 class="relative group"><code>main</code> 上的规则
    <div id="main-上的规则" 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="#main-%e4%b8%8a%e7%9a%84%e8%a7%84%e5%88%99" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>如果分支能活一个月，或者有人直接往 <code>main</code> 上 push，上面的一切都撑不住。这些规则与其说是保护，不如说是为了让环境保持小、让迁移保持新：</p>
<ul>
<li><strong><code>main</code> 受保护且随时可部署。</strong> 只通过 PR 合并，且 <code>migrations</code> 检查<em>针对当前的 <code>main</code></em> 是绿的（严格状态检查，所以上周绿过的 PR 在 <code>main</code> 前进后要重跑）。线性历史，禁止 force-push，禁止删除。</li>
<li><strong>功能分支要短。</strong> 开了两周的分支，环境落后 <code>main</code> 两周，迁移和别人碰撞的概率也多了两周。一个月的功能藏在开关后面分块合入 <code>main</code>。长寿的是开关，不是分支。</li>
<li><strong>一个分支，一个环境，一个数据库。</strong> 命名已经强制了。</li>
<li><strong>只有发布标签能上生产。</strong> 镜像从标签构建一次；staging 和生产跑的是那个镜像，不重新构建。</li>
<li><strong>动了 <code>migrations/</code> 的 PR 由另外两人之一评审。</strong> 见下文。</li>
</ul>
<p>写成规则集，用 <code>gh api -X POST repos/org/myapp/rulesets --input ruleset-main.json</code> 应用一次：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;main&#34;</span><span class="p">,</span> <span class="nt">&#34;target&#34;</span><span class="p">:</span> <span class="s2">&#34;branch&#34;</span><span class="p">,</span> <span class="nt">&#34;enforcement&#34;</span><span class="p">:</span> <span class="s2">&#34;active&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;conditions&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;ref_name&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;include&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;~DEFAULT_BRANCH&#34;</span><span class="p">],</span> <span class="nt">&#34;exclude&#34;</span><span class="p">:</span> <span class="p">[]</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;rules&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;deletion&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;non_fast_forward&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;required_linear_history&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;pull_request&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;parameters&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;required_approving_review_count&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;dismiss_stale_reviews_on_push&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="nt">&#34;required_review_thread_resolution&#34;</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;required_status_checks&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;parameters&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;strict_required_status_checks_policy&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="nt">&#34;required_status_checks&#34;</span><span class="p">:</span> <span class="p">[</span> <span class="p">{</span> <span class="nt">&#34;context&#34;</span><span class="p">:</span> <span class="s2">&#34;check&#34;</span> <span class="p">}</span> <span class="p">]</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>一个诚实的褶皱。这个仓库是 GitHub Free 计划下的私有仓库，而在 Free 上，分支保护和规则集只对公开仓库可用；API 的回答是 <code>403 Upgrade to GitHub Pro or make this repository public</code>。<code>delete_branch_on_merge</code> 设置、environment 和工作流在 Free 上都能用。所以对于私有 Free 仓库上的小团队，上面的规则集是一种约定：<code>migrations</code> 工作流照样跑、照样变红，但没有东西会把合并按钮变灰。这是一个每月 4 美元的决定，我会在第二个人能合并的那一刻就做。</p>
<p>没有 <code>develop</code> 分支，没有 <code>release/*</code> 分支。Git-flow 的设计前提是集成昂贵、必须成批做。分支环境让集成变得便宜，成批的必要就消失了。</p>

<h3 class="relative group">Staging 是标签的环境，生产是一次晋升
    <div id="staging-是标签的环境生产是一次晋升" 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="#staging-%e6%98%af%e6%a0%87%e7%ad%be%e7%9a%84%e7%8e%af%e5%a2%83%e7%94%9f%e4%ba%a7%e6%98%af%e4%b8%80%e6%ac%a1%e6%99%8b%e5%8d%87" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>Staging 复用整套机制。<code>main</code> 上的一个 <code>v*</code> 标签用分支名 <code>staging/v1.42.0</code> 运行 <code>scripts/env up</code>，命名把它变成 <code>staging-v1-42-0-&lt;hash&gt;</code>：同样从模板克隆的数据库，同样闲置即 suspend 的机器，同样的主机名方案。区别在于之后发生的事：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/release.yml (abridged)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">tags</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;v*&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">staging</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;staging/${GITHUB_REF_NAME}&#34; scripts/env up</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">echo &#34;image=$(fly image show -a &#34;myapp-$(BRANCH=staging/$GITHUB_REF_NAME scripts/env name)&#34; --json | jq -r &#39;…&#39;)&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">production</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="l">staging</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w"> </span><span class="l">production       </span><span class="w"> </span><span class="c"># required reviewer on this environment = the promotion gate</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">fly deploy --config fly.api.toml --image &#34;${{ needs.staging.outputs.image }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">fly deploy --config fly.ws.toml  --image &#34;${{ needs.staging.outputs.image }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;staging/${GITHUB_REF_NAME}&#34; scripts/env down</span></span></span></code></pre></div></div>
<p><code>production</code> 作业等待一个设有必要评审人的 GitHub environment。有人看过 staging，点 Approve，生产就按引用收到 staging 跑过的<em>同一个镜像</em>，不是重新构建。最后一步销毁 staging，因为它的任务完成了。如果没人批准，发布候选就是被否了，而当它的&quot;分支&rdquo;（<code>staging/v1.42.0</code> 从未在 origin 上存在过）通不过存活检查时，清扫器会把 staging 清掉。没有清扫器能核实的理由，任何东西都不许存在。</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="#%e6%af%8f%e6%99%9a%e7%9a%84%e6%b8%85%e6%89%ab%e5%99%a8" aria-label="锚点">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/sweep-envs.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">schedule</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">cron</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;0 14 * * *&#39;</span><span class="w">     </span><span class="c"># 02:00 NZST</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">sweep</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/sweep-envs</span></span></span></code></pre></div></div>
<p><code>fetch-depth: 0</code> 是因为清扫器需要每个远程分支及其最后提交日期，而不是 Actions 默认检出的那一个提交。<code>workflow_dispatch</code> 是为了事故之后能手工跑一次。</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="#%e5%bc%80%e5%8f%91%e8%80%85%e5%ae%9e%e9%99%85%e8%a6%81%e5%81%9a%e7%9a%84%e4%ba%8b" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>push 一个分支，开 PR，想让人看的时候打上 <code>env</code> 标签。清单就这么长。三分钟内 PR 上出现一个 URL，每次 push 更新，PR 合并或关闭后消失。如果和队友撞了，迁移检查会比任何人都先告诉他们。没人去抢 staging，因为没有 staging 可抢；没人去拆任何东西，因为合并就是拆除。</p>
<p>而搭起这套东西的人，之后要为每个分支做什么？什么都不用。共享的 Postgres、Redis 和 bucket 开通一次，密钥放进 Actions 一次，此后反复要做的事只剩周一看一眼清扫器的日志。没有工单队列，没有要喊的机器人，没有要提示的对象。</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%e4%bf%a1%e4%bb%bb%e6%8b%86%e9%99%a4%e7%9a%84%e6%8b%86%e9%99%a4" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><code>env down</code> 是常规情况，有意思的地方在于<em>谁</em>来清理。Redis 键和 bucket 对象属于共享服务，而跑 <code>down</code> 的笔记本两者的凭据都不需要，因为环境自己的机器已经有了，也已经知道自己的前缀。所以 <code>down</code> 先把 suspend 的机器唤醒，在上面跑一条小小的清理命令，然后才销毁应用、删除数据库。环境自己收拾自己：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ BRANCH=feature/profile-website ./scripts/env down
</span></span><span class="line"><span class="cl">redis: deleted 0 keys under feature-profile-webs-dcb2e7:*
</span></span><span class="line"><span class="cl">bucket: deleted 278 objects under feature-profile-webs-dcb2e7/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-profile-webs-dcb2e7
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ curl -s -o /dev/null -w &#39;%{http_code}\n&#39; https://feature-profile-webs-dcb2e7-dev.example.com/api/version/
</span></span><span class="line"><span class="cl">530</span></span></code></pre></div></div>
<p>那个 530 再次印证了关于协调的那一点：Worker 仍在路由这个主机名，Fly 那边什么都没有，而没有人需要通知 Cloudflare。</p>
<p>这条清理命令没有 <code>ENV_NAME</code> 就拒绝运行，因为前缀为空时它会把整个共享 bucket 清空。它之所以做成应用里的一条命令、而不是 shell 脚本里的三行，全部理由就是这道防护。</p>
<p>环境照样会泄漏。GitHub Actions 故障期间有分支被删除。开发者为了做个试验在笔记本上跑了 <code>env up</code>，从来没 push。<code>fly apps destroy</code> 偶发失败，被 <code>|| true</code> 吞掉。三个月后有四十个应用，和一张让人问&quot;发生了什么&quot;的账单。</p>
<p>所以有第二道机制，它不信任第一道：上表里那个每晚的清扫器。它列出所有 <code>myapp-*</code> 应用，弄清每个属于哪个分支，销毁分支已不存在或七天没有 push 的那些。闲置一周的分支不需要一个活着的 URL；下一次 push 两分钟就把它重建出来。哈希不可逆，所以清扫器为每个活着的分支算出环境名，销毁不在这个集合里的所有应用，再删掉后面没有应用的任何 <code>env_*</code> 数据库：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/sweep-envs: nightly</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">ORG</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_ORG</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview</span><span class="si">}</span><span class="p">;</span> <span class="nv">TTL_DAYS</span><span class="o">=</span><span class="si">${</span><span class="nv">TTL_DAYS</span><span class="k">:-</span><span class="nv">7</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">cutoff</span><span class="o">=</span><span class="k">$((</span> <span class="k">$(</span>date +%s<span class="k">)</span> <span class="o">-</span> TTL_DAYS*86400 <span class="k">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">git fetch --prune --quiet origin
</span></span><span class="line"><span class="cl"><span class="nv">live</span><span class="o">=</span><span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="nv">IFS</span><span class="o">=</span><span class="s1">$&#39;\t&#39;</span> <span class="nb">read</span> -r ts branch<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;main&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="o">((</span> ts &gt;<span class="o">=</span> cutoff <span class="o">))</span> <span class="o">||</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="nv">live</span><span class="o">+=</span><span class="s2">&#34;</span><span class="k">$(</span><span class="nv">BRANCH</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> scripts/env name<span class="k">)</span><span class="s2">&#34;</span><span class="s1">$&#39;\n&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span> &lt; &lt;<span class="o">(</span>git <span class="k">for</span>-each-ref --format<span class="o">=</span><span class="s1">&#39;%(committerdate:unix)%09%(refname:short)&#39;</span> refs/remotes/origin <span class="p">|</span> sed <span class="s1">&#39;s#^\([0-9]*\)\torigin/#\1\t#&#39;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">apps</span><span class="o">=</span><span class="k">$(</span>fly apps list --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -o <span class="s1">&#39;&#34;Name&#34;: *&#34;myapp-[^&#34;]*&#34;&#39;</span> <span class="p">|</span> cut -d<span class="s1">&#39;&#34;&#39;</span> -f4<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> app in <span class="nv">$apps</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nv">env</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">app</span><span class="p">#myapp-</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> <span class="o">=</span>~ ^<span class="o">[</span>a-z0-9-<span class="o">]</span>+-<span class="o">[</span>0-9a-f<span class="o">]{</span>6<span class="o">}</span>$ <span class="o">]]</span> <span class="o">||</span> <span class="k">continue</span>       <span class="c1"># not a branch environment (e.g. myapp-preview-pg)</span>
</span></span><span class="line"><span class="cl">  grep -qx <span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$live</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;sweeping </span><span class="nv">$app</span><span class="s2">: branch gone or idle &gt; </span><span class="si">${</span><span class="nv">TTL_DAYS</span><span class="si">}</span><span class="s2">d&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nv">ENV_OVERRIDE</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> scripts/env down <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ...then any env_* database with no app: DROP DATABASE.</span></span></span></code></pre></div></div>
<p>演示的分支从来没 push 过（就是上面那次笔记本上的运行），所以在清扫器看来它们全都不存在了；有人从笔记本上拉起来、之后再也没 push 的 spike，一周后正是这个状态。它把剩下的两个拆了，包括 tagline 环境写下的 Redis 键：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ ./scripts/sweep-envs
</span></span><span class="line"><span class="cl">sweeping myapp-feature-branch-envir-c65cb4: branch gone or idle &gt; 7d
</span></span><span class="line"><span class="cl">redis: deleted 0 keys under feature-branch-envir-c65cb4:*
</span></span><span class="line"><span class="cl">bucket: deleted 322 objects under feature-branch-envir-c65cb4/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">sweeping myapp-feature-profile-tagl-fa8832: branch gone or idle &gt; 7d
</span></span><span class="line"><span class="cl">redis: deleted 3 keys under feature-profile-tagl-fa8832:*
</span></span><span class="line"><span class="cl">bucket: deleted 278 objects under feature-profile-tagl-fa8832/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-profile-tagl-fa8832
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ fly apps list --org myapp-preview | grep myapp-
</span></span><span class="line"><span class="cl"> myapp-preview-pg │ myapp-preview │ deployed</span></span></code></pre></div></div>
<p>（一件小事咬了我一口：macOS 自带的是 bash 3，没有关联数组。清扫器的第一版用了关联数组，在我的笔记本上死在 <code>declare -A</code> 上。上面这版用换行分隔的列表，在哪儿都能跑。）</p>
<p>费用是令人愉快的部分。suspend 的机器只付 rootfs，每月几美分。数据库是已经付过钱的卷上的 19 MB。Redis 用的是按量付费的套餐，每十万条命令 20 美分，在分支测试的量级上四舍五入等于零。bucket 前缀按存了多少付。共享 Postgres 节点是唯一的固定项，每月约两美元。二十个活跃分支的花费大致等于一台常开的 <code>shared-cpu-1x</code>，而清扫器把它维持在二十而不是两百。整个演示——三个环境一小时，加上共享部件——比我期间喝的咖啡还便宜。</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="#%e8%b7%a8%e5%88%86%e6%94%af%e7%9a%84%e8%bf%81%e7%a7%bb%e6%b2%a1%e6%9c%89%e5%85%b1%e4%ba%ab%e6%95%b0%e6%8d%ae%e5%ba%93%e6%9d%a5%e9%a2%84%e8%ad%a6" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>当每个开发者端到端负责一个功能时，每个分支里都有一个迁移。这让本节成为重要的一节。</p>
<p>离开共享环境世界时，人们会漏掉一件事。所有人都往同一个 dev 数据库部署时，迁移冲突会立刻、痛苦地浮现，但它们<em>浮现了</em>。一个迁移跑了，下一次部署失败了，有人当天下午修好。每个分支一个数据库之后，每个迁移在自己的环境里都完美运行，两个迁移第一次相遇是在第二个合入 <code>main</code> 的时候。做得不小心，冲突就从便宜的 dev 挪到了不便宜的发布。</p>
<p>四条规则，全都能由 CI 强制，所以没人需要记住它们。</p>
<p><strong>1. 针对合入 <code>main</code> 后的状态，迁移图有两个叶子时 CI 失败。</strong> <code>makemigrations --check</code> 能抓到模型和迁移不一致，但要紧的冲突是两个分支都加了 <code>0042_*</code>。要对着合入 <code>main</code> 的分支检查，而不是分支本身：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git fetch origin main
</span></span><span class="line"><span class="cl">git merge --no-commit --no-ff origin/main <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;merge conflict&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">python manage.py makemigrations --check --dry-run
</span></span><span class="line"><span class="cl">python manage.py migrate --plan 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> grep -q <span class="s2">&#34;Conflicting migrations&#34;</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">1</span></span></span></code></pre></div></div>
<p>这一个是我故意让它发生的。三个演示分支里有两个，<code>feature/profile-tagline</code> 和 <code>feature/profile-website</code>，各用一个迁移给用户资料模型加了一个字段，每个环境在启动时应用了自己的。新列只出现在该出现的地方：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">env_feature_branch_envir_c65cb4      (no new column)
</span></span><span class="line"><span class="cl">env_feature_profile_tagl_fa8832      tagline
</span></span><span class="line"><span class="cl">env_feature_profile_webs_dcb2e7      website</span></span></code></pre></div></div>
<p>两个迁移都是 <code>0042_*</code>。两个分支互相看不见对方的，所以两个环境都不会告诉你有问题。把一个合入另一个，检查就会说：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ git merge --no-commit --no-ff feature/profile-tagline
</span></span><span class="line"><span class="cl">$ python manage.py makemigrations --check --dry-run
</span></span><span class="line"><span class="cl">CommandError: Conflicting migrations detected; multiple leaf nodes in the migration graph:
</span></span><span class="line"><span class="cl">  (0042_profile_tagline, 0042_profile_website in accounts).
</span></span><span class="line"><span class="cl">To fix them run &#39;python manage.py makemigrations --merge&#39;
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ python manage.py makemigrations --merge --noinput
</span></span><span class="line"><span class="cl">Created new merge migration accounts/migrations/0043_merge_20260919_0324.py
</span></span><span class="line"><span class="cl">    dependencies = [
</span></span><span class="line"><span class="cl">        (&#39;accounts&#39;, &#39;0042_profile_tagline&#39;),
</span></span><span class="line"><span class="cl">        (&#39;accounts&#39;, &#39;0042_profile_website&#39;),
</span></span><span class="line"><span class="cl">    ]</span></span></code></pre></div></div>
<p>第二位作者在自己的分支上、自己的环境里、不打扰任何人地运行那个 <code>--merge</code>，下一次 <code>env up</code> 就把 <code>0043</code> 叠加到他们的克隆上。（这个例子里 git 还先报了文本冲突，因为两个字段加在了模型文件的同一行。很真实，而且值得知道：文本冲突在解决之前会遮住迁移冲突。）</p>
<p><strong>2. 迁移要向后兼容上一个发布的代码。</strong> 部署过程中有一个新 schema 与旧代码共存的窗口，机器回滚时则反过来。加列要可空或带默认值，绝不一步改名，绝不删掉当前发布仍在读的东西：先加，部署，回填，切代码，在后续发布里再删。expand and contract。它在这里<em>更</em>重要的原因是，一次性环境让部署更频繁，你待在那个窗口里的时间更多。按标签的 staging 就是彩排它的地方。</p>
<p><strong>3. CI 还会往空数据库里从零迁移。</strong> 模板克隆测试的是&quot;我的迁移能不能应用在真实数据上&rdquo;。空库运行测试的是&quot;整条链从零还能不能跑通&rdquo;，能抓到那种假定前一个数据迁移已经填了什么的迁移。三十秒，抓住一类否则要等到下一台新笔记本才暴露的 bug。</p>
<p><strong>4. schema 变更要有第二双眼睛。</strong> 小团队不需要 <code>CODEOWNERS</code> 文件，但需要一条规则：动了 <code>migrations/</code> 的 PR 合并前由一位队友评审，而评审人的工作不是检查 SQL。是知道自己或团队里的其他人这两周是否在动同一张表，并把它说出来。超过十来个开发者，这份知识就装不进一个人的脑袋了，那时给 <code>migrations/</code> 加一条 <code>CODEOWNERS</code> 才算物有所值。没有任何工具能替代这一点。机械性的东西工具都抓了，这就是件两分钟的活，也是旧共享环境世界里唯一值得保留的部分：两个人发现自己即将相撞的那一刻。</p>

<h2 class="relative group">版本标识，让 URL 说出它在跑什么
    <div id="版本标识让-url-说出它在跑什么" 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="#%e7%89%88%e6%9c%ac%e6%a0%87%e8%af%86%e8%ae%a9-url-%e8%af%b4%e5%87%ba%e5%ae%83%e5%9c%a8%e8%b7%91%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>只有一个共享环境时，&ldquo;staging 上是什么&quot;是个在聊天里问的问题。有很多个时，它必须是环境的属性，否则每个 bug 报告都从考古开始。</p>
<ul>
<li><strong>每个镜像携带提交 SHA，应用把它暴露出来。</strong> <code>fly deploy --build-arg GIT_SHA=... --build-arg GIT_BRANCH=...</code> 把它们作为环境变量烙进镜像，<code>/api/version/</code> 连同环境名和区域一起返回（见上面的输出）。听到&quot;feature-profile-tagl-fa8832 上坏了&rdquo;，任何人第一件事就是确认它跑的是自己以为的那个提交。第一个演示环境是在工具提交之前一个提交的工作树上部署的，端点如实说了：它报的是上一个提交，不是我预期的 SHA。</li>
<li><strong><code>ENVIRONMENT=$ENV</code> 在每一行日志里，也是错误追踪工具的 environment 标签。</strong> 分支的错误不会污染生产的错误追踪，一键就能过滤。</li>
<li><strong>分支有 SHA，发布有标签。</strong> <code>feature-payments-retr-3f9a1c</code> 永远没有版本号；它有提交。staging 和生产有 <code>v1.42.0</code>。</li>
<li><strong>移动端携带的是最低 API 版本，不是 URL。</strong> 开发菜单里的 URL 字段是测试人员把真机指向某个分支的方式，带有 API schema 版本的版本端点则是应用判断对方是否可理解的方式。</li>
</ul>

<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="#%e8%bf%99%e8%a7%a3%e5%86%b3%e4%b8%8d%e4%ba%86%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>它解决不了只有放在一起才有意义的两个功能。分支环境展示一个分支。答案是把两者都藏在开关后面合入 <code>main</code>，然后看 <code>main</code>，可以用一个跟随 <code>main</code> 的常驻环境（<code>main-dev.example.com</code>，每次合并重新部署）来做。这是我唯一会保留的长期非生产环境，没人往它<em>上</em>部署；它是用来看下一个发布会是什么样的。</p>
<p>它解决不了那些要求注册回调、又不接受通配符的提供商。我用的认证服务接受父域名，所以 <code>example.com</code> 覆盖所有 <code>-dev</code> 分支主机名。有些 OAuth 和支付提供商不接受，那就保留一个共享主机名，由 Worker 路由到最后认领它的那个环境。那是一把小锁，锁在一件小事上，比把一切都锁住好得多。</p>
<p>它解决不了迁移的人的一面。工具抓的是机械性冲突。它抓不到你们两个在同一个两周里以互不兼容的方式建模同一个概念，唯一的补救是那次有人注意到的迁移评审。</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%e4%bc%9a%e5%9c%a8%e4%bd%a0%e7%9a%84%e7%8e%af%e5%a2%83%e9%87%8c%e6%a3%80%e6%9f%a5%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>如果你有共享的 dev 或 staging，还有一条预约它的聊天线程：</p>
<ol>
<li>一个做完的分支，要等多久才能让作者以外的人看到它在运行？超过一小时，你就在用交付时间为这把锁付费，而在小团队里，那是团队里很大一部分人在等。</li>
<li>共享数据库多久重置一次，重置时谁丢了工作？</li>
<li>一个新开发者在第一天，能不能不向任何人要任何东西，就拿到一个跑着自己分支的公开 URL？无论他们得要的是什么，那就是最先该自动化的东西。</li>
<li>新环境是从真实数据开始的吗？如果从空库开始，它第一个抓不到的 bug 就是那个在空表上没事、在真表上要跑四十分钟的迁移。</li>
<li>你的非生产托管里，有没有什么东西在所有人都忘了它一个月后还会存在？有的话，先于一切写清扫器。泄漏从第一天就开始了。</li>
</ol>
<p>那个聊天里的问题消失，不是因为人们不再问，而是因为答案永远是&quot;在用啊，用我自己的。&quot;</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>怎样被 ChatGPT 引用：一个小博客做了什么，日志又怎么说</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/</guid>
      <pubDate>Wed, 16 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>SEO</category>
      <category>Crawlers</category>
      <category>LLM</category>
      <category>Hugo</category>
      <category>Cloudflare</category>
      <category>content strategy</category>
      <category>Writing</category>
      <description>想被 ChatGPT 等 AI 助手引用（GEO），我在这个博客上做了什么：该放行哪些 bot、不用浏览器就能读的 HTML、带作者的结构化数据，以及没人读的 llms.txt。</description>
      <content:encoded><![CDATA[<p>想被 ChatGPT 等 AI 助手引用，第一步是放行它们回答问题时来抓页面的检索 bot。这些 bot 和训练爬虫是两回事，拦住一种对另一种毫无影响。然后要给出不跑 JavaScript 也能读的 HTML，并带上写明作者的结构化数据。至于 llms.txt，从我的日志看根本没人来取，所以没用；最要紧的是写作时让每一节第一句就亮出结论，让任何一段都能单独被引用。</p>
<p>这个博客的搜索流量已经平了两三年，我也不指望它再动了。所以我转而去看访客都是从哪来的，AI 助手在不在其中。在，勉强算在。截至 2026 年 9 月 16 日的十二个月里，访问统计记到这里有 5,372 个会话。其中 27 个来自 AI 助手：12 个来自 chatgpt.com，9 个来自 gemini.google.com，6 个来自 Perplexity，来自 claude.ai 的是零。半个百分点。不过从 ChatGPT 来的访客是好的那种。他们平均停留五分钟，直接访问是 27 秒，Google 来的是三分钟，因为他们已经读过答案，想看的是细节。</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="十二个月按来源统计的会话数，AI 助手高亮显示"
    width="2400"
    height="1164"
    src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_590f408ed875db1f.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_590f408ed875db1f.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_d8da7a3f1d1c9190.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_b3dda7d23abf271d.webp"></span><figcaption>十二个月按来源统计的会话数。蓝色是 AI 助手：5,372 次会话里占 27 次。</figcaption></figure>
</p>
<p>所以我花了些时间琢磨，一个小博客怎么才能被选中当这些回答的来源。这篇写的是我在这个站上做了什么，以及日志对此怎么说。有数字的地方我给数字，靠猜的地方我说明是猜。初稿里有三句话，一查之下发现是错的，我把改正留在了文里。</p>

<h2 class="relative group">两种 bot，只有一种会引用你
    <div id="两种-bot只有一种会引用你" 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%a4%e7%a7%8d-bot%e5%8f%aa%e6%9c%89%e4%b8%80%e7%a7%8d%e4%bc%9a%e5%bc%95%e7%94%a8%e4%bd%a0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>第一件要弄清楚的事：&ldquo;AI bot&rdquo; 其实是两种不同的活，用的是不同的 user agent，拦住一种对另一种毫无影响。</p>
<p>训练爬虫成批地抓页面，用来训练下一个模型。OpenAI 的叫 GPTBot，Anthropic 的叫 ClaudeBot，Google 的由 Google-Extended 这个 token 控制，Apple 的是 Applebot-Extended，Common Crawl 的 CCBot 则喂着大多数开放数据集。它们拿走的东西最后变成权重，不会有任何链接指回你。拦掉这些是有道理的，我不觉得这想法傻。</p>
<p>检索 bot 是另一种。有人问 ChatGPT、Claude 或 Perplexity 一个问题时，助手会跑一次搜索，当场抓几个页面，读一遍，然后写出带引用的回答。这些抓取来自 OAI-SearchBot 和 ChatGPT-User、Claude-SearchBot 和 Claude-User、PerplexityBot 和 Perplexity-User。Google 的 AI Overviews 用的就是普通的 Googlebot，所以根本没有单独的开关。会把访客送回来的是这些 bot。如果目标是被引用，你要优化的就是它们，而且值得知道的是，拦不拦 GPTBot，ChatGPT 的搜索结果都不会因此变好或变坏。</p>
<p>这个站的 robots.txt 是 Hugo 的默认，也就是全部放行：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">User-agent: *
</span></span><span class="line"><span class="cl">Allow: /
</span></span><span class="line"><span class="cl">Sitemap: https://jared.lynskey.co.nz/sitemap.xml</span></span></code></pre></div></div>
<p>我想过把它拆开，放检索 bot 进来，把训练爬虫挡在外面，最后觉得对一个存在意义就是被人读的博客来说不值得。真要那么做的话，写出来就是下面这样。把它放出来是因为和<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-cloudflare-crawlers-cache/" >Cloudflare 给 Googlebot 发了一个空页面</a>里的教训一样：列表里写的得是 bot 的名字，不是厂商。没有哪一行能写成&quot;OpenAI&quot;。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">User-agent: GPTBot
</span></span><span class="line"><span class="cl">User-agent: ClaudeBot
</span></span><span class="line"><span class="cl">User-agent: Google-Extended
</span></span><span class="line"><span class="cl">User-agent: Applebot-Extended
</span></span><span class="line"><span class="cl">User-agent: CCBot
</span></span><span class="line"><span class="cl">Disallow: /
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">User-agent: *
</span></span><span class="line"><span class="cl">Allow: /</span></span></code></pre></div></div>
<p>站在 Cloudflare 后面的话，有一件事要查一下。从 2025 年年中起，那里新建的 zone 默认拦 AI 爬虫，bot 设置里还有一个一键开关，能对已有的 zone 做同样的事。它两种都拦。这个博客上周刚搬到 Cloudflare Pages，所以这件事在我的清单上。如果你因为&quot;AI bot&quot;听起来像该挡的东西就把它打开了，那你也把引用一起关掉了。Cloudflare 后来开始在它托管的 robots.txt 里写一行 <code>Content-Signal</code>，带 <code>search</code>、<code>ai-input</code> 和 <code>ai-train</code> 三个独立的标志。没人有义务遵守它，但这套词是对的：和上面一样的划分，只是表达成一种偏好，而不是一堵墙。</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="#%e7%9c%9f%e6%ad%a3%e6%9d%a5%e6%95%b2%e9%97%a8%e7%9a%84%e6%98%af%e8%b0%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>搬到 Cloudflare 有个副作用：我头一次能按 user agent 看到每一个请求。下面是 2026 年 9 月 13 日到 16 日这四天进来的东西，假冒的已经剔掉了，那部分后面再说。</p>
<table>
	<thead>
			<tr>
					<th>Bot</th>
					<th>请求数</th>
					<th>做了什么</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>ClaudeBot（训练）</td>
					<td>1,777</td>
					<td>两天里抓了 1,380 个不同页面，五种语言全在内，另外读了 37 次 robots.txt</td>
			</tr>
			<tr>
					<td>meta-externalagent（训练）</td>
					<td>1,748</td>
					<td>一样，晚一天</td>
			</tr>
			<tr>
					<td>OAI-SearchBot（ChatGPT 的搜索索引）</td>
					<td>59</td>
					<td>五种语言的页面都有，robots.txt 读了十六次</td>
			</tr>
			<tr>
					<td>ChatGPT-User（回答时抓取）</td>
					<td>20</td>
					<td>冷启动那篇的日语版、自建更新服务器那篇、首页</td>
			</tr>
			<tr>
					<td>Claude-User（回答时抓取）</td>
					<td>8</td>
					<td>更新服务器那篇、联系页、首页</td>
			</tr>
			<tr>
					<td>PerplexityBot</td>
					<td>2</td>
					<td>iOS 调试那篇，英语版和越南语版</td>
			</tr>
			<tr>
					<td>Claude-SearchBot、Perplexity-User</td>
					<td>0</td>
					<td></td>
			</tr>
			<tr>
					<td>Googlebot / Bingbot / Applebot / CCBot</td>
					<td>168 / 71 / 45 / 28</td>
					<td></td>
			</tr>
	</tbody>
</table>
<p>有两点很显眼。训练爬虫把整个站读了两遍，用的时间只够检索 bot 读几十个页面。而检索 bot 抓的不是随机页面：ChatGPT-User 和 Claude-User 都奔着更新服务器那篇去了，那是站上最具体的一篇，日语的冷启动那篇被抓的也正是日语版。</p>
<p>再说假冒的。自称 GPTBot 的 20 个请求里，有 18 个来自 OpenAI 公布的地址段之外，要的是 <code>/.env</code>、<code>/.git/HEAD</code> 和 <code>/firebase-adminsdk.json</code>。36 个 &ldquo;ChatGPT-User&rdquo; 请求里有 15 个是同一路货色。&ldquo;PerplexityBot&rdquo; 的流量大多想要 <code>/id_ed25519</code>。user agent 就是一个谁都能发的字符串，漏洞扫描器已经发现 AI bot 的名字会被放行。OpenAI、Anthropic 和 Perplexity 都公布了自己的 IP 段。如果你打算对某个 bot 特殊对待，不管是放还是拦，查地址，别查名字。上面的 20、59 和 8，是从正确地址来的那些请求。</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="声称是 OpenAI user agent 的请求，按是否来自 OpenAI 公布的 IP 段拆分"
    width="2400"
    height="722"
    src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_e7035266dc7ce0b1.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_e7035266dc7ce0b1.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_638e8c6286f5c91f.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_11d44262308d7c6c.webp"></span><figcaption>同样的 user agent 字符串，按来源地址拆开。多数自称 &lsquo;GPTBot&rsquo; 的流量根本不是 OpenAI 发来的。</figcaption></figure>
</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%a1%b5%e9%9d%a2%e4%b8%8d%e7%94%a8%e6%b5%8f%e8%a7%88%e5%99%a8%e4%b9%9f%e5%be%97%e8%83%bd%e8%af%bb" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>检索 bot 在回答的那一刻抓取，有超时，而且大多数不跑 JavaScript。一个在 bundle 加载完之前只是一个空 div 的页面，就是它们读不到的页面。Hugo 输出的是完整的 HTML，所以这个博客白得了这一点，这也是我从没动过心思把它搬到什么更聪明的东西上的主要原因。我做的市场应用是反面例子，一个 React Native 的 Web 应用，为了让爬虫拿到一份真正的文档，先做了一条动态渲染分支，然后又被一个缓存 bug 悄悄抵消了。上面那篇 Cloudflare 的文章讲的就是这事。</p>
<p>速度也是同样的道理，而这里日志纠正了我。我原本以为，静态站放在 Cloudflare 后面，就是从最近的边缘节点出去的。不是。这个博客放在 Cloudflare Pages 上，HTML 是不经缓存发出去的，从首尔发请求，响应从洛杉矶回来，大约要半秒。对一个超时有好几秒的 bot 来说这仍然够用，但它不是我原本会张口就说的几十毫秒，而且我知道这一点只是因为我测了。一个正从六个页面里拼答案的助手，会把那个要花三秒的丢掉，而且不会告诉你它丢了。</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%86%99%e5%be%97%e8%ae%a9%e4%b8%80%e6%ae%b5%e8%af%9d%e8%83%bd%e5%8d%95%e7%8b%ac%e8%a2%ab%e6%91%98%e5%87%ba%e6%9d%a5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这些系统用页面的方式和读者不一样。它们把页面切成块，给每块按问题打分，引用得分高的那些。一篇用八百字铺垫到结论的文章，每一块的分都低。一篇每一节第一句就亮出观点、后面再论证的文章，会在匹配的那一节得分。</p>
<p>这件事对我在这里写东西的影响，比任何技术上的事都大。front matter 里的 description 现在是对文章内容的概括，不是勾你点进来的钩子，因为 description 往往是助手唯一展示的部分。小节标题说的是这一节的结论。数字连同单位写进句子，而不是放在图表里。日期写全，因为&quot;上周&quot;对一年后读这页的模型来说什么都不是。还有，一篇文章只讲一件事。</p>
<p>另一半是要有值得引用的东西，而这里的数据是掺杂的。日志里，回答时的 bot 抓的都是具体的那些页面：冷启动、更新服务器、iOS 调试。Naver 的爬虫叫 Yeti，Daum 的叫 Daumoa，我用的托管平台上机器重启是一个 30 秒的 504 窗口，Cloudflare 在线做的 Brotli 在我的 bundle 上测出来比 gzip 还差。这些事实没几个地方写过，所以需要其中某一条的问题就会落到这里。可是从助手那边点进来的人，大多落在 2023 年和 2024 年那些讲管理的文章上，每篇一两个会话。具体的文章被机器读，泛泛的文章拿到点击。这我给不出一个利落的解释，宁可照实报出来，也不想编一个。杠杆要是有的话，仍然是把你这周弄明白的、当初搜也搜不到的那件具体的事写下来，因为机器明摆着读的就是这些。</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="#%e8%af%b4%e6%b8%85%e6%a5%9a%e6%98%af%e8%b0%81%e5%86%99%e7%9a%84%e8%80%8c%e4%b8%94%e6%af%8f%e6%ac%a1%e9%83%bd%e8%af%b4%e4%b8%80%e6%a0%b7%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>一个助手在决定要不要引用一个页面时，做的事和一个认真的读者差不多：这是谁写的，什么时候写的，和我找到的其他东西对得上吗？这个站上这部分的机制大多是主题的功劳。每篇文章都带 Article 结构化数据，有作者、发布日期和修改日期，作者是一个 Person，链接到同一组公开主页。这在模板里只是几行，却是&quot;一个页面&quot;和&quot;一个有来历的、署了名的人写的页面&quot;之间的区别。</p>
<p>不是主题功劳的那部分是一致性。关于页、llms.txt、各个公开主页和每篇文章顶上的那一行，描述的都是同一个人，同一段工作经历。有东西变了，就全都要改，不然忘掉的那个就成了错的那个。听起来挺较真，但一个页面说我是工程经理、另一个说我是自由职业，正是那种会让模型含糊其辞、或者干脆换个人引用的矛盾。</p>
<p>修改日期也得老实。内容变了它才变，否则我不碰它。为了显得新鲜而去改它，是那种管用一次、然后整个域名都被打折扣的事。</p>

<h2 class="relative group">llms.txt 是什么，以及没人读它这件事
    <div id="llmstxt-是什么以及没人读它这件事" 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="#llmstxt-%e6%98%af%e4%bb%80%e4%b9%88%e4%bb%a5%e5%8f%8a%e6%b2%a1%e4%ba%ba%e8%af%bb%e5%ae%83%e8%bf%99%e4%bb%b6%e4%ba%8b" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>llms.txt 是 2024 年 9 月提出的一个方案：在站点根目录放一个 Markdown 文件，给语言模型一段站点概述和一份精选的链接列表。我今年 2 月加了一份。开头一段讲我是谁，然后按管理、基础设施、移动和社区分节，每篇文章一行：标题、URL，加一句话说这篇文章的结论。读起来像一份写给赶时间的人的目录。</p>
<p>要说明的是，没人读它。在我手上这四天的日志里，请求过 <code>/llms.txt</code> 的只有我自己。Common Crawl 在 2026 年 8 月那一轮抓取里收了这个站的 76 个页面，从来没收过这个文件。没有哪家主要的助手说过它会读它。所以这里有什么被引用都不是因为它，我也不会以此为由推荐给任何人。留着它有两个原因。它只花了一个小时，而且给每篇文章写一句&quot;这篇到底得出了什么结论&quot;，比文件本身更值，因为它让我看到了哪些文章什么结论都没有。它也是站上唯一一个告诉机器这个站是干什么的页面，我宁愿有它而用不上。之后它已经跑偏了，因为我是手写的，会忘，这也是应该改成用 Hugo 的输出格式从 front matter 生成它的理由。这事在清单上。</p>

<h2 class="relative group">Feed、sitemap 和五种语言
    <div id="feedsitemap-和五种语言" 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="#feedsitemap-%e5%92%8c%e4%ba%94%e7%a7%8d%e8%af%ad%e8%a8%80" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>无聊的管道照样算数。sitemap 是爬虫在新文章上线当天找到它的方式。RSS 是它得知一篇文章改过的方式。两个都是 Hugo 的默认，我从没需要操心。ClaudeBot 两天里读了 36 次 sitemap，它就是这样那么快找到 1,380 个页面的。</p>
<p>语言是我没料到会有影响的部分。这里的所有内容都有英语、日语、韩语、简体中文和越南语版本，带 alternate 链接，让爬虫知道它们是同一篇文章。爬虫把它们当成五个站：ClaudeBot 的抓取几乎是在五种语言之间平分的，Common Crawl 8 月收走的 73 个文章页面里，只有四个是英语的。助手用被提问的语言回答，也偏向那种语言的来源，所以一个关于冷启动的韩语问题，可以直接从韩语页面回答，而不是现场把英语页面翻一遍，而 ChatGPT 实际抓走的正是日语那一页。条件是译文读起来得像用那种语言写的。今年早些时候我请母语者帮着检查做的那一遍修订，本来是为了人类读者，但也正是它让越南语页面成了一个来源，而不是一个稀奇玩意儿。</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%83%bd%e6%b5%8b%e9%87%8f%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>比我以为的多，只要别再靠猜。Cloudflare 的免费套餐给的是按 user agent 和路径分的请求日志，一次查询一天，上面那张 bot 表就是从这来的；referrer 要付费套餐才有。访问统计工具里有 referrer，通过它的报表 API 把数据拉出来，而不是眯着眼看仪表盘，开头那些数字就是这么来的。两边合起来能看出个大致轮廓。四天里，检索 bot 从 OpenAI 和 Anthropic 的地址抓了大约九十个页面。上一个月，有两个人从 ChatGPT 过来。读得多，偶尔引，很少点。据我所知，还是没有办法看到那些用了某个页面却没被点击的回答。最有用的检查依然是手动的那种：拿你文章回答的那个问题去问助手，看它引的是谁的页面。</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="十三个月里每月来自 AI 助手的会话数，看不出趋势"
    width="2400"
    height="968"
    src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_cd5dc99be8b0cfc3.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_cd5dc99be8b0cfc3.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_7d401a84f177509c.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_5c308d89ad110ef7.webp"></span><figcaption>AI 助手带来的访问按月分布。没有什么趋势可言。</figcaption></figure>
</p>
<p>如果这一切里只能留一样，我留写作那条规则。robots.txt、结构化数据和 llms.txt 是一个下午的活，做完就完了。让每一节先说观点，写具体的事而不是泛泛的事，是一种习惯，而正是这个习惯决定了一个页面到底值不值得引用。第二样是：先测了再说。被机器信任，结果看起来和被人信任很像：说你是谁，说你知道什么，而且每次都用同一种说法。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>前后端分开部署：不重新部署 API 也能发布 Web 前端</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-14-web-releases-without-redeploying/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-14-web-releases-without-redeploying/</guid>
      <pubDate>Mon, 14 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Fly.io</category>
      <category>Django</category>
      <category>DevOps</category>
      <category>Cloudflare</category>
      <category>Deployment</category>
      <category>Object Storage</category>
      <description>我怎么把前端和后端分开部署：构建产物按版本前缀传到对象存储，切换 Django 读取的指针，再通过 API 清掉 Cloudflare 缓存。</description>
      <content:encoded><![CDATA[<p>为了把前端和后端分开部署，我不再把 Web 构建产物复制进 API 的 Docker 镜像。现在由一个脚本构建前端，按版本前缀上传到对象存储，再改一个指针对象；Django 在每个请求里读这个指针，提供它指向的那个版本。最后调一次 Cloudflare 的清缓存 API，把缓存的 HTML 刷掉。改一个 CSS 大约一分钟就能上线，什么都不用重启。</p>
<p>直到不久前，我做的一个集市应用在 Web 端的每一次改动，走的都是和 Django API 一样的路：构建 Docker 镜像，推上去，在 Fly.io 的每台机器上滚动部署。Web 前端（移动应用的 Web 导出，加上静态站点生成器构建的营销页面）和 API 一起被复制进了镜像，所以没有别的办法把它发出去。改一个 CSS 大约要七分钟，重启 11 台机器。八月有一天我这么干了六次。</p>
<p>这篇按顺序过一遍我用什么替换了它：一个部署脚本构建两棵前端树，按版本前缀上传到对象存储，改一个指针对象，等到每台机器都在提供新版本，再通过 API 清掉 Cloudflare 的缓存。另一侧，Django 在每个请求里读指针，提供它指向的那个版本。API 完全不用重启。</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="#%e8%80%81%e5%8a%9e%e6%b3%95%e7%9a%84%e9%97%ae%e9%a2%98%e5%9c%a8%e5%93%aa" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>最大的成本是重启。11 台机器滚动就是 11 次冷启动，在共享 CPU 上 Django 起来大约要 13 秒，其中 7 秒只是在 import Django。我在部署脚本末尾加了一步预热，把每台机器都请求一遍，免得让真实访客来付这笔账，结果就是每次 Web 发布都以等最慢那台机器收尾。 这些冷启动在 Fly.io 上有多贵，我在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/" >缩容到零，于是每次重启都是一次 30 秒的故障</a>里写过。简单说，每次重启都是一个访客可能拿到 504 的窗口，所以一条什么都不重启的部署路径，就把 Web 发布的这个窗口整个关掉了。</p>
<p>第二个问题是静态站点的输出是拆开的。HTML 进了镜像，但 RSS feed 和搜索索引直接上了存储桶。它们来自同一份源码，只是不在同一时刻构建，所以可能互相对不上，而且对不上之前没人会发现。</p>
<p>第三个更像是习惯问题。当一次发布要七分钟外加一次重启，你就不做小发布了。我会攒三四个修复再一起发，而不是一个一个发，而这正是一行改动和某个坏掉的东西一起上线的原因。</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%ad%98%e5%82%a8%e6%a1%b6%e9%87%8c%e7%9a%84%e5%b8%83%e5%b1%80" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>所有东西都在一个兼容 S3 的存储桶里，<code>web/</code> 前缀之下：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">web/current                              ← a text file containing one version string
</span></span><span class="line"><span class="cl">web/a1b2c3d-20260914021500/manifest.json ← every file in this version
</span></span><span class="line"><span class="cl">web/a1b2c3d-20260914021500/app/...       ← the app&#39;s web export
</span></span><span class="line"><span class="cl">web/a1b2c3d-20260914021500/site/...      ← the static site&#39;s output
</span></span><span class="line"><span class="cl">web/9f8e7d6-20260912093012/...           ← the previous version, still there</span></span></code></pre></div></div>
<p>版本是短 git SHA 加一个 UTC 时间戳，一眼就能看出是哪个提交、什么时候传上去的。发布上线时唯一变化的东西就是 <code>web/current</code>。</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%83%a8%e7%bd%b2%e8%84%9a%e6%9c%ac%e5%81%9a%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>整件事就是一个脚本，在仓库根目录运行。按顺序：</p>
<p><strong>1. 拒绝不干净的工作树。</strong> 它做的第一件事是 <code>git status --porcelain</code>，有任何输出就退出。磁盘上是什么就上传什么，以前营销站的部署脚本就这样发过一次未提交的文案。有个跳过检查的标志给知道自己在干什么的人用，我还没用过。然后它用 <code>git rev-parse --short HEAD</code> 和当前时间算出版本字符串。</p>
<p><strong>2. 构建两棵树。</strong> 应用这边跑 Web 导出，把生产环境的 API 和 WebSocket 地址写死进去，后面接一遍 Brotli 预压缩。预压缩值得解释一下：Cloudflare 会在线做 Brotli 压缩，但质量等级很低，在这个 bundle 上测出来比 gzip 还差（2.04MB 对 1.66MB）。构建时用质量 11 压一次，能压到 1.28MB，所以脚本给每个超过 1KB 的 JS、CSS 和 JSON 文件旁边都写一个 <code>.br</code> 孪生文件。营销站这边跑静态站点生成器，开着压缩（minify）。</p>
<p><strong>3. 上传。</strong> 它遍历两个构建目录，用 16 个线程把每个文件上传到 <code>web/&lt;version&gt;/app/...</code> 和 <code>web/&lt;version&gt;/site/...</code>。每个对象的 <code>Content-Type</code> 根据文件名猜（先去掉 <code>.br</code>），Brotli 孪生文件再加上 <code>Content-Encoding: br</code>。等所有文件都传完，才写 <code>manifest.json</code>，一个带两个相对路径列表的 JSON 对象。顺序很重要：Django 那一侧把没有清单的版本当作不存在，所以传到一半的版本永远不可能被设为当前版本。现在是几百个文件，上传大约六秒。</p>
<p><strong>4. 改指针。</strong> 一次 <code>put_object</code> 把版本字符串写进 <code>web/current</code>。这就是发布。</p>
<p><strong>5. 等机器。</strong> 每台 API 机器把指针缓存五秒，所以改完之后的一小会儿，有些机器还在旧版本上。脚本每半秒轮询一次 API 上的版本接口，数带着新版本的连续回答。Fly 会在所有机器之间做负载均衡，所以连续 16 次匹配的回答（比机器数多）就说明每台都重新读过指针了。一个旧回答就把计数清零。90 秒后放弃，让你手动检查；第一次真正的切换花了 30 秒出头，所以上限没定得更低。</p>
<p><strong>6. 清 Cloudflare 缓存。</strong> 一次 API 调用，下面讲。失败的话脚本打条警告继续走。</p>
<p><strong>7. 发到团队聊天。</strong> 用运行时错误告警的同一个 webhook 发一行，带版本和是谁跑的，这样一波错误可以对照着刚才改了什么来看。回滚用琥珀色发。</p>
<p>从头到尾一次发布一分钟出头，大部分时间是 Web 导出。</p>

<h2 class="relative group">Django 这一侧
    <div id="django-这一侧" 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="#django-%e8%bf%99%e4%b8%80%e4%be%a7" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>提供前端的本来就是 Django。所有非 API 的 URL 返回的都是 Web 导出里对应该路由的预渲染页面；营销、隐私政策和支持页面则来自静态站点的输出。这些视图以前是直接打开磁盘上的文件。现在它们都通过一层知道存储桶存在的小封装来读。</p>
<p>每个请求它解析三样东西：当前版本（从 <code>web/current</code> 读，缓存五秒）、该版本的清单（在该版本为当前版本期间一直缓存）、以及文件本身（一个放文件内容的小型内存 LRU，所以页面的 HTML 和主 bundle 会留在内存里）。清单是 404 便宜的原因：路径不在列表里，就直接答没有，不用去问存储桶。如果客户端接受 Brotli，它会改读 <code>.br</code> 的孪生文件并设置编码头。静态资源带着一年的 immutable 缓存头发出去，因为文件名里有内容哈希。凡是从文件列表推出来的东西，比如把路由映射到预渲染页面的那张表，也按版本缓存，所以新部署之后会在第一个请求时重建，之前不会。</p>
<p>然后是部署脚本轮询的那个接口：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">web_version</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># bundle: the layer that reads web/current and the manifest</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;source&#34;</span><span class="p">:</span> <span class="n">bundle</span><span class="o">.</span><span class="n">source</span><span class="p">(),</span> <span class="s2">&#34;version&#34;</span><span class="p">:</span> <span class="n">bundle</span><span class="o">.</span><span class="n">version</span><span class="p">()})</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="p">[</span><span class="s2">&#34;Cache-Control&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="s2">&#34;no-store&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">response</span></span></span></code></pre></div></div>
<p>它在生产环境返回 <code>bucket:&lt;version&gt;</code>，从镜像提供时返回 <code>filesystem</code>。每台 Fly 机器只回答自己的情况，所以打十几次就知道整个集群有没有收敛。当哪里看着不对劲时，这也是查某台机器认为线上是什么最快的办法。</p>

<h2 class="relative group">通过 API 清 Cloudflare 缓存
    <div id="通过-api-清-cloudflare-缓存" 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%80%9a%e8%bf%87-api-%e6%b8%85-cloudflare-%e7%bc%93%e5%ad%98" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>站点的 HTML 在 Cloudflare 边缘缓存五分钟。不清的话，部署已经在机器上生效，但大多数访客最长五分钟内拿到的还是旧页面，这就失去意义了。</p>
<p>清缓存就是一次 API 调用：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">urllib</span><span class="o">.</span><span class="n">request</span><span class="o">.</span><span class="n">Request</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="sa">f</span><span class="s2">&#34;https://api.cloudflare.com/client/v4/zones/</span><span class="si">{</span><span class="n">zone</span><span class="si">}</span><span class="s2">/purge_cache&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">data</span><span class="o">=</span><span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">({</span><span class="s2">&#34;hosts&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;example.com&#34;</span><span class="p">,</span> <span class="s2">&#34;www.example.com&#34;</span><span class="p">]})</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">    <span class="n">headers</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;Authorization&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;Bearer </span><span class="si">{</span><span class="n">token</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;Content-Type&#34;</span><span class="p">:</span> <span class="s2">&#34;application/json&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div>
<p>我按主机而不是按 URL 清，这样就不用把每个页面列一遍。静态资源根本不需要清：文件名带内容哈希，新构建指向新文件名，旧的只是不再被引用。需要刷新的只有引用这些文件名的 HTML。</p>
<p>脚本在清缓存<em>之前</em>等机器，是有意的。如果先清，Cloudflare 可能从一台还在旧版本的机器上把缓存填回去，那个页面就会在边缘再待五分钟。</p>
<p>如果清缓存失败，脚本打一条警告然后照样结束。缓存五分钟内会自己过期，所以最坏情况是发布慢一点，而不是发布坏了。我宁愿这样，也不要部署在最后一步中止。</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%8a%e7%ba%bf%e5%89%8d%e5%85%88%e6%a3%80%e6%9f%a5%e4%b8%80%e4%b8%aa%e6%9e%84%e5%bb%ba" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>有个不切换的模式，做到第 3 步就停。版本在存储桶里，但没有任何东西在提供它。要看的话，我在一台机器上设一个环境变量，不管 <code>web/current</code> 说什么，那台机器就固定在那个构建上。看着没问题，再用回滚命令把它设为当前版本。它叫回滚，其实就是&quot;把 <code>current</code> 指向这个版本&quot;，往前走和往回退是同一个操作。</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%9b%9e%e6%bb%9a" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>旧版本留在存储桶里，所以回滚就是拿一个更早的版本跑同一条命令，还有个列表命令按从新到旧列出有哪些。它和往前部署一样会等机器、清缓存。我还没用到过。目前没有任何东西会清理旧版本；它们很小，我宁愿留着。</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%b2%a1%e5%8f%98%e7%9a%84%e9%83%a8%e5%88%86" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>API 仍然按老办法、用它自己的部署脚本发布，也应该如此：它有状态，迁移就跑在它上面。Dockerfile 也仍然把两份前端构建复制进镜像。这是有意的。没有配置存储桶时（本地开发、测试套件）或者存储桶读不了时，提供文件的那一层会退回到磁盘上的那些目录，所以存储故障时访客拿到的是随上一个镜像发出的前端，而不是错误页。如果哪天需要把存储桶从链路里摘掉，一个环境变量就可以在生产环境强制这个行为。</p>
<p>前端的改动比 API 频繁得多，现在每次发布大约一分钟，而不是七分钟。我没刻意去改，攒着发的习惯就没了。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-14-web-releases-without-redeploying/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>React Native Web 的 SEO：Cloudflare 给 Googlebot 发了一个空页面</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-cloudflare-crawlers-cache/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-cloudflare-crawlers-cache/</guid>
      <pubDate>Wed, 09 Sep 2026 04:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>SEO</category>
      <category>Django</category>
      <category>Caching</category>
      <category>Crawlers</category>
      <category>Naver</category>
      <category>React Native</category>
      <description>我怎么用动态渲染让 React Native Web 单页应用被 Google 和 Naver 爬虫看见，以及那条让 Googlebot 拿到空 SPA 外壳的 Cloudflare 缓存规则。</description>
      <content:encoded><![CDATA[<p>用 React 或 React Native Web 做的单页应用，Naver 的爬虫（Yeti）和 Bing 都不会执行它的 JavaScript，Google 也只是有时会。解决办法是动态渲染：按 User-Agent 识别爬虫，给它返回服务端渲染的 HTML，内容和应用本来要画出来的一样。在 Cloudflare 上要避开的坑，是把这个 URL 对所有人一起缓存。Cloudflare 对 HTML 不看 <code>Vary</code>，谁的响应先到，后面所有访客拿到的就是谁的，Googlebot 也可能因此拿到空外壳。只缓存爬虫请求，收窄规则之后记得清一次缓存。</p>
<p>我做的这个集市应用是用 React Native Web 写的单页应用。在 JavaScript 跑起来之前，每一个商品页都只是一个 script 标签加一个空 div。这对人没问题，对搜索却是致命的：Google 有时能执行那段 JS，Bing 不会，而对一个商品几乎全是韩语的站点最要紧的 Naver 爬虫，肯定不会。</p>
<p>这篇讲的是我怎么让这些页面被看见，以及之后那条以我没预料到的方式把成果抵消掉的缓存规则。</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%8a%a8%e6%80%81%e6%b8%b2%e6%9f%93%e4%b8%8d%e6%98%af%e4%bc%aa%e8%a3%85" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>办法很老，而且 Google 认可：识别爬虫，给它返回一份服务端渲染的 HTML，内容和 SPA 本该画出来的一样。不是不同的内容，是同样的内容，只是更早。</p>
<p>于是像 <code>/listings/view?id=...</code> 这样的商品 URL 分叉了。浏览器拿到 SPA 外壳。爬虫拿到一份真正的文档：一个标题，商品的关键信息，每张照片一张带 alt 的图，一段正文摘录，以及通往几个相关商品的链接，好让这页不至于成为孤儿。</p>
<p>最后那点比我预想的重要得多。在这些内链存在之前，大约一千个商品页没有任何东西指向它们，Search Console 里能看到抓取一路沉到每天 7 到 11 次请求。找不到你页面的爬虫，不会在乎那些页面有多好。</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%99%b7%e9%98%b1%e5%8e%82%e5%95%86%e7%9a%84%e5%90%8d%e5%ad%97%e4%b8%8d%e6%98%af%e5%ae%83%e5%ae%b6%e7%88%ac%e8%99%ab%e7%9a%84%e5%90%8d%e5%ad%97" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>爬虫识别是 user-agent 匹配，问题就出在这里。</p>
<p>韩国平台用的爬虫名字和公司名一点都不像。Naver 的爬虫叫 <strong>Yeti</strong>，Daum 的叫 <strong>Daumoa</strong>，Kakao 的链接抓取器叫 <strong>kakaotalk-scrap</strong>。三个都不是厂商名。</p>
<p>与此同时，同样这几家还发布带内嵌浏览器的 App，那些浏览器发送的是真实浏览器 UA，后面缀上 App 的名字。KakaoTalk 的内置浏览器说自己是 <code>KAKAOTALK/10.5.1</code>，Kakao 的抓取器说自己是 <code>kakaotalk-scrap/1.0</code>。</p>
<p>用 <code>kakao</code> 去匹配，两个都会中。我当初正是这么干的，结果每一个打开链接的 KakaoTalk、Naver App、Daum App 用户，拿到的都是爬虫页：没有 JavaScript，没有导航，没有登录。一份挑不出毛病的文档，对人却毫无用处。</p>
<p>修复靠的是判断顺序，这一点值得明确写成规则：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 内嵌浏览器会在普通浏览器 UA 后面附上应用名。</span>
</span></span><span class="line"><span class="cl"><span class="n">INAPP_BROWSER_RE</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;kakaotalk/|naver\(inapp|daumapps&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 认机器人名，不认厂商名：Naver 用 Yeti，Daum 用 Daumoa，</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Kakao 的链接预览抓取器用 kakaotalk-scrap。</span>
</span></span><span class="line"><span class="cl"><span class="n">CRAWLER_RE</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;googlebot|bingbot|yeti/|daumoa|kakaotalk-scrap&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">is_crawler</span><span class="p">(</span><span class="n">user_agent</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># 内嵌浏览器永远不是爬虫，所以这个判断放在最前面。</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">user_agent</span> <span class="ow">or</span> <span class="n">INAPP_BROWSER_RE</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">user_agent</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">bool</span><span class="p">(</span><span class="n">CRAWLER_RE</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">user_agent</span><span class="p">))</span></span></span></code></pre></div></div>
<p>内嵌浏览器先判断，而且它赢。并且每一个爬虫匹配词都必须指向一个<strong>机器人</strong>，而不是一个厂商。</p>

<h2 class="relative group">然后我在前面放了 Cloudflare
    <div id="然后我在前面放了-cloudflare" 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="#%e7%84%b6%e5%90%8e%e6%88%91%e5%9c%a8%e5%89%8d%e9%9d%a2%e6%94%be%e4%ba%86-cloudflare" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>源站是悉尼的一台共享 CPU 机器，能撑住每秒二三十个请求。Googlebot 要走大约 995 个 URL，而它们全都打到了悉尼，因为每一个 URL 的 <code>cf-cache-status</code> 都是 <code>DYNAMIC</code>：<strong>除非有规则明说，Cloudflare 不缓存 HTML。</strong> 整个站点大约 16% 的内容被缓存，而真正要紧的那部分一个也不在里面。</p>
<p>那就给爬虫页加一条缓存规则。很简单。除了一点：一个商品 URL 并不只有一个响应体，它有三个。</p>
<ul>
<li>要 HTML 的爬虫拿到服务端渲染页。</li>
<li>人拿到 SPA 外壳。</li>
<li>发送 <code>Accept: text/markdown</code> 的 agent 拿到 Markdown，从数据库拼出来的，因为把一个空的 HTML 外壳转换一遍只会得到一份空文档。</li>
</ul>
<p>源站会发 <code>Vary: Accept</code>，而对 user-agent 做 <code>Vary</code> 本来也没什么指望。然后就是我不知道的这件事：</p>
<blockquote><p><strong>Cloudflare 在 HTML 上忽略 <code>Vary</code>。</strong> 三个响应体共用一个缓存键。</p>
</blockquote><p>哪个先到，哪个就占住这个 URL，对该边缘节点之后的所有访客生效，持续源站 <code>s-maxage</code> 允许的一小时，再加上一天的 <code>stale-while-revalidate</code>。</p>
<p>我在生产环境两个方向都亲眼见到了：</p>
<ul>
<li>一个 Naver App 用户用爬虫页预热了某个商品 URL，随后普通的 iPhone Safari 就在一次 HIT 上拿到了那个页面。一个人盯着一份没有 JavaScript 的文档。</li>
<li>一个人用 SPA 外壳预热了某个 URL，Googlebot 拿到了空外壳。整套 SEO 工作要防的正是这个失败，如今它更快了，还是从边缘发出来的。</li>
</ul>

<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%9d%a0%e6%94%b6%e7%aa%84%e6%9d%a5%e4%bf%ae%e5%a4%8d%e7%9a%84%e8%a7%84%e5%88%99" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>你没法让 Cloudflare 在 HTML 上尊重 <code>Vary</code>。你能做的，是确保三个响应体里只有<strong>一个</strong>有资格进缓存：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">(starts_with(http.request.uri.path, &#34;/listings/&#34;)
</span></span><span class="line"><span class="cl"> and not any(http.request.headers[&#34;accept&#34;][*] contains &#34;text/markdown&#34;)
</span></span><span class="line"><span class="cl"> and (lower(http.user_agent) contains &#34;googlebot&#34; or ...)
</span></span><span class="line"><span class="cl"> and not (lower(http.user_agent) contains &#34;kakaotalk/&#34; or ...))</span></span></code></pre></div></div>
<p>只匹配爬虫 UA，并排除 Markdown 请求。现在人的请求不匹配任何规则，于是读到 <code>DYNAMIC</code> 并像以前一样回源。只有爬虫请求读写缓存，而能进到缓存里的响应体只有爬虫那一个。</p>
<p>边缘 TTL 用 <code>respect_origin</code>，于是有效期仍归渲染这个页面的代码所有，而不是被钉死在控制台里。</p>
<p>一个容易漏掉的运维细节：<strong>收窄规则并不会驱逐已经在缓存里的东西。</strong> 得手动清一次缓存，否则那些刚刚已不再生成的污染条目还会继续被发出去。</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%a4%e4%bb%bd%e6%b8%85%e5%8d%95%e4%bb%a5%e5%8f%8a%e5%ae%83%e4%bb%ac%e7%9a%84%e9%94%99%e8%af%af%e4%b8%ba%e4%bd%95%e4%b8%8d%e5%af%b9%e7%a7%b0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这条规则需要一份爬虫 UA 清单，而 Django 里已经有一份。同一份知识在两个系统里存两份通常是坏味道，但这里有意思的是它们漂移时会发生什么，因为并不对称：</p>
<ul>
<li><strong>Cloudflare 里的机器人清单必须始终是 Django 那份的子集。</strong> 一个 Cloudflare 认为是机器人而 Django 不认的匹配词，会把 SPA 外壳缓存到爬虫专用键下，然后发给真正的爬虫。</li>
<li><strong>内嵌浏览器的排除清单必须是 Django 那份的超集。</strong> 排除得太多，代价只是少几次缓存命中。</li>
</ul>
<p>任何一边朝安全方向错，你只是少缓存一点。第一条朝另一个方向错，你就又在给 Googlebot 发空页面了。顺带一提，这种不对称也正是这些清单用 <code>contains</code> 而不是正则的原因：<code>matches</code> 需要 Business 套餐，而对一份允许在已知安全方向上粗糙的清单，<code>contains</code> 够用了。</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%80%e6%9d%a1%e8%ae%a9%e6%9e%84%e5%bb%ba%e5%a4%b1%e8%b4%a5%e8%80%8c%e4%b8%8d%e6%98%af%e8%ae%a9%e9%a1%b5%e9%9d%a2%e5%a4%b1%e8%b4%a5%e7%9a%84%e4%b8%8d%e5%8f%98%e5%bc%8f" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>爬虫页里嵌着有效期七天的预签名图片 URL。页面本身在边缘缓存一小时，外加一天的 stale-while-revalidate。</p>
<p>这两个数字是相关的，一旦缓存寿命超过图片寿命，边缘就会开始发出每张照片都 403 的页面。不会有任何告警，因为 HTML 本身没问题。</p>
<p>所以定义这些数值的代码，在关系被破坏时会拒绝导入：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">CRAWLER_IMAGE_TTL</span> <span class="o">=</span> <span class="mi">7</span> <span class="o">*</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span>  <span class="c1"># 预签名图片 URL</span>
</span></span><span class="line"><span class="cl"><span class="n">EDGE_S_MAXAGE</span> <span class="o">=</span> <span class="mi">3600</span>
</span></span><span class="line"><span class="cl"><span class="n">EDGE_STALE_WHILE_REVALIDATE</span> <span class="o">=</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 边缘缓存可能比图片 URL 活得更久时，在 import 时就失败。</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">EDGE_S_MAXAGE</span> <span class="o">+</span> <span class="n">EDGE_STALE_WHILE_REVALIDATE</span> <span class="o">&gt;=</span> <span class="n">CRAWLER_IMAGE_TTL</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="ne">RuntimeError</span><span class="p">(</span><span class="s2">&#34;edge cache lifetime must stay below the image URL lifetime&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>两个分处不同文件的常量之间的约束，在代码评审里看不见，在进程启动时却一目了然。把它放在进程启动的地方。</p>

<h2 class="relative group">Cloudflare 做的两件、我不得不撤销的事
    <div id="cloudflare-做的两件我不得不撤销的事" 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="#cloudflare-%e5%81%9a%e7%9a%84%e4%b8%a4%e4%bb%b6%e6%88%91%e4%b8%8d%e5%be%97%e4%b8%8d%e6%92%a4%e9%94%80%e7%9a%84%e4%ba%8b" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><strong><code>Vary: Origin</code> 把打包产物挡在了缓存之外。</strong> <code>django-cors-headers</code> 会在它经手的每个响应上打 <code>Vary: Origin</code>，而 Cloudflare 拒绝缓存任何在 <code>Accept-Encoding</code> 之外还有 vary 的响应。这就悄悄把关键路径上最大的那个 1.6 MB JavaScript 包整个排除在边缘之外。修法是把一个中间件放在列表最前面，让它在出站方向最后执行，重写静态资源路径上的 <code>Vary</code>。</p>
<p><strong>边缘压缩比源站压缩更差。</strong> 让 Cloudflare 现场做 Brotli 得到 2,041,742 字节，而源站自己的 gzip 是 1,658,115 字节。为了不用动脑，代价是大了 23%。即时压缩优化的是 CPU，不是压缩率；源站预压缩的资源会赢。</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%ad%89%e6%8a%93%e5%8f%96%e5%b0%b1%e5%91%8a%e8%af%89%e6%90%9c%e7%b4%a2%e5%bc%95%e6%93%8e" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>两个协议，加在一起覆盖面也令人失望。</p>
<p><strong>IndexNow</strong> 接受一次 ping，覆盖 Bing、Yandex 和 Seznam。Google 和 Naver 不参与。每次保存商品时，我在后台带着很短的超时发一次 ping，已售出和已关闭的条目直接跳过。网络失败只记一条警告，绝不让触发它的那次保存失败——这是一个尽力而为的旁路通道唯一可以接受的行为。</p>
<p><strong>Google 的 Indexing API</strong> 只接受两种结构化数据类型，其中之一正是我有的 <code>JobPosting</code>。在招的职位发 <code>URL_UPDATED</code>，关闭的发 <code>URL_DELETED</code>。那条删除通知之所以诚实，是因为关闭的条目同时输出了 <code>noindex</code>，代码里也这么写着——好让日后拿掉 noindex 的人知道自己还顺手破坏了什么。</p>
<p>这里有两处让我耗了不少时间。配额是每天 200 个 URL，而批量导入一次商品就会触发几百次，所以导入进来的条目会被跳过，否则一次导入就把当天预算全花光。另外端点是带冒号的 <code>urlNotifications:publish</code>。用斜杠的话，Google 返回一个普通的 HTML 404，看起来和 API 没启用一模一样。</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="#%e7%bb%93%e6%9e%84%e5%8c%96%e6%95%b0%e6%8d%ae%e6%95%99%e7%bb%99%e6%88%91%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>三条规则，每一条都是被 Search Console 数落出来的：</p>
<ol>
<li><strong>解析不了的就别输出。</strong> 薪资是自由文本。我以前把原始字符串当作 <code>baseSalary.value</code> 输出，那是无效的，还让每一条职位都被标记缺少单位。现在解析不了的薪资干脆不出现。</li>
<li><strong>别为了满足结构化数据的要求而编造字段。</strong> 招聘广告很少带街道地址。人会很想给 <code>PostalAddress</code> 填个看着像样的东西。招聘广告上编造的地址比不完整的地址更糟，所以只放城市和国家。</li>
<li><strong>有效期不是可选项。</strong> Google 会丢掉没有 <code>validThrough</code> 的 <code>JobPosting</code>，所以要推导一个，并加上下限，免得一条旧条目声称自己昨天就过期了。</li>
</ol>
<p>还有关于站点地图的第四条：<strong>别给所有条目都声称 <code>changefreq: daily</code>。</strong> 要求 Googlebot 每天重新抓取一千个条目，会被读成一台过载的主机，而它的回应是降低整站的抓取速率。从这东西实际上多久变一次来推导。</p>
<p>站点地图还有一道质量下限：标题加正文合计四十个字符。Search Console 曾把大约一千条报成&quot;已抓取 – 尚未编入索引&quot;，其中某个二手页面的全部独有内容，是一个单词的标题加一个电话号码。四十这个数字是故意定低的，因为 484 条里有 471 条是韩语，而韩语单位字符承载的信息更多；按英语调的阈值会砍掉这个市场的一半。内容单薄的条目仍然通过索引页保持可抓取，一旦有人编辑就会自动重新进入站点地图。</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%e4%bc%9a%e4%bb%8e%e4%b8%ad%e5%b8%a6%e8%b5%b0%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li><strong>在一个 URL 上返回不同的响应体，首先是一个缓存键问题。</strong> 弄清楚你的 CDN 用什么做键，并且在你于那个确切的内容类型上验证之前，就假定它忽略 <code>Vary</code>。</li>
<li><strong>当两个系统必须就一份清单达成一致时，先弄清哪个方向的错误是安全的</strong>，然后把它写在两份副本旁边。&ldquo;它们可以漂移，而且只允许朝这个方向漂&rdquo;，胜过假装它们不会漂。</li>
<li><strong>按机器人名字匹配机器人，永远别按厂商名字。</strong> 尤其在英语世界之外，那个厂商同时还做着你用户手里的那个浏览器。</li>
<li><strong>把相距遥远的常量之间的不变式，放在会大声失败的地方。</strong> import 时机是免费的，而且没人能跳过。</li>
</ul>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-cloudflare-crawlers-cache/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Fly.io 冷启动为什么慢：缩容到零后，从 33 秒降到 3 秒</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/</guid>
      <pubDate>Wed, 09 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Fly.io</category>
      <category>Django</category>
      <category>DevOps</category>
      <category>Cloudflare</category>
      <category>性能</category>
      <category>冷启动</category>
      <category>Redis</category>
      <category>调试</category>
      <description>在 Fly.io 上用 auto_stop_machines 缩容到零，请求落到已停止的机器上就得等它启动。我的冷启动要 25 到 33 秒，这篇讲我怎么把它降到 3 秒左右。</description>
      <content:encoded><![CDATA[<p><strong>更新（2026 年 10 月 22 日）</strong>：这篇文章最核心的数字是错的。发布当天，我把这个 zone 的 504 按 user agent 分组，发现 9 月 5 日到 9 日的 7,344 个 504 全部来自 Cloudflare 自己的 Early Hints 探测请求（<code>bastion early hints</code> 和 <code>nginx-ssl early hints</code>）。这些请求从 Fly 源站拿不到任何响应，被记成 504，而且沿用真实请求的路径，看起来和用户侧故障一模一样。同一时期，真实客户端（应用、浏览器、curl、搜索引擎爬虫）一个 504 都没收到。源站本来就不发 preload 头，Early Hints 什么忙也没帮上，所以我当天就把它关了。下面的冷启动是真的，解决办法也依然有效，但 22% 从来不是我的用户遇到的数字。在把 Cloudflare 的 5xx 比例当成故障之前，先看看是哪些 user agent 在收到这些错误。</p>
<p><strong>更新（2026 年 11 月 4 日）</strong>：再更正一点。这些 504 并不是源站没能响应的探测请求，而是 Cloudflare 自己的 Early Hints 缓存查询，根本不会到达源站；这里的 504 只表示这个 URL 没有缓存任何内容。关掉 Early Hints 也不是最好的办法：发送 <code>Link</code> 响应头之后，这个功能开始生效，这些未命中也变成了命中。完整经过见《<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/cloudflare-504-early-hints/" >Cloudflare 504 错误其实不是故障：Early Hints 与 HTTP 103 详解</a>》。</p>
<p>先说结论：开启 <code>auto_stop_machines</code> 后，落到已停止的 Fly.io 机器上的请求要等整台机器启动完。我的机器冷启动要 25 到 33 秒，因为每次机器启动，entrypoint 都会跑一遍 Django 迁移和 <code>collectstatic</code>，而字节码从来没人缓存过。把这些发布相关的工作改成每个镜像只做一次（用以 <code>FLY_IMAGE_REF</code> 为键的 Redis 锁），再在构建时加上 <code>compileall</code>，冷启动降到了 9 秒左右。最后把 <code>auto_stop_machines</code> 从 <code>'stop'</code> 改成 <code>'suspend'</code>（为此得先去掉 swap，Fly 不允许挂起带 swap 的机器），唤醒只要 3 秒左右。</p>
<p>我本来只是去 Cloudflare 面板上看一个流量尖峰，结果在它下面发现了严重得多的东西：一天之内，应用处理的请求有 22% 是失败的。没有任何告警触发，健康检查全是绿的，日志看起来也完全正常。因为那些失败的请求，根本没有到达应用。</p>
<p>原因是我自己写的容器启动脚本，在完全错误的时机做着完全合理的事。</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%b0%96%e5%b3%b0%e6%98%af%e4%b8%aa%e5%b9%8c%e5%ad%90" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我最初想解释的是这样一个小时：约 4,900 个请求，而中位数是每小时 121 个。独立访客 42 个。也就是每人 110 个请求，人类不是这么浏览网页的。</p>
<p>是扫描器。一个机器人在编造子域名，挨个向它们索要密钥文件：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">api-stage-asia.example.com/.env.local
</span></span><span class="line"><span class="cl">api-us-east-1-demo.example.com/.env
</span></span><span class="line"><span class="cl">api-eu-uat.example.com/wp-config.php~
</span></span><span class="line"><span class="cl">app-test-us-east-1.example.com/actuator/configprops
</span></span><span class="line"><span class="cl">prod-api-use2.example.com/.env.local
</span></span><span class="line"><span class="cl">relay.example.com/.env.production</span></span></code></pre></div></div>
<p>三十来个主机名，你能想到的每一种 <code>.env</code> 变体，外加冲着 Django 应用要的某个 Java 框架的 actuator 端点。它们全部拿到了指向规范主机的 301，什么也没被返回。乏味，而且确实无害。</p>
<p>不过既然分析页面开着，我顺手把同一天按状态码分组了。真正的问题在那里。</p>
<table>
	<thead>
			<tr>
					<th>状态码</th>
					<th>24 小时请求数</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>总计</td>
					<td>11,350</td>
			</tr>
			<tr>
					<td>504 Gateway Timeout</td>
					<td>2,474</td>
			</tr>
	</tbody>
</table>
<p>22%。而且不是均匀散布在整个站点，而是精确地压在最疼的地方：</p>
<table>
	<thead>
			<tr>
					<th>端点</th>
					<th>504</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>通知未读数</td>
					<td>621</td>
			</tr>
			<tr>
					<td>通知列表</td>
					<td>614</td>
			</tr>
			<tr>
					<td>消息未读数</td>
					<td>469</td>
			</tr>
			<tr>
					<td>信息流游标</td>
					<td>210</td>
			</tr>
	</tbody>
</table>
<p>这些都是移动端每 30 秒轮询一次的端点。所以这不是一个抽象的错误率数字，而是用户的手机整天、大概持续了好几周，都在悄悄加载不出未读角标。</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%97%a5%e5%bf%97%e4%b8%ba%e4%bb%80%e4%b9%88%e7%9c%8b%e8%b5%b7%e6%9d%a5%e6%ad%a3%e5%b8%b8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我的第一反应是应用变慢了。并没有。我从日志归档里翻出最糟糕那个小时里应用自己的请求日志：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">[2026-09-08 01:00:29] &#34;GET /api/notifications/unread/&#34; 304 61.615
</span></span><span class="line"><span class="cl">[2026-09-08 01:00:29] &#34;GET /api/notifications/&#34;        304 63.568
</span></span><span class="line"><span class="cl">[2026-09-08 01:00:59] &#34;GET /api/notifications/unread/&#34; 304 58.742
</span></span><span class="line"><span class="cl">[2026-09-08 01:00:59] &#34;GET /api/notifications/&#34;        304 105.438</span></span></code></pre></div></div>
<p>44 到 105 毫秒，清一色 304。源站很快。</p>
<p>这是关键线索，值得写成一条规则：<strong>如果边缘报告的错误是源站从没听说过的，那么请求是在到达之前就死掉了。</strong> 别再读应用日志，去看它前面的那一层。</p>
<p>在我这里就是 Fly.io 的代理，答案藏在机器的调度方式里。</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="#%e7%bc%a9%e5%ae%b9%e5%88%b0%e9%9b%b6%e6%98%af%e5%85%b3%e4%ba%8e%e6%88%90%e6%9c%ac%e7%9a%84%e6%89%bf%e8%af%ba%e4%b8%8d%e6%98%af%e5%85%b3%e4%ba%8e%e5%bb%b6%e8%bf%9f%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我的配置是这样，乍看之下相当合理：</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">http_service</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;stop&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_start_machines</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">min_machines_running</span> <span class="p">=</span> <span class="mi">1</span></span></span></code></pre></div></div>
<p>空闲的机器停掉，来流量再启动，用多少付多少。对于流量有峰谷的小应用，这正是你想要的，也正因如此我定义了十台机器，平时却只有一台在跑。</p>
<p>它藏起来的账单是延迟。当一个请求打到一台已停止的机器上，总得有人等它启动完。如果启动比代理的耐心更久，这个人就会拿到 504。</p>
<p>那么我的机器启动要多久？我从来没量过。日志归档知道，因为启动脚本会打印每个阶段，而且每一行都带着机器 ID：</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">WITH</span><span class="w"> </span><span class="n">s</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</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="n">instance</span><span class="p">,</span><span class="w"> </span><span class="k">timestamp</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">         </span><span class="k">CASE</span><span class="w"> </span><span class="k">WHEN</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">ILIKE</span><span class="w"> </span><span class="s1">&#39;%Starting app server%&#39;</span><span class="w"> </span><span class="k">THEN</span><span class="w"> </span><span class="s1">&#39;begin&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="k">WHEN</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">ILIKE</span><span class="w"> </span><span class="s1">&#39;%Migrations complete%&#39;</span><span class="w"> </span><span class="k">THEN</span><span class="w"> </span><span class="s1">&#39;migrated&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="k">WHEN</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;%&#34;GET /healthz%&#39;</span><span class="w">       </span><span class="k">THEN</span><span class="w"> </span><span class="s1">&#39;serving&#39;</span><span class="w"> </span><span class="k">END</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">phase</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">FROM</span><span class="w"> </span><span class="n">logs</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">WHERE</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;myapp&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="k">timestamp</span><span class="p">::</span><span class="nb">date</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;2026-09-08&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="n">instance</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="k">min</span><span class="p">(</span><span class="k">timestamp</span><span class="p">)</span><span class="w"> </span><span class="n">FILTER</span><span class="w"> </span><span class="p">(</span><span class="k">WHERE</span><span class="w"> </span><span class="n">phase</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;begin&#39;</span><span class="p">)</span><span class="w">    </span><span class="k">AS</span><span class="w"> </span><span class="n">started</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="k">min</span><span class="p">(</span><span class="k">timestamp</span><span class="p">)</span><span class="w"> </span><span class="n">FILTER</span><span class="w"> </span><span class="p">(</span><span class="k">WHERE</span><span class="w"> </span><span class="n">phase</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;migrated&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">migrated</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="k">min</span><span class="p">(</span><span class="k">timestamp</span><span class="p">)</span><span class="w"> </span><span class="n">FILTER</span><span class="w"> </span><span class="p">(</span><span class="k">WHERE</span><span class="w"> </span><span class="n">phase</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;serving&#39;</span><span class="p">)</span><span class="w">  </span><span class="k">AS</span><span class="w"> </span><span class="n">first_serve</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="n">s</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">instance</span></span></span></code></pre></div></div>
<p>在每一台机器上，答案都一致：</p>
<table>
	<thead>
			<tr>
					<th>阶段</th>
					<th>耗时</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>启动到迁移完成</td>
					<td>10 到 13 秒</td>
			</tr>
			<tr>
					<td>迁移到处理第一个请求</td>
					<td>8 到 19 秒</td>
			</tr>
			<tr>
					<td><strong>合计</strong></td>
					<td><strong>25 到 33 秒</strong></td>
			</tr>
	</tbody>
</table>
<p>然后我数了数这件事发生的频率：</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">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">starts</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="k">DISTINCT</span><span class="w"> </span><span class="n">instance</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">machines</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="n">logs</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">WHERE</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;myapp&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="k">timestamp</span><span class="p">::</span><span class="nb">date</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;2026-09-08&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">ILIKE</span><span class="w"> </span><span class="s1">&#39;%Starting app server%&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">-- starts: 92, machines: 10</span></span></span></code></pre></div></div>
<p>一天 92 次冷启动。每一次都打开一个半分钟的窗口，任何被路由到那台机器的请求都会超时。22% 不再神秘了。</p>

<h2 class="relative group">原因就在我自己的 entrypoint 里
    <div id="原因就在我自己的-entrypoint-里" 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%8e%9f%e5%9b%a0%e5%b0%b1%e5%9c%a8%e6%88%91%e8%87%aa%e5%b7%b1%e7%9a%84-entrypoint-%e9%87%8c" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这是每一台机器在响应任何请求之前都要跑的东西：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="c1"># entrypoint.sh, run on every machine start</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;Running migrations...&#34;</span>
</span></span><span class="line"><span class="cl">python manage.py migrate --noinput
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;Running collectstatic in background...&#34;</span>
</span></span><span class="line"><span class="cl">python manage.py collectstatic --noinput <span class="p">&amp;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span>    <span class="c1"># then start the app server</span></span></span></code></pre></div></div>
<p>带着「一天 92 次」再读一遍。</p>
<p><code>migrate</code> 是一次完整的 Django 启动：导入所有模型、连上 Postgres、查询迁移表，然后判定无事可做。在共享 CPU 上，为了什么也不做花掉十秒。<code>collectstatic</code> 是第二次完整的 Django 启动，之后还要把静态文件上传到对象存储，同时和正在启动的 Web 服务器抢同一个核心。应用服务器是第三次启动。</p>
<p>三个 Python 解释器，其中两个在重复已经做过的事，机器每醒来一次就来一遍。</p>
<p>当初这么写的理由是站得住脚的，你的代码大概也长得差不多：迁移总得在某处执行，Fly 的 <code>release_command</code> 曾经卡住过，而 entrypoint 是唯一保证会在部署时执行的地方。把部署工作放在这里是对的，只是把它放在<strong>每次机器启动</strong>时不对。而一旦你的机器开始自己停自己起，这两件事就不再是同一个事件了。</p>
<p>整个 bug 就是这一句，我认为它相当常见：</p>
<blockquote><p>属于<strong>发布</strong>的工作，被按<strong>机器启动</strong>执行了。缩容到零把一个事件变成了九十二个。</p>
</blockquote>
<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="#%e8%a7%a3%e6%b3%95%e6%8a%8a%e5%8f%91%e5%b8%83%e5%8f%aa%e8%ae%a4%e9%a2%86%e4%b8%80%e6%ac%a1" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>某个镜像的第八台机器不需要跑迁移。这个镜像只需要跑一次，其他机器只需要知道已经跑过了。</p>
<p>这就是一把每次部署都换名字的锁。Fly 用 <code>FLY_IMAGE_REF</code> 白送了这个名字，而 Redis 我本来就有。整个东西是一个在 Django 存在之前就运行的小脚本，所以常见情况下的答案是毫秒级，而不是一次框架启动：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># release_gate.py: runs before Django exists. Exit code 0 means &#34;prepare this release&#34;.</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">os</span><span class="o">,</span> <span class="nn">time</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">PREPARE</span><span class="p">,</span> <span class="n">SKIP</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">LOCK_TTL</span><span class="p">,</span> <span class="n">WAIT</span><span class="p">,</span> <span class="n">POLL</span> <span class="o">=</span> <span class="mi">300</span><span class="p">,</span> <span class="mi">240</span><span class="p">,</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl"><span class="n">_sleep</span><span class="p">,</span> <span class="n">_monotonic</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">sleep</span><span class="p">,</span> <span class="n">time</span><span class="o">.</span><span class="n">monotonic</span>    <span class="c1"># aliases, so tests can replace them</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">release_id</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Something that changes exactly when the deployed image does.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;FLY_IMAGE_REF&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;FLY_MACHINE_VERSION&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">claim</span><span class="p">(</span><span class="n">client</span><span class="p">,</span> <span class="n">release</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">client</span> <span class="ow">is</span> <span class="kc">None</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">release</span><span class="p">:</span>   <span class="c1"># no Redis, or no idea which release: behave as before</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">PREPARE</span>
</span></span><span class="line"><span class="cl">    <span class="n">done_key</span><span class="p">,</span> <span class="n">lock_key</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;release:</span><span class="si">{</span><span class="n">release</span><span class="si">}</span><span class="s2">:done&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;release:</span><span class="si">{</span><span class="n">release</span><span class="si">}</span><span class="s2">:lock&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">client</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">done_key</span><span class="p">):</span>            <span class="c1"># somebody already did it for this image</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">SKIP</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">owner</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;FLY_MACHINE_ID&#34;</span><span class="p">,</span> <span class="s2">&#34;unknown&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">client</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="n">lock_key</span><span class="p">,</span> <span class="n">owner</span><span class="p">,</span> <span class="n">nx</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">ex</span><span class="o">=</span><span class="n">LOCK_TTL</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">PREPARE</span>                  <span class="c1"># we won the race; we do the work</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Someone else is preparing. Wait for them, because serving requests</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># against a half-migrated schema is worse than starting slowly.</span>
</span></span><span class="line"><span class="cl">    <span class="n">deadline</span> <span class="o">=</span> <span class="n">_monotonic</span><span class="p">()</span> <span class="o">+</span> <span class="n">WAIT</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="n">_monotonic</span><span class="p">()</span> <span class="o">&lt;</span> <span class="n">deadline</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">_sleep</span><span class="p">(</span><span class="n">POLL</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">client</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">done_key</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="n">SKIP</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">client</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="n">lock_key</span><span class="p">,</span> <span class="n">owner</span><span class="p">,</span> <span class="n">nx</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">ex</span><span class="o">=</span><span class="n">LOCK_TTL</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="n">PREPARE</span>              <span class="c1"># the owner died and its lock expired; take over</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">PREPARE</span>                      <span class="c1"># waited long enough; do it ourselves</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ... plus `done` (sets done_key) and a main() that treats any Redis error as PREPARE</span></span></span></code></pre></div></div>
<p>entrypoint 变成一个 <code>if</code>：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="k">if</span> python3 release_gate.py claim<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    python manage.py migrate --noinput
</span></span><span class="line"><span class="cl">    python manage.py collectstatic --noinput <span class="p">&amp;</span>
</span></span><span class="line"><span class="cl">    python3 release_gate.py <span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span></span></span></code></pre></div></div>

<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%af%8f%e4%b8%80%e7%a7%8d%e5%a4%b1%e8%b4%a5%e9%83%bd%e5%bf%85%e9%a1%bb%e8%90%bd%e5%9c%a8%e9%82%a3%e5%b0%b1%e5%87%86%e5%a4%87%e4%b8%8a" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>比起代码，这一节更值得抄走。</p>
<p>这样一个门闩，站在你的用户和一次库迁移之间。如果它朝<strong>跳过</strong>的方向出错，你就会对着一个没迁移的数据库提供服务，那是真正的故障。如果它朝<strong>准备</strong>的方向出错，你只是把一次幂等的空迁移跑了两遍，浪费十秒。</p>
<p>这两个结果并不对称，所以代码也不能对称地对待它们。每一条不确定的路径都返回 <code>PREPARE</code>：</p>
<ul>
<li><strong>没有 Redis，或者 Redis 不可达。</strong> 准备。这正是旧行为，于是缓存故障退化成「慢」，而不是「坏」。</li>
<li><strong>环境里没有镜像引用。</strong> 准备。我不知道这是哪个发布，也就无从知道它是否就绪。</li>
<li><strong>锁的持有者在迁移途中死了。</strong> 它的锁过期，下一个等待者接管并准备。</li>
<li><strong>等待超过了超时时间。</strong> 还是准备。一台永远不提供服务的机器，比一次重复迁移更糟。</li>
</ul>
<p>反方向上我故意留了一处不对称：发现另一台机器确实握着锁的机器会<strong>等待</strong>，而不是开始服务。部署期间启动慢一点没关系，对着只迁移了一半的表结构接受查询则不行。</p>
<p>如果你把它建在一个会吞掉连接错误的缓存包装器上（我一开始就是这么做的），有一个陷阱。这种包装器在 Redis 不可达时让 <code>add()</code> 返回 <code>False</code>，而这读起来和「别人正持有锁」一模一样，那会让每台机器都跳过迁移——恰好是危险的那个方向。用回读锁来区分：一把没有任何人持有的锁，意味着缓存坏了，而不是你输掉了竞争。</p>

<h2 class="relative group">不用 Redis 也能测
    <div id="不用-redis-也能测" 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-redis-%e4%b9%9f%e8%83%bd%e6%b5%8b" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这段逻辑值得写单元测试，因为真正有意思的路径恰恰是你没法手工复现的。一个两方法的假对象就够了：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">FakeRedis</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Enough of a Redis client for the gate: get, and set with nx/ex.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">store</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">get</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">):</span> <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">store</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">set</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">,</span> <span class="n">value</span><span class="p">,</span> <span class="n">nx</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">ex</span><span class="o">=</span><span class="kc">None</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">nx</span> <span class="ow">and</span> <span class="n">key</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">store</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">store</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="n">value</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">True</span></span></span></code></pre></div></div>
<p>然后按自己的节奏推动时间，去跑那些别扭的分支。这也是门闩调用模块级的 <code>_sleep</code> 和 <code>_monotonic</code> 别名，而不直接调用 <code>time.sleep</code> 的原因，这样测试替换它们时不会波及同进程里的其他线程：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">test_a_waiter_takes_over_when_the_owner_disappears</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">redis</span> <span class="o">=</span> <span class="n">FakeRedis</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">release_gate</span><span class="o">.</span><span class="n">claim</span><span class="p">(</span><span class="n">redis</span><span class="p">,</span> <span class="s2">&#34;img-1&#34;</span><span class="p">),</span> <span class="n">release_gate</span><span class="o">.</span><span class="n">PREPARE</span><span class="p">)</span>   <span class="c1"># the owner takes the lock</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">owner_dies</span><span class="p">(</span><span class="n">_seconds</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">redis</span><span class="o">.</span><span class="n">store</span><span class="o">.</span><span class="n">pop</span><span class="p">(</span><span class="s2">&#34;release:img-1:lock&#34;</span><span class="p">,</span> <span class="kc">None</span><span class="p">)</span>                              <span class="c1"># its lock expired</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">patch</span><span class="o">.</span><span class="n">object</span><span class="p">(</span><span class="n">release_gate</span><span class="p">,</span> <span class="s2">&#34;_sleep&#34;</span><span class="p">,</span> <span class="n">owner_dies</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">release_gate</span><span class="o">.</span><span class="n">claim</span><span class="p">(</span><span class="n">redis</span><span class="p">,</span> <span class="s2">&#34;img-1&#34;</span><span class="p">),</span> <span class="n">release_gate</span><span class="o">.</span><span class="n">PREPARE</span><span class="p">)</span></span></span></code></pre></div></div>

<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%9c%89%e6%95%88%e6%9e%9c%e5%90%97%e4%b8%80%e5%8d%8a" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>部署之后，停掉一台机器再启动，同时看着表。现在启动过程会说出自己的判断，然后立刻让路：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">15:23:34  Starting app server...
</span></span><span class="line"><span class="cl">15:23:36  release gate: this release is already prepared, starting straight away
</span></span><span class="line"><span class="cl">15:23:44  &#34;GET /healthz HTTP/1.1&#34; 200</span></span></code></pre></div></div>
<p>十秒，第二台量到十一秒。对比之前的 25 到 33 秒，这个窗口消失了三分之二，而门闩本身只花两秒，因为它从不导入 Django。</p>
<p>但十秒还是十秒。落在这十秒最开头的请求依然会失败。于是我去找剩下的部分。</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%b2%a1%e6%9c%89%e4%ba%ba%e5%9c%a8%e7%bc%93%e5%ad%98%e5%ad%97%e8%8a%82%e7%a0%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>镜像里有一行，几乎每一个 Python Dockerfile 都写着它：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="k">ENV</span> <span class="nv">PYTHONDONTWRITEBYTECODE</span><span class="o">=</span><span class="m">1</span></span></span></code></pre></div></div>
<p>这是好建议。容器不该在运行时把 <code>.pyc</code> 涂进镜像层里。我没有想透的是这笔交易的另一半：如果运行时从不写字节码，而构建时也不写，那就没有任何东西被缓存，于是每一次进程启动都要把整棵依赖树从源码编译一遍。</p>
<p>我数了数：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">site-packages .py  files : 5,707
</span></span><span class="line"><span class="cl">site-packages .pyc files : 0</span></span></code></pre></div></div>
<p>Django、REST 框架、每一个库，在那 92 次日常启动里每次都重新编译。在生产机器上实测：</p>
<table>
	<thead>
			<tr>
					<th><code>django.setup()</code></th>
					<th>耗时</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>出厂状态</td>
					<td>5.15 秒</td>
			</tr>
			<tr>
					<td><code>compileall</code> 之后</td>
					<td>3.17 秒</td>
			</tr>
	</tbody>
</table>
<p>修复是一行，而且 <code>compileall</code> 是显式写入，所以那个环境变量拦不住它：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="k">RUN</span> python -m compileall -q /app/.venv/lib /app/myapp <span class="o">||</span> true</span></span></code></pre></div></div>
<p>十六秒的构建时间，只此一次，还在一个可缓存的层里。</p>
<p>顺手还发现了一个更安静的问题。<code>.dockerignore</code> 里写着：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">__pycache__/
</span></span><span class="line"><span class="cl">*.pyc</span></span></code></pre></div></div>
<p>Docker 是拿它们去匹配<strong>整条</strong>相对路径，而不是路径的每一段，所以没有锚定的模式只会在构建上下文的根目录生效。所有嵌套的 <code>__pycache__</code> 都被打进了镜像。里面有 218 个由笔记本上的 Python 3.15 编译出来的 <code>.pyc</code>，躺在一个解释器是 3.14 的镜像里，被安静地全部忽略。正确的写法是 <code>**/__pycache__/</code> 和 <code>**/*.pyc</code>。</p>
<p>诚实的结果：端到端只换来大约一秒，而不是两秒。十秒变成九秒。孤立的基准测试总是这样高估自己。</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%8c%82%e8%b5%b7%e4%bb%a5%e5%8f%8a%e6%8c%a1%e4%bd%8f%e5%ae%83%e7%9a%84%e9%82%a3%e4%b8%80%e8%a1%8c" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>到了九秒，我已经没有可以再删的工作了。剩下的是一次无法避免的启动：Python 起来、Django 导入、WSGI 应用在一个共享核心上立起来。</p>
<p>那就别启动。Fly 可以给机器的内存做快照，而不是把它关掉：</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">http_service</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;suspend&#39;</span>   <span class="c"># was &#39;stop&#39;</span></span></span></code></pre></div></div>
<p>我去测试，Fly 拒绝了我，并给出了整件事里最有用的一条错误信息：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">failed to suspend VM: failed_precondition:
</span></span><span class="line"><span class="cl">Machines with swap cannot be suspended</span></span></code></pre></div></div>
<p>我的配置顶部有这么一段，注释是我自己写的，我也一直相信它：</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="c"># Swap cushion so a memory spike swaps to disk instead of getting OOM-killed.</span>
</span></span><span class="line"><span class="cl"><span class="nx">swap_size_mb</span> <span class="p">=</span> <span class="mi">2048</span></span></span></code></pre></div></div>
<p>听起来是合理的预防。它真的在干活吗？每一台机器的回答都是：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">MemTotal:   985220 kB
</span></span><span class="line"><span class="cl">SwapTotal: 2097148 kB
</span></span><span class="line"><span class="cl">SwapFree:  2097148 kB</span></span></code></pre></div></div>
<p>一页都没有换出过。当天的日志里也没有 OOM kill——那 11 行匹配到 &ldquo;oom&rdquo; 的记录，是对一个内容哈希里恰好含有这三个字母的 JavaScript 包的请求。而真正的内存护栏在完全不同的地方，在应用服务器自己的配置里：任何 worker 一旦超过 800MB 就会被重启，远在触到 1GB 天花板之前。</p>
<p>所以这层缓冲是一份针对从未发生过的事件的保险，而且已经被另一套机制覆盖，它的保费是挂起能力。删掉。</p>
<table>
	<thead>
			<tr>
					<th>唤醒路径</th>
					<th>到能响应</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>今天早上的冷启动</td>
					<td>25 到 33 秒</td>
			</tr>
			<tr>
					<td>加了门闩和字节码之后的冷启动</td>
					<td>9 到 10 秒</td>
			</tr>
			<tr>
					<td><strong>从挂起恢复</strong></td>
					<td><strong>2.5 到 3.1 秒</strong></td>
			</tr>
	</tbody>
</table>
<p>测了三次，波动不超过半秒。唤醒之后每个端点都在 250 到 435 毫秒内返回 200。</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%9c%ba%e5%99%a8%e7%9d%a1%e7%9d%80%e6%97%b6%e4%bd%a0%e7%9a%84%e5%a5%97%e6%8e%a5%e5%ad%97%e4%bc%9a%e6%80%8e%e6%a0%b7" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>不会有好事，而且没有钩子可以补救。Fly 冻结整个虚拟机：进程收不到信号，下坠途中什么也关不掉。它握着的连接在醒来时仍然留在内存里，指向对端几分钟前就已经丢弃的套接字。</p>
<p>这个问题不在下去的时候解，而在上来的时候解，而且多半取决于你之前是否已经做对了选择：</p>
<ul>
<li><strong>数据库连接按请求打开</strong>（<code>conn_max_age = 0</code>），没有活得够久、够得着变质的连接。</li>
<li><strong>Redis 缓存是降级而不是抛异常。</strong> 包装器捕获连接错误并返回一次未命中。</li>
</ul>
<p>我亲眼看到第二条挣到了它的位置。某次恢复时，日志里出现了完全在预料之中的失败：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">redis.exceptions.ConnectionError: Error while reading from fly-...</span></span></code></pre></div></div>
<p>而它所在的那个请求，用 3.6 秒返回了 200——死掉的缓存意味着慢一点的页面，而不是错误页面。之后的恢复什么都没记。</p>
<p>如果你的缓存客户端在连接死掉时会抛异常，挂起就会把每一次唤醒变成一串 500。在打开这个开关<strong>之前</strong>确认，而不是之后。</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%82%a3%e4%b8%aa%e9%aa%97%e4%ba%86%e6%88%91%e7%9a%84%e6%b5%8b%e8%af%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我第一次测挂起，结论是这功能糟透了：502，30 秒之后，而且连着两次。</p>
<p>我用 <code>fly-force-instance-id</code> 把请求钉在了某一台机器上。用来压测单个实例时这是个好头部，用在这里则糟糕透顶：强制指定实例会绕过本该把它唤醒的代理逻辑。Fly 其实早就告诉我了，只要我把它当成答案而不是错误来读：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">machine was recently stopped and is unavailable to service request</span></span></code></pre></div></div>
<p>有意义的数字来自真实流量走的那条路。经由边缘发出 40 个并发请求，远超 12 的软上限，逼得代理必须扩容到挂起的机器上：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">status codes:  40 × 200
</span></span><span class="line"><span class="cl">slowest:       2.1s</span></span></code></pre></div></div>
<p>两个教训，贵的是第二个。去测量用户走的那条路，而不是方便插桩的那条路。以及，当你的工具拒绝你的时候，去读那句拒绝——&ldquo;Machines with swap cannot be suspended&rdquo; 这一句，打开了整个下午。</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%e5%bb%ba%e8%ae%ae%e4%bd%a0%e5%9c%a8%e8%87%aa%e5%b7%b1%e7%9a%84%e5%ba%94%e7%94%a8%e9%87%8c%e6%a3%80%e6%9f%a5%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>如果你跑在任何会停掉空闲实例的平台上，下面这些问题我花了半天，本可以省下好几周的静默失败：</p>
<ol>
<li><strong>冷启动要多久？</strong> 不是容器跑起来要多久，而是到它能响应一个真实请求要多久。如果你没法用秒数直接回答，就在需要它之前先量出来。</li>
<li><strong>你的 entrypoint 里，哪些按启动执行的事其实属于部署？</strong> 迁移、静态文件收集、缓存预热、索引构建。做一次都没问题，做九十二次都很贵。</li>
<li><strong>有东西在缓存你的字节码吗？</strong> 只有 <code>PYTHONDONTWRITEBYTECODE</code> 而构建里没有 <code>compileall</code>，答案就是没有。</li>
<li><strong>你能用挂起代替停止吗？</strong> 如果不能，是什么挡着？我这边是一个从未被碰过的 swap。</li>
<li><strong>边缘看得见、源站看不见的错误存在吗？</strong> 把两边比一比。那个差值不是统计口径的怪癖，而是死在中间的请求，你的应用日志永远不会显示它们。</li>
</ol>
<p>顺带一提，这段时间里健康检查一直是绿的。它总是绿的：健康检查跑在一台已经起来的机器上，它没法报告这台机器存在之前的那三十秒。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-scale-to-zero-504s/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
