HireRoo TechBlog

HireRooの技術ブログ

技術特化選考を支える基盤を俯瞰して見る

こんにちは!株式会社HireRooの伊藤(@icchy_san)です。

本記事では技術特化形式の選考(以下プロジェクトサービス)基盤全体を俯瞰して紹介したいと思います!プロジェクトサービスについての説明は以前、弊社ブログにて説明しているので、こちらの記事の「技術特化形式」を参照してもらうと、イメージが掴めると思います!

サービス全体像

f:id:icchy-3:20210430133142p:plain
図1. サービス全体像

プロジェクトサービスでは以下のことを行なっています。

  • サーバサイド
    • 出題する問題の管理(CRUD)
    • 選考で利用するGCPリソースの管理
    • クライアントからのWebSocketのリクエストをProxyする
    • ユーザが作成した解答の評価
      • テストケースを利用した自動評価
      • 企業様の従業員による定性評価
  • クライアントサイド
    • オンラインIDEの提供
      • サーバ内のファイルをファイルツリー形式で表示
      • サーバを操作することができるTerminalの表示
      • コーディング用のエディタの提供
      • 選考課題の表示

一つ一つ説明していきます。

サーバサイド

出題する問題の管理

プロジェクトサービスでは問題の管理(CRUD)エンドポイントを持っています。

DB保存前の問題自体は sample.yml のようにYAML形式で定義されており、GitHubのリポジトリにおいています。リポジトリへPushするとCircleCIのJob内でYAMLをパースしてプロジェクトサービスへデータと共にリクエストを行なっています。

GitHubを利用しているためReview後のマージともにCIが走り、プロジェクトサービス用のDBに保存されるようになっているので、問題の追加・更新・削除がとてもしやすいです。

また、後述しますがこのとき各問題ごとに、問題を解く上で必要なパッケージのインストール・初期化などを行う設定が記述されたDockerfileも管理しているため、CI上でContainer Imageを作り、CIからGCRにPushしています。

# sample.yml
id: 13
type: question
version: 1.0
title: 【サンプル】引き算
source:
  name: original
summary: |-
  演算子を使い引き算を実装する課題です。
  候補者がプログラミング未経験か否かを判断するのに適切です。
description: |-
  ## 説明
  あなたはint型である `x` と `y` が引数として渡されました。
  `x` と `y` を引いた結果( `x + y` )を返すプログラムを書いてください。

  ## 例
  ### 例1:
  ```text
  入力: x = 2, y = 1
  出力: 1
  説明: 2 - 1 = 1
  ```

  ### 例2:
  ```text
  入力: x = 10, y = 10
  出力: 0
  説明: 10 - 10 = 0
  ```

  ## 前提
  - `-2^10` < `x` < `2^10`
difficulty: 1
testcase: ${dump("questions/sample/testcase.json")}
signature: ${dump("questions/sample/signature.json")}
hints:
  - ${import("questions/sample/hints/hint-1.yaml")}
answers:
  - ${import("questions/sample/answers/optimal/answer.yaml")}
tags:
  - 数学

選考で利用するリソースの管理

ここでいうリソースはGCPのリソースのことを指します。

管理しているリソースは以下の2つです。

  • GCE
  • Service Directory

1つ目のGCEは、ユーザごとに割り当てるサーバとして利用しています。プロジェクトサービスでは、ユーザごとにGCE(Container-Optimized OS )が割り当てられ、ユーザはその割り当てられたサーバ上でコードを書き、課題を解いていきます。各々にGCEインスタンスが割り当てられるので、他の選考を受けているユーザとは全く別の環境になります。それにより自由に他のユーザのことを気にすることなくパッケージのインストールやファイルの変更などを行うことができます。

例えばRailsを用いたAPIサーバ構築の問題を解く場合、用意されるGCEにはすでにRailsプロジェクトが初期化された状態で提供されます。

そのため、ユーザは用意されたディレクトリ配下でコーディングすることができ、環境構築なども不要なため、すぐに問題を解くことができます。

各問題ごとに異なる環境を用意する必要があるのですが、前述の通り問題を追加する際に用意しているDockerfileから作成したContainer ImageがGCRに保存されており、Container-Optimized OS で利用するContainer Imageとしているため、問題ごとに異なる環境を用意することができます。

