📚

☁️アプリエンジニアのためのインフラ入門 — Cloud Run × Nuxt でキャッシュを理解する

に公開

「なぜデータが古いまま?」「なぜ更新が反映されない?」——そのバグの原因、キャッシュかもしれません。

Cloud Run × Nuxt 構成を題材に、キャッシュにフォーカスして図と実装コードで整理してみました。
また、Nuxtで useFetch を書くとき、裏側で何が起きているか気にしたことはありますか?


まず全体像から(Cloud Run構成)

ブラウザ

Cloud DNS        ← ドメイン → IPアドレスに変換

Cloud CDN        ← 静的ファイル&APIレスポンスをキャッシュ ★

Cloud Load Balancing ← リクエストを振り分け

Cloud Run        ← Nuxt/Nitroが動くコンテナ

Memorystore(Redis) ← よく使うデータをキャッシュ ★

Cloud SQL / Firestore ← データを永続保存

★ がついている層がキャッシュが絡む場所です。「データが古い」バグはこの2箇所を起点に調べます。


Cloud Run の特性を先に知っておく

Cloud Runはコンテナが自動でスケールアウト/スケールインするサービスです。これがキャッシュ設計に直結します。

リクエスト急増

Cloud Run がコンテナを自動で増やす

コンテナ A、B、C... が並列で動く

⚠️ インメモリキャッシュが使えない理由

Nitroの cachedEventHandler はデフォルトでサーバーのメモリにキャッシュします。しかしCloud Runでは:

コンテナ A のメモリキャッシュ ─┐
コンテナ B のメモリキャッシュ   ├── バラバラ。共有されない
コンテナ C のメモリキャッシュ ─┘

同じリクエストでもどのコンテナに当たるかわかりません。インメモリキャッシュだとコンテナによってレスポンスが変わるバグが発生します。

Cloud RunではRedis(Memorystore)を使って全コンテナで共有するのが基本です。


キャッシュとは何か

「一度取得したデータを一時保存しておき、次回は保存済みのデータを返す」仕組みです。

【キャッシュなし】
リクエスト → DB → レスポンス(毎回DBへ、遅い)

【キャッシュあり】
リクエスト → Redis → レスポンス(速い)
               ↓ なければ
              DB → Redis に保存 → レスポンス

速くなる反面、「古いデータが返ってくる」バグの温床にもなります。


public/ に静的ファイルを置くのはどうか

Nuxtの public/ ディレクトリに画像やファイルを置いている場合、リクエストの流れはこうなります。

【public/ に置いた場合】
ブラウザ → Cloud Run(コンテナ起動)→ public/ から返す

【Cloud Storage に置いた場合】
ブラウザ → Cloud CDN → Cloud Storage → 返す

静的ファイルを返すだけなのに Cloud Runのコンテナが起動するのは少しもったいないです。画像や大きなファイルが多い場合は、Cloud Storage + Cloud CDN に逃がすとコストとレスポンス速度が改善します。

ただし、ファイルが少ない・小さい段階では public/ のままで全然OKです。最適化は必要になってからで十分です。


Cloud CDN:フロントの静的ファイルとAPIキャッシュ

Cloud CDNはCloud Load Balancingと連携してキャッシュを行います。

東京のユーザー

Cloud CDN エッジ(東京)← キャッシュがあればここで返す
  ↓(なければ)
Cloud Run(オリジン)

静的アセットのキャッシュ設定

Cloud Runにデプロイする場合、nuxt.config.ts でキャッシュヘッダーを設定します。

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // /_nuxt/** 配下はビルド時にハッシュ値が付くので immutable で長期キャッシュできる
    // 例: /_nuxt/entry.abc123.js(ファイルが変わると別URLになる)
    '/_nuxt/**': { headers: { 'cache-control': 'public, max-age=31536000, immutable' } },

    // ※ public/ 直下のファイル(favicon.ico など)はハッシュが付かないため
    //   このルールは適用されない。public/ ファイルには別途短めのキャッシュを設定する
    '/favicon.ico': { headers: { 'cache-control': 'public, max-age=86400' } },

    // 記事一覧は1時間CDNキャッシュ
    // 頻繁に更新するなら s-maxage を短くするか設定しない
    '/api/articles': { headers: { 'cache-control': 'public, max-age=3600, s-maxage=3600' } },

    // ユーザー固有データはキャッシュしない
    '/api/users/**': { headers: { 'cache-control': 'private, no-store' } },
  }
})

s-maxage がCDN(プロキシ)向けのキャッシュ時間です。max-age はブラウザ向けです。


Memorystore(Redis):Cloud Run での共有キャッシュ

セットアップ

Cloud RunからMemorystoreに接続するにはVPC接続が必要です。

Cloud Run
  ↓(Serverless VPC Access Connector経由)
VPC ネットワーク

Memorystore(Redis)

nuxt.config.ts でサーバーサイドの環境変数を設定します:

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    redisHost: process.env.REDIS_HOST, // 例: 10.0.0.3
    redisPort: process.env.REDIS_PORT, // 例: 6379
  }
})

ローカル開発は Docker で Redis を立てる

ローカル開発では Docker で Redis を立てるのが定番です。docker-compose.yml に追加するだけで使えます。

