让我们构建一个代码执行引擎
#编程 #go #code #distributed

您是否曾经想过,当您在Go Playground或Abiaoqian等在线开发环境中击中“运行”时,幕后会发生什么?

通过遵循,在本文的结尾,您将拥有前端和后端,以实现非常类似的实现:

screenshot

如果您只想查看代码,则可以找到here

重要的是要注意,尽管骨骼,但实现绝不是“玩具”。我们将解决建立此类平台核心要求的最重要注意事项。即:

  1. 安全性 - 我们让用户在服务器上执行任意代码,因此我们需要一种隔离代码执行的方法,以便尽可能限制滥用的可能性。

  2. 可伸缩性 - 我们需要一种随着用户数量的增长而扩展系统的方法。

  3. 限制 - 我们想限制为给定代码执行分配的资源量,以便它不征税我们的服务器以及伤害其他用户的经验。

我们要使用什么

为了解决上述所有注意事项,我们将使用Tork为我们做所有繁重的举重。

简而言之,我一直在为Abiaoqian工作。

它使用docker容器来执行工作流程任务,该任务解决了点1和第3点 - 我们将在一分钟内确切查看。

它还支持分布式设置,以将任务处理扩展到任意数量的工人节点,以解决点#2。

我们可以采用两种方法:

  1. Download并安装香草tork并写一项新的薄API服务,该服务将位于客户和托克之间 - 因为我们不一定想将托克的本地API暴露于它们,以便对他们进行严格的控制将哪些参数发送到tork。这种方法的优点是我可以用我选择的语言编写“中间件”服务器。

  2. Extend Tork公开我们的自定义API端点并禁用所有其他端点。这需要了解进行编程的知识。

出于此演示的目的,我将使用选项#2。

好,让我们写一些代码

您需要:

  1. Docker安装在正在运行演示的计算机上。
  2. Golang >= 1.19+

为项目创建一个新目录:

mkdir code-execution-demo
cd code-execution-demo

初始化项目:

go mod init example.com/code-execution-demo

获得tork依赖性:

go get github.com/runabol/tork

在项目的根部创建一个main.go文件,并使用启动tork所需的最小样板:

package main

import (
    "fmt"
    "os"

    "github.com/runabol/tork/cli"
    "github.com/runabol/tork/pkg/conf"
)

func main() {
   // Load the Tork config file (if exists) 
   if err := conf.LoadConfig(); err != nil {
     fmt.Println(err)
     os.Exit(1)
   }

   // Start the Tork CLI
   app := cli.New()
   if err := app.Run(); err != nil {
     fmt.Println(err)
     os.Exit(1)
   }
}

开始tork:

go run main.go

如果一切顺利,您应该看到这样的东西:

 _______  _______  ______    ___   _ 
|       ||       ||    _ |  |   | | |
|_     _||   _   ||   | ||  |   |_| |
  |   |  |  | |  ||   |_||_ |      _|
  |   |  |  |_|  ||    __  ||     |_ 
  |   |  |       ||   |  | ||    _  |
  |___|  |_______||___|  |_||___| |_|
...

让我们使用ConfigureEngine钩注册我们的自定义端点:

package main

import (
    "fmt"
    "net/http"
    "os"

    "github.com/runabol/tork/cli"
    "github.com/runabol/tork/pkg/conf"
    "github.com/runabol/tork/pkg/engine"
    "github.com/runabol/tork/pkg/middleware/request"
)

func main() {
    // removed for brevity

    app.ConfigureEngine(func(eng *engine.Engine) error {
        eng.RegisterEndpoint(http.MethodPost, "/execute", handler)
        return nil
    })

    // removed for brevity
}

func handler (c request.Context) error {
  return c.String(http.StatusOK, "OK")
}

standalone(未分布)模式启动Tork:

go run main.go run standalone

从另一个终端窗口调用新端点:

% curl -X POST http://localhost:8000/execute
OK

到目前为止很好。

让我们假设客户端将向我们发送以下JSON对象:

{
  "language":"python|bash|go|etc.",
  "code":"the source code to execute"
}

让我们写一个可以将这些值绑定到:
的结构

type ExecRequest struct {
    Code     string `json:"code"`
    Language string `json:"language"`
}
func handler(c request.Context) error {
  req := ExecRequest{}
  if err := c.Bind(&req); err != nil {
    c.Error(http.StatusBadRequest, err)
    return nil
  }  

  return c.JSON(http.StatusOK,req)
}

在这一点上,我们只需回应请求回到用户。但这是一个很好的垫脚石,可以确保具有约束力的逻辑有效。让我们尝试一下:

% curl -X POST -H "content-type:application/json" -d '{"language":"bash","code":"echo hello world"}' http://localhost:8000/execute

{"code":"echo hello world","language":"bash"}

好吧,接下来我们需要将请求转换为tork任务:

