2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Firebaseで、Firestore & Authenticationのバックアップ/リストア環境を構築する

Last updated at Posted at 2020-10-16

###この記事を書いた背景

現在作成しているFirebaseアプリにて、運用フェーズに必要なDBのバックアップ/リストアを構築。
Firebaseでは、firebase-toolを利用したCLIでのバックアップ/リストアのコマンドが用意されているが、今回はリソースを一箇所に集中するために、Firebase functionsで実現できるようにしたい。
構築までに、いろいろなポイントで躓いたので、メモとして残すことにした。

実現させたい事

  1. Production環境のfirestoreを、毎日3:00、Cloud StorageにExportする
  2. Production環境のAuthenticationを、毎日3:00、Cloud StorageにExportする
  3. Staging環境のfirestoreを、毎日4:00、Cloud StorageのデータをfirestoreにImportする
  4. Staging環境のAuthenticationを、毎日4:00、Cloud StorageのデータをfirestoreにImportする
  5. Authenticationにはメールアドレス、パスワードなどの個人情報があるので、KMSを経由して暗号化させる
  6. 上記のExport/Importの成功/失敗をSlackに通知する
  7. 上記を、functionsで実行させる

FirestoreのExport

事前にCloud Storageに保存用のバケットを作成する必要あり。
https://qiita.com/atomyah/items/0c64e11e52c1690eb48e

functions/src/exportFirestore.js

const functions = require("firebase-functions");
const moment = require("moment");

const firestore = require("@google-cloud/firestore");
const client = new firestore.v1.FirestoreAdminClient();
const bucketName = "gs://backup-firestore-daily";
const fileName = moment().format("YYYY-MM-DD");

const notifySlack = require("./module/notifySlack");

module.exports = functions
  .region("asia-northeast1")
  .pubsub.schedule("0 3 * * *")
  .timeZone("Asia/Tokyo")
  .onRun(async (context) => {

    const env = functions.config().functions.env;
    if (env !== "production") {
      return false;
    }
    notifySlack("deploy", `START export firestore: ${env}`, "grey");

    const projectId = JSON.parse(process.env.FIREBASE_CONFIG).projectId;
    const exportDatabaseName = client.databasePath(projectId, "(default)");

    await client
      .exportDocuments({
        name: exportDatabaseName,
        outputUriPrefix: `${bucketName}/${fileName}`,
        collectionIds: [],
      })
      .then(() => {
        notifySlack("deploy", `SUCCESS export firestore: ${env}`, "good");
      })
      .catch((err) => {
        notifySlack(
          "deploy",
          `FAILS export firestore: ${env} --> ${err}`,
          "danger"
        );
      });

    return { message: "finish" };
  });

ここでのポイントは、firestoreインスタンスのexportDocumentsメソッドを利用。
https://cloud.google.com/firestore/docs/reference/rest/v1/projects.databases/exportDocuments?hl=ja
引数のcollectionIdsにある配列内に、特定のコレクションを指定する事も可能。[]にすると、全てのコレクションを取得する。

functions内の冒頭で、下記のif文処理をしているのは、Exportは本番環境のみが実行するため。

 const env = functions.config().functions.env;
 if (env !== "production") {
   return false;
 }

functions.config().functions.env は、事前にCLIからfirebaseに登録する必要あり。(変数名は、何でもOK)

$ firebase functions:config.set functions.env="production" --project production
$ firebase functions:config.set functions.env="staging" --project staging

こんな感じで、FirestoreのExportは簡単。

FirestoreのImport

functions/srt/importFirestore.js

const functions = require("firebase-functions");
const moment = require("moment");

const firestore = require("@google-cloud/firestore");
const client = new firestore.v1.FirestoreAdminClient();
const bucketName = "gs://backup-firestore-daily";
const fileName = moment().format("YYYY-MM-DD");

const notifySlack = require("./module/notifySlack");

module.exports = functions
  .region("asia-northeast1")
  .pubsub.schedule("0 4 * * *")
  .timeZone("Asia/Tokyo")
  .onRun(async (context) => {

    const env = functions.config().functions.env;
    if (env !== "staging") {
      return false;
    }
    notifySlack("deploy", `START import firestore: ${env}`, "grey");

    const projectId = JSON.parse(process.env.FIREBASE_CONFIG).projectId;

    await client
      .importDocuments({
        name: `projects/${projectId}/databases/(default)`,
        inputUriPrefix: `${bucketName}/${fileName}`,
        collectionIds: [],
      })
      .then((responses) => {
        notifySlack("deploy", `SUCCESS import firestore: ${env}`, "good");
      })
      .catch((err) => {
        notifySlack(
          "deploy",
          `FAILS import firestore: ${env} --> ${err}`,
          "danger"
        );
      });

    return { message: "finish" };
  });

