代码执行¶
ZJUI-Learn 的核心是安全执行任意代码的能力。 ZJUI-Learn 必须在三个主要上下文中执行代码:
- 问题和元素代码:这是来自问题
server.py或任何 ZJUI-Learn/课程 元素 的代码。此代码必须尽快执行,因为每当呈现或评分问题时都会执行该代码,因此需要在单个 HTTP 请求期间呈现。 - 外部评分问题:这是学生提交的代码,并按课程代码为他们评分。该代码可能需要更长的时间来执行,并且会排队等待在一组分布式计算机上执行。
- 工作空间:这是一个交互式环境,学生可以在其中编写和执行代码,这与外部评分者的非交互式批量执行形成鲜明对比。工作空间可以持续多个小时,因此它们在一组分布式计算机上执行(工作空间主机,与_外部评分机主机_不同的一组)。
外部评分文档 和 工作空间文档 更详细地描述了这些执行模式。本文档主要涉及描述问题和元素代码的执行方式。
Python 受精卵¶
每次我们想要执行一个问题的代码时,我们都希望使用一个新的 Python 环境,该环境不会被以前的代码修改或破坏。然而,启动一个新的 Python 解释器相对昂贵,需要数百毫秒的时间。与外部评分代码执行相比,问题和元素代码执行(除了安全性之外)的主要关注点是速度。
为了解决这个问题,我们借用了Android的zygote进程的概念。我们不是为每个请求启动一个新的 Python 进程,而是启动一个特殊的 zygote 进程,该进程启动一个 Python 解释器,预加载 numpy 和 lxml 等常用库,并自行分叉。分叉从父进程继承文件描述符,我们用它来与分叉进程通信。分叉进程将使用copy-on-write,它本质上是免费的。当我们想要执行代码时,我们通过stdin向分叉进程发送命令,并通过文件描述符3接收执行代码的结果。在单次使用分叉进程期间可能会发送许多命令。当一个问题完成渲染/分级/等操作后,我们会向分叉进程发送一条特殊的 restart 消息,该进程将以状态 0 退出。 zygote 将检测到子进程正常退出并立即重新分叉自身,分叉将再次开始侦听命令。这样,每个请求都会以几乎零开销获得一个全新的 Python 环境。
工人池¶
一台 ZJUI-Learn 服务器可能会同时为数百或数千个评估提供服务。为了解决这个问题,我们实际上运行了上面描述的一个受精池,我们称之为_worker pool_。该池维护 N 合子并分发请求以在它们之间执行 Python 代码。请求按照 FIFO 进行排队和处理。工作池还负责检测不健康的受精卵并用新的受精卵替换它们。
执行模式¶
ZJUI-Learn 必须在两个主要环境中执行:在开发课程内容的人员的本地计算机上,以及在生产环境中。这两个环境之间的主要区别是 (a) 易于设置以及 (b) ZJUI-Learn 是否在 Docker 容器中运行。
对于本地开发,ZJUI-Learn必须易于设置;它不需要复杂的基础设施或命令来运行。对于此用例,ZJUI-Learn 作为单个 Docker 容器进行分发,可以在没有任何外部依赖项的情况下运行。在生产环境中,我们可以承担一些额外的复杂性,以获得更高的安全性和可靠性。
为了考虑执行 ZJUI-Learn 的各种上下文,ZJUI-Learn 可以通过两种相关但不同的方式执行问题和元素代码:native 和 container。这些模式对应于 workersExecutionMode 配置值。下面更详细地描述它们。
native执行模式¶
在此模式下,ZJUI-Learn 直接执行 Python 代码,与系统其余部分的隔离有限。这主要是上面描述的过程,有一个受精卵池。
这仍然是 ZJUI-Learn 本地开发的默认功能。分发给用户的 priairelearn/prairielearn Docker 镜像包含问题和元素代码所需的所有 Python 和 R 依赖项,并且所述代码在 ZJUI-Learn 执行的同一个容器中执行。这显然不利于安全,但对于本地开发来说并不重要。
container执行模式¶
在这种模式下,ZJUI-Learn使用Docker来提供与ZJUI-Learn和其他课程的一定程度的隔离。
它没有使用如上所述的 zygote 池,而是实际上维护了一个 Docker 容器池,每个容器都运行一个简单的 Node 脚本(executor),而该脚本又运行一个 Python zygote。 Node 脚本侦听来自 ZJUI-Learn 的请求,本质上只是将它们转发到 Python 进程。您可能会问,“为什么不直接将 zygote 作为容器中的主进程运行呢?”嗯,启动 Docker 容器比启动 Python 解释器要昂贵得多。考虑到我们偶尔想要完全重新启动 Python Worker,例如当它遇到错误时,额外的间接级别允许我们优雅地重新启动 Docker 容器内的 Python 进程,而无需重新启动整个 Docker 容器。
这种模式还允许我们将一门课程与另一门课程隔离,以便课程 A 无法看到课程 B 的内容,反之亦然。为了实现这一点,我们利用绑定安装。创建容器时,ZJUI-Learn 还会在主机上创建一个特殊目录,然后将该目录挂载到容器中的 /course 中。要执行课程 A 的内容,ZJUI-Learn 首先将该课程的目录绑定挂载到容器的主机目录。这是传递给容器的,容器现在将在 /course 处看到该课程的内容。将来,当需要在该容器中执行不同课程的代码时,ZJUI-Learn 将简单地更新绑定挂载以指向另一个课程。
实践中的代码执行¶
到目前为止,这个讨论还相当抽象。但是支撑这些东西的所有实际代码又如何呢?不要害怕,亲爱的读者,我们没有忘记这一点!
代码调用者¶
_代码调用者_充当上述不同执行模式之上的抽象,并隐藏代码执行方式的实现细节。当前有两种不同类型的代码调用程序,此处通过其实现的文件名进行引用。
lib/code-caller-container根据container执行模式的要求处理 Docker 容器内部的执行代码。- 此执行模式仅在 Linux 上受支持,因为它依赖于 Docker 转发绑定挂载的能力,而 macOS 上未实现此功能。
lib/code-caller-native按照native执行模式的要求直接处理执行 Python 进程。
这些调用者的主要外部接口是 call() 函数,该函数采用五个参数:
type:正在执行的代码类型(question、course-element或core-element)。directory:包含将执行其代码的文件的目录。- 对于问题,这是课程
questions目录中的一项。 - 对于课程元素,这是课程
elements目录中的一项。 - 对于核心元素,这是 ZJUI-Learn 的
elements目录中的一项。
- 对于问题,这是课程
file:将执行其代码的文件的名称(例如server.py)fcn:file中将被执行的函数的名称(例如grade或render)。args:被调用函数的 JSON 可编码参数数组。
要执行的代码段由 (type, directory, file) 指定,而不是绝对路径,因为磁盘上每个文件的位置可能在每种类型的代码调用者之间发生变化;允许代码调用者根据该信息构造路径,使使用调用者的代码与正在使用的底层调用者无关。
完整的请求周期¶
让我们演练一个典型的请求,以查看需要相应 server.py 文件中的函数才能运行的问题。
- 页面请求由
pages/studentInstanceQuestion或类似的处理。 - 该处理程序在
lib/question中调用getAndRenderVariant(如果用户提交答案,则会调用不同的函数)。 - 该函数调用一个内部函数,该内部函数调用
question-servers/freeform.js中的render。 - 该函数调用
lib/workers中的withCodeCaller。取决于活动的执行模式- 如果在
container模式下运行,lib/code-caller-container调用者将为当前过程“准备”(设置必要的绑定安装)并返回。 - 如果在
native模式下运行,则将返回任何可用的lib/code-caller-native调用者。
- 如果在
- 然后,
call(...)在代码调用者上重复调用,并执行适当的代码片段。 - 一旦在此请求期间不再需要代码调用方,就会对其调用
done()。分叉的worker会被发送restart消息,这将导致worker退出并将控制权返回给zygote。然后,受精卵将再次分叉自身,分叉的工作线程将等待,直到收到更多指令。 - 页面渲染完成并发送响应,从而完成请求周期。
生产运营¶
部署新版本的 ZJUI-Learn 时,确保计算机上存在与正在部署的版本相应的执行程序映像非常重要。这确保了 ZJUI-Learn 能够立即提供流量,而不是等待新版本被拉取。该映像将发布为 prairielearn/executor:GIT_HASH,其中 GIT_HASH 是正在部署的 Git 提交的 SHA-1 哈希值。
ZJUI-Learn 将自动确定运行时使用的执行器映像的正确版本。但是,在紧急情况下,可以将 ZJUI-Learn 配置为使用特定图像和标签。适当设置 workerExecutorImageRepository 和/或 workerExecutorImageTag 配置选项。然后,确保图像存在于机器上。最后,部署更新的配置并重新启动服务器;新的请求将在指定的容器版本中执行。
如果设置了 cacheImageRegistry 选项并且未设置 workerExecutorImageRepository,则 ZJUI-Learn 将使用该注册表中的映像。如果您指定 workerExecutorImageRepository 并希望使用特定注册表中的映像,则应确保该注册表包含在 workerExecutionImageRepository 值中。