Cloudflare Workersフルスタック開発ガイド - D1・R2・KV活用


Cloudflare Workersフルスタック開発ガイド - D1・R2・KV活用

Cloudflare Workersは、CDNのエッジネットワーク上でコードを実行できる革新的なプラットフォームです。従来のサーバーレスプラットフォームと比較して、グローバルに分散されたエッジロケーションで瞬時にレスポンスを返すことができ、レイテンシの大幅な改善とコスト削減を実現します。

本記事では、Cloudflare WorkersとD1(SQLデータベース)、R2(オブジェクトストレージ)、KV(キーバリューストア)を組み合わせたフルスタック開発の実践方法を徹底解説します。

Cloudflare Workersの特徴

エッジコンピューティングのメリット

// 世界中のエッジロケーションで実行される
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // ユーザーに最も近いエッジで処理
    const location = request.cf?.colo // エッジロケーション
    return new Response(`Hello from ${location}!`)
  }
}

V8 Isolateアーキテクチャ

従来のコンテナ型サーバーレスと異なり、V8 Isolateを使用することで以下の利点があります。

  • コールドスタート0ms: 瞬時に起動
  • メモリ効率: 1つのプロセスで複数のリクエストを処理
  • 無限スケール: 自動的に世界中のエッジにデプロイ

料金体系

// 無料枠(Free Plan)
// - 100,000リクエスト/日
// - CPU時間: 10ms/リクエスト
// - KV: 100,000読み取り/日、1,000書き込み/日
// - D1: 5GB/月、5M行読み取り/日

// Paid Plan: $5/月〜
// - 10Mリクエスト/月
// - CPU時間: 50ms/リクエスト
// - KV: 10M読み取り/月、1M書き込み/月
// - D1: 25GB/月

プロジェクトセットアップ

Wranglerのインストール

# Wrangler CLI(Cloudflare Workers開発ツール)
npm install -g wrangler

# ログイン
wrangler login

# 新規プロジェクト作成
npm create cloudflare@latest my-app

プロジェクト構造

my-app/
├── src/
│   ├── index.ts       # エントリーポイント
│   ├── routes/        # ルート定義
│   ├── db/            # D1スキーマとマイグレーション
│   └── utils/         # ユーティリティ
├── wrangler.toml      # Cloudflare設定
├── schema.sql         # D1スキーマ
└── package.json

wrangler.toml設定

name = "my-app"
main = "src/index.ts"
compatibility_date = "2025-01-01"

