快速Docker化基于Socket.IO的HTML5游戏

96
灵雀云
0.1 2015.12.03 17:52* 字数 4788

编者按:Node.js由于其上手快、事件驱动、异步编程的特性,已被广泛的应用在众多企业的生产环境中。Socket.IO通过Node.js实现WebSocket服务端,可以工作在任何平台、浏览器或移动设备,也是H5开发的常用技术。weplay是一个Socket.IO库的展示项目,用了HTML5的画布API,用Redis作为数据中转以及持续化存储的一个中型项目。 容器化后可以实现模块化,更方便地实现版本升级和规模扩展,本文以weplay为例,介绍了H5移动端程序容器化的过程。‌‌

作者简介:戴佳豪,出生并成长在魔都上海。他在上海电力学院完成应用物理学的本科学位,在期间完成了诸如“在线物理做题系统”网站的开发。作为少数本科在读RHCE工程师,在容器化与开源世界有着2年的实战经验。他是忠实的军团要塞2玩家,有必要时会发挥“Cheat Engine”的力量。他始终坚信能用“云”力量改变自己的生活。博客:http://www.djh.im。微博:@戴佳豪_

什么是Node.js

Node.js采用了Google Chrome浏览器的V8引擎,性能很好的同时,将许多系统级的API进行了傻瓜化的封装。相对于运行于网页页面的Javascript脚本,Node.js则是作为一个全面的后台运行进程,用Javascript提供了其他语言能够实现的许多功能。 Node.js由于其上手快、事件驱动、异步编程的特性,已被广泛的应用在众多企业的生产环境中。

什么是Socket.IO

Socket.IO是一个开源的WebSocket库,它通过Node.js实现WebSocket服务端,同时也提供客户端JS库。Socket.IO支持以事件为基础的实时双向通讯,它可以工作在任何平台、浏览器或移动设备,也是H5开发的常用技术。

为什么要容器化

对于任何一个项目的已有改动都将是费时费力的,无论是模块重构还是框架更迭。但是有以下的一些优点让你花些时间,将手头的项目代码制作成一个容器镜像。

将代码的不同模块彻底分离

手头的项目可能在设计以及实现时,就已经做了模块化的编写(这是比较好的情况,更可能是一个不可维护的状态)。

但是如果着手将代码容器化,你将不得已的面对各模块的代码分别运行在不同“虚拟机”里,而仅靠通讯协议来进行数据交互的状况。这也可以说是实现了项目整体的微服务化,在容器化完成以后,项目出了问题,可以直接查对应项目里的日志,哪儿除了错会一览无遗!

为什么不用Vagrant?

总的来说用Vagrant做快发是利用了 Vagrant 的一个插件,将平台服务器作为『虚拟机』启动 Droplet ,是很值得肯定的有效玩法。 VagrantFile 基本上就是个 ruby 脚本,需要一定的ruby知识作为基础;相比 Dockerfile 是类似已定义的宏调用 DSL ,学习成本较低(但如果你要较真,说 COPY 和 ADD 的区别的话,其实也是满麻烦的历史遗留问题,话说回来你知道 WORKDIR 能自动创建文件夹么)。

用Vagrant启动出来的是一个个的虚拟机,如果你的项目资金不足以支撑起一台有VMware esxi的物理服务器,用docker的实现-单一进程即容器化的『虚拟机』,会有更轻量级的内存管控,数据粒度操作以及管理界面学习成本。

实际上技术上的主要区别我觉得在于被初始化的脚本( Vagrant 中是 vm.Provision ),你有没有考虑守护进程的配置,服务出错的调试方案,防火墙配置,软件的定期更新,软件的优化,更在于这个项目本身的易读易改性,谁都不想照着本 500 页的手册页只为了编写一个 APP 是吧(向卡西欧图形计算器致敬)。个人以为 docker 这方面做的更好,比如只需要在启动时配置“环境变量”就能决定我开哪个版本的服务器,而无需触及启动脚本本身。

更方便规模扩展