# docker-compose.yml
services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
docker compose up -d

開発・本番で接続先を切り替える

環境変数で切り替えるのがシンプルです。

// server/utils/cache.ts
import { createStorage } from 'unstorage'
import redisDriver from 'unstorage/drivers/redis'

const config = useRuntimeConfig()

export const cache = createStorage({
  driver: redisDriver({
    host: config.redisHost ?? 'localhost',   // 開発: localhost、本番: Memorystoreのアドレス
    port: Number(config.redisPort ?? 6379),
  })
})
# .env(ローカル開発)
REDIS_HOST=localhost
REDIS_PORT=6379

# Cloud Run の環境変数(本番)
REDIS_HOST=10.0.0.3   # Memorystore の内部IPアドレス
REDIS_PORT=6379

これで NODE_ENV を分岐させなくても、環境変数だけで開発・本番の切り替えができます。

キャッシュの取得・保存・削除

// server/api/articles.ts
import { cache } from '../utils/cache'
import { db } from '../utils/db'
import { articlesTable } from '../schema'

export default defineEventHandler(async () => {
  const CACHE_KEY = 'articles:all'
  const TTL = 60 * 10 // 10分

  // 1. Redis を確認
  const cached = await cache.getItem(CACHE_KEY)
  if (cached) {
    return cached // キャッシュがあればそのまま返す
  }

  // 2. DB から取得(Cloud SQL など)
  const articles = await db.select().from(articlesTable)

  // 3. Redis に保存
  await cache.setItem(CACHE_KEY, articles, { ttl: TTL })

  return articles
})

更新時はキャッシュも削除する

// server/api/articles/[id].patch.ts
import { cache } from '../../utils/cache'
import { db } from '../../utils/db'
import { articlesTable } from '../../schema'
import { eq } from 'drizzle-orm'

export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')
  const body = await readBody(event)

  // DB を更新
  await db.update(articlesTable)
    .set(body)
    .where(eq(articlesTable.id, Number(id)))

  // ★ Redis のキャッシュを削除(忘れると古いデータが返り続ける)
  await cache.removeItem('articles:all')
  await cache.removeItem(`articles:${id}`)

  return { success: true }
})

ユーザー固有データのキャッシュ

ユーザーごとに異なるデータをキャッシュするときは、キーにユーザーIDを含めます。

// server/api/users/[id].ts
import { cache } from '../../utils/cache'
import { db } from '../../utils/db'
import { usersTable } from '../../schema'
import { eq } from 'drizzle-orm'

export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')
  const CACHE_KEY = `users:${id}` // ← ユーザーIDをキーに含める
  const TTL = 60 * 5 // 5分

  const cached = await cache.getItem(CACHE_KEY)
  if (cached) return cached

  const user = await db
    .select()
    .from(usersTable)
    .where(eq(usersTable.id, Number(id)))
    .get()

  await cache.setItem(CACHE_KEY, user, { ttl: TTL })
  return user
})

キャッシュの判断フロー(Cloud Run版)

このデータ、キャッシュしていい?

  ├─ ユーザー全員に同じデータ?
  │     YES → Cloud CDN(s-maxage設定)+ Redis
  │     NO  → Redis のキーにユーザーIDを含める

  ├─ 更新頻度は?
  │     低い(記事一覧など) → TTL長め(10分〜1時間)
  │     高い(在庫数など)   → TTL短め(30秒〜)or キャッシュしない

  └─ Cloud Run(複数コンテナ)?
        YES(基本そう) → 必ず Redis を使う(インメモリ不可)

useFetch の1行を改めて眺める(Cloud Run版)

const { data } = await useFetch('/api/articles')

この1行で起きていること:

ブラウザ

Cloud DNS → IPアドレス解決

Cloud CDN → キャッシュがあれば返す ★
  ↓(なければ)
Cloud Load Balancing → コンテナへ振り分け

Cloud Run(Nuxt/Nitro)→ server/api/articles.ts を実行

Memorystore(Redis) → キャッシュを確認 ★
  ↓(なければ)
Cloud SQL → データ取得

Redis に保存 → レスポンスを返す

ブラウザ → data に格納

「データが古い」バグに出くわしたら、★ の2箇所を順番に確認するのが基本です。


まとめ

キャッシュの種類 場所 Cloud Run での実装 注意点
Cloud CDN CDNエッジ cache-control ヘッダー Purge忘れ、s-maxageの設定
Memorystore(Redis) VPC内 unstorage + redisDriver 更新時の削除忘れ、キーの設計
インメモリ コンテナ内 cachedEventHandler Cloud Runでは使わない

静的ファイルの置き場所:

状況 置き場所
ファイルが少ない・小さい public/ でOK
ファイルが多い・大きい Cloud Storage + Cloud CDN

Redis の接続先:

環境 Redis
ローカル開発 Docker(localhost:6379
本番(Cloud Run) Memorystore(VPC経由)

Cloud Run特有のポイントは「コンテナが複数になるのでインメモリキャッシュは使えない」これだけ覚えておけば、あとは実装で対応できます。

参考:『絵で見てわかるITインフラの仕組み 新装版』(翔泳社)

Discussion