Your Inertia SSR server is down and your site still returns 200

Your Inertia SSR server is down and your site still returns 200

你的 Inertia SSR 服务器宕机了,但网站依然返回 200 状态码

The site looked fine. Every page loaded, every blog post rendered, the styling was right, nothing in the logs. What was wrong was that Search Console had crawled two pages out of the entire sitemap, and the rest sat in Discovered – currently not indexed. Dozens of posts, almost nothing indexed, and no broken page anywhere to point at. The cause turned out to be a process that had stopped running. Not crashed loudly — just stopped. And because of how Inertia handles that case, the only clients that noticed were the ones we could not see. 网站看起来一切正常。每个页面都能加载,每篇博客都能渲染,样式也没问题,日志里也没有报错。但问题在于,Google Search Console 在整个站点地图中只抓取了两个页面,其余的都处于“已发现——目前未编入索引”的状态。几十篇文章几乎都没有被索引,却找不到任何一个报错的页面。原因最终查明是一个进程停止运行了。它没有剧烈崩溃,只是静静地停止了。由于 Inertia 处理这种情况的方式,唯一注意到这一点的客户端是我们无法直接观察到的那些。

Disclosure: I work on Bunfolio, a free portfolio-site builder for freelancers. This happened to us, and the diagnosis is the useful part. 披露:我供职于 Bunfolio,这是一个为自由职业者提供的免费作品集网站构建工具。我们遇到了这个问题,而排查过程正是本文最有价值的部分。

The failure mode

故障模式

The stack is Laravel 12, Inertia.js and React 19, with server-side rendering enabled. In that setup, php artisan inertia:start-ssr runs a small Node server (port 13714 by default) that loads your built server bundle, bootstrap/ssr/ssr.js. On each request Laravel POSTs the page object to it, gets back HTML, and the @inertia Blade directive prints that HTML inside <div id="app">. 我们的技术栈是 Laravel 12、Inertia.js 和 React 19,并启用了服务端渲染(SSR)。在这种配置下,php artisan inertia:start-ssr 会运行一个小型 Node 服务器(默认端口 13714),加载构建好的服务端包 bootstrap/ssr/ssr.js。每次请求时,Laravel 都会将页面对象 POST 给它,获取 HTML,然后 @inertia Blade 指令会将该 HTML 打印在 <div id="app"> 内部。

When that Node process is not answering, Laravel does not fail the request. It falls back to client-side rendering: it emits an empty <div id="app" data-page="{…}"> with the page props serialised into the attribute, and ships the response with a 200. Your browser downloads the JavaScript, reads data-page, renders the app, and everything looks completely normal. 当该 Node 进程无响应时,Laravel 并不会让请求失败。它会回退到客户端渲染:输出一个空的 <div id="app" data-page="{…}">,并将页面属性序列化到该属性中,然后以 200 状态码返回响应。你的浏览器下载 JavaScript,读取 data-page,渲染应用,一切看起来完全正常。

So the failure is invisible to exactly the people checking for it, and visible only to clients that do not execute JavaScript. That is a decent chunk of the things you care about: crawlers that do not render, link unfurlers, anything reading your page with an HTTP library. Google does render JavaScript, but rendering is queued and budgeted separately from crawling, and on a new site with essentially no inbound links you should assume that budget is close to zero. An empty #app is a page with no prose in it, and a page with no prose in it is not a page worth indexing. 因此,这种故障对于那些手动检查的人来说是不可见的,只有那些不执行 JavaScript 的客户端才能发现。这涵盖了你关心的很大一部分对象:不渲染 JS 的爬虫、链接预览工具,以及任何使用 HTTP 库读取你页面的程序。Google 虽然会渲染 JavaScript,但渲染任务是排队的,且与抓取预算分开计算。对于一个几乎没有入站链接的新网站,你应该假设这个渲染预算接近于零。一个空的 #app 意味着页面没有正文,而没有正文的页面是不值得被索引的。

The part that makes this a genuine trap rather than an ordinary outage: you cannot detect it by looking at the site. Uptime checks pass. Status codes are 200. Screenshots are perfect. The failure only exists in the response body, and only in the part of it that a browser immediately overwrites. 让这成为一个真正的“陷阱”而非普通故障的原因在于:你无法通过查看网站来发现它。正常运行时间检查通过,状态码是 200,截图完美。故障仅存在于响应正文中,且仅存在于浏览器会立即覆盖的那部分内容中。

Why the process stops

进程为何停止

