BasicAI SaaS 技术架构

BasicAI SaaS 是一个以数据为中心的 MLOps 平台,可用于快速开发和迭代 AI 模型,功能覆盖从数据集管理、数据标注、数据治理、模型训练,直到模型推理的整个 AI 生命周期,支持点云、图片、文本、音频、视频等各类数据标注。

BasicAI SaaS 技术架构

BasicAI SaaS 全面遵循云原生架构原则,采用容器化部署方式,并使用 Kubernetes 来编排容器和屏蔽底层基础设施差异,以保障服务性能的可扩展性,部署规模的可弹性,以及在故障情况下的服务韧性。前后端均按功能模块拆分为了多个独立服务,每个服务均可独立升级,还可根据客户需求灵活组合。应用服务均设计为无状态,结合 Kubernetes 的自动扩缩容机制,能够依据当前负载情况自动调整集群规模。数据库、消息队列和缓存等基础服务均选择了开源且云原生友好的分布式或支持集群模式的开源软件,可应对海量数据的存储和查询。基于 GitLab CI 的 DevOps 支持从代码提交,到软件包和镜像构建,直至发布到对应 Kubernetes 集群(开发/测试/生产)的整个发布过程的全自动化,使得开发人员可以专注于代码编写,同时也显著减少了重复性的运维工作。

整体架构

basicai-architecture

从上到下,整个系统可划分为五层,分别是接入层(Access Layer)、应用服务层(Application Service)、基础服务层(Base Service)、容器抽象层(Kubernetes)和基础设施层(Infrastructure)。

接入层

接入层接收外部的请求并转发给相应的服务,其中又包含了四层(TCP)和七层(HTTP)负载均衡。接入层对外隐藏了内部服务运行细节,只对外暴露 Web 前端、API 网关、WebSocket 等少量需要公开访问的服务,保障了内部服务的安全性。

应用服务层

应用服务包含前端、后端服务,以及各种模型推理服务。前端服务除了主应用,由于标注工具类应用的技术栈跟主应用差别较大,因此各类标注工具独立成为了单独的应用,比如图片标注工具、点云标注工具等。后端服务采用微服务架构,按业务模块划分微服务,各微服务通过 API 网关统一对外提供服务,这样还能在网关里集中实施认证、鉴权、限流等操作。

基础服务层

基础服务层包含数据库、消息队列、缓存、对象存储等支撑服务。考虑到 SaaS 服务对性能水平可扩展性的要求,数据库和消息队列均采用了新兴的分布式系统 TiDB 和 Pulsar。缓存使用了 Redis,也支持集群模式。对象存储使用了兼容 AWS S3 的开源对象存储系统 MinIO,同样支持分布式。

容器抽象层

为了屏蔽底层基础设施的差异性,引入了 Kubernetes 容器抽象层,这样无论是在什么样的软硬件环境,上层服务的部署方式都是统一的。

基础设施层

基础设施层提供计算、存储、网络等硬件资源,可以是各大公有云,也可以是自建私有云。

请求路由

basicai-request-routing

整个系统宏观来看,包含给用户使用的前台应用(APP)、给内部使用的管理后台(Admin),以及给开发者使用的 API 开放平台(Open)三个子系统。各个子系统通过不同的域名对外提供服务,由于认证鉴权方式不一样,因此拥有各自的 API 网关(Admin API Backend 兼具网关作用),但各个子系统会共享业务微服务,这样能最大程度复用业务代码,同时还能降低开发和运维的成本。

技术方案

结构化数据存储

tidb-architecture

作为 ToB 的业务系统,BasicAI 的业务数据复杂多样,相互之间关联紧密,并且需要通过事务来保障数据的一致性,因此只能选择关系型数据库。一个需要标注的数据集包含的数据个数,少则几万,多则几十万,甚至上百万,标注结果就更多了,单个数据标注出来的对象就有几十上百个,每个租户可以创建几十上百个数据集。再加上 SaaS 多租户的特性,随着租户数量的增加,数据量会急剧增加。
基于上述需求,我们最终选择了分布式关系数据库 TiDB,它具有如下优势。

  • 纯分布式架构,拥有良好的扩展性,支持弹性扩缩容
  • 兼容 MySQL,大多数场景下可直接替换
  • 支持高可用,少数副本失效情况下能够自动修复数据和转移故障
  • 支持 ACID 事务
  • HTAP,同时支持 OLTP + OLAP
  • 丰富的工具链生态,覆盖部署、迁移、同步、备份等场景

非结构化数据存储

minio-architecture

