Go×GCP

Cloud Firestore を Go で使い倒す:スキーマ設計・実装・パフォーマンス最適化

1. はじめに

連載の流れと本記事の位置付け

本連載は、Go 言語と Google Cloud Platform(GCP)を組み合わせて “モダンなクラウドネイティブ開発” を実践形式で学ぶシリーズです。第1回では Cloud Run + Cloud Build を活用し、マイクロサービスを高速にビルド & デプロイするパイプラインを構築しました。そこで得た CI/CD 基盤を踏まえ、第2回となる本記事では Cloud Firestore を永続層に据えたアプリケーションを題材に、スキーマ設計・実装・性能最適化までを深掘りします。

前回記事の振り返り

  • Cloud Run でコンテナをサーバーレス実行し、リクエスト駆動で自動スケール
  • Cloud Build によるビルド → テスト → デプロイの自動化
  • k6 を用いた並列ストリーム負荷試験で、スループットと可観測性を確認

これらの基盤はそのまま活かしつつ、今回はストレージ層を Firestore へ拡張することで、リアルタイム性とスケーラビリティを備えたバックエンドを完成させます。

本記事のゴール

  1. Firestore の主要概念(コレクション/ドキュメント/インデックス)を理解する
  2. Go クライアントを用いた CRUD・トランザクション実装ができる
  3. ローカルエミュレータ + CI パイプラインでテストを自動化し、性能を計測できる

サンプルレポジトリ

本記事の完成コード一式は GitHub モノレポ にまとめています。 ▶ https://github.com/lancelot89/go-gcp-samples

クイックスタート

# 1. レポジトリを取得
git clone https://github.com/lancelot89/go-gcp-samples.git
cd go-gcp-samples/v2-firestore

# 2. Firestore Emulator を使ってローカル起動
docker compose up --build

# 3. Cloud Build でデプロイしたい場合
#    v2-firestore/cloudbuild.yaml が自動デプロイの設定一式です
  • v2-firestore/ …第2回(本記事):Cloud Run + Firestore サンプル
  • v1-cloud-run/ …第1回:Cloud Run + Cloud Build 基礎サンプル

2. Firestore を選ぶ理由

NoSQL と RDB の住み分け

RDB はスキーマと強整合性を持つ一方、水平スケールには制約があります。Firestore は ドキュメント指向 で、スキーマレス・自動シャーディングにより高い書き込み並列性を実現します。そのため、ユーザー生成コンテンツや IoT テレメトリなどスパイクの激しいワークロードに適しています。

要件Cloud SQL (PostgreSQL)Cloud SpannerCloud Firestore
スケール垂直 / 手動水平 / 自動水平 / 自動
一貫性強整合強整合リージョン強整合
トランザクションACIDACIDドキュメント粒度 ACID
料金モデルvCPU・ストレージノード課金読み書き・ストレージ

Cloud SQL / Spanner との比較

  • Cloud SQL は既存 RDBMS 資産を活かせるが、テーブル設計とインデックス調整が必須。
  • Spanner はグローバル分散トランザクションまで対応するが、初期コストが高い。
  • Firestore従量課金 + 自動スケール、リアルタイムリッスンが利用できる点が強み。

代表的ユースケース

  • Realtime チャット、コラボレーションツール
  • SaaS のマルチテナント設定ストア
  • モバイル & IoT デバイスデータの高速集約

3. 全体アーキテクチャ

データフロー概要

  1. クライアントが REST API に JSON を POST
  2. Cloud Run(Go)でリクエストを検証し、Firestore コレクションへ書き込み

ローカルエミュレータを含む開発パイプライン

┌──────────────┐   docker-compose up
│ Firestore      │◀──────────────┐
│ Emulator       │                │
└──────────────┘                │
          ▲ gRPC                │
          │                      │  HTTP/1.1
┌──────────────┐                │
│ Go App (local)│───────────────┘
└──────────────┘
  • CI では Cloud Build 上でも Emulator を起動し、統合テストを実行

4. 前提条件と環境準備

プロジェクトと API 有効化

PROJECT_ID="my-firestore-demo"
gcloud config set project ${PROJECT_ID}
# Firestore & 必要 API
for api in firestore.googleapis.com cloudbuild.googleapis.com run.googleapis.com;
do gcloud services enable $api; done
  • Firestore は Native モード を推奨(リアルタイム更新 + モバイル SDK 互換)。
  • 東京リージョン(asia‑northeast1) or マルチリージョン(nam5 等)を選択。

ローカル開発ツール

