Firestore Local Emulator のデータを擬似的に永続化する

Firestore Local Emulator にimport/export機能が追加されたらしいので、それを利用して擬似的に永続化しました。

Dockerを落とすたびにリセットされるデータ

最近、業務でFirestoreを使用した開発をしています。ローカルではエミュレータを起動し、それに接続しています。このエミュレータには個人的に不便なことがあり、データをメモリ上に保存しています。ローカルの開発にはDockerを使用しており、エミュレータもDocker上で動かしています。そのため、Dockerを落とすたびにデータが全消去されてしまいます。

何とかしたいなーと思いつつもいい方法が見つからず放置していましたが、少し前にこちらの記事を見つけ、最近import/export機能が実装されたことを知りました。

Firestore エミュレータのデータをローカル環境で import/export する - Qiita
概要 Firebase エミュレータで Firestore の import/export ができるようになったので、導入方法について紹介します。これまで、エミュレータを再起動する度にデータが初期化されていていましたが、特定のタ...

この機能を使えばデータを擬似的に永続化できるのでは?と思い、試してみました。

gcloudコマンドでの起動に対応していない?

上の記事ではfirebaseコマンドでエミュレータを起動していますが、自分の業務ではGolangで書かれたサーバーサイドから接続する関係でgcloudコマンドで起動していました。ところが、ドキュメントを見るとimportやexportの記述が見つかりませんでした。

gcloud beta emulators firestore | Cloud SDK Documentation

gcloudコマンドでは対応していないのかなと思いつつ、以下のfirebaseコマンドのソースを見てみると、

GitHub - firebase/firebase-tools: The Firebase Command Line Tools
The Firebase Command Line Tools. Contribute to firebase/firebase-tools development by creating an account on GitHub.
  • export:エミュレータに対してPOSTリクエストする
  • import:exportしたファイルを起動時にパラメータで渡す

という処理になっていました。

これならシェルスクリプトで無理やり実行できるのでは?と思いやってみました。

できたもの

以下の構成で進めます。

.  
├── docker-compose.yml  
├── firestore  
│   ├── Dockerfile  
│   ├── firestore_export <- exportファイルの保存先  
│   └── start.sh         <- Docker起動時の実行スクリプト  
└── go  
    └── src  
        └── main.go      <- アプリケーション
version: "3.7"
services:
firestore:
build:
context: ./firestore
environment:
- PROJECT_ID=test-project-id
volumes:
- ./firestore:/firestore
command: /bin/bash /firestore/start.sh
go:
image: golang:1.14
tty: true
environment:
- PROJECT_ID=test-project-id
- FIRESTORE_EMULATOR_HOST=firestore:8812
volumes:
- ./go/src:/go/src
working_dir: /go/src
FROM google/cloud-sdk:alpine
RUN apk add --update --no-cache openjdk8 \
&& gcloud components update \
&& gcloud components install cloud-firestore-emulator beta --quiet
#!/bin/bash
export_path="/firestore/firestore_export/firestore_export.overall_export_metadata"
start_command="/google-cloud-sdk/platform/cloud-firestore-emulator/cloud_firestore_emulator start --host=0.0.0.0 --port=8812"
if [ -e $export_path ]; then
$start_command --seed_from_export=$export_path &
else
$start_command &
fi
while true
do
curl -XPOST -H "Content-Type: application/json" -d "{\"database\": \"projects/$PROJECT_ID/databases/(default)\", \"export_directory\": \"/firestore\", \"export_name\": \"firestore_export\"}" http://0.0.0.0:8812/emulator/v1/projects/$PROJECT_ID:export
sleep 5
done

Docker起動時に実行するstart.sh では以下のことをしています。

  1. exportファイルの存在確認をし、あれば引数にセットする
  2. gcloudコマンドではなく、gcloudコマンドで使われている(と思われる)実行ファイルを直接実行する
  3. 5秒毎にデータをexportし、マウントしたディレクトリに保存

これによってexportしたデータは5秒毎にローカルに保存され、Dockerを落としてもデータをある程度維持できます。5秒毎にcurlを叩くのでローカル環境への負荷が少し気になりましたが、1日つけっぱなしでも特に重くなったりはしなかったので多分大丈夫だと思います。

試しに以下のファイルを用意し、

