Go Playground の仕組みを調べてみた

目次

はじめに

こんにちは、okarin です。弊社はコーディング試験サービスを提供しており、Go、Rust、Python、C++など、合計 16 言語の実行環境をサポートしています(本記事執筆時点)。コーディング試験のように任意のコードを実行する仕組みがどのように実装されているか気になる方も多いのではないでしょうか。今回は馴染みのある方も多いであろう、Go Playground の仕組みを調べてみたので、解説したいと思います。

先日、 Ruby Playground (ruby/play-ruby) の仕組みを調べてみた という記事を公開したので、 Ruby の場合はどのようになっているのか気になった方は、こちらもぜひご覧ください。

Go Playground のアーキテクチャ

まず、Go Playground がどのような設計になっているのかを解説します。

Go Playground でコードを入力して、Run ボタンをクリックすると、フロントエンドサーバーにコードが送られます。

ここで注意が必要なのですが、 本記事では、Go のコードをビルドするサーバーをフロントエンドサーバー、バイナリを実行するサーバーをバックエンドサーバーと呼びます。これは Go Talks のスライドの表現に倣っています。

フロントエンドサーバーでビルドした後は、バイナリをバックエンドサーバーに送ります。バックエンドサーバーは gVisor のコンテナを事前に立ち上げておいて、標準入力にバイナリを書き込むことでコードを実行します。

gVisor を聞き慣れない方もいらっしゃるかと思いますが、コンテナ環境でセキュアにコードを実行できるようにするものと考えていただければ大丈夫です。

最後にコードを実行した結果をフロントエンド、クライアントに返却して表示する、という流れになります。

Go Playground のコード実装

本章では Go Playground がどのように実装されているのか、実際にコードをみていきます。

クライアント

Run ボタンの挙動

まずはクライアントのコードを見てみます。 ここで Run ボタンの id が run となっています。

<input type="button" value="Run" id="run" />

playground.js ファイルを読み込んだ後、playground 関数の引数で、 'runE'l: '#run, #embedRun' を渡しています。

<script src="/playground.js"></script>
<script>
  playground({
    codeEl: "#code",
    outputEl: "#output",
    runEl: "#run, #embedRun",
    fmtEl: "#fmt",
    fmtImportEl: "#imports",
    shareEl: "#share",
    shareURLEl: "#shareURL",
    enableHistory: true,
    enableShortcuts: true,
    enableVet: true,
    toysEl: ".js-playgroundToysEl",
  });
</script>

この playground 関数はどこからきているのか追っていくと、準標準パッケージの golang.org/x/tools/godoc/static からきていることがわかります。 playground.js のデータは URL エンコードされているので、デコードして一部を抜き出すと以下のようになります。

playground 関数を実行すると、引数で渡した runEl フィールドの要素の click イベントに run 関数を渡しています。この run 関数の中では /compile エンドポイントに対して、入力した Go コードを body とする POST リクエストを送っていることが分かります。

したがって、 Run ボタンをクリックすると、 /compile エンドポイントに対してリクエストを送るようになります。

function playground(opts) {
  var transport = new HTTPTransport(opts['enableVet']);

  function run() {
    transport.Run(
      body(),
      highlightOutput(PlaygroundOutput(output[0]))
    );
  }

  $(opts.runEl).click(run);
}

function HTTPTransport(enableVet) {
  return {
    Run: function (body, output, options) {
      $.ajax('/compile', {
        type: 'POST',
        data: { version: 2, body: body, withVet: enableVet },
        dataType: 'json',
        success: function (data) {
          ...
        },
        error: function () {
          ...
        },
      });
      ...
    },
  };
}

フロントエンドサーバー

/compile エンドポイントにリクエストを送っていることが分かったので、フロントエンドサーバーの実装をみてみます。

/compile エンドポイントのハンドラはcompileAndRun 関数になります。ここでは、Go のコードをビルドして、バックエンドサーバーに送ります。その後、バックエンドサーバーから受け取った結果を変換して、イベントなどを含むレスポンスとして返却します。

