サンドボックスとしてWebAssemblyを使ってみた 〜コードゴルフコンテストAnybatrossの裏側〜

こんにちは! カヤックの谷脇です。この記事は面白法人グループAdvent Calendar1日目の記事です。初日から飛ばしていくぞ〜〜!

Anybatrossとは

YAPC::Fukuoka 2025に合わせてカヤックが行ったコードゴルフコンテストとそのサイトです。YAPC::Hiroshima 2024から数えて3回目です。詳しくはこちら。

techblog.kayac.com

このサイトでは、参加者への課題として仕様を満たすプログラムを書くように求められており、そのプログラムコードの総バイト数が少ければ少ないほどランキングが上位にいくというものでした。いわゆるコードゴルフですね。

ちなみに現在コンテストは終了しております。しかし、ランキングは更新されないものの実行とテストはできる期間になっております。皆様も試してみてね。

また講評については、また別の記事として公開させていただきます。

前回のバージョンからの変更点

前回のYAPC::Hakodate 2024で公開したバージョンでは「Perlbatross」という名前になっており、Perlのコードを短くするコンテストでした。Yet Another Perl Conferenceだからですね。しかし時代の変化もあってか、YAPCにはPerl以外の言語を書く方が多く参加するようになり、トークの内容もPerlに関係しないものが多くを占めるようになってきました。それならPerlbatrossも他の言語に対応しよう! というわけで今回はRuby, Python, JavaScript, PHPでも解けるように変更しました。

Anybatrossの複数言語対応後のセレクタ

複数言語に対応する方法の検討

そもそもユーザー入力のプログラムコードを実行するのでサンドボックス環境が必要です。今まではPerl以外他に何も入ってないAWS Lambdaを用意してそこで実行していました。つまりLambda自体のサンドボックスに期待する形です。

似たような形でやっても良かったのですが、LambdaのサンドボックスはPerlbatross本体のコードや問題文が参照できてしまっていたりなど不十分な点がありました。どちらかと言えばCTF的な解き方をされている方もおられて、それはそれで悪くはないのですが、本来の趣旨からは外れるものです。与えられた問題文からコードを短く書くというお題に集中してもらうために、より強固なサンドボックス機構でユーザーコードを動作させたいと考えました。

また複数の言語に対応するとなると、サンドボックス機構はさらに重要です。同じLambda関数に言語ランタイムを全部入れると別の言語のランタイムやコマンドをsystem関数で呼び出されてしまったりして純粋な言語のスコアが出せません。また、分けるとなると管理が煩雑になります。ホストが指定したファイル・ディレクトリ・環境変数などしか読めず、またネットワークには確実に繋がらないようなサンドボックスが欲しいところです。

というわけでWebAssemblyをサンドボックス機構として使えないかという話につながります。

YAPC::Hakodate 2024開催時点での構成

YAPC::Hakodate 2024時点でのPerlbatrossの構成は以下のようになっていました。

Perlbatrossの構成図

インターネットからリクエストが来るとCloudFrontがwebsite LambdaをFunction URL経由で呼び出します。website LambdaはHTMLをレンダリングしてレスポンスを返します。またここにはないですが、CSSなどの静的なファイルはS3に置かれておりLambdaを介さずにCloudFrontから直接配信されます。

特徴的なのはjudge Lambdaです。website LambdaはLambda extensionとしてlamuxを起動しており、lamux経由で別のLambda関数であるjudge Lambdaを呼び出します。lamuxについては以下の記事が詳しいです。

zenn.dev

website Lambdaからjudge Lambdaへは問題の入力データとユーザーが提出したコードが渡されます。judge Lambdaは純粋にユーザーが提出したコードの実行のみを行います。出力結果のテスト自体はwebsite Lambda側で行います。

judge Lambda内でもLambda関数自体のperlプロセスとは分離してperlコマンドを実行する形でユーザーコードを実行していました。この時環境変数をフィルタするなどの処理も行っていました。つまり、Lambdaの環境自体をサンドボックス環境として利用していたのがYAPC::Hakodate 2024時点での構成でした。

WebAssemblyを使った新しいjudge Lambda

YAPC::Hakodate 2024開催時点ではwebsite Lambdaとjudge Lambdaは両方ともPerlで実装されていました。しかし今回はWASMランタイムを内包する関係上judge LambdaをGo実装に置き換えました。PerlでWASMを扱うCPANモジュールにはいくつかあるのですが、更新日時が古く、トラブルが出ることを避けたためです。

PerlのWASMモジュールの用意

PerlのWASMモジュールはzeroperlというリポジトリを利用しました。zeroperlはPerlにパッチを当ててWASMモジュールとしてコンパイルしたものです。zeroperl自体はWASI Preview 1をターゲットにしています。

zeroperlを動作させるには以下の手順が追加で必要です。

  • LANG環境変数にCをセットする
  • /dev/null を用意する

これを踏まえてWASMランタイムを選定していきます。

WASMランタイムの選定