需要标注的数据都是非结构化的文件数据,类型多样,可以是图片、点云、语音、视频、文本等,文件大小不一,小的文本、图片只有几百 KB 或者几 MB,大的点云文件有几十上百 MB,单个租户可能就有几十上百 TB 的数据。
对于这样的海量文件数据存储需求,对象存储是不二选择。考虑到维护成本,在公有云环境会直接选择公有云提供的对象存储服务,比如 AWS S3、阿里云 OSS 等。如果是私有化部署,我们选择了开源的 MinIO,理由如下。

  • 兼容 S3 API,可无缝替换各大公有云的对象存储服务
  • 使用可配置冗余度的纠错码来防止磁盘故障导致的数据丢失
  • 通过高性能的算法来防止 Bitrot 错误
  • 支持服务端数据加密
  • 支持数据写入后即不可更改
  • 支持大多数高级身份管理标准,以及接入外部 OpenID Connect 身份服务
  • 持续复制能及时检测更新并快速复制,因此能支持大规模和跨数据中心场景
  • 支持跨地域的联邦集群,将多个集群的资源统一在一个命名空间下

半结构化数据存储

annotation-result-object-storage-2

BasicAI 作为一个 AI 数据标注平台, 里面存在海量的 JSON 标注结果数据。标注结果,也就是从点云和图片中通过人工手动或模型自动标注出来的对象(Object)信息,由于标注对象的描述形式很多样,有 3D 框、2D 框、矩形、多边形、线段等,因此只能以半结构化的 JSON 格式来存储。单个标注对象的信息大小从几百 Byte 到几百 KB,在点云分割场景下,需要保存分割区域里所有点的信息,单个区域里包含的点可能有成千上万,因此对象信息会比较大。传统的方式是使用数据库来存储标注结果,这样可以支持比较复杂的查询,但会面临以下挑战。
首先是存储挑战,按 1000 个租户,每个租户 100 万 Data,每个 Data 里有 30 个 Object,那么总的 Object 数量就是 300 亿。按每个 Object 1KB 大小计算,则需要的存储空间为 30TB。
其次是读写挑战,如果说存储上的挑战还勉强可以通过加节点和磁盘来解决,读写上的挑战就没法了,因为读写性能无法通过加节点来线性扩展。对于连续帧,每次在工具里打开就会同时加载几百个 Data 的几千个 Object,也就是一次从数据库读取几千条记录(数据量几 MB 到几百 MB),提交时也会面临同样的压力。如果说对于并发度不高的私有化部署场景,数据库还能勉强支撑,那么对于 SaaS,同时会有成千上万用户在线操作,数据库方案就变得完全不可行。
要想扛住这么大数据量的存储和读写,只有放弃数据库存储方式,改为使用文件来存储标注结果。每个 Data 的每个标注来源的标注结果存为一个文件,每次读取和提交标注结果都按这个粒度来整体操作,标注结果文件数量跟 Data 数量能大致维持在一个数量级。对象存储作为一种能够支撑海量小文件存储的文件存储系统,就非常适合标注结果的存放。
上图所展示的方案中,浏览器直接从对象存储服务以文件的形式读写标注结果,极大降低了 API 服务和数据库的读写压力。在连续帧场景下,浏览器会同时并发下载和上传几百个文件,但浏览器的同源并发限制会拉长整体的下载和上传时间。由于对象存储服务不支持单个请求批量下载和上传多个文件,如果这里遇到了性能瓶颈,那么可以增加一个“批量请求代理服务”来支持批量下载和上传文件。相比于数据库存储方案,文件存储方案性能提升的根本原因在于将随机的数据库读写转变为顺序的文件读写。

  • 相比于数据库存储方案,文件存储方案性能提升的根本原因在于将随机的数据库读写转变为顺序的文件读写。按一个连续帧 300 个 Data,每个 Data 30 个 Object 计算,一次连续帧标注结果加载或提交,数据库存储方案需要随机读写 9000 个 Object,而文件存储方案只需要连续读写 300 个文件,这里的性能差异是巨大的;
  • 将标注结果从数据库里移除之后,其记录数和存储大小会降低 1~2 个数量级,成本会显著降低,同时也避免了影响其它业务数据读写;
  • 支持海量标注结果存储,如果使用公有云的对象存储服务(AWS S3、阿里云 OSS),那么近乎拥有无限的存储空间,成本也比数据库的 SSD 磁盘低,私有化场景可以使用开源的 MinIO 来无缝替换;
  • 浏览器直接从对象存储服务读写标注结果,大大降低了 API 服务的带宽需求,公有云对象存储服务的带宽更大,价格也更便宜,还可以并发读写多个文件来加速;

大文件上传

minio-pre-signed-url-upload

