事情是这样的,公司自研了一个大模型服务,想要在内部试用,但问题是模型一次只能响应一个请求。为了能多人同时使用,就需要同时部署多个服务。于是乎,我拿到了5台8卡机器(3090),要把这个服务集群部起来。
最后花了两天时间解决了这个问题,在不同机器上部署了服务,并写了一个代理服务器来做负载均衡。下面是解决问题的过程,如果大家有更好的方案,欢迎评论指导交流~
服务示意图:
如何充分利用一台机器?
普通的Web服务可以通过多线程或多进程的方式并发处理多个请求,以充分利用服务器的CPU与内存资源。而本文中的服务不同,它的主要资源瓶颈在GPU。经过测试发现,运行这样一个模型需要2张卡,而整台机器一共是8卡。只运行单个服务时,通过nvidia-smi
命令可以发现GPU根本用不满。
解决办法是在启动服务时指定GPU。比如CUDA_VISIBLE_DEVICES=0,1 python server.py
可以限制服务只使用0和1两块GPU。这样我们就能在同一台机器上同时启动4个服务。
如何在多台机器上部署?
这个服务是一个使用Gradio开源工具部署的Python服务。最简单也是最笨的方法就是分别登录每台机器,下载模型,安装环境。
由于我自身对这个服务的环境还不太熟悉,只能照猫画虎先在另一台机器上尝试把这个服务运行起来,并把过程步骤记录下来。推荐使用miniconda来安装Python环境,对新手比较友好。
如果对安装过程比较熟悉了,就推荐封装成Docker镜像,这样可以方便在新机器上部署,避免繁琐的重复安装过程。
如何做负载均衡?
现在多个服务已经部署起来了,我们需要的是设置一个统一的入口,并将流量分发到各个服务上。
如果是简单的负载均衡,Nginx做起来还是很方便的。像这样就可以将请求按时间顺序依次分发给列表中的服务器。
nginx
upstream backend {
server backend1.example.com;
server backend2.example.com;
}
但问题是我们的服务是有状态的 :前后的多个HTTP请求间有关联关系。这样一来上述的负载均衡方案就行不通了。比如第一个请求发送给了backend1
,而第二个请求是依赖第一个请求的,却发送给了backend2
,那么第二个请求就失败了。就像老生常谈的依赖Session的登录方案,如果多个服务的Session不是共享的,那么它们就无法一起工作。
然而Nginx还有其他的负载均衡策略来处理有状态服务问题,比如ip_hash
策略,即通过计算将不同的IP映射到不同的服务地址,这样同一个IP地址的请求就只会打到同一个服务上。可问题是这个策略在内网服务上也行不通,一个原因是内网IP地址的前面部分都相同,Nginx默认的计算规则会把它们都映射到同一个服务地址上(需要修改默认计算规则)。另一个原因是公司的网关并没有把请求的真实地址暴露出来,这样我们就没法拿到客户端的真实IP地址。
那么如何标识各个客户端呢?我最后用的方法是在前端生成一个唯一ID,暂且叫它SessionID,并携带在每个请求上。使用Node.js实现一个反向代理服务器,它根据SessionID判断将请求分发到哪个服务上。
实现反向代理使用的是http-proxy
库,主要的是如何根据sessionid
来设置转发规则。
js
const http = require("http");
const httpProxy = require("http-proxy");
const proxy = httpProxy.createProxyServer();
http
.createServer(function (req, res) {
// ... 根据sessionid获取服务地址
const serverUrl = loadBalancer.newRequest(sessionid);
// 代理请求到serverUrl
proxy.web(req, res, { target: serverUrl });
})
.listen(7070);
转发规则为:在一个请求到来时,如果SessionID记录存在,就将请求发到对应的服务上 ;如果不存在,就挑选出一个最空闲的服务 (当前绑定会话数最少),并把SessionID与该服务地址记录下来。同时每个会话的有效期为5分钟,如果相同的sessionID超过5分钟没有新的请求,就将这个会话记录删除,避免不活跃的会话长时间占据某个服务。这样就能满足我们的业务需求了:相同客户端的请求打到同一个服务,同时不同客户端的请求均匀分配到各个服务上。
还有一个问题,浏览器如何让每个请求都携带上SessionID呢?由于我们是用的一个SDK来对接后端服务,所以没法直接修改请求代码。一个方法是使用Cookie,在请求时自动携带,但问题是前端服务与后端服务地址并不相同,没法实现。另一个方法就是重写请求方法。SDK里用到了fetch与EventSource,EventSource是不支持设置请求头的,所以我们可以把SessionID放到URL的查询参数里。重写请求的逻辑如下:
js
const originalFetch = window.fetch;
window.fetch = function (url: RequestInfo | URL, options) {
// 修改默认的url,将sessionID添加到查询参数里
return originalFetch.call(this, addSessionIDToURL(url), options);
};
class CustomEventSource extends EventSource {
constructor(url: string | URL, options?: EventSourceInit) {
super(addSessionIDToURL(url), options);
}
}
window.EventSource = CustomEventSource;
总结
提高请求并发量的一种方法就是把服务部署到多台机器上,然后用一个代理服务器做负载均衡。简单场景可以直接用Nginx默认的负载均衡策略,遇到更复杂的场景时就需要自定义转发逻辑了,前端也可以自己用Node.js来实现一个代理服务器。当然,本文中的方法还是很简单的,还有很多问题要处理,比如如何一键更新服务版本?如果运行过程中其中一个服务挂了怎么办?如何自动扩展?对于生产场景的集群部署,还是需要更专业的工具。