func buildTask(er ExecRequest) (input.Task, error) {
        var image string
        var run string
        var filename string

        switch er.Language {
        case "":
                return input.Task{}, errors.Errorf("require: language")
        case "python":
                image = "python:3"
                filename = "script.py"
                run = "python script.py > $TORK_OUTPUT"
        case "go":
                image = "golang:1.19"
                filename = "main.go"
                run = "go run main.go > $TORK_OUTPUT"
        case "bash":
                image = "alpine:3.18.3"
                filename = "script"
                run = "sh ./script > $TORK_OUTPUT"
        default:
                return input.Task{}, errors.Errorf("unknown language: %s", er.Language)
        }

        return input.Task{
                Name:    "execute code",
                Image:   image,
                Run:     run,
                Files: map[string]string{
                        filename: er.Code,
                },
        }, nil
}

所以我们在这里基本上在这里做三件事:

  1. language字段映射到docker图像。
  2. 根据languagecode写入容器中的适当文件。
  3. 运行必要的命令以在容器中执行代码。

让我们在处理程序中使用它:

task, err := buildTask(req)
if err != nil {
  c.Error(http.StatusBadRequest, err)
  return nil
}

最后,让我们提交工作:

input := &input.Job{
  Name:  "code execution",
  Tasks: []input.Task{task},
}   

job,err:= c.SubmitJob(input)
if err != nil {
  return err
}

fmt.Printf("job %s submitted!\n", job.ID)   

让我们尝试运行更新的处理程序:

go run main.go run standalone
curl -X POST -H "content-type:application/json" -d '{"language":"bash","code":"echo hello world"}' http://localhost:8000/execute

如果一切顺利,您应该在日志中看到类似的东西:

job 5488620e9bc34e09b6ec3677ea28a067 submitted!

接下来,我们要获取执行输出,以便我们可以将其返回给客户端。但是,由于tork不同步运行,我们需要一种告诉托克的方法,让我们知道工作已完成(或失败)。

这是JobListener的来源:

result := make(chan string)

listener := func(j *tork.Job) {
  if j.State == tork.JobStateCompleted {
    result <- j.Execution[0].Result
  } else {
    result <- j.Execution[0].Error
  }
}

// pass the listener to the submit job call
job, err := c.SubmitJob(input, listener)
if err != nil {
  return err
}

return c.JSON(http.StatusOK, map[string]string{"output": <-result})

由于作业侦听器没有在“主”线程/goroutine中执行,因此我们需要一种将其传递回主线程的方法。幸运的是,Golang拥有这个真正方便的东西,称为channel

好吧,让我们看看这是否有效:

curl -X POST -H "content-type:application/json" -d '{"language":"bash","code":"echo hello world"}' http://localhost:8000/execute
{"output":"hello world\n"}

很好!

安全

让我们更新我们的Task定义以执行更严格的安全性约束:

input.Task{
  Name:  "execute code",
  Image: image,
  Run:   run,
  Limits: &input.Limits{
    CPUs:   ".5", // no more than half a CPU
    Memory: "20m", // no more than 20MB of RAM
  },
  Timeout:  "5s", // terminate container after 5 seconds
  Networks: []string{"none"}, // disable networking
  Files: map[string]string{
  filename: er.Code,
}

让我们禁用托克的内置端点:

在项目根部创建一个名为config.toml的文件,并具有以下内容:

# config.toml
[coordinator.api]
endpoints.health = true
endpoints.jobs = false
endpoints.tasks = false
endpoints.nodes = false
endpoints.queues = false
endpoints.stats = false

现在启动项目时,您应该看到tork拿起配置:

% go run main.go run standalone          
7:08PM INF Config loaded from config.tom
...

前端

让我们尝试让前端与我们的后端交谈:

git clone git@github.com:runabol/code-execution-demo.git
cd code-execution-demo/frontend
npm i
npm run dev

http://localhost:3000

扩大

我们要解决的最后一点是可伸缩性。

有很多方法可以调整tork的可伸缩性,但是为了我们的目的,我们将保持简单,并竭尽全力启动RabbitMQ经纪人,这将使我们能够分发任务处理:

启动兔子经纪人。示例:

docker run \
  -d \
  --name=tork-rabbit \
  -p 5672:5672 \
  -p 15672:15672 \
  rabbitmq:3-management

接下来,将以下内容添加到您的config.toml

[broker]
type = "rabbitmq"

[broker.rabbitmq]
url = "amqp://guest:guest@localhost:5672"

如果当前正在运行,请停止tork。

启动托克协调员:

go run main.go run coordinator

从单独的终端窗口启动一个工人。如果您愿意,您也可以启动其他工人:

go run main.go run worker

使用curl或前端尝试提交代码片段。

结论

希望您和我一样喜欢本教程。

可以在Github上找到完整的源代码。