Three ways we found to end up here, all of them quiet. 我们发现了三种导致这种情况的方法,它们都非常隐蔽。

  1. It was never supervised. Someone started inertia:start-ssr by hand over SSH to test it. It works, the page renders, everyone moves on. The shell closes, the process dies with it, and the app keeps serving 200s.

  2. 没有被守护进程管理。 有人通过 SSH 手动启动了 inertia:start-ssr 进行测试。它运行正常,页面渲染成功,大家就没再管它。当 Shell 关闭时,进程随之终止,但应用依然返回 200。

  3. The server bundle throws at render time. Our own version of this: twenty-two components called a bare global route(), which the @routes Blade directive defines in the browser. Node has no such global, so every server render died with route is not defined — and Inertia treated that the same way it treats a dead process, by falling back to the client.

  4. 服务端包在渲染时抛出异常。 我们遇到的情况是:22 个组件调用了一个裸的全局 route() 函数,该函数由 @routes Blade 指令在浏览器中定义。Node 环境中没有这个全局变量,因此每次服务端渲染都因 route is not defined 而失败——而 Inertia 将其视为与进程死亡相同的情况,回退到客户端渲染。

The fix was a shim at the top of ssr.jsx: 修复方法是在 ssr.jsx 的顶部添加一个垫片(shim):

import { route as routeFn } from 'ziggy-js';
import { Ziggy } from './ziggy';
const ziggyConfig = { ...Ziggy, url: process.env.APP_URL ?? Ziggy.url };
global.route = (name, params, absolute, config = ziggyConfig) => routeFn(name, params, absolute, config);

Taking the host from APP_URL at run time rather than from the value baked into ziggy.js at build time matters too, otherwise the markup React hydrates against does not match what the client would have produced. It is running the wrong code. More on that below — it is the second trap and it is worse than the first. 在运行时从 APP_URL 获取主机名,而不是使用构建时写入 ziggy.js 的值,这一点也很重要。否则,React 进行水合(hydrate)时的标记将与客户端生成的内容不匹配。这会导致运行错误的代码。下文会详细说明——这是第二个陷阱,而且比第一个更糟糕。

Detecting it properly

如何正确检测

There is a built-in health check, and you should run it: php artisan inertia:check-ssr. But be clear about what it proves. It opens a connection to the configured SSR URL and confirms something answers. It does not prove your public site is using that process, that the bundle it loaded renders your pages without throwing, or that the HTML reaching a crawler contains anything. A green check-ssr with an empty #app in production is entirely possible, and is precisely the state we were in. 内置了一个健康检查工具,你应该运行它:php artisan inertia:check-ssr。但要清楚它证明了什么:它只是打开一个到配置的 SSR URL 的连接,确认有东西在响应。它不能证明你的公共网站正在使用该进程,不能证明加载的包在渲染页面时没有报错,也不能证明到达爬虫的 HTML 包含任何内容。在生产环境中,check-ssr 显示绿色但 #app 为空是完全可能的,这正是我们当时的状态。

The real test is to be the crawler. Fetch the page over HTTP, find <div id="app">, strip the scripts and tags out of everything after it, and count what is left. 真正的测试方法是模拟爬虫。通过 HTTP 获取页面,找到 <div id="app">,剔除其后的所有脚本和标签,然后统计剩余的内容。

Every line should report thousands of characters. A 0 means SSR is not reaching that page, and the deploy has failed for search purposes even though the site works perfectly in a browser. Stripping <script> blocks before counting is the load-bearing detail. Without it you are counting the serialised data-page JSON, which is large and present in both the working and the broken case — so the check would pass either way. 每一行都应该报告数千个字符。如果结果为 0,意味着 SSR 没有到达该页面,尽管网站在浏览器中运行完美,但从搜索优化的角度来看,这次部署已经失败了。在统计前剔除 <script> 块是关键细节。如果不这样做,你统计的就是序列化后的 data-page JSON 数据,它在正常和故障情况下都存在,因此检查会始终通过。

For the authoritative version, use “Test live URL” in Search Console and open “View tested page” → “HTML”. That is Google telling you what Google got. 权威的验证方法是使用 Search Console 中的“测试实际网址”,然后打开“查看测试后的网页”→“HTML”。那是 Google 在告诉你它实际获取到了什么。

The second trap: a stale bundle

第二个陷阱:过期的包

This one cost more time than the outage, because nothing at all appears wrong. inertia:start-ssr reads bootstrap/ssr/ssr.js once, at boot. If you rebuild assets and do not restart the process, it carries on rendering the previous bundle indefinitely. Your browser fetches the new client build and hydrates over the old server HTML, so the page you are looking at is correct and current. Meanwhile curl — and every crawler — gets markup from whatever your code looked like at the last restart. The page is not broken. It is just old. 这比宕机本身花费了更多时间,因为看起来一切正常。inertia:start-ssr 只在启动时读取一次 bootstrap/ssr/ssr.js。如果你重新构建了资源但没有重启进程,它会无限期地继续渲染旧的包。你的浏览器获取了新的客户端构建并覆盖了旧的服务端 HTML,所以你看到的页面是正确且最新的。与此同时,curl 以及所有爬虫获取到的却是上次重启时代码生成的标记。页面没有坏,只是过时了。