また、cloud-initを活用してGCEインスタンスのライフサイクルをトリガーにContainer Imageの取得や更新を行なっており、ユーザからの更新の際などに変更が加わったContainer Imageを再度作成(Commit)しSnapshotをとっています。 以下に、利用している cloud-init (一部抜粋) を紹介します。ここで出てくる .CommitImage はユーザごとに作成されるGCRのPATH(ex: asia.gcr.io/${project_name}/${repo_name}/${path} )が入ります。

起動時に実行されるシェルスクリプト

# cloud-config(start_script.sh)

# 略

# 提出タグがついたImageが存在している場合
if [ "$(docker images -q {{ .CommitImage }}:submission)" != "" ]; then
  # 提出タグのイメージが更新されないように別のタグをつける
  docker tag {{ .CommitImage }}:submission {{ .CommitImage }}:latest
  exit 0
fi

# ユーザが中断した時のタグが存在しない場合
if [ "$(docker images -q {{ .CommitImage }}:latest)" == "" ]; then
  # 問題開始用に初期化されているBaseイメージを取得
  docker pull {{ .BaseImage }}:{{ .BaseTag }}
  # ユーザが更新したimageとしてPushするためのタグをつける
  docker tag {{ .BaseImage }}:{{ .BaseTag }} {{ .CommitImage }}:latest
fi

# 略

ストップ開始時に実行されるスクリプト

# cloud-config(stop_script.sh)

# 略

# 生成されるとあるファイルの存在確認
docker exec myproject test -f $FILE_PATH
CODE=$(echo $?)

# ユーザの最新の変更が含まれているlatestタグを付けてContainer Imageを作成
docker commit myproject {{ .CommitImage }}:latest

# とあるファイルが存在していた場合にはsubmissionタグを付ける
if [ $CODE -eq 0 ]; then
  docker tag {{ .CommitImage }}:latest {{ .CommitImage }}:submission
fi

# 略

シャットダウン時に実行されるスクリプト

# cloud-config(shutdown_script.sh)
# 略

# Commitして作成したImageをGCRへPush
docker push {{ .CommitImage }}
docker rm myproject

# 略

2つ目のService Directoryは、いわゆるサービスディスカバリ用のサービスで、プロジェクトサービスではユーザごとに用意しているGCEインスタンスの検出に利用しています。

Internal DNSとService Directory内の名前空間を紐づけており、同一ネットワーク内での名前解決をできるようにしています。

ここら辺の設計は比較的新しいサービスを利用しており、面白いので詳しい内容(設計や工夫など)については、改めて別のブログで紹介させていただきます。

WebSocketのProxy

f:id:icchy-3:20210430141312p:plain
図2. Proxy部分に注目

クライアントのオンラインIDEとGCEのサーバはWebSocketを利用して通信しています(図2)。WebSocketを利用することでサーバのファイルの同期(ファイルツリーやファイルの変更など)やTerminalのサーバとの接続を行なっています。

プロジェクトサービスも他のサービス同様基本的にCloudRunで稼働しているのですが、フルマネージドなCloudRunではWebSocketを利用できないのでGCEを利用してWebSocketのProxyサーバを用意しています。

WebSocketのProxyサーバから各ユーザのGCEインスタンスへリクエストを流す際は、先ほど述べたService Directoryを利用しています。

ServiceDirectoryのEndpointを作成する部分は以下のコード(一部抜粋)のように、ServiceDirectoryのAPIを使っています。

createEndpointReq := &sdpb.CreateEndpointRequest{
    Parent:     fmt.Sprintf("projects/%s/locations/asia-northeast1/namespaces/hireroo/services/project", s.projectName),
    EndpointId: fmt.Sprintf("project-%d", projectID),
    Endpoint: &sdpb.Endpoint{
        Address: address,
    },
}
_, err := s.client.CreateEndpoint(ctx, createEndpointReq)

Proxyサーバ側では、作成されたエンドポイントを次のように参照するようにし、リクエストを流すようにします。

