WebAssembly(Wasm)这几年发展很快,从最初的浏览器端高性能计算,扩展到了服务端、边缘计算、嵌入式等多个领域。WebAssembly 2.0的标准也在逐步完善,带来了很多重要的新特性。

我们团队最近在做一个基于WebAssembly的高并发计算平台,用了很多WebAssembly 2.0的新特性,踩了不少坑,也积累了一些经验。这篇文章就来分享一下WebAssembly 2.0的架构设计,以及如何实现高可用高并发。

WebAssembly 2.0新特性

先简单介绍一下WebAssembly 2.0的几个重要新特性。

第一个是组件模型(Component Model)。这是WebAssembly 2.0最重要的特性之一。组件模型定义了一种跨语言的模块组合方式,让不同语言编写的WebAssembly模块可以互相调用。以前WebAssembly模块之间的交互很麻烦,需要手动处理内存和类型转换,组件模型标准化了这个过程,让模块组合变得简单。

第二个是多内存(Multi-Memory)。WebAssembly 2.0支持一个模块拥有多个线性内存,这对于很多场景很有用。比如可以把不同类型的数据放在不同的内存里,隔离起来,提高安全性。也可以用多个内存来实现更高效的内存管理。

第三个是异常处理(Exception Handling)。以前WebAssembly没有异常机制,出错了只能用返回值来表示,很不方便。WebAssembly 2.0加入了异常处理指令,支持try-catch-finally,和高级语言的异常处理类似,让错误处理更优雅。

第四个是引用类型(Reference Types)和尾调用(Tail Call)。引用类型让WebAssembly可以直接引用宿主对象,不用再用整数索引。尾调用优化让递归函数不会栈溢出,对于函数式编程风格很重要。

第五个是SIMD的完善。WebAssembly 1.0已经有了SIMD,但支持还不够完善。2.0进一步完善了SIMD指令,增加了更多操作,性能更好。

这些新特性让WebAssembly的能力大大增强,也让架构设计有了更多的可能性。

整体架构设计

我们的平台是一个高并发的计算平台,用户提交计算任务,平台调度WebAssembly模块执行,返回结果。整体架构分为几层:

接入层负责接收用户请求,做鉴权、限流、协议转换。用的是Nginx加自研的网关,支持HTTP和gRPC两种协议。接入层是无状态的,可以水平扩展,保证高可用。

调度层负责任务调度和资源管理。用户提交的任务进入任务队列,调度器根据任务的优先级、资源需求、节点负载,把任务分配给合适的执行节点。调度层用的是自研的调度器,支持优先级调度、公平调度、延迟调度等多种策略。

执行层负责实际执行WebAssembly模块。每个执行节点上运行着多个Wasm运行时实例,每个实例隔离执行一个任务。执行层是整个系统的核心,性能和隔离性都很关键。

存储层负责存储用户的模块、任务数据、执行结果。用的是对象存储加Redis缓存,热点数据放在Redis里,冷数据放在对象存储里。存储层也是高可用的,数据多副本存储。

监控层负责整个系统的监控和告警。采集各个层的指标,比如请求量、延迟、错误率、节点负载、任务执行时间等,用Prometheus加Grafana做监控,Alertmanager做告警。

这几层都是解耦的,通过消息队列和RPC通信,可以独立扩展和升级。

执行层设计

执行层是最核心的部分,详细说一下。

每个执行节点上运行着一个Wasm运行时管理器,负责管理多个Wasm运行时实例。我们用的是Wasmtime作为底层运行时,它支持WebAssembly 2.0的大部分新特性,性能也不错。

每个Wasm运行时实例是一个隔离的执行环境,有自己的内存、资源限制、权限控制。实例之间完全隔离,一个实例崩溃不会影响其他实例。这对于多租户场景很重要,不同用户的任务在不同的实例里执行,互不干扰。

实例的创建和销毁是有开销的,所以我们用了实例池的设计。提前创建好一批空闲实例,任务来了直接从池子里取一个用,用完之后重置状态放回池子里。这样避免了频繁创建销毁实例的开销,提高了吞吐量。

实例池的大小是动态调整的,根据当前的任务量和节点负载,自动扩缩容。任务多的时候增加实例数,任务少的时候减少实例数,节省资源。

每个实例都有资源限制,包括CPU时间、内存使用、执行时间。用WebAssembly的资源限制机制加上操作系统的cgroup来做双重限制。防止恶意模块或者有bug的模块占用过多资源,影响其他任务。

