🐟

NestJS+cloud function+firestoreでAPIサーバーを構築

2024/02/11に公開

背景

直近の案件でGCPの環境を構築する必要がありましたが、terraformで構築する際に色々躓きかけた箇所が多かったのでその時の覚書になります。
普段AWSばかり触っており、GCPに関する知識が浅かったのもありますが、cloud function,firestoreをterraformで構築するノウハウは検索情報に乏しかったり、AIに聞いたりしても解決が少し難しかった気がします。

構成

フロントエンドはvite+reactで構築し、SPAをFirebase Hostingにホストしています(これはterraformの管理外)。
APIはNestJSで構築しており、cloud function上で実行し、firestore,cloud storageにアクセスします。

共通設定

コンソール上から既にプロジェクトを作ってる状態とします。
ディレクトリ構成は色々工夫できるようですが、利用するリソースがそこまで多くないので全て同一ディレクトリ内で設定しました。

先に共通で使用する変数の定義です。
project_name,project_id,location_id,org_idをコンソールかクレデンシャルから得られる情報を設定していきます。
jwt_secret_keyはNestJSで使用する環境変数になります。

variables.tf
variable "project_name" {
  default     = "sample-project"
  type        = string
}

variable "project_id" {
  default     = "sample-project-12345"
  type        = string
}

variable "location_id" {
  default = "asia-northeast1"
  type        = string
}

variable "org_id" {
  default = "----------"
  type        = string
}

variable "jwt_secret_key" {
  description = "The JWT secret key"
  type        = string
}

jwt_secret_keyに指定する環境変数は以下に定義します。※git管理外

terraform.tfvars
jwt_secret_key = "hoge"

terraformで利用するプロバイダの設定です。

provider.tf
terraform {
  required_providers {
    google = {
      source = "hashicorp/google"
      version = "5.12.0"
    }
  }
}

provider "google" {
  credentials = file("credential.json") ※git管理外に

  project =  var.project_id
  region  = "asia-northeast1"

  user_project_override = true
}

使用するプロジェクトの指定を行います。

main.tf
resource "google_project" "default" {
  provider = google

  project_id      = var.project_id
  name            = var.project_name
  org_id          = var.org_id

  # Firebase のプロジェクトとして表示するため
  labels = {
    "firebase" = "enabled"
  }
}

firestoreの設定

※datastoreモードです。
複合インデックスが必要な場合は以下のように設定してしまいます。

firestore.tf
resource "google_firestore_database" "datastore_mode_database" {
  project                 = var.project_id
  name                    = "(default)"
  location_id             = var.location_id
  type                    = "DATASTORE_MODE"
  delete_protection_state = "DELETE_PROTECTION_DISABLED"
  deletion_policy         = "ABANDON"
  timeouts {}
}

// インデックス
resource "google_datastore_index" "TestIndex" {
  project    = var.project_id

  kind = "TestTable"
  properties {
    name = "testId"
    direction = "ASCENDING"
  }
  properties {
    name = "createdAt"
    direction = "DESCENDING"
  }
}

cloud functionの設定

ポイントについてはコメントで記しています。

api-function.tf
data "archive_file" "function_archive" {
  type        = "zip"
  // NestJSのソースがあるディレクトリを指定
  source_dir  = "../../api"
  // zipが肥大化しないように以下のものは指定から外す
  excludes = [ "node_modules", "dist", "credential.json" ]
  output_path = "./function.zip"
}

// 関数ソースが入ったzipを配置するストレージバケット
resource "google_storage_bucket" "api_function_bucket" {
  name          = "api-function-${var.project_id}"
  location      = var.location_id
  storage_class = "STANDARD"
}

// cloud functionの対象となるオブジェクトを指定するリソース
resource "google_storage_bucket_object" "packages" {
  name   = "function.zip"
  bucket = google_storage_bucket.api_function_bucket.name
  source = data.archive_file.function_archive.output_path
}

// IAMを設定するために必要なサービスアカウント
// これに必要な権限を付与してcloud-functionに紐付ける
resource "google_service_account" "api-function-user" {
  project      = var.project_id
  account_id   = "api-function-user"
  display_name = "api-function-user"
}

// アカウントの権限を借用する権限
resource "google_project_iam_member" "project" {
  project = var.project_id
  role    = "roles/iam.serviceAccountTokenCreator"
  member  = "serviceAccount:${google_service_account.api-function-user.email}"
}

resource "google_project_iam_member" "service-account-user-iam" {
  project = var.project_id
  role    = "roles/iam.serviceAccountUser"
  member  = "serviceAccount:${google_service_account.api-function-user.email}"
}

// firestore(datastore)をたたくために必要なIAM
resource "google_project_iam_member" "firestore-user-iam" {
  project = var.project_id
  role    = "roles/datastore.user"
  member  = "serviceAccount:${google_service_account.api-function-user.email}"
}

// ストレージバケット(sampleバケット)をたたくために必要なIAM
resource "google_storage_bucket_iam_member" "bucket_iam" {
  bucket = "sample"
  role   = "roles/storage.objectViewer"

  member  = "serviceAccount:${google_service_account.api-function-user.email}"
}

// cloud functionはデフォルトでアクセス不可
// memberをallUsersとして誰でもコール可能にする
resource "google_cloudfunctions_function_iam_member" "api-events-iam" {
  project        = var.project_id
  region         = var.location_id 

  cloud_function = google_cloudfunctions_function.api-function.name
  role           = "roles/cloudfunctions.invoker"
  member         = "allUsers"
}

resource "google_cloudfunctions_function" "api-function" {
  // 先に設定したサービスアカウントを設定
  service_account_email = google_service_account.api-function-user.email

  name                  = "api-function"
  runtime               = "nodejs18"
  source_archive_bucket = google_storage_bucket.api_function_bucket.name
  source_archive_object = google_storage_bucket_object.packages.name

  trigger_http          = true
  available_memory_mb   = 512
  timeout               = 60
  entry_point           = "handler" // 実行する関数名

  environment_variables = {
  // デフォルトのタイムゾーンはUTC。日付周りの計算でJTCにする必要があった
    TZ = "Asia/Tokyo" 
    VITE_FIRESTORE_NAMESPACE = "TestNameSpace"
    VITE_JWT_SECRET_KEY = var.jwt_secret_key
  }
}

NestJS側の設定

cloud functionをterraformでapplyする際は、package.jsonのscripts上に書かれた"build"が自動的に実行されます。なのでコマンドが存在しない場合はエラーとなります。
また、エントリーポイントとなる関数のファイル名がデフォルトでfunction.jsになります。


import { bootstrapNest } from './bootstrap';
import 'reflect-metadata';
import { http, Request, Response } from '@google-cloud/functions-framework';


async function bootstrapServer() {
  // bootstrapNestでNestFactory.create, initしたものを返却しています
  const nestApp = await bootstrapNest(); 
  return nestApp.getHttpAdapter().getInstance();
}

export const handler = async (req: Request, res: Response) => {
  const server = await bootstrapServer();
  return server(req, res);
};

Discussion