🙌

オンラインジャッジシステムを開発した話

2023/07/20に公開

背景

学校の授業でオンラインジャッジシステムをテーマにしたシステム開発をすることになりました。授業内では参考としてAIZU ONLINE JUDGEが紹介されていましたが、私はAtCoderの方が馴染みが深かったので、AtCoderを参考に開発することにしました。

https://github.com/mtaku3/capybara-online-judge

開発に際していくつか制限がありました。

  • PHP + Apache2を使用しなければならない
  • Laravelなどのフレームワークは使用してはならない
  • ライブラリの使用については応相談

外部設計

今回はアーキテクチャとしてDDD(ドメイン駆動設計)とオニオンアーキテクチャを採用することにしました。
DDDについては何度か実装したことがありますが、理解が浅く、エリック・エバンス本を読み始めたばかりだったので、ざっくりとした、なんちゃってDDDになっていると思います。
モデリングには松岡さんが紹介されていたSUDOモデリングを行うことにしました。

SUDOモデリング:システム関連図

システム関係図
ここで実行キューがAppとJudgerの間に挟まっている理由を説明します。
まず、UXの観点からユーザーがプログラムを提出した後はすぐにWeb UIに再度表示され、操作可能な状態になるべきだと考えました。しかし、PHPはいわゆる手続き型言語なので、提出のリクエストを受けてプログラムをコンパイル・実行してしまうと、それが終了するまでユーザーは操作できない状態が続いてしまいます。そのため、プログラムのコンパイル・実行処理を非同期にするために、間に実行キューを挟んで、プログラムの実行のみを担うJudgerがプログラムの処理を行うようにしました。

SUDOモデリング:ユースケース図

ユースケース図
基本的にはオンラインジャッジシステムとして必要最低限の機能要求を満たすよう設計しました。
特に、テストケースは追加した後、削除できない(かつ入力データと出力データは変更できない)ようにしました。代わりに有効化/無効化をすることができるようにしました。このようにした理由は、仮にテストケースが削除された場合、過去の提出のジャッジ結果の整合性が保てなくなってしまうからです。

SUDOモデリング:ドメインモデル図

ドメインモデル図
特に説明することはありません。データベースにPostgreSQLを使用する予定だったので、最低限ユースケースがRDBで実現できることを考えながら設計しました。

内部設計

開発期間が3ヶ月ありましたが、週1回の授業だったので、内部設計という内部設計はできませんでした。
使用するスタック/ライブラリとしては

プログラムを安全に実行するには?

このシステムの一番重要な点は、ユーザーが提出したプログラムを迅速かつ安全に実行することになります。
今回はDockerコンテナ内で実行することにしました。しかし、Dockerのランタイムruncはサンドボックスとして設計されているわけではないので、セキュリティレベルは低いと言えます。
そこで、プロセスレベルでのセキュアなサンドボックスを実装した代替ランタイムrunsc(gVisor)を使用しました。

コンテナ内のPHPからDockerにインタラクトする

Dockerにインタラクトする方法としてDocker Engine APIを使用することにしました。
Docker Engine APIの使用にはDockerのUnixソケットに接続する必要があるのでJudgerのコンテナには/var/run/docker.sockをマウントします。

Docker Engine APIはREST APIになっており、非常に使いやすいのですが、コマンドをコンテナ内で実行させた後の標準出力の取得には一工夫必要でした。
Execインスタンスの実行結果のレスポンスは、Content-Typeがapplication/vnd.docker.raw-streamになっていました。application/vnd.docker.multiplexed-streamというのもあるようなのですが、私の環境ではこちらのレスポンスは見られませんでした。ドキュメントを読んでもどちらになるのかよくわかりませんでしたが、おそらくコンテナ作成時にTTYを無効にした場合はapplication/vnd.docker.multiplexed-streamになるようです。(?)
フォーマットとしては、フレームに分かれており、各フレームの最初の8バイトはヘッダーになっています。
ヘッダーは先頭から

  • 1バイト目:ストリームタイプ (0:stdin, 1:stdout, 2:stderr)
  • 2~4バイト目:0が入っていて、特に意味はないようです
  • 5~8バイト目:フレームサイズ

このフォーマットに従って処理すれば正しく標準出力を取り出せます。

コンテナにコピーしたはずのファイルがコピーされていない..

Judgerのプログラムの処理の手順としては以下のようになっています。

  1. コンテナを作る
  2. ソースファイルをコピーする
  3. (必要であれば)コンパイルする
  4. 各テストケースを処理する
    (a). 入力データをコピーする
    (b). 出力データをコピーする
    (c). 実行する
  5. 実行結果を元にジャッジ結果を決定する
  6. データベースに保存する

しかし、ジャッジ結果の判定が上手くいかきませんでした。
結論から言うとgVisorのバグだったようです。
gVisorはDockerのOverlayFSとは別に内部でファイルシステムを持っているようでそちらのキャッシュへの反映が上手くいかないことがあるようです。発生条件としては、一度でもexecするとコピーが一切効かなくなります。
対策としては、Volumeを作ってコンテナにマウントすることでgVisorのキャッシュをバイパスするようにしています。

最後に

バックエンドの方はよくできた方かなと思っています。PHPにジェネリックがないので、DDDはかなりやりずらかったです(笑)
フロントエンドはJavascript地獄になってしまいました。やはりフレームワークは偉大です。

Discussion