ツールバージョン用途
Go1.24+サーバー実装
gcloud CLI475.0+デプロイ / Emulator 起動
Docker Desktop25+Emulator, CI テスト
direnv / Make任意環境変数管理とビルド自動化

IAM ロール

  • roles/datastore.user(アプリ実行用)
  • roles/cloudbuild.builds.editor(CI)
  • roles/run.admin(デプロイ)

5. Firestore Emulator でローカル開発

Docker で Emulator 起動

# docker-compose.fire.yml
services:
  firestore:
    image: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
    command: ["gcloud", "beta", "emulators", "firestore", "start", "--host-port=0.0.0.0:8080"]
    ports:
      - "8080:8080"
docker compose -f docker-compose.fire.yml up -d
export FIRESTORE_EMULATOR_HOST=localhost:8080

テスト分離戦略

  • テスト開始前に namespaces を UUID 付けで作成し、データ衝突を防止
  • Go の testing パッケージで t.Parallel() を併用し、CI での高速化を図る

CI/CD への統合

Cloud Build でも Emulator をサービスとして起動:

# cloudbuild.yaml
- name: "gcr.io/cloud-builders/docker"
  args: ["compose", "-f", "docker-compose.fire.yml", "up", "-d"]
- name: "gcr.io/cloud-builders/go"
  env: ["FIRESTORE_EMULATOR_HOST=firestore:8080"]
  args: ["test", "./..."]

これにより、本番データと分離した完全自動テストが可能になります。

6. スキーマ設計入門 ― Collections & Documents

Firestore はスキーマレスとはいえ、読み書きパターンを先に設計すること が性能・コストを左右します。本章では Todo 管理 API を例に、正規化 vs 非正規化の判断軸とホットスポット回避策を示します。

6.1 正規化 vs 非正規化

パターンメリットデメリット
正規化(親子を分離)ドキュメントサイズが小さく更新競合が少ない読み取り時に複数回のクエリが必要、料金増加
非正規化(Embed)1 回の読み取りでデータが揃う更新時に複数ドキュメント修正の手間、サイズが肥大

判断基準: 取得頻度が圧倒的に多い側を優先し、書き込み頻度が低い/小規模なら非正規化。

6.2 サブコレクションを用いたネスト

users/{userId}
  └─ todos/{todoId}
          └─ comments/{commentId}
  • users コレクション直下に todos を置くことで、セキュリティルールがシンプル
  • サブコレクションは独立したスケーリング単位。

6.3 ホットスポットとその回避策

6.3.1 ホットスポットとは?

Firestore は ドキュメント ID をもとに内部でシャーディング し、スケールアウトします。ところが

  • 連番 ID(1, 2, 3…********************)や時系列で増えるキー を採用すると、直近の数シャードだけに書き込みが集中
  • シャードあたり 最大 10,000 書き込み/秒 を超えるとレイテンシ上昇 → RESOURCE_EXHAUSTEDABORTED が頻発

この現象を ホットスポット と呼びます。

症状代表的なログ/指標説明
書き込みレイテンシ急増Cloud Monitoring: write_ops_latency p99 が跳ね上がる単一シャード飽和
リトライ急増Error Reporting: RESOURCE_EXHAUSTED同時書き込み制限ヒット
トランザクション衝突Error Reporting: ABORTED楽観的ロック失敗

