Elaina —— 基于 Docker 的远程代码运行器

Elaina —— 基于 Docker 的远程代码运行器

编程那点事 随便写写 Go 5463 字 / 12 分钟

文章头图 PixivID: 85439235 @yuurei0

这几天过年,难得能闲下来做点自己的事情。前一阵子事情挺多的,博客更新也断了将近三个月。 之前在 LeanCloud 的官网看到他们有一个使用各种语言调用 SDK 的示例代码。

不过这些代码并不能实时运行。我又想起以前写 Apicon 时,曾有在 API 详细页加入可实时运行的示例代码的想法。那么说干就干,写一个实时代码运行器吧!

就这样,Elaina 诞生了! [runtime lang=“php” height=“300px”]

<?php
	echo('Hello Elaina!');

[/runtime]

欢迎大家的 star✨ wuhan005/Elaina

そう、私です ——没错,就是我

至于为什么叫 Elaina 这个名字嘛…… 一月份的时候补完了《魔女之旅》动画,顿时爱上了腹黑贪财可爱的屑魔女伊蕾娜😝。之后又抽时间去补了下小说,这种无关联的短篇故事我很喜欢,像一则则预言,读完后耐人寻味;使我回想起小时候看过的《小王子》,好像也是架空世界观下的各种稀奇古怪的故事,背后总会暗喻些小道理啥的。

嘛…… 好像有些偏题了,回到正题上来。

