感谢 来笙云 Laysense.cn 提供算力
作为一个全栈工程师,开发、维护和测试一些软件系统时,必然会涉及到多种编程语言,有时还需要测试一些编程语言的安全特性,常常需要敏捷地了解它们并立即上手。在朋友的推荐和社区分享下,我了解到一个名叫 glot.io 的开源项目。
它支持40多种编程语言,无论是热门的 Python、Go、Rust、Kotlin,还是冷门的 COBOL、Erlang、Haskell 只需在网页上选择对应的语言,即可开始编写。为使用者提供了一个 Sandbox(沙箱)和 Playground(游乐场)环境,既不需要配置它们的 Runtime(运行环境)和 IDE(集成开发环境),也不需要担心误操作对系统产生破坏性,还不会占用任何用户端的系统资源,实现真正的零开销运行代码。
在代码编写界面,可以创建多个源码文件,完成后点击Run
就能执行它并得到输出,类似我们平时编程那样,将输出打在终端上。整个过程不会生成任何可执行文件,所以它的应用场景不是在线编译,而是在线运行代码片段。
glot.io 这个网站提供了公开的代码片段执行和分享功能,任何人在注册后都可以分享自己的代码片段,并使用它的 API。但有时为了安全性和访问速度考量,需要自行搭建这个开源平台,这篇文章将介绍 Glot 的私有化部署。
Glot是什么
根据项目 README 上的一句介绍:
an open source pastebin with runnable snippets and API.
这是一个开源的共享剪切板和代码片段执行器,并提供 API。它使用 MIT 协议开源,代码托管于 github 之上。
https://github.com/glotcode/glot
Glot 并不是一个独立的工程,它分为多个组件,这样设计底层架构有利于业务解耦,降低后期维护和升级开发的难度,它们之间的逻辑关系如下:
由下面这些组件构成,也全部使用 MIT 协议开源,均托管于 Github:
- glot-www:提供 B/S 前端应用
- docker-run:提供执行 glot-images 镜像能力的微服务
- glot-images:按需构建的执行器镜像
- code-runner:容器内的执行调度器
其中 glot-www 是一个 B/S 架构应用的服务器,用来提供一个面向用户的 WebUI(网站),它包含前后端的组件,后端使用 Haskell 语言编写。实现代码片段保存和共享、用户登录、以及共享剪切板所的功能,由 pgSQL 提供存储支持。与此同时,它与实际的代码执行业务互相解耦,使用 RestAPI 进行 RPC 调用,可做到前端服务器和后端代码执行服务器逻辑上隔离。
Glot 的代码执行沙箱基于 Docker,在容器中编译和运行,不但与宿主机隔离,且容器之间也相互隔离,还能对运行资源进行限制,防止宿主机被不信任的代码破坏。当然,各编程语言的执行容器构成不尽相同,这样才能在节约存储空间的同时最大保持运行效率,比如 C 和 C++ 共用了glot/clang
这个镜像,C# 和 F# 的镜像都有 mono 这个依赖……这些 Docker 镜像由 glot-images 项目进行生成,它并非使用传统的 Dockerfile,而是使用了 nix 进行构建,支持多种主流编程语言。
宿主机与沙箱的通讯,实际上就构建并将代码传入容器。这个传递方式不使用文件,而使用 stdi(基本输入)的方式传递 json,例如这样的形式:
echo '{
"language": "python",
"files": [
{
"name": "main.py",
"content": "print(42)"
}
]
}' | docker run --rm -i --read-only --tmpfs /tmp:rw,noexec,nosuid,size=65536k --tmpfs /home/glot:rw,exec,nosuid,uid=1000,gid=1000,size=131072k -u glot -w /home/glot glot/javascript:latest
执行完成之后以 stdo(基本输出)的方式输出 json,stdout、stderr 流、以及错误信息在序列化后拆分成各个字段:
{
"stdout": "42\n",
"stderr": "",
"error": ""
}
一般编程语言分为编译型、解释型和虚拟机型,其中解释型直接执行文本文件中的内容,编译型则需将其编译为可执行文件再执行,而虚拟机型在编译完之后,还需用 vm 执行字节码。glot-images 将各类编程语言生成的工作流统一归做 json 格式的文本流,这样标准化更利于开发和扩展,这种能力归功于 code-runner 这个组件。
code-runner 作为 glot 的一个特殊组件,并不运行在宿主机中,它是一个 cli 工具,运行在执行容器中,使用 Rust 语言开发。在 glot-images 的每个镜像中,均以相同方式工作在底层。它支持多种编程语言从编译到运行的生命周期管理,同时接管运行时的 stdio(基本输入输出),例如 C 语言,首先会将输入的文本反序列化,写入到文件,接着调用 clang 编译这个文件,最后再运行编译器生成的可执行文件,执行过程中也会将预定义的 stdi 发送给程序,程序的 stdo/stderr 流被它记录下来随后序列化为 json 文本返回。实际上在使用docker run
这类命令执行 glot-images 镜像时,就是调用了之中的 code-runner,而不是调用了clang
这种编译器。
使用 stdi 传递 json 给它,就会调用相应的编译执行流程:
echo '{
"language": "python",
"files": [
{
"name": "main.py",
"content": "print(42)"
}
]
}' | code-runner
与 glot-images 的镜像相同,执行后也会使用 stdo 以 json 格式返回:
{
"stdout": "42\n",
"stderr": "",
"error": ""
}
要将这些跑在 Docker 上的执行器服务化、RPC(远程过程调用)化,必须有一个 daemon 在底层进行调度,一边开放 HTTP 服务,另一边通过 unix socket 操纵 DockerEngine,执行容器操作。提供这个能力的就是 docker-run 组件,它也使用 Rust 语言开发。
例如这样访问 docker-run,和上文中的例子相同:
curl 'http://localhost:8088/run' \
-H 'X-Access-Token: some-secret-token' \
-H 'Content-type: application/json' \
-d '{
"image": "glot/python:latest",
"payload": {
"language": "python",
"files": [
{
"name": "main.py",
"content": "print(42)"
}
]
}
}'
有了这些组件,就可以自行私有化搭建一个 glot 服务,因为各组件的标准化和解耦,可以随意进行裁剪和二次开发。
接下来将介绍 docker-run 和 glot-images 这两个基本组件的搭建(不搭建前端 WebUI 和共享服务)。
Glot服务搭建
首先应该准备一台性能不错的服务器,要求 CPU 核心数和 RAM 不能太低。以下步骤使用 Debian 12 系统进行操作,整个过程需要有稳定的网络环境,并且已更新包管理器的索引。
安装Docker和运行环境
首先需要安装前置依赖,其中 git 和 gcc 安装 Rust 时需要,runsc 是 gVisor 运行环境
sudo apt-get install ca-certificates curl git gcc runsc
安装 Docker,这里参考了官方文档的安装方式,先进行软件源的添加,再安装各组件
https://docs.docker.com/engine/install/debian/
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
gVisor 作为谷歌开源的一款轻量容器运行时沙箱,可作为 Docker 的运行时中间件,隔离容器内的 syscall,提升容器安全性,具体可以参考官网 https://gvisor.dev
对于 Docker,需要配置 gVisor 为 DockerEngine 插件,创建配置文件后写入以下内容:
vi /etc/docker/daemon.json
{
"default-runtime": "runsc",
"runtimes": {
"runsc": {
"path": "/usr/bin/runsc"
}
}
}
在修改配置文件后,应重启 DockerEngine
systemctl restart docker
可以使用以下命令检查检查 Docker 和 gVisor 安装状态
docker system info
docker system info | grep 'runsc'
接者我们创建名为glot
的用户,作为 daemon 的运行角色
useradd -m glot
usermod -aG docker glot
安装 Rust,这里参考官方文档,使用脚本进行安装
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
安装完毕后,可以使用以下命令检查 Rust 的安装
cargo -V
因官方的 crates 源速度很慢,如果国内使用可以换为镜像源,这里使用了 SJTU 镜像
vi ~/.cargo/config.toml
编译docker-run
克隆 docker-run 项目,准备使用源码进行编译安装
git clone https://github.com/glotcode/docker-run glot-docker-run
进入仓库目录,使用 cargo 编译 Rust 工程
cd glot-docker-run
cargo b -r
检查编译结果,在工程目录的target/release
中将会生成名为docker-run
的可执行文件
复制可执行文件和 systemd 服务模板
cp target/release/docker-run /home/glot/bin/
cp systemd/docker-run.service /etc/systemd/system/
docker-run 服务使用 systemd 进行托管,作为 daemon 运行,它对外提供一个 http 服务,其他应用使用 RestAPI 与之对接
编辑 systemd 服务配置文件
vi /etc/systemd/system/docker-run.service
docker-run 的配置文件全部为环境变量,一些重要的参数已经给出了注释
其中SERVER_LISTEN_ADDR
和SERVER_LISTEN_PORT
决定了 daemon 监听的 ip 和端口号,可以根据需求修改
SERVER_WORKER_THREADS
为 worker 线程数,根据实际业务并发量修改,即越多可同时执行的任务越多
API_ACCESS_TOKEN
是 RestAPI 的访问 Token,设定一个较复杂的值,可防止未授权访问,在调用中以 HTTP 请求 Header 的X-Access-Token
字段进行传递
RUN_MAX_EXECUTION_TIME
参数用来限制任务执行的超时时间,其单位为秒,如果一个任务大于这个时间没有执行完毕,docker-run 就会销毁这个容器,并会返回一个 400 错误
RUN_MAX_OUTPUT_SIZE
参数是用来限制最大输出量的,它的单位是 Byte,如果输出的内容过大,同样会被丢弃并报错
这些参数的详细配置也可以参考 docker-run 项目的 README
[Unit]
Description=docker-run
[Service]
User=glot
Group=glot
Restart=always
RestartSec=10
ExecStart=/home/glot/bin/docker-run
# 服务绑定 ip
Environment="SERVER_LISTEN_ADDR=0.0.0.0"
# 服务监听端口
Environment="SERVER_LISTEN_PORT=8088"
# worker 线程数
Environment="SERVER_WORKER_THREADS=10"
# API Token
Environment="API_ACCESS_TOKEN=some-secret-token"
# Docker socket 路径
Environment="DOCKER_UNIX_SOCKET_PATH=/var/run/docker.sock"
Environment="DOCKER_UNIX_SOCKET_READ_TIMEOUT=3"
Environment="DOCKER_UNIX_SOCKET_WRITE_TIMEOUT=3"
# 容器主机名
Environment="DOCKER_CONTAINER_HOSTNAME=glot"
# 容器用户
Environment="DOCKER_CONTAINER_USER=glot"
# 容器最大内存限制
Environment="DOCKER_CONTAINER_MEMORY=1000000000"
# 容器内是否禁用网络支持
Environment="DOCKER_CONTAINER_NETWORK_DISABLED=true"
Environment="DOCKER_CONTAINER_ULIMIT_NOFILE_SOFT=90"
Environment="DOCKER_CONTAINER_ULIMIT_NOFILE_HARD=100"
Environment="DOCKER_CONTAINER_ULIMIT_NPROC_SOFT=90"
Environment="DOCKER_CONTAINER_ULIMIT_NPROC_HARD=100"
Environment="DOCKER_CONTAINER_CAP_DROP=MKNOD NET_RAW NET_BIND_SERVICE"
Environment="DOCKER_CONTAINER_READONLY_ROOTFS=true"
Environment="DOCKER_CONTAINER_TMP_DIR_PATH=/tmp"
Environment="DOCKER_CONTAINER_TMP_DIR_OPTIONS=rw,noexec,nosuid,size=65536k"
# 容器工作目录
Environment="DOCKER_CONTAINER_WORK_DIR_PATH=/home/glot"
Environment="DOCKER_CONTAINER_WORK_DIR_OPTIONS=rw,exec,nosuid,size=131072k"
# 容器执行超时时间
Environment="RUN_MAX_EXECUTION_TIME=15"
# 最大允许输出
Environment="RUN_MAX_OUTPUT_SIZE=100000"
# 日志级别
Environment="RUST_LOG=debug"
[Install]
WantedBy=multi-user.target
修改完配置文件就可启动服务了,并将它设为开机自启
systemctl daemon-reload
systemctl enable --now docker-run.service
GET 请求刚才配置的那个地址的根路径,测试服务运行状态正常,我这里是 http://localhost:8088/
拉取Docker镜像
glot-images 构建了各编程语言的执行镜像,这些镜像使用 nix 构建,但因为 nix 的配置比较复杂,且占用存储空间巨大,这里直接使用上传在 DockerHub 的镜像了(弊端就是语言版本比较旧)
通过 docker pull
命令拉取各个镜像,可以按照自己的需求拉取,比如你只需要执行某几个编程语言
docker pull glot/assembly
docker pull glot/ats
docker pull glot/bash
docker pull glot/clang
docker pull glot/clisp
docker pull glot/clojure
docker pull glot/cobol
docker pull glot/coffeescript
docker pull glot/crystal
docker pull glot/csharp
docker pull glot/dart
docker pull glot/elixir
docker pull glot/elm
docker pull glot/erlang
docker pull glot/fsharp
docker pull glot/golang
docker pull glot/groovy
docker pull glot/guile
docker pull glot/hare
docker pull glot/haskell
docker pull glot/idris
docker pull glot/java
docker pull glot/javascript
docker pull glot/julia
docker pull glot/kotlin
docker pull glot/lua
docker pull glot/mercury
docker pull glot/nim
docker pull glot/nix
docker pull glot/ocaml
docker pull glot/pascal
docker pull glot/perl
docker pull glot/php
docker pull glot/python
docker pull glot/raku
docker pull glot/ruby
docker pull glot/rust
docker pull glot/sac
docker pull glot/scala
docker pull glot/swift
docker pull glot/typescript
docker pull glot/zig
这些全部拉取下来大概需要38GB,可以使用docker images
命令检查拉取情况
如果已经拉取了所有的镜像,可以执行单元测试脚本,来验证各编程语言执行容器的正确性,在 docker-run 的目录下的scripts
目录内
cd glot-docker-run/scripts/
./test_glot.sh 'http://localhost:8088' 'some-secret-token'
使用Glot服务
docker-run 这个组件对外提供 RestAPI 接口,其他进程或者其他主机可以直接调用,它共有三个功能对应其路径:
功能 | 方式 | 路径 | 是否需要 Token |
---|---|---|---|
查看服务状态 | GET | / | 否 |
获取 Docker 版本 | GET | /version | 是 |
执行代码 | POST | /run | 是 |
查询服务状态
curl http://localhost:8088/
返回 daemon 的服务名、版本等
{
"name": "docker-run",
"version": "1.4.0",
"description": "Api for running code in transient docker containers"
}
查询宿主机 DockerEngine 信息,访问这个接口需要在请求 Header 的X-Access-Token
字段中携带 Token
curl http://localhost:8088/version \
-H 'X-Access-Token: some-secret-token'
执行成功将会返回 DockerEngine 的版本信息
{
"docker": {
"version": "26.1.2",
"apiVersion": "1.45",
"gitCommit": "ef1912d",
"goVersion": "go1.21.10",
"os": "linux",
"arch": "amd64",
"kernelVersion": "6.2.16-3-pve",
"buildTime": "2024-05-08T13:59:59.000000000+00:00",
"platform": {
"name": "Docker Engine - Community"
},
"components": [
{
"name": "Engine",
"version": "26.1.2"
},
{
"name": "containerd",
"version": "1.6.31"
},
{
"name": "runsc",
"version": "0.0~20221219.0"
},
{
"name": "docker-init",
"version": "0.19.0"
}
]
}
}
执行响应的代码前,需要构建一个 json 请求体,用来描述创建的执行任务的行为,下表是它的定义:
字段名 | 类型 | 说明 | 备注 |
---|---|---|---|
image | str | 执行镜像名 | 需要创建的 Docker 镜像 tag,根据宿主机上存储的镜像决定 |
payload | obj | 提交给 golt-image 的负载 | 见下表 |
payload 结构:
字段名 | 类型 | 说明 | 备注 |
---|---|---|---|
language | str | 语言类型 | 决定 code-runner 的编译和运行行为,支持的语言见 code-runner 的 README,如c 、python |
files | array | 程序源码文件 | 为一个数组,可以输入多个文件对象,见下表 |
文件对象的结构:
字段名 | 类型 | 说明 | 备注 |
---|---|---|---|
name | str | 文件名 | 大多数编程语言的主文件必须为main ,如main.c 或者main.py |
content | str | 文件数据 | 代码文件内的文本 |
eg:
{
"image": "glot/python:latest",
"payload": {
"language": "python",
"files": [
{
"name": "main.py",
"content": "print(42)"
}
]
}
}
访问这个接口也需要在请求 Header 中携带 Token:
curl 'http://localhost:8088/run' \
-H 'X-Access-Token: some-secret-token' \
-H 'Content-type: application/json' \
-d '{
"image": "glot/python:latest",
"payload": {
"language": "python",
"files": [
{
"name": "main.py",
"content": "print(42)"
}
]
}
}'
我们可以直接将它与自己熟悉的编程语言对接,实现给应用或者平台提供运行任意代码的能力。
这里使用 Python 通过 RestAPI 调用 glot(docker-run),实现运行一段 rust 代码并取回输出为字符串:
import requests
def run_code(image, lang, file_name, code):
resp = requests.post(
url="http://localhost:8088/run",
headers={
"X-Access-Token": "some-secret-token",
},
json={
"image": image,
"payload": {
"language": lang,
"files": [
{
"name": file_name,
"content": code,
},
],
},
},
)
json_content = resp.json()
return json_content
image = "glot/rust:latest"
lang = "rust"
file_name = "main.rs"
code = """
fn main() {
for i in 1..=9 {
for j in 1..=i {
print!("{}x{}={:2} ", j, i, j * i);
}
println!();
}
}
"""
result = run_code(image, lang, file_name, code)
print(result["stdout"])
测试可以正确输出执行内容
有了通用接口的能力,我们就有了将其集成进自己平台的可能,只要发挥创造力,就可以围绕在线执行代码提供相关的业务,或者作为微服务连接上游的业务,比如搭建 OJ(在线判题)平台等。以及 glot 项目以 MIT 协议开源,这意味着我们可以随意修改底层代码,比如增加网络和共享路径支持、增加第三方库等。总之,这是一个完成度很高、十分推荐的开源项目。
由 来笙云 Laysense.cn 强力支持