存放在对象存储里的文件,读取时都是直接请求对象存储服务,但上传时因为需要做一些检查和保存文件元信息到数据库,一般都会经过应用服务来转发。对于小文件这样做没有太大问题,但对于大文件(几百 MB 甚至上 GB)这样的方案就不太合适了。应用服务一般是接收到完整文件之后再上传到对象存储服务,这样会占据大量的内存和磁盘缓存空间,并且多一次网络转发,即便是内网,也会显著增加延时和内网流量,此外上传链路的所有环节都需要支持非常大的 HTTP 请求体,比如需要调高 Nginx 的 client_max_body_size 参数,但这会带来潜在的风险。
这里可以利用对象存储服务的预签名地址(Pre-signed URL)来直接上传文件到对象存储里,省去应用服务转发环节,具体过程如下。

  1. 客户端向应用服务请求上传一个文件到某个 Bucket 的某个 Path,Path 也可在服务端自动生成;
  2. 应用服务请求对象存储服务生成一个预签名上传地址;
  3. 应用服务响应该预签名上传地址给客户端;
  4. 客户端使用该预签名上传地址直接上传文件到对象存储里;
  5. 【可选】如果应用服务需要保存文件元信息或者关联该文件到其它业务对象,客户端提交文件信息(Bucket、Path、文件类型、文件大小等)给应用服务来保存到数据库;

异步计算

pulsar-architecture

在数据集上传完成之后,还需要对其中的数据进行多种后处理来优化在平台里的展示和标注性能,包括缩略图生成、点云二进制压缩、点云渲染、点云切分等。缩略图生成让图片列表的加载速度更快,点云二进制压缩能够将文本格式的点云文件压缩到其原始大小的三分之一左右,点云渲染会生成一个用于点云列表展示的底图,避免采用非常消耗带宽和计算资源的在前端直接加载和渲染点云文件的方式,点云切分用于将非常巨大(上百万点数)的点云文件按区域切分成多个小块,或者进行粗采样来降低单个文件的大小。为了减少用户上传等待时间,同时跟上传过程解耦,这些后处理最好异步进行。为了避免资源竞争,以及方便后续动态调整资源容量,后台异步计算最好跟前台 API 服务独立部署,这样就需要借助于独立的消息队列服务来支持不同进程之间的通信和协作。
消息队列服务我们选择了新兴的 Pulsar,而不是当前更加常见的 Kafka、RabbitMQ、RocketMQ 等,原因如下。

  • 云原生友好,存算分离,可独立扩展存储和计算节点
  • 高性能,超低延时,轻松支持上百万 Topics
  • 同时支持队列和流两种消息处理模式
  • 支持共享、排他和故障转移等多种订阅模式
  • 单个实例就支持跨多个机房的多个集群,可在跨地域的集群之间复制数据
  • 分层存储支持将冷热数据分开存储来降低存储成本
  • 保证消息不丢
  • 支持多租户
  • 通过轻量级的 Serverless 框架 Pulsar Functions 支持流原生的数据处理

Web 消息推送

websocket-stomp-simple-broker

BasicAI 许多场景都需要依赖 Web 消息推送。比如数据集压缩包上传,压缩包上传到服务端后,需要提取里面的各个文件来一一保存,并根据文件类型进行多种后处理(缩略图生成、点云压缩、点云渲染等),整个周期可能会很长(几分钟到几个小时)。借助于消息推送,服务端可以实时把压缩包的处理进度反馈给用户,否则用户需要不断刷新页面才能确定处理是否完成。还有就是 Team 内的协作,包括成员加入/移除、任务分配/回收、数据锁定/解锁等。
好在现在基本上所有浏览器都支持 WebSocket 双向通信了,一旦浏览器(也可以是其它支持 WebSocket 的客户端)跟服务端建立了 WebSocket 连接,那么除了浏览器可以发送消息给服务端,服务端也可以主动推送消息给浏览器。不过 WebSocket 只是解决了通信层面的问题,并没有提供主题、订阅等高层次的消息通信原语。应用层得自己维护用户跟连接之间的映射关系,当需要推送消息给某个用户,需要先找到该用户连接所在的节点,然后通过该节点上的该用户连接把消息发送出去。如果要推送消息给一组用户,那么得一一找到这些用户的连接并把消息发送出去。本质上来说,这里需要的是一个类似于消息队列服务这样的中间件来解耦消息的发送方和接收方,传统的消息队列服务都是用于服务端内部通信,也比较重型,这里推荐使用 STOMP (Simple Text Orientated Messaging Protocol) 这个面向文本的轻量级的消息协议。如果使用 Java Spring 框架,那么其已内置 WebSocket 及 STOMP 协议支持
STOMP 支持主题、订阅等消息通信原语,在 BasicAI 里,为每个用户和每个 Team 都各自定义了一个主题(不需要提前创建),用来给某个用户或某个 Team 下的所有用户推送消息,此外还有一个全局主题,用于给所有在线用户推送消息。前端应用启动时,会订阅当前用户主题、当前 Team 主题,以及全局主题,这样就能接收到与当前用户有关的所有消息。