# D1データベース
[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "your-database-id"

# KVネームスペース
[[kv_namespaces]]
binding = "KV"
id = "your-kv-id"

# R2バケット
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "my-bucket"

# 環境変数
[vars]
ENVIRONMENT = "production"
API_VERSION = "v1"

# シークレット(wrangler secret put で設定)
# JWT_SECRET
# DATABASE_URL

D1データベース(SQLite)

D1の特徴

D1は、Cloudflareが提供するSQLiteベースの分散データベースです。

// エッジで実行されるSQLデータベース
// - グローバルに分散
// - 自動レプリケーション
// - SQLiteベースで使いやすい

データベース作成

# D1データベース作成
wrangler d1 create my-database

# データベースID取得(wrangler.tomlに追加)
# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

スキーマ定義

-- schema.sql
CREATE TABLE users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT NOT NULL UNIQUE,
  name TEXT NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE posts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id INTEGER NOT NULL,
  title TEXT NOT NULL,
  content TEXT NOT NULL,
  published BOOLEAN DEFAULT 0,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE TABLE comments (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  post_id INTEGER NOT NULL,
  user_id INTEGER NOT NULL,
  content TEXT NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (post_id) REFERENCES posts(id),
  FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE INDEX idx_posts_user_id ON posts(user_id);
CREATE INDEX idx_posts_published ON posts(published);
CREATE INDEX idx_comments_post_id ON comments(post_id);

マイグレーション実行

# ローカル環境でマイグレーション
wrangler d1 execute my-database --local --file=./schema.sql

# 本番環境でマイグレーション
wrangler d1 execute my-database --file=./schema.sql

D1クエリの実行

// src/db/queries.ts
import { D1Database } from '@cloudflare/workers-types'

export interface User {
  id: number
  email: string
  name: string
  created_at: string
}

export interface Post {
  id: number
  user_id: number
  title: string
  content: string
  published: boolean
  created_at: string
}

// ユーザー作成
export async function createUser(
  db: D1Database,
  email: string,
  name: string
): Promise<User> {
  const result = await db
    .prepare('INSERT INTO users (email, name) VALUES (?, ?) RETURNING *')
    .bind(email, name)
    .first<User>()

  if (!result) throw new Error('Failed to create user')
  return result
}

// ユーザー取得
export async function getUser(db: D1Database, id: number): Promise<User | null> {
  return await db
    .prepare('SELECT * FROM users WHERE id = ?')
    .bind(id)
    .first<User>()
}

// 投稿一覧取得
export async function getPosts(
  db: D1Database,
  limit = 10,
  offset = 0
): Promise<Post[]> {
  const { results } = await db
    .prepare('SELECT * FROM posts WHERE published = 1 ORDER BY created_at DESC LIMIT ? OFFSET ?')
    .bind(limit, offset)
    .all<Post>()

  return results
}

// 投稿作成
export async function createPost(
  db: D1Database,
  userId: number,
  title: string,
  content: string
): Promise<Post> {
  const result = await db
    .prepare(
      'INSERT INTO posts (user_id, title, content) VALUES (?, ?, ?) RETURNING *'
    )
    .bind(userId, title, content)
    .first<Post>()

  if (!result) throw new Error('Failed to create post')
  return result
}

// バッチクエリ(トランザクション)
export async function createUserWithPost(
  db: D1Database,
  email: string,
  name: string,
  postTitle: string,
  postContent: string
): Promise<{ user: User; post: Post }> {
  // バッチで複数クエリを実行(トランザクション的)
  const [userResult, postResult] = await db.batch([
    db.prepare('INSERT INTO users (email, name) VALUES (?, ?) RETURNING *')
      .bind(email, name),
    db.prepare('INSERT INTO posts (user_id, title, content) VALUES (last_insert_rowid(), ?, ?) RETURNING *')
      .bind(postTitle, postContent),
  ])

  const user = userResult.results[0] as User
  const post = postResult.results[0] as Post

  return { user, post }
}

D1とDrizzle ORMの統合

// src/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  createdAt: integer('created_at', { mode: 'timestamp' })
    .notNull()
    .$defaultFn(() => new Date()),
})

export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  userId: integer('user_id').notNull().references(() => users.id),
  title: text('title').notNull(),
  content: text('content').notNull(),
  published: integer('published', { mode: 'boolean' }).notNull().default(false),
  createdAt: integer('created_at', { mode: 'timestamp' })
    .notNull()
    .$defaultFn(() => new Date()),
})
// src/index.ts
import { drizzle } from 'drizzle-orm/d1'
import { eq } from 'drizzle-orm'
import * as schema from './db/schema'

export interface Env {
  DB: D1Database
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const db = drizzle(env.DB, { schema })

    // Drizzle ORMでクエリ
    const allUsers = await db.select().from(schema.users)

    return Response.json(allUsers)
  },
}

KV(キーバリューストア)

KVの特徴

// KVは低レイテンシのキーバリューストア
// - エッジでのキャッシング
// - 最終的整合性(eventually consistent)
// - 大量の読み取りに最適
// - TTL(有効期限)設定可能

KVネームスペース作成

# KVネームスペース作成
wrangler kv:namespace create "KV"

# プレビュー用(開発環境)
wrangler kv:namespace create "KV" --preview

KV基本操作

// src/cache/kv.ts
export async function getCachedUser(
  kv: KVNamespace,
  userId: number
): Promise<User | null> {
  const cached = await kv.get(`user:${userId}`, 'json')
  return cached as User | null
}

export async function setCachedUser(
  kv: KVNamespace,
  userId: number,
  user: User,
  ttl = 3600 // 1時間
): Promise<void> {
  await kv.put(
    `user:${userId}`,
    JSON.stringify(user),
    { expirationTtl: ttl }
  )
}

export async function deleteCachedUser(
  kv: KVNamespace,
  userId: number
): Promise<void> {
  await kv.delete(`user:${userId}`)
}

KVを使ったキャッシング戦略

