Skip to content

Instantly share code, notes, and snippets.

@x7ddf74479jn5
Last active October 21, 2022 06:39
Show Gist options
  • Save x7ddf74479jn5/49c01d076bc0e7e503c7d0a6fa8ca7b4 to your computer and use it in GitHub Desktop.
Save x7ddf74479jn5/49c01d076bc0e7e503c7d0a6fa8ca7b4 to your computer and use it in GitHub Desktop.
Firestore倹約術

Firestore倹約術

目的

Firestoreのread/write課金を減らす。フリーティアの範囲内の運用にする。

方針

read/write

  • SWRと組み合わせてFirestoreへの問い合わせを減らす。
  • 弱整合は許容し、revalidateを極力発火させない。
  • コードベースや時間効率性への配慮は断念する。
  • 利用側ではcustom hookで意識しないで使えるようにする。
  • lib, model, repository, usecaseにレイヤーを分け依存関係を整理する。

モニタリング

  • 課金上限を超過したらサービスまたはインスタンス単位で落とす。
    1. すべてのサービスを停止する。
    2. 停止するインスンタンスを指定する。
    3. Firestoreへのアクセスを禁止する
  • Pub/SubトリガーにCloud Billingの予算超過アラートを利用する。
  • インスタンス停止後、Slackに通知する。

TOC

リンク

コスト管理の自動レスポンスの例  |  Cloud Billing  |  Google Cloud

next-firebase-starter

関連: 型安全Firestoreへの道

キャッシュ戦略

キャッシュは管理が難しい。認識漏れを起こしやすく意図しないバグ混入するので注意して実装する。

Mutation

デフォルトでrevalidateを抑制しているuseSWRImmutableの方を採用する。 mutationの方法にも様々あるけど、mutationの際に直接ローカルキャッシュを変更しrevalidateを起こさせない方法を採用したい。参考リンクでいう"Mutate Based on Current Data"を使う。

"Bounce Mutate"を使わないためmutate関数はswrから直接持ってくるか、useSWRConfigの返り値から取り出す。 import { mutate } from "swr" or const { mutate } = useSWRConfig()mutate(key, data?, shouldRevalidate?)関数の第一引数は変更するローカルキャッシュのキー、第二引数はローカルキャッシュに反映させるデータorデータを返す関数(Promiseでも可)、 第三引数はrevalidateの可否。

// src/usecases/book.ts
export const useAddBook = async (book: Book) => {
  await addBook(book);
  await mutate(
    generateCacheKey(),
    () => (prev?: Book[]) => {
      if (!prev) return;
      return [...prev, book];
    },
    false
  );
};

複数のキャッシュを更新したい場合は続けてmutate関数を記述する。正規表現でキーを指定して一括でrevalidateする方法もあるようだけど、そろそろしんどくなってくる。

export const useUpdateBook = async (id: string, book: Book) => {
  await updateBook(id, book);
  await mutate(
    generateCacheKey(),
    () => (prev?: Book[]) => {
      if (!prev) return;
      return prev.map((prevBook) => (prevBook.id === id ? book : prevBook));
    },
    false
  );
  await mutate(generateCacheKey(id), book, false);
};

Cache Key

カリー関数でmodelごとのキャッシュ生成関数を作成する君。

// src/lib/swr.ts
export const getCacheKeyGenerator =
  (model: string) =>
  (...keySegments: string[]) =>
    `${model}${keySegments.length > 0 ? `/${keySegments.join("/")}` : ""}`;

Firestoreへの依存を持ってしまうが、○○Refecrenceからidやpathが取れるのでそれをキーにしてもいいかもしれない。

Repository

Firestoreへのread/writeの処理をsrc/repository以下に書く。src/lib/firebaseへの依存を持つ。FirestoreのAPIはこの層の内側には露出させない。

// src/repositories/book.ts
import type { PartialWithFieldValue } from "firebase/firestore";
import { addDoc, deleteDoc } from "firebase/firestore";
import { collection, doc, getDoc, getDocs, updateDoc } from "firebase/firestore";

import { db, getConverter } from "@/lib/firebase";
import type { Book } from "@/models/book";
import { bookSchema } from "@/models/book";

const bookConverter = getConverter<Book>(bookSchema.parse);

const getBookDocRef = (id: string) => {
  return doc(db, "books", id).withConverter(bookConverter);
};

const getBookColRef = () => {
  return collection(db, "books").withConverter(bookConverter);
};