func compileAndRun(ctx context.Context, req *request) (*response, error) {
	// Go コードのビルド
	br, err := sandboxBuild(ctx, tmpDir, []byte(req.Body), req.WithVet)
	if err != nil {
		return nil, err
	}

	// ビルドしたバイナリをバックエンドサーバーで実行
	execRes, err := sandboxRun(ctx, br.exePath, br.testParam)

	rec := new(Recorder)
	rec.Stdout().Write(execRes.Stdout)
	rec.Stderr().Write(execRes.Stderr)
	events, err := rec.Events()

	return &response{
		Events:      events,
		Status:      execRes.ExitCode,
		IsTest:      br.testParam != "",
		TestsFailed: fails,
		VetErrors:   br.vetOut,
		VetOK:       req.WithVet && br.vetOut == "",
	}, nil
}

Go コードのビルド

sandboxBuild の一部を抜粋したコードが以下になります。受け取ったコードを go build していることがわかります。また、このときビルドタグで faketime を使っています。faketime に関しては少し話が逸れるので、後述します。

func sandboxBuild(ctx context.Context, tmpDir string, in []byte, vet bool) (br *buildResult, err error) {
br.exePath = filepath.Join(tmpDir, "a.out")

	var goArgs []string
	goArgs = append(goArgs, "build")

	goArgs = append(goArgs, "-o", br.exePath, "-tags=faketime")

	cmd := exec.Command("/usr/local/go/bin/go", goArgs...)

	if err := cmd.Start(); err != nil {
		return nil, fmt.Errorf("error starting go build: %v", err)
	}

	return br, nil
}
ビルドしたバイナリをバックエンドサーバーに送信

sandboxRun の一部を抜粋したコードが以下になります。ここでは、ビルドしたバイナリをバックエンドサーバーに送信します。バックエンドサーバーでバイナリを実行した結果は sandboxtypes.Response にデコードされます。

func sandboxRun(ctx context.Context, exePath string, testParam string) (execRes sandboxtypes.Response, err error) {
	// ビルドしたバイナリファイルの読み込み
	exeBytes, err := os.ReadFile(exePath)

	ctx, cancel := context.WithTimeout(ctx, maxRunTime)
	defer cancel()
	sreq, err := http.NewRequestWithContext(ctx, "POST", sandboxBackendURL(), bytes.NewReader(exeBytes))

	sreq.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(exeBytes)), nil }

	// バックエンドサーバーにリクエストを送信
	res, err := sandboxBackendClient().Do(sreq)

	defer res.Body.Close()

	if err := json.NewDecoder(res.Body).Decode(&execRes); err != nil {
		log.Printf("JSON decode error from backend: %v", err)
		return execRes, errors.New("error parsing JSON from backend")
	}
	return execRes, nil
}

バックエンドサーバー

バックエンドサーバーのコードは少し特殊になっています。HTTP サーバーのコードとバイナリを実行するコードが混在していて、 mode オプションが contained の場合はバイナリを実行するようになっているので、注意が必要です。

バックエンドサーバーの起動

まずはサーバーを起動する main 関数をみていきます。ここでのポイントは以下になります。

  • mode の分岐コンテナでバイナリを実行するか、サーバーを起動するかの分岐
  • readyContainer チャネルの初期化
  • makeWorkers で Worker を起動
  • サーバーを起動
var (
	mode       = flag.String("mode", "server", "Whether to run in \"server\" mode or \"contained\" mode. The contained mode is used internally by the server mode.")
)

func main() {
	flag.Parse()
	// フラグによる分岐
	if *mode == "contained" {
		// gVisor で標準入力で受け取ったバイナリを実行
		runInGvisor()
		panic("runInGvisor didn't exit")
	}

	// チャネルの初期化
	readyContainer = make(chan *Container)

	mux := http.NewServeMux()
	// バイナリファイルが送られたら、runHandler で処理
	mux.Handle("/run", ochttp.WithRouteTag(http.HandlerFunc(runHandler), "/run"))

	// worker の立ち上げ
	makeWorkers()

	httpServer = &http.Server{
		Addr:    *listenAddr,
		Handler: &ochttp.Handler{Handler: mux},
	}
	// server の起動
	log.Fatal(httpServer.ListenAndServe())
}