高可用设计

高可用是分布式系统的基本要求,我们从几个层面做了保障。

第一个是节点层面的高可用。每个执行节点都有健康检查,调度器会定期检查节点的健康状态,如果节点不健康,就不会再给它分配新任务,已经分配的任务会迁移到其他节点。节点故障是常态,系统要能自动处理。

第二个是任务层面的高可用。每个任务都有超时和重试机制,如果任务执行超时或者失败,会自动重试。重试有次数限制,超过次数就标记为失败,通知用户。任务的状态持久化在数据库里,即使调度器重启,也能恢复任务状态。

第三个是数据层面的高可用。所有数据都多副本存储,对象存储用的是三副本,Redis用的是主从加哨兵。任何一个副本故障,都有其他副本可以用,不会丢数据。

第四个是部署层面的高可用。系统部署在多个可用区,每个可用区都有完整的接入层、调度层、执行层、存储层。一个可用区故障,流量自动切换到其他可用区,服务不中断。

第五个是降级和熔断。在系统压力过大的时候,会自动降级,比如暂停低优先级任务,只保证高优先级任务执行。对于故障的依赖服务,会熔断,避免雪崩效应。

高并发设计

高并发是这个平台的核心需求,我们从几个方面做了优化。

第一个是异步化。整个系统都是异步的,接入层用异步IO,调度层用事件驱动,执行层用异步执行。用户提交任务之后不需要等待,拿到任务ID就可以走了,任务执行完之后通过回调或者轮询获取结果。这样大大提高了系统的吞吐量。

第二个是无状态化。除了执行层的实例池,其他层都是无状态的,可以随便水平扩展。接入层、调度层、存储层的代理,都可以通过加机器来提高并发能力。

第三个是缓存。热点数据都放在Redis里,比如用户的模块、常用的配置、任务状态等。减少数据库和对象存储的访问压力,提高响应速度。缓存有过期时间和更新机制,保证数据一致性。

第四个是批量处理。对于一些小任务,我们支持批量提交和批量执行,减少网络开销和调度开销。比如用户有100个小任务,可以一次性提交,调度器批量分配,执行节点批量执行,效率比一个个提交高很多。

第五个是预热。对于常用的模块,我们会提前加载到执行节点的实例里,任务来了直接执行,不需要再加载模块。模块加载是有开销的,特别是大模块,预热能显著降低延迟。

组件模型的应用

WebAssembly 2.0的组件模型在我们的架构里发挥了很大作用。

以前,用户的模块如果需要调用一些公共功能,比如HTTP请求、数据库访问、日志记录,要么自己实现,要么通过宿主函数导入。自己实现会增加模块大小,宿主函数导入又和具体的运行时绑定,不够灵活。

有了组件模型之后,我们把公共功能封装成标准的Wasm组件,比如HTTP组件、数据库组件、日志组件。用户的模块只需要声明依赖这些组件,运行时会自动把组件链接进来。这样用户的模块更小,也更通用,可以在不同的运行时里运行。

组件模型还支持跨语言组合。比如用户用Rust写了一个计算模块,用Go写了一个数据处理模块,用TypeScript写了一个逻辑模块,这三个模块可以通过组件模型组合在一起,互相调用。这在以前是很难做到的,现在变得很简单。

我们还做了一个组件市场,用户可以把自己写的组件发布到市场上,其他用户可以直接使用。这样形成了一个生态,大家可以复用组件,不用重复造轮子。

性能优化

性能是高并发系统的关键,我们做了很多优化。

第一个是运行时优化。我们对Wasmtime做了一些定制,比如调整JIT编译策略,优化内存分配器,改进指令调度。对于热点模块,还会做AOT编译,提前编译成本地代码,执行的时候直接运行,省去了JIT编译的开销。

第二个是内存优化。WebAssembly的线性内存是按需增长的,我们调整了内存增长的策略,避免频繁的内存重分配。对于大内存的模块,用了大页内存,减少TLB miss。还做了内存复用,多个实例可以共享只读的内存页,比如代码段,减少内存占用。

第三个是CPU优化。利用SIMD指令加速计算密集型任务,很多模块里的循环计算都可以用SIMD并行化。我们还做了CPU亲和性,把实例绑定到特定的CPU核心上,减少上下文切换和缓存失效。