// backendURLにServiceDirectoryに登録されているエンドポイントの文字列を作成し格納
backendURL := fmt.Sprintf("ws://project-%s.%s.%s", projectId, internalDomain, req.URL.Path)
// 中略
// ws クライアントの作成
connBackend, resp, err := dialer.Dial(backendURL, requestHeader)
// 中略
replicateWebsocketConn := func(dst, src *websocket.Conn, errc chan error) {
    for {
        msgType, msg, err := src.ReadMessage()
        if err != nil {
            m := websocket.FormatCloseMessage(websocket.CloseNormalClosure, fmt.Sprintf("%v", err))
            if e, ok := err.(*websocket.CloseError); ok {
                if e.Code != websocket.CloseNoStatusReceived {
                    m = websocket.FormatCloseMessage(e.Code, e.Text)
                }
            }
            errc <- err
            dst.WriteMessage(websocket.CloseMessage, m)
            break
        }
        err = dst.WriteMessage(msgType, msg)
        if err != nil {
            errc <- err
            break
        }
    }
}
// 引数を入れ替えることで双方向にやりとりする
go replicateWebsocketConn(connPub, connBackend, errClient)
go replicateWebsocketConn(connBackend, connPub, errBackend)

ユーザが作成した解答のテストケースを利用した自動評価

ユーザが作成した解答は、提出する際に問題用に用意しているテストケースを元に評価されます。テストの評価方法は、レスポンスのBody とステータスコードをもとに判断しています。

レスポンスのBodyもしっかりと見ているため、仮にステータスコードが200だったとしてもテストケースは落ちます。

最終的に、いくつのテストケースが通ってどのくらいのレイテンシーがあったのかを計測しDBに保存します。

コードを提出したり、解答を一時停止するとその時点のContainerのSnapshotが作られ、GCRにPushされます。コードを提出した際は、提出用のタグをContainer Imageに付け、一時停止した際は、latestタグを付け管理しています。タグをつけることで再開するときにlatestタグのContainer ImageをPullして走らせるため、前回の続きから再開することができると共に、バックアップとすることができます。

企業様の従業員による定性評価

自動評価では、テストケースの実行結果から評価を行なっていました。

それだけでは分からないこともあるので、面接官によるコードレベルの評価も行うことができます。評価の際はユーザがコードを書いたオンラインIDEをRead Onlyモードでコードレビューができます。

レビューする際もユーザが利用していた同じGCE インスタンスを利用するのですが、前述の通り、提出用のタグがついたContainer ImageをPullして走らせます。

提出時のSnapshotから起動しているため、実際にサーバを起動してリクエストを送ってみるといったことが可能となっています。仮に変更を加えてしまったとしても、再度評価する際には再び提出用のタグが追加ImageをPullして走らせます。

自動テストでコケていたところは、実際にAPIサーバを走らせて動作確認をすることで再現することができます。

クライアントサイド

オンラインIDEの提供

クライアント側の主な機能としては、オンラインIDE機能です。冒頭に共有した記事にイメージGIFがあるのでまだ見てない方は見ていただくとイメージできると思います。

オンラインIDEで行っていることは、

  • ファイル同期
  • pty(擬似ターミナル)を利用したターミナルの操作
  • エディタでのコーディングサポート

です。

オンラインIDE上でのファイル同期やptyは、全てWebSocketでProxyサーバと接続して行なっています。WebSocketから先の話は、「WebSocketのProxy」で記述した通り、Service Directoryを利用して選考と対応しているのGCEインスタンスに接続しています。

オンラインIDEの説明だけでも1記事になるくらいのボリュームがあるので、別記事にて実現する上での工夫だったり、設計について投稿します。

まとめ

いかがだったでしょうか。

本記事では、プロジェクトサービスをクライアントサイド・サーバサイドを横断して紹介しました。横断しているが故に、ざっくりとした説明にはなってしまいましたが、全体像を掴んでいただけていたら幸いです。プロジェクトサービス1つにおいても様々な工夫をしているので、今後更にクライアント・サーバで深掘りした記事を書いていきます!そちらもぜひよろしくお願いします。

また、HireRooではこのように課題を解決するために新しい技術も怯まず、積極的に利用していける、挑戦したいと思っている仲間を募集しています!気になる方はぜひTwitterのDMやWantedlyの採用ページからご応募ください!

それではまた!