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

81—1 · 2022年11月11日 · 最后由 81—1 回复于 2022年11月13日 · 9484 次阅读

背景

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

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

方案效果对比

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

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

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

架构设计

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

可以看到多 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 的调用的,家庭版有两种方式实现:

同样对于家庭版,远程用户每次只能登录一个账号,因此我还需要破解下,让家庭版支持多用户同时登录,参考 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:

浏览器任务集

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

根据上报的 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 有好些要改。

对于 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 游戏等云主机式的应用。
鄙人不善比喻,不当之处欢迎反馈交流。

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 1 条回复 时间 点赞

似乎大家不太明白我在说啥😂

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册