第四个是IO优化。对于IO密集型任务,用了异步IO和零拷贝技术,减少数据拷贝和线程阻塞。网络IO用了io_uring,文件IO用了异步接口,都比传统的同步IO快很多。

经过这些优化,我们的平台在标准测试集上的性能比最初版本提升了三倍多,延迟降低了一半。

安全设计

安全是多租户计算平台的重中之重,WebAssembly本身提供了很好的沙箱隔离,我们又在上面加了几层防护。

第一个是模块验证。所有用户提交的Wasm模块在执行之前都会经过严格的验证,检查模块的格式、指令、类型是否合法。防止恶意构造的模块利用运行时的漏洞。验证用的是WebAssembly标准的验证器,加上我们自己的一些检查规则。

第二个是权限控制。每个模块都有一个权限清单,声明它需要哪些权限,比如网络访问、文件访问、系统调用。运行时根据权限清单来限制模块的行为,没有声明的权限就不能用。默认是最小权限,什么都不能做,需要什么权限自己声明。

第三个是资源隔离。前面提到过,每个实例都有CPU、内存、执行时间的限制。除此之外,还有文件系统隔离,每个实例只能访问自己的工作目录,不能访问其他目录。网络隔离也是,实例的网络流量经过代理,可以限制访问的域名和端口。

第四个是审计日志。所有模块的执行都有详细的日志,包括执行时间、资源使用、系统调用、网络请求等。日志存在不可篡改的存储里,出了问题可以追溯。对于可疑的行为,还会实时告警。

实际案例

说一个实际的案例,我们的平台支撑了一个图片处理的业务。

这个业务是用户上传图片,平台做各种处理,比如裁剪、缩放、滤镜、格式转换、人脸识别等。以前是用Node.js写的,用了sharp库,性能还可以,但并发上不去,高峰期经常超时。

后来我们把图片处理的逻辑用Rust写成Wasm模块,部署到我们的平台上。Rust的性能好,Wasm的隔离性好,平台的并发能力强。

改造之后,单张图片的处理延迟降低了40%,吞吐量提升了三倍,高峰期也不超时了。而且因为Wasm的隔离性,即使某个处理模块有bug崩溃了,也不会影响其他任务,系统的稳定性大大提高。

用户还可以自己上传自定义的图片处理模块,用我们的组件模型组合标准的组件,快速开发新的处理功能。这个业务的开发效率也提升了很多。

遇到的坑

做这个平台的过程中,踩了不少坑,分享几个印象深的。

第一个是Wasm运行时的兼容性问题。不同的运行时对WebAssembly 2.0新特性的支持程度不一样,有的支持组件模型,有的不支持;有的支持多内存,有的只支持实验性的。我们一开始想支持多种运行时,后来发现兼容性太麻烦,就统一用了Wasmtime,只支持一种运行时,简单很多。

第二个是组件模型的工具链还不够成熟。虽然标准已经有了,但编译工具、链接工具、调试工具都还在发展中,有些功能还不完善。我们遇到过很多工具链的bug,比如链接出错、类型不匹配、调试信息丢失等,花了很多时间解决。建议大家在生产环境用组件模型之前,先充分测试。

第三个是性能调优很复杂。Wasm的性能调优和原生程序不一样,有很多特殊的地方,比如线性内存的访问模式、JIT编译的开销、SIMD的对齐要求等。我们花了很多时间做profiling和优化,才达到现在的性能水平。建议大家不要一开始就过度优化,先跑起来,再根据profiling的结果针对性优化。

第四个是安全永远不能掉以轻心。WebAssembly的沙箱虽然很安全,但也不是绝对安全的,历史上也出现过漏洞。我们定期做安全审计,更新运行时版本,修补已知漏洞。还做了模糊测试,自动生成各种畸形的模块来测试运行时,发现潜在的问题。

写在最后

WebAssembly 2.0带来了很多激动人心的新特性,特别是组件模型,让Wasm模块的组合和复用变得简单。基于这些新特性,我们可以设计出高可用、高并发、安全的计算平台。

当然,WebAssembly 2.0的生态还在发展中,工具链还不够成熟,有些特性还需要时间完善。但总的趋势是好的,WebAssembly正在从一个浏览器端的技术,变成一个通用的跨平台计算技术。

如果你也在做WebAssembly相关的项目,或者对Wasm感兴趣,欢迎交流。希望这篇文章能给你一些启发。