项目背景
为了满足xx大学在科研、教学和实训流程上的项目管控、人员协同、进度把控、任务分配、资源分配、数据分析、成果管理等需求,为实践教学提供集项目协同、大数据采集、数据众包服务、数据清洗与治理、数据分析平台等常用的工作台,需搭建一套集系统管理、项目管理、课程管理、算法开发、数据众包、文献查询、审批管理和学术圈为一体的教学科研实训平台。
算法训练系统
简介
算法训练是本系统的核心功能之一,为用户提供深度学习算法训练平台,内置多种常用数据集,同时支持代码编程及图形化编程,其中代码支持python、scala、r 三种语言,图像化编程通过拖拽组件并选择参数完成建模无需编写代码即可开始算法训练,这种编程方式极大降低了开发者的门槛,提升了开发效率。
功能需求
- 创建算法训练任务
- 代码在线编写(支持R、Scala、Python语言)
- 可视化建立算法模型
- 检验算法模型是否合规
- 算法模型图形化视图可转换为代码视图
- 在线运行算法训练任务
- 可监控代码运行状态及运行日志
- 弹性算力调度
- 可对算法任务进行编排如执行顺序,最大运行数量等
- 可自动调度服务器资源为算法任务提供运行环境
- 每个算法任务可使用最大资源和最大运行时间可控
- 可限制任务执行所消耗的最大资源
- 算法执行结束后,可自动回收运算资源
非功能需求
- 安全性:必须限制用户程序的运行资源和系统权限,即用户的程序运行时间、内存、cpu、线程数都有所限制,且无法对系统造成破坏。
职责
下图是简略的执行逻辑图,用户提交代码后,在服务器集群中开辟一个独立机器,将代码放在机器中执行。0
而我职责简单来说就是弹性调度服务器资源为任务提供执行的环境,并编排任务的执行。
设计思路
此系统类似于oj(online judage)系统,执行逻辑都为用户提交代码,客户端通过http将用户代码传入服务端,服务端将代码在测评机上运行,并返回运行结果。而用户提交的代码不一定是安全的,它可能无限创建进程或消耗文件来消耗测评机资源,或者建立到远程服务的连接,给攻击者提供后门。为了保证服务器的安全,我们需要限制用户程序运行的资源及系统的调用。
资源的限制
限制程序运行的资源是指限制内存、运行时间、进程、线程数量等资源。一般使用setrlimit()来完成。
setrlimit() 函数是C的一个函数,具体使用可参考https://blog.csdn.net/u012206617/article/details/89286635
系统调用的限制
程序一切与系统有关的操作,比如输入输出,创建进程,获取系统信息等,都需要系统调用(System Call)。限制系统调用可以限制程序的一些危险行为,例如获取系统目录结构,或者获取系统权限等。如果将未限制系统调用的程序与服务器或者其他程序运行在一起,可能会破坏系统的安全和其他程序的运行。
目前常用的两个限制系统调用方案是ptrace() 和 seccomp,前者的原理是在目标程序每次尝试进行系统调用时通知主程序,如果发现为危险的系统调用,及时将程序杀死。ptrace() 是在每次系统调用时都会产生两次中断(进入系统调用前一次,系统调用返回后一次),影响效率,相比之下seccmap可能是更好的选择。
seccomp(全称securecomputing mode)是linux kernel支持的一种安全机制。在Linux系统里,大量的系统调用(systemcall)直接暴露给用户态程序。但是,并不是所有的系统调用都被需要,而且不安全的代码滥用系统调用会对系统造成安全威胁。通过seccomp,我们限制程序使用某些系统调用,这样可以减少系统的暴露面,同时是程序进入一种“安全”的状态。
使用 ptrace()
的评测系统有:HustOJ、UOJ; 使用 seccamp
的评测系统有:QDUOJ、TJudger。
基于Docker 的沙箱
使用编码的方式来限制程序运行的资源和系统调用,需要复杂的逻辑处理,且用户程序与web服务器运行在一起仍有很大的安全隐患,我们可以考虑另一种思路来保证系统的安全,将目标程序与系统环境隔离,形成一个沙箱(SandBox)环境。
什么是沙箱?
沙箱是指一个虚拟系统程序,其提供的环境相对于每一个运行的程序都是独立的,而且不会对现有的系统造成影响。其实java的虚拟机jvm就是使用的沙箱机制。
Docker 是一个开源应用容器引擎,可让开发者打包他们的应用及依赖包到一个轻量级、可移植的容器中,容器完全使用沙箱机制,相互之间无任何接口。
思路
我们只需要在每次运行用户程序时,创建一个Docker容器,将输入数据与用户程序传入,让程序在容器内运行,创建一个监视线程,监控程序运行状态,并将输出放到一个特定的目录与外界挂载即可。
Docker的镜像技术可以为不同的语言提供对应的运行环境。
对于需要运行环境的语言(如 python 等脚本语言),我们可以将系统的库目录映射到容器中,至于安全性,一般不需要考虑,因为用户程序在容器中也是以普通用户运行的,对系统目录没有写权限,而库目录中也没有可以读到的配置文件等敏感信息。
Docker提供的网络功能也可以进制容器中的目标程序联网。
有关资源的限制,可以使用Docker提供的资源限制
优缺点
优点:
不需要自己编写代码来完成资源限制功能,可以直接使用Docker提供的资源限制
容器间彼此隔离,安全性更高
不需要限制系统调用
缺点:
- 容器创建需要一定开销,经测试每次运行需要额外大约一秒的时间。
对于本系统来说,因为深度学习算法训练本身就是耗时较长,所以容器创建的时间开销基本可以忽略不计
需要考虑的问题
服务器使用的是集群,应该如何管理创建的docker?
用户程序运行结束后,如何回收docker资源” />基于k8s编排docker容器
其实上面几个问题本质是解决跨机器管理容器问题,也就是容器编排,而这不得不提容器编排圈中很火的技术k8s(Kubernetes),它是Google大规模容器管理系统borg的开源版本实现,它提供应用部署、维护、扩展机制等功能。
kubernetes 简介
kubernetes是一个完备的分布式系统支持平台,支持多层安全防护、准入机制、多租户应用支撑、透明的服务注册、服务发现、内建负载均衡、强大的故障发现和自我修复机制、服务滚动升级和在线扩容、可扩展的资源自动调度机制、多粒度的资源配额管理能力,完善的管理工具,包括开发、测试、部署、运维监控,一站式的完备的分布式系统开发和支撑平台。
k8s 是以pod为最小调度单位对容器进行编排,容器则被封装在Pod之中。一个pod可能由一个或多个容器组成,这些容器拥有同样的声明周期,作为一个整体一起编排到Node上。它们共享环境、储存卷和IP空间。
我们一般不会直接创建pod,**而是交由k8s的控制器进行创建管理Pod。**在控制器中可以定义Pod的部署方式,多少个副本、需要在哪个Node上运行上等等。不同的控制器有不同的特性,适用于不同的业务环境,常见的控制器有 **Deployment、DaemonSet、Job、CronJob、StatefulSet等控制器。**下面是几种控制器的适用场景。
Deployment:适合无状态的服务部署
StatefulSet:适合有状态的服务部署
DaemonSet:一次部署,所有的node节点都会部署,例如一些典型的应用场景:
运行集群储存daemon,例如在每个Node上运行
在每个Node上运行日志收集 daemon
Job:一次或多次执行一个任务
CronJob:周期性或定时执行任务
k8s 提供了对资源管理和调度的方式,只需在模板文件中设置参数即可限pod的计算资源、外部引用资源、资源对象。
想要深入了解k8s,可以翻阅官方文档,写的非常完整
实现思路
基于上述介绍,我们可以形成一个大致的实现思路。我们只需要在每次运行用户程序时,创建出一个pod,将资源映射进pod中,在pod中执行用户的程序代码即可,容器的编排工作我们就无需再考虑。
上文我们说到,pod的创建一般是交由控制器来处理,根据对五种常用控制器特性的了解,Job控制器无疑是最符合我们业务需求的控制器。我们再详细介绍下job控制器:
job控制器可以执三种类型的任务:
- 一次性任务:通常只会启动一个Pod(除非Pod失败,Pod创建失败会不断重启)。一旦Pod成功终止,Job就算完成了。
- 串行式任务:连续多次的执行某一任务。当上一个任务完成时,接着执行下一个任务,直到所有任务执行完。
- 并行式任务:同一时间并发多次执行任务。
注:这里的串行和并行执行多次任务都是指的同一任务执行多次。
根据业务需求,最合适的就是使用一次性任务,用户每次运行程序时,创建一个执行一次性任务的job控制器,让pod执行用户程序,当程序完成后,pod终止,job就完成了。还可以通过设置
spec.ttlSecondsAfterFinished
的参数让job在任务执行完成后,等待一段时间后删除。(一般是需要预留一些时间的,例如需要查看执行日志什么的)。资源限制
有关资源限制,前文中提到使用docker提供的资源限制方法,在k8s中也提供了对资源管理和调度的方式,只需在模板文件中设置参数即可限pod的计算资源、外部引用资源、资源对象。使用案例如下:
resources: #资源限制和请求的设置 limits: #资源限制的设置 cpu: String #CPU的限制,单位为CPU内核数,将用于docker run --cpu-quota 参数; #也可以使用小数,例如0.1,它等价于表达式100m(表示100milicore)memory: String #内存限制,单位可以为MiB/GiB/MB/GB,将用于docker run --memory参数, requests: #资源请求的设置 cpu: String #CPU请求,容器刚启动时的可用CPU数量,将用于docker run --cpu shares参数 memory: String #内存请求,容器刚启动时的可用内存数量
但此时还有两个问题未解决:
- 资源服务器和k8s集群服务器是分开的,如何在pod中访问程序运行需要的资源(如用户的代码等)
- 如何监控代码执行状态
解决容器之间数据存储和共享问题
首先说第一个问题,在docker中我们使用挂载来将宿主机的文件映射到容器中,而k8s定义了自己的存储卷(volume)抽象,它们提供的数据存储功能非常强大。不仅可以通过配置将数据注入pod中,在pod内部,容器间还可以共享数据。而对于不同机器的Pod,可以通过定义存储卷来实现数据共享。
k8s 中存储卷主要分为4类
- 本地存储卷:主要用于Pod中容器之间的数据共享,或Pod与Node中的数据存储和共享
- 网络存储卷:主要用于多个Pod之间或多个Node之间的数据存储和共享
- 持久存储卷:基于网络存储卷,用户无需关心存储卷使用的存储系统,只需定义具体需要消耗多少资源即可
- 配置存储卷:主要用于向各个Pod注入配置信息
本系统选用的网络存储卷nfs,基于以下原因:
- 提供程序运行的机器有多个,即需要多个Node共享数据存储。
- storageclass配置过于复杂,使用网络存储卷即可满足需求。
- 公司无服务器运维,k8s集群搭建和使用都是由一人完成,持久化储存只是为了让存储系统使用者与提供者解耦,所以没有必要使用。
网络存储卷
这里有必要再提下网络存储卷的概念:网络储存卷就是为了解决多个Pod之间或多个Node之间数据存储和共享问题,k8s支持众多的云提供商的产品和网络存储方案,如NFS/iSCSI/GlusterFS/RDB/flocker等。
本系统选用的是NFS(Network File System)网络文件系统,它允许网络中的计算机通过TCP/IP网络共享资源。通过NFS,本地NFS的客户端应用可以直接读写NFS服务器上的文件,就像访问本地文件一样。
我们只需要将资源服务器作为服务端,k8s集群服务器作为客户端,使用网络存储卷技术即可让k8s中的Pod共享资源服务器的目录和文件,nfs可以通过配置文件对暴露的目录进行安全限制,如限制可访问主机、读写权限等。
监控程序执行的状态
首先是如何判断用户程序的状态,用户程序运行大致可划分为 排队中、运行中、失败、完成四种状态,之前我们提到开启一个监控线程监控容器内程序的状态
pod生命的不同阶段分别对应着一个相位值,我们可以通过这些相位值判断出用户程序的运行状态。
- Pending:Pod已被k8s系统接接受,但尚有一个或多个容器镜像未能创建。比如,调度前前消耗的运算时间,以及通过网络下载镜像所消耗的时间,这些准备都会导致容器镜像未创建
- Running : Pod 已经绑定到Node,所有容器均已创建。至少有一个容器还在运行,或者正在启动或重新启动
- Succeeded:Pod中的所有容器都已经成功终止,并且至少有一个容器表现出失败的终止状态。也就是说,容器以非零状态退出,要么被系统终止
- Failed:Pod中的所有容器都已终止,并且至少有一个容器表现出失败的终止状态。也就是说,容器要么以非零状态退出,要么被系统终止
- Unknown:出于某种原因,无法获得Pod的状态,这通常是Pod所在的宿主机通信出错而导致的。
这里有一个问题,这是pod的状态,怎么能确定pod成功后,pod容器中运行的代码也成功了呢,也就是pod的succeed和Failed状态怎么和用户程序的状态相对应呢,其实上面有一句话,“容器要么以非零状态退出,要么被系统终止”,意思是除了系统直接终止容器导致容器退出外,只有容器以非零状态退出才会出现Failed状态。那么我们只要监控容器内部用户程序的运行状态,如果运行失败,就让程序以非零状态退出即可。例如如下shell命令
python run.py# 判断命令是否执行成功if [ $" />0 ];thenecho "================执行失败==============================="exit 0;fi
因为一次性执行job就会开启一个pod,一个容器,所以我们只需要在容器启动时运行该脚本即可,k8s 中pod的command参数和args参数分别可以设置容器的启动命令和启动参数列表,利用这两个参数即可达到我们想要的效果。
注:command和args设置会分别覆盖原Docker镜像中定义的EntryPoint与CMD,在使用时请务必注意以下规则:
如果没有在模板中提供command或args,则使用Docker镜像中定义的默认值运行。
如果在模板中提供了command,但未提供args,则仅使用提供的command。Docker镜像中定义的默认的EntryPoint和默认的命令都将被忽略。
如果只提供了args,则Docker镜像中定义的默认的EntryPoint将与所提供的args组合到一起运行。
如果同时提供了command和args,Docker镜像中定义的默认的EntryPoint和命令都将被忽略。所提供的command和args将会组合到一起运行。
虽然可以通过pod的状态来判断程序的状态,但pod状态目前仍无法实时监测,当时调研的实现方案有三种:
- 定时轮询查询pod状态
- 使用k8s生命周期回调
- 使用k8s 的watch监听
定时轮询pod状态
这个思路就很简单,一段时间查询一下对应job的状态,但缺点也很大,首先会有延迟,其次轮询对k8s服务器和web服务器的消耗都很大,如果开的job多了,无疑会大幅度增加服务器负担,所以不可取。
使用k8s生命周期回调
k8s有两个生命周期事件,PostStart事件和PreStop事件,分别在容器刚刚创建成功和容器结束前执行回调。
PostStart:容器刚刚创建成功后,触发事件,执行回调。如果回调中的操作执行失败,则该容器会被终止,并根据该容器的重启策略决定是否要重启该容器。
PreStop:**容器结束前,触发事件,执行回调。**无论回调执行结果如何,都会结束容器。
回调的实现方式有两种,一种是Exec,一种是HttpGet,
- Exec回调会执行特定的命令或操作。如果Exec执行的命令最后在stdout的结果中为OK,则代表执行成功;否则,就被认为执行异常,并且kubelet将强制重新启动该容器。
- HttpGet回调会执行特定的HttpGet请求,它通过返回的HTTP状态码来判断该请求执行是否成功,
我们可以利用回调机制,在容器创建成功时发起http请求,通知服务端改变状态为运行中
在容器结束前发起http请求,将程序运行的状态(失败或成功)传给服务端,然后通知服务端改变状态。
下图是pod的生命周期事件图
问题
虽然这种方案是可行的方案,但在真正实现的过程中,发现无法通过get请求将程序执行的状态直接传回,只能通知服务器去查询该pod的状态。但此时pod还未停止,仍处于运行的状态,所以我们无法得知程序是否运行成功。但后期我们想到在pod停止的时候开启异步使用watch监听容器的状态,这样pod后续的状态就能监听到。
使用watch监听pod状态
在k8s 中可以通过Watch接口持续检测pod或其他组件的状态变化,例如pod列表,如果pod状态发生变化,就会输出
kubectl get pod -w 或者--watch
大致思路就是使用使用k8s 提供的javaClient 调用watch 监听pod的状态,(创建job的时候会用label来唯一标识job和pod),由于watch监听是一个持续的过程,javaClient在调用k8s watch api时采用的是OkHttpClient,按官方的例子,OkHttpClient10s就会断开连接,我们可以通过配置来设置OkHttpClient的超时时间,超时时间不宜设置的过长,如果没有及时关闭链接,导致k8s apiserver会浪费很多连接,压力大,但太短有可能程序还未执行完连接就关闭了。
所以这里又产生了一个问题:怎么合理的设置OkHttpClient的超时时间,并保证不会在程序结束前关闭连接?
问题
关于这个问题起初的方案是将**OkHttpClient的超时时间设置的比用户程序的时间限制长一点即可,**因为用户程序执行超时是一定会销毁pod的,这样即使连接关闭了也无所谓。但本系统是算法训练系统,**执行时间一般较长,用户程序的时间限制也比较宽泛,**如果长时间保持这样的连接,确实有点浪费资源。
生命周期回调+watch 持续监听程序状态
但如果我们跟上一种策略相结合,在pod结束前(此时程序已经执行完成)在启动pod监听,就可以大大减少连接的时间。所以最终我们采用了生命周期回调+watch监听结合的策略。
到此为止,有关用户程序执行的沙箱环境的设计和实现思路已经阐述完成,但,还有一些细节的问题需要我们去解决。
- 如何控制job的工作数量?
- 用户程序的执行结果如何展示给用户?
控制job的工作数量
除了对单个用户程序进行资源限制,我们还需要限制整体正在运行的程序数量,否则许多用户一起运行程序,仍然会把服务器资源占满。
思路
想要限制job的工作数量最简单的思路就是在运行的时候查询下目前正在运行的job数量,如果小于max,则创建job。
一旦job数量超过最大限额应该怎么处理呢,直接告诉用户运行失败肯定不太有好,最好是有一个容器能够将用户的请求储存起来,让用户的执行请求进入排队状态,等待其他程序运行完成后,再让其继续执行。这逻辑让我们很容易就想起来了队列这一数据结构,使用消息队列中间件无疑可以很好解决这一问题。
具体流程为:
用户向服务端发送运行程序的请求直接将请求存入消费者队列中
消费者拿到请求后先去k8s服务器查询job的数量
如果数量没有超过限额就消费,向k8s服务器发送请求创建job
大于或等于限额就将消息重新存入队头,过一段时间再消费
上面的步骤虽然可以满足需求,但仍有可优化的部分,上文我们说到在判断job数量的时候,我们每次都是去k8s服务器查询job的数量,而job的数量超额的时候,消息队列会不断去重试,每次都会去查询k8s中job的数量,这无疑会加大对k8s服务器的压力,其实,我们可以手动去维护正在运行的job数量,而不用每次都去k8s中查询。我们只需使用redis的基本数据结构string缓存job最大限额,每次开启一个job的时候减一,当job销毁时加一即可。
但此处需要注意,job减一到发起请求创建job必须是一个原子性操作, 所以此处可以加上分布式锁,如果job创建失败也需要考虑数据回滚的问题。job我们之前说的是自动销毁的,所以我们需要再pod监听处,当pod失败或完成时维护job的数量。
执行结果如何告知用户
上文我们曾提到将**用户程序的输出保存到特定目录下,**但如何让用户知道程序的输出呢?
关于这点有两个方案:
- 使用websocket建立长连接,每隔一段时间读取日志文件,实时输出日志。
- 让客户端直接请求日志内容,每次请求都可以看到最新的日志。
因为本系统的程序普遍比较耗时,建立长连接需要消耗大量资源去维护连接,根据以往的开发经验,建立长连接很容易因为网络波动或者用户的一些操作断开连接,且开发步骤比较繁琐,考虑到程序日志无实时性要求,所以采用第二种方案。
minio对象存储
关于日志和代码文件的储存本系统也进行了一定的设计。
本系统有特定的资源服务器,其采用的是开源对象存储项目minio搭建的分布式对象存储系统。minio设计的主要目标是作为私有云对象存储的标准方案。主要用于存储海量的图片,视频,文档等。非常适合于存储大容量非结构化的数据,例如图片、视频、日志文件、备份数据和容器/虚拟机镜像等。
搭建的具体过程可以参考博客https://blog.csdn.net/R1011/article/details/124399434
上文我们也提到使用nfs 将k8s集群服务器和资源服务器连接到一起,将资源服务器的目录共享给k8s集群,我们将用户的代码文件上传到minio的资源目录下并共享给k8s集群,让其运行时通过数据卷映射用户的代码目录,同时我们在启动时还需一个数据卷映射特定的用户日志目录,程序运行时将日志输出到这个目录下,这样就能将日志同步到资源服务器内。客户端在查询程序日志时,只需直接通过minio访问该日志文件即可。
总结
最后,我们用一张图来总结一下本算法系统的架构。