ここでのポイントは、先ほど作成したStorageのバケット(backup-firestore-daily)に対して、Staging環境からデータを読み取るためのIAM rolesを付与する必要がある。そうしないと、IAMのエラーが出る。
IAM rolesの追加方法は、下記を参考。
https://blog.jicoman.info/2019/10/access-gcs-bucket-in-other-project/

AuthenticationのExport

Firebaseアプリでは、認証まわりをFirebase Authentication(以後Auth)を利用すると思う。FirebaseのCLIでも、この辺りのExport/Importコマンドは用意されている。
Authは、メールアドレス、パスワードなどの個人情報が含まれているので、今回はKMSを利用して暗号化したデータをStorageに保存することにする。

KMSに関して詳しく知りたい方は、下記を参考に。
https://cloud.google.com/kms/docs?hl=ja

functions/src/exportAuth.js

const functions = require("firebase-functions");
const moment = require("moment");
const admin = require("firebase-admin");

const firebaseTools = require("firebase-tools");
const fs = require("fs");
const gcs = require("@google-cloud/storage");
const kms = require("@google-cloud/kms");
const region = "asia-northeast1";

const bucketName = "backup-authentication-daily";
const bucketNameOfClaims = "backup-authentication-claims-daily";
const fileNameDate = moment().format("YYYY-MM-DD");
const tmpDir = "/tmp";
let resultStatus = "OK";

const kmsProjectId = "prod-myApp-a869e";
const keyring = "my-auth-keyring";
const key = "my-auth-key";

const notifySlack = require("./module/notifySlack");

module.exports = functions
  .region("asia-northeast1")
  .pubsub.schedule("0 3 * * *")
  .timeZone("Asia/Tokyo")
  .onRun(async (context) => {

    const env = functions.config().functions.env;
    if (env !== "production") {
      return false;
    }
    notifySlack("deploy", `START export Auth: ${env}`, "grey");

    const projectId = JSON.parse(process.env.FIREBASE_CONFIG).projectId;
    const plaintextFileName = `${fileNameDate}.json`;
    const tmpPlaintextFilePath = `${tmpDir}/${plaintextFileName}`;
    const tmpCiphertextFilePath = `${tmpDir}/${plaintextFileName}.encripted`;

    // ローカルにAuthを取得
    await firebaseTools.auth.export(tmpPlaintextFilePath, {
      project: projectId,
    });

    // ファイル読み込み
    const plaintext = fs.readFileSync(tmpPlaintextFilePath);

    // 暗号化
    const kmsClient = new kms.KeyManagementServiceClient();
    const keyName = kmsClient.cryptoKeyPath(kmsProjectId, region, keyring, key);

    const [result] = await kmsClient.encrypt({ name: keyName, plaintext });
    fs.writeFileSync(tmpCiphertextFilePath, result.ciphertext);

    // GCS に保存
    const gcsClient = new gcs.Storage();
    const bucket = gcsClient.bucket(bucketName);
    await bucket.upload(tmpCiphertextFilePath);

    // ローカルのファイルを削除
    fs.unlinkSync(tmpPlaintextFilePath);
    fs.unlinkSync(tmpCiphertextFilePath);

    /////////
    /// export custom_claims
    ////////
    const string_text = JSON.parse(plaintext.toString("utf-8"));
    const claimsArr = [];
    for (const row of string_text["users"]) {
      await admin
        .auth()
        .getUser(row["localId"])
        .then((userRecord) => {
          if (userRecord.customClaims) {
            claimsArr.push({
              uid: row["localId"],
              userType: userRecord.customClaims.userType,
              status: userRecord.customClaims.status,
            });
          }
          resultStatus = "OK";
        })
        .catch((err) => {
          resultStatus = "NG";
        });
    }
    const claimsHash = { custom_claims: claimsArr };

    const claimsTextFileName = `${fileNameDate}_custom_claims.json`;
    const claimsTextFilePath = `${tmpDir}/${claimsTextFileName}`;
    fs.writeFileSync(claimsTextFilePath, JSON.stringify(claimsHash));

    const gcsClientClaims = new gcs.Storage();
    const bucketClaims = gcsClientClaims.bucket(bucketNameOfClaims);
    await bucketClaims.upload(claimsTextFilePath);

    // ローカルのファイルを削除
    fs.unlinkSync(claimsTextFilePath);

    if (resultStatus === "OK") {
      notifySlack("deploy", `SUCCESS export Auth: ${env}`, "good");
    } else {
      notifySlack("deploy", `FAILS export Auth: ${env} --> ${err}`, "danger");
    }

    return { message: "OK" };
  });

