需求
毕业设计,实现一个能够自动编译、运行、监测程序运行使用资源、恶意系统调用的监控的一个OJ平台。
在设计实现的过程中的想法、碰到的问题、求解的过程以及方法,在这里记录下来。
基础结构
OJ主要由前端系统(WEB)和后端的判题程序构成,想法是后端的裁判程序做通用点,减少和前端系统的耦合,所以把后端给分离出来成一个独立的程序,大概的结构图是这样的。
解释下:
1. 前端其实可以由任何流行的web语言来实现。
2. 这里的代理可有可无,代理在这里可以实现很多功能,比如负载均衡、数据库的业务逻辑等都可以在这里实现。
3. 裁判模块主要实现程序的编译、运行、监控、给出判定结果,是无状态的,所以整个系统的扩展性高。
后端使用到的技术
操作系统选的是linux,原因是工具多,稳定,系统API丰富。
裁判模块用的语言是C++,主要原因是性能、系统编程方便。
裁判模块的网络i/o用的是cheetah,一个事件驱动的网络库。
模块间的通信采用的是protobuf。
裁判模块的设计
这里借鉴了nginx的设计,单线程多进程的方式,由master进程和minion进程(数量可配置)组成。
- master管理minion进程的生命周期,在minion进程挂掉之后会重启。minion进程,在初始化的时候创建一个监听socket,fork出minion进程。
- minion进程共享master进程的监听socket,每accept一个连接,加入到reactor里,每读取到一个请求执行一次判题的过程。
- 判题的过程是编译程序,fork一个子进程,将标准输入、标准输出、标准错误输出重定向到几个文件上,然后调用execv将编译好的程序替换掉当前的binary,运行完成后比对输出文件和答案文件,给出结果。
碰到的问题
- cpu时间、内存使用量的监控,限制程序的cpu时间、内存使用量。
- 系统调用的监控。
- execv系统调用的一些pitfalls。
问题的思考的过程和解决方法
- 1.cpu时间、内存使用量的监控,限制程序的cpu时间、内存使用量。
-
如何做到cpu时间和内存使用量的测量呢?linux操作系统上有很多方法可以获得一个进程的这些信息,这里列举几个方法以及各自的优缺点。- 用system系统调用定期调用top,ps等工具,来分析这些工具的输出。优点,方便。缺点,这个定期的频率不好选择,如果频率低了程序的性能降低,高了的话监测的精准度不够,比如某个程序在1ms就运行完毕了,这样的话这些工具甚至都捕捉不到这个进程运行过。
- 定期扫描/proc文件系统里被监测程序的信息。优点,/proc文件系统提供了非常详细的信息,一些top、ps的实现都是读取这个系统。缺点,同1。
- 使用wait系统调用,在子进程结束的时候读取下程序的资源使用信息,具体使用方法可以参考manpage。优点,精准,在程序结束的时候,父进程会收到子进程的SIGCHLD信号,在这个时候使用wait系统调用能获得比较精准的cpu和内存使用量。缺点,由于对程序资源做了限制(见下一个问题),在内存超出了限制之后,程序malloc内存会失败,在C的程序里会导致SIGSEGV,errno被设置为ENOMEM,在c++程序里也可能会导致SIGABRT、SIGKILL(由于new失败抛出的bad_alloc没有被捕获导致abort的调用),但是也有其他的情况会导致SEGFAULT,比如访问不存在的内存地址,这样做无法准确区别到底是内存超了,还是程序访问了不存在的内存的情况。
在获得程序的资源使用量之后,可以通过一下结合这些方法方式来实现时间、内存的限制。
- 当top,ps返回的结果中发现内存、时间的使用量超出了限制之后,发送SIGKILL给子进程。这样做还是需要频率的问题,间隔时间大了,子进程可能分配了大量的内存,对整个系统安全造成威胁。
- 使用setrlimit系统调用来实现cpu、内存使用量的限制,当使用量超出限制就发送SIGXCPU(超时),malloc调用失败(内存超了,errno设置为ENOMEM)。这种做法比较清爽,因为进程的资源使用量操作系统是最清楚不过了。
在经过一段时间研究,资料搜寻,找不到一种完美、简单的方式完成这个功能。最后经过权衡之后还是选择了3和2,在wait4返回的子进程退出状态来判断各种情况,这里只能通过一种比较丑陋的hack来判断是否是内存超了,通过setrlimit系统调用将进程的内存限制设置为限制的125%,如本来的内存限制为1M,现将内存限制设置为1.25M,在进程退出之后,判断内存使用量是否超过1M。
...
if (fork() == 0) {
/* 子进程 */
rlimit r;
getrlimit(RLIMIT_CPU, &r);
r.rlim_cur = time_limit() / 1000;
setrlimit(RLIMIT_CPU, &r);
getrlimit(RLIMIT_AS, &r);
r.rlim_cur = mem_limit() * 1024 * 1.25;
setrlimit(RLIMIT_AS, &r);
}
...
struct rusage usage;
wait4(pid, &s, 0, &usage); // reap the process and retrieve resources usage
mem_usage = usage.ru_maxrss;
if (WIFEXITED(s)) {
fprintf(stderr, "WEXITSTATUS %d\n", WEXITSTATUS(s)); /* 正常退出 */
} else if (WIFSIGNALED(s)) { /* 程序被信号结束了 */
fprintf(stderr, "WTERMSIG %d\n", WTERMSIG(s));
if (WTERMSIG(s) == SIGSEGV) {
if (mem_usage> mem_limit)
mf = 1; /* 超出内存限制 */
else
sf = 1;
} else if (WTERMSIG(s) == SIGXCPU) { //SIGXCPU indicates the process used up the CPU time assigned to it
tf = 1; /* 超出时间限制 */
} else if (WTERMSIG(s) == SIGKILL ||
WTERMSIG(s) == SIGABRT) {
if (mem_usage> mem_limit)
mf = 1; /* 超出内存限制 */
else
of = 1; /* 运行时错误 */
} else {
of = 1; /* 运行时错误 */
}
}
- 2.恶意系统调用的监控
-
用户提交上来的程序可能会包含恶意的系统调用,如操作文件系统(unlink),进程复制(fork),网络(socket, sendto, recvfrom)等系统调用。如何限制这些调用呢?- 使用strace工具来查看程序的系统调用,一旦发现非法的系统调用,就发送SIGKILL,这样做的一个很大的缺点是实时性,当一个子进程系统调用调用完毕之后,我们的程序才可能反应过来。
- 我们想要的是实时的监控子进程的系统调用过程,在尝试调用禁止的系统调用的时候我们就需要把程序杀死,好在linux提供了ptrace系统调用,原理是在程序trap进系统调用之前会先检查当前进程是否被traced了,如果是的话,通过SIGTRAP暂停当前进程,通知tracer,给tracer一个机会来做一些事情,如恢复子进程执行、杀死进程等。strace就是基于ptrace来实现的。
综上所述,我采用了ptrace的方式来实现监控系统调用,关于ptrace系统调用可以参考manpage。
... if (fork() == 0) { ... /* 这里设置当前进程要被trace */ if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) == -1) { fprintf(stderr, "failed to ptrace : %s\n", strerror(errno)); exit(1); } ... } ... /* 等到execv调用后的第一个信号 */ if (waitpid(pid, &s, 0) == -1) { fprintf(stderr, "waitpid(%d, 0, 0) error %s\n", pid, strerror(errno)); v.set_status(verdict_result::UNKNOWN_ERROR);; return std::move(v); } /* 设置trace选项,在子进程退出的时候发送EXIT事件,监视syscall,在父进程发生错误退出的时候杀死所有的子进程 */ if (ptrace(PTRACE_SETOPTIONS, pid, 0, PTRACE_O_TRACESYSGOOD | PTRACE_O_TRACEEXIT | PTRACE_O_EXITKILL ) == -1) { fprintf(stderr, "ptrace(PTRACE_SETOPTIONS, %d, 0, PTRACE_O_TRACEEXIT | PTRACE_O_EXITKILL) error %s\n", pid, strerror(errno)); v.set_status(verdict_result::UNKNOWN_ERROR);; return std::move(v); } while (true) { /* 恢复运行但是监视syscall */ if (ptrace(PTRACE_SYSCALL, pid, 0, 0) == -1) { fprintf(stderr, "ptrace(PTRACE_SYSCALL, %d, 0, 0) error %s\n", pid, strerror(errno)); v.set_status(verdict_result::UNKNOWN_ERROR);; return std::move(v); } /* 获取子进程的状态 */ if (waitpid(pid, &s, 0) == -1) { fprintf(stderr, "waitpid(%d, &s, 0); error %s\n",pid, strerror(errno)); v.set_status(verdict_result::UNKNOWN_ERROR);; return std::move(v); } /* 这里判断进程是否退出了 */ if (WIFEXITED(s) || (WSTOPSIG(s) == SIGTRAP && (s & (PTRACE_EVENT_EXIT << 8))))break; fprintf(stderr, "WSTOPSIG %d\n", WSTOPSIG(s)); if (WSTOPSIG(s) == SIGSEGV) { fprintf(stderr, "child process %d received SIGSEGV, killing... ", pid); if (kill(pid, SIGKILL) == -1) fprintf(stderr, "failed. %s\n", strerror(errno)); else fprintf(stderr, "done.\n"); } else if (WIFSTOPPED(s) && (WSTOPSIG(s) & 0x80)) { /* 这里的WORD_SIZE和ORIG_EAX根据机器字长不同需要做特殊处理 */ #ifdef __x86_64__ #define ORIG_EAX ORIG_RAX #define WORD_SIZE 8 #else #define WORD_SIZE 4 #endif /* 在进入或退出系统调用前子进程被暂停下来, 暂停信号的第7位被置1, 也就是0x80中断号*/ long call_num = ptrace(PTRACE_PEEKUSER, pid, WORD_SIZE * ORIG_EAX);/* 拿到系统调用号*/ assert(call_num < NR_syscalls); fprintf(stderr, "child process calling syscall, number: %ld\n", call_num); if (syscall_mask[call_num]) { /* 调用了禁用的系统调用, 发送SIGKILL */ fprintf(stderr, "child process %d is trying to invoke the banned system call, killing it... ", pid); if (kill(pid, SIGKILL) == -1) fprintf(stderr, "failed. %s\n", strerror(errno)); else fprintf(stderr, "done.\n"); v.set_status(verdict_result::RUNTIME_ERROR); return std::move(v); } } }
- 3.execv系统调用的一些pitfalls。
- 这是我在实现的时候碰到的一个问题,提交的程序定义了一个超过内存限制的全局数组,当调用setrlimit给程序设置了内存使用限制之后,调用execv,本以为execv会返回ENOMEM表示内存不够,但是却出现了进程直接被杀死(SIGKILL)的情况。而父进程在却在等待子进程执行execv后发送的第一个信号,waitpid返回了ENOCHLD的错误,表示不存在子进程。在网络上搜索很长的时间,并没有发现这类的问题,只好看下linux kernel的实现了,版本是2.6.32.65的,后面的版本这块的实现变化不大。
首先是找到elf加载器的实现,在fs/binfmt_elf.c里,定位到load_elf_binary函数。注意到714行和719行的关键注释。
...读取可执行文件,一致性检查,在这里可以通过返回值来返回错误...
/* Flush all traces of the currently running executable */
retval = flush_old_exec(bprm);
if (retval)
goto out_free_dentry;
/* OK, This is the point of no return */
current->flags &= ~PF_FORKNOEXEC;
current->mm->def_flags = def_flags;
...加载可执行文件里的elf格式信息,计算程序的bss(未初始化数据段,全局变量)大小...
/*
* Calling set_brk effectively mmaps the pages that we need
* for the bss and break sections. We must do this before
* mapping in the interpreter, to make sure it doesn‘t wind
* up getting placed where the bss needs to go.
*/
retval = set_brk(elf_bss, elf_brk);
if (retval) {
send_sig(SIGKILL, current, 0);
goto out_free_dentry;
}
也就是说在执行完flush_old_exec之后,如果没有返回错误,那么就不能使用返回值了,因为老的executable的信息已经被完全的清理了,但是默认信号处理程序没有被清空。所以接下来的操作出现了错误之后只能通过发送信号来处理了。注意到874行调用set_brk来扩大堆空间,如果这个大小超过了rlimit,那么就会失败,然后这个程序就被杀掉了。
所以父进程在等待子进程的第一个信号时,要判断程序是否被kill掉了。当然也有可能是其他情况导致程序被KILL掉,无法用SIGKILL来断定程序内存超了,所以这种情况实在是没有什么好的方法来解决,希望有这方面经验的大神能指点一二。
//wait for the first signal caused by execv
if (waitpid(pid, &s, 0) == -1) {
fprintf(stderr, "waitpid(%d, 0, 0) error %s\n", pid, strerror(errno));
v.set_status(verdict_result::UNKNOWN_ERROR);;
return std::move(v);
}
/*
* there might be a case where the child process failed to execv
* due to virtual memory limit caused by huge global variables,
* in that case the child process is killed by kernel by sending a SIGKILL.
*/
if (WIFEXITED(s)) {
fprintf(stderr, "child process exited, WEXITSTATUS %d\n", WEXITSTATUS(s));
v.set_status(verdict_result::UNKNOWN_ERROR);
return std::move(v);
} else if (WIFSIGNALED(s)) {
fprintf(stderr, "child process terminated at execv, WTERMSIG %d(", WTERMSIG(s));
if (WTERMSIG(s) == SIGSEGV) {
fprintf(stderr, "SIGSEGV)\n");
v.set_status(verdict_result::SEGMENTATION_FAULT);
} else if (WTERMSIG(s) == SIGKILL) {
fprintf(stderr, "SIGKILL) memory limit exceeded.\n");
v.set_status(verdict_result::MEMORY_LIMIT_EXCEEDED);
v.set_mem_usage(sub.mem_limit());
} else {
v.set_status(verdict_result::UNKNOWN_ERROR);;
fprintf(stderr, ")\n");
}
return std::move(v);
}
潜在优化点
- 目前的minion进程在执行判题的的过程中是需要等待子进程的信号或者子进程执行完毕的,可以将这个等待的过程用事件的方式加入到reactor里面来提高并发度。
- 多个进程共享一个监听socket,可能会出现惊群效应。