在适合的场景中将业务部署至 Serverless
相比于传统服务有极大优势,诸如降低成本、弹性伸缩、高扩展能力、高稳定性等等这里不再细说,我们这次聊聊在业务中如何把服务合理的部署到 Serverless
。本文中的 Serverless
指代适用于部署服务的 FaaS
元素,大多数云平台都提供 FaaS
支持,如 AWS Lambda / Azure Functions 等。
由于 Serverless
的特性约束,我们需要在固定的编程模型上构建模块,这无论是对移植还是构建新项目都有巨大阻碍,同时还会与原有服务的结合性、部署与维护的难度,标准化等等问题值得考虑,如今市场上推出了很多基于云平台的 Serverless
提供的快速部署服务,它们在抹平差异与提高体验上做出了很多成果,遗憾的是至今没有以此为基础纯粹的开源系统,这里我借鉴了很多 Serverless
部署平台设计的经验,提出一种基于无服务化的可在生成使用的工程化的部署平台设计思路,大家可以在此基础上设计构建现代化的部署平台。
在此之前,你可以阅读有关 Serverless 架构的基础知识,了解其与传统运维架构的区别。另,我在下文提出的模型已在生产上得到实践,设计细节也经过一些优化,但介绍时会尽量减少技术细节,多谈论具有普适性的方法。
优势
在开始之前我们先讨论构建专用的 Serverless
部署平台的优缺点,其中一部分是 Serverless
本身的特性所致,这证明了合适的场景下迁移至 Serverless
是有巨大成效的,一部分是平台设计上的优势:
高速的部署。
传统部署方式常常一次花费数分钟,且难以控制更别说维护与扩展。新平台让部署始终保持在秒的量级,部分项目从触发到完成部署能够保持在 10 秒以下。
项目具备自动伸缩与高可用性。极低的资源消耗。
这是
Serverless
自有的优势。高可控。
下文的设计中我们保持了多个函数端点,回滚与备用方案简单可靠。
轻量。
也是由于部署成为轻、快、小的工作,大量的工程都得以拆分,按模块推进迭代使所有的工作都向轻的方向发展。
高开发者体验。
除去项目上的优势,部署平台保证了在任意阶段回溯、中断的可能性,且可拆卸非常易于维护,对于应用的开发者来说也具备感知低、体验高的优势。
流程总览
我们可以把所有的部署环节分为 4 个步骤,包括从发起部署请求开始到部署完成的阶段:
- 在用户机器 (或 CI/CD 服务端机器) 的本地同步环节
- 通用服务端的鉴权、处理、同步构建信息
- 构建服务的打包与编译
- 部署服务,部署至 Serverless 并搜集必要信息
[serverless bridge] --> [builder] --> [init shell]
[auth] ↓
[ci/cd hook] | ↓ |--> [build service]
[user cli] | <--> [general service] --|
[website] | ↓ |--> [deploy service] --> [server less]
[build database] |--> [router]
|--> [log][health][...]
-----------------
[code storage]
关于取舍上的疑问:
我们为什么把 编译 与 部署 的工作不放在一个环境中运行?
部署 Serverless 时,未来很有可能扩展多个语言,并不仅局限于前端,甚至可能业务需求超出当前的平台的构建功能,这时可以允许用户为构建过程自定义脚本,此时编译部分相当于只提供上下文的一个容器环境,它是清洁的,用户即便在其中如何操作也只会影响相对于此用户的业务代码,并不会对部署的环境变量、脚本、私密信息造成影响。另一方面,部署服务实际上消耗的 CPU 极低,相比于构建服务更健壮,将部署服务单独运行不仅便于实现
redeploy
类的需求,也更能保证在回滚的可靠性。区分多个服务的通信成本?
存在通信成本,但很低。实际上面对更复杂的业务时我们可能以上步骤拆分的更细致,但服务之间还是只有 触发/被触发 的事件关系,并不会长时间挂起通信。即便我们把所有服务设计在一个进程中,也只是共享内存,省去了对用户部署项目的源码读写时间,事实上这与构建时间相比不值一提。另外由于我们在
code diff
上有着良好的设计,每次对用户源码的读写成本会更低。(事实上即便你把所有的功能杂糅在一期,在回滚、查看构建结果等需求的面前,还是需要保存用户代码)接入第三方鉴权的难度?
很多公司已经有了自己的鉴权系统,服务越多意味着每个服务都要单独接入鉴权系统且针对不同的权限设计不同的控制范围 (而且面对上游的变动你得做非常多的垃圾工作),这是很多让人头疼的工作。这里我们只需将通用服务端接入鉴权,其他的服务既鉴权也不鉴权,具体我们下文细说。
按上所示我们梳理一下全部的部署流程:
- 用户端通过
cli
hook
或其他方式将代码上传到通用服务 (我们也可以称为web service
,因为主要为前端提供服务),在通用服务中我们至少链接一个鉴权服务与部署数据库,即可以辨明 用户 - 项目 - 部署 等关系。 - 在通用服务中还需要对待部署的源码 diff,将变更后的代码储存到代码库 (
data-storage
) 并更新一个版本,随后向build-service
发起一个构建通知。 build-service
根据指定标识拉取指定代码,预处理代码并在众多的 构建服务堆 中找到一个合适的构建脚本去处理,最后开始构建。build-service
完成一次构建之后只发出通知并开始清理环境 (如上传完成的代码、清理构建日志等)。准备开始下一次构建。- 在通用服务中通知
deploy service
开始部署。 deploy service
也是通过标识开始拉取构建完成的代码,检查后根据通知部署至serverless
并设置指定的路由、网关、日志等操作。- 最后由
general service
通知用户部署完毕,返回各地址与详细信息。
收集部署代码
无论是通过 Git Hook 还是用户本地运行命令,收集源码都是一次部署的第一步,与常见的打包上传、构建后上传、推镜像等方式不同,我们需要更注重用户代码块的重量与收集速度,因为按本文的架构设计我们需要把所有的源码文件全部上传到 general service
,这当然不可能每次传输数十兆的文件,常见的解决方案是将用户的每个基础文件或文件夹与记录对比,只上传修改后文件,这和 Git 有点像。
假设我们的服务中包含了用户所有的文件描述与哈希,那么就可以轻易对比一个文件是否改动过,在逻辑的实现上可以考虑以目录的层级为基础,广度优先逐层对比,直到收集完所有的文件与它们的描述信息,再逐一传送给服务端,最后我们向服务端请求一个部署指令。在此也包含几个常见问题:
- 用户的鉴权
- 项目的归属
- 部署的权限
- 部署版本等单次部署信息
面对 用户-项目-部署 这样非常简单的逻辑关系我们可以轻易在服务端实现,不必细说,只谈谈何时为它们建立关系。
我们可以把 项目的维护、部署的维护 看作是不同的资源,但它们与项目的源码都没有任何关系。在记录时,假设这次部署没有任何源码,我们只为用户记录 建立项目 - 绑定项目与用户 - 建立部署版本 - 绑定部署版本与项目
简单的关系,最后再开始接受这次的源码上传,将这次的源码看做一个库存放在 OSS 或其他低频写入的数据库中,在写入源码时可以将部署版本写作前缀或是描述信息中 (非 SQL)。这样就可以通过任一源码文件找到部署版本,也能通过任意版本找到所有源码。举个例子,本地存在多个文件存放于不同文件夹中,开始这次的源码收集:
同步用户、项目信息,创建一个部署版本,准备使用部署版本来上传源码。
收集源码:
2.a 从目录层级浅到深逐一收集,立即与服务端对比,如果未改动则放弃文件夹下的所有文件
2.b 在原有文件对象 (如:
Stats
) 的基础上为每一个已经改动的文件创建新的描述对象,包含该文件的相对位置2.c 统计改动大小等信息,依次上传所有的改动
2.d 所有的改动上传完毕,请求服务端锁住所有文件并且不再接受改动
服务端验证所有文件的合法性后将 文件描述信息 与文件一同写入 OSS (这里以 OSS 为例) 的某个属于此项目的版本集合内。
服务端收到部署
v-d.d.d
版本的请求与部署相关信息,向build service
发起通知同时更新版本的记录信息。
先说说这样设计的目的。在收集代码部分我们着重于两点:一是把所有的源码拆分开按文件上传,只记录它们的位置信息,而非普通的打包;二是将源码与 部署、项目 等业务逻辑关系分开处理。记住这两点,这是此文部署服务设计的关键所在。
很多的部署平台、服务的需求提现上之所以不能够按需部署就是因为他们粗暴的把单次源码打包上传,对于来自 Git Hook 触发的源码更是如此,即便有 diff
也是在经过网络传输之后,这其中已经浪费了非常多的网络传输时间,想象一下当你改动了一个数十甚至上百兆的项目后,被迫要等数十秒甚至数分钟才能完成构建的触发,特别是在外网、跳板连接时,效率可想而知。甚至它们在构建后只是粗暴的回收构建容器,更无需谈与上次构建的源码对比。这里我们可能只需要通过数十次 简单地、快速地、并发地 HEAD
请求就可以弄清楚究竟要上传多少代码,服务端也只需要并发地将 hash
与 OSS 内项目的集合进行查询是否有此 hash
相关文件即可,实际落地时,大型项目的 query & diff
时间也能控制在数秒左右,这样的设计可以极大的节约构建前的准备时间。
上文多次提及将编号版本的源码存储上 OSS 或其他数据库中,这样设计有多个意义:源码在多数情况下是较大较多的文件块,甚至有时还会包含媒体类型文件,特点又是单次写入永久不会改写,未来在其他服务中最多也只会读取,单独存储这样的大量数据就不会影响原有的数据库,也非常利于我们在未来环境变更、业务变更、性能优化。比如当你需要迁移到某个云服务商时,你可以考虑他们内网通信的一些大文件存储服务。
准备构建的设计
在 general service
的准备
按上文设计,直到文件同步全部完成我们都没有开始构建,这是因为我们把 构建 这个动作的权限仍旧放在 general service
上,这样设计的优势在于用户可以在任何时间以自己的鉴权信息构建指定版本号的所有文件,rebuilt
这类的需求在当前部署系统的设计中可以顺理成章的实现。比如你正在使用 Git Hook 反复的触发构建而代码并没有变化时
这显得很有用。
在构建的请求中,别忘了收集一些构建所需的信息,如用户青睐的脚本、指定的 before-build
、指定的 builder
(下文详细说明) 等等。现在可以通过 general service
向 build service
发送一个通知:『构建开始了』。
在 build service
的准备工作中,几乎没有任何关于鉴权的要求,只要是来自服务推送的构建就完成,很简单。我们在代码层面几乎只接受一个简单的信息,就是所谓的版本编号,因为所有的源码已经储存在了额外的空间,我们可以只通过此唯一版本号轻易的找到与本次构建相关的所有文件并将文件全部同步下来。(这次同步代码是只读的,但也必然是内网的同步,损耗时间的极短,不必担心)
[general service] <---> [build service] <--- [code storage]
在 general service
这端,我们还不能扔掉用户或是 Git,还需要 hold 住这个链接并向他们传输一下构建日志信息,这里你可以选用第三方的日志服务或是直接透传构建服务器的日志数据库。无论如何,日志查看的权限仍旧还在中央服务的这里,这非常易于你设计或接入权限系统。在未来,你还可以在中央服务上加入一些其他指令,如中止构建、立刻重新开始等等,中央服务也只需要简单的更新一次自己的记录数据库并与 build service
发出通知即可。
在 build service
的准备
好了,我们直到收到构建通知时可以简单的开始在 code storage
上下载源码,但这还不够,我们还得将源码文件复原成用户上传之前的层级关系。也许你已经想到了,很简单。由于我们在每个文件的描述信息上都记录了它所在的相对位置,build service
只需要一次遍历即可恢复整个代码库。
当然,这些用户代码在未来需要隔离构建,我们至少要准备一些容器,这里有两种方案:
使用单独准备的脚本来控制容器。
单独脚本的好处是单独的脚本更容易分离和维护,甚至你可以用自己熟悉的语言来写管理脚本,比如有很多成熟的
python
shell
管理脚本,只需简单的修改即可使用。难点在于当你要从代码中注入一段逻辑或参数时只能通过环境变量这个办法,如果有交互、控制类的需求还需要在代码中保留这个运行shell
的子进程信息并管理。使用编程通过接口控制。
与上述相反,你可以在代码中根据业务逻辑更加精细的调整容器,如预先建立一个池分配资源。当然你需要自己完成所有的控制细节,如创建、管理、挂载、同步日志等。
在真正的启动容器之前最好别忘了设置好环境变量。假设我们有一个基础的构建脚本 build-init.sh
,build-init.sh
内不应该涉及任何具体的语言、框架、编译方式等信息,我们借助于这个初始脚本来规划在不同的语言、框架时如何构建 (具体在下文中介绍) 。而在收到 构建通知 时携带的元信息最好在容器的环境变量中设定好,这可能是当前项目的构建偏好、指定初始化位置等信息,但绝不应该是任何密钥。在构建容器初始化设置的所有环境变量都应该只与这次构建相关,与部署无关,不要有任何包含项目所处的业务信息。这是为了构建的安全考虑。
构建
我见过很多在 Jenkins
上写几句脚本就自称自动化、打包个镜像就算发布的平台 (可能你也正在公司里遭受这种『平台』的折磨),这些『平台』服务大多有一些通病:
- 发布慢 (因为构建很吃力)
- 支持语言少,扩展极难
- 构建脚本调试困难,构建脚本调试权限混乱
- 构建用时不透明、不隔离、不可控
在你下定决心要优化构建脚本时,花了五牛二虎之力找到那个写在一堆命令中间的命令后发现,居然是随手安装的某个服务,面对依赖众多年代久远长篇累赘的脚本时你才发现优化它们几乎是不可能的事。并无夸张,造成混乱的原因是构建平台设计之初没有将 构建 这一部分解耦,更没有在根据不同语言设计可扩展的构建插槽,面对不断迭代的新语言、新框架需求,逐渐变得无法维护,体验低下。当然这些人中也不乏佼佼者,它们找到了一个偷懒的好办法,就是在每次构建时都让用户自行写一个 dockerfile
,既不关心过程也不关心内容,也就是我们常说的『无为运维』之道。
构建原理
由于要在 Serverless
上运行我们的构建结果,所以仅仅构建是不能满足需求的,我们还需要一些功能来抹平 Serverless
与单片机之间的沟壑。我们的目标是构建过程像在单片机上一样简单,但也要能够无缝迁移到 Serverless
。除此之外,构建服务可能面临多语言、多框架的问题,在能够完成任务的基础上要做到进行合理的抽象拆解,让构建过程简单到谁都可以写,每种框架的脚本也能自由切换。本文以 NodeJS
为例,介绍设计的方案:
[builder A] [builder B] [builder C] ... // repos
---------------------------------
[npm / cdn / oss]
---------------------------------
↓
--------- <container> ----------
init.sh
↓
starter.js (nodejs shim)
↓
require([builder])(configs)
↓
output files
↓
[end.sh]
在一个构建过程中,会有以下几步:
在容器启动时挂载启动
init.sh
(或者任何你在容器指定命令时第一次启动的脚本)。这个脚本用于维护基础环境。用于构建的
starter.js
。当然你也可以跳过步骤 1 只用NodeJS
解决所有问题。2.a
starter.js
收集用户指定的构建目标,同时也可以自行做预构建,如分析目标框架。2.b 根据指定框架在
npm
/cdn
/oss
或者任何你想要的地方下载准备的脚本,我们可以称为builder
。2.c 引用
builder
并向其中注入当前构建信息。如 入口、输出目标、可用工作目录等。2.d 由于
init.sh
的初始化,builder
只在可用工作目录内构建并输出结果到出口。NodeJS
退出,end.sh
收集退出码等信息并将输入文件上传或储存到code storage 2
,也就是专用的输出代码储存空间。
为了能够保证构建完成,我们也需要准备相应的 builder
,粒度可根据场景决定。这样做的好处是我们所维护的 builder
不再是一段在子进程中执行的脚本,而是有上下文的,经过预处理的插件,比如这里外挂 python
插件,编写者根本不需要考虑进程和预处理问题,builder
编写者只需要写一个有具体参数的函数 (当然你可以用一个通用类型来检查约束这个插件函数),函数只做一件事,根据传入的参数做指定的构建。在公司内部,任何新框架新语言的改革都可以轻易的维护 builder
项目 (新增一种插件即可),如果是企业级或开源服务,也可以让各个语言、框架的专家分别负责自己的 builder
(now
就是这样做的),他们对于自己浸淫已久的框架有有着自己的优化手段。 当然,如果你喜欢,也可以一直在插件里写 shell
。
既然存在上下文同时又是框架专属插件,测试与维护起来更加轻而易举,在更改、新增一个插件时可以通过测试用例来保证构建的稳定性。
链接 Serverless
上一节中提到在构建过程中插入以编程语言为基础的插件,也许你会担心这样做增加了系统的复杂性,其实不然,在面对 Serverless
不同的编程语言接口时,我们还是需要考虑把每种编程语言、框架进行手动接入,因为一个普通的项目是无法直接部署在 Serverless
平台上。不同平台在不同语言上对上下文、handler 等有着不同的约束,你需要根据自己选用的平台 (如 AWS / Azure / Google / Aliyun) 和框架来考虑如何链接。
同样以 NodeJS
为例,在大多数的 Serverless
平台上他们对 req
/ res
/ context
等对象进行了自己的封装,我们可以设计一个 bridge.js
来抹平之间的差异:
// bridge.js
module.exports.handler = (req, res, context) => {
// req.xx = ...
// res.json = function() {}...
try {
// PLACEHOLDER
} catch (e) {
console.error(e)
process.exit(1)
}
return code(req, res, context)
}
在每个 builder
中,应考察平台与语言框架之间的差异,来设计 bridge
,最后将 PLACEHOLDER
替换成构建后的入口。如在 NodeJS
中我们需要扩展 req
/ res
至标准 HTTP
对象。在不同的 Serverless
平台上真正部署时,都可以指定从 bridge
开始执行,经过中间件抹平差异后开始引用业务代码。
在部署中我们尽可能的不 hack
用户代码,这里还可以统一集成一些监控、健康检查、日志的服务。
回收代码
在构建结束时构建服务需要将 outputs
回收到指定储存空间而非直接传递发布,一方面是便于多次覆盖发布,一方面是需要展示给用户查看具体的构建成果,有必要时我们也可以让用户下载这些文件手动部署。构建完成得到正确的退出码时从容器中拷贝出代码上传,与收集代码的工作类似,这时你可以为每个文件记录一次位置信息打散再保存,也可以打包压缩后储存在 OSS,因为大多数 Serverless
都支持从 OSS 直接部署一个压缩的代码包,这样我们就省去了很多后续工作。
注意,到此为止我们都还没有处理业务相关的环境变量、密钥等业务信息,所以从容器 outputs
取出的代码包虽是已构建的但也不能正常运行,我们还需要在部署服务中将它们结合分发到各个环境中。是的,本文的设计中构建环节并不区分环境,构建代码本身就不包含环境信息,如果你的业务出现构建与环境强关联,建议你把构建服务部署在多个节点上。(当然,并不推荐这样做)
部署
部署目标
Serverless
的特点是启动快,单次运行时并不处理复杂业务,能够将业务解耦成细粒度的逻辑使开发者专注于逻辑本身,所以我们通常不会在每个 Serverless
节点的业务上部署业务路由。并非无法实现,你可以通过传统路由的方式识别不同的路径参数来解决不同事务,但在很多人的设计下这可能会使单个 Serverless
服务成为一个巨大而全面的应用,保持着长时间运行而非拆分组件,这就丧失了原本的优势:横向扩展能力、低成本、高速部署与启动、自动收缩等等。
试想一下,如果你业务中的核心业务被调用与计算的频率较高,而周边服务较少,甚至可以依赖缓存,那完全可以将它们解耦成多个服务,通过你刚刚构建的平台一键高速部署,核心业务有着 Serverless
自动收缩、高负载、极速启动的的特点,而周边服务则多数时间的处于『休眠』状态节省资源,如必要,还可以继续横向扩展服务无痛接入。这在某些场景下是非常难得且高效的解决方案。
那是不是所有的业务路由和转发都要交付网关或是服务商提供的负载均衡器来解决?不完全如此。当你考虑到所有业务适合作为单个模块时则可以讲它们部署在单个 Serverless
节点上,涉及模块之间的联系则可以交于外部,绝不是单个计算函数只处理单个问题。简而言之你可以做到任何一点,其中的考量视业务场景而定。
还有一点值得在部署之前考虑,由于 Serverless
是无状态的,我们不会在容器内存储任何数据 (这样做没有意义),常见的解决方案是连接第三方的数据库与服务,如在不同的云平台会有内网可操作的数据库与其他服务。部署时所有的第三方服务都需要有多环境。我们需要谨记无状态的特点,它是好的编程风格但不适合所有人,这意味着你不能再依赖内存保存所有状态,也不能在所有的服务中共享内存信息 (它们本来就处于不同的容器中),每当你要保存一些状态时都要考虑通信。
部署方式
由于我们在构建时已经把代码包完整的保持在 OSS,所以代码块的部署工作非常简单,只需每次接受 general service
通知时找到相应的存储位置即可 (不下载)。按上文所述,general service
在调用同时还会收集用户配置的 Serverless
外部路由信息、密钥、环境变量等,我们需要加以整理并逐一创建:
- 为用户在
Serverless
平台创建项目、服务等,具体视不同的平台接口而定。 - 创建一个
Lambda
(视平台),入口指定为我们固定的bridge
。 - 填入用户指定的内存大小、运行上限、密钥等信息。
- 创建网关等第三方服务 (如果有)。
- 根据用户、项目信息创建子域名,并指向此
Lmabda
。 - 根据指定的缓存信息创建 CDN (如果有)。
完成这些步骤后一个可以被业务直接使用的 Serverless
服务基本部署完成,我们可以在 deploy service
直接落库并通知 general service
部署完成,将聚合信息传递给用户。
在各个 Serverless
服务平台中,只要我们没有开始调用服务总是不计费 (或计费极低),所以在部署时的策略与传统的替换、滚动等是不同的,我们总是新增而非修改,每当收到一个新的通知,无论是新项目、新版本还是重新部署我们都会创建一个全新的函数服务并配置全新的子域 (或其他标记方式)。当某个节点需要转到生产时我们以网关、负载均衡、自定义路由、域名解析等方式将生产流量指向函数即可,这样做的好处显而易见:
- 总是可以回溯到任意版本且设计简单、回滚高速
- 按端点统计资源,大型业务也能一目了然
- 可以做
pre-production
预热,无缝切换 - 多环境只需指定多服务与多域名连接至指定的端点即可
对于部署前端资源,可以让资源静态资源运行在一个函数中,在外层做 CDN 即可,对于前端现在流行的 SSR,函数计算的容器就是完美的服务端。
优化与实践
关于部署速度
与传统的打包部署相比,本文介绍的基于 FaaS
平台的部署架构可以极大的提高部署速度,同时由于项目的解构与拆解,一个函数模块从收集直到部署完成只需要几秒的时间,即便你的项目足够大,也在秒的量级。一方面得益于代码包更新的优化,一方面得益于 Serverless
端点几乎可以忽视的启动时间。对于重视部署速度、高速迭代的团队而言,这几乎最优的解决方案。
有少数项目在业务拆分后还是有非常多的外部依赖导致 build
时间过长,我们可以额外建立一个 cache storage
用于缓存的外部依赖,在构建时按项目区分即可。这部分依赖可以提供在 builder
的上下文中,由每个 builder
自行对比决定什么场景需要使用外部缓存,不同语言框架的设计各不相同,也可以做到各个领域内的优化。
关于构建容器
虽然文中推荐手动挂载容器构建代码,但你仍旧可以选择使用 Serverless
构建代码,也就是每一个构建单独创建一个函数端点,优势是减少管理和开发成本,但目前国内的云平台普遍没有较高函数运行的内存、代码空间也比较低,实用性较差,如果正在接入 AWS / Azure 等平台 (或是自建,如基于 Kubernetes
的 Fission),可以试试这种更简单的方案。
安全性
关于平台的安全性与可用性你可以在相关 Serverless
平台查看,部署平台承担部署但并非运行容器。对于部署平台,安全性的考虑集中在插入的部署脚本,但始终只在容器中进行构建并不会对其他项目产生影响,如必要也可以对 builder
采取白名单与过滤的做法。
管理
我们收集了关于项目、部署、日志等信息,如果你需要一个类似 CMDB 的可视化控制台,完全可以依据数据这些聚合给 WEB 服务,没有什么难度。在 bridge
中我们已统一插入了针对业务的监控,针对运行时性能监控可以链接到云平台的查询接口。