クラウドコンパイル環境の構築・運用

目次

こんにちは!株式会社ハイヤールー代表葛岡(@kkosukeee)です。

これまでエンジニア採用におけるコーディング試験の SaaS である『HireRoo(ハイヤールー)』はどのように開発・運用されているか、フロントエンド・バックエンドはどのようの構成で動いてるのかを紹介してきました。

本記事では俯瞰でシステムを眺めるだけでなく、より詳細に HireRoo のコアであるクラウドコンパイル環境の設計・構築に関して執筆したいと思います。

クラウドコンパイルとは

Go をよく書かれる方だと一度は Go Playground を使われたことがあるかと思います。Go Playgroundでは、ウェブ上でコードを書くことができ、更にコードを実行し出力を得ることができます。

書いたコードはクラウド上で実行されるため PC のセットアップなどは特に必要なく、誰かとコードを共有したいときや、ちょっとしたコードを書いて実行するのに非常に便利です。

HireRoo では候補者の方がコーディング試験の問題を解くために、Go Playground 同様にクラウド上でコンパイル・実行できる環境を提供しています(以下クラウドコンパイル環境)。これを実現することによって候補者は環境構築をする必要なく問題に取り組むことができます。

2021 年 4 月時点で 11 言語を対応しており、実際のコーディング試験でお使いいただいています。続いて私達がどのようにクラウドコンパイル環境を構築し運用しているかの詳細をお話します。

サービス構成

クラウドコンパイル環境は大きく2つのマイクロサービスから成り立っています。1つはアルゴリズム形式の問題やユーザーが提出したコードを格納するためのサービス(以下チャレンジサービス)と、ユーザーのコードを任意の言語で実行するサービスです(以下コンパイルサービス)。

共に Envoy Gateway の背後に配置されており、ユーザーはクライアントアプリケーション(React の SPA)から grpc-web 経由でリクエストを投げ、Gateway を通しチャレンジサービスにリクエストが届きます。チャレンジサービスは直接コードを実行せず、後続のコンパイルサービスにリクエストを流すことによりコードを実行し出力を得ます。

前述の通りチャレンジサービスはコードを実行しません。チャレンジサービスはコードの実行以外にユーザーの提出コードのパフォーマンスや網羅率といったデータを候補者のコーディング試験のパフォーマンスとして格納することが主な責務です。コードの実行自体は後述のコンパイルサービスが担いそのコンパイルサービスにリクエストを流し、得た出力を格納するまでがチャレンジサービスの責務になります。

続いてコードのコンパイル・実行が責務のコンパイルサービスがどのような設計で運用されているかについて触れていきます。

コンパイルサービス設計思想

コンパイルサービスではユーザーが提出する任意のコードを 11 言語(2021/04/23 現在)で実行できるように各言語ごとに更にサービスを切り離しています。もともとは 5 言語ほどしか対応しておらず、All in One の Docker Image を Cloud Run 上にデプロイしていました。

しかし 11 言語を全てサポートする Docker Image はあまりにも無駄が多く必要以上にビルドに時間がかかるため 1 言語 1 サービスで運用することを決断し、言語毎にそれぞれの Cloud Run を用意し、分離しました。

チャレンジサービスと切り離した背景としては、セキュリティーや計算資源を考慮する必要があるためです。アルゴリズムの問題では容易に無限ループになるコードを提出してしまうことがあります。これをチャレンジサービスと同じサービスで実行すると計算資源が枯渇し、必要以上に多くのメモリの割当などが必要で理想的でないためです。

これを考慮し 11 言語をサポートする 11 個の Cloud Run サービスを本番にデプロイしており、各サービスはユーザーが提出するコードを Cloud Run 上で実行し出力をチャレンジサービスに返します。続いてコードの実行部分の実装について見ていきます。

コンパイルサービス実装詳細

Cloud Run の各言語サービスは全て同じインターフェイスを Protocol Buffer で定義しており、チャレンジサービスは同じ gRPC クライアントライブラリを使用し会話することができます。gRPC サーバーは以下のような RPC を定義しており、チャレンジサービスはユーザーが実行したいコードを if 文で分岐し、対象の言語サーバーにリクエストを流します。

service CompileService {
  // コードを実行するためのRPC
  rpc CompileCode(CompileCodeRequest) returns (CompileCodeResponse);
}

message CompileCodeRequest {
  // プログラミング言語(例:python, ruby, go etc.
  string runtime = 1;
  // 実行対象となるコード
  string code_body = 2 ;
  // 実行対象となるコードの引数。複数の場合はカンマ区切り
  string input = 3 ;
  // 実行対象となるコードの型、関数名などを格納しているデータ
  Signature signature = 4;
}

message CompileCodeResponse {
  // 引数に対する実行対象コードの出力
  string output = 1;
  // エラーや標準出力にログを吐いていた場合のログ
  string log = 2;
  // コンパイルのステータス(例:SUCCESS, FAILED, etc
  string status = 3;
}

