ダイナミックSpinel(3) Railsコンソール編
(1) eval編と(2) DSLプラグイン編のつづきです。今回は、動いているバイナリにソケットでつなぐコンソールを作ります。つなぐ先として、いちばん面白そうなのがRailsアプリです。この記事では、RoundhouseでSpinel向けに変換したRailsアプリにFluxpinelを組み込んで、動いているサーバの中をソケット越しにのぞいてみます。
いまのところ無改造というわけにはいきませんが、Roundhouseの出力を手で少し直せば動くところまで来ました。Fluxpinel側でどこまでできているか、手作業をなくすためにRoundhouseとSpinelに何を提案したいか、実現したらRailsアプリでどう使えるか、を書きます。
Roundhouseがつくるバイナリ
Roundhouse1は、Railsアプリを別の言語のプロジェクトとして出力するツールです。ターゲットにはRust、Go、TypeScriptなどがあり、そのひとつがSpinelでコンパイルするRubyです。
Spinel向けの出力では、RoundhouseはRailsアプリを「メタプログラミングを使わないRuby」に書き替えます。Active RecordやAction Controllerにあたるフレームワークの部分も、Spinelでコンパイルできる形のRubyとして一緒に出力されます。それをspin build2でコンパイルすると、ひとつの実行ファイルになります。データはSQLiteのファイル(既定ではstorage/development.sqlite3)に置きます。
Roundhouseのリポジトリには、変換を確かめるための小さなブログアプリを作るスクリプト(scripts/create-blog)が入っています。rails newとRails 8のscaffoldで、記事(Article)とコメント(Comment)のアプリを作るもので、Turbo StreamsとAction Cableも使っています。RoundhouseのCIは、このアプリを各言語に変換して動かし、同じURLに本物のRailsと同じHTMLを返すかを比べています。この記事でも、このブログアプリを題材にします。
できあがったバイナリはHTTPサーバです。接続ごとにSpinelのグリーンスレッドを1本立てて、CPUのコアごとにOSのスレッドを1本使います。リクエストの読み書きで待つあいだ、グリーンスレッドは順番をほかのスレッドに譲ります。つまり、待ち時間の多い、常駐するプログラムです。外からつないで中をのぞくには、ちょうどいい形をしています。
Fluxpinelを組み込んだときのプロセスの中身
ここからは、あくまで現時点の想定です。Fluxpinelを組み込んだバイナリの中では、次のものが動く見込みです。
+-------------------------- build/blog/blog(1つのプロセス) -----------------------------+
|
| グリーンスレッド(Spinelのスケジューラが回す)
| [accept] 接続を待つ ---------> 接続ごとに [req] を立てる
| [req] リクエストを読む -> ルーティング -> コントローラ -> SQLite -> 応答を書く
| [jobs] ジョブのキューを待つ
| [console] 127.0.0.1:4000 を待つ ---> 接続ごとに [sess] を立てる <- 足すもの
| [sess] 行を読む -> Fluxpinel.feed(session, line) -> 返事を書く <- 足すもの
|
| コンパイル済みのコード(Railsアプリとフレームワーク) Fluxpinel(Rubyインタプリタ)
| Spinelのランタイム: 1つのヒープ、1つのGC、例外、組み込みメソッド
+-----------------------------------------------------------------------------------------+
[console]と[sess]は、コンパイル済みのRubyとして書きます。ソケットを受けて、行を読み、Fluxpinelに渡し、返事を書くだけです。Fluxpinelが動くのは、[sess]がFluxpinel.feedを呼んだときだけで、そのグリーンスレッドの上でコードを評価して戻ります。コンソールでConsole.count("Article")を打つと、[sess]のスレッドがコンパイル済みのArticle.countを呼びます。リクエストのスレッドが同じメソッドを呼ぶのと、やっていることは変わりません。
この形なら、ふだんのアプリの動きはほとんど邪魔しないはずです。
- リクエストはFluxpinelを通らない: リクエストのスレッドが実行するのは、コンパイル済みのコードだけ
- 待っているあいだは何もしない: コンソールのスレッドは、ふだんはソケットを待っている。待っているグリーンスレッドはスケジューラから外れるから、CPUを使わない
- 評価のあいだも順番を独り占めしない: 計算が長く続くスレッドには、ランタイムの監視スレッドが「順番を譲れ」と合図する。Fluxpinelも、ループの折り返しとブロックの呼び出しのたびに、この合図を確かめる。コンソールに重い処理を打っても、リクエストに順番が回る(ループやブロックを使わない深い再帰は除く)
- 値を写さない: コンソールが持つオブジェクトはアプリと同じヒープにあって、同じGCが面倒を見る
難点はあります。Rubyのコードを動かすOSのスレッド(ワーカー)が1本になることです(後述)。コンソールで打った重い処理はそのぶんリクエストとCPUを分け合います。セッションは打ったコードを持ち続け、メモリ消費が少しずつ増えます。
動かしてみた画面
Roundhouseの出力を手で少し直し(これも後述)、Fluxpinelを組み込んでビルドしたところ、ここまで動きました。ブログのサーバを動かしたまま、別の端末からつなぎます。
手で直した差分とビルドの手順は、Fluxpinelのリポジトリのexamples/blogに置きました。使ったのは、Roundhouseの2953060d9(2026年10月23日のmain)と、Spinelのd693a9b3aです。RoundhouseもSpinelも毎日のように変わるため、別の組み合わせでは、そのままでは動かないかもしれません。
ブログのディレクトリで起動します($FXはFluxpinelのディレクトリです)。
$ FLUXPINEL_CONSOLE=4000 "$FX/build/blog/blog" &
real_blog (spinel) listening on http://localhost:3000, pid 2510874
fluxpinel console on 127.0.0.1:4000
$ nc 127.0.0.1 4000
fx> Console.count("Article")
=> 3
fx> a = Console.find("Article", 1)
=> #<Article:0x00007856818a8df0 @errors=[], @error_details=[], ... @id=1, @title="Getting Started with Rails", ...>
fx> a.instance_variable_get(:@title)
=> "Getting Started with Rails"
fx> c = Console.create("Comment", {article_id: 1, commenter: "fx", body: "コンソールから書きました"})
=> #<Comment:0x00007856818a9070 @errors=[], ...>
fx> c.instance_variable_get(:@id)
=> 4
fx> Console.count("Comment")
=> 4
fx> exit
(inspectの出力は途中を省いています)
ブラウザに記事のページを開いておき、Console.create(...)を実行すると、リロードしなくても「コンソールから書きました」というコメントが現れます。Turbo StreamsがAction Cableを通して、開いているページに届けるからです。サーバは止めていないし、再コンパイルもしていません。exitで閉じるのはコンソールのセッションだけで、Railsはそのまま動き続けます。
Console.count(...)やConsole.find(...)は、コンパイル済みのコードに用意する、コンソール用の入口です。いや、わかる、Article.countと書きたい。いまはそう書けない理由があります。焦っせんなよ。
いまのFluxpinelができていること
| 項目 | 状態 |
|---|---|
| コンパイル済みのプログラムとFluxpinelをひとつのTUでビルドする | できている |
| スクリプトから、コンパイル済みのモジュールのメソッド(エントリ)を呼ぶ | できている |
コンパイル済みのオブジェクトを値として持つ。class、is_a?、inspect、==を使う |
できている |
コンパイル済みのオブジェクトのインスタンス変数を読む(instance_variable_get) |
できている |
| グリーンスレッドの上でスクリプトを動かす | できている |
| コンソールの評価(評価の単位、書きかけの行、行をまたぐローカル変数) | できている |
| 複数のOSスレッドで動くプログラムに組み込む | まだ |
コンソールの仕組みは、Fluxpinelの2つの関数です。コンパイル済みのコードが、ソケットから読んだ行をそのままFluxpinelに渡します。
module Fluxpinel
native_func :session, [], :any, "fx_api_session"
native_func :feed, [:any, :string], :string, "fx_api_feed"
end
def serve_console(sock)
session = Fluxpinel.session
sock.write("fx> ")
while (line = sock.gets)
reply = Fluxpinel.feed(session, line)
break unless reply # exitでセッションが閉じた
sock.write(reply)
end
sock.close
end
ソケットを扱うのは、コンパイル済みのコードの側です。Fluxpinelは、行を受け取って、返す文字列を作るだけ。
Fluxpinel.feedは、受け取った行をセッションのバッファにためて、毎回Prismがパースします。
セッションは、評価のたびにローカル変数を引き継ぎます。さきほどのコンソールがaを次の行で使えるのは、このためです。
コンソールでexitを打つと、閉じるのはセッションだけです。サーバはそのまま動き続けます。
マルチスレッドはそのうち
表の最後の行「複数のOSスレッドで動くプログラムに組み込む」は、まだできません。Fluxpinelの内部の状態(メソッドの表やフレームのスタック)は、OSのスレッドをまたいで共有できるようにできていないからです。そこでFluxpinelは、組み込んだプログラムの、Rubyのコードを動かすOSのスレッド(ワーカー)の数を1に固定します(SPINEL_WORKERS=1)。ランタイムの監視スレッドは、これとは別のOSスレッドで動きます。
ブログのサーバも、1本のワーカーで動きます。グリーンスレッドは、1本のワーカーの上を順番に動きます。上の画面のとおり、ページもふつうに返ってきます(Roundhouseのサーバも、はじめは1本のOSスレッドで動いていました)。CPUのコアを1つしか使わないぶん、混んだときの速さは落ちます。
コンソールの評価にだけロックをかければ、リクエストの処理は複数のOSスレッドのまま並べて、コンソールだけを1本ずつ動かせるかもしれません。GCとの関係も含めて調べることが多いので、これは後回しにしています。
Roundhouseへの提案
上の画面は、Roundhouseの出力に、3つの手作業を加えて作りました。この3つを、Roundhouseに提案したいと思っています。
1. サーバの起動をエントリに移す
Roundhouseが出力するmain.rbは、ファイルのトップレベルでサーバを起動して、そのままリクエストを待ち続けます。
一方、Fluxpinelを組み込んだバイナリでは、Fluxpinelが動き出せるのは、コンパイル済みのプログラムのトップレベルが終わったあとです。トップレベルでサーバのループに入られると、Fluxpinelの出番が来ません。
そこで、サーバを起動する部分をメソッド(たとえばMain.start)に移してもらい、バイナリはそのメソッドから始まるようにしたい。コンソールのスレッドもそこに立てます。こんな感じ:
module Main
def self.start(argv)
# ...(もとはトップレベルにあったコード)
console_port = ENV["FLUXPINEL_CONSOLE"].to_s.to_i
Thread.new { Console.listen(console_port) } if console_port > 0
Tep::Server::Threaded.new(MainApp.new).run(port, workers, false, unix_listener)
end
end
2. コンソール用のエントリを生成する
Console.count("Article")のような入口は、モデルごとに分岐するメソッドです。
module Console
def self.count(model)
case model
when "Article" then Article.count
when "Comment" then Comment.count
else raise ArgumentError, "unknown model: #{model}"
end
end
end
Spinelは、文字列から実行時にクラスを探して呼ぶコードをコンパイルできないから、こういうcaseで書く必要があります。Roundhouseはもともと、ルーティングの表からコントローラーを選ぶところを、同じ形のcaseで出力しています。Roundhouseはモデルの一覧も知っているから、count、find、where、create、updateのような入口を生成するのは、Roundhouseの得意な仕事のはずです。
3. Fluxpinelを組み込むルート
Fluxpinelを組み込むビルドは、ふつうのspin buildとは手順が違います。Spinelにコンパイル済みのプログラムをライブラリとしてCに出力させて、FluxpinelのCのコードと合わせて、ひとつの翻訳単位としてコンパイルします。今回は、Fluxpinelのmake hostに、spinがSpinelに渡すフラグ(RBSの場所など)とリンクするライブラリ(SQLiteなど)を手で渡しました。Roundhouseの出力に、このビルドを選ぶオプション(たとえば--console)があれば、使う人はビルドの細かい手順を知らずに済みます。
Spinelへの提案
ここまでの形で、上の画面は作れました。ただ、Console.count("Article")というのがぜんぜんRubyらしくありません。ここから先はSpinelの変更が要ります。
コンパイル済みのオブジェクトのメソッドを呼ぶ
いまSpinelがエクスポートできるのは、モジュールのメソッドだけです。だから、スクリプトからa.titleとは書けず、インスタンス変数を直接読むか、エントリを経由するしかありません。
Spinelが、コンパイル済みのオブジェクトのメソッドを名前で呼ぶ口を出せるようになれば、コンソールでa.titleやArticle.where(...).countとそのまま書けます。Roundhouseがコンソール用のエントリを生成する必要もなくなります。(1)eval編で「動的ディスパッチ」として書いた変更と同じ理屈です。
spinがFluxpinelを組み込む
Roundhouseの出力はspinのプロジェクトです。ところがspinがライブラリとしての出力を使うのは、CRubyの拡張ライブラリを作るときだけです。アプリのビルドで、Fluxpinelのようなホストと一緒にコンパイルする道をspinに用意してもらえれば、Roundhouseのオプションはその上に作れます。
複数のOSスレッドとの共存は、先に書いたとおり、とりあえずFluxpinelの側の課題です。Spinelのランタイムにパッチが必要かどうかは、まだわかりません。
任意のRailsアプリではどう使えるか
Roundhouseが任意のRailsアプリを変換できるなら、変換したバイナリにFluxpinelを組み込んで、次のように使えます。
- 本番のバイナリでの
rails console: レコードを数える、探す、壊れたレコードを直す、といった作業を、動いているバイナリに対して行う - 動いているプロセスの中を見る: ジョブのキューの長さ、キャッシュの中身、Action Cableの購読の数など、プロセスの中にしかない状態を、エントリを通して読む
- 再起動しない設定の切り替え: ログの細かさや機能フラグを、コンパイル済みのコードがFluxpinelに問い合わせる形にしておけば、コンソールから切り替えられる
rails runnerの代わり: 一回きりのデータの修正スクリプトを、コンソールからloadで動いているプロセスに流し込む。再コンパイルもデプロイも要らない
できないこともはっきりしています。コンパイル済みのメソッドは差し替えられません。コンソールでArticle#titleを再定義しても、コンパイル済みのコードは古いtitleを呼びます。
コンソールは、同じプロセスの中のことならなんでもできてしまい危険なので、ソケットはUNIXドメインソケットにだけ開くべきでしょう。127.0.0.1には、同じマシンのほかのユーザもつなげます。認証もありません。パーミッションで守れるUNIXドメインソケットのほうがいくらか安全です。
まとめ
Roundhouseは、Railsアプリを静的な形に書き下して、Spinelで速いバイナリにします。Fluxpinelは、その静的なバイナリに、もう一度Rubyの動的なところを差し込みます。逆向きの道具のようですが、組み合わせると、速さを保ったままrails consoleの便利さを取り戻せます。
Roundhouseの出力を手で直すなら、すでに動いています。この手作業をなくせるよう、動いた差分を持ってRoundhouseとSpinelに提案できたらいいなと思っています。
-
spinはSpinelのプロジェクトツールです。依存するパッケージを解決して、プロジェクト全体をひとつのCの翻訳単位にしてからコンパイルします。 ↩