使用Netty+Docker重构我的在线运行代码项目

项目介绍

很久很久以前,做过一个在线运行代码的项目,见:

在线运行C++、java、python代码的小项目实现

做这个项目的初衷,是可以在博客里嵌入一段可运行的代码,提升阅读体验。不过后来发现,除了一些Java的基础知识,也没什么知识点是单个文件可以演示出来的,所以到后面用的就不多了。

不过还是一直想把这个项目更新重构一下,毕竟之前做的那个版本安全问题太严重了(只需要写一段空循环代码,就能让CPU长时间处于100%负载,甚至还可以通过文件API去控制修改我服务器上的一些文件,而我之前是一直把这个项目跑在主力服务器上的,只是因为我的博客访问量太小,也没什么人会用这个东西,而且即使会用通常也不会想着搞点破坏,才让我这个安全漏洞百出的项目跑了这么久)

技术点

重构之后,我使用了 Netty + Websocket + Docker 的技术点,WebSocket用于提升一些功能性,因为有一些代码的运行时间是可以很长的(例如循环输出代码),而用户不能等待很长一段时间都没有响应,于是可以通过Websocket在程序运行期间即时返回一些已输出信息。

之所以为什么选用Netty开发Websocket服务端,其实主流目前使用Java开发Websocket服务端的技术一般就是NettySpringBoot,我选用Netty一方面是考虑平时使用Netty开发不多,可以通过这次项目练习巩固一下。另一方面是因为,之前考虑着和同学配合,使用Java与Go实现后端功能,由Go语言来在服务器上运行代码,然后Java和Go后端(进程间通信)通过socket连接来实现,那么Netty正好也方便socket开发。

使用Docker的原因则是考虑安全性了,因为使用Docker可以实现隔离式地运行代码,每次运行一段代码都即时创建一个容器来完成,这样即使一次运行破坏了容器也没有关系,缺点就是创建容器比较消耗时间,响应时间比较长。而对于空循环消耗系统资源的情况,目前是考虑使用超时机制来保障安全,限制一段代码最多只能运行10秒,以后可以进一步进行优化。

连接Docker的关键技术——docker-java

Docker服务可以通过监听端口,通过TCP、HTTP连接暴露API,供远程调用。

maven依赖:

<dependency>
    <groupId>com.github.docker-java</groupId>
    <artifactId>docker-java</artifactId>
    <version>3.2.1</version>
</dependency>

我学习这个的使用主要是通过下面这两篇文章:

主要是看第二篇文章,让服务器上的Docker服务暴露一个端口,供远程连接,并通过证书来保证安全连接。

开发WebSocket服务端

这部分可以看以前的文章:Netty心跳检测和基于Websocket协议的服务端开发

启动类:

public class WebsocketServer {
    private static final int port = 7000;

    /**
     * 绑定端口并启动服务器
     * @param port
     * @throws Exception
     */
    public void bind(int port) throws Exception{
        //配置服务器的NIO线程组
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try{
            ServerBootstrap serverBootstrap = new ServerBootstrap();
            serverBootstrap.group(bossGroup,workerGroup)
                    .channel(NioServerSocketChannel.class)
                    .option(ChannelOption.SO_BACKLOG, 1024)
                    .childHandler(new WebSocketChannelInitializer());
            System.out.println("---------服务器正在启动---------");
            ChannelFuture future = serverBootstrap.bind(port).sync();
            //等待服务端监听端口关闭
            future.channel().closeFuture().sync();
        }finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }

    public static void main(String[] args) throws Exception {
        new WebsocketServer().bind(port);
    }
}

通道初始化:

public class WebSocketChannelInitializer extends ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
        //向管道加入处理器
        //得到管道
        ChannelPipeline pipeline = ch.pipeline();
        //基于HTTP的编解码器
        pipeline.addLast(new HttpServerCodec());
        //以块方式传输数据
        pipeline.addLast(new ChunkedWriteHandler());
        //HTTP数据在传输过程中是分段,HttpObjectAggregator可以将多个段聚合
        pipeline.addLast(new HttpObjectAggregator(1024));
        //将http协议升级为websocket协议,参数代表请求的uri
        pipeline.addLast(new WebSocketServerProtocolHandler("/runcode"));
        pipeline.addLast(new TextWebsocketFrameHandler());
    }
}

自定义一个WebSocket帧的处理器:

public class TextWebsocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {


