🙌

WasmLinux: WebブラウザでLinuxカーネルとBusyBoxを動かす(エミュレーションなしで)

2024/02/28に公開

WebブラウザでOS動かしてどうすんだよ という根源的な疑問に回答が無いままとりあえずできちゃった。。

https://wasmlinux-demo.pages.dev/

※ コマンドが終了してもプロンプトが出ません。Enterを空打ちする必要があります (バグ)

WasmLinuxは、WebAssembly "ネイティブ" なLinux環境です。カーネルもユーザーランドも、WebAssemblyのツールチェインでコンパイルされたWebAssemblyモジュール(をwasm2cでCにしたもの)です。

前回はカーネルしか動いていなかったんですが、今回はブラウザ上で ifconfig lo up して ping 127.0.0.1 したり top したり vi したりできます。BusyBox入ってるので。 ただしまだ実用性は皆無 です。Proof of Conceptって奴ですね。

https://twitter.com/dotmjt/status/1762538791989084649

前回の記事:

https://zenn.dev/okuoku/articles/73c36d078790f4

今回はMUSL libcを移植してBusyBoxが動くようになった、要するに、 このWebブラウザ上で動くWasmLinux向けのアプリが作れる段階になった というのが更新点です。

SDKとかディストリビューションはそのうち用意しようと思います。ただ、一度作っちゃうとメンテがクッソ大変なので。。

WebVMとかContainer2wasmとは何が違うのか?

この話題は避けて通れないので普段の記事と違って最初に書いておきます。

世間には WebVMcontainer2wasm のように、既存のCPUをWebAssembly上でカーネルごとエミュレートすることで既存のアプリをそのまま動かそうというソリューションが既にあります。いやまぁ Emscriptenだって昔はARM32向けに生成したLLVM-IRを直接JavaScriptに変換して動いていた んだけど。。

WasmLinuxはこれらと違ってCPUのエミュレーションはせず、 カーネルとユーザーランドの両方をWebAssemblyに直接コンパイルする ことを目指しています。これにより、普通のC/C++アプリケーションをWebAssemblyで動かそうとすると何が足りないのかがより浮き彫りになる。はず。

パフォーマンス的には、ぶっちゃけ大差ないと思います(ブラウザが動くくらい最新のCPUは十分に高速なため)。ただしJITCの段数を減らせるぶん消費電力の面では有利になるかも。

  • pros: 一度WasmLinuxに移植すれば、WebAssemblyが動くところならどこでも動く
  • cons: Linuxの常識が全く通用しない環境になる ため、どうやっても移植コストはゼロにならないと考えられる -- busyboxはコードの修正は一切不要だったけど、NOMMU環境で動かないアプリはかなり厳しい
    • NOMMU環境の厳しさ: fork が無い(vfork のみ)、 mmap でファイルをマップしたり共有メモリを作ったりできない、...
    • WebAssembly環境の厳しさ: ガチのハーバードアーキテクチャ(命令列がメモリに配置されない)、C言語仕様に死ぬほど厳密、そもそもELFでない、...

結構consが重いんですよね。。それでも完全に無意味なプロジェクトって程では無いと思っていますが。。(後述)

他に、WASIのPOSIX"風"インターフェースの代わりにLinuxのAPIを直接使えば移植コスト減らせるじゃんという WALI もありますが、WALIは結局動作にLinuxが必要なのでポータビリティの面ではWasmLinuxより一段下がると考えています。Wasm上のLinux ABIという席を争う立場ですね。

やったこと

一応(元)プロなのでやればできる。何のプロだよって感じだけど。。

構成

大まかなプロジェクト構成はデモページ https://wasmlinux-demo.pages.dev/ にも書いていますが、やったことを軸に纏めると:

WasmLinuxは Runner と呼んでいるコンポーネントでカーネルとユーザーランド、外部環境を連携させています。Runnerは普通のC++20アプリで、ブラウザ版はEmscriptenでビルドされます。Runnerの仕事としては:

  • Wasm側のインスタンシエート、メモリ確保やスレッド生成等LKLやユーザランドの動作に必要なものの実装
  • 一部syscallの実装 -- 主にvforkやclone
  • 疑似的なinetdを実装しており、外部からのtelnet接続を受け入れ、WasmLinux環境内でtelnetdを起動してコンソールを提供

といったグルーコード的な役割を果します。

インチキ

デモページの図にもあるように、ブラウザ側のWasmエンジンを直接使わず、LinuxもBusyBoxも 一旦wasm2cでCに変換してから改めてEmscriptenに掛けています 。自分で setjmp/longjmpを実装するのが面倒だったんで。。

あと、このような構成を取ることで、単一のC++プログラムをPC上とブラウザ上の両方で動かすことができてデバッグに有利なんです。。

WASI-libcと同じような実装をすれば、Webブラウザ側のWebAssemblyエンジンで直接実行できるようになるはず。

https://github.com/WebAssembly/wasi-libc/pull/467

更に:

  • wasm2c が追加するメモリアクセスのbound checkを削除(ブラウザ側のエンジンでも行われるので冗長) -- パフォーマンスよりも.wasmのサイズに効く。これだけで 25MiB → 17MiB になったのでCloudflare Pagesにデモを置けるようになった
  • Emscriptenの std::counting_semaphore 実装が何故か超クッソ激烈に遅かった & Dev toolを開いてる間だけ高速化するという辛い状態だったのでPOSIX semaphoreに置き換え

というワークアラウンドを入れています

https://zenn.dev/okuoku/scraps/322d4329de5700

Musl libcの移植

足りないヘッダをARM32からコピペして、終わりッ!

https://github.com/okuoku/wasmlinux-musl/commit/ff1d7f2077f5c0b9c967951014f47f62992beebc

... syscall stubは流石に専用のものを実装しています。

https://github.com/okuoku/wasmlinux-musl/tree/f6bd94f8d03f8e41dc817b44a6ac981e0280b4f0/arch/wasm32

あと、Webブラウザの仕様上 pthread_cancel は実装しようが無いので一旦除いています。Web workerに対して非同期シグナルを送ることはできません。 SIGTERM で強制的に消すとかは将来実装するかもしれないけど。

前回問題になったような、C関数の引数を適当に増やして呼ぶ(これは未定義挙動)のはMusl libcも同じなのでそこそこの数の対策を入れています。

https://zenn.dev/okuoku/scraps/3c3838befe00e4

ユーザースレッド(clone)の実装

clone syscallは Runner側で実装 しています。今のところ fork APIは実装していないので、Runnerがサポートしているのは pthread_create 等新規スレッドの作成 CLONE_THREAD のみです。

https://zenn.dev/okuoku/scraps/2bc933b986f489

cloneやforkのようなAPIは普通のC言語関数としては表現できないので、WebAssemblyに持ってくるにはどうしても高レベルな実装を機能別に用意してやる必要があります。なのでカーネル側の実装を直接使うことはできません。

vforkの実装

ブラウザ環境ではMMUを実装できないので、fork の代わりに vfork を実装しています。この制約はNOMMU Linux(uClinux)と同じですね。vforkはforkの厳密なサブセットで、プロセスのコピーを伴わないため簡単に実装できます。が、仕様が微妙すぎるので 最新のPOSIXではgetcontextやusleepとかと一緒に廃止 されました。

vforkはGCC拡張であるstatement as expressionとsetjmp/longjmpを使って実現しています。

https://zenn.dev/okuoku/scraps/e2efd931afaee8

GCCやClangの statement as expression拡張構文 は、関数型言語のように最後の式が返値となるようなシーケンスを記述できます。C言語マクロではよく do{ ... } while(0); イディオムが使われますが、それの値を返却できる版ですね。

