Cloudflare Workers入門 - エッジコンピューティング実践

中級 | 30分 で読める | 2025.01.10

公式ドキュメント

Cloudflare Workersとは

Cloudflare Workersは、Cloudflareのエッジネットワーク上でコードを実行するサーバーレスプラットフォームです。200以上のロケーションでミリ秒単位の低レイテンシを実現します。

セットアップ

実践メモ: wrangler loginでブラウザ経由の認証が完了したら、すぐにプロジェクトを作成できます。

# Wranglerのインストール
npm install -g wrangler

# ログイン
wrangler login

# プロジェクト作成
wrangler init my-worker
cd my-worker

基本的なWorker

requestをWorkerが処理してresponseを返し、必要なdata serviceは任意branch、logとmetricは別の観測線で扱う図

// src/index.ts
export default {
 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
 const url = new URL(request.url);

 if (url.pathname === '/') {
 return new Response('Hello from Cloudflare Workers!');
 }

 if (url.pathname === '/api/time') {
 return Response.json({
 timestamp: new Date().toISOString(),
 timezone: 'UTC',
 });
 }

 return new Response('Not Found', { status: 404 });
 },
};

Honoフレームワーク

以降は、信頼する管理者だけが操作する管理APIの例です。一般利用者向けのファイル共有・個人データAPIではありません。管理者は保管庫全体にアクセスできます。ユーザーごとに分離する場合は、検証済みユーザーIDと各データの所有者を照合する認可を別途設計してください。

JWT(署名付きトークン)は信頼する認証サーバーで発行します。署名鍵をブラウザへ渡したり、利用者が自由に role を指定できる発行APIを作ったりしてはいけません。JWT_SECRET は十分に長いランダム値を wrangler secret put JWT_SECRET で登録し、JWT_ISSUERJWT_AUDIENCE はその認証サーバーと一致する値を設定します。

// src/index.ts
import { Hono } from 'hono';
import { jwt } from 'hono/jwt';
import type { JwtVariables } from 'hono/jwt';
import { bodyLimit } from 'hono/body-limit';

type Bindings = {
 KV: KVNamespace;
 DB: D1Database;
 BUCKET: R2Bucket;
 JWT_SECRET: string;
 JWT_ISSUER: string;
 JWT_AUDIENCE: string;
};

const app = new Hono<{ Bindings: Bindings; Variables: JwtVariables }>();

// Must be registered before every protected route, including /files.
app.use('*', async (c, next) => {
 if (c.req.path === '/') return next();
 if (!c.env.JWT_SECRET || !c.env.JWT_ISSUER || !c.env.JWT_AUDIENCE) {
 return c.json({ error: 'Service unavailable' }, 503);
 }
 return jwt({
 secret: c.env.JWT_SECRET,
 alg: 'HS256',
 verification: { iss: c.env.JWT_ISSUER, aud: c.env.JWT_AUDIENCE },
 })(c, next);
});
app.use('*', async (c, next) => {
 if (c.req.path === '/') return next();
 const payload = c.get('jwtPayload');
 if (typeof payload.sub !== 'string' || !payload.sub
 || typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)
 || payload.exp <= Date.now() / 1000) {
 return c.json({ error: 'Unauthorized' }, 401);
 }
 if (payload.role !== 'admin') return c.json({ error: 'Forbidden' }, 403);
 c.header('Cache-Control', 'private, no-store');
 await next();
});
app.use('*', bodyLimit({
 maxSize: 1024 * 1024 + 64 * 1024,
 onError: (c) => c.json({ error: 'Request too large' }, 413),
}));

app.get('/', (c) => c.text('Hello, Hono on Workers!'));

app.get('/api/users', async (c) => {
 const { results } = await c.env.DB.prepare(
 'SELECT * FROM users LIMIT 10'
 ).all();
 return c.json(results);
});

app.post('/api/users', async (c) => {
 const body = await c.req.json().catch(() => null);
 if (!body || typeof body.name !== 'string' || !body.name.trim()
 || body.name.length > 100 || typeof body.email !== 'string'
 || body.email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
 return c.json({ error: 'Invalid name or email' }, 400);
 }
 const { name, email } = body;
 await c.env.DB.prepare(
 'INSERT INTO users (name, email) VALUES (?, ?)'
 ).bind(name, email).run();
 return c.json({ success: true }, 201);
});

export default app;

KV Storage

ポイント: KV Storageは結果整合性モデルです。即時反映が不要なキャッシュに使い、権限変更や失効を即時反映したい認可情報には使わないでください。

キーバリューストレージです。以下は上の管理者認証を通るキャッシュAPIです。すべての追加ルートは、認証・サイズ制限の登録より後に置きます。

// wrangler.toml
// [[kv_namespaces]]
// binding = "KV"
// id = "your-kv-namespace-id"

// 使用例
app.get('/api/cache/:key', async (c) => {
 const key = c.req.param('key');
 const value = await c.env.KV.get(key);

 if (!value) {
 return c.json({ error: 'Not found' }, 404);
 }

 return c.json({ key, value: JSON.parse(value) });
});