    private DockerJavaClient dockerJavaClient = new DockerJavaClient();

    /**
     * 读取Websocket客户端发来的信息
     * @param ctx
     * @param msg
     * @throws Exception
     */
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
        try{
            CodeWrapper codeWrapper = CodeWrapperUtil.fromJson(msg.text());
            dockerJavaClient.exec(CodeLang.valueOf(codeWrapper.getLangType().toUpperCase()),codeWrapper.getContent(),ctx);
            log.info("收到客户端信息:"+msg.text());

        }catch (Exception e){
            log.warn("执行过程中出现异常:");
            e.printStackTrace();
            ctx.channel().writeAndFlush(new TextWebSocketFrame("发生意外,运行出错!"));
        }
    }


    /**
     * 建立连接
     * @param ctx
     * @throws Exception
     */
    @Override
    public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
        log.info("与客户端建立连接");
    }

    /**
     * 连接关闭
     * @param ctx
     * @throws Exception
     */
    @Override
    public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
        log.info("与客户端断开连接");
    }

    /**
     * 捕获异常
     * @param ctx
     * @param cause
     * @throws Exception
     */
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        log.error("异常发生"+cause.getMessage());
        ctx.close();//关闭连接
    }
}

各种编程语言的枚举

直接看代码:

/**
 * 编程语言及其相关属性的枚举
 * @author RhettPeng
 */

public enum CodeLang {
    /**
     * Python语言
     */
    PYTHON3{
        @Override
        public String getImageName() {
            return "python:3";
        }

        @Override
        public String getContainerNamePrefix() {
            return "python-running-script-";
        }

        @Override
        public String[][] getExecCommand(String fileName) {
            return new String[][]{{"python",fileName}};
        }

        @Override
        public String getFileName() {
            return "temp.py";
        }
    },
    /**
     * C++语言
     */
    CPP{
        @Override
        public String getImageName() {
            return "gcc:7.3";
        }

        @Override
        public String getContainerNamePrefix() {
            return "cpp-running-file-";
        }

        @Override
        public String[][] getExecCommand(String fileName) {
            return new String[][]{{"g++",fileName,"-o","temp"},{"./temp"}};
        }

        @Override
        public String getFileName() {
            return "temp.cpp";
        }
    },
    /**
     * JAVA语言
     */
    JAVA{
        @Override
        public String getImageName() {
            return "openjdk:11";
        }

        @Override
        public String getContainerNamePrefix() {
            return "java-running-file-";
        }

        @Override
        public String[][] getExecCommand(String fileName) {
            // jdk11可以不经过javac
            return new String[][]{{"java",fileName}};
        }

        @Override
        public String getFileName() {
            return "Untitled.java";
        }

    },
    /**
     * Go语言
     */
    GOLANG{
        @Override
        public String getImageName() {
            return "golang:1.14";
        }

        @Override
        public String getContainerNamePrefix() {
            return "golang-running-file-";
        }

        @Override
        public String[][] getExecCommand(String fileName) {
            // Go可不经过编译
            return new String[][]{{"go","run",fileName}};
        }

        @Override
        public String getFileName() {
            return "temp.go";
        }

    };

    private static final String SRC_PATH = "/root/sourcecode/";

    public String getImageName(){
        return null;
    }

    public String getContainerNamePrefix(){
        return null;
    }

    // 代表将一个文件运行起来需要执行的指令,供Docker EXEC调用
    public String[][] getExecCommand(String fileName){
        return null;
    }

    public String getFileName(){return null;}
}

这部分的功能,我一开始是考虑使用接口或枚举二者选其一来完成,后来还是选择了枚举,虽然使用接口实现更容易拓展,但枚举一可以实现单例,二能避免写很多冗杂的小类。

Docker客户端的开发

这个类是实现核心功能的类了,我一开始的思路是,在宿主机上创建几个用于存放用户源代码的目录,接收用户传来的代码后通过File API在宿主机上创建对应的文件,然后创建容器时通过数据卷绑定,让容器运行源文件。

但是这样的思路我在实现的时候遇到了一些问题,例如多个用户同时运行一段Java代码,会创建多个Java容器,由于多个容器之间是共享数据卷的,因此创建的文件名不能相同,否则会冲突,而Java文件的命名要求又必须和public类相同,因此会比较麻烦。