// キャッシュアサイドパターン
export async function getUserWithCache(
  db: D1Database,
  kv: KVNamespace,
  userId: number
): Promise<User | null> {
  // 1. KVキャッシュから取得試行
  const cached = await getCachedUser(kv, userId)
  if (cached) {
    console.log('Cache hit')
    return cached
  }

  // 2. キャッシュミス → DBから取得
  console.log('Cache miss')
  const user = await getUser(db, userId)

  if (user) {
    // 3. KVにキャッシュ
    await setCachedUser(kv, userId, user)
  }

  return user
}

// ライトスルーキャッシュ
export async function updateUser(
  db: D1Database,
  kv: KVNamespace,
  userId: number,
  updates: Partial<User>
): Promise<User> {
  // 1. DBを更新
  const result = await db
    .prepare('UPDATE users SET name = ? WHERE id = ? RETURNING *')
    .bind(updates.name, userId)
    .first<User>()

  if (!result) throw new Error('User not found')

  // 2. キャッシュを更新
  await setCachedUser(kv, userId, result)

  return result
}

セッション管理

// src/auth/session.ts
import { nanoid } from 'nanoid'

export interface Session {
  userId: number
  email: string
  createdAt: number
}

export async function createSession(
  kv: KVNamespace,
  userId: number,
  email: string
): Promise<string> {
  const sessionId = nanoid()
  const session: Session = {
    userId,
    email,
    createdAt: Date.now(),
  }

  // 24時間有効なセッション
  await kv.put(`session:${sessionId}`, JSON.stringify(session), {
    expirationTtl: 86400,
  })

  return sessionId
}

export async function getSession(
  kv: KVNamespace,
  sessionId: string
): Promise<Session | null> {
  const session = await kv.get(`session:${sessionId}`, 'json')
  return session as Session | null
}

export async function deleteSession(
  kv: KVNamespace,
  sessionId: string
): Promise<void> {
  await kv.delete(`session:${sessionId}`)
}

R2オブジェクトストレージ

R2の特徴

// R2はS3互換のオブジェクトストレージ
// - 転送料金なし(egress free)
// - S3 APIと互換性
// - 画像、動画、ファイルストレージに最適

R2バケット作成

# R2バケット作成
wrangler r2 bucket create my-bucket

R2基本操作

// src/storage/r2.ts
export async function uploadFile(
  bucket: R2Bucket,
  key: string,
  file: File | Blob
): Promise<void> {
  await bucket.put(key, file, {
    httpMetadata: {
      contentType: file.type,
    },
  })
}

export async function getFile(
  bucket: R2Bucket,
  key: string
): Promise<R2ObjectBody | null> {
  return await bucket.get(key)
}

export async function deleteFile(
  bucket: R2Bucket,
  key: string
): Promise<void> {
  await bucket.delete(key)
}

export async function listFiles(
  bucket: R2Bucket,
  prefix?: string
): Promise<R2Objects> {
  return await bucket.list({ prefix })
}

画像アップロードAPI

// src/routes/upload.ts
import { nanoid } from 'nanoid'

export interface Env {
  BUCKET: R2Bucket
  DB: D1Database
}

export async function handleUpload(
  request: Request,
  env: Env
): Promise<Response> {
  // マルチパートフォームデータを解析
  const formData = await request.formData()
  const file = formData.get('file') as File

  if (!file) {
    return Response.json({ error: 'No file uploaded' }, { status: 400 })
  }

  // ファイル検証
  const maxSize = 5 * 1024 * 1024 // 5MB
  if (file.size > maxSize) {
    return Response.json({ error: 'File too large' }, { status: 400 })
  }

  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
  if (!allowedTypes.includes(file.type)) {
    return Response.json({ error: 'Invalid file type' }, { status: 400 })
  }

  // ユニークなファイル名生成
  const ext = file.name.split('.').pop()
  const key = `uploads/${nanoid()}.${ext}`

  // R2にアップロード
  await env.BUCKET.put(key, file, {
    httpMetadata: {
      contentType: file.type,
    },
    customMetadata: {
      originalName: file.name,
      uploadedAt: new Date().toISOString(),
    },
  })

  // DBに記録
  await env.DB
    .prepare('INSERT INTO files (key, name, size, type) VALUES (?, ?, ?, ?)')
    .bind(key, file.name, file.size, file.type)
    .run()

  return Response.json({
    success: true,
    key,
    url: `/api/files/${key}`,
  })
}