WASMランタイム選定時には以下の要件がありました。

  1. Goで利用可能なこと
  2. WASI Preview 1に対応していること

1は言わずもがな、Goからライブラリの形で利用できるのが望ましいとしました。2ですが、WASM単体の仕様だと標準入出力やファイルの読み書きといったIOの仕様がありません。そこでWASIと言う形で標準的なIOの仕様が定義されています。現在WASIはPreview 2まで出ているのですが、多くのランタイムやWASMモジュールはPreview 1までの対応に留まっているため、今回はWASI Preview 1に対応していることを要件としました。

wazero

GoにはwazeroというピュアGoで実装されたWASMランタイムがあります。最初にこちらを試しました。WASI Preview 1にも対応しています。断片的なコードですが以下のような感じで実行するようにしました。

compiled, _ := rt.CompileModule(ctx, perlWasm)
af := afero.NewMemMapFs()
iofs := afero.NewIOFS(af)
root := &sysfs.AdaptFS{FS: iofs}
fsconf := wazero.NewFSConfig().(sysfs.FSConfig).
    WithSysFSMount(root, "/").(sysfs.FSConfig).
    WithSysFSMount(&devFS{}, "/dev")
afero.WriteFile(af, "code.pl", []byte(code), 0o644); err != nil {
mc := wazero.NewModuleConfig().WithFSConfig(fsconf)
config := mc.
    WithStdout(&bytes.Buffer{}).
    WithStderr(&bytes.Buffer{}).
    WithStdin(strings.NewReader(input)).
    WithEnv("LANG", "C").
    WithArgs("perl", "code.pl")
mod, _ := r.rt.InstantiateModule(ctx, compiled, config)
mod.Close(ctx)

これでperl -v等は動作しました。しかし、いざスクリプトを動かすと以下のようなエラーが発生しました。

instantiate failed: module[] function[_start] failed: wasm error: out of bounds memory access
        wasm stack trace:
                .$1194(i32,i32,i32,i32,i32,i32,i32,i32) i32
                ...

つまりWASMモジュール内でメモリアクセス違反が発生してしまっているようです。時間も差し迫っていたのでこの時点でwazeroを一旦止めて別のランタイムではどうかを試すことにしました。

脇道に逸れて: OOBが発生する原因

Anybatross終了後に追加で検証したところ、オリジナルのzeroperlでは、wazero上でファイルマウントをしたファイルを読み書きするとOOBが発生することがわかりました。

ファイル読み込みに関連する何かで問題が起きていると推察しました。ちなみにzeroperlでは標準モジュールなどの動作に必要なファイルをperlコマンド実装内のファイル読み込みにフックを差し込み、ファイルをCのバイト配列に変換した巨大なC言語のコードと共にコンパイルすることで、WASMモジュール内にファイルを埋め込んでいます。

一方RubyのWASMモジュールであるruby.wasmでは、wasi-vfsというライブラリを利用して同様の仮想ファイルシステムを実装しています。そこで、zeroperlの既存の仮想ファイルシステム部分をwasi-vfsに置き換えたところ、wazero上でもOOBが発生しなくなりました。wasi-vfsは後からファイルを追加することも可能になるため、こちらの方が柔軟性も高いです。まだ公開はしていませんが、wasi-vfsを利用したzeroperlのフォークを作成中です。

wasmtime-go

次に試したのがwasmtime-goです。wasmtimeはWebAssemblyに関する仕様策定を行なっているBytecode Alianceが管理しているWASMランタイムです。いわば公式実装の一つと言えます。このランタイムもWASI Preview 1に対応しており、Goバインディングも公式に提供されています。wazeroと違い、cgoを利用しているため、クロスコンパイル等の注意が必要です。

また、wasmtime-goにはwazeroにあったようなGoのインターフェイスを利用したファイルしテムマウントの仕組みはないため、tmpディレクトリを利用してホスト側のディレクトリをマウントすることでファイルの読み書きを実現しました。

wasmtimeであればzeroperlも問題なく動作したため、最終的にwasmtime-goを採用しました。

各言語モジュールの用意

AnybatrossではPerl以外にもプログラミング言語をサポートしたかったため、他の言語のWASMモジュールも選定しました。選定は以下の要件で行いました。

  • WASI Preview 1に対応していること
  • コンパイル済みのWASMモジュールが公開されていること
    • 時間がなく、自分でビルドする余裕がなかったため
  • 出来るだけ公式もしくは言語公式のコントリビューターが管理していること

Ruby

先ほど登場しましたが、ruby.wasmを利用しました。リポジトリのURLを見るとおりCRuby公式のOrganizationで管理されているリポジトリです。zeroperlが利用しているasyncifyと言う仕組みもこのruby.wasmを開発している方がRubyKaigiで発表したトークで知りました。

logmi.jp

ruby.wasmは配布されているtarballは、rubyコマンド本体と標準ライブラリなどが含まれたlibディレクトリに分かれています。これをwasi-vfsのpackコマンドでバンドルした上で、judge Lambda内に組み込みました。

Python

Pythonについてはbrettcannon/cpython-wasi-buildリポジトリ内のReleasesから配布されているものを利用しています。CPythonの公式ではWASMアーキテクチャはTier2サポートとなっていますが、公式からのビルド済みバイナリはありません。このため、Tier2サポートのメンテナーとして掲載されているBrett Cannon氏が管理しているこのリポジトリのモジュールを利用しました。

このリポジトリから配布されているtarballにはCPython本体のWASMモジュールと標準ライブラリが分かれて含まれています。ruby.wasmと同じですが、wasi-vfsは組み込まれていません。なので、judge LambdaのDockerイメージ内でPython向け標準ライブラリを展開した上で、Python起動時に標準ライブラリディレクトリをマウントさせて動作させました。

JavaScript

JavaScriptのランタイムはquickjs-ng/quickjsを使用しました。元となったQuickJSはC言語で実装された軽量なJavaScriptランタイムです。現在は開発が継続していますが、停滞していた時期にQuickJS-NGがフォークとして立ち上げられました。その後QuickJS-NGにはWASMターゲット向けのビルドが追加され、Releasesから配布されています。Anybatrossではこのビルドを利用しています。

なお、QuickJSはNode.jsなどのランタイムとは異なるAPIを持っています。標準入出力はstd.in.readAsString()std.out.puts()などのAPIでアクセスします。問題を解く際にここで戸惑った方もいたかもしれません。ちゃんとAnybatrossのFAQ内のランタイム情報を確認しドキュメントを確認した上で解いた方はえらいですね!

PHP

最後にPHPですが、vmware-labs/webassembly-language-runtime内のPHPのWASMモジュールを利用しました。このリポジトリは各種の言語ランタイムが集められてビルドされたものが配布されているのですが、更新が昨年で止まっています。README.mdではwebassemblylabs/webassembly-language-runtimesに引き継がれているように書かれていますが、こちらも昨年で更新が止まっており、Releasesも存在しません。なのでVMWare labs側のReleasesで配布されているPHP 8.2.6のWasmEdge版のモジュールを利用しました。PHPを使われている方にとっては若干古いバージョンで申し訳ありません。

そしてこのWasmEdge版というのがまた曲者です。WasmEdgeはwasmtimeとは別のWASMランタイムで、"基本的には"WASI Preview 1に準拠しています。しかし、WasmEdge独自にexportされた関数としてwasi_snapshot_preview1::sock_openなどのソケット関連の関数があります。そのため、wasmtimeでPHPのWASMモジュールをそのまま起動すると、この関数がないと言われてしまいます。

Anybatrossではユーザー提出のコードがSocketなどのネットワークプログラミングを行う必要はなく、むしろ制限したいため、独自に呼び出した瞬間にエラーになるstub関数を用意して対応しました。

コールドスタート対策

これでローカルの開発環境では全ての言語が動作することが確認できました。しかし、Lambda上で動作させるとコールドスタート時にinit phaseでタイムアウトが起こり、起動しないことがわかりました。各WASMモジュールはGoの関数起動時に読み込む処理が走ります。wasmtimeはコンパイル型のランタイムであるため、WASMモジュールを実行に適した形式に変換するコンパイル処理が行われます。これが予想以上に時間がかかってしまっていたようです。

そこで、AOT(事前コンパイル)を利用することにしました。wasmtimeコマンドではwasmtime compileコマンドでWASMモジュールをAOTコンパイルすることができます。それぞれのモジュールを事前にコンパイルしてDockerイメージに組み込むことで、コールドスタート時のinit phaseでのタイムアウトを防ぎました。なお、wasmtime compileで生成される成果物はアーキテクチャとOS依存があるため注意が必要です。今回は開発はApple Silicon Mac, Lambdaはamd64のLinuxだったため、Macのamd64で起動するように設定したDockerコンテナ上で生成しました。

まとめ

このような形でWebAssemblyを用いることでかなり強固なサンドボックス環境を実現できました。本当は私が常用するGo言語にも対応したかったのですが、コンパイル環境をどうするかという問題を解決できず、今回は見送りました。次回は何らかの方法でGoのようなコンパイル言語にも対応できたら良いなと思っています。

また、今回はコードゴルフのコンテストサイトでしたが、ユーザースクリプトを実行したいサービスなどにも応用できると思います。私の発表でもCodeActのような形でAI生成のコードを実行したいこともあるので、そういった場面でも軽量なサンドボックス環境として利用できるのではないかと考えております。

宣伝

YAPC::Fukuoka 2025のリジェクトコンをSmartHRさんと一緒に行います!

https://smarthr.connpass.com/event/376033/smarthr.connpass.com

私も今回Anybatrossで使用したzeroperlの他の使い道について語ります。ご期待ください。

以上です。よいWebAssemblyライフを!

次はid:t-akihitoによる「爆破解体(未完)のその後を書くぞ」です!