最终我通过创建容器的时候通过EXEC,直接将代码写入容器中的一个文件,而不经过宿主机,得到了更高的隔离性,不过这样也遇到了一些麻烦,比如我一开始是这样写的:

/**
* 将程序代码写入容器中的一个文件
* @param dockerClient
* @param containerId
* @param langType
* @param sourcecode
* @return 文件名
* @throws InterruptedException
*/
private String writeFileToContainer(DockerClient dockerClient,String containerId,CodeLang langType,String sourcecode) throws InterruptedException {
    String workDir = "/usr/src/myapp";
    String fileName = langType.getFileName();
    String path = workDir + "/" + fileName;

    // 创建一个指令请求
    ExecCreateCmdResponse createCmdResponse = dockerClient.execCreateCmd(containerId)
            // 通过重定向符写入文件
            .withCmd("echo", "'"+sourcecode+"'", ">" , path)
            .exec();

    // 执行指令
    dockerClient.execStartCmd(createCmdResponse.getId())
            .exec(new ExecStartResultCallback(System.out,System.err))
            .awaitCompletion();
    return fileName;
}

主要思路是通过echo+重定向符,将源代码写入容器中的一个文件,但是这样写最终却发现没有创建任何文件!

后来通过搜索发现,使用Docker EXEC的时候,重定向符确实会失效,这是因为EXEC的环境其实不在用户SHELL中,如果要使用重定向符,需要这样修改:

/**
* 将程序代码写入容器中的一个文件
* @param dockerClient
* @param containerId
* @param langType
* @param sourcecode
* @return
* @throws InterruptedException
*/
private String writeFileToContainer(DockerClient dockerClient,String containerId,CodeLang langType,String sourcecode) throws InterruptedException {
    String workDir = "/usr/src/myapp";
    String fileName = langType.getFileName();
    String path = workDir + "/" + fileName;
    // 通过重定向符写入文件,注意必须要带前面两个参数,否则重定向符会失效,和Docker CMD的机制有关
    ExecCreateCmdResponse createCmdResponse = dockerClient.execCreateCmd(containerId)
            .withCmd("/bin/sh","-c", "echo '"+sourcecode+"' > "+path)
            .exec();
    dockerClient.execStartCmd(createCmdResponse.getId())
            .exec(new ExecStartResultCallback(System.out,System.err))
            .awaitCompletion();
    return fileName;
}

最终整个类:

/**
 * @author RhettPeng
 */
public class DockerJavaClient {
    /**
     * 计数器,用于给容器名取后缀
     */
    private static int counter = 0;

    /**
     * 获取一个docker连接
     * @return
     */
    public DockerClient getDockerClient(){
        DockerCmdExecFactory dockerCmdExecFactory = new JerseyDockerCmdExecFactory().withReadTimeout(10000)
                .withConnectTimeout(2000);

        DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder()
                .withDockerHost("tcp://8.129.170.210:2375")
                .withDockerTlsVerify(true)
                .withDockerCertPath("D:\\certs\\docker")
                .withDockerConfig("D:\\certs\\docker")
                .withRegistryUrl("https://index.docker.io/v1/")
                .withRegistryUsername("Rhett")
                .withRegistryPassword("123456")
                .withRegistryEmail("995632825@qq.com")
                .build();
        return DockerClientBuilder.getInstance(config)
                .withDockerCmdExecFactory(dockerCmdExecFactory).build();
    }

    /**
     * 创建运行代码的容器
     * @param dockerClient
     * @param langType
     * @return
     */
    private String createContainer(DockerClient dockerClient,CodeLang langType){
        // 创建容器请求
        CreateContainerResponse containerResponse = dockerClient.createContainerCmd(langType.getImageName())
                .withName(langType.getContainerNamePrefix()+counter)
                .withWorkingDir("/usr/src/myapp")
                .withStdinOpen(true)
                .exec();

        return containerResponse.getId();
    }

    /**
     * 将程序代码写入容器中的一个文件
     * @param dockerClient
     * @param containerId
     * @param langType
     * @param sourcecode
     * @return
     * @throws InterruptedException
     */
    private String writeFileToContainer(DockerClient dockerClient,String containerId,CodeLang langType,String sourcecode) throws InterruptedException {
        String workDir = "/usr/src/myapp";
        String fileName = langType.getFileName();
        String path = workDir + "/" + fileName;
        // 通过重定向符写入文件,注意必须要带前面两个参数,否则重定向符会失效,和Docker CMD的机制有关
        ExecCreateCmdResponse createCmdResponse = dockerClient.execCreateCmd(containerId)
                .withCmd("/bin/sh","-c", "echo '"+sourcecode+"' > "+path)
                .exec();
        dockerClient.execStartCmd(createCmdResponse.getId())
                .exec(new ExecStartResultCallback(System.out,System.err))
                .awaitCompletion();
        return fileName;
    }

