您是否曾经想过,当您在Go Playground或Abiaoqian等在线开发环境中击中“运行”时,幕后会发生什么?
通过遵循,在本文的结尾,您将拥有前端和后端,以实现非常类似的实现:
如果您只想查看代码,则可以找到here。
重要的是要注意,尽管骨骼,但实现绝不是“玩具”。我们将解决建立此类平台核心要求的最重要注意事项。即:
-
安全性 - 我们让用户在服务器上执行任意代码,因此我们需要一种隔离代码执行的方法,以便尽可能限制滥用的可能性。 p>
-
可伸缩性 - 我们需要一种随着用户数量的增长而扩展系统的方法。
-
限制 - 我们想限制为给定代码执行分配的资源量,以便它不征税我们的服务器以及伤害其他用户的经验。
我们要使用什么
为了解决上述所有注意事项,我们将使用Tork为我们做所有繁重的举重。
简而言之,我一直在为Abiaoqian工作。
它使用docker容器来执行工作流程任务,该任务解决了点1和第3点 - 我们将在一分钟内确切查看。
它还支持分布式设置,以将任务处理扩展到任意数量的工人节点,以解决点#2。
我们可以采用两种方法:
-
Download并安装香草tork并写一项新的薄API服务,该服务将位于客户和托克之间 - 因为我们不一定想将托克的本地API暴露于它们,以便对他们进行严格的控制将哪些参数发送到tork。这种方法的优点是我可以用我选择的语言编写“中间件”服务器。
-
Extend Tork公开我们的自定义API端点并禁用所有其他端点。这需要了解进行编程的知识。
出于此演示的目的,我将使用选项#2。
好,让我们写一些代码
您需要:
- Docker安装在正在运行演示的计算机上。
- 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
}
所以我们在这里基本上在这里做三件事:
- 将
language
字段映射到docker图像。 - 根据
language
将code
写入容器中的适当文件。 - 运行必要的命令以在容器中执行代码。
让我们在处理程序中使用它:
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
扩大
我们要解决的最后一点是可伸缩性。
有很多方法可以调整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上找到完整的源代码。