// ファイル取得
export async function handleGetFile(
  request: Request,
  env: Env,
  key: string
): Promise<Response> {
  const object = await env.BUCKET.get(key)

  if (!object) {
    return new Response('File not found', { status: 404 })
  }

  return new Response(object.body, {
    headers: {
      'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
      'Cache-Control': 'public, max-age=31536000',
      'ETag': object.etag,
    },
  })
}

画像リサイズ(Image Resizing)

// Cloudflare Image Resizingを使用
export async function handleResizedImage(
  request: Request,
  env: Env,
  key: string
): Promise<Response> {
  const url = new URL(request.url)
  const width = url.searchParams.get('w') || '800'
  const quality = url.searchParams.get('q') || '85'

  // R2からオリジナル画像取得
  const object = await env.BUCKET.get(key)
  if (!object) {
    return new Response('Image not found', { status: 404 })
  }

  // Image Resizing(Cloudflare有料プラン)
  const resizedImage = await fetch(`https://example.com/cdn-cgi/image/width=${width},quality=${quality}/${key}`)

  return new Response(resizedImage.body, {
    headers: {
      'Content-Type': 'image/webp',
      'Cache-Control': 'public, max-age=31536000',
    },
  })
}

ルーティングとAPI設計

Honoフレームワークの統合

npm install hono
// src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { drizzle } from 'drizzle-orm/d1'
import * as schema from './db/schema'

export interface Env {
  DB: D1Database
  KV: KVNamespace
  BUCKET: R2Bucket
}

const app = new Hono<{ Bindings: Env }>()

// ミドルウェア
app.use('*', logger())
app.use('*', cors())

// ヘルスチェック
app.get('/health', (c) => {
  return c.json({ status: 'ok' })
})

// ユーザーAPI
app.get('/api/users', async (c) => {
  const db = drizzle(c.env.DB, { schema })
  const users = await db.select().from(schema.users)
  return c.json(users)
})

app.get('/api/users/:id', async (c) => {
  const id = parseInt(c.req.param('id'))
  const db = drizzle(c.env.DB, { schema })

  const [user] = await db
    .select()
    .from(schema.users)
    .where(eq(schema.users.id, id))

  if (!user) {
    return c.json({ error: 'User not found' }, 404)
  }

  return c.json(user)
})

app.post('/api/users', async (c) => {
  const body = await c.req.json()
  const db = drizzle(c.env.DB, { schema })

  const [user] = await db
    .insert(schema.users)
    .values({
      email: body.email,
      name: body.name,
    })
    .returning()

  return c.json(user, 201)
})

// 投稿API
app.get('/api/posts', async (c) => {
  const db = drizzle(c.env.DB, { schema })
  const limit = parseInt(c.req.query('limit') || '10')
  const offset = parseInt(c.req.query('offset') || '0')

  const posts = await db
    .select()
    .from(schema.posts)
    .where(eq(schema.posts.published, true))
    .limit(limit)
    .offset(offset)

  return c.json(posts)
})

// ファイルアップロード
app.post('/api/upload', async (c) => {
  const formData = await c.req.formData()
  const file = formData.get('file') as File

  if (!file) {
    return c.json({ error: 'No file uploaded' }, 400)
  }

  const key = `uploads/${Date.now()}-${file.name}`
  await c.env.BUCKET.put(key, file)

  return c.json({ key, url: `/api/files/${key}` })
})

// ファイル取得
app.get('/api/files/*', async (c) => {
  const key = c.req.path.replace('/api/files/', '')
  const object = await c.env.BUCKET.get(key)

  if (!object) {
    return c.json({ error: 'File not found' }, 404)
  }

  return new Response(object.body, {
    headers: {
      'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
    },
  })
})

export default app

認証とセキュリティ

JWT認証

npm install hono jose
// src/auth/jwt.ts
import { SignJWT, jwtVerify } from 'jose'

export async function createToken(
  payload: { userId: number; email: string },
  secret: string
): Promise<string> {
  const encoder = new TextEncoder()
  const secretKey = encoder.encode(secret)

  return await new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('24h')
    .sign(secretKey)
}

