こんにちは!株式会社ハイヤールー代表葛岡(@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はこれからもコーディング試験を軸とし、新たなエンジニア採用の当たり前を築いていきます。中には非常に難易度の高い(クラウドコンパイルは個人的にかなり難易度高いと思っています笑)機能の開発もあるので、エンジニアとしては非常にチャレンジングな環境だと思います。
引き続きエンジニアを積極採用していますので一ミリでも興味を持った方ぜひTwitter DMか以下の採用リンクからご応募下さい(本気で待っています...!!)。それでは次回の記事で!