Baza danych — Firestore¶
Firestore to dokument NoSQL — bez schematu, bez migracji, bez konfiguracji sieci. Jeden zasób Terraform, zerowy koszt baseline.
Dlaczego Firestore, nie Cloud SQL¶
Pełne porównanie w ADR-002. Skrót:
| Kryterium | Cloud SQL | Firestore |
|---|---|---|
| Koszt przy 0 req | ~$7/mies (instancja działa zawsze) | $0 |
| Koszt przy małym ruchu | ~$7/mies | $0 |
| Zasoby Terraform | ~5 (instance, db, user, VPC peering, proxy) | 1 (google_firestore_database) |
| Migracje schematu | Wymagane (Alembic/Flyway) | Brak schematu |
| Połączenie z Cloud Run | Cloud SQL Auth Proxy lub Private IP | Bezpośrednio przez HTTP API |
| Backup | Point-in-time recovery | Geo-replikacja managed |
Wybraliśmy Firestore bo model danych aplikacji (lista logowań per user) jest naturalnie dokumentowy, a koszt jest kluczowy dla prototypu.
Terraform — warstwa tf/database/¶
# tf/database/main.tf
# Włącz Firestore API
resource "google_project_service" "apis" {
service = "firestore.googleapis.com"
disable_on_destroy = false
}
# Baza danych Firestore (jedna per projekt — "default")
resource "google_firestore_database" "db" {
project = var.project_id
name = "(default)" # (1)
location_id = var.region # europe-central2
type = "FIRESTORE_NATIVE" # (2)
depends_on = [google_project_service.apis]
}
# IAM — Cloud Run SA może czytać/pisać
resource "google_project_iam_member" "run_sa_firestore" {
project = var.project_id
role = "roles/datastore.user" # (3)
member = "serviceAccount:cloud-run-backend-sa@${var.project_id}.iam.gserviceaccount.com"
}
name = "(default)"— Firestore obsługuje jedną bazę(default)per projekt na Free tier. Kolejne bazy wymagają Blaze plan.FIRESTORE_NATIVEvsDATASTORE_MODE— Native obsługuje realtime listeners, richer query model, better SDK. Datastore Mode to tryb kompatybilności z Cloud Datastore. Zawsze wybieraj Native dla nowych projektów.roles/datastore.user— może czytać, pisać, usuwać dokumenty. Nie może zarządzać bazą (tworzyć indeksów programatycznie, usuwać bazy). Zasada least privilege.
Model danych¶
Kolekcje Firestore nie wymagają wcześniejszego tworzenia — powstają przy pierwszym zapisie.
Firestore "(default)"
│
├─ logins/ ← kolekcja (prod)
│ └─ {uid}/ ← dokument = user ID (z JWT sub)
│ └─ events/ ← subkolekcja
│ ├─ {auto_id}/ ← dokument = jedno logowanie
│ │ ├─ timestamp: Timestamp
│ │ ├─ email: "user@gmail.com"
│ │ └─ name: "Jan Kowalski"
│ └─ ...
│
└─ staging_logins/ ← kolekcja (staging)
└─ {uid}/
└─ events/
└─ ...
Izolacja prod/staging przez prefix kolekcji (staging_) — ten sam projekt GCP, ta sama baza Firestore, ale oddzielne dane.
Email whitelist — kolekcja config¶
Aplikacja sprawdza, czy email zalogowanego użytkownika jest na liście dozwolonych. Lista przechowywana w Firestore:
Inicjalizacja (jednorazowo po pierwszym deploy backendu):
# Przez gcloud CLI
gcloud firestore documents create \
"projects/gcp-prototype-1-20260224/databases/(default)/documents/config/allowed_emails" \
--fields='emails=array_value:[value_1={string_value="benhornbeam@pm.me"}]' \
--project=gcp-prototype-1-20260224
# Lub przez GCP Console → Firestore → + Start collection
# Collection ID: config
# Document ID: allowed_emails
# Field: emails (Array) → add values
Jak działa w kodzie:
# app/main.py
_CACHE_TTL = 300 # 5 minut cache
_allowed_cache: dict = {"emails": frozenset(), "expires": 0.0}
def get_allowed_emails() -> frozenset:
now = time.monotonic()
if now < _allowed_cache["expires"]:
return _allowed_cache["emails"] # cache hit
doc = db.collection("config").document("allowed_emails").get()
emails = frozenset(doc.to_dict().get("emails", [])) if doc.exists else frozenset()
_allowed_cache["emails"] = emails
_allowed_cache["expires"] = now + _CACHE_TTL
return emails
def check_access(email: str) -> None:
allowed = get_allowed_emails()
if allowed and email not in allowed: # pusta lista = brak ograniczeń
raise HTTPException(status_code=403, detail="Access denied")
Pusta lista = brak ograniczeń
Jeśli dokument config/allowed_emails nie istnieje lub emails jest pustą listą — dostęp jest otwarty dla wszystkich zalogowanych użytkowników. Whitelist jest opcjonalny.
Klient Python — google-cloud-firestore¶
from google.cloud import firestore
# Client używa Application Default Credentials
# W Cloud Run: automatycznie używa SA cloud-run-backend-sa
db = firestore.Client()
# Zapis
db.collection("logins") \
.document(uid) \
.collection("events") \
.add({
"timestamp": datetime.now(timezone.utc),
"email": email,
})
# Odczyt — ostatnie 5, posortowane malejąco
events = (
db.collection("logins")
.document(uid)
.collection("events")
.order_by("timestamp", direction=firestore.Query.DESCENDING)
.limit(5)
.stream()
)
firestore.Client() bez argumentów używa ADC. W Cloud Run — SA cloud-run-backend-sa z rolą roles/datastore.user. Lokalnie — Twoje konto (przez gcloud auth application-default login).
Free tier Firestore¶
| Metryka | Free tier limit/dzień |
|---|---|
| Odczyty dokumentów | 50,000 |
| Zapisy dokumentów | 20,000 |
| Usunięcia dokumentów | 20,000 |
| Storage | 1 GB łącznie |
| Egress | 10 GB/mies |
Dla prototypu z kilkoma użytkownikami — Free tier wystarczy na wiele miesięcy.
Pułapki¶
Firestore database jest immutable po stworzeniu
Po terraform apply nie możesz zmienić location_id ani type. Zmiana = destroy + create = utrata wszystkich danych. Wybierz region raz i trzymaj się go.
roles/datastore.admin nie działa na poziomie projektu
Google nie obsługuje roles/datastore.admin jako project-level IAM binding (zwraca Error 400). Dostępna alternatywa: roles/datastore.owner — szersza rola (może zarządzać samą bazą). Dla uprawnień CI/CD SA (gh-infra-worker) używamy roles/datastore.owner; dla Cloud Run runtime SA (cloud-run-backend-sa) — roles/datastore.user.
Indeksy kompozytowe
order_by("timestamp").limit(5) nie wymaga dodatkowego indeksu — Firestore ma automatyczne indeksy dla pojedynczych pól. Złożone zapytania (order_by po wielu polach, where + order_by po różnych polach) wymagają manualnego tworzenia indeksów w konsoli lub przez gcloud firestore indexes create.