    /**
     * 在容器上EXEC一条CMD命令
     * @param dockerClient docker客户端
     * @param command 命令,EXEC数组
     * @param containerId 容器ID
     * @param timeout 超时时间(单位为秒)
     * @param ctx
     * @param isFinal 是否是最后一条指令
     * @throws InterruptedException
     */
    private void runCommandOnContainer(DockerClient dockerClient, String[] command, String containerId,
                                       int timeout,ChannelHandlerContext ctx,boolean isFinal) throws InterruptedException {
        ExecCreateCmdResponse createCmdResponse = dockerClient.execCreateCmd(containerId)
                .withAttachStdout(true)
                .withAttachStderr(true)
                .withCmd(command)
                .exec();
        dockerClient.execStartCmd(createCmdResponse.getId())
                .exec(new RunCodeResultCallback(ctx,isFinal))
                .awaitCompletion(timeout,TimeUnit.SECONDS);
    }

    /**
     * 执行一个程序
     * @param langType 编程语言类型
     * @param sourcecode 源代码
     * @throws InterruptedException
     * @throws IOException
     */
    public void exec(CodeLang langType, String sourcecode, ChannelHandlerContext ctx){
        DockerClient dockerClient = getDockerClient();
        // 计数器加一
        counter++;

        // 创建容器
        String containerId = createContainer(dockerClient, langType);

        // 运行容器
        dockerClient.startContainerCmd(containerId).exec();

        try {
            writeFileToContainer(dockerClient, containerId, langType, sourcecode);
            String[][] commands = langType.getExecCommand(langType.getFileName());
            for(int i = 0;i<commands.length;i++){
                runCommandOnContainer(dockerClient, commands[i], containerId, 10, ctx,i==commands.length-1);
            }


        }catch (Exception e){
            e.printStackTrace();
        }finally {
            // 移除容器
            dockerClient.killContainerCmd(containerId).exec();
            dockerClient.removeContainerCmd(containerId).exec();
            counter--;
            try {
                dockerClient.close();
            } catch (IOException exception) {
                exception.printStackTrace();
            }
        }
    }
}

将Docker的响应返回给用户

至于怎么将Docker的响应返回给用户,就是在EXEC执行指令的时候,通过一个结果回调,让它持有WebSocket处理器的一个引用,这样收到一个响应就能立刻返回给用户:

/**
 * 接收到docker信息的回调
 * @author RhettPeng
 */
@Slf4j
public class RunCodeResultCallback extends ResultCallbackTemplate<ExecStartResultCallback, Frame> {

    private ChannelHandlerContext ctx;
    private boolean isFinal;
    private long startTime;

    public RunCodeResultCallback(ChannelHandlerContext ctx,boolean isFinal) {
        this.ctx = ctx;
        this.isFinal = isFinal;
        if(isFinal) {
            startTime = System.currentTimeMillis();
        }
    }

    @Override
    public void onNext(Frame frame) {
        log.info("收到docker响应");
        if (frame != null) {
            String msg = new String(frame.getPayload());
            switch (frame.getStreamType()) {
                case STDOUT:
                case RAW:
                case STDERR:
                    ctx.channel().writeAndFlush(new TextWebSocketFrame(msg));
                    break;
                default:
                    break;
            }
        }
    }

    @Override
    public void onComplete() {
        if(isFinal){
            long endTime = System.currentTimeMillis();
            ctx.channel().writeAndFlush(new TextWebSocketFrame("程序运行结束,总耗费时间:"+(endTime-startTime)/1000.0+"s"));
        }
    }
}

原创文章,作者:彭晨涛,如若转载,请注明出处:https://www.codetool.top/article/%e4%bd%bf%e7%94%a8nettydocker%e9%87%8d%e6%9e%84%e6%88%91%e7%9a%84%e5%9c%a8%e7%ba%bf%e8%bf%90%e8%a1%8c%e4%bb%a3%e7%a0%81%e9%a1%b9%e7%9b%ae/

发表评论

电子邮件地址不会被公开。