FirebaseのSparkプラン(無料枠)でRealtimeDBの自動バックアップを構築する

個人開発のアプリでRealtimeDBのバックアップを無料でやりたい

Firebase RealtimeDBのBlazeプラン(有料)には自動バックアップ機能があります。 でもBlazeプランは青天井なので個人開発ではなるべく触りたくありません。お金持ちならいいけど。

RealtimeDBのドキュメントには、以下のような記述があります。

自動バックアップ  |  Firebase Realtime Database  |  Firebase

また、実際にfirebaseのコンソールにも同様の記述があります。

f:id:osamtimizer:20180513162844p:plain

というわけで、無料枠からはみ出ない範囲でRealtimeDBの自動バックアップを手動で構築してみることにします。

で、一番最初に思いついたのは以下のようなワークフローです。

Firebase cloud functionsにスケジューリングされたfunctionを実装

このfunctionでRealtimeDBを丸ごと引っ張ってくる

Firebase cloud storageにjsonなりzipなりで格納

Firebase cloud functionsは単体でスケジューラを作成できない

調べてみたところcloud functions単体にはcronに相当する機能がないようです。 (AWS Lambdaにはcronがあるのに...)

Rate または Cron を使用したスケジュール式 - AWS Lambda

Google App Engineを使おう

今回はGoogle Cloud PlatformのGoogle App Engineにスケジューラを作成してfunctionsを定期的に実行することにします。

ちなみにApp Engineにも無料枠/有料枠があり、大量のバックアップを実行したい場合は素直にFirebaseのBlazeプランでやった方がいいと思います。 (この記事ではあくまで個人開発の小規模データベースのバックアップを目的としています)

無料枠の上限ですが、1日あたり28インスタンス時間と定められています。

インスタンス時間とはなんぞや

単純にインスタンスが稼働している時間のことのようです。 1日あたり28インスタンス時間ということは、複数のインスタンスを稼働させない限りは無料枠から足は出ることは無いと思います。 ちなみに皆さんご存知の通りGCPは負荷に応じてよしなにスケーリングしてくれるので、App Engine上にブログやサービスを構築しており、それらが一時的にバズったりするとインスタンスがモリモリ生成されて一瞬で無料枠を使い果たすので注意が必要です。

今回のケースはcronスケジューラなのでインスタンス数が増えるものでもないので特に問題はない(たぶん)です。 今のところこのインスタンス時間をはみ出た事はありませんが、今後この無料枠が変更されることもあり得るので、実際に利用する場合はApp Engineの利用規約や料金表を確認するのが安全です。

また、スケーリングを禁止(最大インスタンス数を1で固定)する設定もできるようです。

参考:

GAE でうっかり発生していた課金を無くして無料運用に戻した話 – OTCHY.NET

developers-jp.googleblog.com

Google App Engineでスケジュールジョブを作成する

Precondition

ジョブを作成する前に

Firebase toolsとGoogle Cloud SDKを使用するので、あらかじめ構築しておく必要があります。

それぞれ、手順は以下の通りです。

Firebase CLI リファレンス  |  Firebase

Quickstart for macOS  |  Cloud SDK Documentation  |  Google Cloud

また、あらかじめ関連付けが完了しているGoogle Cloud PlatformプロジェクトとFirebaseプロジェクトを用意しておいてください。

cloud.google.com

はじめに: 最初の関数の記述とデプロイ  |  Firebase

App Engineにcronジョブをデプロイ

githubにApp Engineでcloud functionsをスケジューリングするテンプレートプロジェクトがあるのでこちらを使用します。

github.com

$ git clone https://github.com/firebase/functions-cron
$ cd functions-cron

このプロジェクトは以下のようなディレクトリ構成となっています。

.
├── LICENSE
├── appengine
│   ├── app.yaml
│   ├── cron.yaml
│   ├── lib
│   ├── main.py
│   ├── main_test.py
│   ├── pubsub_utils.py
│   ├── requirements.txt
│   └── setup.cfg
├── firebase-debug.log
├── firebase.json
├── functions
│   ├── index.js
│   ├── package-lock.json
│   └── package.json
├── package-lock.json
└── readme.md

