基于windows的群控浏览器云的设计与实现

背景

在完成了《多浏览器同步测试工具的设计与实现》后,从实际应用来看,通过windows docker镜像的方式不仅对服务器的性能资源要求大,对于后期维护成本来说也是巨大的。因此需要进一步优化我们的浏览器池方案,前期在做同步功能开发时参考了 f2etest ,它的浏览器云方案还是比较成熟的,但可惜很多组件没有维护了,难以着手改动,于是结合我们自己的需求和最新的guacamole组件,重新设计开发这么一套基于windows server的多RDP浏览器webdriver云方案。

过程中还深度调研了自定义VNC Server的方案,打算通过多vnc服务差异端口,分享指定应用的方式实现。尝试了至少6种vnc server后发现,即使能解决界面的差异化展示,同一个用户的操作行为从根本上还是无法隔离的,最终放弃了。

方案效果对比

对于多容器和多RDP的两套方案,我们都做了开发实现,并做了与同步操作功能的对接,为了得到确定方案选型,于是在同一台设备上用双系统,进行了更为详细的试验数据对比。
效果如下:


效果对比

过程就没录视频了,但是效果差异还是很明显的,多RDP方案性能明显要更优。

windows server的方案需要舍弃我们原来对safari虚拟机的支持,不过对于Mac OS真机的支持也是后面要重点攻克的,所以就暂时先舍弃吧。

架构设计

两套的架构上差异还是很大的,对于资源的要求也不同。虚拟机比较吃cpu,而内存开销到时不大,多用户主要是内存消耗,实际测下来也发现,多用户方案最终的瓶颈是内存。
先上架构图:


容器化虚拟系统用户浏览器云方案架构
多RDP用户浏览器云方案架构

可以看到多RDP的方案比容器化的方案要复杂很多,涉及到5个服务间的互相调用以及时序协调,但实际程序运行也就是秒秒钟的事。然而容器化方案虽然结构简单,但是基于家庭版windows 10 iso封装浏览器容器最小也得18G,特别是多个容器同时启动时,机器I/O狂飙,风扇呼呼作响,或许会有生命财产安全。

核心技术点

  • 基于windows server的远程桌面管理服务搭建
  • 基于python的自定义exe服务程序封装
  • 基于python的windows系统用户管理
  • 基于 ggr 的动态webdriver hub管理
  • 基于 guacamole-commom-js 的自定义RDP Web客户端开发
  • Guacamole server的调用Api

上述知识点,下面会挑部分介绍,有空会补充下每个点的展开介绍。
总体的数据流程如下:


请求流程设计

Windows hub 服务

首先,要想让我们windows server能够响应任务的要求,动态的创建与任务要求浏览器一一对应的用户,我们需要在windows server上部署一个监听服务。你可以用你熟悉的任意语言框架来实现这个服务,只要能和系统命令行交互就行,我使用的是比较熟悉的python flask服务实现的,上面说的“基于python的windows系统用户管理”就是其中的一个功能模块,提供主要的功能如下:

  • 根据任务信息创建用户
  • 获取用户登录后的启动程序执行状态。
  • 组织任务浏览器信息生产任务的ggr配置文件,并启动对应的hub服务,将端口信息入库。
  • 接收到任务结束命令后,清理用户账号及用户文件。

创建用户方法

可以看到实际就是调用系统命令行执行创建用户,这里要注意,创建用户命令同时并发会存在系统目录被锁报错,所以要注意容错重试。

    def do_create_user(self, win_user):
        user_info = {
            'username': win_user,
            'password': app.config['DEFAULT_RDP_PASSWORD'],
            'real_name': win_user,
            'group': "Users",
        }
        command = "net user %s %s /passwordchg:no /expires:never /FULLNAME:%s /add" % (user_info['username'], user_info['password'], user_info['real_name'])
        code = run_os_cmd(command)
        retry = 3
        while code != 0 and retry > 0:
            logger.warning(f"create user {user_info['username']} failed, retry {retry}!")
            time.sleep(3)
            command = "net user %s %s /passwordchg:no /expires:never /FULLNAME:%s /add" % (
            user_info['username'], user_info['password'], user_info['real_name'])
            code = run_os_cmd(command)
            retry = retry - 1

        # 设置属组
        command = "net localgroup \"%s\"  %s /add" % (user_info['group'], user_info['username'])
        run_os_cmd(command)