6.3.2 回避パターン

  1. ランダムプレフィックス import ( "crypto/rand" "encoding/hex" ) func newDocID() string { b := make([]byte, 2) // 4 文字 = 65,536 通り _, _ = rand.Read(b) return fmt.Sprintf("%s_%d", hex.EncodeToString(b), time.Now().UnixNano()) } // => "a3f1_1721546630123456" 先頭 4 文字にランダム値を付与するだけで 最大 65,536 シャード に分散。
  2. 分散カウンター
    集計用途の 1 ドキュメントに書き込む代わりに、counter_shards/{shardId} を 64 〜 256 個生成し、
    トランザクションなしの Increment(1) を各シャードに分散。集計時は Cloud Function 等で合算。
  3. タイムパーティショニング ID
    ミリ秒時間を逆順 (9999 - timestamp) でエンコードし、前にランダム 2〜3 文字を添えると 時系列降順 の同時取得と分散の両立が可能。
  4. コレクション分割
    トラフィックが予測可能に高い場合は todos_2025_07 など タイムスタンプでコレクションをローテーション し、ログライクに運用。

6.3.3 検知とチューニングフロー

  1. Cloud Monitoring で次指標をダッシュボード化
    • firestore.googleapis.com/document/write_latency
    • firestore.googleapis.com/document/write_count
  2. p95/p99 レイテンシが 500 ms を超えたら ホットスポット疑い
  3. Error Reporting で RESOURCE_EXHAUSTED が 1 分あたり数十件 → 分散手法適用
  4. 施策導入後 24 時間のレイテンシグラフを比較し、改善 < 200 ms を確認

まとめ: ホットスポットは “書き込みが一極集中するキー設計” が原因。Firestore は自動スケールとはいえ、ID 設計でレイテンシと料金が大きく変わるため、ランダム化 + 分散カウンターを基本戦術に据えましょう。


7. Go クライアントで CRUD 実装

7.1 ディレクトリ構成

以下のように clean architecture 風にレイヤー分割しています。

v2-firestore/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── firestore/
│   │   ├── repository.go
│   │   └── model.go
│   ├── handler/
│   │   └── todo.go
│   └── service/
│       └── todo_service.go
├── pkg/
│   └── config/
│       └── config.go
└── go.mod

ポイント

  • cmd/ はアプリケーションのエントリポイントのみ配置し、依存方向は常に internal/ へ一方向。
  • internal/firestore ではデータアクセス層(Repository)をまとめ、Firestore 以外の実装にも差し替え可能なインターフェースを定義。
  • internal/handler は HTTP ハンドラや Pub/Sub 受信トリガーなどのアダプタを配置し、ユースケース層(Service)と疎結合。
  • pkg/ 配下は外部公開しても差し支えない汎用コードや設定ヘルパーを格納。

7.2 依存関係

go 1.24
require (
  cloud.google.com/go/firestore v1.17.0
  google.golang.org/api/option v0.147.0
)

7.3 基本 CRUD 実装例

// todo_repository.go
package firestore

import (
  "context"
  "cloud.google.com/go/firestore"
)

type TodoRepository struct {
  cli *firestore.Client
}

func (r *TodoRepository) Create(ctx context.Context, t *model.Todo) error {
  _, err := r.cli.Collection("todos").Doc(t.ID).Set(ctx, t)
  return err
}

func (r *TodoRepository) Get(ctx context.Context, id string) (*model.Todo, error) {
  snap, err := r.cli.Collection("todos").Doc(id).Get(ctx)
  if err != nil {
    return nil, err
  }
  var t model.Todo
  if err := snap.DataTo(&t); err != nil { return nil, err }
  return &t, nil
}

7.4 リアルタイムリスナー

ds := r.cli.Collection("todos").Snapshots(ctx)
for {
  snap, err := ds.Next()
  if err != nil { break }
  log.Printf("changed: %d docs", len(snap.Changes))
}

7.5 バッチ書き込み

batch := r.cli.Batch()
for _, t := range todos {
  ref := r.cli.Collection("todos").Doc(t.ID)
  batch.Set(ref, t)
}
_, err := batch.Commit(ctx)

7.6 Cloud Build パイプラインとデプロイ

以下は Firestore Emulator を Docker Compose で立ち上げたうえで、v2-firestore サービスのみをビルド & デプロイ する cloudbuild.yaml の実例です。Mono‑repo でも対象ディレクトリを絞ることで、他モジュールに影響なく CI/CD を回せます。

steps:
  # Start Firestore Emulator for testing
  - name: 'gcr.io/cloud-builders/docker'
    args: ["compose", "-f", "v2-firestore/docker-compose.fire.yml", "up", "-d"]
    dir: "."

  # Wait for Firestore Emulator to be ready (optional, but good practice)
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
    entrypoint: bash
    args:
      - '-c'
      - |
        for i in $(seq 1 10); do
          curl -s http://localhost:8080 > /dev/null && break
          echo "Waiting for Firestore Emulator..."
          sleep 5
        done

  # Build the container image
  - name: 'gcr.io/cloud-builders/docker'
    args:
      - 'build'
      - '-t'
      - 'asia-northeast1-docker.pkg.dev/$PROJECT_ID/go-gcp-samples/v2-firestore:$COMMIT_SHA'
      - '.'
    dir: "v2-firestore"

  # Push the container image to Artifact Registry
  - name: 'gcr.io/cloud-builders/docker'
    args:
      - 'push'
      - 'asia-northeast1-docker.pkg.dev/$PROJECT_ID/go-gcp-samples/v2-firestore:$COMMIT_SHA'
    dir: "v2-firestore"

  # Deploy container image to Cloud Run
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
    entrypoint: gcloud
    args:
      - 'run'
      - 'deploy'
      - 'v2-firestore'
      - '--image'
      - 'asia-northeast1-docker.pkg.dev/$PROJECT_ID/go-gcp-samples/v2-firestore:$COMMIT_SHA'
      - '--region'
      - 'asia-northeast1'
      - '--platform'
      - 'managed'
      - '--allow-unauthenticated'
      - '--set-env-vars=PROJECT_ID=$PROJECT_ID' # Pass PROJECT_ID to the Cloud Run service
    dir: "v2-firestore"

options:
  logging: CLOUD_LOGGING_ONLY

解説ポイント

  • `` を v2-firestore に指定 → ルート /workspace ではなくサブディレクトリをカレントに実行。
  • Emulator 起動 を最初の step に置くことで、ユニットテスト・統合テストも同ジョブ内で可能。
  • Artifact Registry 名称 に mono‑repo のディレクトリを含めるとタグ衝突を防止しやすい。
  • 本番環境では Emulator step を除外した cloudbuild-prod.yaml を用意し、Cloud Build Trigger で切り替えると高速化。

8. トランザクションとバッチ書き込み

8.1 楽観的トランザクション

err := r.cli.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error {
  doc, err := tx.Get(r.cli.Collection("counters").Doc("todo_count"))
  if err != nil { return err }
  count := doc.Data()["value"].(int64)
  tx.Update(doc.Ref, []firestore.Update{{Path: "value", Value: count + 1}})
  return nil
})
  • 最大 500 読み書き・60 秒以内に完了必須。

