0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自社採用サービスに「コーディングテスト機能(オンラインジャッジ)」を実装した話 — Judge0 セルフホスト × Go × CodeMirror 6

0
Posted at

はじめに

エンジニア採用サービスに、候補者がブラウザ上でコードを書き、その場で自動採点される「コーディングテスト」機能を実装しました。いわゆる 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 構成はシンプルで、serverworkersprivileged: 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 で冪等にし、再配信されても二重採点しない
  • 可視性タイムアウト:採点のワーストケースより十分長く(ジョブのタイムアウト < 可視性タイムアウト)
  • 環境分離devprod でキューを分離し、ローカルの実行が本番のメッセージを奪わないようにする(環境変数で 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

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?