H5 应用容器与奖池的架构
目录
背景#
合作方 App 的后台调一次 /app/url,拿到一个地址,交给用户的浏览器打开。原来这个地址就是第三方服务商的 H5 页面,平台在这中间没有自己的页面,所以加不了防护,各服务商的加载体验不统一,也拿不到加载过程的埋点。
要做的是在中间放一个平台自己的页面:/app/url 改成返回容器页(前端代码里叫 container shell,下文简称 shell)的地址,shell 再把服务商的 H5 应用装进 iframe。防护、统一的加载体验和埋点都可以放在 shell 上做。
后来平台还要在 shell 页面底部挂一条奖池活动条(下文叫 overlay),显示四档奖池的实时金额,用户可以报名、退出,中奖时会弹出通知。报名之后,用户每结算一笔订单,系统就从钱包里扣一张抽奖券的钱,按比例分进四档奖池,再为这笔订单抽一次奖。订单和钱包都在交易平台,也就是平台里负责下单和钱包的服务。
本文只讲从打开应用到推送的运行时链路,以及抽奖券从扣款到派奖的过程;后台管理、计费、抽奖随机数的审计、各服务商的适配器和埋点管道不在范围内。
整体架构#
浏览器这一侧只有一个用户域名,请求先到 Cloudflare,再按路径分给 Worker 或 ALB。合作方后台到 container-service 是服务端之间的调用,不经过浏览器:
唯一的用户域名"] WK["Worker 路由 /open/*
shell 静态资源"] ALB["ALB IngressGroup
其余路径按前缀分流"] subgraph EKS["EKS"] direction TB CS["container-service
/v1/app /v1/container /v1/overlay"] OB["overlay 静态包
/widget/prize"] PS["prize-service
/v1/prize"] SS["stream-service
/stream/prize"] end PB["合作方后台"] SHELL --> CF OVL --> CF CF --> WK CF --> ALB ALB --> CS ALB --> OB ALB --> PS ALB --> SS PB -->|"/app/url,服务端签名"| CS
钱和推送的路径从交易平台的 Kafka topic 进来,中间都在服务端,最后回到交易平台的接口和用户的浏览器:
不持有数据"] PS["prize-service
唯一的数据 owner"] DB[("MySQL 8 与 Redis 7")] TAPI["交易平台的扣款接口与中奖通知"] SS["stream-service
不存状态"] OVL["浏览器里的 overlay"] TOPIC -->|"batch 消费"| PC PC -->|"内部接口:资格、扣款、payout"| PS PS -->|"扣款"| TAPI PC -->|"中奖通知"| TAPI TAPI -->|"审批结果"| PS PS --- DB PS -->|"outbox 经 intake"| SS SS -->|"SSE"| OVL
组件职责#
有状态的组件只有两个:container-service 和 prize-service,都用 MySQL 和 Redis 存状态。其余四个要么跑在浏览器里,要么只经手数据。
| 组件 | 负责什么 | 状态放在哪 |
|---|---|---|
| container shell | 用 URL 里的入口 token 调一次 resolve,把服务商的 H5 应用装进 iframe,注入 overlay,替 overlay 持有 refresh token | 浏览器 localStorage 缓存服务商地址,不存 overlay 凭证 |
| container-service | /app/url 决定返回容器页地址还是服务商原地址;resolve 一次性兑换;签发、刷新 overlay 凭证 |
Redis 存入口 session 和 overlay session,MySQL 存开关和合作方凭证 |
| 奖池 overlay | 奖池条、报名和退出、中奖记录;REST 调 prize-service,SSE 连 stream-service | 无,凭证每次向 shell 要 |
| prize-service | 用户接口和后台活动接口;抽奖券扣款与奖池入账;payout 的生命周期;outbox 和每秒一次的奖池 tick | MySQL 8 是 system of record,Redis 7 放可重建的副本和幂等键 |
| prize-consumer | 消费已结算订单:判断资格、定价、交给 prize-service 扣款;扣款成功才抽奖;中奖后建 payout、通知交易平台 | 无,只有 Kafka 的 offset |
| stream-service | 每个用户会话一条 SSE;建连时验凭证;按房间或个人投递 prize-service 发来的帧 | 无 |
奖池这边不直接连任何合作方。交易平台发布已结算订单的 Kafka topic,提供扣款接口,接收中奖通知,再把合作方的审批结果回传给 prize-service。系统设计文档给的理由是:直连就得给每个合作方发凭证、逐个接入,经交易平台转一手只多一跳。
prize-consumer 不持有数据库,奖池的数据只有 prize-service 一个 owner。consumer 的持久性交给 Kafka,pod 挂了就从未提交的 offset 重放,代价是它的每一次持久写都是一次网络调用。
技术栈与选型理由#
| 组件 | 技术栈 |
|---|---|
| container shell | Cloudflare Workers 的 static assets(wrangler 4),一段不依赖前端框架的 container.js |
| 奖池 overlay | React 19.2、TypeScript 6、Vite 8、Tailwind CSS 4、TanStack Query 5 |
| container-service | Java 25、Spring Boot 4.1.1(Spring MVC)、Spring Data JPA 加 MySQL、Redis、Spring Kafka、jjwt |
| prize-service | Java 25、Spring Boot 4.1.1、Spring Security、Spring Data JPA 加 Liquibase、MySQL 8、Redis 7(Lettuce) |
| prize-consumer | Java 25、Spring Boot 4.1.1、Spring Kafka 的 batch listener |
| stream-service | Java 25、Spring Boot 4.1.1、Spring MVC 的 SseEmitter 跑在 virtual threads 上、Nimbus JOSE |
| 部署 | EKS;用户域名下的服务共用一个 ALB IngressGroup;Cloudflare 代理 DNS;Vault Agent 注入密钥;ArgoCD 同步 manifest;Jenkins 构建镜像 |
后端四个服务都沿用现有的 Java 运行时,没有为这个项目引入新的服务端栈。下面按 shell、overlay、container-service、奖池的两个服务、stream-service 和部署六部分,看每处选型换来了什么、代价是什么。
shell:Cloudflare Workers 和一段不依赖框架的脚本#
选 Workers 的 static assets,看中的是它可以只占域名下的一个路径。Worker 路由挂在已经由 Cloudflare 代理的域名上、位于源站前面,只接住匹配的路径,其余请求照常回源,所以同一个域名可以一部分由边缘直接返回,其余交给 ALB,命中静态文件的请求也不执行 Worker 代码。
几个备选各有一处不合适:
- Cloudflare Pages 只能绑定整个域名,不能挂在某个路径上,Cloudflare 的迁移文档把 non-root routes 列为 Workers 才有的功能;而且 Cloudflare 在 2025 年 4 月说明,之后的投入和新功能都放在 Workers 上。
- 不写代码的 Origin Rules 也能把一个路径指到另一台源站,但要用到的 DNS 记录和 Host 头 override 只对 Enterprise 开放。
- AWS 上的等价做法是 CloudFront 按 path pattern 分多个 origin,但这里 Cloudflare 已经在最前面代理 DNS,再加一层 CloudFront 就是两层 CDN。
- 把 shell 做成 EKS 里的一个 nginx 服务最简单,部署也和其他服务一致,代价是缓存没命中时请求会回源,shell 能不能打开也要看集群的状态。
用 Workers 的代价在平台这一层。shell 走 wrangler 和 Cloudflare 发布,四个 Java 服务走 Jenkins、ArgoCD 和 EKS,发布、回滚、日志和权限要分两处管。路由规则和部署方式都是 Cloudflare 专有的:Workers 的运行时 workerd 按 Apache 2.0 开源,但 Cloudflare 自己也说明它不是现成的边缘计算平台,换 CDN 时这一层要重做,能直接搬走的只有静态文件。以后如果要在边缘加逻辑,比如按请求生成 CSP,可以让 Worker 脚本先执行(run_worker_first),这部分请求就开始按 Workers 计费,也受每个请求的 CPU 时间限制。
shell 本身是一段不依赖前端框架的 container.js。它在服务商的 H5 和 overlay 之前加载,不带框架的运行时,也不会和 overlay 的 React 版本互相牵制;代价是装 iframe、注入 overlay、刷新凭证这些逻辑都要直接写 DOM 操作。
overlay:TanStack Query 与 Tailwind CSS 4#
overlay 看到的真实状态都来自对 prize-service 的 REST 拉取,SSE 帧只是提示,这部分服务端状态交给 TanStack Query 缓存和同步。Tailwind CSS 4 有一处要额外处理:v4 用 @property 给自定义属性定初始值,而 overlay 的样式渲染在 shadow root 里,写在 shadow root 里的 @property 在各家浏览器上都不生效,这部分声明要单独提到宿主页面的 document 上,细节见同一页面挂多个 UI 叠加层那篇。
container-service:会话放 Redis,配置放 MySQL#
入口 session 和 overlay session 放在 Redis,开关和合作方凭证放在 MySQL。prize-service 本来就用 MySQL 和 Redis,container-service 沿用这两种存储,后端不用再多运维一种数据库。代价在持久性上:prize-service 放进 Redis 的只是能重建的数据,这里放的却是会话本身,Redis 丢了数据,在线用户的 refresh token 跟着失效,要从合作方 App 重新打开一次应用;丢的是会话,不是钱。
prize-service 和 prize-consumer:MySQL 记账、Kafka 批量消费#
prize-service 用 MySQL 做 system of record,money path(抽奖券从扣款到派奖的这条链路)上 DISPATCHING 那一行靠的就是它的事务,流程见「扣款和派奖都走交易平台的钱包」那一节。Redis 里只放能从 MySQL 重建的副本和幂等键,丢了只损失性能,不损失钱。代价是奖池每一笔钱的写入都落在这一个 MySQL 上,它的写入能力就是记账的上限。
prize-consumer 用 Spring Kafka 的 batch listener,一次处理 poll 回来的一整批消息。代价是重启和 rebalance 时,还没提交 offset 的整批消息都会重投,下游每一步都得能重复执行,DISPATCHING 那一行就是为这个准备的。
stream-service:SseEmitter 跑在 virtual threads 上#
stream-service 定栈时的判断是:virtual threads 不会提高连接数上限,它去掉的是为了达到这个上限而改用 reactive 栈的理由,写循环可以继续用阻塞式的写法。连接数上限由 Tomcat 的 maxConnections 决定,这里配成 12000,单实例的目标是一万条连接;这个计数器为什么跟线程模型无关,以及 SSE 的选型和单机容量实测,见 SSE 还是 WebSocket 那篇。
代价在内存上:SSE 那篇在 4 核 8 GB 的容器里测到,2 万条 SSE 连接时 Tomcat 每连接 RSS 是 148.1 KB,Netty 是 36.0 KB;换成 Jetty(Spring MVC 不变)是 64.1 KB,不用改写法就能收回一部分。
部署:共用的 ALB IngressGroup 和 Vault Agent#
四个服务的 Ingress 通过同一个 IngressGroup 合并到一个 ALB 上,同源按路径分流靠的就是它,也省掉了每个服务一个负载均衡器。代价是这个 ALB 的规则由几个仓库一起写:两个 Ingress 声明了同一个前缀时,先生效的是 group.order 更小的那个,order 相同就按 namespace/name 的字典序排。
密钥由 Vault Agent 注入:init 容器在应用启动前把密钥渲染进共享的内存卷,应用读文件就行,不用接 Vault 的 API,manifest 和镜像里也不出现密钥。代价是 pod 能不能起来,要看那一刻 Vault 是否可用。
关键设计决策#
下面五条决策是并列的,分别管浏览器入口、凭证、钱、推送和 overlay 的发版。
浏览器只认一个源#
overlay 跟容器页跑在同一个 JS 上下文里,中间没有 iframe 隔开(为什么这么选,见同一页面挂多个 UI 叠加层那篇),它发出的请求都带着容器页的 Origin。奖池的接口、推送和 overlay 静态包原先放在另一个域名上,每个带凭证的请求都要先过一次 CORS preflight,推送用的 EventSource 同样受 CORS 约束。Vite 还会在构建时把 import.meta.env 静态替换掉,写进前端包的绝对地址换一次域名,就得把每个前端重新构建一遍,而域名要能在不重新部署的情况下切换。
现在只有一个用户域名,由 Cloudflare 代理。Worker 路由只挂 /open/*,返回 shell 的静态资源;其余路径回源到一个 ALB,四个服务的 Ingress 通过同一个 IngressGroup 合并到这个 ALB 上,按路径前缀分流。overlay 全部改用相对路径,preflight 没有了,前端包里也不再出现域名。在边缘按路径把一个域名拆给不同后端是常见做法:AWS 的博客介绍 CloudFront 按 path pattern 分 origin 时,给的理由同样是省掉 SPA 的 CORS preflight;Cloudflare 自己的 dashboard 也由一个 Router Worker 按 URL 路径把请求转给各团队的前端。
同源去掉的是 CORS,Origin 白名单还在:prize-service 对非 GET 请求做 CSRF 检查,浏览器在同源的 POST 上也会带 Origin,所以每个环境仍要配 origin 列表。域名也从代码挪进了配置,散在 ingress、Worker 路由、入口链接、Origin 白名单和 CSP 几处,换域名要一起改,其中 CSP 在 shell 部署时渲染,shell 还得重新部署一次。路径前缀则成了四个仓库加一个 Worker 共用的命名空间,分散在四份 Ingress 和一条 Worker 路由里,会不会重叠只能靠各仓库自己约定;/open/* 在边缘就被 Worker 接走,不会回源到 ALB,哪个服务以后在这个前缀下加接口,请求到不了它。
container-service 签发凭证,奖池的两个服务本地验签#
奖池这边要知道每个请求、每条推送连接背后是哪个合作方的哪个用户、用什么币种。这些信息只在打开应用那一刻的 container-service 手里,而推送连接会成批重连,按连接回头去问,重连风暴就原样转到了被问的那个服务上。所以 container-service 在 resolve 时签发一张给奖池用的凭证:
入口 token 是一张用合作方 API secret 签名、5 分钟有效的 JWT,resolve 时只能兑换成功一次。overlay 凭证是一对 token:ES256 签名的 access token 3 分钟有效;refresh token 12 小时有效,每次刷新都轮换,拿已经轮换掉的旧 token 来刷新会被判定为重用,整串作废。overlay 手里只有一个 getToken 回调,不经手 refresh token;prize-service 和 stream-service 都用公钥在本地验签,不调用任何服务,也签发不了凭证。
代价有四处:轮换会误伤合法用户,撤销在 SSE 上生效慢,token 会进访问日志,换一次公钥要几个服务一起改。
轮换是 RFC 9700 §4.14.2 描述的 refresh token rotation,服务器分不清交旧 token 的是攻击者还是合法客户端,合法客户端也得重新拿授权,放到这里就是用户要从合作方 App 重新打开一次应用。
撤销一个用户的凭证,在 SSE 上要等到这个用户下一次建连才生效:推送服务只在建连时验一次,按过期时间切断连接,等于按计时器给自己制造重连风暴。
按 HTML 标准的 IDL,EventSource 带不了自定义请求头,SSE 的 token 放进了 query string,会出现在沿途的访问日志里;同源之后,这里有了改用 HttpOnly cookie 的条件。
两个服务各自验签,算法、公钥、issuer、audience、claim 名任何一样对不上,用户就只在一边通过认证;公钥又是配置里的静态值,没有 JWKS,换密钥要几个服务一起改配置、一起重启。
扣款和派奖都走交易平台的钱包#
奖池这边不持有用户的钱,抽奖券的扣款和奖金的发放都发生在交易平台的钱包上,奖池只发指令、记账。交易平台的扣款接口不能指望它按幂等键识别重复请求,Kafka 在重启和 rebalance 时又会把整批消息重投一遍,两者叠在一起,一次普通的滚动重启就可能把在途的抽奖券扣两次。
触发点是交易平台的已结算订单 topic,所以用户这一笔订单在奖池开始干活之前就已经完成,用户等待的东西里没有一样依赖奖池。prize-consumer 负责筛消息、定价和抽奖,扣款交给 prize-service:先把抽奖券这一行改成 DISPATCHING 并提交,再在事务之外调用交易平台,最后在一个事务里写回结果,扣款成功的同时给奖池入账。同一笔再来一次,直接从这一行回答,不再问交易平台,代码注释把这一行称作防止第二次扣款的唯一保护。
抽中之后,prize-service 建一条 payout,同时把这笔奖金从奖池里扣掉,再由 consumer 通知交易平台。合作方在 72 小时内审批:批准,钱由交易平台打给用户;拒绝或者超时,奖金退回奖池。
UNRESOLVED 是设计里的一种正常状态:钱可能扣了,也可能没扣,系统自己判断不了,要有人去核对交易平台的记录再结案。抽奖券的扣款也晚于主订单,两者之间用户余额可能已经变了。
推送层只转发、不存状态#
overlay 上的奖池数额 3 秒没有更新就标成过期;中奖要趁用户还在会话里通知到,但在落库之前一个字都不能播出去。推送连接数跟着在线用户数走,奖池的 tick 跟着活动数走。
prize-service 用 transactional outbox 把帧交给 stream-service 的 intake 接口,模式本身见事件驱动架构那篇:一个事实和通知它的义务在同一个事务里提交。奖池的 tick 每秒一次,挂在一个 lease 下面,只有拿到 lease 的 pod 发。stream-service 每个会话一条 SSE,建连时验凭证,帧只按房间(一个活动下的所有会话)或个人投递,内容它一律不读,加新消息不用改它。
stream-service 不存任何东西,也不发 id: 行,浏览器重连时不会带 Last-Event-ID,它也不补发。overlay 自己接管重连,重新连上之后先拉一遍当前状态。推送服务的决策记录里有一句 a frame is not a fact:帧只是提示,钱的记录在 prize-service,overlay 拉取时看到的才是真实状态。
断线期间发出的帧会丢,正确性全靠重连之后那一次拉取;延迟上多了 outbox 轮询和一次 intake 调用两跳。扩容也还没解决:多实例之间用 Redis pub/sub 还是 Kafka 做 relay,决策记录里推迟到试点(pilot)之后再定,在那之前 stream-service 只能跑一个副本,加第二个副本,一条房间帧只会到达跟 intake 请求落在同一个实例上的那部分会话。
overlay 走固定入口发版#
overlay 和 shell、container-service 分开发版。overlay.js 和 overlay.css 每次构建都叫这个名字,同一个 URL 没法既长期缓存,又在发版后马上换成新内容。
版本最省事的放法,是直接写进 resolve 下发的 overlay 地址:resolve 的响应本来就不缓存,连跳转都省了。但这个地址是 container-service 的环境变量,每发一版 overlay 就要改一次它的 Deployment,pod 跟着重启,overlay 的发版就绑在了 container-service 上。
最后的做法是让固定地址只当指针。请求它得到一个 302,跳到按 overlay.js 和 overlay.css 的内容算出来的 hash 目录,那里的文件缓存一年,标成 immutable;指针缓存 5 分钟,另外允许 5 分钟的 stale-while-revalidate。container-service 的配置从此不用动,overlay 发版就是部署一个新镜像。hash 只看内容,同一份源码构建出的路径相同,回滚会回到玩家浏览器里已经缓存的那个地址。
这套做法有几处代价。这个域名在 Cloudflare 上的浏览器缓存 TTL 是 4 小时,会把比它短的源站缓存头改成 4 小时,指针的 5 分钟要靠一条只作用于入口路径的 Cache Rule 才能生效。每次打开应用多一跳 302,5 分钟内再打开时由浏览器缓存直接应答;这 5 分钟也是新版本到达已经拿过指针的玩家要等的时间,发版是一次全切。页面打开期间用的一直是加载时那一版,prize-service 的接口和推送的帧要同时兼容新旧两版 overlay;shell 调用 overlay 的方式要是改得不兼容,得先发能兼容两种调用的 shell,再发 overlay。
第三方脚本基本都是这两层。实测了几个第三方脚本和 CDN 的缓存头:URL 里带版本的,比如 Sentry 的 SDK、jsDelivr 和 unpkg 上的包、Stripe.js 内部带 hash 的文件,都缓存一年;URL 固定的入口缓存几分钟到十几分钟,Stripe.js 的 js.stripe.com/v3/ 是 120 秒,reCAPTCHA 是 300 秒,gtag 是 900 秒,只有 Cloudflare 的统计脚本缓存一天。Cloudflare Turnstile 两层都有:api.js 缓存 5 分钟、返回 302,跳到带 hash 的路径,那里缓存一年,这里的做法和它一样。Stripe 要留一个固定入口,是因为商户网站的 HTML 里写死了 js.stripe.com/v3/;破坏兼容的改动它另外放进 URL 里带名字的大版本(比如 js.stripe.com/dahlia/stripe.js),每年两次。这里的接入方是自己的 shell,不兼容的改动靠排好 shell 和 overlay 的发版顺序,用不着命名版本。
扩展性#
负载有三个来源,各自落在不同的组件上:
| 负载跟着什么涨 | 落在哪里 | 上限或约束 |
|---|---|---|
| 打开应用的次数 | shell 的静态文件由边缘返回;每次打开调一次 container-service 的 resolve | 静态文件请求不收费、不限量,缓存在离用户最近的节点;resolve 和 Redis 要跟着打开次数扩 |
| 在线的 overlay 数 | 每个 overlay 一条 SSE;access token 3 分钟过期,每个 overlay 3 分钟内至少刷新一次,每次都在 Redis 里轮换 refresh token | stream-service 只能单副本,单实例目标一万条连接;刷新请求至少是在线数除以 180 秒 |
| 报名用户的结算订单 | prize-consumer 批量消费;每张抽奖券两次 MySQL 事务,加一次交易平台扣款调用 | consumer 的并行度不超过交易平台那个 topic 的分区数;扣款吞吐受交易平台接口限制 |
推送是唯一还不能横向扩的一层。container-service 的会话在 Redis 里,prize-service 的数据在 MySQL 和 Redis 里,这两个服务的应用层没有本地状态,可以直接加副本;stream-service 在多实例 relay 定下来之前只能跑一个副本,在线的 overlay 数超过一个实例能扛的连接数,就只能给这一个实例加资源。
另外两处上限不在这套系统里。Kafka 的每个分区在一个消费组里只分给一个 consumer,prize-consumer 的 pod 加得比交易平台那个 topic 的分区还多,多出来的就闲着;抽奖券扣款要等交易平台的接口,这个接口能接多大的量,决定了活动能跑多大。
重连会把压力转到 prize-service 上。推送连接断开后会成批重连,建连时的验签在本地做,这一步不放大;但 overlay 重连后要先拉一遍当前状态,所有断开的会话会在同一段时间里打到 prize-service。stream-service 只有一个副本,它每重启一次,所有在线会话都要重连一次。ALB 这时也会在很短时间里收到大量新建连接,AWS 的建议是流量在五分钟内翻倍以上时,提前给负载均衡器做 LCU reservation。
长连接用 ALB 还是 NLB#
推送的 SSE 连接先到 Cloudflare,再回源到 ALB,两段都有超时。源站在 Proxy Read Timeout(默认 125 秒,只有 Enterprise 能调)内没有返回数据,Cloudflare 会返回 524,所以 stream-service 建连后要马上把响应头发出去;ALB 的空闲超时默认 60 秒,可以在 1 到 4000 秒之间调,连接上 60 秒没有数据就会被关掉。现在每秒一次的奖池 tick 会发给房间里的所有会话,这两个超时都碰不到;哪天有连接可能长时间收不到帧,心跳间隔要比 60 秒短,或者把 ALB 的空闲超时调大。
连接多了以后,ALB 本身撑得住,它按 LCU 自动扩容,要算的是费用。以 us-east-1 为例,ALB 一个 LCU 每小时 0.008 美元,相当于 3,000 条活跃连接或每小时 1 GB 处理字节,按用量最大的那一项收费;NLB 一个单位每小时 0.006 美元,相当于 100,000 条活跃连接或每小时 1 GB 处理字节。只看连接数,NLB 的单价低得多。但这里每条连接每秒都会收到一个 tick,只要每条连接每秒收到的数据超过大约 93 字节,ALB 的费用就由处理字节数决定,而这一项 NLB 只便宜四分之一。
换成 NLB 还有架构上的代价。NLB 工作在第四层,不能按路径分流,SSE 只能换一个单独的域名,浏览器那边又回到跨域:CORS 要重新配,凭证那一节说的改用 HttpOnly cookie 也要重新考虑。想留在同一个域名下,就得让 Cloudflare 按路径换源站,要么用只对 Enterprise 开放的 Origin Rules override,要么让一个 Worker 转发每一条长连接。TLS 在哪一层终止、源站只接 Cloudflare 流量的规则,也都要在 NLB 上再做一遍。
Slack 的结构可以参考。Slack 高峰时同时在线的用户数以百万计,客户端流量在 AWS 上先到 NLB,NLB 做四层透传,后面按流量类型分成两组 Envoy,一组专门接 WebSocket,一组接 API 和其他请求,TLS 在 Envoy 上终止,再由 Envoy 转给后端服务。也就是说,前面换成 NLB 之后,七层的路由和 TLS 就要由自己的代理来做。Slack 早先用 HAProxy 接 WebSocket 时,每次重载配置都让旧进程再跑好几个小时,等长连接自己断开,免得用户频繁掉线;stream-service 发版时要面对的也是这个问题。
对这套系统,ALB 够用。推送层现在的瓶颈是 stream-service 只能跑一个副本,不在 ALB。值得换 NLB 的是两种情况:连接数到了百万级,每条连接上的数据又很少,连接数成了费用的大头;或者需要超过 4000 秒的空闲超时。
安全#
凭证本身的取舍在「container-service 签发凭证」那一节,这里看另外三条边界:边缘和源站之间、同一个源里的几个服务之间,以及谁能改用户域名下的内容。
源站只接 Cloudflare 的流量#
Cloudflare 对所有套餐都提供不计量的 DDoS 防护,打到 /open/* 的流量由边缘直接返回静态文件,既到不了源站,也不产生费用;如果改成让 Worker 脚本先执行,每个请求都要按 Workers 计费,攻击流量也会变成费用。这层防护成立的前提是 ALB 只接受从 Cloudflare 过来的流量:代理 DNS 能藏住源站 IP,但 Cloudflare 的文档提醒 DNS 的历史记录查得到,还要在源站上只放行 Cloudflare 的 IP,或者用 Authenticated Origin Pulls、Cloudflare Tunnel。
同一个源共用一个安全边界#
同源之后,这几个服务共用一个安全边界。同一个源的页面可以直接访问彼此的 window,也共用 localStorage,所以域名下任何一个路径出现 XSS,脚本拿到的就是整个源的权限:能操作同源打开的 shell 页面、读写 localStorage、以同源身份发请求,Origin 白名单也挡不住。服务商的 H5 在 iframe 里、来自它自己的域名,不在这个边界里。
谁能改用户域名下的内容#
shell 替 overlay 持有 refresh token,能改 shell 的人就能拿到所有用户的凭证,所以 Cloudflare 账号和 wrangler 用的 API token 要和生产集群的部署权限按同一个级别管理。共用的 ALB 是同一类问题:AWS Load Balancer Controller 的文档提醒,只有能创建、修改 Ingress 的人都在信任边界之内时才该用 IngressGroup,能往这个 group 里写 Ingress 的人,就能往用户域名下挂路由、覆盖别人的规则。
小结#
用户那一侧现在是一个域名、一条 SSE、一张 3 分钟的凭证;服务端有状态的只有 container-service 和 prize-service;money path 上的每一步都先在自己的库里落一行,再去碰交易平台。已经能看到的欠账有两笔:推送层没有多实例 relay,只能单副本;签名公钥是配置里的静态值,没有轮换机制。UNRESOLVED 要人工结案是设计上接受的代价,不算欠账,但它决定了奖池上线后后台得有人值守。安全上除了凭证,还要守住两处:源站只接 Cloudflare 的流量,以及谁能改用户域名下的内容。
参考资料#
- Routes(Cloudflare Workers 文档,路由挂在已代理域名上、位于源站前面,不匹配的请求照常回源)
- Static Assets(Cloudflare Workers 文档,命中静态文件时不执行 Worker 代码,文件自动缓存在最近的节点)
- Billing and limitations(Workers static assets:静态文件请求不收费、不限量,执行到 Worker 脚本的请求按 Workers 计费)
- Migrate from Pages to Workers(兼容表里 non-root routes 只有 Workers 支持)
- Your frontend, backend, and database — now in one Cloudflare Worker(Cloudflare Blog,2025-04-08,Pages 之后的投入和新功能都放在 Workers 上)
- Origin Rules(Cloudflare 文档,DNS 记录和 Host 头 override 只在 Enterprise 上可用)
- Introducing workerd: the Open Source Workers runtime(Cloudflare Blog,2022-09-27,Apache 2.0 开源,不是现成的边缘计算平台)
- Building vertical microfrontends on Cloudflare’s platform(Cloudflare Blog,2026-01-30,Router Worker 按 URL 路径分发请求,Cloudflare dashboard 内部也在用)
- Connection limits(Cloudflare 文档,Proxy Read Timeout 默认 125 秒,只有 Enterprise 能调)
- Error 524(Cloudflare 文档,源站在 Proxy Read Timeout 内没有响应时返回 524)
- DDoS protection(Cloudflare 文档,所有套餐都有不计量的 L3、L4、L7 DDoS 防护)
- Protect your origin server(Cloudflare 文档,DNS 历史记录会暴露源站 IP,只放行 Cloudflare IP、Authenticated Origin Pulls、Tunnel)
- Same-origin policy(MDN,同源文档可以直接互相访问,存储按源隔离)
- KafkaConsumer(Kafka 4.2 javadoc,每个分区在消费组里只分给一个 consumer)
- Ingress annotations(AWS Load Balancer Controller 文档,IngressGroup 的信任边界提醒与
group.order的排序规则) - Vault Agent Injector(HashiCorp 文档,init 容器在应用启动前把密钥渲染进共享内存卷)
- TanStack Query Overview(TanStack 文档,服务端状态的获取、缓存与同步)
- Elastic Load Balancing pricing(AWS,ALB 的 LCU 和 NLB 的 NLCU 各自的计费维度与 us-east-1 价格,2026-09-24 查询)
- Edit attributes for your Application Load Balancer(AWS 文档,ALB 空闲超时默认 60 秒,可调范围 1 到 4000 秒)
- Using Load Balancer Capacity Unit Reservation to prepare for sharp increases in traffic(AWS Networking & Content Delivery Blog,流量五分钟内翻倍以上时提前预留容量)
- Traffic 101: Packets Mostly Flow(Slack Engineering,2023-08-28,NLB 四层透传,WebSocket 和 API 分别由两组 Envoy 接)
- Migrating Millions of Concurrent Websockets to Envoy(Slack Engineering,2021-03-15,高峰时数以百万计的同时在线用户,HAProxy 重载时让旧进程跑几个小时等长连接断开)
- Stripe.js versioning and support policy(Stripe 文档,URL 里带名字的大版本每年两次,非破坏性更新推给所有版本)
- Improve Single-Page Application (SPA) Performance with a Same Domain policy using Amazon CloudFront(AWS Networking & Content Delivery Blog,2023-05-09,CloudFront 按路径分 origin 省掉 CORS preflight)
- Server-sent events(HTML 标准,
EventSource构造函数的 IDL) - RFC 9700: Best Current Practice for OAuth 2.0 Security(§4.14.2,refresh token rotation)
- Env Variables and Modes(Vite 文档,
import.meta.env在构建时静态替换) - Origin(MDN,同源的 POST、PUT、PATCH、DELETE 也带 Origin,GET、HEAD 不带)