export const addBook = async (book: Book) => {
  await addDoc(getBookColRef(), book);
};

export const getBook = async (id: string) => {
  const doc = await getDoc<Book>(getBookDocRef(id));
  return doc.data();
};

export const getBooks = async () => {
  const snapshot = await getDocs<Book>(getBookColRef());
  return snapshot.docs.map((doc) => doc.data());
};

export const updateBook = async (id: string, book: PartialWithFieldValue<Book>) => {
  await updateDoc<Book>(getBookDocRef(id), book);
};

export const deleteBook = async (id: string) => {
  await deleteDoc(getBookDocRef(id));
};

Usecase

アプリケーションのユースケースをsrc/usecases以下に書く。repository層への依存を持つ。

getCacheKeyGenerator("book")はキー生成関数生成関数。以下の場合、generateCacheKeyはキー生成関数で、引数を取れば"book/{id}"、なければ"book"を生成する。

FirestoreへのfetcherをueSWRImmutableの第二引数に入れる。これでswrがFirestoreへのリクエストをキャッシュしてくれる。

// src/usecases/book.ts
import { mutate } from "swr";
import useSWRImmutable from "swr/immutable";

import { getCacheKeyGenerator } from "@/lib/swr";
import type { Book } from "@/models/book";
import { addBook, deleteBook, getBook, getBooks, updateBook } from "@/repositories/book";

const generateCacheKey = getCacheKeyGenerator("book");

export const useBooks = () => {
  return useSWRImmutable<Book[]>(generateCacheKey(), getBooks);
};

export const useBook = (id: string) => {
  return useSWRImmutable<Book | undefined | null>(generateCacheKey(id), () => getBook(id));
};

export const useAddBook = async (book: Book) => {
  await addBook(book);
  await mutate(
    generateCacheKey(),
    () => (prev?: Book[]) => {
      if (!prev) return;
      return [...prev, book];
    },
    false
  );
};

export const useUpdateBook = async (id: string, book: Book) => {
  await updateBook(id, book);
  await mutate(
    generateCacheKey(),
    () => (prev?: Book[]) => {
      if (!prev) return;
      return prev.map((prevBook) => (prevBook.id === id ? book : prevBook));
    },
    false
  );
  await mutate(generateCacheKey(id), book, false);
};

export const useDeleteBook = async (id: string) => {
  await deleteBook(id);
  await mutate(
    generateCacheKey(),
    () => (prev?: Book[]) => {
      if (!prev) return;
      return prev.filter((prevBook) => prevBook.id !== id);
    },
    false
  );
  await mutate(generateCacheKey(id), null, false);
};

すべてのサービスを停止する

警告: この例ではプロジェクトから Cloud Billing を削除し、すべてのリソースをシャットダウンします。リソースは正常にシャットダウンされず、削除されて回復不能となる可能性があります。Cloud Billing を無効にすると、正常な復旧は行われません。Cloud Billing を再度有効にすることはできますが、サービスが復旧される保証はありません。その場合、手動構成が必要になります。

コスト管理の自動レスポンスの例

すべてのサービスを停止する例