RemoteApp启动应用

这个应用是windows用户登录后的执行的程序,windows server有现成的RemoteApp管理工具,但是家庭版的windows其实也可以能实现RemoteApp的调用的,家庭版有两种方式实现:

    1. Windows 10如何设置用户登录时运行的程序
    1. Windows remote app tool

同样对于家庭版,远程用户每次只能登录一个账号,因此我还需要破解下,让家庭版支持多用户同时登录,参考 Windows 多用户远程桌面限制破解工具 。

因为我是根据“任务id+浏览器id”加密后生成的用户名,所以这个可以作为关键key,用来与服务器换取对应的配置信息。对于启动程序承担的任务,其实还是比较简单的,主要是如下2个部分:

  • 启动获取浏览器配置信息
  • 自动启动selenoid服务并上报服务端端口
  • 轮询任务状态保持selenoid服务
   def main(self):
       self.user = os.getlogin()
       if not self.handle_browser_info():
           print('normal user')
           return
       if self.status and self.status == 2:
           print('selenoid is started')
           return
       print('run sync user')
       self.start_selenoid()
       self.update_task_browser_status()
       self.loop_task_status()

这个其实就是个python脚本,我们可以用pyinstaller将其封装成一个exe,放到C盘给remoteapp调用:

pyinstaller -D -i favicon.ico main.py # 这样打的包是有后台黑框显示的,方便问题定位。

配置windows server remoteapp:


remoteapp

浏览器任务集

我们同时会有多个人同时选择同样的浏览器,进行不同的配置任务。
那么就需要考虑根据任务进行配置隔离,selenium-grid 虽然也能实现这样的任务集,但是一种浏览器只能对应一个版本。因此我采用多个 selenoid + ggr 的方式来实现,而且只需要公用一套 browser.jsonwebdriver 驱动文件,方便维护 。

ggr

根据上报的selenoid端口创建ggr服务的 xml 配置文件:

    def create_task_quota_xml(self):
        root = Element('qa:browsers', {"xmlns:qa": "urn:config.gridrouter.qatools.ru"})
        for task_browser_id in self.browsers:
            info = self.browsers[task_browser_id]
            if info['status'] != 2:
                logger.error(f'browser not ready {json.dumps(info)}')
                continue
            browser = Element('browser', {'name': info["browser_name"], 'defaultVersion': info["browser_version"]})
            version = SubElement(browser, 'version', {'number': info["browser_version"]})
            region = SubElement(version, 'region', {'name': '1'})
            SubElement(region, 'host', {'name': info["node_ip"], 'port': info["node_port"], 'count': '5'})
            root.append(browser)
        pretty(root)
        tree = ElementTree(root)
        file_path = f'{self.task_dir_path}/test.xml'
        tree.write(file_path, encoding='utf-8')

启动ggr服务并上报服务端口:

    def start_ggr_server(self):
        port = find_free_port()
        self.start_port = port[0]
        cmd = f"{app.config['GGR_EXE']} -guests-allowed -guests-quota test -listen :{self.start_port} -quotaDir {self.task_dir_path}"
        logger.info(f"exec command: {cmd}")
        p = subprocess.Popen(cmd, shell=True, cwd=self.task_dir_path)
        self.ggr_pid = p.pid
        print(self.ggr_pid)

Guacamole 的应用

对于guacamole这种运维层面的服务器管理工具很多人都可能不太熟,我们方案的实现中主要涉及到2块内容:

  • guacamole server的搭建和api调用
  • guacamole client的封装

guacamole server

用docker-compose搭建是比较简单的,而且我们很多数据都是一次性的,也不用考虑持续存储数据,那就把数据库也放一起得了,参考项目 guacamole-docker-compose

建议去掉 yml 里的 https 和 nginx 配置,不然 api 有好些要改。

image.png

对于Api调用,参考文档: guacamole-rest-api-documentation
我主要是在创建用户的同时,创建了该用户连接配置,并记录下guaca_id

guacamole client

基于 guacamole-common-js,我们自己可以实现web版guacamole client的封装.

其中也有些坑是要注意的,如实际显示容器大小和我们设定的分辨率大小不一致,会导致鼠标操作坐标异常,需要做对应比例的转换计算。

核心的rdp连接部分如下:

