项目介绍
很久很久以前,做过一个在线运行代码的项目,见:
做这个项目的初衷,是可以在博客里嵌入一段可运行的代码,提升阅读体验。不过后来发现,除了一些Java的基础知识,也没什么知识点是单个文件可以演示出来的,所以到后面用的就不多了。
不过还是一直想把这个项目更新重构一下,毕竟之前做的那个版本安全问题太严重了(只需要写一段空循环代码,就能让CPU长时间处于100%负载,甚至还可以通过文件API去控制修改我服务器上的一些文件,而我之前是一直把这个项目跑在主力服务器上的,只是因为我的博客访问量太小,也没什么人会用这个东西,而且即使会用通常也不会想着搞点破坏,才让我这个安全漏洞百出的项目跑了这么久)
技术点
重构之后,我使用了 Netty + Websocket + Docker
的技术点,WebSocket
用于提升一些功能性,因为有一些代码的运行时间是可以很长的(例如循环输出代码),而用户不能等待很长一段时间都没有响应,于是可以通过Websocket
在程序运行期间即时返回一些已输出信息。
之所以为什么选用Netty
开发Websocket
服务端,其实主流目前使用Java开发Websocket
服务端的技术一般就是Netty
和SpringBoot
,我选用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-java在java中连接Docker(一)--简单连接_杨-CSDN博客_docker-java
- Docker-java在java中连接Docker(二)--安全连接_杨-CSDN博客_docker-javax509
主要是看第二篇文章,让服务器上的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/