一、背景介绍
飞书深诺集团是专注海外数字营销解决方案的专业服务提供商,为有全球化营销需求的企业提供标准&定制相结合的全链路服务产品,满足游戏、APP、电商、品牌等典型出海场景需求。到 2022 年,集团各产线前端单页面应用(下文直接用 SPA 代替)的数量,已经占前端项目总数量的三分之二以上,个别巨石应用会面临以下问题:
- 应用单次构建-发布时间,已经超过了10min,从开发到生产上线部署,会经历 4 个环境 dev / test / pre / prod,总部署时间大于 40min 以上。
- 应用接入了 6+ 其他产线提供的 MF(Module Federation) 微前端组件,任何微前端组件进行版本升级时,都需要重走 dev -> prod 4 个环境的构建发布流程。
- 考虑到 test 环境可能同时有多个测试在多环境进行发布测试,这个量级又以倍数上升。
Life is short,将如此多的时间浪费在构建发布上是不值得的,解决此问题势在必行。
二、基本思路
解决思路拆解
根据上面信息可知,当前总构建发布时间
= 单次构建发布时间 * 环境个数
= 单次构建时间 * 环境个数 + 单次发布时间 * 环境个数
因为单次发布时间,一般只涉及到镜像的推送与发布,已基本没有可提升的空间,所以解决思路目前有两个:
-
解决思路一:减少单次构建时间
-
解决思路二:构建产物实现多环境复用,使公式由(* 环境个数)变为(* 1)
解决思路一:减少单次构建时间
目前业界针对减少单次构建时间,主要有以下常规主流方案:
-
使用 swc 或 esbuild 等新一代构建工具,带来极致的构建效率
-
应用构建缓存,本质上都是实现增量构建,主要有两种方案:
- 利用 docker 构建缓存,将不变的步骤缓存起来
- 利用 webpack 或 esbuild 本身构建缓存,提升打包速度
-
多进程优化,利用 webpack,实现多进程打包
以上方案业界内均有成熟实践,社区方案成熟度都还不错,也有较完善的文档进行支持,这里我们基于对 swc、esbuild 与 babel 在具体项目实践的横向对比,使用了 esbuild 替代原有的 babel,提升了单次构建时间。
解决思路二:构建产物多环境复用
构建各环境都能通用的服务镜像,并在不同环境部署的思路,并无新颖之处,在发布后端服务时,先构建服务镜像、再发布并在镜像运行时注入环境变量,是早已实现的通用方案。
我们能否借鉴后端构建发布模式,实现对前端构建产物的复用呢?
如果要部署一个 Java 服务或 Node.js 服务,流程往往是这样的:
根据上面时序图可以看到,在部署服务时,通过向推送至镜像仓库的通用 docker 镜像,注入不同的环境变量、以及配置中心的配置,来实现服务在不同环境的差异化。
通过时序图也可以看到,后端服务部署存在三个核心点:
- 运行环境
- 配置中心设置的全局变量
- 通用软件制品(可以复用的编译后产物)
整个过程可以抽象理解为这样:
技术的底层逻辑上,前端后端没有本质区别,参考后端服务的构建思路,仔细分析 SPA 构建、打包后的产物,得到我们处理前端应用的方案,如下图所示:
通过以上设计,可以使 html 承担起全局变量维护及变量注入的功能,而将 SPA 的主要构建产物 js、css,变成通用制品,实现多环境复用。
为支持通用制品的构建、以及多环境 html 对通用制品的复用,需要对运维部署架构进行重新设计。
一般来说,可以将 SPA 的构建产物分为 3 个类别:
- 抽出了包含变量的 index.html
- 通用制品 js / css
- Static 下的其他静态文件,如 jpg、png 等
传统部署方案是,将上面 3 个类别的构建产物,全部放到 nginx 的 docker 镜像内。(注:使用镜像的原因是方便运维实现多环境及灰度功能。)
当用户访问时,会通过 SLB 打到 K8s 集群,再由内部的 ingress-nginx 将流量分发到相应的 docker 镜像的 index.html 等静态资源,如下图所示:
按照构建通用制品的解决思路,为了实现 js / css 的共用,整体部署应改为:
如图所示:
-
通用制品 js/css 构建 1 次后,即可多环境复用,可以部署在云存储空间内。(如阿里云 oss、腾讯云 cos)
-
虽然仍需要根据不同环境,生成不同的 html 文件。但这个过程可以用 html 模版引擎替代完成,生成时间可以非常短(1s内)。
三、整体方案
根据以上思路,对 SPA 的新架构敏捷部署方案的设计如下:
全流程时序图
各步骤详解
开发 -> 构建 js / css
- 开发时,需要注意项目中,所有根据环境变量变化而变化的变量,都要维护在 html 里。如 api 的 baseUrl, 在 test 环境为 test-xxx.xxx.com,在 prod 环境为 xxx.xxx.com,这时可以在 html 中维护一个变量
window.appConfig.gateway
=不同的值
,然后在项目中直接使用window.appConfig.gateway
即可。 - 构建时,构建的 js / css,一定是抽离了所有受环境变量影响的变量,这样才能被多环境复用。
- 构建时,我们用的 CI 为 Gitlab-runner,这里因为我们使用 k8s 集群,所以构建时,运维会自动拉起一个临时 runner 执行构建任务,执行后动态销毁该 runner,对服务器资源占用较少。
构建 js / css -> 上传云存储空间
- 构建时,使用的
publicPath
,通常为${云存储空间的CDN域名}/${项目ID}/${版本号}/
。 - 构建结束后,会将制品 js/css 资源传入云存储空间内,上传到
${云存储空间的CDN域名}``/${项目ID}/${版本号}/
目录下,与publicPath
保持一致。 - 上传成功后,
${项目ID}/${版本号}
将作为唯一版本标识,来定位项目当前使用的 js、css 版本。
配置中心 -> 生成 html
前端同学可能会很少接触,但做过后端的同学都会对服务注册、发现及配置管理的概念非常熟悉,这里我们使用的配置中心是 Apollo [参考一]。
这里需要针对每个 SPA 项目,在配置中心的新建一个 app-id 应用,并配置一个基础 namespace,在该 namespace 中, 配置该项目的 tpl
变量(html 模版)及其他变量参数(如引用js的版本 version
、api 调用的 baseUrl gateway_url
等)。
简单的 tpl 模版示例如下:
html
<!-- version:{{version}} -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<link rel="icon" href="/favicon.ico"/>
<title>XX后台</title>
<script>
const env = '{{env}}';
/** window.appConfig 可在js逻辑中直接取用 */
window.appConfig = {
env,
gateway:'{{gateway_url}}',
};
</script>
<link href="https://xxx.xxx.com/{{version}}/main.css" rel="stylesheet">
<script defer="defer" src="https://xxx.xxx.com/{{version}}/main.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
该 namespace 下其他变量分别为:env
、version
、gateway_url
。
这里的原理是,可以用 nunjucks(Nunjucks) 模版引擎,将 tpl 和其他变量组合起来,生成新的 html。
这里有个拓展项,如果该项目引用了多个微前端项目,也可以将这些微前端 js 的 url,配置在 tpl
上,并仿照 version
变量,将微前端所用的 js 版本的变量抽出,实现项目对微前端引用版本的管理。
构建包含 html 和静态文件资源的 docker 镜像
如果需要发布最新构建版本的 js / css,可以先获取构建上传后的唯一标识 ${项目ID}/${版本号}
,并将 version
修改为该值。
发布时,执行顺序如下:
1)生成 html ,并放在 nginx 的 docker 容器里
比如需要引入最近构建版本的 js / css,可以先获取上一步构建上传后的唯一标识 ${项目ID}/${版本号}
,并将 version
修改为该值。
2)复制静态目录下的文件,放在 nginx 的 docker 容器里
针对一些必须放在域名根目录下的文件,如微信授权相关验证文件,是不能和 js / css 一样,放在 cdn 域名里处理的。
所以也需要放在 docker 容器里,这样就可以从域名根目录下读取到相应资源。
3)镜像构建完成,上传 Docker 仓库
传统的部署方式是,将静态资源上传到服务器上,直接用 nginx 代理,但这种方案已经过与简陋了。
这里之所以使用容器化部署,是可以基于 k8s + istio 进行灰度发布、流量治理及多环境部署。
比如要支持多环境,可以在配置中心的 app id 下,新建多个环境的 namespace,这样就可以在多个环境,构建发布不同的版本的 docker 镜像,并同时对进行多个版本的项目进行测试。
性能差异测试
这里主要针对云存储(CDN)域名的增加,带来的访问性能差异。
按照改造前方案,构建产物 html / js / css / static目录下的静态资源,都会打进 docker 镜像,共用一个域名。
但改造后, 通用制品 js / css 会上传至云存储的 CDN 域名,所以用户访问时,会多一个域名,多一步 DNS解析 + TCP 链接的时间,这个国内访问约多消耗 150ms 左右,国外访问约多消耗 350ms 左右。
但同时经我们测试,同样的静态资源,放在业务域名下并开启 CDN + 全球加速(会回源到 docker 镜像内),与放在云存储域名下并开启 CDN + 全球加速(会回源到云存储空间),下载速度还是有略微差异:
放在云存储域名下会略快一些(1m资源大约快60ms~200ms左右),猜测是回源时到docker镜像有带宽限制(购买的带宽是多少,就是多少),而回源至云存储空间无带宽限制。
所以静态资源放云存储空间会带来更快的下载速度,抵消了一部分增加新的 CDN 域名带来的连接耗时,付出这部分性能成本也是值得的。
四、总结及后续优化方向
该方案已经在公司稳定运行了一段时间,通过本方案,达成总构建发布时间 = 单次构建时间 * 1 + 单次发布时间(通常 < 15s) * 环境个数,又因为该方案将构建和发布解耦,在 SPA 部署时,带来了比较可观的时间收益:
- 方案上线前,单个 SPA 在单个环境,部署时间约为4~10min;方案上线后,时间缩短至 2 min 以内,部署时间也不会受到应用本身大小限制。
- 生产回滚时间可以做到 1min 内回滚,只需回滚上一次发布的镜像即可。
- 假设公司一天在 dev / test / pre / prod 4个环境发布 100 个项目,平均每个项目发布时间按 6min 算,方案上线后,2min 即可实现发布,可以节约开发、测试小伙伴们约 7 个小时,基本相当于节省了一个人力。
同时,在实践过程中,我们也发现了一些有待改进的点:
- 针对提升单次构建时间,可以在运维层面加入构建缓存或者尝试远程构建缓存,进一步缩短单次构建时间。
- SPA 敏捷部署架构方案中的操作,涉及构建CI、注册中心、发布系统等,均在不同的操作界面上,割裂感比较严重,可以集成在一个系统进行处理。
这些将在后续的工作中逐渐调试和完善。
作者
马宗皓 (飞书深诺架构与平台技术,资深前端研发工程师)