跳到主要内容

在线代码执行器

需求背景

在处理一些简单代码或者项目需要有运行代码能力的时候,需要立即获得这段代码的执行结果,如果每次都是使用命令行手动编译、配置环境、打开 IDE 来运行,会浪费大量时间,所以需要一个工具能在线执行常用的代码,运行各种常见的编程语言

不止要做一个在线代码执行器,还可以封装成一个 starter,让其他服务可以轻松的接入提供的代码执行器

需求分析

功能需求

两个最核心的功能:

  1. 支持大多数常用代码的运行能力
  2. 返回运行结果或者相关错误信息

安全需求

  1. 内存限制
  2. 时间限制
  3. 权限限制

技术选型

  • SpringBoot
  • Docker 容器隔离运行代码
  • docker-java 调用本地 Docker 的功能

核心设计

  • 编译命令(不用编译的语言可以设置为空)
  • 运行命令
  • 保存的文件名(为了兼容不同语言的代码文件)

可以设计一个枚举,在枚举中编写不同语言的语言类型,保存的文件名,以及编译和运行的命令

业务流程

  1. 用户输入代码
  2. 后端将代码写入宿主机的临时文件中
  3. 启动一个 Docker 容器
  4. 将临时文件复制到容器中
  5. 在容器中运行编译代码命令
  6. 在容器中运行执行代码命令
  7. 后端返回执行结果
  8. 清理临时文件以及 Docker 容器

20241102164008

功能开发

准备镜像

1、使用一个大而全的镜像,下面是 Dockerfile 文件的内容:

# 创建 Ubuntu 镜像
FROM ubuntu:20.04

# 修改默认终端为 bash
SHELL ["/bin/bash", "-c"]

# 设置为中国国内源
RUN sed -i s@/ports.ubuntu.com/@/mirrors.aliyun.com/@g /etc/apt/sources.list
RUN sed -i s@/archive.ubuntu.com/@/mirrors.aliyun.com/@g /etc/apt/sources.list
RUN sed -i s@/security.ubuntu.com/@/mirrors.aliyun.com/@g /etc/apt/sources.list

RUN apt-get clean

# 更新镜像到最新的包
RUN apt-get update
RUN apt-get update -y

# 安装需要的包
RUN apt-get install software-properties-common -y
RUN apt-get install zip unzip curl wget tar -y

# 安装 Python
RUN apt-get install python python3-pip -y

# 安装 C
RUN apt-get install gcc -y

# 安装 C++
RUN apt-get install g++ -y

# 安装 Java
RUN apt-get install default-jdk -y
RUN apt-get install default-jre -y

# 安装 Node.js
RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash -
RUN apt-get install nodejs -y

# 安装 Go
RUN apt-get install golang -y
ENV GOCACHE /box
ENV GOTMPDIR /box

# 更新包
RUN apt-get clean -y
RUN apt-get autoclean -y
RUN apt-get autoremove -y

# 设置默认工作目录
WORKDIR /box

2、查看容器占用的资源

20241102164024

只占用几 MB,就算同时运行上百个容器,也完全可以接受

3、引入相关依赖

<dependencies>
<!--Java 操作 Docker-->
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java</artifactId>
<version>3.3.0</version>
</dependency>
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-transport-httpclient5</artifactId>
<version>3.3.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- https://hutool.cn/docs/index.html#/-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.8</version>
</dependency>


<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

4、详细代码:

GitHub - kbws13/OnlineJudge-code-sandbox: Code sandbox service

安全优化

  1. 内存限制:限制 60MB,如果每个容器占用了 60MB,也能支持几十个容器同时启动
  2. 时间限制:设置容器在执行命令时的等待时间,超过这个时间不再执行,直接返回数据,后面直接清除掉容器就行
  3. 权限限制:所有执行的用户代码都在容器中进行,且没有挂载到宿主机的文件系统,因此不可能获取到宿主机的敏感数据

封装为 starter

在配置类上面添加配置的注解

@Slf4j
@Data
@ConfigurationProperties(prefix = "codesandbox.config")
@Configuration
public class DockerSandbox {
}

在 resources 目录下新建META-INF/spring目录,然后新建org.springframework.boot.autoconfigure.AutoConfiguration.imports文件,在文件中将 DockerSandBox 引入就行(这是 2.7.0 之后写法)

执行mvn install命令,将项目安装到本地仓库

创建一个 demo 用于测试

1、引入依赖

<dependency>
<groupId>xyz.kbws</groupId>
<artifactId>oj-code-sandbox</artifactId>
<version>1.0</version>
</dependency>

