感谢 来笙云 Laysense.cn 提供算力

作为一个全栈工程师,开发、维护和测试一些软件系统时,必然会涉及到多种编程语言,有时还需要测试一些编程语言的安全特性,常常需要敏捷地了解它们并立即上手。在朋友的推荐和社区分享下,我了解到一个名叫 glot.io 的开源项目。

它支持40多种编程语言,无论是热门的 Python、Go、Rust、Kotlin,还是冷门的 COBOL、Erlang、Haskell 只需在网页上选择对应的语言,即可开始编写。为使用者提供了一个 Sandbox(沙箱)和 Playground(游乐场)环境,既不需要配置它们的 Runtime(运行环境)和 IDE(集成开发环境),也不需要担心误操作对系统产生破坏性,还不会占用任何用户端的系统资源,实现真正的零开销运行代码。

image.png

在代码编写界面,可以创建多个源码文件,完成后点击Run就能执行它并得到输出,类似我们平时编程那样,将输出打在终端上。整个过程不会生成任何可执行文件,所以它的应用场景不是在线编译,而是在线运行代码片段。

image.png

glot.io 这个网站提供了公开的代码片段执行和分享功能,任何人在注册后都可以分享自己的代码片段,并使用它的 API。但有时为了安全性和访问速度考量,需要自行搭建这个开源平台,这篇文章将介绍 Glot 的私有化部署。

Glot是什么

根据项目 README 上的一句介绍:

an open source pastebin with runnable snippets and API.

这是一个开源的共享剪切板和代码片段执行器,并提供 API。它使用 MIT 协议开源,代码托管于 github 之上。

https://github.com/glotcode/glot

Glot 并不是一个独立的工程,它分为多个组件,这样设计底层架构有利于业务解耦,降低后期维护和升级开发的难度,它们之间的逻辑关系如下:

image.png

由下面这些组件构成,也全部使用 MIT 协议开源,均托管于 Github:

其中 glot-www 是一个 B/S 架构应用的服务器,用来提供一个面向用户的 WebUI(网站),它包含前后端的组件,后端使用 Haskell 语言编写。实现代码片段保存和共享、用户登录、以及共享剪切板所的功能,由 pgSQL 提供存储支持。与此同时,它与实际的代码执行业务互相解耦,使用 RestAPI 进行 RPC 调用,可做到前端服务器和后端代码执行服务器逻辑上隔离。

Glot 的代码执行沙箱基于 Docker,在容器中编译和运行,不但与宿主机隔离,且容器之间也相互隔离,还能对运行资源进行限制,防止宿主机被不信任的代码破坏。当然,各编程语言的执行容器构成不尽相同,这样才能在节约存储空间的同时最大保持运行效率,比如 C 和 C++ 共用了glot/clang这个镜像,C# 和 F# 的镜像都有 mono 这个依赖……这些 Docker 镜像由 glot-images 项目进行生成,它并非使用传统的 Dockerfile,而是使用了 nix 进行构建,支持多种主流编程语言。

image.png

宿主机与沙箱的通讯,实际上就构建并将代码传入容器。这个传递方式不使用文件,而使用 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'

image.png

image.png

接者我们创建名为glot的用户,作为 daemon 的运行角色

useradd -m glot
usermod -aG docker glot

安装 Rust,这里参考官方文档,使用脚本进行安装

https://rustup.rs/

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

image.png

安装完毕后,可以使用以下命令检查 Rust 的安装

cargo -V

image.png

因官方的 crates 源速度很慢,如果国内使用可以换为镜像源,这里使用了 SJTU 镜像

vi ~/.cargo/config.toml

image.png

编译docker-run

克隆 docker-run 项目,准备使用源码进行编译安装

git clone https://github.com/glotcode/docker-run glot-docker-run

进入仓库目录,使用 cargo 编译 Rust 工程

cd glot-docker-run
cargo b -r

image.png

检查编译结果,在工程目录的target/release中将会生成名为docker-run的可执行文件

image.png

复制可执行文件和 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_ADDRSERVER_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/

image.png

拉取Docker镜像

glot-images 构建了各编程语言的执行镜像,这些镜像使用 nix 构建,但因为 nix 的配置比较复杂,且占用存储空间巨大,这里直接使用上传在 DockerHub 的镜像了(弊端就是语言版本比较旧)

https://hub.docker.com/u/glot

image.png

通过 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命令检查拉取情况

image.png

如果已经拉取了所有的镜像,可以执行单元测试脚本,来验证各编程语言执行容器的正确性,在 docker-run 的目录下的scripts目录内

cd glot-docker-run/scripts/
./test_glot.sh 'http://localhost:8088' 'some-secret-token'

image.png

使用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 请求体,用来描述创建的执行任务的行为,下表是它的定义:

字段名类型说明备注
imagestr执行镜像名需要创建的 Docker 镜像 tag,根据宿主机上存储的镜像决定
payloadobj提交给 golt-image 的负载见下表

payload 结构:

字段名类型说明备注
languagestr语言类型决定 code-runner 的编译和运行行为,支持的语言见 code-runner 的 README,如cpython
filesarray程序源码文件为一个数组,可以输入多个文件对象,见下表

文件对象的结构:

字段名类型说明备注
namestr文件名大多数编程语言的主文件必须为main,如main.c或者main.py
contentstr文件数据代码文件内的文本

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"])

测试可以正确输出执行内容

image.png

有了通用接口的能力,我们就有了将其集成进自己平台的可能,只要发挥创造力,就可以围绕在线执行代码提供相关的业务,或者作为微服务连接上游的业务,比如搭建 OJ(在线判题)平台等。以及 glot 项目以 MIT 协议开源,这意味着我们可以随意修改底层代码,比如增加网络和共享路径支持、增加第三方库等。总之,这是一个完成度很高、十分推荐的开源项目。

来笙云 Laysense.cn 强力支持