main.go実行 → Firestoreコンテナの再起動 → main.go実行

としてみました。

package main
import (
"context"
"fmt"
"log"
"os"
"strconv"
"cloud.google.com/go/firestore"
)
func main() {
ctx := context.Background()
client, err := firestore.NewClient(ctx, os.Getenv("PROJECT_ID"))
if err != nil {
log.Fatal(err)
}
create(ctx, client)
print(ctx, client)
}
func create(ctx context.Context, client *firestore.Client) {
ref, _, err := client.Collection("companies").Add(ctx, map[string]interface{}{
"name": "test_company",
})
if err != nil {
log.Fatal(err)
}
for i := 0; i < 5; i++ {
_, _, err := client.Collection("users").Add(ctx, map[string]interface{}{
"name": "test_" + strconv.Itoa(i),
"company_id": ref.ID,
})
if err != nil {
log.Fatal(err)
}
}
}
func print(ctx context.Context, client *firestore.Client) {
companies, err := client.Collection("companies").Documents(ctx).GetAll()
if err != nil {
log.Fatal(err)
}
for _, company := range companies {
fmt.Println(company.Data())
}
users, err := client.Collection("users").Documents(ctx).GetAll()
if err != nil {
log.Fatal(err)
}
for _, user := range users {
fmt.Println(user.Data())
}
}
root@bde17f5d8780:/go/src# go run main.go   
map[name:test_company]  
map[company_id:EEG48ivVXqwjOgHDY8lJ7IiO9bRekcClGJ6Y0ljh9co name:test_4]  
map[company_id:EEG48ivVXqwjOgHDY8lJ7IiO9bRekcClGJ6Y0ljh9co name:test_1]  
map[company_id:EEG48ivVXqwjOgHDY8lJ7IiO9bRekcClGJ6Y0ljh9co name:test_0]  
map[company_id:EEG48ivVXqwjOgHDY8lJ7IiO9bRekcClGJ6Y0ljh9co name:test_3]  
map[company_id:EEG48ivVXqwjOgHDY8lJ7IiO9bRekcClGJ6Y0ljh9co name:test_2]

--- Firestore再起動 ---

root@bde17f5d8780:/go/src# go run main.go   
map[name:test_company]  
map[name:test_company]  
map[company_id:VZu8kWTjTd_36LaSJKxqqCTulZl9SXsahUAKvzvqIjw name:test_2]  
map[company_id:VZu8kWTjTd_36LaSJKxqqCTulZl9SXsahUAKvzvqIjw name:test_0]  
map[company_id:EEG48ivVXqwjOgHDY8lJ7IiO9bRekcClGJ6Y0ljh9co name:test_4]  
map[company_id:EEG48ivVXqwjOgHDY8lJ7IiO9bRekcClGJ6Y0ljh9co name:test_1]  
map[company_id:VZu8kWTjTd_36LaSJKxqqCTulZl9SXsahUAKvzvqIjw name:test_4]  
map[company_id:VZu8kWTjTd_36LaSJKxqqCTulZl9SXsahUAKvzvqIjw name:test_1]  
map[company_id:VZu8kWTjTd_36LaSJKxqqCTulZl9SXsahUAKvzvqIjw name:test_3]  
map[company_id:EEG48ivVXqwjOgHDY8lJ7IiO9bRekcClGJ6Y0ljh9co name:test_0]  
map[company_id:EEG48ivVXqwjOgHDY8lJ7IiO9bRekcClGJ6Y0ljh9co name:test_3]  
map[company_id:EEG48ivVXqwjOgHDY8lJ7IiO9bRekcClGJ6Y0ljh9co name:test_2]

2回目に実行した時に1回目で保存したレコードも取得できているので、無事に永続化できました。

データを消したいときは、コンテナを止めてfirestore/firestore_export フォルダを削除してから起動し直せばOKです。

注意点

この方法でimportしたデータは、全てのProjectIDに対して初期データとして扱われるようです。

上の例ではtest-project-id というProjectIDでexportしていますが、importするとtest-project-id-1 でも、test-project-id-2 でも、どんなProjectIDを指定しても同じ初期データが入った状態になります。

ランダムなProjectIDを使ってUTを書いている場合は気をつけましょう。

自分は2時間ハマりました。