フレームワークにApp Engineで利用されているwebapp2を使ってる...とか色々あるんですが、 ひとまずApp Engine用のディレクトリとcloud functions用の2つのディレクトリがあることだけ分かれば大丈夫です。

今回触る必要があるファイルは、

.
├── appengine
│   ├── app.yaml
│   ├── cron.yaml
│   ├── requirements.txt
├── functions
│   ├── index.js

この辺だけです。

次にApp Engineにジョブをデプロイします。

$ gcloud config set <yourprojectname>
$ cd appengine/
$ pip install -t lib -r requirements.txt
$ gcloud app create
$ gcloud app deploy app.yaml \cron.yaml

ちなみにfunctions-cron/appengine/cron.yamlには以下のような記述があります。

cron:
- description: Push a "tick" onto pubsub every hour
  url: /publish/hourly_tick
  schedule: every 1 hours

- description: Push a "tick" onto pubsub every day
  url: /publish/daily_tick
  schedule: every 24 hours

- description: Push a "tick" onto pubsub every week
  url: /publish/weekly_tick
  schedule: every saturday 00:00

どうやらここでスケジューリングの設定ができる模様。 schedule:の部分の記述を変えれば"金曜日の夜だけ実行"みたいなのもできます。

詳しい記述方法は公式のドキュメントに書かれているので読んでおくことをオススメします。 https://cloud.google.com/appengine/docs/standard/python/config/cronref?hl=ja#schedule_format

gcloudコマンドでApp Engineにデプロイします。

$ gcloud app deploy app.yaml \cron.yaml

実行すると

Services to deploy:

descriptor:      [/Users/username/sandbox/google/functions-cron/appengine/app.yaml]
source:          [/Users/username/sandbox/google/functions-cron/appengine]
target project:  [yourprojectname]
target service:  [default]
target version:  [version number]
target url:      [https://yourprojectname.appspot.com]


Configurations to update:

descriptor:      [/Users/username/sandbox/google/functions-cron/appengine/cron.yaml]
type:            [cron jobs]
target project:  [yourprojectname]


Do you want to continue (Y/n)? #Yでデプロイ開始

という表示が出るので、Yで続行。

Beginning deployment of service [default]...
Some files were skipped. Pass `--verbosity=info` to see which ones.
You may also view the gcloud log file, found at
[/Users/username/.config/gcloud/logs/YYYY.MM.DD/********.log].
╔════════════════════════════════════════════════════════════╗
╠═ Uploading 0 files to Google Cloud Storage                ═╣
╚════════════════════════════════════════════════════════════╝
File upload done.
Updating service [default]...done.
Setting traffic split for service [default]...done.
Deployed service [default] to [https://yourprojectname.appspot.com]
Updating config [cron]...done.

Cron jobs have been updated.

Visit the Cloud Platform Console Task Queues page to view your queues and cron jobs.
https://console.cloud.google.com/appengine/taskqueues/cron?project=yourprojectname


You can stream logs from the command line by running:
  $ gcloud app logs tail -s default

To view your application in the web browser run:
  $ gcloud app browse

以下のページに進むと、デプロイされたジョブを確認することができます。

console.cloud.google.com

f:id:osamtimizer:20180513163502p:plain

  • /publish/daily-tick
  • /publish/hourly-tick
  • /publish/weekly-tick

の3つのジョブが確認できますが、まだこれらのジョブに紐づけられたfunctionが存在しないため、 "今すぐ実行"をクリックしてもエラーが発生してしまいます。

cloud functionsのデプロイ

では次にcloud functionsのfunctionをデプロイします。 cloud functionsの関数を記述するためにはfunctions/index.jsを編集します。

初期状態では以下のような文字列をログに出力するだけの関数が用意されています。

var functions = require('firebase-functions');

exports.hourly_job =
  functions.pubsub.topic('hourly-tick').onPublish((event) => {
    console.log("This job is ran every hour!")
  });

ひとまずこの状態でデプロイしてみることにします。

$ firebase deploy --only functions --project <yourprojectname>
=== Deploying to 'yourprojectname'...

i  deploying functions
i  functions: ensuring necessary APIs are enabled...
✔  functions: all necessary APIs are enabled
i  functions: preparing functions directory for uploading...
i  functions: packaged functions (XX.XX KB) for uploading
✔  functions: functions folder uploaded successfully
i  functions: updating function hourly_job...
✔  functions[hourly_job]: Successful update operation.

✔  Deploy complete!

Project Console: https://console.firebase.google.com/project/yourprojectname/overview

デプロイが完了すると、Firebase consoleからデプロイしたプロジェクトを選択->Functions->ダッシュボードにてデプロイしたfunction一覧が表示されます。

f:id:osamtimizer:20180513163401p:plain

新しいfunctionの実装

必要なモジュールをインストールします。

$ npm i --save moment-timezone @google-could/storage firebase-functions firebase-admin 

#firebase CLIを初めて使う場合は以下のコマンドが必要
$ npm i -g firebase-tools
$ firebase login
#Googleアカウントのログイン要求があるのでログイン情報を入力

Firebase toolsの初期化等は以下のページを参照するのが良いと思います。

Firebase CLI リファレンス  |  Firebase

const moment = require('moment-timezone');
//Timezoneを日本に設定
moment.tz.setDefault("Asia/Tokyo");
const gcs = require('@google-cloud/storage');
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp(functions.config().firebase)

const database = admin.database();
const storage = admin.storage();

//hourly_tickイベントをsubscribe
exports.hourly_job = functions.pubsub.topic('hourly_tick').onPublish((event) => {
  const ref = database.ref();
  const bucket = storage.bucket();
  const dirname = 'Backup/' + moment().format('YYYY_MM_DD');
  const filename = moment().format('YYYY_MM_DD_HH:mm:ss') + '.json';
  const file = bucket.file(dirname + '/' + filename);
  const stream = file.createWriteStream({
    gzip: true
  });

  ref.once('value').then((snapshot) => {
    stream.on('error', (err) => {
      console.error(err);
    });

    stream.on('finish', (err) => {
      console.log('successfully finished.');
    });

    //JSON.stringifyでjson stringに変換してからstreamに渡す
    stream.end(JSON.stringify(snapshot.val()));

  }).catch((err) => {
    console.error(err);
  });
  //何らかのpromiseもしくはvalueをreturnしない場合、functionsのlogでエラーが出てしまう
  return 0;
});

細かい点ですが、Firebase clound functionsはサーバのリージョンがデフォルトで米国なのでmoment.jsでは米国の現地時間が出力されてしまいます。 そのため、moment-timezoneでタイムゾーンmoment.tz.setDefault("Asia/Tokyo");としてセットしています。

リージョンを日本にしてプロジェクト生成していた場合は普通のmoment.jsで問題ないです。

補足: タイムゾーンの設定はプロジェクトの設定で変えられるかも?と思ってましたがどうやら最初に決めたリージョンからは動かせない模様。

qiita.com

ちなみになんでこんなこと書いてるかというと、自分のApp Engineのプロジェクトのリージョンをうっかりus-centralで作ってしまったためです。悲しいなあ。

それはともかく、変更が完了したので再度functionsをデプロイします。

$ firebase deploy --only functions --project <YourProjectName>

App Engine->タスクキュー->cronジョブのタブに行き、/publish/hourly_tickジョブを実行します。

Firebase cloud storageに、バックアップファイルが格納されていることを確認できれば、バックアップのスケジューリングが正常に完了しています。

バックアップが正常に作成されていない場合は、以下のコマンドでfunctionsのログを確認できます。

$ firebase functions:log --project <yourprojectname>

あとは、App Engine上のcronジョブによって1時間ごとに自動でバックアップが作成されます。

f:id:osamtimizer:20180513163548p:plain

おわりに

いろんなPassやらBaaSやらを組み合わせれば小さなWebサービスならほぼ無料で運用できるかも。