在服务架构设计时多加些考虑,这样容器化后的项目能有更好的规模弹性拓展能力。 相比传统的接入负载均衡+大量后台服务器的设计,用容器化的项目能做到更细粒度的弹性扩容:

  • 一个项目如果遇到CPU密集的情况,那就多点中间处理容器;
  • 数据库压力大了,我们可以考虑设计一个数据库群集,实时插入新的容器节点;
  • 相比之前的实现,能做到出错定位准确,开支大大减小等优点。

版本升级更方便

你只需要在写Dockerfile的时候在第一行,改个版本号,然后重新构建一个镜像,就算在构建时出了问题,也能在反馈日志中查找到。同时,你可以在一台服务器上快速、方便的同属运行多个版本的项目,对于测试和上线都是非常棒的选择。

灵雀云之美

在本次容器化实验中,使用了灵雀云CaaS公有云平台。

  • 持续部署:我欣喜的发现在部署新版本时,网站依然能提供旧版本的服务,而当后台部署完成以后,更新的容器能自动为后续的请求提供服务;
  • 灵雀云的镜像加速器:只需要在docker的启动项中加入灵雀的镜像加速地址。再重启docker守护进程,以后pull镜像速度都是杠杠的;
  • 灵雀的镜像中心:中国本土的『docker-hub』。你能在这里对镜像内容进行留言提问;收藏到自己的账号中,方便日后部署服务;查看构建过程;查看镜像的版本和源代码仓库。但是也是有不足的地方:如不能展示镜像的层数或是镜像总体大小;
  • 灵雀提供了免费的域名绑定和动态负载:你可以很方便的使用网页或CLI工具配置容器,比如对一个容器开启多个实例,这样能方便的做到项目的横向拓展。

项目实战

原项目简介

weplay是一个socket.io库的展示项目,你可以在socket.io官网的展示页面看到它。它是由socket.io库的开创者rauchg,将一个已有的用Javascript实现的GBC模拟器,包装成一个Node.js模块后,再运用socket.io和HTML5的画布API,用Redis作为数据中转以及持续化存储的一个中型项目。

准备工作

1.项目总览:

  • weplay作为IO服务器,提供评论数据的持续化储存和实时GBC界面数据的输出服务。
  • weplay-web提供网页服务。
  • weplay-emulator运行js模拟器,将数据渲染出的每个画面输出到Redis中。
  • weplay-presence用socket.io接口统计实时在线总人数。

项目已经被很明确的分割成了几个模块,由后台服务器链接起来。

2.数据库的加密加固工作:

由于此项目之前的架构大概是一体化,单一服务器的部署思路。Redis数据库端没有做加密访问。但是我们都知道,Redis是很容易被破解的,它的快速是把双刃剑。如果我们在此添加访问受密码保护的Redis服务器的代码,那么我们的服务就算是在某些危机四伏的弹性云平台上也能很好的运作。 所以对于所有模块进行打补丁。

3.与容器化无关的功能增加与版本更新

  • 如添加弹幕模式
  • 优化模拟器运行效率和内存占用
  • 可由环境变量方便的选择加载的游戏ROM

容器化核心:Dockerfile编写

1.首先给个具体的实例,是weplay-web,项目网页服务器的容器化Dockerfile。

FROM node:0.10
MAINTAINER Jiahao Dai <dyejarhoo@gmail.com>

RUN git clone https://github.com/imdjh/weplay-web /srv/weplay-web && \
        cd /srv/weplay-web && \
        npm install && \
        npm install forever -g

ADD docker-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

EXPOSE 3000

CMD ["/entrypoint.sh"]

实际上编写Dockerfile很简单,不外乎几个步骤:

  1. FROM宏,用来指定基础镜像,docker的应用镜像都是有层级的,可以复用造一个轮子不用从砍大叔开始。
  2. MAINTAINER,用来告诉使用者这个镜像有问题该找谁
  3. RUN,你希望镜像在被构建(build)时,执行什么命令,在这里我们用git工具下载最新的源代码并用npm安装依赖
  4. ADD,你希望将什么文件复制到容器中
  5. EXPOSE,你希望容器的哪些端口对外开放
  6. CMD,有哪些命令(如果是指令,一定主意路径要在PATH环境变量中)是需要在容器初始运行时作为进程PID1存在的(当ENTRYPOINT为空时)。