ここでのポイントは、前半にてAuthのバックアップをKMSを利用してStorageに保存。後半にて、AuthのCustom ClaimsをStorageに保存。(claimsには運用に必要な情報しかないので、KMSは利用しない。)

悲しいことに、firebaseTools.auth.exportのメソッドでは、下記のAuth情報しか取れない。。。
https://orangelog.site/firebase/firebase-user-csv-import/
Authに紐づいているCustom Claimsは、authのuidを使ってEachで回して取得するという力技が必要。(Firebaseの今後の改善に期待!)

今回利用した、KMSのkeyringとkeyは下記コマンドで作成。

// 設定したいプロジェクトをセット
$ gcloud config set project prod-myApp-a869a

// keyringの作成
$ gcloud kms keyrings create \
--location=asia-northeast1 \
my-auth-keyring 

// keyの作成
$ gcloud kms keys create \
  --location=asia-northeast1 \
  --keyring=my-auth-keyring \
  --purpose=encryption \
  my-auth-key  

// 暗号化EncriyptのIAM rolesを付与 for Production
$ gcloud kms keys \
add-iam-policy-binding \
--location=asia-northeast1 \
--keyring=my-auth-keyring \
my-auth-key \
--member=serviceAccount:prod-myApp-a869e@appspot.gserviceaccount.com \
--role=roles/cloudkms.cryptoKeyEncrypter

// 複合化DecriyptのIAM rolesを付与 for Staging
$ gcloud kms keys \
add-iam-policy-binding \
--location=asia-northeast1 \
--keyring=my-auth-keyring \
my-auth-key \
--member=serviceAccount:staging-myApp-a342e@appspot.gserviceaccount.com \
--role=roles/cloudkms.cryptoKeyDecrypter

作成したkeyにrotationのオプションを付けることも出来ます。
https://qiita.com/os1ma/items/f7a00e82c0758bb08c13

AuthenticationのImport

事前に利用しているバケットに対して、Stagingから読み取りできるように、IAM rolesを付与する必要あり。(やることはFirestoreのImportと同じ。)

functions/src/importAuth.js

const functions = require("firebase-functions");
const admin = require("firebase-admin");
const moment = require("moment");

const fs = require("fs");
const gcs = require("@google-cloud/storage");
const kms = require("@google-cloud/kms");
const region = "asia-northeast1";

const bucketName = "backup-authentication-daily";
const bucketNameOfClaims = "backup-authentication-claims-daily";
const fileName = moment().format("YYYY-MM-DD");
let resultStatus = "OK";
const tmpDir = "/tmp";

const kmsProjectId = "prod-myApp-a869e";
const keyring = "my-auth-keyring";
const key = "my-auth-key";

const notifySlack = require("./module/notifySlack");