POSIXではvforkは(というか unistd.h で宣言されるAPIは全て)Cマクロでも良い[1]ということになっているので、それを利用して:

https://github.com/okuoku/wasmlinux-runner/blob/908d4a535035913fcdea05610027160a09206ca4/umwrap/wasm2c/w2cfixup.h#L17-L20

のようなマクロとしています。ここの run_to_execveRunnerに実装されていて 、ユーザーランドのスレッドはそのままに カーネルコンテキストだけ差し替えて 続きを実行し、execve 箇所まで到達したら:

という実装になっています。

... ただし、このような setjmp の使い方は実はCでは違反とされています。 setjmpの返値を変数にストアするのはダメ です。

https://zenn.dev/okuoku/scraps/f1044bc008b139

まぁTLSとかを経由して渡せば良いのかな多分。

シグナル配送の実装 (不完全)

Webブラウザの仕様上、完全にPOSIX準拠なシグナルは実装できません。というわけで、現時点では syscallの結果をアプリに返す前に、シグナルがあるかチェックして存在する場合はシグナルハンドラを実行する という実装にしています。

https://zenn.dev/okuoku/scraps/4d4612392e7266

これが、冒頭のデモでコマンド終了後にプロンプトが出ない問題に繋っています。つまり、プロセス終了時の SIGCHLD シグナルで read が中断されないため、ユーザーが何か入力することでread syscallから返らせないと処理が先に進まなくなってしまっているというわけです(多分)。

LKLの機能ではシグナルを処理できないので、 専用の getsignal 操作を追加しています 。syscallが戻してきた errnoERESTART だった場合にRunner側から呼ぶことで、スレッドに対するシグナル配送状態をクリアでき、シグナルハンドラへの関数ポインタを得られるようにしています。

疑似inetdの実装

冒頭のデモのコンソールは、Runnerに内蔵させたtelnetクライアントが出力しています。WasmLinuxシステムにtelnetを掛けるために、inetdに相当する機能をRunner側に持たせることにしました。

RunnerはWin32でも動かしたい & Emscriptenでも動かないといけないということで、専用のI/O抽象化レイヤ(miniio)を用意して:

をそれぞれリンクすることで、単一のコードで本物のtelnetクライアントのサポートとEmscripten上での擬似的な通信の両方を実現しています。

https://twitter.com/okuoku/status/1754807732266467383

ちなみに最初に実装したのはlibuvのバックエンドなので、最初の動作確認はPuTTYで行いました。

普通のinetdでは設定ファイルで起動するプロセスを指定できますが、今回の疑似inetdでは起動するプロセスをハードコードしています。

https://github.com/okuoku/wasmlinux-runner/blob/908d4a535035913fcdea05610027160a09206ca4/hostrunner/runner.cpp#L1534

ブラウザ内Telnetクライアントの実装

簡単なTelnetの実装に関してはZennにも良い記事があるんですが、今回はlibtelnetを使った自前の実装 (minitelnet) を用意しました。

https://zenn.dev/kumavale/articles/9ed167321294cd

https://zenn.dev/okuoku/scraps/fc50f69bfb0460

MinitelnetはPOSIX風の端末ioctlを使って出力するようにして、xterm-ptyアドオンを使ってxterm-jsに出力しています。xterm-ptyはRubyをブラウザで動かす記事で存在を知りました。

https://mametter.hatenablog.com/entry/2024/02/01/105413

かんそう

カーネルと違ってユーザーランドは、まぁ動いてあたりまえなのであまり苦労は無いかな。。

移植コスト問題

WasmLinuxの設計をするにあたって "Upstreamにメンテナンスコストを掛けさせない" という点を重要視しています。WASIとかWALIはかなり積極的な移植を求めているのに対して、WasmLinuxはBusyBoxを動かすのにソースコードの修正は不要にできた(ただしビルド周りは微調整必要)ので、この点が新しいんじゃないかなと。

この点を推しとかないと、将来Wasm用のLinux ABI標準なりLinuxディストリビューションなりを狙うときに誰が移植のメンテナンスコストを払うのかというのが問題として立ちはだかることになります。Debian/kFreeBSDとか無くなっちゃったし。。

WasmLinuxでDebian/WebAssemblyを狙う ...のは直近では難しいと思いますが、展開の面では意識せざるを得ない所です。残念ながらエコシステムの無いソフトウェアは価値を認められづらいので。WasmLinuxは、少くともEmscriptenやWASIよりはDebianになりやすい。。ようにしていきたいと思います。

というわけで、WasmLinuxの重要なタグラインはデモページにも書いたように Write once(by other folks), run anywhere(i want) です。他人が書いたプログラムを自分が動かしたいところで(苦労なく)動かせるように、システムを設計します。

エミュレーションに勝つには?

既存のアプリを活用するという観点で、WasmLinuxは

  • 既存のCPU(x86とかRISC-V)をエミュレーションする
  • WASIやEmscripten上に真面目に移植する

といったソリューションとの競争に直面しています。特に前者はWasmLinuxより消費リソースの面を除いて優れているのでどうしたもんかと。。

最もstraightforwardなのは、 WasmLinuxの存在意義はWebAssemblyの進化のためと開き直る ことでしょう。実際、WasmLinuxを突き詰めることで、WebAssemblyに既存のC/C++を移植するために必要な機能性を洗い出していくことが可能だと思います。

あとは、Linuxアプリの中間配付フォーマットとしての立場を狙うことも考えています。つまり、現代は有力なCPUアーキテクチャがx64、arm64、RISC-Vの3種存在するという、ちょっと前までのx64一辺倒の時代から変化しているので、パッケージとして配付後ターゲットCPU形式に 再度 コンパイルできるフォーマットに需要があるのではないか。

単に3回コンパイルしてそれぞれ配付すれば良いじゃん ...というのはごもっともなんですが、WebAssemblyからの再変換とすることでメモリレイアウト等が同一であることを保証できるので、x64で実行していたプロセスをARMで再開する といった、アーキテクチャ混在コンピューティングの可能性[2]を拓けるのではないかと期待しています。例えばAndroidみたいなプラットフォームのNDKにどう。。?ただ、以前AppleはApple WatchやAppleTVのようなIoT製品ではLLVM-IR(bitcode)納品を義務付けてたのに止めちゃったので、あんまり流行らないのかな。。

https://akerun.hateblo.jp/entry/2022/12/22/xcode14_bitcode

更に グラフィックスAPIのバインディングを用意する のも強い気がしていて、特に前Unity WebGLをネイティブ移植したとき に作成したWebGLのC言語バインディングをWasmLinuxに持ってこれると、そのEGL/OpenGL ES2スタブを使って既存のOpenGL ES2なアプリを移植できるようになります。 ...いやまぁEmscriptenでもできるんですけど、WasmLinuxはEmscriptenよりはLinuxらしい環境なので。。エミュレーターでも原理的には可能ですが、たぶんそういうところに真面目に取り組むところは居ないんじゃないかなと予想。

次の一手

前回の記事では次の一手をBusyBoxの実行に定め、今回はそれを達成しました。次の目標としては、コンパイラ + CMake + LLDB を動かして、マイコンの開発環境をWeb上に再現したい所。ただ、それはちょっと遠いので、最初は素のSDL2を動かす(OpenGL ES2やOpenALのバインディングを用意する)ところまで行けないもんかなと。

脚注
  1. このため、ユーザーが #undef vfork のようにundefしてきたり、 (vfork)() みたいな呼出し方をしてこないことを前提とできる ↩︎

  2. ここでは今思いついたように書いているけど実際は逆で、元々WasmLinuxは分散POSIXカーネル研究からスタートしているので、それに有利になるようなデザインを意図的に選んでいます。 ↩︎

Discussion