和几个注意点:

  1. 每个Dockerfile中的宏指令会生成一个镜像层(image layer),所以最好在执行命令的时候,最后要回收垃圾,这样执行完这条宏,生成的镜像层会比较轻巧。例如这条语句:RUN apt-get update && apt-get install -y libcairo2-dev && rm -rf /var/lib/apt/lists/*,就做到了最小化安装一个软件源里的二进制包。
  2. 将守护进程写在单一的文件中,例如本项目中的docker-entrypoint.sh。方便拓展和日后修改。

对于这个项目的Dockerfile,大家可以参见如下给出的软件仓库链接:

  1. weplay-web -> letweplay
  2. weplay + weplay-presence -> letweplay-io
  3. weplay-emulator -> letweplay-core

docker-entrypoint.sh的一般编写思路

  1. 设置默认环境变量的值
  2. 当某些环境变量未被设置时,进入出错模式,自动退出执行。
  3. 执行守护进程

例如letweplay的入口脚本:

#!/bin/bash

# 设置环境变量
export WEPLAY_WEB_PORT=3000
export WEPLAY_IO_URL="${IO_URL_PORT:-BAD}"
export NODE_ENV='production'

if [[ -n "${REDIS_PORT}" ]];then
    export WEPLAY_REDIS_URI=${REDIS_PORT_6379_TCP_ADDR}:${REDIS_PORT_6379_TCP_PORT}
    # 当某些环境变量未被设置时,进入出错模式,自动退出执行
    export WEPLAY_REDIS_AUTH=${REDIS_PASSWORD}
else
    echo "Redis setting not found, can't start server." >&2 && exit 1
fi

if ( $(echo ${WEPLAY_IO_URL} | grep -q BAD ) );then
    echo "IO_URL_PORT is missing, can't start entry server." >&2 && exit 1
fi

# 执行守护进程
forever /srv/weplay-web/index.js

容器化实现

1.Docker Compose方案

感谢compose工具,否则部署一个多模块的项目会是让人头疼的一件事。 并且灵雀云的开源命令行工具alauda,支持compose部署,可以做到多模块的项目一键部署在灵雀云平台。对于本项目来说我们可以按照下面的步骤迅速布置起来一个平台环境。

  1. 首先使用python2的pip工具,下载安装alauda工具。注意python3环境下,此工具会报错不能正常使用。
$ python --version
Python 2.7.10

$ pip install alauda
....
  1. 其次让我们登陆进入自己的账号,进行日后API调用的Oauth审核。
$ alauda login 
Username: imdjh
Password: 
[alauda] Successfully logged in as imdjh.
[alauda] OK
  1. 此时我们可以看一眼账号中服务的状态
$ alauda service ps

   Name      Command     State                      Ports                      Instance Count     IaaS     Region 
------------------------------------------------------------------------------------------------------------------
lwp-db                  Running    lwp-db-imdjh.myalauda.cn:10024->6379/tcp    1                 AZURE    BEIJING2
lwp-entry               Running    lwp-entry-imdjh.myalauda.cn:80->3000/tcp    1                 AZURE    BEIJING2
lwp-core                Running                                                1                 AZURE    BEIJING2
lwp-io                  Running    lwp-io-imdjh.myalauda.cn:80->3001/tcp       1                 AZURE    BEIJING2
[alauda] OK

$ # 对于不需要的服务,我们可以使用命令轻松的删除它
$ alauda service rm lwp-core
  1. 将如下yml格式的内容保存成my.yml
core:
  image: 'index.alauda.cn/imdjh/letweplay-core:fallback'
  environment:
    - GAME=pokemon/yellow
    - SAVEDELAY=120000
    - WEPLAY_REDIS_AUTH=rosebud
  links:
    - redis
  size: 'XS'
entry:
  image: 'index.alauda.cn/imdjh/letweplay:latest'
  environment:
    - 'IO_URL_PORT=http://io-imdjh.myalauda.cn'
    - 'WEPLAY_REDIS_AUTH=rosebud'
    - 'THIS_URL_PORT=http://entry-imdjh.myalauda.cn'
    - WEPLAY_WEB_PORT=80
  links:
    - redis
  ports:
    - 80/http
  restart: on-failure
  size: 'XXS'
io:
  image: 'index.alauda.cn/imdjh/letweplay-io:latest'
  environment:
    - 'THIS_URL_PORT=http://io-imdjh.myalauda.cn'
    - 'WEPLAY_REDIS_AUTH=rosebud'
  links:
    - redis
  ports:
    - 3001/http
  size: 'XXS'
redis:
  image: 'index.alauda.cn/library/redis:latest'
  size: 'XXS'
  ports:
    - 6379/tcp
  volumes:
    - /data:10

再运行alauda compose up -f my.yml,等待出现诸如:

$ alauda compose up -f my.yml
[alauda] Creating and starting service "redis"
[alauda] Creating and starting service "core"
[alauda] Creating and starting service "io"
[alauda] Creating and starting service "entry"
[alauda] OK
  1. 因为redis在启动后需要一定时间才能开始监听端口,而链接到的各容器会不能立即接连到数据库而出错。所以我们需要使用如下指令手动重启容器,由于它们并不存有数据,所以重启是无害的。
$ alauda service stop io && alauda service start io
$ alauda service stop entry && alauda service start entry
$ alauda service stop core && alauda service start core
  1. 既然用了有状态的容器,那么就对数据库做一个存档吧
$ alauda backup create redis-$(date +%F) redis '/data'
[alauda] Creating backup "redis-2015-11-30"
[alauda] OK

2.单个击破-单个部署方案:

  1. 单个击破-单个布置方案

如果处于种种原因没办法安装compose工具,那么我们也能一条条的docker run起来:

$ #跑一个Redis容器,命令为some-redis
$ sudo docker run --name some-redis -d redis

$ #跑个GBC模拟器,链接到some-redis
$ sudo docker run -d -e 'GAME=pokemon/yellow' --link some-redis:redis imdjh/letweplay-core

$ #Socket.io服务器开起来,链接到some-redis
$ sudo docker run -d --link some-redis:redis -e 'THIS_URL_PORT=http://localhost:3001' -p 3001:3001 imdjh/letweplay-io

$ #开启万维网服务器,链接到some-redis,服务已经全部开启!
$ sudo docker run -d -e 'THIS_URL_PORT=1.2.3.4:3000' -e 'IO_URL_PORT=1.2.3.4:3001' --link some-redis:redis -p 0.0.0.0:3000:3000 imdjh/letweplay

3.图形化操作方案:

如果你更习惯图形化界面的操作,可以在灵雀云控制台中,由redis, letweplay-core, letweplay-io, letweplay的顺序进行服务的创建。

本实例一共使用了4个容器。配置如下:
3台XXS(256 MB)的容器分别运行:letweplay, letweplay-io, redis
1台XS(512MB)的容器运行:letweplay-core

部署的配置参数可以用如下截图作为参考:

数据库redis部分

IO服务器letweplay-io部分

模拟器服务器letweplay-core部分

问题&解决

前台页面无反应,但看得出IO服务器还是能用的

我们能看出IO服务器是能用的,但是也最好检查一下letweplay-io项目的日志和监控状态。如果一切良好,基本可以排除IO端的问题。

所以接着我们以此类推,查看letweplay-core项目的日志和监控状态。如果letweplay-core项目的CPU占用率平均小于5%我们基本可以推断出是js模拟器发生了问题。我们需要重启它。 在于灵雀云平台的实战中,我发现每当letweplay-core出现问题时,单单重启core部分也不能解决问题,控制台中显示error: failed to connected to redis,可以简单判断出是core部链接到后端数据库的部分出了问题(原因尚不明确)。所以我们要按照:redis -> letweplay-io -> letweplay-core -> letweplay的顺序依次重启,因为一旦重启redis而不重启其余被redis项目链接的项目,会造成链接到空项目的问题。

注意:本项目的及时存档功能,数据是存储于redis容器中的, 如果redis服务器被重启了以后,那么游戏的进度数据将会荡然无存。 对此,我推荐使用Volume挂载或是灵雀云的有状态容器服务。

游戏画面不动,在线人数总是为0

当遇到这种情况,肯定是socket.io服务器就有问题了,对此我们首先要处理socket.io服务器端的故障。 关于问题的判断,可以打开浏览器的控制台,如果有类似如图的问题:

那么就针对环境变量进行修改,改成正确的地址,如图所示:

letweplay-core项目总是启动不了,提示『Bad GAME selection, please try another one!』错误

这个问题出现letweplay-core服务在下载ROM文件时发生超时等不能下载的情况。如果你运行的容器对下载服务器的线路不是特别稳定的话就回发生这种情况,在这种情况下。我们可以用DN_SERVER环境变量来定义第三方的下载服务器。

本项目系列镜像可用环境变量参考表(cheat sheet)

letweplay

  • WEPLAY_REDIS_AUTH,字符串,空(未指定默认值),指定连接的Redis服务器的连接密码;
  • THIS_URL_PORT,字符串,空(未指定默认值),指定当前服务器的地址、不能留空;
  • IO_URL_PORT,字符串,空(未指定默认值),指定socket.io服务器的地址、不能留空;
  • WEPLAY_WEB_PORT,整数,3000,接口服务器的端口。

letweplay-io

  • WEPLAY_REDIS_AUTH,字符串,空(未指定默认值),指定连接的Redis服务器的连接密码;
  • WEPLAY_SERVER_UID,整数,1337,用于指定socket.io服务器的频道;
  • CONTROLDELAY,整数,50,以毫秒为单位指定每个IP发出指令的最小间隔;
  • COUNTERDELAY,整数,12000,以毫秒为单位人数计数服务器查人头的间隔;
  • THIS_URL_PORT,字符串,空(未指定默认值),指定当前服务器的地址、不能留空

letweplay-core

  • WEPLAY_SAVE_INTERVAL,整数,120000,单位是毫秒的自动保存延迟;
  • DN_SERVER,字符串,’http://imdjh-dn.daoapp.io/‘,指定第三方ROM下载服务器;
  • GAME,字符串,空(未指定默认值),指定游戏名;
  • WEPLAY_REDIS_AUTH,字符串,空(未指定默认值),指定连接的Redis服务器的连接密码。

项目展示

演示地址:lwp-entry-imdjh.myalauda.cn

『奇巧淫技』分享

  1. 如果你发现更新了项目的代码,而在镜像构建时没有得到预期的结果时。可能是构建缓存在作怪,这时候一个解决问题的笨方案可以是在Dockerfile中下载代码的宏,诸如RUN git clone git://github.com/imdjh/weplay改成RUN git clone --depth=1 git://....这样的话,docker构建进程就会认为之前的缓存已经失效(未命中)而执行下载代码的工作。
  2. 灵雀的构建可以在国内或国外,通常来说,如果你的项目镜像在构建时(build)就会下载或更新很多文件(如yum update或npm install)就应该选在国外构建,如果是会在构建时下载只有在国内才快的,就选在国内。值得一提的是:国内国外构建对于同一个项目来说会共用缓存层,这也就给在国内构建多了一个理由。
  3. hub.docker.com在容器构建时是永远不会复用上一次构建过的层的,即不打开构建缓存功能:--no-cache=true
  4. 灵雀的博客中会有神秘的礼品活动,混在干货文章中随机出现,这么重要的事,我只在最后悄悄告诉你。

拓展阅读 & 参考链接

镜像仓库(灵雀)-letweplay letweplay-io letweplay-core

容器源码仓库-letweplay letweplay-core letweplay-io

Node.js源码仓库-imdjh/gameboy imdjh/weplay-emulator imdjh/weplay-web imdjh/weplay-presence imdjh/weplay

知乎-使用 Node.js 的优势和劣势都有哪些?

微软文档-在 Azure App Service 中使用 Socket.IO 建立 Node.js 聊天應用程式

docker官方文档-Dockerizing a Node.js web app

基于docker的数据库群集设计-Getting started Galera with Docker

Docker实践