8.2 悲観的(ロック)トランザクション注意点

  • 同一ドキュメントへ集中的に書く場合は リトライ増
  • エラー時に firestore.Aborted をチェックし指数バックオフで再試行。

8.3 バッチ vs トランザクションの使い分け

ケースバッチトランザクション
一括初期データ投入
整合性必須のカウンター更新
料金抑制○ (同数)

9. インデックス設計とクエリ最適化

9.1 単一フィールドインデックス

Firestore のデフォルトで自動作成されるため個別作成は不要。ただし 除外設定 でストレージ節約可。

9.2 複合インデックス

複数フィールドをフィルタ & ソートするクエリには必須。

# firestore.indexes.json (抜粋)
{
  "indexes": [
    {
      "collectionGroup": "todos",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "userId", "order": "ASCENDING" },
        { "fieldPath": "priority", "order": "DESCENDING" }
      ]
    }
  ]
}

CI パイプラインで gcloud firestore indexes composite create を呼び、PR 段階で検証すると運用が楽。

9.3 クエリ実行計画の確認

  • Cloud Console の 「クエリプレビュー」 で必要インデックスを検出。
  • 読み取り数 は課金に直結 → クエリを .Select() で射影し転送量を削減。

10. セキュリティルール & IAM

10.1 ルール言語の基礎

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId}/todos/{todoId} {
      allow read, write: if request.auth.uid == userId;
    }
  }
}
  • request.auth.uid で Firebase Auth ユーザー ID を取得。
  • 時刻ベース制限 など柔軟な条件式が書ける。

10.2 ルールテスト CLI

firebase emulators:exec --project=demo "go test ./..."

CI でルールが変更された PR をコメントアウトすると事故を防止。

10.3 IAM とサービスアカウント

サービス必要ロール
Cloud Run → Firestoreroles/datastore.user
Cloud Build デプロイroles/datastore.owner (最小構成なら user + indexAdmin)

最小権限の原則: App サービス アカウントに roles/datastore.user のみを付与し、インデックス管理は CI 用 SA で行う。

11. Observability ― Logging・Tracing・Metrics

11.1 構造化ログで Cloud Logging を活かす

import (
  "context"
  "log"
  "cloud.google.com/go/logging"
)

func NewLogger(projectID string) (*logging.Logger, func()) {
  client, _ := logging.NewClient(context.Background(), projectID)
  logger := client.Logger("app")
  return logger, func() { _ = client.Close() }
}

func (h *TodoHandler) Create(ctx context.Context, r *http.Request) {
  // …中略…
  h.logger.StandardLogger(logging.Info).Printf("todo_created", map[string]interface{}{
      "todoId": t.ID,
      "userId": t.UserID,
  })
}
  • logging.Logger.StandardLogger() を使うと Go の log と同等 API で JSON 構造化ログ を出力。
  • severitytrace, spanId, labels も自動注入されるため、Cloud Trace との相関 が容易。

11.2 OpenTelemetry で分散トレース

import (
  "go.opentelemetry.io/otel"
  "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
  "go.opentelemetry.io/otel/sdk/trace"
)

