· 9 分钟 · 运维 / CI/CD / 数据库 / Cloudflare
三张安全网:自动部署、错误日志和数据备份
网站功能做得差不多了,这一篇补的是「出事的时候怎么办」:改用 GitHub Actions 部署,并且部署前自动更新数据库;打开后台日志;每周自动备份数据库,并且真的测过能恢复。
这是 Seabloom 建站记录的第八篇。前面七篇都在加功能,这一篇不加任何用户看得见的东西,补的是三张「安全网」:
| 安全网 | 防的是什么 |
|---|---|
| 自动部署 | 改了代码却没上线;或者上线顺序错了导致报错 |
| 错误日志 | 网站出错了,但没人知道、也查不到原因 |
| 数据备份 | 数据被误删、被改坏,或者一次迁移出了问题 |
它们的共同点是:平时完全感觉不到,出事的那一天才知道有多重要。
一、为什么要先做这三件事
做之前先给网站做了一次体检。结果大部分都健康:129 个测试全部通过,没有已知漏洞,没有待审核的内容。但发现了三个问题:
- 自动部署两天没工作了。 推送代码后,Cloudflare 本来会自动构建和部署,但最近几次都没有反应,线上一直是手动部署的版本。如果哪天忘了手动部署,线上就会悄悄停留在旧版本。
- 没有错误日志。 后端代码里有
console.error,但这些输出没有被保存,出错了无处可查。 - 没有定期备份。 数据全在一个数据库里,误删一张表,或者一次 migration 写错了,就没有回头路。
二、自动部署:换成 GitHub Actions
原来的方式为什么不可靠
原来用的是 Cloudflare 自带的 Workers Builds:它连着 GitHub 仓库,检测到推送就构建部署。问题是它是一个「黑盒」,坏了的时候,我们看不到日志,也不知道原因(之前有一次卡在「克隆仓库」这一步)。
新的方式
项目里本来就有一个 GitHub Actions 工作流(ci.yml),每次推送都会跑格式、代码规范、类型检查、测试和构建。现在在它后面加了一个部署任务:
deploy:
needs: check # 检查全部通过才部署
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
concurrency:
group: deploy
cancel-in-progress: false # 同一时间只有一个部署,后来的排队
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
steps:
# ...安装依赖...
- run: pnpm db:migrate:remote # 先更新线上数据库
- run: pnpm run deploy # 再部署代码
几个要点:
needs: check:检查不通过就不部署。 以前测试失败了,Workers Builds 照样会部署(它不知道测试的存在)。现在,红了的代码到不了线上。
数据库迁移也自动化了。 第二篇讲过上线顺序:先改数据库,再上新代码。以前这一步靠人记,忘了就会出现「新代码访问不存在的表」。现在它写进了流水线,每次都按同样的顺序执行。
concurrency:部署排队。 如果连续推送两次,两个部署同时跑,可能会出现旧代码覆盖新代码的情况。设置同一个 group,后来的会等前一个跑完。注意这里是 cancel-in-progress: false:部署进行到一半被取消(比如数据库改了一半),比排队等待危险得多。
密钥放在 GitHub Secrets 里。 部署需要一个 Cloudflare 的 API 令牌。它不能写在代码里(代码会被很多人看到,也会留在 Git 历史里),而是存在仓库设置的 Secrets 里,只有工作流运行时能读到,日志里也会被自动打码。
令牌的权限也要尽量小:只给「编辑 Workers」和「编辑 D1」,不给 DNS、账单等其他权限。万一泄露,损失也有限。这叫最小权限原则。
没配密钥时自动跳过。 工作流先检查密钥是否存在,不存在就输出一条提示、跳过部署步骤,而不是报错失败。这样在密钥配好之前,推送代码不会每次都收到一封「构建失败」的邮件。
这就是 CI/CD
- CI(持续集成):每次提交都自动检查,尽早发现问题;
- CD(持续部署):检查通过后自动发布到线上。
第一篇讲过 CI,这次补上了 CD。整条流水线:
git push → 格式检查 → 代码规范 → 类型检查 → 测试 → 构建
→ 更新线上数据库 → 部署 → 线上更新完成
任何一步失败,后面的都不会执行。
三、错误日志
Cloudflare Workers 有一个免费的日志功能(Workers Logs),默认是关的。打开只需要在 wrangler.jsonc 里加一行:
"observability": { "enabled": true }
之后,后端里所有的 console.log / console.error,以及每个请求的基本信息(路径、状态码、耗时),都会被保存下来,可以在 Cloudflare 后台搜索。免费版每天 20 万条,对个人网站足够了。
后端本来就有统一的错误处理,所有没被捕获的异常都会走到这里:
app.onError((err, c) => {
console.error(err); // 打开日志后,这一行的输出就能在后台查到了
return c.json({ error: { code: "internal", message: "服务器出错了,请稍后再试" } }, 500);
});
注意给用户看的是一句通用的「服务器出错了」,详细的错误只写进日志。如果把异常的原文返回给用户,可能会泄露代码结构、SQL 语句这类信息,给攻击者提供线索。
四、数据备份
两个指标:能丢多少,要多久恢复
设计备份方案时,有两个常用的指标:
- RPO(Recovery Point Objective,恢复点目标):出事后,最多能接受丢掉多久的数据?
- RTO(Recovery Time Objective,恢复时间目标):出事后,多久能恢复?
银行的 RPO 接近零,一笔交易都不能丢;个人网站丢掉几天的数据可以接受,但最好不要丢掉全部。
两层保护
第一层:D1 时间旅行(Time Travel)
D1 自带的功能,不需要任何配置。它记录了数据库最近一段时间内的每一次变化,可以把数据库恢复到这段时间内的任意时刻,RPO 几乎是零。
wrangler d1 time-travel restore seabloom --timestamp 2026-10-30T12:00:00Z
适合「刚才误删了数据」这种情况。缺点是只保留最近几天,而且数据和数据库在同一个地方。
第二层:每周自动备份
用 Workers 的定时任务(Cron Triggers),每周一凌晨把所有表导出成一个 JSON 文件,存进 R2:
"triggers": { "crons": ["0 19 * * SUN"] }
export default {
scheduled: (_event, env, ctx) => {
ctx.waitUntil(runBackup(env)); // 定时任务入口
},
fetch: (request, env, ctx) => ... // 原来的请求入口
};
waitUntil 的意思是:让 Worker 等这个任务做完再结束。Worker 的生命周期很短,不加的话,函数返回后任务可能会被中途掐断。
导出的逻辑很简单:从 sqlite_master 查出所有表,逐个 SELECT *:
const { results } = await db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%'",
)
.all();
for (const { name } of results) {
if (SKIPPED.has(name)) continue;
tables[name] = (await db.prepare(`SELECT * FROM "${name}"`).all()).results;
}
几个决定:
- 会话表和登录尝试记录不备份。 它们是临时数据,恢复后大家重新登录就好。少备份一点敏感数据,备份文件泄露时的风险也小一点。
- 只保留最近 8 份,更早的自动删除。每份现在只有几十 KB,存储不是问题,但不能让它无限增长。
- 按表自动发现,而不是写死表名。 以后加了新表,备份会自动包含它,不会因为忘了改备份代码而漏掉。
- 站长也可以手动备份、下载:在做有风险的操作(比如一次大的数据迁移)之前,先点一下「立即备份」;下载一份存在自己电脑上,就多了一个不在 Cloudflare 上的副本。
没测试过恢复的备份,等于没有备份
这是运维里一句很有名的话。很多事故的真相是:备份一直在做,但从来没人试过恢复,真到要用的时候才发现备份文件是坏的、不完整的,或者没人知道怎么恢复。
所以恢复的方法是写成代码、并且有测试的:
it("恢复:导出 → 转成 SQL → 在一个空库里执行,数据一模一样", async () => {
const backup = await exportDatabase(client.env.DB);
const fresh = createTestClient(); // 一个只有表结构、没有数据的新库
restore(fresh, toRestoreSql(backup));
// 每张表的数据都和原来一模一样
// 恢复出来的账号,能用原来的密码直接登录
});
最后那一条检查很关键:数据「看起来」一样还不够,要证明它能用。
恢复时用的是一个脚本,把备份的 JSON 转成 SQL,再用 wrangler 执行:
node scripts/restore-backup.mts seabloom-2026-10-30T19-00-00Z.json > restore.sql
wrangler d1 execute seabloom --remote --file restore.sql
生成的 SQL 大致是:
PRAGMA defer_foreign_keys = ON;
DELETE FROM "posts";
DELETE FROM "users";
INSERT INTO "users" ("id", "nickname", ...) VALUES ('u1', 'O''Neil', ...);
INSERT INTO "posts" ...
- 单引号要转义:昵称里如果有
'(比如 O'Neil),要写成'',否则 SQL 语法就断了。测试里专门放了一条带单引号和换行的帖子。 defer_foreign_keys:表之间有外键,比如帖子必须属于一个存在的用户。恢复时先删后插,顺序很难保证完全正确。这个设置让数据库等到事务结束时才检查外键,中间的顺序就无所谓了。
五、踩过的坑
外键检查只在事务里才能推迟
恢复测试第一次跑,报错 FOREIGN KEY constraint failed。
原因是 defer_foreign_keys 只在一个事务内部有效。D1 执行一个 SQL 文件时,会把整个文件放在一个事务里,所以线上没问题;但测试用的本地 SQLite 是一条语句一个事务,推迟检查就失效了。
修法是在测试里手动加上 BEGIN 和 COMMIT,让本地环境和 D1 的行为一致,并在旁边写清楚为什么要这样做。测试环境和线上环境的差异,是这类问题最常见的来源。
Cloudflare 的 cron 里,星期天不是 0
部署时报错:invalid cron string: 0 19 * * 0。
标准的 cron 里,星期几的取值是 0~6,0 代表星期天。但 Cloudflare 的 cron 用的是 1~7(1 代表星期天),0 是非法的。改成 SUN 这种英文缩写,就不会有歧义了。
能用名字就别用数字,这条规则在很多地方都适用:SUN 比 0 或 1 清楚,"owner" 比 2 清楚。
另外,cron 用的是 UTC 时间。0 19 * * SUN 是 UTC 周日 19:00,也就是北京时间周一凌晨 3 点。选凌晨,是因为那时访问的人最少。
让 Node 直接运行 TypeScript
恢复脚本需要复用后端的 toRestoreSql 函数(后端代码是 TypeScript)。较新的 Node(23.6 和 22.18 之后)可以直接运行 .ts 文件(自动去掉类型标注),不需要先编译。这台电脑上是 Node 24。但有两个前提:
- import 时要写完整的
.ts后缀; - 被 import 的文件不能再 import 别的不带后缀的模块。
所以 backup.ts 被刻意写成了「不 import 任何东西」的独立文件,文件开头的注释也说明了原因,免得以后有人加了一个 import,把恢复脚本弄坏。
六、面试怎么讲
一分钟介绍
功能做完后,我给网站补了三张安全网。部署从 Cloudflare 的托管构建换成了 GitHub Actions,测试全部通过后才部署,并且自动先跑数据库迁移再上新代码,用 concurrency 保证部署排队,令牌按最小权限放在 Secrets 里。打开了 Workers 的日志,错误细节只进日志、不返回给用户。备份分两层:D1 的时间旅行用于近期的精确恢复,每周的定时任务把所有表导出到 R2、保留 8 份;恢复脚本有测试,会把备份恢复进一个空库,并验证账号能用原密码登录。
可能被问到的问题
Q:CI 和 CD 分别是什么? A:CI 是持续集成,每次提交都自动跑检查和测试,尽早发现问题;CD 是持续交付或持续部署,检查通过后自动把代码发布到线上(或准备好随时发布)。好处是发布变得频繁、小步、可重复,出问题时也容易定位是哪次改动造成的。
Q:部署流水线里,数据库迁移应该放在什么位置? A:放在部署新代码之前,而且迁移要向后兼容:只加表、加列,不删不改,这样迁移完成后旧代码依然能正常运行。如果要删字段,分两次发布:先上一个不再使用该字段的版本,确认稳定后再删字段。迁移失败时流水线要停下,不能继续部署。
Q:密钥应该怎么管理? A:不能写进代码或提交到仓库,要放在 CI 平台的 Secrets 或专门的密钥管理服务里,运行时注入环境变量,日志里打码。权限按最小原则给,定期轮换;一旦怀疑泄露,立即作废重建。
Q:RPO 和 RTO 是什么? A:RPO 是能接受丢失多长时间的数据,决定了备份的频率;RTO 是出事后多久能恢复服务,决定了恢复流程要多快、要不要提前演练。比如每天备份一次,RPO 就是一天。
Q:怎么保证备份是可用的? A:定期做恢复演练,最好自动化:把备份恢复到一个独立的环境,检查数据是否完整、业务是否可用。我的项目里,恢复是一个有测试的函数,测试会把备份恢复到空库,逐表比对数据,再用原来的账号登录。另外,备份要和原数据分开存放,最好有一份在不同的地方。
Q:逻辑备份和物理备份有什么区别? A:逻辑备份是把数据导出成 SQL 或 JSON 这样的格式,可读、可跨版本、可以只恢复一部分,但大数据量时导出和恢复都慢。物理备份是直接复制数据库文件或存储快照,速度快,但通常只能整体恢复,也和数据库版本绑定。D1 的时间旅行属于后者,我的每周 JSON 导出属于前者,两者互补。
七、还差一步
GitHub Actions 部署需要一个 Cloudflare API 令牌,这一步只能由站长在 Cloudflare 后台创建,再填进 GitHub 仓库的设置里(README「自动部署」一节写了详细步骤)。在那之前,部署任务会自动跳过,仍然需要手动部署。