前言: 

 这篇文章将讲述如何利用Docker云计算框架和PHP技术在Linux环境下实现一个多人在线编程环境,同时保证服务器安全。我把它叫做云IDE。可能没有桌面级IDE的全部功能,只有简单的多语言编程,运行,下载代码功能,虽然现在这种平台在网络上还是不少的,不乏包括百度、华为这些大厂。不过当前有叙述这一实现思路的文献并不多(国内),中国知网上泛眼过去几乎看不到影子,我看到的一篇是《CodeRun_浏览器里的云端编程开发IDE》,结果下载下来发现就是一段腾讯科技的新闻。或许这篇文章更适合作为一篇学术论文发表,但是嘛,我最近刚考完研,有一些时间,想做一些学术研究,又不是很想依照论文的格式死死地来,主要是没有啥参考文献吧,纯个人创造,还请大家多多包含。

主要用到的技术有:Dockerfile文件编写、PHP编程、JQuery Ajax异步交互、Linux Shell编程

一、背景

  随着云计算和大数据时代的到来,越来越多的服务被集成至云端,这其中不乏编程领域。云计算的宗旨是PaaS,Platform as a Service,即平台即服务,用一个浏览器或手持设备就能完成所有的事情。在传统编程领域,我们都是在本地安装编译程序,而在云时代,这些都可以迁往云端,利用容器虚拟化技术加上现在流行的HTML5前端设计语言可以打造类似本地编译环境的效果。目前,一些平台已经做到了在线使用工程并且debug的功能。鉴于网上缺乏这类平台的实现思路,于是我就利用所学知识,打造了这个简易的云端IDE平台,希望能给各位的深入研究提供一个思路。

二、实现思路

  整体的实现思路还是非常直接的。我们可以为每一个用户建立一个私有的文件夹,文件夹中存放用户代码,和用户指定的输入数据,以及程序的运行结果数据。然后使用PHP语言调用docker容器,可以在执行参数中对容器的运行环境进行限制,通过管道流进而在容器中运行用户代码。这一切的数据传输都将由Ajax异步刷新技术实现,用户不必刷新页面。在服务器上,有一个专门的守护进程,来保证用户写了死循环程序,又关闭浏览器导致服务器资源一直被消耗的情况。思路的大致框架如下:


三、实现过程

1、Docker镜像的获取

     有两种方式,一种我们可以去DockerHub上去找,但是因为天朝,于是就有了另外的一种方式,就是从国内的镜像仓库,比如上图中的DaoCloud。有一个镜像是一定要的,就是Linux系统镜像,我用的是Ubuntu,你也可以拖Cent OS。剩下的就根据你想让系统实现什么样的语言编程。我这里只做了C/C++和Java,于是我需Java的镜像就好了,GCC的我是基于Ubuntu构建的,当然也可以使用官方。如果以后想实现现在比较火的Python编程的话,只需后面再拖Python的镜像就行了。注意认准offical标识哦,其他的可能是有做过某些方面的修改,我们希望用的比较纯净的官方镜像。下图以Ubuntu镜像为例。



2、镜像的编辑之一

   有的镜像不是一拖下来就能用的,比如GCC,这时我们就要使对原始镜像进行改造。Docker提供了两种方式构建新的镜像,一种是使用commit命令,另外一种是DockerFile。下面对两种方式都做一个简单介绍。

  2.1 先介绍一下DocketFile,以及我们要用到的一些指令,如果想更深入了解,建议参阅官方文档 。

  关于DockerFile,官方的说明是:

  Docker can build images automatically by reading the instructions from a Dockerfile. A Dockerfile is a text document that contains all the commands a user could call on the command line to assemble an image. Using docker build users can create an automated build that executes several command-line instructions in succession.

译文:Docker能够通过从Dockerfile中读取指令来自动地构建镜像。一个Dockerfile是一个包括所有能够组成一个镜像的,用户能够调用的命令行文本文件。使用docker build命令,用户能够创建一个自动化的镜像,这个镜像通过成功执行一些命令行指令来构建。