import { Message } from "firebase-functions/v1/pubsub";
import * as functions from "firebase-functions";
import { CloudBillingClient } from "@google-cloud/billing";
import { InstancesClient } from "@google-cloud/compute";
const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT;
const PROJECT_NAME = `projects/${PROJECT_ID}`;
const billingClient = new CloudBillingClient();
const instancesClient = new InstancesClient();
const timezone = "Asia/Tokyo";
const ZONE = "asia-northeast1";
process.env.TZ = timezone;
const runtimeOpts = {
timeoutSeconds: 180,
memory: "512MB" as const,
};
/**
* Determine whether billing is enabled for a project
* @param {string} projectName Name of project to check if billing is enabled
* @return {bool} Whether project has billing enabled or not
*/
const _isBillingEnabled = async (projectName: string) => {
try {
const [res] = await billingClient.getProjectBillingInfo({
name: projectName,
});
return res.billingEnabled;
} catch (e) {
console.log("Unable to determine if billing is enabled on specified project, assuming billing is enabled");
return true;
}
};
/**
* @return {Promise} Array of names of running instances
*/
const _listRunningInstances = async (projectId: string, zone: string) => {
const [instances] = await instancesClient.list({
project: projectId,
zone: zone,
});
return instances.filter((item) => item.status === "RUNNING").map((item) => item.name!);
};
/**
* @param {Array} instanceNames Names of instance to stop
* @return {Promise} Response from stopping instances
*/
const _stopInstances = async (projectId: string, zone: string, instanceNames: string[]) => {
await Promise.all(
instanceNames.map((instanceName) => {
return instancesClient
.stop({
project: projectId,
zone: zone,
instance: instanceName,
})
.then(() => {
console.log(`Instance stopped successfully: ${instanceName}`);
});
})
);
};
export const limitUse = functions
.region("asia-northeast1")
.runWith(runtimeOpts)
.pubsub.topic("budget-notifications")
.onPublish(async (message: Message) => {
const pubsubData = JSON.parse(Buffer.from(message.data, "base64").toString());
if (pubsubData.costAmount <= pubsubData.budgetAmount) {
return `No action necessary. (Current cost: ${pubsubData.costAmount})`;
}
if (!PROJECT_ID) {
return "No project specified";
}
const billingEnabled = await _isBillingEnabled(PROJECT_NAME);
if (!billingEnabled) {
return "Billing already disabled";
}
const instanceNames = await _listRunningInstances(PROJECT_ID, ZONE);
if (!instanceNames.length) {
return "No running instances were found.";
}
await _stopInstances(PROJECT_ID, ZONE, instanceNames);
return `${instanceNames.length} instance(s) stopped successfully.`;
});
import { Message } from "firebase-functions/v1/pubsub";
import * as functions from "firebase-functions";
import * as slack from "slack";
const runtimeOpts = {
timeoutSeconds: 180,
memory: "512MB" as const,
};
const timezone = "Asia/Tokyo";
process.env.TZ = timezone;
const BOT_ACCESS_TOKEN = process.env.BOT_ACCESS_TOKEN;
const CHANNEL = process.env.SLACK_CHANNEL || "general";
export const notifySlack = functions
.region("asia-northeast1")
.runWith(runtimeOpts)
.pubsub.topic("budget-notifications")
.onPublish(async (message: Message) => {
const pubsubAttrs = message.attributes;
const pubsubData = Buffer.from(message.data, "base64").toString();
const budgetNotificationText = `${JSON.stringify(pubsubAttrs)}, ${pubsubData}`;
await slack.chat.postMessage({
token: BOT_ACCESS_TOKEN,
channel: CHANNEL,
text: budgetNotificationText,
});
return "Slack notification sent successfully";
});
import { Message } from "firebase-functions/v1/pubsub";
import * as functions from "firebase-functions";
import { CloudBillingClient } from "@google-cloud/billing";
const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT;
const PROJECT_NAME = `projects/${PROJECT_ID}`;
const BILLING_ACCOUNT_NAME = process.env.BILLING_ACCOUNT_NAME;
const billing = new CloudBillingClient();
const timezone = "Asia/Tokyo";
process.env.TZ = timezone;
const runtimeOpts = {
timeoutSeconds: 180,
memory: "512MB" as const,
};
/**
* Determine whether billing is enabled for a project
* @param {string} projectName Name of project to check if billing is enabled
* @return {bool} Whether project has billing enabled or not
*/
const _isBillingEnabled = async (projectName: string) => {
try {
const [res] = await billing.getProjectBillingInfo({ name: projectName });
return res.billingEnabled;
} catch (e) {
console.log("Unable to determine if billing is enabled on specified project, assuming billing is enabled");
return true;
}
};
/**
* Disable billing for a project by removing its billing account
* @param {string} projectName Name of project disable billing on
* @return {string} Text containing response from disabling billing
*/
const _disableBillingForProject = async (projectName: string) => {
const [res] = await billing.updateProjectBillingInfo({
name: projectName,
resource: { billingAccountName: "" }, // Disable billing
});
return `Billing disabled: ${JSON.stringify(res)}`;
};
export const stopBilling = functions
.region("asia-northeast1")
.runWith(runtimeOpts)
.pubsub.topic("budget-notifications")
.onPublish(async (message: Message) => {
const pubsubData = JSON.parse(Buffer.from(message.data, "base64").toString());
if (pubsubData.costAmount <= pubsubData.budgetAmount) {
return `No action necessary. (Current cost: ${pubsubData.costAmount})`;
}
if (!PROJECT_ID) {
return "No project specified";
}
const billingEnabled = await _isBillingEnabled(PROJECT_NAME);
if (billingEnabled) {
return _disableBillingForProject(PROJECT_NAME);
} else {
return "Billing already disabled";
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment