Firestore のローカル開発や CI で使う公式エミュレーターが重くてつらかったので、Rust で互換エミュレーター hidane(火種) を作りました。2026 年 10 月に最初のリリース v0.1.0 を出しています。
この記事では、公式エミュレーターの何が重かったのかを測った結果と、軽くするために設計で何をしたか、そして作り直しても既存のテストがそのまま通るように互換性をどう確かめているかを書きます。
hidane は非公式のオープンソースプロジェクトで、Google LLC とは関係ありません。Firebase と Cloud Firestore は Google LLC の商標です。
公式エミュレーターの何が重かったのか
まず、日々の不満を数字にしました。対象は公式エミュレーター v1.22.0 です。
1. Java 21 が必要
firebase-tools 15.0.0(2025 年 12 月)で、Java 21 未満でのエミュレーターの実行がサポート外になりました。jar 自体も Java 21 向け(クラスファイルのバージョン 65)にコンパイルされているので、直接起動する場合も JRE 21 以上が要ります。
ハマりやすい点もあります。
- 公式のインストールガイドには、いまも「Java JDK version 11 or higher」と書かれている(2026 年 10 月時点)
- firebase-tools は
PATH上のjavaしか見ない(JAVA_HOMEは見ない)ので、複数の JDK が入ったマシンでは、Java 21 が入っていても失敗することがある
ローカルでデータベースを 1 つ動かすためだけに、開発者全員のマシンと CI に JDK 21 を入れることになります。
2. 起動が遅く、メモリを食う
| 計測項目(公式 v1.22.0) | 値 |
|---|---|
ポートが開くまで(java -jar で直接、10 回の平均) |
740 ms(初回は 1.1 s) |
同じく firebase emulators:start --only firestore 経由(5 回の平均) |
2.5 s |
| 起動から 10 秒後のメモリ(RSS) | 95 MiB |
| 1,000 件書き込んだ後のメモリ | 456 MiB |
JVM のヒープ上限はデフォルトで物理メモリの 25%(計測したマシンでは 16 GiB)なので、書き込むほどそこまで膨らむ余地があります。
3. リスナーがあると、書き込むほど遅くなる
一番困っていたのがこれです。10 万件を 500 件ずつバッチで書き込むと、次のようになりました。
| 条件 | 合計時間 | 1 バッチあたりの時間 |
|---|---|---|
| リスナーなし | 11.0 s | ほぼ一定(5〜15 ms) |
コレクション全体に onSnapshot
|
121 s(11 倍) | 1 万件ごとに約 168 ms ずつ伸びる |
onSnapshot を limit(50) で |
65.5 s(6 倍) | 1 万件ごとに約 70 ms ずつ伸びる |
1 バッチで書く量は同じなのに、保存済みの件数に比例して遅くなっていきます。結果を 50 件に絞ったリスナーでも遅くなります。firebase-tools の #3477 などで報告されている症状で、Emulator UI を開くとリスナーが張られるため、「UI を開いていると遅い」という声とも一致します。
ここまでの数字は、調査の初期に他の重い処理が走っているマシン(M1 Max、ロードアベレージ 12〜58)で測ったものです。傾向を見るための数字で、静かなマシンでの再計測を予定しています。
「軽い」を設計の目標にする
作り始める前に、次の目標を決めました。
- 起動(ポートが開くまで)100 ms 未満
- 待機中のメモリ 50 MiB 未満
- 1 回のコミットのコストは、変更したドキュメントの数だけに比例させる。保存済みの件数やリスナーの有無に引きずられない
あわせて、互換性のために次の条件を守ります。
- 既存の SDK とテストコードをそのまま使える(gRPC・REST・ブラウザ向けの WebChannel を、公式と同じく 1 つのポートで受ける)
- firebase-tools の設定やコマンドを変えずに使える
- エラーメッセージやスナップショットの届き方まで公式と同じにする
軽くするためにやったこと
JVM をやめて、Rust の単一バイナリにする
起動時間と待機中のメモリは、JVM をやめた効果がほとんどです。hidane は Rust の単一バイナリで、Linux 版は静的リンクなので Alpine でもそのまま動きます。
サーバーは hyper の上に tonic(gRPC)と axum(REST・WebChannel・エミュレーター固有のエンドポイント)を載せ、1 つのポートで 3 つのプロトコルを受けています。振り分けはパスではなく content-type で行います。tonic はサービスごとに /google.firestore.v1.Firestore/* をまとめて受け取りますが、ブラウザ SDK の WebChannel も /google.firestore.v1.Firestore/Listen/channel のように同じ接頭辞の URL を使うためです。application/grpc で始まるリクエストだけを tonic に渡し、それ以外は HTTP のルーターに渡しています。
コミットが「自分の変更」を返すストレージ
リスナーがあっても書き込みが遅くならないことが、ストレージ設計の中心です。
- データはインメモリで、ドキュメントのパスを順序を保ったままバイト列にエンコードしたキーで持ちます。コレクションは 1 つのキー範囲になります。
- 各コミットには、データベースごとに単調増加するコミット時刻(マイクロ秒)を付け、ドキュメントはバージョンの履歴を持ちます。読み取りは「ある時刻の時点で最新のバージョン」を見るので、クエリ・トランザクション・リスナーが一貫したスナップショットを得られます。
- コミットは、触ったドキュメントの変更前と変更後を返します。
/// コミットで触ったドキュメント 1 件
pub struct Change {
pub path: Arc<ResourcePath>,
pub before: Option<Arc<StoredDocument>>,
pub after: Option<Arc<StoredDocument>>,
}
/// 成功したコミットの結果
pub struct Commit {
pub commit_time: ReadTime,
pub changes: Vec<Change>,
}
pub trait Store: Send + Sync {
// ...
/// `write` を 1 つのアトミックなコミットとして実行する
fn commit(
&self,
database: &str,
write: &mut dyn FnMut(&mut dyn WriteBatch) -> Result<(), StoreError>,
) -> Result<Commit, StoreError>;
}
リスナーへの通知は、この changes だけを見て、各リスナーのクエリに合うかどうかを判定します。データベースを読み直さないので、通知のコストは変更した件数に比例し、保存済みの件数には依存しません。
公式エミュレーターの内部実装はわかりません(jar の逆コンパイルはしていません)。ただ、症状が保存済みの件数に比例していたので、少なくとも「書き込みのたびに全体をなめる」経路を作らない設計にしました。
結果、コレクション全体にリスナーを張った状態で 10 万件を書き込んでも、1 バッチあたり約 10.5 ms のまま最後まで変わらず、合計 4.9 秒でした。
メモリを削る
ストレージ単体のメモリも測りながら削りました。最初の実装は 100 万件で 1.9 GiB でしたが、次の 3 つで 473 MiB(1 件あたり約 500 バイト)まで下がりました。
-
値の型を小さくする: protobuf から生成した値の型(
Value)が 72 バイトありました。めったに使わないパイプライン用のバリアントをBoxに逃がして、32 バイトにしました。 - フィールドをエンコードしたまま持つ: デコード済みのマップは、2 フィールドのドキュメントでも 11 要素分の領域を確保していて、1 件あたり約 1 KB になっていました。protobuf のバイト列のまま持ち、読むときにデコードします。
- パスを共有する: 1 つのドキュメントの全バージョンで、同じパスを共有します。
軽くても、挙動が違えば使えない
作り直しで一番怖いのは、「軽いけれど、乗り換えるとテストが落ちる」ことです。テストは公式エミュレーターに対して書かれているので、エラーメッセージの文言やスナップショットが届く順番が少し違うだけで壊れます。
そこで、**公式エミュレーターをオラクル(正解)**にしました。
- 公式エミュレーターに境界ケースを含むリクエストを投げ、応答をそのまま記録する(
tools/oracle/、現在 17 個のフィクスチャ) - hidane のテストスイートが、記録をすべて hidane に対して再生して突き合わせる
- Admin SDK、Web SDK(Node・Lite・ブラウザ)、
@firebase/rules-unit-testingを両方のエミュレーターに流し、結果を比べる
たとえば Authorization ヘッダーの扱いだけで 146 件あり、REST の応答 57 件はバイト単位で比べています。REST の JSON は、公式(Java の protobuf ライブラリ)の出力に合わせて、2 スペースのインデントや数値の書式まで揃えています。
ただし、公式のバグは真似しません。先ほどの「リスナーがあると遅くなる」もその 1 つで、こうした違いは docs/parity-exceptions.md にすべて書いています。オラクルの仕組みや、仕様が公開されていない WebChannel をどう実装したかは、Zenn の記事に詳しく書きました。
firebase-tools から、そのまま起動させる
もう 1 つの課題は、firebase-tools に公式の代わりに hidane を起動させる方法でした。firebase-tools は java -jar <公式の jar> でエミュレーターを起動するので、その java を差し替えることにしました。
hidane exec -- firebase emulators:start --only firestore
hidane exec は、一時ディレクトリに java として hidane を置いて PATH の先頭に足し、そのうえで後ろのコマンドを実行します。java として呼ばれた hidane は、次のように振る舞います。
- 公式エミュレーターの jar を起動しようとしている → 渡されたフラグ(ホストやポートなど)で、自分がエミュレーターとして動く
-
java -version(firebase-tools のバージョンチェック)→PATHに Java 21 以上があればその出力を、なければ Java 21 のバージョン行を返す - それ以外の Java の起動 →
PATH上の次のjavaに任せる
Ctrl-C はそのままコマンドに伝わり、コマンドが終わると一時ディレクトリを消して、コマンドと同じ終了コードで終わります。PATH を書き換えるのはそのコマンドの間だけなので、マシン上の他の Java には影響しません。mise や asdf の shim 経由で java が自分自身に戻ってきてバージョンチェックがループする問題には少しハマりましたが、環境変数で印を付けて回避しています。
Java が入っていないマシンでも、firebase emulators:start は 2.3 秒で準備完了になりました。hidane 自体は数 ms で起動するので、残りは firebase-tools 側の時間です。
結果
| 公式 v1.22.0 | hidane | |
|---|---|---|
| ポートが開くまで | 0.74 s(CLI 経由 2.5 s) | 5.5 ms |
| メモリ(待機中 / 1,000 件後) | 95 MiB / 456 MiB | 7.4 MiB / 21 MiB |
| 10 万件 + リスナー | 121 s(件数に比例して遅くなる) | 4.9 s(バッチごとに一定) |
同じマシン・同じ条件で並べた比較ではありません。公式は先ほどの負荷のかかったマシンで、hidane は後日、静かな状態の M1 Max で測っています。同一条件での再計測を予定しています。
使ってみる
curl -fsSL https://hidane.dev/install.sh | sh
hidane exec -- firebase emulators:start --only firestore
ほかのインストール方法もあります。どれも同じリリースのバイナリが入ります(macOS・Linux・Windows)。
brew install hidane-dev/tap/hidane # Homebrew
npm install --save-dev hidane # npm
cargo binstall hidane # または cargo install hidane
dart pub global activate hidane # pub.dev
docker run --rm -p 8080:8080 ghcr.io/hidane-dev/hidane
firebase-tools を使わずに、単体で起動することもできます。
hidane --host 127.0.0.1 --port 8080
export FIRESTORE_EMULATOR_HOST=127.0.0.1:8080
まだできないこと
- Security Rules(v0.2 で対応予定。いまは全リクエストを許可します)
- Emulator UI のリクエストモニタ、Functions エミュレーターへのイベント通知
- Go・Python・Java・iOS・Android・Flutter での動作確認(同じプロトコルを話しますが、まだ試していません)
ルールを使わないテストや開発なら、いまから公式エミュレーターの代わりに使えます。
おわりに
「重いのは仕方ない」と思っていた公式エミュレーターも、どこが重いのかを測ってみると、設計で避けられるものでした。
お手元のテストスイートで試してみて、公式エミュレーターと違う応答があれば、Parity gap のテンプレートで issue を立ててもらえるととても助かります。Go・Python・Java・モバイルの SDK で試した報告(動いた・動かなかった)も歓迎です。使い方の相談は Discussions へどうぞ。