func initTracer(projectID string) func() {
  exp, _ := otlptracegrpc.New(context.Background())
  tp := trace.NewTracerProvider(
    trace.WithBatcher(exp),
    trace.WithResource(resource.NewWithAttributes(
      semconv.SchemaURL,
      semconv.ServiceNameKey.String("todo-api"),
    )),
  )
  otel.SetTracerProvider(tp)
  return func() { _ = tp.Shutdown(context.Background()) }
}
  • OTLP exporter は Cloud Trace で ネイティブ受信(2024 年末より GA)。
  • 1 リクエスト = 1 Span 方針を徹底し、Firestore 呼び出しごとに子 Span を生成してボトルネック特定。

11.3 Cloud Monitoring カスタム指標

import monitoring "cloud.google.com/go/monitoring/apiv3"
  • Firestore の document/write_ops_count など 既存メトリクス に加え、アプリ固有メトリクス(例: キュー長、goroutine 数)を API 経由で Push。
  • ダッシュボード: レイテンシ p95/p99エラー率 を 1 画面に集約して on‑call を支援。

12. 負荷試験とスケーリング検証

12.1 k6 スクリプトを Firestore 用に拡張

import http from 'k6/http';
import { check, sleep } from 'k6';

export let options = {
  stages: [
    { duration: '30s', target: 50 },
    { duration: '1m', target: 200 },
    { duration: '30s', target: 0 },
  ],
};

export default function () {
  const payload = JSON.stringify({ title: `t-${__VU}-${__ITER}` });
  const params = { headers: { 'Content-Type': 'application/json' } };
  const res = http.post('https://todo-api-xxxxx.a.run.app/todos', payload, params);
  check(res, { 'status == 200': (r) => r.status === 200 });
  sleep(1);
}
  • Cloud Build の k6 run ステップで 毎 PR 性能回帰を検知。
  • RPS と Firestore 書き込み数 を Correlate し、書き込みレイテンシが 200 ms を超えたら ID 分散 or バッチ導入を再検討。

12.2 自動スケーリング挙動の観測

  1. Cloud Run の container/concurrent_request_count
  2. Firestore write_ops_count
  3. k6 http_req_duration

三者を同時可視化し、どの層がボトルネックか を特定。


13. コスト計算と最適化 Tips

項目単価 (東京リージョン)計算式 / 月例備考
書き込み¥0.056 / 100K150K writes → ¥84バッチで半減可
読み取り¥0.018 / 100K300K reads → ¥54.Select() でフィールド絞り込み
ストレージ¥0.024 / GB2 GB → ¥48TTL で自動削除

合計 ≈ ¥186 / 月(小規模 API 想定)

13.1 最適化チェックリスト

  • 読み取り削減: REST API キャッシュ(Cloud CDN or Cloud Run /cache レイヤ)
  • 書き込み削減: クライアントバッファリング & バッチ Commit
  • ストレージ削減: Firestore TTL ポリシー + BigQuery Export で低頻度データを倉庫化

14. よくあるトラブルシューティング

エラーコード原因対処
RESOURCE_EXHAUSTED同時書き込み制限超過ID 分散 / バッチ化 / リトライ with Backoff
ABORTEDトランザクション衝突楽観的ロック → 失敗時リトライ
PERMISSION_DENIEDIAM or Security Rules 不備SA 権限確認 / ルールテスト CLI
NOT_FOUNDドキュメントパス誤りパス生成ロジック、サブコレクション階層確認

14.1 デバッグフロー

  1. Cloud Logging で TraceID から関連ログを束ねて閲覧
  2. gcloud alpha firestore operations list で長時間オペレーション確認
  3. Stackdriver Profiler で CPU/メモリホットスポット解析

15. まとめ

  • Firestore を永続層に追加 し、Cloud Run + Cloud Build の CI/CD 基盤上でリリースまでを自動化。
  • スキーマ設計→ID 分散→インデックス設計→可観測性→コスト管理まで網羅し、運用目線 でのベストプラクティスを紹介。

16. 次回予告

次回は Workflows / Pub/Sub / Spanner などとの連携を実際に組み合わせ、イベント駆動 & トランザクション分散 を検証予定。

  • この記事を書いた人

ふくまる

機械設計業をしていたが25歳でエンジニアになると決意して行動開始→ 26歳でエンジニアに転職→ 28歳でフリーランスエンジニアに→ 現在、34歳でフリーランス7年目 Go案件を受注中 Go,GCPが得意分野

-Go×GCP