前述のインターフェイスを満たす gRPC サーバーの実装についていは割愛しますが、主なタスクとしては以下となります。

  • 任意の入力を型情報と共に展開し各言語の Syntax に落とし込む
  • コードテンプレートを使用し実行時にコードを自動生成する
  • 実行対象であるコードを実行し標準出力の結果をパースする
任意の入力を型情報と共に展開し、各言語の Syntax に落とし込む

ユーザーはアルゴリズムの問題に対し任意の入力でコードを実行できます。以下の GIF をご覧いただくと雰囲気が掴めると思います。

実行時には対象となるコード、使用言語、選択中のアルゴリズムの問題といった情報をサーバー側にリクエストとして送ります。サーバーはリクエストを受け取るとまず、アルゴリズムの問題スキームを ID から参照し、任意の入力を展開していきます。

展開に使っているアルゴリズムは再帰的なものが多く少し複雑であるためここでは割愛しますが、以下のように標準化された入力を各言語の Syntax に合わせ展開することでコードが実行できるようになります。

関数名:merge
入力:[[1,2,3], [4,5,6]]

Go:merge([]int{1,2,3}, []int{4,5,6})
Python: merge([1,2,3], [4,5,6])
...
コードテンプレートを使用し実行時にコードを自動生成する

展開された入力と実行対象となるコードがあればコードをコンパイル・実行することができます。ただし出力結果は何らかの形で吐き出さないと後続の処理(正解しているか否かの判断 etc)ができないため、何らかの形で実行対象となるコード標準出力に吐き出す必要があります。

そこで私達はテンプレート言語を使い、各言語の Wrapper モジュールを作成し、実行結果のコードを JSON 形式にシリアライズし標準出力に吐き出しています。これにより後続の処理は出力結果である JSON をパースするだけで良くなります(Go の場合は Unmarshal)。

具体的には以下のようなテンプレートを各言語に事前に用意しておき、実行時に展開された入力と実行対象の関数で埋めます。

// wrapper.go
package main

import (
    "encoding/json"
    "fmt"
)

// 実行時に呼ばれる関数
func main() {
    // json.Marshalを使い標準化し標準出力に吐く
    data, _ := json.Marshal({{ .CallFunction }})
    fmt.Printf("\n%s", string(data))
}

{{ .CallFunction }} は展開された関数と入力に置き換えられ、任意の関数と入力を実行時に作成することができます。

実行対象であるコードを実行し標準出力の結果をパースする

展開された入力、実行対象のコード、標準出力に吐き出す Wrapper モジュールが揃えば後はコンパイル・実行するだけです。各言語手順は異なりますが、Go の場合だと以下のようなコマンドを実行し、成果物であるバイナリーファイルを子プロセスで実行します。

// 実行に必要なファイル群
buildArgs := []string{"build", "-o", binFile, executableFile, submitFile, customtypesFile}

// 要はgo build -o hoge hogeを走ってるだけ
if out, err := exec.Command("go", buildArgs...).CombinedOutput(); err != nil {
    return &CompileRes{
        Log:    string(out),
        Status: "FAILED",
    }, nil
}

// 実行に時間がかかりすぎた際には強制終了するため
tctx, cancel := context.WithTimeout(ctx, COMPILE_TIMEOUT)
defer cancel()

// 生成されたBinaryを実行しているだけ
out, err := exec.CommandContext(tctx, binFile).CombinedOutput()
if tctx.Err() == context.DeadlineExceeded {
    return &CompileRes{
        Log:    TIMEOUT_EXCEEDED_ERR,
        Status: "BUILT",
    }, nil
}

ここで実行しているコードは前述の通り結果を JSON 形式で標準出力に吐き出すため、容易に呼び出し元で json.Unmarshal などを使用し展開することができます。展開されたコードはさらに Log 出力結果と別々に分別(詳細は割愛)され最終的にはコンパイルサービスはチャレンジサービスに結果を返します。

工夫としては実行時あまりにも長い時間がかかる場合には TimelimitExceeded を返すようにしており、これにより無駄な計算資源を使わないようにしています(無限ループに入ると大変です)

これによりクラウド上でユーザーの任意のコードを実行することができます。まだまだ課題が残るところはありますが、現状問題なく稼働しており、レイテンシに関しても言語によりバラバラではありますが気にならないレベルとなっています。

まとめ

最後まで読んでいただきありがとうございます。本記事では HireRoo のコーディング試験で必要となる機能のクラウドコンパイル環境に関してお話しました。

クラウドコンパイル環境というのはあまり多くなく、なかなか知見が少ない分野ではありましたが、Go Playground の設計思想なども参考にし、最終的には満足できるものを作ることができたと思っています。

HireRoo はこれからもコーディング試験を軸とし、新たなエンジニア採用の当たり前を築いていきます。中には非常に難易度の高い(クラウドコンパイルは個人的にかなり難易度高いと思っています笑)機能の開発もあるので、エンジニアとしては非常にチャレンジングな環境だと思います。

引き続きエンジニアを積極採用していますので 1 ミリでも興味を持った方は、是非お気軽に下記募集要項からご応募ください!

https://www.wantedly.com/companies/company_9352198/projects

(本気で待っています…!!)。それでは次回の記事で!