成为全栈·基础补充·Linux 服务器入门
我部署这个项目的第一次尝试,失败在一个我完全没想到的地方:
toml
# 注意:即便生产用 STORAGE_DRIVER=r2、local 驱动永不被实例化,
# 顶层 node:fs 导入仍会求值,故本 flag 不可省。
compatibility_flags = ["nodejs_compat"]
这是我自己在 wrangler.toml 里写的注释 ,而它记录的是一个真实踩过的坑:Cloudflare Workers 不支持 node:* 的顶层导入 ,即使那段代码的分支永远不会执行。

而这件事的教训不是"Workers 有个限制"------而是我的代码结构让一个永不执行的分支产生了副作用。
这篇讲服务器上那些"本地没事、线上出事"的问题,以及判断它们的方法。
服务器和本地最大的不同:文件系统
先讲一条最容易踩、也最难查的差异。
你的开发机有一个可写的文件系统,服务器不一定有。
而这个项目里有一处直接相关的代码:
ts
/** 可重建缓存的容量预算;本地投稿草稿不在这些清理范围内。 */
static const dataDiskBytes = 30 * mib;

这个缓存要写盘------在本地 SQLite 上是毫无问题的(就是一个文件),而在线上:
| 环境 | 数据库 | 写盘 |
|---|---|---|
| 本地 | SQLite 文件 | ✅ |
| D1 | Cloudflare 托管 | 不存在这个概念 |
| Workers 本地盘 | 只读 | ❌ 失败 |
而 wrangler.toml 里那行配置就是在处理这个:
toml
STORAGE_DRIVER = "r2" # 生产必须 r2(local 写盘在 CF 只读文件系统会失败)
"local 写盘在 CF 只读文件系统会失败" ------这行注释记录的就是那个现象:代码按预期走了 local 驱动,然后写盘失败了。
而失败的表现很糟糕:它不会在启动时报错 ,因为那段代码在启动时没执行;而是在第一次写缓存时才失败------那时候可能已经是运行几小时之后。
所以这条规则是:配置项不能只按"当前会不会用到"来决定值,要按"万一被用到时会怎样"来决定。
而 M4-27 讲的那个 --dart-define 默认值也是同一类判断:默认值要让"产物能用",而不是让错误早暴露。
环境变量的三个来源
服务器上的配置来自三个地方,而它们的优先级和生效时机都可能不一致:
bash
# 1. 环境变量
export NODE_ENV=production
# 2. 配置文件
wrangler.toml 里的 [vars]
# 3. 加密 secret(不在配置文件里)
wrangler secret put JWT_SECRET
而这个项目的组织方式很清晰:
toml
[vars]
NODE_ENV = "production"
DB_FILE = ":memory:" # CF 下无意义(走 D1 binding),保留无害
STORAGE_DRIVER = "r2" # 生产必须 r2
CORS_ORIGINS = "https://www.example.com,https://manage.example.com"
# JWT_SECRET 为敏感项,走加密 secret(不进 git / 不写本文件):
# wrangler secret put JWT_SECRET
三个设计点:
一、非敏感配置进 [vars],敏感的走 secret。 判断标准是"会不会进 git"------JWT_SECRET 进 git 就等于公开了。
二、DB_FILE = ":memory:" 保留了一个"无意义的值"。
注释写得很坦率:"CF 下无意义(走 D1 binding),保留无害。"
而这个做法是有价值的:它让本地和线上用同一份配置 schema (B-19 讲的那个 zod schema),而 schema 里这个字段有默认值 (z.string().default(':memory:'))。
如果不保留这个键,本地和线上的配置结构就不一样了------而那种差异会在某个时刻变成一个 bug。
三、CORS_ORIGINS 明确列出了两个真实域名。
而注释里有一句:
toml
# CORS:填前端实际域名(逗号分隔);留空或 '*' 将被拒跨域(审阅 B05)
"留空或 '*' 将被拒" ------这说明这个项目的 CORS 配置在代码层面就拒绝通配符,而不是"允许但不推荐"。
这是一个值得学的做法:把"不安全但常见"的做法直接禁掉,代价是配起来麻烦一点,收益是不需要每次 review 都检查 CORS。

