###この記事を書いた背景
現在作成しているFirebaseアプリにて、運用フェーズに必要なDBのバックアップ/リストアを構築。
Firebaseでは、firebase-toolを利用したCLIでのバックアップ/リストアのコマンドが用意されているが、今回はリソースを一箇所に集中するために、Firebase functionsで実現できるようにしたい。
構築までに、いろいろなポイントで躓いたので、メモとして残すことにした。
実現させたい事
- Production環境のfirestoreを、毎日3:00、Cloud StorageにExportする
- Production環境のAuthenticationを、毎日3:00、Cloud StorageにExportする
- Staging環境のfirestoreを、毎日4:00、Cloud StorageのデータをfirestoreにImportする
- Staging環境のAuthenticationを、毎日4:00、Cloud StorageのデータをfirestoreにImportする
- Authenticationにはメールアドレス、パスワードなどの個人情報があるので、KMSを経由して暗号化させる
- 上記のExport/Importの成功/失敗をSlackに通知する
- 上記を、functionsで実行させる
FirestoreのExport
事前にCloud Storageに保存用のバケットを作成する必要あり。
https://qiita.com/atomyah/items/0c64e11e52c1690eb48e
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
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
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と同じ。)
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で、取得メソッドが用意されている?と嬉しいのですが。。。)
今後の課題
- admin.auth().importUsersでは、一度に1000人までしかimportできないので、Authの件数が増えたらeachで回すなどの工夫が必要
- Custom ClaimsのExport/Importにて、Eachで回して、1人づつ取得/設定を行っている。Authの件数が増えた時の処理速度が気になる
- 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に進化に期待!