做一个代码运行器,我最初的想法就是在 Docker 容器里执行代码。只要硬件资源,网络访问等限制做好,就很难有安全问题。 我所担心的是临时启动、执行、销毁 Docker 容器的速度会不会很慢,因为以前自己在 CLI 中操作时,总是需要等上那么几秒钟。但事后实际测试下来,整个过程只用了 0.3 - 0.5 秒不到。并且多次执行所花费的时间不会有太大的波动,尚不清楚 Docker 是不是有缓存之类的东西做了优化(?

我给 Elaina 定义了“模板”与“沙箱”两个概念。“模板”规定了运行环境的编程语言、资源限制、超时时间等通用设置。“沙箱”则是基于单个模板创建的具体运行实例,可以设置其初始的代码。值得一提的是,Elaina 服务本身也是部署于 Docker 内的,因此在环境配置,特别是文件卷配置方面,较易搞混踩坑。

接下来将介绍运行一段代码我们需要做什么。

编写运行环境 Dockerfile

首先,我们需要为所支持的编程语言编写 Dockerfile,来构建其运行环境的 Docker 镜像。Elaina 目前支持 PHP、Python、Go、JavaScript (NodeJS) 四种编程语言。所有的 Dockerfile 你都可以在项目 /docker/images 目录下找到。

所有的镜像都是基于对应编程语言的官方镜像中的 alpine 镜像构建的,简单的创建了个运行目录/runner。alpine 下预装的依赖很少,只有语言基本的运行环境,因此体积也小很多,大多只有 40 - 50 MB。(除了 Go 这个离谱的 golang:1.15-alpine 镜像有 100+ MB)

创建、运行、销毁容器

有了编程语言镜像后,我们需要创建一个容器,将用户输入代码以 Docker 文件卷的形式映射进入容器内,运行容器,获取输出内容,销毁容器。

因为这里是对宿主机的 Docker Daemon 进行操作,因此需要将 /var/run/docker.sock 映射进运行 Elaina 服务的容器内。这样 Docker Go SDK 将会通过这个套接字找到 Client 并与之交互。Elaina 容器本身不需要是特权容器。

接下来使用 Docker SDK 创建容器 /internal/task/task.go#L90-L109

resp, err := client.ContainerCreate(t.ctx,
	&container.Config{
		Image: t.runner.Image,
		Cmd:   t.runner.Cmd,
		Tty:   false,
	},
	&container.HostConfig{
		NetworkMode: networkMode,
		Mounts: []mount.Mount{
			{
				Type:   mount.TypeBind,
				Source: t.sourceVolume,
				Target: "/runner",
			},
		},
		Resources: container.Resources{
			NanoCPUs: t.template.MaxCPUs * 1000000000,    // 0.0001 * CPU of cpu
			Memory:   t.template.MaxMemory * 1024 * 1024, // Minimum memory limit allowed is 6MB.
		},
	}, nil, nil, t.uuid)

第一个结构体中的 Image 为上一步构建的 Dockerfile 名称与 Tag。Cmd 为容器运行时执行的命令,其实就是各种语言编译+运行的命令,对于 PHP、Python、JavaScript 等解释型语言,就是简单的 php code.php python3 code.py node code.js 这样;而由于 Go 支持 go run 这样的“类似解释型语言的运行方式”,因此是go run code.go

第二个结构体设置了临时容器的网络模式,这里根据定义的模板是否运行接入外网来选择是 bridge 还是 noneMounts 是对临时容器的文件卷配置,我们事先已经将用户传入的代码写入到宿主机上的临时文件夹内,这里对应到新创建的临时容器内的 /runner 目录。即用户传入的代码经历了 Elaina 容器 -> 宿主机 -> 临时容器内。

第三个结构体是对临时容器的 CPU、内存资源进行限制。需要注意的是,Docker 限制了容器最小运行所需内存为 6 MB,这一点需要在用户添加模板时进行验证。

之后就是启动容器了!启动后容器将执行上面 Cmd 中的命令执行我们的用户代码。

okBody, errChan := client.ContainerWait(t.ctx, resp.ID, "")

if t.template.Timeout == 0 {
	t.template.Timeout = 3600
}

timeout := time.NewTimer(time.Duration(t.template.Timeout) * time.Second)
var waitBody container.ContainerWaitOKBody
var errExec error
select {
case waitBody = <-okBody:
	break
case errC := <-errChan:
	errExec = errC
case <-timeout.C:
	errExec = errors.New("execute timeout")
}

启动后对等待容器运行结束,我们在“模板”里设置的最大运行时间将在这里进行判断。这里使用 select 多路复用接收消息即可。可以看到即使不限制最大超时时间(即 t.template.Timeout = 0 ),我还是给加上了个一个小时的超时限制,不然真就跑到死跑不完容器越积越多了。😆

如果代码成功运行直至结束,代码的输出将会被打到 log 里,我们只需要读取容器的 log 输出即可。如果代码因为语法错误编译 / 解释失败, 可以通过 waitBody 中的 StatusCode 属性返回的状态码进行判断。(值不为 0 则有错误)

最后就是收尾工作了~

if err := client.ContainerStop(t.ctx, resp.ID, nil); err != nil {
	log.Error("Failed to stop container: %v", err)
}

if err := client.ContainerRemove(t.ctx, resp.ID, types.ContainerRemoveOptions{
	RemoveVolumes: true,
	Force:         true,
}); err != nil {
	log.Error("Failed to remove container: %v", err)
}

err = os.RemoveAll(t.sourceVolume)
if err != nil {
	log.Error("Failed to remove volume folder: %v", err)
}

暴力地停止容器,暴力地强制删除容器,暴力地删除临时文件卷,一气呵成。

至此,整个代码的运行过程完美结束。

如何方便的调用?

Elaina 真正的用途应该是通过 iframe 嵌入到第三方网页的内容中,并运行指定的代码。

这里我编写了一个简陋的 JavaScript 前端库,用来比较方便的将 Elaina 嵌入第三方网页。这里有一个问题 —— 第三方网页如何将大量的代码传入到 iframe 中不同域下的 Elaina 页面呢?直接通过 URL 中的 GET 参数传递肯定不行。这里使用了 postMessage 来实现安全跨域通信。第三方网页将代码使用 postMessage 发送至 iframe 中的 Elaina 页面;而 Elaina 页面添加一个接收 "message" 事件的监听器即可。

比较有意思的是,以上操作中,容易产生安全问题的是发送方,也就是第三方网站,而非接收方 Elaina。发送方可以通过加上origin选项来限制能够接受消息的域。以防止敏感信息被页面窗口中其它不同的域所接收。

值得一提的是,Bilibili 的用户登录表单,也是通过 iframe 的形式嵌入进网页内,在 iframe 内登录成功后,会跳转到一个空白的页面 https://passport.bilibili.com/ajax/miniLogin/redirect;页面中使用 postMessage 向所有域发送 success 消息。外部的窗口在接收到该消息后,即表明 iframe 内用户已登录成功,从而跳转 / 刷新页面。

<!-- 蛤蛤蛤蛤蛤蛤蛤蛤蛤谁也看不到我 -->
<script>
    window.parent.postMessage('success', '*');
</script>
<!-- 楼上疯了 -->

这个注释还真有 B站 的风格啊 😆

坑!坑!坑!

上面这些主要功能其实很快就写完了,后面很长的一段时间都是在踩各种坑,凄惨……

限流

每个沙箱都存在单个 IP 最大创建容器以及总容器的限制。已有的基于漏斗或者令牌桶的中间件并不适用于这个场景。我想要的是某个时刻内对最大容器数量的限制,而不是一段时间内对数量的限制。同时现有的令牌桶中间件对于每个限流的操作需要创建一个单独的实例,那么我就需要手动维护一个 map,存储多个令牌桶的实例,来实现不同 IP、不同模板下的限流。这将变得十分繁琐。

其实上面提到的这个需求并不难,上个 Redis 三两下就搞定了。换句话说只要给我提供个 kv 数据库就行。这时我想到了 go-cache 这个缓存库。我可以将各个 IP、模板 UID 作为 Key,Value 为它们当下所创建的容器数。但 go-cache 设计的初衷毕竟是用来做缓存,对于 Value 的 Increase 方法,如果 Key 不存在,它将会返回 error。因此需要先判断某个 Key 是否存在,不存在则设置它的 Value 为 0,存在则 Value + 1。但 go-cahce 却没有类似 GetOrSet 这样的原子操作方法。因此执行以上的操作还需要我自己维护一个锁来保证并发场景下操作的原子性……

算了算了,最终我决定自己手撸一个简单的限流。不就两个读写锁的事嘛…… 又不是不能用。代码见/internal/ratelimit/ratelimit.go,后来用 Hey 做压测的时候,还是稳稳地限制住了的,看起来没啥问题(?

Docker 文件卷绝对路径

上文提到了用户输入的代码经历了 Elaina 容器 -> 宿主机 -> 临时容器,因此需要将宿主机上的一个目录 A 映射到 Elaina 容器内,这样才能打通第一步从 Elaina 容器 -> 宿主机。而第二步 宿主机 -> 临时容器 时,是将目录 A 映射进临时容器中;这一步是 Elaina 服务在自己的容器内调用 Docker API 完成的,而 Docker 文件卷必须填写绝对路径,因此 Elaina 服务需要知道目录 A 在宿主机上的绝对路径。我们可以约定好目录 A 的绝对路径就是 /.elaina,但实际使用下来,会遇到创建的临容器无权限访问该目录的问题,即使设置了目录权限为 777,容器内目录所有者 uid 与宿主机所有者 uid 一样似乎也没能解决。因此便约定目录 A 的绝对路径为宿主机当前用户的家(~)目录下,这样创建的目录好像就没有无权访问的问题了。而宿主机用户家目录的绝对路径是 Elaina 在容器内是没有办法得知的。因此目前的方案需要在启动服务时在配置里人工手动输入。

描述地可能比较绕,关于这个问题还有探讨的空间可以仔细研究。

GitHub Public Packages

起初,我通过 GitHub Actions 将 Elaina 打包成 Docker 镜像推送至 GitHub 自带的 Docker 镜像托管仓库。谁知在服务器上拉取时竟让我输入凭证?!

一查才知道,这个问题在 GitHub Community 里已经引发了很多人的不满,大家纷纷认为公开的仓库不应该需要登录才能拉取,但 GitHub Staff 回应目前确实就要这样。然后把问题标记成了已解决?!

这下大家就不乐意了,都认为这是个应该被修复的 bug,但从 2019 年 9 月到现在(2021 年 2 月),事情依旧未被解决。甚至有人开始上升到骂微软的层面了。

在意识到 GitHub Packages 拉取时需要登录后,我决定还是把镜像上传到 DockerHub,同时删除 GitHub Packages 上的镜像。谁知上传到 GitHub Packages 的公开镜像无法被删除。官方的说明是为了避免破坏正在使用该依赖的项目。如果硬要删除,只能联系 GitHub 走 DMCA。真的绝了。

WordPress + WP Editor.md 🙄

这是令我最难受的一个部分。Elaina 部署到线上后,我打算将其接入我的博客中,这样在一些展示了代码的文章中,读者可以直接点击“运行”,实时查看代码执行的结果。起初我的打算是通过编写一个 WordPress 插件,就像之前写友链插件一样,将 Elaina 的 iframe 嵌入到文章中,有以下两种方式:

wp_insert_post_data Hook

如果你观察过 WordPress 数据库中存储文章的 wp_posts 表,你会发现存在 post_contentpost_content_filtered 两个字段。就拿我写博客时用的 Markdown 编辑器而言,我编写文章的时候使用的是 Markdown,最后在前台显示的是渲染过后的 HTML。WordPress 在 post_content 中存储文章渲染过后的内容,在 post_content_filtered 中存储编辑文章时,编辑器内呈现给作者的原内容。

因此我们只需 Hook WordPress 中的 wp_insert_post_data 这个钩子,它在文章被添加或更新时触发。我们在这时修改 post_content 的内容,将文章原文中的代码替换成 Elaina 的 iframe 标签即可。这样前台渲染的内容就变成了嵌入的 Elaina 代码运行器了。

但问题就出在…… 不止我一个人是这么想的!我所用的 Markdown 编辑器插件 —— WP Editor.md,也是使用这个方法。Hook wp_insert_post_data 然后修改 post_content 将 Markdown 内容替换为渲染后的 HTML。这就导致了: 如果我在 WP Editor.md 前触发钩子修改 post_content,之后 WP Editor.md 会直接覆写 post_content 使得我的改动全部丢失。 如果我在 WP Editor.md 后触发钩子修改 post_content,这时代码已经被 WP Editor.md 插入了各种 HTML 标签,甚至有一些带方扩号的代码还被当成 LaTeX 解析成了公式!!

这波进退维谷,前后包抄,左右为难我是没想到的…… 遂放弃这个方案。

ShortTag

ShortTag 即短标签,我之前写的 WordPress 友链插件 Frlink,就是使用短标签嵌入内容。WP Editor.md 也对短标签做了适配,短标签里的内容全都不解析原样输出。但是 WordPress 却会对短标签内容的内容进行处理,对特殊字符进行 HTML 实体化编码,在每行结尾加上<br />标签,多一个空行就给补上一对<p></p>标签等等。短标签处理函数拿到传入的内容后,根本分不清哪些是 WordPress 插入的 HTML 标签,哪些是代码内容里自带的。

$code = str_replace(
        array("<", ">", """, "'", "&", "&", "<", ">", "(", ")", "_", "!", "{", "}", "^", "+", "\"),
        array("<", ">", "\"", "'", "&", "&", "<", ">", "(", ")", "_", "!", "{", "}", "^", "+", "\\\\"),
        $content);
$code = substr(trim($code), 22);
$code = substr($code, 0, strlen($code) - 21);
$code = base64_encode(str_replace("<br />\n", "\n", $code));

现在为了勉强能用,用了上述这种很恶心的前后截断 + 替换的方法,十分的不稳。空行时会有的 <p></p> 标签还未进行处理,目前就是强行要求自己嵌入的代码里不能有空行…… 真的太难了。 我尝试以 RoarCTF Writeup 那篇文章 作为例子,给文章中的 Fuzz PHP 位运算的代码以及 Go 切片内存分配的示例代码上了 Elaina,勉强能用。但是因为不能换行,代码格式惨不忍睹。

总结

至此,关于 Elaina 这个项目的介绍就到这里。这次我还贴心的给她配了英文的 README,希望能有更多的 star 吧哈哈哈。

这个项目今后还有可以提升的空间,我最初的计划里是有自定义 DNS 的功能的,这样我就可以在禁用外网的情况下,将特殊的域名解析到内网服务上。具体适用场景就是作为 Apicon 展示 API 调用时,将 i.apicon.cn 直接解析到部署在内网的 API Gateway IP,这样就算禁掉外网也没有关系了。

同时,代码运行器这东西,稍微改一下不就做出来个 Online Judge 或者在线作业评测平台了嘛?搞不好自己以后真的会写 EggOJ 这种玩意(不是

有一说一,用自己老婆 Elaina 作为项目名真的让人干劲满满啊!写得时候整个人都是心花怒放的,这也归功于 README 中伊蕾娜挥舞魔杖的 GIF 图,真的太可爱了呜呜呜!

回到《魔女之旅》这部作品上来,少部分章节虽然有些虐,但至少主角团没人受伤嘛,那这就不算虐!并且偏百合向的设定也是很棒啊~ 伊蕾娜x扫帚小姐,我嗑这对! 但同时由于每篇故事都很短,很多伏笔很快就回收了,让人多多少少能直接猜到结尾,这让我偶尔觉得有点乏味。不过像第四卷的「忘却之都」「冰封之城」「勇者、飞龙、活人祭品」都在为最后一章「忘却归乡的艾姆妮西亚」做铺垫,虽然单篇故事是独立的,但总能留下些东西到最后一并串起来还是挺让人惊叹的。 有时自己也会有“世界那么大,我想去看看。”的念头,但也只是想想而已啦。毕竟有很多东西眼下是不能放下的。 有时真的感觉自己好无助,被所有人按照他们设想的方向推着前进,却也难找到人倾诉,最后也只能自己一个人找个没人的地方放松一下,默默消化。 今后也还会经历很多不愿意或不顺心的事情,因此不知道什么时候养成了走一步看一步的习惯,人还是要有点规划才行啊 —— 即使是一个人的规划。