websocket-stomp-broker-relay

Spring STOMP 内置的 SimpleBroker 只支持单节点,如果是多节点,每个用户只会连接到其中一个节点,接受到消息推送请求的节点很可能跟用户连接的节点不同,这样就无法推送消息给该用户。这里需要借助于外部的支持 STOMP 协议的消息队列服务(比如 RabbitMQ),通过 StompBrokerRelay 来转发消息给所有其它节点。

websocket-multiple-instance-unidirectional-communication

RabbitMQ 是比较重型的消息队列服务,BasicAI 已经使用了 Pulsar 来作为消息队列服务,但 Pulsar 暂时还不支持 STOMP 协议。为了避免再维护一个消息队列服务,这里我们选择了轻量级的 Redis 解决方案。简单来说,其解决原理就是利用 Redis 的 Pub/Sub 功能,当某个节点收到消息推送请求时,把该消息通过 Redis 广播给其它所有节点,然后所有节点各自在内部寻找符合条件的用户连接,并把消息发送出去。

资源调度

rancher-architecture

考虑到国内国外两种截然不同的市场,以及网络互通障碍,BasicAI SaaS 至少需要在国内和国外各部署一套,此外还有大客户私有化部署,因此需要支持各大公有云、客户私有云,甚至裸金属服务器等各种运行环境。为了屏蔽底层运行环境的区别,简化部署,容器化运行 + Kubernetes 容器编排就成为了很自然的选择。
对于 Kubernetes 集群,除了生产集群,还有开发/测试集群,其中还分了国内/国外,为了便于统一管理,使用了 Kubernetes 多集群可视化管理工具 Rancher。借助于 Rancher UI,还能让不熟悉 Kubernetes 命令行操作的研发人员能够很容易地部署和管理应用服务。

rancher-clusters

rancher-workloads

指标/日志监控

monitoring-architecture

Kubernetes 环境下,指标监控的首选是 Prometheus + Grafana,Rancher 已经内置了相关应用,只需要点击安装即可启用,默认就会采集 Node、Pod 等资源使用情况,并且提供了相关 Grafana Dashboard 来方便查看。对于其它应用服务和基础服务的自定义指标,比如业务指标、数据库连接数、数据库查询次数、缓存查询次数、消息队列待处理消息个数等,为了避免在 Rancher 升级时受到影响,可以搭建一套新的 Prometheus + Grafana,尽量不去动 Rancher 内置的应用。但为了集中查看所有指标,可以在自行搭建的 Grafana 里添加 Rancher Prometheus 源,并同步一些重要的 Dashboard 过去。
对于日志监控,传统的方案是使用 ELK(Elasticsearch + Logstash + Kibana),但 ELK 比较重型,部署复杂,资源消耗也高,因此我们选择了非常轻量的 Loki + Grafana 方案,这样还能把指标和日志的查询统一到 Grafana 里。
Loki 也是 Grafana 官方出品的,它具有如下特性。

  • 内存占用很低,因为只索引 Label,不索引日志内容,这也是跟 ELK 最大的区别
  • 支持多租户
  • 可通过灵活的 LogQL 来查询日志,类似于 PromQL
  • 可扩展性,所有组件既能运行在单个进程内来单机部署,也能拆分为多个进程来分布式部署
  • 与 Grafana 无缝集成

grafana-monitoring

grafana-logging

DevOps

gitlab-ci-architecture

BasicAI 采用敏捷开发,平均每个迭代版本 2~3 周完成,每天需要构建和部署到开发环境几十次,如果是手动操作,效率低不说还容易出错。通过 GitLab CI 我们实现了完整的 DevOps 流程,每当有代码推送到 dev、test 和 main 分支,就会自动触发软件包构建、镜像构建,以及发布到对应的开发、测试和生产环境。对于生产环境发布,保险起见,可以设为手动触发。
关于 CI/CD,传统的方式是使用 Jenkins,我们选择 GitLab CI 主要是基于以下考虑。

  • 跟代码托管统一,减少工具数量
  • 全功能,支持代码、镜像、Maven 包、NPM 包、PyPI 包等各种资产托管
  • 强大灵活的 CI Pipeline,CI Runner 可运行在 SSH、Shell、Docker、Kubernetes 等多种环境
  • 开源免费

gitlab-ci-pipeline