app.put('/api/cache/:key', async (c) => {
 const key = c.req.param('key');
 const body = await c.req.json();

 await c.env.KV.put(key, JSON.stringify(body), {
 expirationTtl: 3600, // 1時間で期限切れ
 });

 return c.json({ success: true });
});

D1 Database

実践メモ: D1はSQLiteベースなのでSQLの知識がそのまま活かせます。マイグレーション機能も内蔵しています。

SQLiteベースのサーバーレスデータベース。

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

# マイグレーション作成
wrangler d1 migrations create my-database init
-- migrations/0001_init.sql
CREATE TABLE users (
 id INTEGER PRIMARY KEY AUTOINCREMENT,
 name TEXT NOT NULL,
 email TEXT UNIQUE NOT NULL,
 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_email ON users(email);
// D1操作
async function getUsers(db: D1Database) {
 const { results } = await db.prepare(`
 SELECT id, name, email, created_at
 FROM users
 ORDER BY created_at DESC
 LIMIT 50
 `).all();

 return results;
}

async function createUser(db: D1Database, name: string, email: string) {
 const result = await db.prepare(`
 INSERT INTO users (name, email) VALUES (?, ?)
 `).bind(name, email).run();

 return result.meta.last_row_id;
}

R2 Storage

R2はS3互換APIを持つため、既存のS3コードを最小限の変更で移行できます。

学習例では1MiB以下のPNG候補だけを受け付け、公開画像として表示せず添付ファイルとしてダウンロードします。先頭の識別バイトは簡易形式確認であり、完全な画像検証や無害性の保証ではありません。実際に画像を公開するなら、安全に更新された画像デコーダーによる検証・再エンコードなどを追加してください。R2の公開URL(r2.dev・公開カスタムドメイン)は有効にせず、取得もこの認証付きWorkerを経由させます。

// wrangler.toml
// [[r2_buckets]]
// binding = "BUCKET"
// bucket_name = "my-bucket"

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

 if (!(file instanceof File)) {
 return c.json({ error: 'No file provided' }, 400);
 }
 if (file.size === 0 || file.size > 1024 * 1024) {
 return c.json({ error: 'File too large or empty' }, 413);
 }
 const bytes = new Uint8Array(await file.arrayBuffer());
 const signature = [137, 80, 78, 71, 13, 10, 26, 10];
 if (file.type !== 'image/png'
 || !signature.every((value, index) => bytes[index] === value)) {
 return c.json({ error: 'PNG files only' }, 415);
 }

 const id = crypto.randomUUID();
 const key = `uploads/${id}`;
 await c.env.BUCKET.put(key, bytes, {
 httpMetadata: {
 contentType: 'application/octet-stream',
 },
 });

 return c.json({
 id,
 url: `/files/${id}`,
 });
});

// ファイル取得
app.get('/files/:id', async (c) => {
 const id = c.req.param('id');
 if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id)) {
 return c.notFound();
 }
 const object = await c.env.BUCKET.get(`uploads/${id}`);

 if (!object) {
 return c.notFound();
 }

 return new Response(object.body, {
 headers: {
 'Content-Type': 'application/octet-stream',
 'Content-Disposition': 'attachment; filename="download.png"',
 'X-Content-Type-Options': 'nosniff',
 'Cache-Control': 'private, no-store',
 },
 });
});

注意: R2のバケット名はwrangler.tomlで正しくバインディングする必要があります。設定ミスはランタイムエラーになります

認証ミドルウェア

Honoは登録順に処理します。認証を末尾に追加しても、先に登録済みのルートを保護できません。上の初期化例の順序を守り、JWTの署名・発行者・利用先の検証の後に、有効期限・ユーザーID・管理者権限を確認します。CORSは認証の代わりにはなりません。

成功する操作だけでなく、未認証・署名不正・期限切れ・一般ユーザーのトークンで /api/users/api/upload/files/:id が拒否されることを確認します。1MiB超のファイル、HTML、名前だけPNGに変えたファイルを拒否し、正しいファイルも画面内で実行されずダウンロードされることを確認してください。公開運用時は別途、管理者単位の回数制限・保存容量の上限・不要ファイルの保持期限を設定します。

注意: JWTのsecretはハードコードせず、環境変数やシークレットストアから取得してください

デプロイ

ポイント: wrangler devでローカルテストし、問題なければwrangler deployで本番反映。環境別の設定はwrangler.tomlの[env]セクションで管理します。

# 開発サーバー
wrangler dev

# 本番デプロイ
wrangler deploy

# 環境別デプロイ
wrangler deploy --env production
# wrangler.toml
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-01-01"

[env.production]
vars = { ENVIRONMENT = "production" }

[[kv_namespaces]]
binding = "KV"
id = "xxx"

[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "xxx"

関連記事

まとめ

Cloudflare Workersは、低レイテンシなエッジコンピューティングを実現します。KV、D1、R2と組み合わせることで、フルスタックアプリケーションを構築できます。

参考リソース

← 一覧に戻る
PR
PR
PR
PR