バイナリを実行する Worker の立ち上げ

numWorkers の数だけ Worker を立ち上げます。 Worker はコンテナを立ち上げて、 readyContainer に空きがあればコンテナ構造体を送信します。その後、再度コンテナを立ち上げて、 readyContainer に空きができるまで待機する、というループを繰り返します。

バックエンドサーバーが Docker で立ち上げられるので、 Docker の中にコンテナを立ち上げるようなイメージになります。

func makeWorkers() {
	ctx := context.Background()
	for i := 0; i < *numWorkers; i++ {
		go workerLoop(ctx)
	}
}

func workerLoop(ctx context.Context) {
	for {
		c, err := startContainer(ctx)
		if err != nil {
			log.Printf("error starting container: %v", err)
			time.Sleep(5 * time.Second)
			continue
		}
		readyContainer <- c
	}
}

gVisor を利用したコンテナの立ち上げ

startContainer では gVisor を利用してコンテナを立ち上げます。ここでのポイントは以下になります。

  • –runtime=runsc を指定 gVisor を利用してコンテナを起動(ドキュメント
  • -i オプションを指定コンテナを起動したあと、標準入力を受け付ける状態で待機
  • mode オプションの指定 main 関数の分岐で、 runInGvisor が実行される runInGvisor は受け取ったバイナリを実行する
func startContainer(ctx context.Context) (c *Container, err error) {
	name := "play_run_" + randHex(8)

	cmd := exec.Command("docker", "run",
		"--name="+name,
		"--rm",
		"--tmpfs=/tmpfs:exec",
		"-i", // 標準入力を受け付ける状態で待機

		"--runtime=runsc", // gVisor のランタイム
		"--network=none",
		"--memory="+fmt.Sprint(memoryLimitBytes),

		*container,
		"--mode=contained") // mode の分岐用のフラグ

	stdin, err := cmd.StdinPipe()
	pr, pw := io.Pipe()
	stdout := &limitedWriter{dst: &bytes.Buffer{}, n: maxOutputSize + int64(len(containedStartMessage))}
	stderr := &limitedWriter{dst: &bytes.Buffer{}, n: maxOutputSize}
	cmd.Stdout = &switchWriter{switchAfter: []byte(containedStartMessage), dst1: pw, dst2: stdout}
	cmd.Stderr = stderr

	// docker run の実行。
	// 標準入力に書き込まれるのを待つ状態の container を立ち上げる
	if err := cmd.Start(); err != nil {
		return nil, err
	}

	ctx, cancel := context.WithCancel(ctx)
	c = &Container{
		name:      name,
		stdin:     stdin,
		stdout:    stdout,
		stderr:    stderr,
		cmd:       cmd,
		cancelCmd: cancel,
		waitErr:   make(chan error, 1),
	}

	return c, nil
}

起動した gVisor コンテナを取得

コンテナの起動、サーバーの起動の処理をみてきたので、バイナリが送られてきた時の処理(runHandler 関数)をみていきます。

readyContainer チャネルからコンテナの構造体を取得します。ここで readyContainer に空きができるので、 複数立ち上がっている worker のうち 1 つから、 readyContainer にコンテナの構造体が送信されます。

func runHandler(w http.ResponseWriter, r *http.Request) {
...

c, err := getContainer(r.Context())

...
}

func getContainer(ctx context.Context) (*Container, error) {
	select {
	case c := <-readyContainer:
		return c, nil
	case <-ctx.Done():
		return nil, ctx.Err()
	}
}

ビルドしたバイナリを gVisor コンテナ内で実行

コンテナの標準入力にビルドしたバイナリを書き込み、実行しています。ここでようやくユーザーが入力した Go のコードが実行されています。

実行した後は標準出力をレスポンスとして返します。

func runHandler(w http.ResponseWriter, r *http.Request) {
	...
	// バイナリの読み込み
	bin, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBinarySize))

	// コンテナの標準入力にバイナリを書き込んで、Go コードの実行
	if _, err := c.stdin.Write(bin); err != nil {
		log.Printf("failed to write binary to child: %v", err)
		http.Error(w, "unknown error during docker run", http.StatusInternalServerError)
		return
	}
	c.stdin.Close()

	err = c.Wait()
	res := &sandboxtypes.Response{}
	res.Stdout = c.stdout.dst.Bytes()
	res.Stderr = cleanStderr(c.stderr.dst.Bytes())
	sendResponse(w, res)
}

