1. はじめに
連載の流れと本記事の位置付け
本連載は、Go 言語と Google Cloud Platform(GCP)を組み合わせて “モダンなクラウドネイティブ開発” を実践形式で学ぶシリーズです。第1回では Cloud Run + Cloud Build を活用し、マイクロサービスを高速にビルド & デプロイするパイプラインを構築しました。そこで得た CI/CD 基盤を踏まえ、第2回となる本記事では Cloud Firestore を永続層に据えたアプリケーションを題材に、スキーマ設計・実装・性能最適化までを深掘りします。
前回記事の振り返り
- Cloud Run でコンテナをサーバーレス実行し、リクエスト駆動で自動スケール
- Cloud Build によるビルド → テスト → デプロイの自動化
- k6 を用いた並列ストリーム負荷試験で、スループットと可観測性を確認
これらの基盤はそのまま活かしつつ、今回はストレージ層を Firestore へ拡張することで、リアルタイム性とスケーラビリティを備えたバックエンドを完成させます。
本記事のゴール
- Firestore の主要概念(コレクション/ドキュメント/インデックス)を理解する
- Go クライアントを用いた CRUD・トランザクション実装ができる
- ローカルエミュレータ + 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 Spanner | Cloud Firestore |
---|---|---|---|
スケール | 垂直 / 手動 | 水平 / 自動 | 水平 / 自動 |
一貫性 | 強整合 | 強整合 | リージョン強整合 |
トランザクション | ACID | ACID | ドキュメント粒度 ACID |
料金モデル | vCPU・ストレージ | ノード課金 | 読み書き・ストレージ |
Cloud SQL / Spanner との比較
- Cloud SQL は既存 RDBMS 資産を活かせるが、テーブル設計とインデックス調整が必須。
- Spanner はグローバル分散トランザクションまで対応するが、初期コストが高い。
- Firestore は 従量課金 + 自動スケール、リアルタイムリッスンが利用できる点が強み。
代表的ユースケース
- Realtime チャット、コラボレーションツール
- SaaS のマルチテナント設定ストア
- モバイル & IoT デバイスデータの高速集約
3. 全体アーキテクチャ
データフロー概要
- クライアントが REST API に JSON を POST
- 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 等)を選択。
ローカル開発ツール
ツール | バージョン | 用途 |
---|---|---|
Go | 1.24+ | サーバー実装 |
gcloud CLI | 475.0+ | デプロイ / Emulator 起動 |
Docker Desktop | 25+ | 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_EXHAUSTED
/ABORTED
が頻発
この現象を ホットスポット と呼びます。
症状 | 代表的なログ/指標 | 説明 |
---|---|---|
書き込みレイテンシ急増 | Cloud Monitoring: write_ops_latency p99 が跳ね上がる | 単一シャード飽和 |
リトライ急増 | Error Reporting: RESOURCE_EXHAUSTED | 同時書き込み制限ヒット |
トランザクション衝突 | Error Reporting: ABORTED | 楽観的ロック失敗 |
6.3.2 回避パターン
- ランダムプレフィックス
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 シャード に分散。 - 分散カウンター
集計用途の 1 ドキュメントに書き込む代わりに、counter_shards/{shardId}
を 64 〜 256 個生成し、
トランザクションなしのIncrement(1)
を各シャードに分散。集計時は Cloud Function 等で合算。 - タイムパーティショニング ID
ミリ秒時間を逆順 (9999 - timestamp
) でエンコードし、前にランダム 2〜3 文字を添えると 時系列降順 の同時取得と分散の両立が可能。 - コレクション分割
トラフィックが予測可能に高い場合はtodos_2025_07
など タイムスタンプでコレクションをローテーション し、ログライクに運用。
6.3.3 検知とチューニングフロー
- Cloud Monitoring で次指標をダッシュボード化
firestore.googleapis.com/document/write_latency
firestore.googleapis.com/document/write_count
- p95/p99 レイテンシが 500 ms を超えたら ホットスポット疑い
- Error Reporting で
RESOURCE_EXHAUSTED
が 1 分あたり数十件 → 分散手法適用 - 施策導入後 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 → Firestore | roles/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 構造化ログ を出力。severity
、trace
,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 自動スケーリング挙動の観測
- Cloud Run の
container/concurrent_request_count
- Firestore
write_ops_count
- k6
http_req_duration
三者を同時可視化し、どの層がボトルネックか を特定。
13. コスト計算と最適化 Tips
項目 | 単価 (東京リージョン) | 計算式 / 月例 | 備考 |
---|---|---|---|
書き込み | ¥0.056 / 100K | 150K writes → ¥84 | バッチで半減可 |
読み取り | ¥0.018 / 100K | 300K reads → ¥54 | .Select() でフィールド絞り込み |
ストレージ | ¥0.024 / GB | 2 GB → ¥48 | TTL で自動削除 |
合計 ≈ ¥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_DENIED | IAM or Security Rules 不備 | SA 権限確認 / ルールテスト CLI |
NOT_FOUND | ドキュメントパス誤り | パス生成ロジック、サブコレクション階層確認 |
14.1 デバッグフロー
- Cloud Logging で TraceID から関連ログを束ねて閲覧
gcloud alpha firestore operations list
で長時間オペレーション確認- Stackdriver Profiler で CPU/メモリホットスポット解析
15. まとめ
- Firestore を永続層に追加 し、Cloud Run + Cloud Build の CI/CD 基盤上でリリースまでを自動化。
- スキーマ設計→ID 分散→インデックス設計→可観測性→コスト管理まで網羅し、運用目線 でのベストプラクティスを紹介。
16. 次回予告
次回は Workflows / Pub/Sub / Spanner などとの連携を実際に組み合わせ、イベント駆動 & トランザクション分散 を検証予定。