2.1.2 DockerFile语法,这里就提我们要用的

    2.1.2.1 escape指令:用于表示换行,如果不具体话的话,默认是【\】,在Linux下影响到不是很大,如果是Windows,要设置为【`】。

   2.1.2.2 From指令:用于标识构建新镜像的基础镜像。

   2.1.2.3 WORKDIR指令:对RUN、CMD、ENTRYPOINT,COPY和ADD指令设置工作目录,如果我们设置的工作目录不存在的话,即使在后面的指令中没有用到,它也会被创建。

   2.1.2.4 CMD指令:官方文档显示这个指令有3种使用方法,我们使用

                            CMD command param1 param2 (shell form)

                           这种方式。

                          CMD指令的主要目的是在容器运行的时候给予其一个默认的操作。在一个Dockerfile中,CMD指令只能有一个,如果有多个,只有最后一个指令会被执行。

  2.2 接下来是commit指令,这个命令用于从一个容器来构建新镜像。具体示例可参阅官方文档

  2.2.1 语法

     docker commit [OPTIONS] CONTAINER [REPOSITORY[:TAG]]

  2.2.2 简单例子(来自官方,我们用到的就是这种,官方还有其他的)这个例子是从ID为c3f279d17e0a的镜像构建新的

$ docker ps

CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS              NAMES
c3f279d17e0a        ubuntu:12.04        /bin/bash           7 days ago          Up 25 hours                            desperate_dubinsky
197387f1b436        ubuntu:12.04        /bin/bash           7 days ago          Up 25 hours                            focused_hamilton

$ docker commit c3f279d17e0a  svendowideit/testimage:version3

f5283438590d

   3、镜像的编辑之二

   a、我们先进入拖下来的Ubuntu镜像,记得进入控制台交互模式,就是在docker run的时候加参数-t -i。t即terminal,i即interaction,我是这么理解的。

   b、运行apt-get update更新源,然后运行apt-get install gcc,安装gcc编译器。

   c、安装完gcc后退出容器,使用docker ps -a指令,找到刚才刚退出的那个容器(从Status栏中的时间可推知)

   d、使用命令docker commit 06a8d7269e27 XXX指令,构建新镜像,06a8d7269e27为容器ID,XXX为新镜像名,注意要全部小写。

经过以上4步,接下来就是Dockerfile登场了,我的Dockerfile是这样的。

#escape\
FROM 265d2046fe63
WORKDIR /tmp
CMD ./myappp
做个说明:escape \  指明分隔符

                FROM 265d2046fe63  就是我们前面基于容器构建的新镜像的ID

                WORKDIR /tmp  设置容器启动时的工作目录为/tmp

                CMD ./myappp  容器启动时,执行./myapp指令,其实就是运行用户编写的程序,程序实体将通过映射的方式传入容器中

编写完Dockerfile后,使用docker build -t gccrun .命令构建tag为gccrun的镜像。不要忘记了后面的那个小【.】,这个句点表示的是上下文,如果为句点的话,就表示以当前目录为构建镜像的上下文,上下文中要包含构建新镜像所用到的所有文件,所以建议在一个新的空目录中执行指令,不要是用【/】,在Linux中,/代表根目录,如果这样的话,将会导致整个硬盘的内容作为上下文传输给docker守护进程。

这里创建的是运行时要用的镜像,编译的话使用将Dockerfile的CMD改为编译的命令就行,然后构建一个叫gcccom的镜像。

4、网站服务器用户权限的设置

要先查询网页服务器所对应用户的权限,适当提升,因为我们需要进行一些系统级操作,比如读写文件。我用的是Apache,进入apache2.conf中找到${APACHE_RUN_USER}以及${APACHE_RUN_GROUP},这显然是两个引入变量名,注意上面的蓝字,发现这些变量名定义在/etc/apache2/envvars中




跟踪,发现Apache执行的用户名为www-data,所在用户组为www-data,然后要将这个用户具有执行sudo的权限,具体可见这篇文章http://blog.csdn.net/mgsky1/article/details/79059400

5、PHP编程部分之一

  第4步的作用是能够让PHP执行一些Shell命令,要调用system函数,还有操作文件的fopen函数,如果不设置的话,将会导致权限不足提示Permission Denied。system之类的函数属于比较“危险”的函数,默认PHP并不开放,记得去php.ini中进行修改。创建compiler.php文件

  5.1 根据语言做好初始化操作,以Java为例,代码中的$random为随机数,下同,每一个用户都有自己的文件夹

  

 if($lan == "java")
  {
      $myfile = fopen("$random/Main.java", "w") or die("Unable to open file!");//创建Main.java文件
      fwrite($myfile, $code);//将用户代码写入,$code为提交表单后POST来的代码
      fclose($myfile);//关闭文件
      $type = "class";//设置类型,用于运行完毕后进行清理
      $comCMD ="sudo docker run --rm -v '/var/www/html/cloud/$random':/usr/src/myapp -w /usr/src/myapp daocloud.io/java:7 javac Main.java 2>$random/com.txt";//编译指令,运用管道流进行输出重定向
      $runCMD = "sudo docker run --rm --name $random -i -v '/var/www/html/cloud/$random':/usr/src/myapp -w /usr/src/myapp daocloud.io/java:7 java Main <$random/in.txt >$random/out.txt 2>&1";//运行指令,运用管道流进行输入输出重定向
 }

5.2 在容器进行编译,若编译失败清理文件

 

        system($comCMD,$status);//$status保存命令的返回值,在这里,就是执行是否成功
        $myfile = fopen("$random/com.txt", "r") or die("Unable to open file!");//读取com.txt文件
        $error = @fread($myfile,filesize("$random/com.txt"));//将文件内容读入至$error
        if($error != "" )
        {
                if($lan == "gcc" && strstr($error,"error") == true)//判断是否编译错误
                {
                  echo $error;//显示编译错误信息
                  fclose($myfile);
                  system("rm -r /var/www/html/cloud/$random");//删除文件夹
                  return;
                }
                else if($lan != "gcc")
                {
                  echo $error;
                  fclose($myfile);
                  system("rm -r /var/www/html/cloud/$random");//删除文件夹
                  return;
                }

        }

   5.3 如果编译成功则在容器中运行,运行完成后执行清理

  

   $myfile = fopen("$random/in.txt", "w") or die("Unable to open file!");
   fwrite($myfile, $input);
   fclose($myfile);
   system($runCMD,$status);
   $myfile = fopen("$random/out.txt", "r") or die("Unable to open file!");
        $info = @fread($myfile,filesize("$random/out.txt"));
        if($info != "")
        {
                echo $info;//显示运行结果
                fclose($myfile);
                system("find . -name '*.".$type."' | xargs rm -rf");//根据$type变量,寻找杂项文件(比如java的.class文件)并清除
                system("rm -r /var/www/html/cloud/$random");//删除文件夹
                return;
        }
   system("rm -r /var/www/html/cloud/$random");//删除文件夹
6、PHP编程部分之二

第5步是正常的程序执行步骤,如果用户想中途停止呢?(比如是一个死循环程序),我们就需要一个用于停止容器的代码。killContainer.php。这部分的内容就很简单了,在第5步中,我的容器名称就是那个随机数$random,所以,在这里直接把这个容器杀死就好

   $cmd = "sudo docker kill ".$_GET['num'];//num为提交表单后获取的用户随机数
   system($cmd);


7、前端JQuery编程部分

     云IDE在浏览器上主要通过JQuery脚本来进行控制,使用Ajax异步交互与后台进行数据传输。比较核心的两个函数就是tj(提交)和zz(终止)

7.1 tj(提交函数),分两个部分,第一个是计时,第二个是Ajax

7.1.1 计时部分

        这一部分的功能就是在预定的时间(我设定是5秒)内将运行按钮变灰,即不可点击,5秒后恢复可点击状态,这样做的缘由是给予程序一定的运行时间,避免创建重复容器导致运行失败。核心就是setInterval 和 clearInterval 这两个函数。

  var intvalID = setInterval(function()
  {
      count++;
      $('#startRun').text("等待("+count+")");//告知用户等待秒数
      if(count == 5)//count用于计时,初始值为0
      {
         $('#startRun').removeAttr("disabled");//使运行按钮可点击
         $('#startRun').text("运行");//修改运行按钮的内容为"运行"
         count = 0;
         clearInterval(intvalID);
       }
  }
  ,1000);//每过1s执行function()

7.1.2 Ajax异步交互部分

       这里就是与后台服务器交互的核心了,使用Ajax避免用户刷新页面,提升用户体验。Jquery Ajax语法点这里 。这里借助了setTimeout函数来延时执行zz(终止)函数,zz(终止)函数的具体内容见下节。

$flag = 0;//用于标记是否运行成功,如果5秒后依然是0,则有可能是死循环,执行强制终止
$.ajax({
          cache: true,
          type: 'POST',
          url:'compiler.php',
          data:$('#myForm').serialize(),// 你的formid
          async: true,
          error: function(request) { //如遇网络问题导致连接失败,弹出提醒
               alert("Connection error");
           },
           success: function(data) { //如果执行成功,将回传数据显示在屏幕上
                 $flag = 1;
                 $("#output").html(data);
                 $("#running").html('');
                 $('#stopRun').attr("disabled","true");//将停止按钮设置为不可点击
               }
            });
  setTimeout(function () {//若5秒后依然运行未完成,强制终止
       if($flag == 0)
       {
          zz();
          $('#stopRun').attr("disabled","true");
        }
   }, 5000);

7.2 zz(终止)函数,这里就直接一个Ajax异步

 function zz() {
  $("#running").html('<img src="img/loading.gif" style="width:20px"/>终止中');
  var id = $('#random').val();
   $.ajax({
            cache: true,
            type: 'GET',
            url:'killContainer.php?num='+id,//所以上面提及的PHP脚本中使用GET方法,与提交用的POST不同,POST可以传输比较大的数据,GET限制1024字节,POST理论上没有限制
            async: true,
            error: function(request) {
                 alert("Connection error");
             },
             success: function(data) {//成功终止后,清除屏幕多余数据
                $("#output").html('');
                $("#running").html('');
                $("#stop").html('已终止程序');
               }
            });
     }

8、Linux Shell编程部分之一

   第7步是保障服务器安全的第一步,在前端保障,但是有可能出现用户提交了一段死循环程序,然后关闭了浏览器,这时Jquery就不起作用了,而这个死循环程序依然在消耗服务器资源,所以,在服务器上也必须有措施做好“最后一道防线”。这里,我将把一个定时清理容器的.sh脚本注册为Linux系统服务,在后台静默运行。

  先上代码吧:daemon.sh

#!/bin/bash
while echo "Begin New Round"
do
  sleep 0.5m;
  sudo docker ps | grep grun | awk '{print $1}' | xargs docker rm -f;
  sudo docker ps | grep java | awk '{print $1}' | xargs docker rm -f;
echo "End This Round";
done

看上去似乎非常简单,思路和是很清晰的,一个死循环,每隔半分钟清理名为grun和java的容器(注:grun是C/C++运行的容器,编译期间应该是不会造成死循环的)。但是那两个sudo执行的命令就展现了Linux Shell命令的精简和强大文字处理能力。

第一个是【|】,这个是管道符号,也就是说,将符号左边命令的输出作为符号右边命令的输入

第二个是【grep】,这个是Linux的文本查询命令,全称是(global search regular expression(RE) and print out the line,全面搜索正则表达式并把行打印出来),支持正则,但是我们这边用到的是精确查找,因为我们是根据镜像名定位,镜像名又是我们定死的。例子:

$ sudo docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                                        NAMES
956165ee5c24        ubuntu              "/bin/bash"              9 hours ago         Up 9 hours          0.0.0.0:8087->22/tcp, 0.0.0.0:8086->80/tcp   adoring_jang
d56f8e4b698f        ubuntu              "/bin/bash"              4 months ago        Up 10 hours         0.0.0.0:8085->80/tcp                         silly_yalow
629978e3bdb9        registry            "/entrypoint.sh /e..."   4 months ago        Up 10 hours         0.0.0.0:5000->5000/tcp                       suspicious_jang

$ sudo docker ps | grep ubuntu
956165ee5c24        ubuntu              "/bin/bash"              10 hours ago        Up 10 hours         0.0.0.0:8087->22/tcp, 0.0.0.0:8086->80/tcp   adoring_jang
d56f8e4b698f        ubuntu              "/bin/bash"              4 months ago        Up 11 hours         0.0.0.0:8085->80/tcp                         silly_yalow 

可以发现返回的是匹配行,想详细了解这个命令,点这里

第三个是【awk】,awk是一种编程语言,用于在linux/unix下对文本和数据进行处理。数据可以来自标准输入(stdin)、一个或多个文件,或其它命令的输出。脚本通常是攘括在单引号或者双引号中。用{ }包裹。它先读入有'\n'换行符分割的一条记录,然后将记录按指定的域分隔符划分域,填充域,$0则表示所有域,$1表示第一个域,$n表示第n个域。默认域分隔符是"空白键" 或 "[tab]键"。下面举一个简单的例子,就可以看到$0,$1有什么区别。

$ sudo docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                                        NAMES
956165ee5c24        ubuntu              "/bin/bash"              9 hours ago         Up 9 hours          0.0.0.0:8087->22/tcp, 0.0.0.0:8086->80/tcp   adoring_jang
d56f8e4b698f        ubuntu              "/bin/bash"              4 months ago        Up 10 hours         0.0.0.0:8085->80/tcp                         silly_yalow
629978e3bdb9        registry            "/entrypoint.sh /e..."   4 months ago        Up 10 hours         0.0.0.0:5000->5000/tcp                       suspicious_jang

然后我调用命令sudo docker ps | grep ubuntu | awk '{print $0}' 

$ sudo docker ps | grep ubuntu | awk '{print $0}'
956165ee5c24        ubuntu              "/bin/bash"              9 hours ago         Up 9 hours          0.0.0.0:8087->22/tcp, 0.0.0.0:8086->80/tcp   adoring_jang
d56f8e4b698f        ubuntu              "/bin/bash"              4 months ago        Up 10 hours         0.0.0.0:8085->80/tcp                         silly_yalow
现在我改成 sudo docker ps | grep ubuntu | awk '{print $1}' 

$ sudo docker ps | grep ubuntu | awk '{print $1}'
956165ee5c24
d56f8e4b698f
所以,按照其工作原理,按照空格或者TAB键分割,$0显示的是所有,$1显示的是第一列,在这里,有点类似表格。

想详细了解,点这里

第四个是【xargs】,它擅长将标准输入数据或管道传来的数据转换成命令行参数。下面再举个例子,就用最简单的echo字符串,echo命令正常的顺序是echo "string",使用了xargs后,变为some command | xargs echo

$ echo "abc" | xargs echo
abc
想详细了解,点 这里

sudo docker ps | grep grun | awk '{print $1}' | xargs docker rm -f;为例,意思就是,将docker ps 的输出作为grep的输入,寻找与grun镜像名相匹配的行,然后打印第一列,也就是容器ID,最后通过xargs命令,将这些ID转换为docker rm -f XXX中的XXX(参数),对容器进行销毁。

9、Linux Shell编程部分之二

有了8的铺垫,我们还需要将这个sh脚本注册为系统服务。先上代码,这段代码是网络上搜索来进行修改的,在代码中使用我使用注释简单解释一下意思,本人水平有限,服务这方面还是第一次涉猎,查了一些资料,还请多多包涵。

#!/bin/bash
#description: hello.sh
#chkconfig: 2345 20 81 #优先级
#From Internet
EXEC_PATH=/var/www/html/cloud/Shell/ #shell文件存放路径
EXEC=daemon.sh #shell文件名
DAEMON=/var/www/html/cloud/Shell/daemon.sh
PID_FILE=/var/run/daemon.sh.pid

. /etc/rc.d/init.d/functions
#判断Shell文件是否存在
if [ ! -x $EXEC_PATH/$EXEC ] ; then
       echo "ERROR: $DAEMON not found"
       exit 1
fi
#关闭服务
stop()
{
       echo "Stoping $EXEC ..."
       ps aux | grep "$DAEMON" | kill -9 `awk '{print $2}'` >/dev/null 2>&1 #与上文8步类似,找到服务pid,杀死,并把输出重定向至无底洞/dev/null
       rm -f $PID_FILE #删除pid文件
       usleep 100
       echo "Shutting down $EXEC: [  OK  ]"
}
#启动服务
start()
{
       echo "Starting $EXEC ..."
       $DAEMON > /dev/null & #将shell运行时的输出重定向至无底洞/dev/null,结果就是在控制台开不到输出
       pidof $EXEC > $PID_FILE #写入pid文件,防止进程启动多个副本
       usleep 100
       echo "Starting $EXEC: [  OK  ]"
}
#重启服务
restart()
{
    stop
    start
}
#case语句判断传入参数,根据此执行相应操作
case "$1" in
    start)
        start
        ;;
    stop)
        stop
        ;;
    restart)
        restart
        ;;
    status)
        status -p $PID_FILE $DAEMON
        ;;
    *)
        echo "Usage: service $EXEC {start|stop|restart|status}"
        exit 1
esac

exit $?

要说明的一个地方是chkconfig命令,这个用来控制服务启动的优先级,我这里是2345 20 81,2345表示系统运行级别为2、3、4、5时启动服务,20表示启动优先级,81表示关闭优先级。关于前4个数,这里有张表格,来自百度百科

等级0关机
等级1单用户模式
等级2无网络连接的多用户命令行模式
等级3有网络连接的多用户命令行模式
等级4不可用
等级5带图形界面的多用户模式
等级6重新启动
最后将这个服务脚本放置到/etc/init.d文件夹下,去掉.sh后缀,就可以使用service命令调用了。Tip:如果调用后发现还会有控制台输出shell脚本运行信息,关掉终端重开就不会有了。

10、代码下载的实现

这一部分主要用到的是PHP的header函数,设置http报头。$code为用户代码,$filename为文件名。设置好报头之后,直接把用户代码打印出来就能实现下载功能了。

   header("Content-type:application/octet-stream");//.*( 二进制流,不知道下载文件类型)
   header("Accept-Ranges:bytes");
   header("Accept-Length:".strlen($code));//代码长度
   header("Content-Disposition:attachment; filename=".$filename);//以附件形式下载
最后来一张效果图吧


写在最后:为了理出这篇文章,查阅了不少po主的博客,包括CSDN、博客园还有百度百科,在此一一表示感谢!现在在等研究生出成绩的这段时间内整文章,也是我现在想做也可以做的。眼看马上就要毕业,真的很想能够如愿以偿上第一志愿,不论最后研究生能不能上,还是会继续在这行倒腾,不论代码是自己写的还是部分引用网络的,能够大致读懂,弄清架构还是重要的,更重要的是创意,东西就是我们创造出来的嘛,这是一个很有意思的过程,加油!

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