权限:文件权限位那三个数字
服务器上最常遇到的问题是"权限不足",而它的表现往往不是清晰的报错:
bash
-rwxr-xr-x 1 user staff 5223 Aug 8 10:23 run_android.sh
| 位 | 含义 |
|---|---|
rwx |
所有者:读、写、执行 |
r-x |
同组:读、执行 |
r-x |
其他人:读、执行 |
而三个常见问题对应三个不同原因:
| 症状 | 原因 | 解法 |
|---|---|---|
Permission denied |
文件没有执行位 | chmod +x script.sh |
| 目录不可写 | 目录权限 | chmod 755 dir |
| 端口被占用 | 需要特权端口 | sudo 或改端口 |
而"改了权限还是不行"是一个常见状态------因为浏览器缓存了文件权限。
而在这个项目里 run_android.sh 是个真实的例子:它是可执行的(-rwxr-xr-x),所以可以直接 ./tool/run_android.sh 而不是 bash tool/run_android.sh。
而它开头那行 shebang 就是在解释这个文件该用哪个解释器跑:
bash
#!/usr/bin/env bash
没有 shebang 的 shell 脚本,直接执行时会用默认 shell(可能是 sh),而 sh 不支持某些 bash 语法 ------症状是"在我这能跑,在你那报错"。
进程和网络:两个必须会查的东西
部署之后最常问的两个问题是"它跑起来了吗"和"它能被别人访问吗"。而它们对应两套命令。
进程:
bash
ps aux | grep node # 有没有进程
lsof -i :3000 # 谁占了这个端口
kill -9 <pid> # 干掉它(-9 是强杀,先试 kill)
而这个项目里 run_android.sh 用了 exec:
bash
exec flutter run ...
就是让 flutter 替换掉 shell 进程 ------这样 Ctrl-C 直接传给 flutter,而不是留下一个孤儿进程。
少了 exec 的症状:终端回来了,但 flutter 还在跑、还占着设备。
网络:
bash
curl -sS https://api.example.com/api/v1/articles | jq '.code'
而这一条命令能区分三类问题(B-15 讲的那张排查表):
curl 结果 |
问题在哪 |
|---|---|
| 连不上 | 网络 / 防火墙 / 服务没起 |
| 502 | 服务没起,但代理在 |
返回 JSON 但 code != 0 |
服务正常,是业务错误 |
而 curl 是这个专栏里最有用的一个命令 ------因为它能在不打开浏览器的前提下确认"服务到底通不通"。
而 B-19 讲的那个微信登录排查,本质上就是一次"用无效 code 做 curl 探测" ------一次廉价的请求把排查范围砍掉大半。
文件传输和同步
服务器上改代码的两个方式,各有代价。
方式一:在服务器上直接改。 快,但没有历史、容易手滑 。而这个专栏的规矩是"用户自己管 commit"(M0-06 讲过),所以服务器上的改动最终要回到本地仓库------那时候很容易发现改错了地方。
方式二:本地改、推送、服务器拉取。
bash
# 服务器上
git pull
npm ci # 注意是 ci 不是 install
systemctl restart <service>
而这里有个容易出错的命令区别:
| 命令 | 行为 | 什么时候用 |
|---|---|---|
npm install |
更新 package-lock.json |
加了新依赖 |
npm ci |
完全按 lock 文件安装 | 部署时 |
npm ci 是部署的正确选择 ,因为它保证"服务器上装的版本和 lock 文件里完全一致"------而 npm install 可能因为 lock 文件的约束不一致而装出不同的版本。
这个差异在开发时看不出来(你的 lock 是最新的),只在服务器上暴露。
一条我一开始想省掉的事
那个 nodejs_compat 的坑,我第一次遇到时的判断是"那就别在顶层导入 node:fs"。
于是我把 import fs from 'node:fs' 挪进了一个函数里------只在 local 驱动真正用到时才 import。
结果代码变丑了,而且没解决问题。
因为注释里写的那句话是关键:
即便生产用 STORAGE_DRIVER=r2、local 驱动永不被实例化,顶层 node:fs 导入仍会求值,故本 flag 不可省。
而我挪进函数里之后,顶层导入确实没有了------所以那个坑其实是被我"绕过"了。
但这带来了新问题 :我为了绕过一个环境限制,把本来清晰的模块结构拆成了"动态导入的样子",而收益只是让部署通过了。
所以我后来改回来了 :保留顶层导入 + 开 nodejs_compat。
理由是:为了绕过环境限制而扭曲代码结构,代价会一直留在代码里------下一个人看到那个奇怪的动态导入,会以为是某种性能优化。
而这个判断标准我认为可以推广:
如果绕过限制的做法会让代码变得不自然,说明你在绕过的是症状而不是原因。
nodejs_compat 那个 flag 就是原因(Workers 需要显式声明支持 node 内置模块);而"顶层导入即使不执行也会求值"是它的机制。理解了机制,就知道 flag 是正解而不是 workaround。
小结
服务器这一章,沉淀下来的是四件事:
- 文件系统可能是只读的 ------ 配置项要按"万一被用到会怎样"来决定,不只看"当前用不用"。
- 配置分三种来源 (env / vars / secret),而本地和线上应该用同一份 schema,即使某些键在某个环境里无意义。
npm ci而不是npm install------ 部署必须严格按 lock 文件。curl是最重要的诊断工具 ------ 它能区分"网络不通""服务没起""业务报错"三类问题。
而这一章真正的判断标准是第 1 条背后那个:
本地能跑不能证明线上能跑,因为它们的文件系统、CPU 时间、指令集都可能不同。
这个专栏有三处真实的例子:
| 问题 | 本地 | 线上 |
|---|---|---|
| 文件写盘 | ✅ | ❌ 只读文件系统 |
| CPU 耗时 | 无限制 | Workers 有硬配额 |
| fetch 选项 | error 可用 |
不被支持 |
三个问题的共同点是:本地测试全部通过,而它们只在部署后出现。
所以这个专栏现在的做法是(B-17 记的那笔债):
CI 里必须有一条针对目标运行时的最小执行检查 ------ 因为"本地全绿"这件事本身提供不了保证。
下一篇讲 Docker。它解决的是另一个问题:把"我机器上能跑"变成"任何机器上都能跑"------而这个专栏没用 Docker,因为它的部署目标是 Cloudflare Workers(不是容器平台)。
延伸阅读
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
