基于Docker的Codepad / OJ构想与实现(伪)

渊源

本人一直在忙一个项目Avalon,有一天突然心血来潮,想为它加一个在线程序执行的功能,查阅了很多资料,其中包括对本项目颇有助益的基于 Docker 容器的沙盒化评测系统及其相关项目Menci / docker-sandbox,终于做出了ProgramLeague / Avalon-Executive这个还很不完善的雏形。

今天刚把核心功能测试跑过,为了庆祝一下,就写了这篇文章。

构想

起先我的想法和基于 Docker 容器的沙盒化评测系统中的想法很类似:接收客户机的程序,在真机编译,再送进Docker,利用已经放在Docker image中的程序完成资源限制,最后收集输出。

构想1 - 来自oi.men.ci

上图来自基于 Docker 容器的沙盒化评测系统

但是在实现的过程中,我发现一个巨大的问题:

Windows端编译的程序怎么能整到Docker的Linux container中运行

虽然Docker有Windows container,但看看Hub上少得可怜的镜像,还是算了吧。

因此,上述过程中的编译也必须在Docker中完成。

新构想

首先前端服务向后端服务(Executive)发出请求,随后后端服务调用执行器,执行器与Docker交互,执行预处理、编译、运行等的操作,均完成后将结果发送至上层,上层再回复给前端服务。

对预处理、编译和运行的解释如下:

  • Pre-Process:初始化Docker;拉取image;创建container;将代码写入文件;将代码文件拷入对应的Container等准备工作
  • Compile:将代码编译为可执行程序
  • Run:执行可执行程序并收集输出

显而易见,Pre-Process和Compile要共用一个container,而Run则需独立创建一个container运行。

实现

为了偷懒降低实现复杂度,我使用了Docker提供的资源限制而非自己编译一个基于setrlimit()的资源限制组件来完成资源限制功能。因此,pre-processor将代码文件放入一个不加资源限制的container后,compiler将该代码文件编译,接着(将该可执行文件放入真机)->(将该可执行文件放入一个新的 有资源限制的container),随后runner(在这个有资源限制的container中运行可执行文件),最后收集输出并回复上一层。

当然在实际实现时也不能不加资源限制,只是相对runner的资源限制,compiler的资源限制应该能宽松不少。

几点注意

  1. 为了提高性能及节省资源,compiler、pre-processor使用的container是可以复用的;我们只需为每个语言创建独立的container,并不需要为每份提交创建;在具体的代码实现中,使用一个CompilerContainerPool就好。

  2. 由于(拉取镜像、创建pre-processor和compiler使用的container)在整个程序的生命周期中只需操作一次,因此可以将pre-processor拆出initiator专门完成这种在整个程序的生命周期中只需操作一次的任务然后在程序入口点调用,从而避免每次收到提交都要检测初始化工作是否已经完成。

  3. 建议使用类似docker-client这种直接基于HTTP协议而非基于命令行的Docker API以防一些奇怪的问题(虽然我还没遇到过)。

  4. 对于JawaJava,基于docker-client“将文件从container复制至真机”的实现是个坑… …下面给出本垃圾钻研出的实现代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    public void copyFileOut(String containerId, File output, String pathInContainer)
    throws DockerException, InterruptedException, IOException {
    File temp = new File(output.getParent() + File.separator + "_temp.tar");
    Files.createParentDirs(temp);
    if (!temp.createNewFile())
    throw new IOException("can not new temp file: " + temp.toString());
    TarArchiveOutputStream aos = new TarArchiveOutputStream(new FileOutputStream(temp));
    try (final TarArchiveInputStream tarStream = new TarArchiveInputStream(
    client.archiveContainer(containerId, pathInContainer))) {
    TarArchiveEntry entry;
    while ((entry = tarStream.getNextTarEntry()) != null) {
    aos.putArchiveEntry(entry);
    IOUtils.copy(tarStream, aos);
    aos.closeArchiveEntry();
    }
    }
    aos.finish();
    aos.close();
    boolean unTarStatus = unTar(new TarArchiveInputStream(new FileInputStream(temp)), output.getParent());
    StringBuilder builder = new StringBuilder();
    if (!unTarStatus)
    throw new RuntimeException("un tar file error");
    if (!temp.delete())
    builder.append("temp file delete failed, but ");
    LOGGER.info(builder.append("copy files ").append(pathInContainer).append(" out of container ").append(containerId).append(" successful").toString());
    }
    private boolean unTar(TarArchiveInputStream tarIn, String outputDir) throws IOException {
    ArchiveEntry entry;
    boolean newFile = false;
    while ((entry = tarIn.getNextEntry()) != null) {
    File tmpFile = new File(outputDir + "/" + entry.getName());
    newFile = tmpFile.createNewFile();
    OutputStream out = new FileOutputStream(tmpFile);
    int length;
    byte[] b = new byte[2048];
    while ((length = tarIn.read(b)) != -1)
    out.write(b, 0, length);
    out.close();
    }
    tarIn.close();
    return newFile;
    }
  5. 注意及时释放资源!! 注意及时释放资源!! 注意及时释放资源!! 包括文件资源不仅要close还要及时删除;container及时stop/kill并remove;client最后要close等。

就这些了

项目地址:ProgramLeague / Avalon-Executive

以后可能还有更详细的说明。

=。=
0%