概要
STANDARD_HA + PRIVATE_SERVICE_ACCESS
tier = "BASIC"(単一ノード)を STANDARD_HA に変更しプライマリ+レプリカの自動フェイルオーバー(SLA 99.9%)を得る。接続方式もレガシーな DIRECT_PEERING から、Cloud SQL と同じ PRIVATE_SERVICE_ACCESS(Service Networking)に統一する。
AUTH/TLS + maxmemory-policy
auth_enabled = true + transit_encryption_mode = SERVER_AUTHENTICATION で平文・無認証接続を廃止し、AUTH文字列は Secret Manager で管理する。デフォルト noeviction はキャッシュ用途では書き込みエラーの温床になるため allkeys-lru に変更する。
キャッシュスタンピード対策(分散ロック)
キャッシュミス時に全Podが同時にDBへ問い合わせる「キャッシュスタンピード」を、SET key value NX EX ttl による分散ロックで1リクエストに絞り込む。ロックを取得できなかったPodは短間隔でキャッシュをポーリングして結果を待つ。
TTLジッター + Circuit Breaker
固定TTLによる同時失効(Thundering Herd)を防ぐため乱数ジッターを加える。Redis呼び出しの連続失敗を CircuitBreakerState(値オブジェクト)で追跡し、閾値超過で一定時間Redisを迂回しDBへ自動フォールバックする。
問題
ECサイト MOps チームでは、フラッシュセール開催時に「残り在庫数」をリアルタイム表示する在庫参照APIを Cloud Run 上で運用している。セール開始直後は同一商品への読み取りが秒間数千リクエストに達し、毎回 Cloud SQL に問い合わせるとプライマリDBの負荷が急上昇するため、Memorystore for Redis をキャッシュ層として導入することになった。以下の Terraform コードと Python アプリケーションコードには 7つの設計上の問題 が潜んでいる。
問題点を全て洗い出し、STANDARD_HA(自動フェイルオーバー)× PRIVATE_SERVICE_ACCESS 接続 × AUTH/TLS × maxmemory-policy(allkeys-lru) × コネクションプール × キャッシュスタンピード対策(分散ロック)× TTLジッター + Circuit Breaker フォールバック を考慮した Bad→Good リファクタリングを行ってください。
制約・前提条件
- Terraform 1.8+(
googleプロバイダー 5.x)、Memorystore for Redis 7.2、リージョンasia-northeast1 - Cloud Run v2 サービスから Direct VPC Egress で Redis に接続する想定
- キャッシュキーは
stock:{product_id}、在庫データの正は Cloud SQL、Redis は read-through cache として使う - セール開始直後は同一商品への読み取りが秒間数千リクエストに達し、キャッシュミス時に多数の Pod が同時に DB へ問い合わせる懸念がある
- 現状は BASIC ティア(単一ノード・フェイルオーバーなし)・AUTH/TLS 無効・
maxmemory-policy未設定(デフォルトnoeviction)・固定TTL・コネクションプールなし・Redis 障害時にアプリがそのままエラーになる
悪いコード (Before)
resource "google_redis_instance" "stock_cache" {
name = "mops-stock-cache"
region = "asia-northeast1"
memory_size_gb = 4
redis_version = "REDIS_7_2"
# 問題①: 単一ノード、フェイルオーバーなし
tier = "BASIC"
# 問題②: レガシー接続方式(VPCルート漏洩・IPレンジ管理の負荷大)
connect_mode = "DIRECT_PEERING"
authorized_network = google_compute_network.vpc.id
# 問題③: 認証なし・平文接続
auth_enabled = false
transit_encryption_mode = "DISABLED"
# 問題④: redis_configs 未指定 → maxmemory-policy はデフォルト noeviction
}
import redis
def get_stock(product_id: str) -> int:
# 問題⑤: リクエストのたびに新規クライアント生成(コネクションプールなし)
r = redis.Redis(host="10.1.2.3", port=6379)
cached = r.get(f"stock:{product_id}")
if cached is not None:
return int(cached)
# 問題⑥: キャッシュミス時、全Podが同時にDBへ問い合わせる
# (キャッシュスタンピード)
stock = query_stock_from_db(product_id)
# 問題⑦: 固定TTL(同時失効→Thundering Herd)。
# Redis障害時のフォールバックもなくエラーがそのまま伝播する
r.set(f"stock:{product_id}", stock, ex=60)
return stock
STANDARD_HA へPRIVATE_SERVICE_ACCESS へallkeys-lru へredis.ConnectionPool で共有SET NX EX 分散ロックヒント(段階的開示)
ヒント1 — 方向性
BASIC ティア(単一ノード)・DIRECT_PEERING(レガシー接続)・AUTH/TLS 無効、(2) メモリ管理 — maxmemory-policy 未設定によるキャッシュ用途でのOOM書き込みエラー、(3) アプリケーション/耐障害性 — コネクションプールなし・キャッシュスタンピード(トラフィック急増時に多数のPodが同時にDBへ殺到)・固定TTLによる同時失効(Thundering Herd)・Redis障害時のフォールバック欠如。Memorystore for Redis は Cloud SQL の Private IP と同様に PRIVATE_SERVICE_ACCESS(Service Networking)を使うのが現在の推奨接続方式。
ヒント2 — アプローチ
- 問題①:
tier = "BASIC"(単一ノード、フェイルオーバーなし)→STANDARD_HA(プライマリ+レプリカ、自動フェイルオーバー、SLA 99.9%) - 問題②:
connect_mode = "DIRECT_PEERING"(レガシー、VPCルート漏洩リスク)→PRIVATE_SERVICE_ACCESS(Service Networking 経由) - 問題③:
auth_enabled = false+transit_encryption_mode = "DISABLED"(平文・無認証)→ 両方有効化し、AUTH文字列を Secret Manager で管理 - 問題④:
redis_configs未設定(デフォルトnoevictionはメモリ逼迫時に書き込みエラー)→maxmemory-policy = "allkeys-lru" - 問題⑤: リクエストごとに
redis.Redis()新規生成 →redis.ConnectionPoolをモジュールレベルで共有 - 問題⑥: キャッシュミス時、全Podが同時にDBへ問い合わせ →
SET NX EXの分散ロックで1リクエストに絞り込み、他は結果をポーリング待機 - 問題⑦: 固定TTL(同時失効→Thundering Herd)+ Redis障害時のフォールバック欠如 → TTLに乱数ジッターを加え、簡易サーキットブレーカーでDB自動フォールバック
ヒント3 — コードの骨格
# Memorystore for Redis スケルトン
resource "google_redis_instance" "stock_cache" {
tier = "STANDARD_HA" # 修正①
connect_mode = "PRIVATE_SERVICE_ACCESS" # 修正②
auth_enabled = true # 修正③
transit_encryption_mode = "SERVER_AUTHENTICATION" # 修正③
redis_configs = {
maxmemory-policy = "allkeys-lru" # 修正④
}
}
# キャッシュスタンピード対策 + TTLジッター スケルトン
def get_stock(product_id: str) -> int:
if _is_breaker_open(): # 修正⑦: 障害時はDB直行
return query_stock_from_db(product_id)
try:
client = _redis_client() # 修正⑤: プールから取得
cached = client.get(f"stock:{product_id}")
if cached is not None:
return int(cached)
return _fetch_with_lock(client, product_id) # 修正⑥: 分散ロック
except RedisError:
_record_failure()
return query_stock_from_db(product_id)
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | tier = BASIC(単一ノード) | 可用性 | STANDARD_HA(自動フェイルオーバー、SLA 99.9%) |
| 2 | connect_mode = DIRECT_PEERING(レガシー) | ネットワーク | PRIVATE_SERVICE_ACCESS(Service Networking) |
| 3 | AUTH/TLS 無効(平文・無認証) | セキュリティ | auth_enabled + transit_encryption_mode + Secret Manager |
| 4 | maxmemory-policy 未設定(noeviction) | メモリ管理 | redis_configs { maxmemory-policy = allkeys-lru } |
| 5 | コネクションプールなし(毎回新規接続) | 接続管理 | redis.ConnectionPool の共有 |
| 6 | キャッシュスタンピード対策なし | 負荷集中 | SET NX EX による分散ロック |
| 7 | 固定TTL + フォールバック欠如 | 耐障害性 | TTLジッター + 簡易 Circuit Breaker |
アーキテクチャ図 — Bad vs Good の変換フロー
模範解答
resource "google_redis_instance" "stock_cache" {
name = "mops-stock-cache"
region = "asia-northeast1"
memory_size_gb = 4
redis_version = "REDIS_7_2"
tier = "BASIC" # 問題①
connect_mode = "DIRECT_PEERING" # 問題②
authorized_network = google_compute_network.vpc.id
auth_enabled = false # 問題③
transit_encryption_mode = "DISABLED" # 問題③
# 問題④: redis_configs なし(maxmemory-policy = noeviction)
}
# ── Private Service Access(Cloud SQL と同じ仕組み)────────────
resource "google_compute_global_address" "redis_private_ip_range" {
name = "mops-redis-private-ip"
purpose = "VPC_PEERING"
address_type = "INTERNAL"
prefix_length = 20
network = google_compute_network.vpc.id
}
resource "google_service_networking_connection" "private_vpc" {
network = google_compute_network.vpc.id
service = "servicenetworking.googleapis.com"
reserved_peering_ranges = [google_compute_global_address.redis_private_ip_range.name]
}
# ── Memorystore for Redis(HA + Private + AUTH/TLS)───────────
resource "google_redis_instance" "stock_cache" {
name = "mops-stock-cache"
region = "asia-northeast1"
memory_size_gb = 5
redis_version = "REDIS_7_2"
tier = "STANDARD_HA" # 修正①: 自動フェイルオーバー
connect_mode = "PRIVATE_SERVICE_ACCESS" # 修正②
authorized_network = google_compute_network.vpc.id
depends_on = [google_service_networking_connection.private_vpc]
auth_enabled = true # 修正③
transit_encryption_mode = "SERVER_AUTHENTICATION" # 修正③
redis_configs = {
maxmemory-policy = "allkeys-lru" # 修正④
}
replica_count = 1
read_replicas_mode = "READ_REPLICAS_DISABLED"
maintenance_policy {
weekly_maintenance_window {
day = "SUNDAY"
start_time { hours = 18 minutes = 0 }
}
}
}
# AUTH文字列を Secret Manager に保存
resource "google_secret_manager_secret" "redis_auth" {
secret_id = "mops-stock-cache-auth"
replication { auto {} }
}
resource "google_secret_manager_secret_version" "redis_auth_value" {
secret = google_secret_manager_secret.redis_auth.id
secret_data = google_redis_instance.stock_cache.auth_string
}
# ── Cloud Run v2(Direct VPC Egress)───────────────────────────
resource "google_cloud_run_v2_service" "stock_api" {
name = "mops-stock-api"
location = "asia-northeast1"
template {
vpc_access {
network_interfaces {
network = google_compute_network.vpc.id
subnetwork = google_compute_subnetwork.run_subnet.id
}
egress = "PRIVATE_RANGES_ONLY"
}
containers {
image = "asia-northeast1-docker.pkg.dev/PROJECT_ID/mops/stock-api@sha256:abc123"
env {
name = "REDIS_HOST"
value = google_redis_instance.stock_cache.host
}
env {
name = "REDIS_AUTH"
value_source {
secret_key_ref {
secret = google_secret_manager_secret.redis_auth.secret_id
version = "latest"
}
}
}
}
}
}
import redis
def get_stock(product_id: str) -> int:
r = redis.Redis(host="10.1.2.3", port=6379) # 問題⑤
cached = r.get(f"stock:{product_id}")
if cached is not None:
return int(cached)
stock = query_stock_from_db(product_id) # 問題⑥
r.set(f"stock:{product_id}", stock, ex=60) # 問題⑦
return stock
"""stock_cache.py — フラッシュセール在庫数キャッシュ(Redis cache-aside)"""
from __future__ import annotations
import random
import time
from dataclasses import dataclass, replace
import redis
from redis.exceptions import RedisError
CACHE_KEY_PREFIX = "stock"
BASE_TTL_SECONDS = 60
TTL_JITTER_SECONDS = 10 # 修正⑦: 同時失効を防ぐジッター幅
LOCK_TTL_SECONDS = 5
LOCK_WAIT_TIMEOUT_SECONDS = 3.0
LOCK_POLL_INTERVAL_SECONDS = 0.05
FAILURE_THRESHOLD = 5
OPEN_DURATION_SECONDS = 10.0
# 修正⑤: プロセス内で共有するコネクションプール
_pool = redis.ConnectionPool(
host="REDIS_HOST_PLACEHOLDER",
port=6379,
password="REDIS_AUTH_PLACEHOLDER",
ssl=True,
max_connections=50,
socket_timeout=1.0,
socket_connect_timeout=1.0,
)
@dataclass(frozen=True, slots=True)
class CircuitBreakerState:
"""Redis障害検知の簡易サーキットブレーカー状態(値オブジェクト)。"""
failure_count: int = 0
opened_at: float | None = None
_breaker = CircuitBreakerState()
def _redis_client() -> redis.Redis:
return redis.Redis(connection_pool=_pool)
def _is_breaker_open() -> bool:
if _breaker.opened_at is None:
return False
return (time.monotonic() - _breaker.opened_at) < OPEN_DURATION_SECONDS
def _record_failure() -> None:
global _breaker
new_count = _breaker.failure_count + 1
opened_at = time.monotonic() if new_count >= FAILURE_THRESHOLD else _breaker.opened_at
_breaker = replace(_breaker, failure_count=new_count, opened_at=opened_at)
def _record_success() -> None:
global _breaker
_breaker = CircuitBreakerState()
def get_stock(product_id: str, db_lookup=query_stock_from_db) -> int:
"""在庫数を取得する(Redis優先、障害時はCloud SQLへフォールバック)。
Args:
product_id: 商品ID。
db_lookup: DB問い合わせ関数(テスト時に差し替え可能)。
Returns:
在庫数。
"""
cache_key = f"{CACHE_KEY_PREFIX}:{product_id}"
if _is_breaker_open(): # 修正⑦
return db_lookup(product_id)
try:
client = _redis_client() # 修正⑤
cached = client.get(cache_key)
if cached is not None:
_record_success()
return int(cached)
stock = _fetch_with_stampede_protection( # 修正⑥
client, product_id, cache_key, db_lookup
)
_record_success()
return stock
except RedisError:
_record_failure() # 修正⑦
return db_lookup(product_id)
def _fetch_with_stampede_protection(
client: redis.Redis, product_id: str, cache_key: str, db_lookup
) -> int:
"""分散ロックでDB問い合わせを1リクエストに絞り込む。"""
lock_key = f"lock:{cache_key}"
deadline = time.monotonic() + LOCK_WAIT_TIMEOUT_SECONDS
while time.monotonic() < deadline:
acquired = client.set(lock_key, "1", nx=True, ex=LOCK_TTL_SECONDS)
if acquired:
try:
stock = db_lookup(product_id)
ttl = BASE_TTL_SECONDS + random.randint(0, TTL_JITTER_SECONDS)
client.set(cache_key, stock, ex=ttl)
return stock
finally:
client.delete(lock_key)
time.sleep(LOCK_POLL_INTERVAL_SECONDS)
cached = client.get(cache_key)
if cached is not None:
return int(cached)
return db_lookup(product_id)
def query_stock_from_db(product_id: str) -> int:
"""Cloud SQL から在庫数を取得する(実装は省略)。"""
...
| 問題 | 修正内容 | 効果 |
|---|---|---|
| ① tier=BASIC | STANDARD_HA(プライマリ+レプリカ) | ノード障害時も自動フェイルオーバーでキャッシュ層を継続 |
| ② DIRECT_PEERING | PRIVATE_SERVICE_ACCESS | Cloud SQLと同じ接続方式に統一し運用負荷を削減 |
| ③ AUTH/TLS無効 | auth_enabled + transit_encryption + Secret Manager | 認証・暗号化された接続のみ許可 |
| ④ maxmemory-policy未設定 | allkeys-lru | メモリ逼迫時も書き込みエラーにならず自動追い出し |
| ⑤ コネクションプールなし | redis.ConnectionPool 共有 | TCPハンドシェイクのオーバーヘッドを削減 |
| ⑥ スタンピード対策なし | SET NX EX 分散ロック | キャッシュミス時のDB問い合わせを1リクエストに集約 |
| ⑦ 固定TTL/フォールバックなし | TTLジッター + Circuit Breaker | 同時失効を分散、Redis障害時もAPIを継続 |
ポイント解説
BASIC ティアのような単一ノード構成は、最も守りたい瞬間に単一障害点になりやすい。noeviction はキャッシュ用途では事故りやすいデフォルト値 — Memorystore for Redis のデフォルト maxmemory-policy は Redis本家のデフォルトを踏襲した noeviction。永続化データストアとしては妥当だが、キャッシュ用途では書き込みエラーの温床になるため明示的に allkeys-lru 等へ変更する意識が必要。SETNX)・確率的早期再計算・シングルフライトのいずれかで対策する。google_redis_instance のティア変更は再作成を伴う — BASIC → STANDARD_HA の変更はTerraform上でインスタンスの再作成(ダウンタイムを伴う)になる場合がある。本番導入時は新規インスタンスを作成しアプリ側の接続先を段階的に切り替える方が安全。実務への応用
MOps チームのフラッシュセール在庫APIのように「読み取りが一時的に跳ね上がるが正のデータはDBにある」ワークロードでは、キャッシュ導入そのものよりも「キャッシュミス時の挙動」の設計がスケーラビリティを左右する。分散ロックによるスタンピード対策は実装コストがあるため、まずはトラフィック予測(過去のセール実績)から必要性を判断し、被害が大きい商品(在庫僅少の目玉商品)から段階的に適用するのが現実的。
Redis AUTH文字列を Secret Manager 経由で配布する構成は、Cloud SQL の IAM DB 認証ほど強力ではないが、静的パスワードをTerraformコードやtfstateに平文で残さないという点で最低限のセキュリティラインになる。
今日のまとめ
次のステップ
- 発展問題:
allkeys-lruの代わりにallkeys-lfu(アクセス頻度ベースの追い出し)を使った場合の挙動の違いを検証し、フラッシュセールのような「短時間に特定キーへアクセスが集中する」ワークロードでどちらが適切かを比較せよ。加えて、分散ロックをSETNXの自前実装ではなく Redlock アルゴリズム(複数Redisノードでの合意)に置き換える必要があるケースを考察せよ - 参考: Memorystore for Redis Tier 比較(Basic/Standard)ドキュメント / Private Service Access(VPC ピアリング)/ Redis
maxmemory-policy公式ドキュメント / キャッシュスタンピード対策パターン(Mutex Lock / Probabilistic Early Expiration)/ Cloud Run Direct VPC Egress