module.exports = functions
  .region("asia-northeast1")
  .pubsub.schedule("0 4 * * *")
  .timeZone("Asia/Tokyo")
  .onRun(async (context) => {

    const env = functions.config().functions.env;
    if (env !== "staging") {
      return false;
    }
    notifySlack("deploy", `START import Auth: ${env}`, "grey");

    const plaintextFileName = `${fileName}.json`;
    const ciphertextFileName = `${plaintextFileName}.encripted`;
    const tmpPlaintextFileName = `${tmpDir}/${plaintextFileName}`;
    const tmpCiphertextFileName = `${tmpDir}/${plaintextFileName}.encripted`;

    // GCS からLocalの/tempにDL
    const options = {
      destination: tmpCiphertextFileName,
    };
    const gcsClient = new gcs.Storage();
    const bucket = gcsClient.bucket(bucketName);
    await bucket.file(ciphertextFileName).download(options);

    // ファイル読み込み
    const ciphertext = fs.readFileSync(tmpCiphertextFileName);

    // 複合化
    const kmsClient = new kms.KeyManagementServiceClient();
    const keyName = kmsClient.cryptoKeyPath(kmsProjectId, region, keyring, key);
    const [result] = await kmsClient.decrypt({
      name: keyName,
      ciphertext,
    });
    const plaintext = result.plaintext.toString("utf8");
    fs.writeFileSync(tmpPlaintextFileName, plaintext);

    const jsonText = JSON.parse(plaintext); //parse json
    const userAuthes = []; //この配列の中にパースしたcsvの中身をkey-value形式で入れていく。

    jsonText["users"].forEach(function(response) {
      if (response["localId"]) {
        userAuthes.push({
          uid: response["localId"],
          email: response["email"],
          passwordHash: Buffer.from(response["passwordHash"], "base64"),
          passwordSalt: Buffer.from(response["salt"], "base64"),
        });
      }
    });

    await admin
      .auth()
      .importUsers(userAuthes, {
        hash: {
          algorithm: "SCRYPT",
          key: Buffer.from(functions.config().auth.hash.key, "base64"),
          saltSeparator: Buffer.from("Bw==", "base64"),
          rounds: 8,
          memoryCost: 14,
        },
      })
      .then(function(results) {
        results.errors.forEach(function(indexedError) {
          console.log(">>>>>> Error importing user " + indexedError.index);
          resultStatus = "NG";
        });
      })
      .catch(function(error) {
        console.log(">>>>>> catch Error importing users:", error);
        resultStatus = "NG";
      });

    // ローカルのファイルを削除
    fs.unlinkSync(tmpPlaintextFileName);
    fs.unlinkSync(tmpCiphertextFileName);

    /////
    // Import custom claims
    ////
    const claimsTextFileName = `${fileName}_custom_claims.json`;
    const claimsTextFilePath = `${tmpDir}/${claimsTextFileName}`;

    // GCS からLocalの/tempにDL
    const gcsClientClaims = new gcs.Storage();
    const bucketClaims = gcsClientClaims.bucket(bucketNameOfClaims);
    await bucketClaims.file(claimsTextFileName).download({
      destination: claimsTextFilePath,
    });

    const claimsCiphertext = fs.readFileSync(claimsTextFilePath);
    const jsonClaimsData = JSON.parse(claimsCiphertext.toString("utf-8"));

    for (const row of jsonClaimsData.custom_claims) {
      if (row.uid) {
        await admin.auth().setCustomUserClaims(row.uid, {
          userType: row.userType,
          status: row.status,
        });
      }
    }

    // ローカルのファイルを削除
    fs.unlinkSync(claimsTextFilePath);

    if (resultStatus === "OK") {
      notifySlack("deploy", `SUCCESS import Auth: ${env}`, "good");
    } else {
      notifySlack("deploy", `FAILS import Auth: ${env}`, "danger");
    }

    return { message: resultStatus };
  });

ここでのポイントは、functionsのAdminを利用して、admin.auth().importUsers でAuthをImportするのですが、パスワードは暗号化されているので、下記を参考にFirebase SCRYPTでパスワードをハッシュしてユーザーをインポートする必要がある。
https://firebase.google.com/docs/auth/admin/import-users?hl=ja

パスワードハッシュパラメータは、重要な機密情報なので、functions.config()に登録して利用します。(firebaseで、取得メソッドが用意されている?と嬉しいのですが。。。)

今後の課題

  1. admin.auth().importUsersでは、一度に1000人までしかimportできないので、Authの件数が増えたらeachで回すなどの工夫が必要
  2. Custom ClaimsのExport/Importにて、Eachで回して、1人づつ取得/設定を行っている。Authの件数が増えた時の処理速度が気になる
  3. Firestore、AuthともにImportをすると、差分が残る仕様になっている。下記のような状況が発生するので、将来的にゴミデータが残り続ける。解決策としては、StagingのFirestoreを一度空にしてからImportする方法があるが、そうすると、毎回のImportにかかる処理時間が増えて、利用料金も増える。
<Import前>
Production:user A, userB
Staging:user B, userC

<Import後>
Production:user A, userB
Staging:userA, user B, userC

最後に。。。

Firebaseはアプリ作成に関しては、とても簡単で優れていると思う一方、リリース後の運用フェーズを考えると、もう少し改善余地があると思う。
例えば、Custom Claimsの一括Export/Importなどのメソッドが出来ないなど。。。
さらに期待をするなら、FirebaseのGUIからBackup/Restoreを簡単に登録できる仕組みがあったらいいと思う。
以前利用していた、HerokuではAdd-onで簡単に設定が出来ていたので、今後のFirebaseに進化に期待!

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?