2、配置 yml

也可以不配置,因为有默认数据

codesandbox:
config:
image: codesandbox:latest
timeout-limit: 3
time-unit: seconds
# memory limit default: 1024 * 1024 * 60 MB
memory-limit: 62914560
# The following code does not need to be configured, but only provides an extension(下面这段代码不用配置,只是提供了扩展)
# memory-swap: 0
# cpu-count: 1

3、执行测试


import cn.hutool.core.io.resource.ResourceUtil;
import com.yuyuan.executor.DockerSandbox;
import com.yuyuan.executor.ExecuteMessage;
import com.yuyuan.executor.LanguageCmdEnum;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import javax.annotation.Resource;
import java.nio.charset.StandardCharsets;


@SpringBootTest
class DockerSandboxTest {

@Resource
private DockerSandbox dockerSandbox;

@Test
void testJava() {
String code = ResourceUtil.readStr("languageCode/Main.java", StandardCharsets.UTF_8);
ExecuteMessage execute = dockerSandbox.execute(LanguageCmdEnum.JAVA, code);
System.out.println(execute);
}
}

最终效果:

20241102164041

性能分析

问题分析

  1. 当访问量提升的时候,异常率就随之提升,最高的时候可以接近 100%
  2. 吞吐量不足

在线运行代码消耗的是 CPU 资源,且编译比运行占用的资源多了将近 1/3

主流程中有一个清理临时文件以及 Docker 容器的操作可以用异步处理

将 DockerSandBox 类中的cleanFileAndContainer方法改为异步:

/**
* 清理文件和容器
*/
private static void cleanFileAndContainer(String userCodePath, String containerId) {
CompletableFuture.runAsync(() -> {
// 清理临时目录
FileUtil.del(userCodePath);

// 关闭并删除容器
DOCKER_CLIENT.stopContainerCmd(containerId).exec();
DOCKER_CLIENT.removeContainerCmd(containerId).exec();
});
}

这样做后吞吐量确实增加了一些但不多

考虑一下之前的流程,每次请求都会创建一个新的容器,这样不仅效率低而且浪费了大量资源

容器池设计

其实分析业务流程就可以发现,性能瓶颈是在创建和销毁资源的时候出现的,那我们可以借助池化思想,设计一个容器池(就像线程池那样)进行资源预热,在请求量很大的情况下,超出系统可处理的请求,也可以在这个池子中等待,直到资源可用时继续从池子中取出;操作完成后容器可以复用,就避免了重复创建容器,销毁容器

需求如下:

  1. 获取容器:当需要使用容器时,从池中获取一个容器。如果池中容器足够,就立即返回;否则,可能会加入等待队列或者触发扩容
  2. 容器用完之后清空所有代码数据
  3. 扩容:当池中容器不足且排队队列太长时,可以进行扩容操作,向池中添加新的容器。同时对扩容的容器进行时间判断,如果超出一定时间则销毁
  4. 销毁:对于池中容器,当超过一段时间后,需要进行销毁

设计思路:

  1. 使用阻塞队列:使用阻塞队列管理池中的容器(初始容器数量:corePoolSize),确保线程安全
  2. 获取容器:在获取数据的时候,首先检查池中是否有足够的容器可用。如果可用则直接返回,否则可能需要排队,如果队列过长则触发扩容
  3. 扩容:当池中的容器不足时,可以触发扩容操作,想池中添加新的容器。扩容时,可以根据需求创建容器,不超过 maximumPoolSize
  4. 销毁:设计一个定时任务,定期(keepAliveTime)检查池中的容器,销毁池中存在时间超过一定限制的扩容容器

容器池流程:

20241102164057

测试

20241102164109

扩展优化

优雅关闭

通过实现ApplicationListener接口,在关闭项目的时候删除容器和临时代码文件

代码如下:

package xyz.kbws.executor;

import cn.hutool.core.io.FileUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

/**
* @author kbws
* @date 2024/11/2
* @description:
*/
@Slf4j
@Component
public class CleanContainerListener implements ApplicationListener<ContextClosedEvent> {

@Resource
private DockerDao dockerDao;

@Resource
private ContainerPoolExecutor containerPoolExecutor;

@Override
public void onApplicationEvent(ContextClosedEvent event) {
// 清理所有容器以及残余文件
containerPoolExecutor
.getContainerPool()
.forEach(containerInfo -> {
FileUtil.del(containerInfo.getCodePathName());
dockerDao.cleanContainer(containerInfo.getContainerId());
});
log.info("container clean end...");
}
}