rdpConnection = (browser) => {
    let eleNode = document.getElementById(browser.win_user);
    if (!eleNode){
      console.log('no element', browser.win_user);
      return
    }
    let width = 1920;
    let height = 1080;
    if (browser.screen_size){
      const size = browser.screen_size.split('x')
      width = Number(size[0])
      height = Number(size[1])
    }

    const wsPath = browser.guacamole_tunnel
    let client = new Guacamole.Client(new Guacamole.WebSocketTunnel(wsPath));
    let wrapper =client.getDisplay().getElement()

    let scale  = 1
    let pixel_density = window.devicePixelRatio || 1;
    let optimal_dpi = 96 * pixel_density;
    let optimal_width  = width * pixel_density;
    let optimal_height = height * pixel_density;

    wrapper.style.position  = 'absolute'
    wrapper.style.left      = '50%'
    wrapper.style.top           = '50%'
    wrapper.style.transform     = 'translate(-50%,-50%)';

    client.connect(encodeURI(`token=${browser.guacamole_token}&GUAC_DATA_SOURCE=postgresql&GUAC_ID=${browser.guacamole_id}&GUAC_TYPE=c&GUAC_WIDTH=${optimal_width}&GUAC_HEIGHT=${optimal_height}&GUAC_DPI=${optimal_dpi}`))
    client.onstatechange = function(state){
      if(state == 3){
        eleNode.appendChild(wrapper);
        loginBrowsers.push(browser.win_user);
        new Promise((resolve => {
          function getClientHeight() {
            if (wrapper.clientHeight){
              scale = Math.min(eleNode.clientHeight / wrapper.clientHeight, eleNode.clientWidth / wrapper.clientWidth)
              wrapper.style.transform = 'translate(-50%,-50%) scale('+scale+')'
              resolve()
            }else {
              setTimeout(()=> {
                getClientHeight()
              }, 1000)
            }
          }
          getClientHeight()
        }))
      }else if(state == 5) {
        loginBrowsers.splice(loginBrowsers.indexOf(browser.win_user), 1)
      }
    }
    let touch = new Guacamole.Mouse.Touchscreen(client.getDisplay().getElement()); // or Guacamole.Touchscreen
    let mouse = new Guacamole.Mouse(client.getDisplay().getElement());
    mouse.onmousedown = mouse.onmouseup = touch.onmousedown = touch.onmouseup  =  function(mouseState){
      client.sendMouseState(mouseState);
    }
    touch.onmousemove = mouse.onmousemove =  function(mouseState) {
      let height    = wrapper.clientHeight
      let width     = wrapper.clientWidth
      mouseState.x /= scale
      mouseState.y /= scale
      mouseState.x += width / 2
      mouseState.y += height / 2
      client.sendMouseState(mouseState);
    };
    // Keyboard
    var keyboard = new Guacamole.Keyboard(document);

    keyboard.onkeydown = function (keysym) {
      client.sendKeyEvent(1, keysym);
    };

    keyboard.onkeyup = function (keysym) {
      client.sendKeyEvent(0, keysym);
    };
  };

windows server 2019 的远程服务配置

很多教程都还是2008的,不建议使用,因为太老了,其它程序的依赖库很多都要手动装,还经常莫名其妙不管用,能被气死。

这个配置过程真的是又臭又长,在此不做细说,后面单独开一个贴介绍。由于windwos server 2019文档不多,建议参考windows server 2016 / 2012的相关配置,他两长的基本一样,但也有些差别。

如:2019的RemoteApp需要装AD域,装AD域后发现原来的账号安全策略没法被禁用了等等。

参考文档:
Windows Server2012远程桌面服务配置和授权激活

结语

此多RDP用户的方案和多容器方案几乎同时并行开发的,这样的多线程工作,考验不光是我们的工作条理性,这过程中的怀疑,困惑,与无奈都是翻倍的,现在看来或许觉得这之前做的容器化方案是浪费时间,但在工期节点不断临近时,它的简单的架构似乎才是最快速有效的。也就是它的快速交付,才让我能腾出空间来进一步调研多用户的方案。就像你明知道距离50米的公共厕所条件差,1公里外酒店有个5星级厕所,但是无奈你此时肚子疼一样。


有了上述的实现,我们还可以结合其它应用进行更多扩展开发,如用网页玩Windows PC游戏等云主机式的应用。
鄙人不善比喻,不当之处欢迎反馈交流。

你可能感兴趣的:(基于windows的群控浏览器云的设计与实现)