はじめに
エンジニア採用サービスに、候補者がブラウザ上でコードを書き、その場で自動採点される「コーディングテスト」機能を実装しました。いわゆる HackerRank / LeetCode 風のアレを自前で作った、という話です。
本記事では 全体アーキテクチャ と、実装中に踏み抜いた 現実的な落とし穴(特に Judge0 を本番で動かすときの cgroup 問題)を共有します。同じものを自作する人の地雷除去になれば幸いです。
技術スタック
- コード実行エンジン: Judge0(セルフホスト)
- バックエンド: Go + PostgreSQL + Amazon SQS
- フロントエンド: React + CodeMirror 6
全体アーキテクチャ
候補者(ブラウザ)
│ ① 実行(Run) … 同期
│ ② 提出(Submit) … 非同期
▼
Go API ──② SendMessage──▶ Amazon SQS(採点キュー)
│ │
│ ① その場で実行 │ 採点ワーカー(ConsumeMessageでPull)
▼ ▼
Judge0 (self-hosted) ◀───────┘
│ isolate でサンドボックス実行
▼
テストケースごとの結果を PostgreSQL に保存
ポイントは 「実行(Run)」は同期、「提出(Submit)」は非同期 に分けたことです。
- Run:公開サンプルだけを即実行して結果を返す(候補者の試行錯誤を速く)
- Submit:全テストケースをコンパイル+実行する重い処理なので、SQS にジョブを積んでワーカーが採点
1. コード実行エンジン:Judge0 をセルフホストする
Judge0 は OSS のオンラインコード実行システムです。内部で isolate を使い、提出コードを名前空間・cgroup で隔離して実行します。マネージドな Judge0 Cloud もありますが、今回はコスト・レイテンシ・制御性の観点でセルフホストを選びました。
docker-compose 構成はシンプルで、server と workers を privileged: true で動かします。
services:
server:
image: judge0/judge0:1.13.1
volumes:
- ./judge0.conf:/judge0.conf:ro
privileged: true
restart: unless-stopped
workers:
image: judge0/judge0:1.13.1
command: ["./scripts/workers"]
volumes:
- ./judge0.conf:/judge0.conf:ro
privileged: true
restart: unless-stopped
…が、ここからが本番でした。
🔥 ハマり①:Ubuntu 24.04 は cgroup v2、Judge0 は cgroup v1 前提
isolate のセキュアなサンドボックス(--cg)は cgroup v1 を前提にしています。ところが Ubuntu 22.04 以降はデフォルトで cgroup v2。そのまま動かすと、メモリ制限などがうまく効きません。
対処は GRUB でカーネルを cgroup v1 に切り替える こと。
# /etc/default/grub の GRUB_CMDLINE_LINUX に追記
GRUB_CMDLINE_LINUX="systemd.unified_cgroup_hierarchy=0"
sudo update-grub && sudo reboot
# 再起動後に確認(tmpfs なら cgroup v1)
stat -fc %T /sys/fs/cgroup # → tmpfs
リブートが必要ですが、Docker の名前付きボリューム(PostgreSQL のデータ等)は消えません。
restart: unless-stoppedを付けておけば全コンテナが自動復帰します。GRUB の変更はカーネル起動パラメータを足すだけで、ディスク上のデータには触れません。
cgroup v2 のまま動かす道(rlimit モード)もありますが、サンドボックスが「プロセス単位の制限」になり弱くなるため、本番は cgroup v1 + --cg 推奨です。
🔥 ハマり②:マウントした設定ファイルの権限
judge0.conf には DB パスワード等が入るので、つい chmod 600 にしたくなります。これが罠でした。
Judge0 のコンテナは 非 root ユーザーで動くため、600(root のみ読み取り可)にすると設定を読めず、起動時に
./scripts/load-config: line 11: /judge0.conf: Permission denied
→ DB 設定が読めず localhost にフォールバック → PG::ConnectionBad: Connection refused → 起動ループ、という連鎖でハマります。
対処は単純で、マウントするファイルは chmod 644(誰でも読めるように)。
🔥 ハマり③:Java が "Could not allocate metaspace" で起動しない(ローカルのみ)
ローカルの Mac(Apple Silicon, Rosetta)や rlimit モードでは、メモリ制限が 仮想アドレス空間 (RLIMIT_AS) に対して効きます。JVM は起動時に約 1GB のクラス空間を「予約」するため、256MB 制限だと VM 初期化前に落ちます。
Error occurred during initialization of VM
Could not allocate metaspace: 1073741824 bytes
本番の cgroup v1(--cg)では制限が 物理メモリ(RSS) にかかるので、仮想予約は無料 → 256MB でも問題なく動きます。「ローカルだけ Java が落ちる」現象は、この違いが原因でした。
2. 非同期採点パイプライン(Go + SQS)
「提出」は SQS にジョブを積み、in-process のコンシューマが採点します。
- 冪等性:submission ID で冪等にし、再配信されても二重採点しない
- 可視性タイムアウト:採点のワーストケースより十分長く(ジョブのタイムアウト < 可視性タイムアウト)
-
環境分離:
devとprodでキューを分離し、ローカルの実行が本番のメッセージを奪わないようにする(環境変数で URL を出し分け)
DLQ(デッドレターキュー)も用意し、maxReceiveCount を超えたジョブは隔離して後から調査できるようにしています。SQS はリクエスト課金(ストレージではない)なので、ロングポーリング(20秒)で待てば無料枠にも収まります。
3. フロントエンドのコードエディタ(CodeMirror 6)
Python の「貼り付けるとインデントが壊れる」問題
ターミナルや Web からコードをコピペすると、行頭に余計なスペースが入り、Python が IndentationError で即死する……という体験がありました。Python はインデントが意味を持つので、エディタが勝手に再インデントしてはいけません。
そこで paste を transaction filter で正規化し、貼り付け時点でタブ→スペース変換・共通インデント除去・末尾空白除去をかけます。
import { EditorState } from "@codemirror/state";
const pasteSanitiser = EditorState.transactionFilter.of((tr) => {
if (!tr.docChanged || !tr.isUserEvent("input.paste")) return tr;
const edits: { from: number; to: number; insert: string }[] = [];
tr.changes.iterChanges((fromA, toA, _b1, _b2, inserted) => {
edits.push({ from: fromA, to: toA, insert: sanitisePaste(inserted.toString()) });
});
return { changes: edits, userEvent: "input.paste" };
});
あわせて indentOnInput: false(タイプ時の自動再インデント無効)と indentWithTab(Tab=スペース4つ)を入れ、「打った/貼ったものがそのまま提出される」忠実なエディタにしています。
Judge0 のエラーを「該当行」にハイライトする
VS Code のような赤い波線が欲しいですが、各言語のリアルタイム解析を積むのは重すぎます。そこで コンパイラ自身が返す行番号 を使います。Judge0 の compile_output / stderr を正規表現でパースし、@codemirror/lint の Diagnostic に変換して該当行へ赤線を引きます。
// gcc / clang / go / javac の "file:line:col: error: msg"
const compileRe = /[\w./+-]+:(\d+)(?::\d+)?:\s*(?:fatal\s+)?error:\s*(.*)/gi;
// Python の traceback "File \"script.py\", line N" も拾う
言語を問わず、実際のコンパイラが教えてくれる行を出すので正確です。マーカーは「直近の実行結果」に紐づき、ユーザーがコードを編集した瞬間にクリアします(古いエラーが残らないように input / delete ユーザーイベントで消す)。
時間切れの自動提出
制限時間が来るとエディタとボタンをロックしますが、それだけだと書きかけのコードが採点されずに消えるので、0:00 で現在のコードを自動提出します。多重提出を防ぐため ref でガードし、「実際に取り組んでいた場合のみ」発火(リロード直後の空テンプレを提出しない)させています。
4. 地味だが致命的:テストケースの「空白」問題
最後に、ハマると原因が分かりにくいやつを。
Judge0 は出力比較時、末尾の空白はトリムしますが、先頭はしません。 採点担当が期待値を 0 のつもりで誤って " 0"(先頭スペース)と入力すると、正しい 0 を出力する解答が wrong_answer になります(正答率が地味に下がる)。
対処は、テストケース保存時にサーバ側でサニタイズすること。
func sanitizeExpectedOutput(s string) string {
lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
for i := range lines {
lines[i] = strings.TrimRight(lines[i], " \t") // 各行の末尾空白を除去
}
// 前後の空行を除去(省略)…
// 1行出力のときだけ先頭空白も除去(複数行は意味があり得るので触らない)
if len(lines) == 1 {
lines[0] = strings.TrimLeft(lines[0], " \t")
}
return strings.Join(lines, "\n")
}
「意味を持ち得る相対インデントは壊さず、絶対に無意味な空白だけ削る」というのがポイントです。
まとめ
-
Judge0 セルフホストは強力だが、
cgroupと権限で確実にハマる。Ubuntu 24.04 なら cgroup v1 へ切り替え、設定ファイルは644。 - 採点は非同期(SQS)、エディタは「忠実さ」が命(特に Python の貼り付け)。
- 実際のコンパイラ出力を UI に還元すると、体験が一段良くなる。
- 出力比較の空白の非対称トリムは、地雷なので保存時にサニタイズ。
このコーディングテスト機能は、エンジニア採用サービス saiyouba で実際に稼働しています。
よければ覗いてみてください 👉 https://saiyouba.com