export async function verifyToken(
  token: string,
  secret: string
): Promise<{ userId: number; email: string } | null> {
  try {
    const encoder = new TextEncoder()
    const secretKey = encoder.encode(secret)

    const { payload } = await jwtVerify(token, secretKey)
    return payload as { userId: number; email: string }
  } catch {
    return null
  }
}
// src/middleware/auth.ts
import { Context, Next } from 'hono'
import { verifyToken } from '../auth/jwt'

export async function authMiddleware(c: Context, next: Next) {
  const authHeader = c.req.header('Authorization')

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  const token = authHeader.substring(7)
  const payload = await verifyToken(token, c.env.JWT_SECRET)

  if (!payload) {
    return c.json({ error: 'Invalid token' }, 401)
  }

  c.set('userId', payload.userId)
  c.set('userEmail', payload.email)

  await next()
}

// 使用例
app.get('/api/protected', authMiddleware, async (c) => {
  const userId = c.get('userId')
  return c.json({ message: `Hello user ${userId}` })
})

レート制限

// src/middleware/rateLimit.ts
export async function rateLimitMiddleware(
  c: Context,
  next: Next,
  limit = 100, // 100リクエスト
  window = 60 // 60秒
) {
  const ip = c.req.header('CF-Connecting-IP') || 'unknown'
  const key = `ratelimit:${ip}`

  const current = await c.env.KV.get(key)
  const count = current ? parseInt(current) : 0

  if (count >= limit) {
    return c.json({ error: 'Rate limit exceeded' }, 429)
  }

  await c.env.KV.put(key, (count + 1).toString(), {
    expirationTtl: window,
  })

  await next()
}

デプロイとモニタリング

デプロイ

# 本番環境にデプロイ
wrangler deploy

# ステージング環境にデプロイ
wrangler deploy --env staging

環境変数とシークレット

# シークレットの設定
wrangler secret put JWT_SECRET
wrangler secret put DATABASE_URL

# 環境変数の確認
wrangler secret list

ログとモニタリング

// src/middleware/logging.ts
export async function loggingMiddleware(c: Context, next: Next) {
  const start = Date.now()

  await next()

  const duration = Date.now() - start
  console.log({
    method: c.req.method,
    path: c.req.path,
    status: c.res.status,
    duration,
    ip: c.req.header('CF-Connecting-IP'),
    userAgent: c.req.header('User-Agent'),
  })
}

app.use('*', loggingMiddleware)

パフォーマンス最適化

キャッシュ戦略

// Cache APIを使用
export async function handleWithCache(request: Request): Promise<Response> {
  const cache = caches.default

  // キャッシュから取得試行
  let response = await cache.match(request)

  if (response) {
    console.log('Cache hit')
    return response
  }

  // キャッシュミス → 生成
  console.log('Cache miss')
  response = new Response('Hello World', {
    headers: {
      'Cache-Control': 'public, max-age=3600',
    },
  })

  // キャッシュに保存
  c.executionCtx.waitUntil(cache.put(request, response.clone()))

  return response
}

並列処理

// 複数のクエリを並列実行
export async function getDashboardData(env: Env): Promise<DashboardData> {
  const [users, posts, comments] = await Promise.all([
    env.DB.prepare('SELECT COUNT(*) as count FROM users').first(),
    env.DB.prepare('SELECT COUNT(*) as count FROM posts').first(),
    env.DB.prepare('SELECT COUNT(*) as count FROM comments').first(),
  ])

  return {
    usersCount: users.count,
    postsCount: posts.count,
    commentsCount: comments.count,
  }
}

まとめ

Cloudflare Workersを使ったフルスタック開発では、以下のような利点があります。

  • グローバルエッジデプロイ: 世界中のユーザーに低レイテンシで配信
  • コスト効率: 無料枠が大きく、従量課金も安価
  • スケーラビリティ: 自動スケールで無限に拡張可能
  • 統合されたエコシステム: D1、KV、R2、Durable Objectsを統合

従来のサーバーレスプラットフォームと比較して、コールドスタートがなく、レイテンシが低いため、ユーザー体験の向上とコスト削減を両立できます。エッジコンピューティングの恩恵を活かし、次世代のWebアプリケーションを構築しましょう。