ここまでの流れを整理して簡単にまとめると、以下のようになります。

Worker がコンテナを事前に起動しておき、 readyContainer チャネルに標準入力の書き込み待ち状態のコンテナの情報を送ります。サーバーがリクエストを処理する時に readyContainer チャネルからコンテナを取得し、コンテナの標準入力にバイナリを書き込むことで Go のコードを実行します。

faketime

Go Playground の実装について理解できたと思いますので、ここでは faketime について解説します。

まずビルドタグを指定すると、ビルド時にどのファイルを含めるかを指定することができます(ドキュメント)。

faketime タグを指定してビルドすると、 faketime 用のファイルを使ってビルドされます。例として、runtime パッケージの time_fake.go をみてみます。 faketime という名前からも予測できるかもしれませんが、 faketime のビルドタグを指定すると、実際の時間ではなく fake の値を利用するようになります。

コードを見てみると、 1257894000000000000 ナノ秒が現在時刻として利用されています。

なぜこのような仕組みが必要になってくるかというと、CPU などのリソース消費を抑えるために、 time.Sleep の処理がコードに含まれていても即座にレスポンスを返したいからです。

実際に Go Playground で以下のような time.Sleep を含んだコードを実行してみます。

package main

import (
	"fmt"
	"time"
)

func main() {
	for i := 0; i < 3; i++ {
		time.Sleep(1 * time.Second)
		fmt.Println(time.Now())
	}
}

上記のコードを実行すると、以下のようなレスポンスが 1 秒おきに表示されます。 この挙動を見るだけだと fake の時間が使われているだけのように感じますが、 Network 通信を調べると面白いことが分かります。

2009-11-10 23:00:01 +0000 UTC m=+1.000000001
2009-11-10 23:00:02 +0000 UTC m=+2.000000001
2009-11-10 23:00:03 +0000 UTC m=+3.000000001

Chrome Devtools の Network タブを開いておいて、上記のコードを実行すると、フロントエンドサーバーからは以下のようなレスポンスが返ってきます。

クライアントはこのレスポンスを受け取って、 Delay が指定されている場合は、メッセージを遅延して表示するようになっていて、あたかもスリープしているかのような挙動がみえるようになっています。

このように、 faketime を利用することで、バックエンドサーバーでは実際にスリープさせることなく即座にレスポンスを返しながらも、ユーザーに対してはスリープしているかのようにみせています。

{
  "Errors": "",
  "Events": [
    {
      "Message": "2009-11-10 23:00:01 +0000 UTC m=+1.000000001\n",
      "Kind": "stdout",
      "Delay": 1000000000
    },
    {
      "Message": "2009-11-10 23:00:02 +0000 UTC m=+2.000000001\n",
      "Kind": "stdout",
      "Delay": 1000000000
    },
    {
      "Message": "2009-11-10 23:00:03 +0000 UTC m=+3.000000001\n",
      "Kind": "stdout",
      "Delay": 1000000000
    }
  ],
  "VetErrors": ""
}

まとめ

本記事では、 Go Playground の仕組みを調べて解説しました。 Docker 内でコード実行用のコンテナを立ち上げていたり、 faketime を使ったビルドなど、個人的には学びが多かったです。この記事を読んでくださった方にもなにか学びがあると嬉しいです。

参考資料

※本記事で利用した playground のコードは、本記事執筆時点で、BSD-3-Clause ライセンスで公開されています。

https://go.googlesource.com/playground/+/df5b938fb2fa4ef45126c6161329c85931f8b25d/LICENSE

Copyright 2014 The Go Authors.