从白屏到四十秒构建:记账项目托管平台CI/CD与部署实践
我手上有个自己用了挺久的记账小项目,叫 Badget,最早就是本地npm run dev跑着玩,后来朋友要试用,被迫搬到线上。第一次部署踩的坑现在想起来还挺丢人——构建成功、页面白屏、环境变量在预览环境里全空、数据库连不上,来回折腾了一整晚。这套东西折腾完之后,我把 Badget 的整个交付链路重做了一遍:从代码提交到线上可用,中间不再是手动点几下,而是一条能自解释、能自愈、出问题能快速定位的流水线。这篇就把 Badget 在托管平台上做部署和 CI/CD 的完整过程拆开讲,包括构建模型怎么理解、环境变量怎么分层、缓存怎么命中、回滚怎么做、密钥和数据迁移怎么摆位置。适合已经能把本地项目跑起来、但一上线就状况百出的同学,也适合想把自己副业项目的交付流程做得更像回事的人。下面所有配置和命令都是我在 Badget 上实跑过的,参数取值和踩坑记录会一并给出来。
1. 一个记账项目为什么要绑定托管平台的构建模型
1.1 Badget 的技术栈决定了它能省掉多少运维
Badget 的栈很朴素:Next.js 的 App Router 模式、TypeScript、Prisma 做数据访问、Postgres 存账目和分类、Tailwind 做样式。整个应用没有长连接、没有后台常驻任务、没有定时跑批,最重的计算是"按月份聚合支出"这类 SQL。这个形态意味着它对运行时的要求极低:一个能执行 Node 的环境、一份能连数据库的密钥、一个能放静态资源的桶,基本就够了。
我一开始也想过买台小机器自己装环境,后来算了一笔账:这台机器要装 Node、装反向代理、配证书自动续期、配进程守护、配日志轮转,升级 Node 版本的时候还得手动重来一遍。这些东西单看每一件都不难,但它们会持续消耗注意力,而 Badget 本身只是个记账工具,我每周能投在它上面的时间不到三小时。托管平台的价值不在于"免费",而在于把这些周期性重复劳动从我的待办清单里彻底删掉。
还有一点容易被忽略:Badget 的流量是极度不均匀的。月初记账的人多,月中几乎没人,深夜更是零请求。自建机器为了扛住月初那几小时,得按峰值配资源,剩下二十多天全在空转。而按请求计费、无请求不产生实例的模型,天然贴合这种曲线。这不是省钱的问题,是资源模型和应用形态匹配的问题。
1.2 从"代码"到"线上可用"中间到底发生了什么
很多人对部署的理解停留在"把代码传上去"。实际上一次完整的发布要经过六个阶段,每个阶段都可能出问题,而且失败表现完全不同:
| 阶段 | 做什么 | 典型失败表现 |
|---|---|---|
| 依赖安装 | 按 lock 文件还原 node_modules | 版本冲突、私有包拉取失败 |
| 构建 | 执行 build 脚本,产出静态文件与服务端函数 | 类型错误、内存超限、找不到模块 |
| 产物收集 | 识别框架输出目录,打包函数 | 输出目录配错,页面 404 |
| 环境注入 | 把对应环境的环境变量写入运行时 | 变量为空导致连接串拼错 |
| 上线 | 分配域名、切换流量 | 预览域名被访问控制拦住 |
| 运行时 | 请求触发函数执行 | 冷启动超时、数据库连接数打满 |
我最初把"构建成功"当成"部署成功",结果页面能打开、数据全是空的——问题出在第四阶段,预览环境的环境变量没配。这个错误的教训是:构建通过只说明代码能编译,不说明它能在目标环境里跑起来。后面我把每个阶段单独验证了一遍,才把这类问题压下去。
1.3 自建和托管两条路线的取舍
我把两条路线做了一次对照,结论不是"托管一定更好",而是看你的项目处于什么阶段:
| 维度 | 自建机器 | 托管平台 |
|---|---|---|
| 首次上线耗时 | 半天到一天(装环境、配证书、配代理) | 十几分钟(连仓库、填变量) |
| 版本升级 | 手动,有停机风险 | 平台侧完成,无感 |
| 流量弹性 | 按峰值固定配置 | 按请求伸缩 |
| 冷启动 | 无 | 有,首次请求慢几百毫秒 |
| 长连接/常驻任务 | 支持 | 不适合 |
| 单次排错成本 | 需要登录机器看日志 | 控制台直接看构建与运行日志 |
| 月成本下限 | 固定,即使零流量 | 低流量下极低 |
Badget 属于典型的"读多写少、无状态、可随时重建",托管路线明显更合适。但如果你做的是需要 WebSocket 长连接、或者要跑定时任务的爬虫服务,托管平台的函数模型会很别扭,这种时候老老实实买机器更省心。选型这件事没有普适答案,只有"你的应用形态和平台的假设是否一致"。
2. 第一次上线为什么会白屏:构建配置的四个关键位
2.1 环境变量的三种作用域,配错就是白屏
托管平台一般把环境变量分成三档:Production、Preview、Development。这个划分不是装饰,它对应三条完全不同的执行路径。Badget 的数据库连接串在 Production 里配了,Preview 里忘了配,结果每次提 PR 生成的预览站点都能打开、但所有接口返回 500,日志里是一句"连接串未定义"。
我的做法是给变量加前缀区分用途,并且强制三档都填:
# 生产环境 DATABASE_URL=postgres://prod-host:5432/badget NEXT_PUBLIC_APP_ENV=production # 预览环境(指向独立的测试库,绝不能和线上共用一个库) DATABASE_URL=postgres://preview-host:5432/badget_preview NEXT_PUBLIC_APP_ENV=preview # 本地开发 DATABASE_URL=postgres://localhost:5432/badget_dev NEXT_PUBLIC_APP_ENV=development这里有个反复被踩的点:以NEXT_PUBLIC_开头的变量会被内联进浏览器产物。我早期图省事,把一个带写权限的接口密钥加了NEXT_PUBLIC_前缀,构建完直接能在 bundle 里搜到明文。判断标准很简单——这个值如果被人从浏览器里看到,会不会出事。会出事的一律不加前缀,只在服务端读取。
还有一个实操细节:修改变量之后必须重新部署才生效,因为变量是在构建期注入的。我第一次改完变量刷新页面没变化,以为是缓存,其实是旧部署还在跑。控制台里重新触发一次构建,问题立刻消失。
2.2 构建命令、输出目录与 Node 版本的锁定
托管平台会尝试自动识别框架,识别成功就用默认配置,识别失败就按 Node 项目兜底。Badget 用的是 App Router,自动识别没问题,但有两处我还是显式写死了:
{ "buildCommand": "npm run build", "installCommand": "npm ci", "outputDirectory": ".next", "framework": "nextjs" }installCommand从npm install换成npm ci是个小改动但收益明显。npm install在 lock 文件与 package.json 不一致时会静默改写 lock 文件,导致本地和线上的依赖树悄悄分叉;npm ci严格要求两者一致,不一致就直接报错退出。我吃过一次亏:本地能跑、线上报"模块找不到",查了半天是本地 lock 文件被某次npm install改过没提交。换成npm ci之后这类问题在构建阶段就暴露了,早暴露比线上暴露强太多。
Node 版本也得锁。平台默认版本会随时间升级,某次默认版本跳了一个大版本,Badget 依赖里的一个原生模块编译失败,构建直接红。解决办法是在package.json里写engines字段:
{ "engines": { "node": ">=20.0.0 <21.0.0" } }写范围而不是写死某个小版本,既能挡住大版本跳变,又不会因为补丁版本更新就构建失败。这个字段平台会读,不读的话至少 CI 里也能用actions/setup-node读。锁定运行时版本这件事,收益和成本的比例高得离谱,属于必须做的那一类。
2.3 预览部署的访问控制与域名策略
每次提 PR 都会生成一个预览地址,这个机制对协作极其友好,但默认配置下预览站点是公开可访问的。Badget 的预览环境连的是测试库,虽然数据是假的,但我不想让任何人随便扫到。平台提供了两层控制:一是给预览部署开启访问保护,二是用口令保护。
我选的是访问保护,因为它对团队内部透明、对外部完全不可见。代价是本地用curl验证接口的时候会被拦,需要在请求头里带一个绕过令牌。这一点在做自动化冒烟测试时要注意,否则脚本永远返回 401,你会误以为是接口挂了。
域名方面,我把生产域名和预览域名分开管理。生产域名绑在主分支上,预览域名用平台自动生成的随机串。有个小细节值得说:预览域名每次部署都会变,所以任何写在代码里的绝对 URL(比如邮件模板里的回调地址)都不能硬编码域名,必须从环境变量里读。我在 Badget 的分享链接功能上就遇到了这个问题——预览环境生成的分享链接指向了生产域名,点进去数据对不上。
3. 原生 Git 集成够不够用:流水线选型的判断依据
3.1 原生集成能覆盖到哪一步
把仓库连上平台之后,你立刻获得一条最小可用流水线:push 到主分支触发生产构建,push 到其他分支或提 PR 触发预览构建。这条链路已经覆盖了"代码变更自动上线"这个最核心的需求,Badget 前期就是靠它跑的,一行配置没写。
但它的能力边界也很清楚:它只在构建前后做事,不提供独立的测试阶段。也就是说,如果你的单元测试挂了,原生集成不会拦你,代码照样上线。这是很多人第一次用托管平台时最不适应的地方——习惯了 CI 里跑测试再发布,突然发现测试环节没了。
平台确实提供了构建钩子和忽略构建的条件,但那些都是围着"要不要构建"做文章,不是"构建前先跑测试"。所以问题变成:你的项目需不需要一道质量门禁?
3.2 出现这三个信号就该外挂一层 CI
我给自己的判断标准是三条,中任意一条就该把测试挪到外部 CI 里去:
- 项目有单测或端到端测试,且你希望它们在合并前必须绿;
- 团队不止一个人,需要 PR 上有明确的检查状态才能合并;
- 构建过程本身有副作用(比如要生成数据库类型定义),这些副作用需要在构建前完成。
Badget 在第 2 条上过线了——有朋友一起改代码,没有检查状态直接合并的风险太高。所以我在代码托管平台上加了一个工作流,专门跑 lint、类型检查和单测,构建和部署仍然交给平台原生集成。这个分工的好处是:测试和部署解耦,测试失败不会浪费一次构建额度,部署失败也不会污染测试结果。
这里我要提醒一句:不要把部署也塞进外部 CI 里用命令行调用平台接口。这样做技术上可行,但你会失去平台原生的预览部署、部署列表、一键回滚这些能力,等于自己把便利性拆了。让擅长构建的平台做构建,让擅长跑测试的 CI 做测试,各干各的。
3.3 质量门禁的串联顺序与超时控制
顺序有讲究。我一开始把端到端测试放在最前面,结果每次改一行样式都要等三分钟浏览器启动,反馈太慢没人愿意等。调整之后的顺序是:
name: quality-gate on: pull_request: branches: [main] push: branches: [main] jobs: check: runs-on: ubuntu-latest timeout-minutes: 12 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - run: npm run lint - run: npm run typecheck - run: npm run test:unit顺序是 lint → 类型检查 → 单测。理由是失败概率和耗时都递增:lint 十几秒就能给出结果,类型检查通常在半分钟内,单测最慢。把快的放前面,开发者能在最短时间内拿到反馈。
timeout-minutes这个字段别省。我有一次依赖安装卡在网络上,工作流跑了四十分钟还在转,白占额度。设了超时之后,超过十二分钟直接判失败,你能立刻看到并处理。
还有一个容易忽略的点:cache: npm的缓存键是基于 lock 文件哈希的,lock 文件一改缓存就失效。这不是 bug,是正确行为。但如果你频繁改 package.json(比如反复调依赖版本),会导致缓存一直命中不了,构建变慢。这时候可以考虑把依赖变更集中到一次提交里做完。
4. 构建时间从三分钟压到四十秒的几个动作
4.1 依赖缓存到底在什么条件下命中
平台侧的依赖缓存是基于 lock 文件内容哈希的。只要 lock 文件不变,第二次构建会直接复用上一次的 node_modules,安装阶段从几十秒降到两三秒。这一步是免费的,不需要任何配置。
但缓存命中率低的情况很常见,我统计过 Badget 早期的构建日志,命中率只有三成左右。原因是那段时间我在频繁调依赖版本,几乎每次提交都改了 lock 文件。后来我把依赖升级集中到每周一次,日常提交不碰 package.json,命中率直接上到九成以上。
外部 CI 里的缓存策略是另一回事。actions/setup-node的cache: npm默认缓存全局 npm 目录,键是 lock 文件哈希,同样是 lock 变了就失效。这里可以做一层优化:加一个 restore-keys,让 lock 变化时也能命中部分缓存。
- uses: actions/setup-node@v4 with: node-version: 20 cache: npm cache-dependency-path: package-lock.json说实话这个优化的收益没有想象中大,因为npm ci本身在干净环境下也就三四十秒。真正吃时间的是构建阶段,不是安装阶段。别把优化精力放错地方。
4.2 忽略构建:让不该构建的提交直接跳过
Badget 仓库里除了源码还有文档、设计稿、SQL 备份。每次改一个 README 都会触发一次完整构建,纯属浪费。平台的"忽略构建"能力就是干这个的,配置一段命令,返回 0 表示跳过,返回 1 表示继续。
# 只有当源码或依赖变化时才构建 git diff --quiet HEAD^ HEAD -- . \ ':(exclude)*.md' \ ':(exclude)docs/**' \ ':(exclude)design/**'这段的意思是:如果本次提交只改了 markdown、文档目录、设计目录,命令返回 0,构建跳过;否则返回 1,正常构建。第一次配置的时候我写反了逻辑,导致所有提交都被跳过,连续几次 push 都没上线,还以为是平台抽风。这种地方一定要先在本地跑一遍,确认返回码再提交。
如果以后把 Badget 拆成 monorepo(比如把共享的类型定义抽成独立包),忽略构建的脚本要改成按目录判断:
git diff --quiet HEAD^ HEAD -- packages/shared src apps/web只监控受影响的目录。monorepo 里最常见的浪费就是改 A 包触发 B 包重建,加一条路径过滤能省掉大量无意义构建。
4.3 产物瘦身:图片、字体和那些被忽略的依赖
构建慢的另一个隐形原因是产物太大。Badget 早期直接往public里丢了几十张 PNG 图标,构建时要做一轮压缩和拷贝,光这一步就吃掉二十秒。改法是把图标换成 SVG 内联,或者挪到对象存储里用外链,构建阶段完全不用碰它们。
字体是第二个坑。用第三方字体服务加载确实方便,但构建期要拉取字体清单,网络一抖动构建就卡住。我改成只引入需要的那几个字重,并且加了font-display: swap,构建稳定性和首屏表现都好了不少。
第三个是依赖体积。有个日期处理库我只是想用它格式化一下,结果它把整个时区数据库都打了进来。换成原生Intl.DateTimeFormat之后,产物体积降了接近两百 KB。这类问题可以用打包分析工具查,一眼就能看出哪个包占了最多空间。每次新增依赖之前先看一眼体积,比事后优化省力得多。
做完这三件事之后,Badget 的构建时间从三分钟出头降到了四十秒左右。这个数字不是靠某一个奇技淫巧,是几个小优化的叠加。
5. 线上出问题时,日志该从哪个方向看
5.1 构建期失败和运行期失败,表现完全不同
这两类问题必须分开看待,因为它们的日志位置、排查路径、修复方式全都不一样。
构建期失败的典型表现是:部署状态直接是错误,站点还是上一版,新代码根本没上线。日志在构建输出里,报错信息通常很明确——类型不匹配、模块找不到、内存超限。这类问题在本地跑一次npm run build基本能复现,修起来不费劲。
运行期失败的典型表现是:部署状态是成功,页面能打开,但某个功能报错。日志在运行时日志里,需要按请求去查。这类问题本地很难复现,因为本地环境和线上环境的差异(变量、数据库、区域)才是根因。
我给自己定的排查顺序是:先看部署状态。状态是错误就去看构建日志,别浪费时间想别的。状态是成功但功能异常,才去翻运行时日志。这个顺序能砍掉一半的无效排查。
5.2 函数超时、冷启动和区域选择的连锁反应
Badget 的月度统计接口曾经间歇性超时。表现很怪:多数时候秒回,少数请求等十几秒然后报错。日志里能看到执行时间分布极其两极化。
第一层原因是数据库查询没走索引。按月份聚合的查询在数据量涨到几万行之后开始变慢,加了(user_id, created_at)的联合索引之后,单次查询从秒级降到几十毫秒。这一步解决了大部分问题。
第二层原因是冷启动。无请求一段时间后函数实例会被回收,下一次请求要重新初始化。而 Badget 的 Prisma 客户端在初始化时会做一次连接探测,这个动作叠加在冷启动上,让首次请求的耗时翻倍。解决办法是把客户端实例化放到模块顶层并复用,避免每次请求都新建:
// 放在模块顶层,同一实例内的多次调用共享这个连接 const globalForDb = globalThis const prisma = globalForDb.__badgetPrisma ?? createClient() if (process.env.NODE_ENV !== 'production') { globalForDb.__badgetPrisma = prisma } export default prisma第三层原因是区域。函数默认可能跑在离数据库很远的区域,跨区域往返一次就是几百毫秒。把函数区域和数据库区域配到同一片之后,这部分延迟基本消失。这个配置在平台的函数设置里能改,但很多人不知道要改。
三层原因叠在一起,表现出来的就是"有时候快有时候慢"。排查这类问题一定要看耗时分位数(P50 和 P99),只看平均值会被掩盖。
5.3 回滚不是终点,验证才是
平台提供了一键回滚,把流量切回上一个部署。这个能力很好用,但回滚之后必须验证三件事,否则你会以为问题解决了其实没有:
第一,回滚只切换了代码版本,数据库结构没跟着回滚。如果新版本包含了一次迁移脚本,回滚后代码读的是旧字段、数据库里是新结构,照样出错。所以迁移脚本必须向前兼容,具体做法在下一节讲。
第二,回滚不会清缓存。如果问题出在缓存了脏数据,回滚之后脏数据还在,表现依旧。这时候要手动清一次缓存再看。
第三,回滚之后要确认流量真的切过去了。我看过一次回滚状态是成功,但实际还有部分请求打到新实例上,因为切流是渐进的。等一两分钟再验证,别急着下结论。
我的习惯是回滚之后立刻打开一个关键路径(Badget 里是"新增一笔支出")走一遍,从提交表单到列表刷新,全链路确认。这比看监控图表直观。
6. 密钥和数据库迁移:流水线里最容易出事的两环
6.1 环境变量的分层管理与轮换
Badget 的变量分三类管理:
| 类型 | 例子 | 存储位置 | 轮换频率 |
|---|---|---|---|
| 公开配置 | 应用环境标识、公开 API 基地址 | 环境变量(可带公开前缀) | 几乎不变 |
| 服务端密钥 | 数据库连接串、签名密钥 | 环境变量(敏感标记) | 半年一次或泄露后立即 |
| 临时调试项 | 开关、日志级别 | 环境变量(开发档) | 用完即删 |
第三类最容易被遗忘。我有个调试开关在线上躺了两个月,某次清理变量时才发现。建议每次发版前扫一遍变量列表,把带DEBUG、TEMP、TEST字样的过一遍,确认还需不需要。
轮换密钥这件事要提前设计好"双活窗口":新密钥先生效,旧密钥保留一段时间,等所有实例都切到新密钥之后再销毁旧的。如果直接替换,正在跑的旧实例会瞬间失效,表现为一批请求突然报错。这个窗口我一般留二十四小时。
6.2 迁移脚本该放在流水线的哪一步
这是我在 Badget 上纠结最久的问题。迁移放构建前、构建后、还是独立跑?三种都有问题:放构建前,构建失败的话迁移已经执行了;放构建后,可能多个并发部署同时跑迁移;独立跑,容易漏。
最后的方案是:迁移作为独立的一步,在部署之前手动或由流水线串行触发,且脚本必须向前兼容。所谓向前兼容,指的是新结构上线后,旧版本的代码仍然能正常工作。具体做法是分两次发布:
第一次发布只加不删。加新字段、加新表,但不动旧字段。这一步上线后,新旧代码都能跑。第二次发布才改代码去读新字段,同时保留旧字段的写入。等确认稳定了,第三次发布才删旧字段。
这样回滚的时候,旧代码面对新结构也不会崩。听起来啰嗦,但这是唯一能让"随时回滚"这个承诺成立的做法。Badget 有一次为了省事把加字段和删字段放在同一次发布里,结果出问题回滚,旧代码读不到字段直接 500,只能手动改数据库救场。那次之后我就老老实实分三步走了。
迁移脚本本身的写法也有讲究:一定要幂等。用"如果不存在则创建"的写法,而不是"直接创建"。这样脚本重复执行不会报错,重跑流水线的时候不会卡住。
6.3 密钥泄露之后的前十分钟该做什么
没人希望用上这套流程,但真出事的时候,顺序很重要。
第一步是止血,不是排查。立刻在平台上轮换泄露的那个密钥,让旧值失效。这一步必须在十分钟内完成,因为它直接决定损失范围。
第二步是看影响面。查日志里有没有异常的请求模式,比如某个 IP 的请求量突然暴涨、或者出现了从来没见过的查询参数。平台的日志能按时间范围筛,把泄露时间点前后的日志拉出来对比。
第三步才是排查泄露路径。常见原因就那么几个:变量被加上了公开前缀导致打进浏览器产物、密钥被写进了提交历史、日志里打印了完整连接串。Badget 曾经在错误处理里把完整的数据库连接串打进了日志,虽然日志只有自己能看,但这也是不该有的行为。从那以后我在日志输出上加了脱敏,任何包含连接串、令牌的字段一律打码。
第四步是复盘加防护。我给仓库加了一个提交前检查,扫描改动里有没有形似密钥的字符串。这类工具误报率不低,但宁可多看一眼,也比事后补救强。
7. 把这套流程跑顺之后我改掉的几个习惯
流程搭好之后,真正提升效率的不是配置本身,而是几个使用习惯的改变。
第一个习惯是小步提交、频繁部署。以前我习惯攒一堆改动一次推,出问题时很难定位是哪一处引起的。改成一次只做一件事、推一次、看一次部署结果之后,问题定位时间从小时级降到分钟级。预览部署让这个习惯变得可行——每次 push 都有一个可点的地址,改完立刻能看效果,不用等合并。
第二个习惯是把环境差异写进文档而不是记在脑子里。Badget 有一个"为什么预览环境连的是另一个库"的说明,写在仓库的部署文档里。这么做不是为了别人,是为了三个月后的自己。我吃过一次亏:隔了两个月回去改代码,忘了预览库是独立的,直接在上面跑了清理脚本,把测试数据全清了。
第三个习惯是给每个自动化步骤留一个人工兜底。流水线再顺也会有意外的时刻,比如平台侧故障、CI 排队、依赖源抽风。这时候要知道怎么手动触发一次构建、怎么手动切回上一个版本、怎么单独跑一次迁移。这些操作我都在文档里写了步骤,并且每季度手动走一遍,确保真需要用的时候手不生。
第四个习惯是关注分位数而不是平均值。这一点在性能问题上尤其明显。平均值好看不代表没有长尾,而用户感知到的往往是那个最慢的请求。Badget 的统计接口平均响应两百毫秒,听起来没问题,但 P99 是四秒,那 1% 的用户体验极差。把监控从"平均"切到"分位"之后,我才发现这个问题。
这套东西全部配完,前后大概花了两个周末。第一个周末在填坑,第二个周末在优化。现在 Badget 的发布流程是:改代码、跑本地检查、推分支、看预览、合并、自动上线,全程不需要登录服务器,也不需要记住任何部署命令。要说还有什么不满意的,大概是构建缓存偶尔还是会失效,以及平台默认的 Node 版本升级总是来得比预期早——但这两件事都属于"知道会发生、提前有准备"的范畴,不再是那种半夜被叫起来救火的意外了。