最近工作内容有关将老板vibe coded的一个Next.js服务做AWS部署(AI牛马都知道这活有多脏,哈哈),团队的基建技术栈是AWS,方向是serverless,于是花了一些时间学习相关知识概念,在本篇做个记录。
概念
1. BFF是什么?
Backend for frontend。
我的理解是它是一个静态React 前端和后端交互的一层gateway,它的主要工作是接收前端请求、调用下游真正的后端 (BE)、整合数据。
如果我们牢记代码工程的一条金律:没有什么是再加一层中间层做不到的,就能意识到对于前端项目,多加一个BFF一定视为了解决单纯React SPA做不好的事情:在微服务架构中,如果让纯前端做数据整合和多接口的调用、重试这些逻辑,React负担太重。那么为了解耦,引入BFF,视为了React 层尽可能只做渲染,而BFF处理与后端的逻辑调用和数据处理。
2. Next.js是什么?
Next.js 是一个基于 React 的全栈 Web 开发框架, Next.js 把 React 从一个"前端 UI 库"升级为可以处理路由、数据请求、服务端逻辑的一站式应用框架。
另一个概念叫Node.js,Node.js是JavaScript的运行时,它可以让JS代码脱离浏览器在服务器上运行。和Next.js的关系就是Next.js实际上试运行在Node.js上的。作为一个老Java人,我简单理解,Next.js相当于Spring Boot, node.js相当于JRE。
Next.js相当于把React和BFF打包了,一般一个Next.js项目就默认为React + BFF,而因为BFF这里因为更强调它服务器的角色,所以用"node.js"层表述。
3. Stateless是什么?
简单理解,state是状态,或者说数据,服务器本身带有对数据的管理和持有,就是stateful,没有就是stateless。比如如果你在服务内存里存了数据逻辑,或在服务器存了本地文件,里面有用户数据,那么服务就是stateful的。在原来的项目中,关系型数据存在SQLite文件中,而文件是在服务器路径下的,那么它就是stateful的。如果所有数据都单独管理在服务器之外,单独存在数据库服务器中,那么server本身就是stateless的。
微服务架构下,用stateless server架构比较多是因为请求和服务器不是持久绑定的,同一个用户的2次请求可能打到不同的服务器上,如果是stateful的,那么就得要求两个请求达到一个服务器上才好管理。
也不是所有服务都会采用stateless架构,我印象中websocket就是stateful的,服务器会维护链接和用户的关系。
4. Serverless是什么?
serverless,无服务器,一个基础架构概念。之前微服务,stateless我觉得更多是工程概念吧,就是代码级别的架构选择。但是这里serverless,到了物理层面,服务器上,就说明它是一个infra概念。它强调工程师可以不去关心服务器本身,而把代码逻辑只考虑为运行函数。
有点抽象,我理解了几天,大概就是常规的server,我们都是在服务器上一直运行,如果有请求来了,服务器处理,请求没来,没有流量,它也一直待机。这种对高并发逻辑,服务器一直运行也没毛病,毕竟服务器荷载率很高。但是如果服务器的QPS比较低,或者不稳定,为了节约成本,让服务器资源更物尽其用,最开始会有根据高流量时段缩容和扩容,比如电商大促就申请更多的服务器,平时就缩小服务器集群规模。
现在serverless把这件事做到极致,就是有请求来,拉起一个服务器处理,处理完了销毁,也就是说如果没有流量,其实没有服务器在空跑,节省资源。当然听起来它会有一个overhead,就是启动服务器的过程会给用户带来延迟,不过这就是tradeoff了。这个过程是云服务厂商管理的,对于开发来说不需要关心,属于责任分离了,separation of concern。
注意,serverless部署的前提是服务本身是stateless的,否则,如果server实例保存了用户状态,被销毁了不就找不着了。
5. AWS Lambda是什么
这个serverless概念做成的云服务产品,AWS上叫Lambda。它让你可以只上传代码,无需管理服务器,代码会在事件触发时自动运行、自动扩容,按实际执行时间计费。
Lambda 是还在发展中的产品,可能一些feature会变,不过它目前是有限制的,它单次执行最长 15 分钟,内存最大 10GB。超过15min没完成,lambda实例会被销毁,所以需要较长计算过程的服务不适合用lambda部署,或者说得想别的办法适配这个运行限制。与之对比的是Fargate这种容器,单进程可以一直运行直至结束。
多说一句这里从直觉上,这个按需拉起是pub-sub模型,也就是说lambda本身是事件驱动的。上游比如gateway,接到URL请求时发送率事件,下游有worker消费事件,完成对Lambda实例的拉起。所以对Lambda 上部署的逻辑来说,需要做的就是把逻辑subscribe到事件源,这样你的逻辑就可以触发事件,完成对Lambda的驱动。
6. AWS Amplify Hosting是什么?
AWS厂商为前端应用,尤其Next.js应用提供的一个部署产品,它底层是基于Lambda的,也就是serverless的。同时Gen 2会进行全链路整合,CICD,数据对接等等,说白了就是打包到一起卖一个产品,这样工程师需要单独管理的东西比较少,这样比较开箱即用。
AWS Amplify Hosting 底层是基于 CloudFront (CDN) 和 Lambda 函数来运行你的 Next.js SSR 逻辑的,也就是说它是网关+serverless的模式,有两层。
决策
把一个纯vibe-coding项目接管,并做云部署,需要根据团队的技术方向做很多决策,它们在技术上不一定绝对正确,但是会是在团队架构下的最优解。
前提:这是一个Next.JS全栈项目,有React写UI模块,node.js BFF,以及SQLite 做存储。对于agent后端(真正的BE),调用的是外部的REST endpoint。
方向:尽量开箱即用,能托管不要自己处理,向serverless方向走,尽量省钱。
1. 对于Streaming response的处理
现在的很多项目都是会连Agent (LLM)后端的,而对于LLM请求,有一个特点就是streaming,它可以持续很久,并且是流式返回,不是一股脑都返回在response里。
那么回到项目本身,这个项目它是会调用多个agent endpoint完成流式返回的。这个逻辑原来在BFF层,也就是说浏览器的请求会经过BFF层转发重新路由去调用真正的Agent BE。
browser -> Stateic React -> Next.js(BFF) -> Streaming Endpoints
如果这个服务部署在Amplify这样的产品上,则是:
bash
browser -> Amplify -> Amplify(Lambda Serverless) -> BE ednpoint
这里要注意,从BE上来的每一个中间节点都要支持流式响应,才可以保证用户在浏览器也拿到流式响应,而不是buffered响应。
常见的手段:先用Smoke Test测一下Amplify对流式输出是怎么处理的。这一块交给agent写个脚本,部署到Amplify上,然后请求一下发现并不是流式输出的,是在响应完成的那一刻直接返回的。
这里其实Amplify在部署BFF的时候,里面有两层,一层CloudFront, 一层Lambda,这两层可能都进行了buffered输出,也可能只有CloudFront进行了buffer输出。(因为Lambda最新支持了Lambda Streaming Response)。
我的感受是目前Amplify对streaming的支持其实并不好,那么在这种情况下就要考虑agent endpoint的调用,要剥离开这套双层架构。如果此时去问AI,AI给了我Lambda Function URL的方案。这里我不展开了,因为我采取了另一个方案:agent调用逻辑从BFF层拿开,变为直接从浏览器调用。
2. BFF层的定位
AI的方案让我意识到保留streaming endpoints在BFF里的方案是多加一层Lambda,但是BE已经是流式访问了,联想到Lambda的冷启动问题,我似乎觉得在这里加一个Lambda 层完全没有必要,既然SPA可以直接调用streaming并渲染结果,那么不如把streaming endpoint的控制权上移到React层,这样BFF层没有关于streaming response的处理,用纯净的物理管道保障数据流,就变得轻巧很多。
由于这个项目不是我最开始搭建的,所以最开始没有直接想到这个方案,但一旦考虑剥离streaming endpoint,就其实对BFF层的定位有了更明确的规划。它的逻辑集中处理项目本身的CRUD逻辑,以及其他非streaming HTTP的调用,运行在Lambda上就是合理的。
BFF的定位因此可以总结为:
- stateless routes
- non-streaming http routes
- CRUD routes
bash
browser <---> 前端 React <---> Amplify (内置 CloudFront 网关) <---> BFF Lambda (Next.js) <---> BE
^ ^
| |
----------------------------------------------------------------
踩坑
- CORS问题
当我们把streaming endpoint上移到React层,失去了BFF层的路由的时候,就会产生跨域问题。
我也真的这次才明白什么是CORS跨域问题。
CORS(跨域资源共享)纯粹是"浏览器"的安全限制机制。服务器和服务器之间通信,是不存在 CORS 限制的。也就是说BFF存在的时候,BFF向后端的调用是没有CORS限制的,它们是服务器间通讯。
但是当streaming endpoint上移到浏览器层的时候(React代码运行在浏览器,当它直接调用后端streaming endpoint的时候,使用原生FetchAPI执行的),这时浏览器调用BFF可能是域名A,而直接调用BE可能是域名B,A和B不是一个域名,因此出现了跨域问题。
它的机制是这样的:
- 浏览器会先把你真正的请求扣下来。
- 浏览器代替你,向 BE的ALB 发送一个探测请求(HTTP OPTIONS 方法,俗称 Preflight 预检请求),问 BE:"嘿,有个域名A 想拿你的数据,你允许吗?"
- 如果此时BE的 ALB(或者后面的容器代码)没有明确回答:"我允许(返回 Access-Control-Allow-Origin: 域名A)",浏览器就会直接报错拦截,把连接掐断。这就是你看到的 CORS 报错。
看懂这个机制的话,破解CORS也很明白,就是从BE那里把域名A允许放行就可以了。具体来说在BE的ALB层或者容器代码层应该都可以。问AI就好了。
TBD
另外关于数据库的迁移这篇没有处理,因为我还没有干到那......,做完再总结吧。
至于数据库为什么会迁移?回顾一下,Amplify底层对BFF的部署是serverless的,也就是说代码逻辑也得是stateless的,那么SQLite 就不行啦,这个就是个文件和服务实例在一起的。要找单独的数据层解决,因此要做数据迁移。由于技术栈和团队方向,大概率是要走向DynamoDB了,没有关系型数据库的日子,我会想它的。哈哈。
这个任务我遇到的最大困难,是对vibe-coding项目代码本身的不了解,它经过了上层vibe-coding搭产品,又经过了工程师的接管与迭代,已经不可避免的是屎山一坨。虽然整个部署过程我都是和CC一起分析决策的,但很多时候我需要先了解项目本身有什么,或者说我无法、也不能完全信任CC给我的决定,我需要学习,理解,然后指导CC做判断。
在这个过程中深切意识到,人类的时间和判断是真正的稀缺资源,当AI 全能,决定最终结果的还是人和人的差距。