メインコンテンツへスキップ
データベース・RLS
Supabase
TypeScript
アーキテクチャ設計
信頼性
セキュリティ
コスト最適化

Supabase Edge Functions 実践ガイド:@supabase/server・背景タスク・制約の中で本番運用する

Supabase Edge Functionsを本番で使い切るための実装ガイド。CPU 2秒・メモリ256MB・実行時間150/400秒という制約から「何を載せて何を載せないか」を判断し、@supabase/serverのwithSupabaseで認証とRLSスコープを宣言的に扱い、Webhookを冪等に受け、EdgeRuntime.waitUntilで重い処理を背に回す。Hono routing・シークレット・Deno.testでのテスト・ログ・CI/CDまで、公式ドキュメント準拠の実コードで解説します。

公開日
読了時間
21分
著者
友田 陽大
シェア

Edge Functionsは、Supabaseで最も過小評価されている機能であり、同時に最も誤用されている機能でもあります。

過小評価される理由は「フロントからDBを直接叩けるなら要らないのでは」という誤解。誤用される理由は、制約を知らずに重い処理を載せてしまうことです。CPU実行時間は1リクエストあたり2秒。この1行を知っているかどうかで、設計の成否が分かれます。

この記事は、Supabase Edge Functionsを本番で使い切るための実装ガイドです。公式ドキュメント(2026-08-16時点)に忠実に、採否の判断基準、@supabase/serverwithSupabase による認証の宣言的な扱い、Webhookを冪等に受ける実装、レスポンス後の背景タスク、テスト・ログ・CI/CDまでを、そのまま使えるコードで扱います。


1. まず制約を見る:何を載せて、何を載せないか

Edge Functionsは「Deno互換ランタイム、TypeScriptファースト」の実行環境です。公式のリミットはこうです。

項目
CPU実行時間リクエストあたり 2秒(実際にCPUを使った時間)
実行時間(ウォールクロック)Free 150秒 / 有料 400秒
メモリ256MB
リクエストのアイドルタイムアウト150秒(応答が無ければ 504 Gateway Timeout)
関数サイズCLIバンドル 20MB / サーバー側バンドル 5MB
関数数Free 100 / Pro 500 / Team 1,000 / Enterprise 無制限
ログ1件の長さ最大 10,000文字
ログ件数10秒あたり 100イベント
シークレット1プロジェクト100個、名前256文字、サイズ48KiB
一時ストレージ(/tmpFree 256MB / 有料 512MB

「CPU 2秒」と「ウォールクロック150〜400秒」が別物であることが、この機能の設計の鍵です。外部APIの応答を30秒待っても、待っている間CPUは動いていないので2秒は消費しません。

したがって判断はこうなります。

向いている向いていない
Webhookの受信(Stripe、GitHub、決済、CRM)画像・動画のエンコード/変換
外部APIの呼び出しと整形(BFF的な中継)大規模なデータ集計・レポート生成
LLM応答のストリーミング中継重い暗号処理・機械学習推論
サーバー限定の秘密鍵を使う処理巨大な依存を含むバンドル(サイズ上限)
Cronからの定期ジョブ(軽量なもの)長時間のバッチ(DB側のCron/Queuesへ)
RLSでは表現しきれない業務ルールの適用常駐が必要なプロセス

重い処理をどうしても実行する必要があるなら、Edge Functionsは受け付けて記録し、実行はキューに委譲する役割に徹します(第6章)。

実行される場所

リクエストはグローバルAPIゲートウェイが受け、IPアドレスから地理的位置を判定して最も近いエッジロケーションにルーティングします(例:アムステルダムからのリクエストをフランクフルトへ)。各リクエストは新しいV8アイソレートで実行されます。公式は「コンパクトなESZip形式と最小限のDenoランタイムのオーバーヘッドにより、初回実行でもミリ秒単位で高速」としています。

ただし、データベースが遠ければ全体は速くなりません。ユーザーの近くで実行されても、DBへの往復が地球を半周するなら意味がない。DBアクセスが主体の関数は、この点を必ず測ってから配置を判断してください。


2. 最初の1本と withSupabase

2026年の標準は @supabase/server パッケージの withSupabase ラッパーです。公式ブログの説明はこうです。

認証の検証、クライアントのセットアップ、リクエストコンテキスト、そしてサーバーサイドの定型処理を代わりに引き受ける新しいパッケージ。Edge Functions、Vercel Functions、Cloudflare Workers、Hono、Bun で動作する。

作成からデプロイまではCLIで完結します。

supabase functions new hello-world     # supabase/functions/hello-world/index.ts が生成される
supabase start                          # ローカルスタックを起動
supabase functions serve hello-world    # ローカルで実行
supabase functions deploy hello-world   # デプロイ

生成されるテンプレートはこうです。

export default {
  fetch: withSupabase({ auth: ['publishable', 'secret'] }, async (req, ctx) => {
    const { name } = await req.json()

    return Response.json({
      message: `Hello ${name}!`,
    })
  }),
}

ローカル呼び出しは次の通りです。

curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/hello-world' \
  --header 'apiKey: <SUPABASE_PUBLISHABLE_KEY>' \
  --data '{"name":"Functions"}'

認証モードを「宣言」する

withSupabase の中心は auth オプションです。これは設定ではなくこの関数を誰が呼べるかの宣言です。

// 認証済みユーザーのみ(既定)
withSupabase({ auth: 'user' }, handler)

// 認証不要。Webhookやヘルスチェック向け
withSupabase({ auth: 'none' }, handler)

// サーバー間通信(シークレットキー)
withSupabase({ auth: 'secret' }, handler)

// publishable キー
withSupabase({ auth: 'publishable' }, handler)

// ユーザーJWT または シークレットキーのどちらでも受ける
withSupabase({ auth: ['user', 'secret'] }, handler)

さらに特定のキーだけを受け付ける指定ができます。auth: 'secret:automations' と書けば、ダッシュボードの Settings > API keys で "automations" と名付けたシークレットキーだけが通ります(publishable:<name> も同様)。

これは地味に見えて強力です。「バッチ用の関数は、バッチ用のキーでしか呼べない」をコードで表現でき、キーが漏れたときの影響範囲が1本のキーの失効で閉じます。

ctx が渡してくれるもの

interface SupabaseContext {
  supabase: SupabaseClient      // ユーザーにスコープされる。RLSを尊重
  supabaseAdmin: SupabaseClient // 管理クライアント。RLSをバイパス
  userClaims: UserIdentity | null
  jwtClaims: JWTClaims | null
  authMode: AuthMode
}
import { withSupabase } from 'npm:@supabase/server@^1'

export default {
  fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => {
    // ctx.supabase は呼び出したユーザーのRLSポリシーに自動的にスコープされる
    const { data, error } = await ctx.supabase.from('todos').select()
    if (error) return Response.json({ error: error.message }, { status: 500 })
    return Response.json({ data, email: ctx.userClaims?.email })
  }),
}

ctx.supabasectx.supabaseAdmin の使い分けが、この機能のセキュリティ設計そのものです。

  • 既定は ctx.supabase RLSが効くので、認可の誤りがあってもデータは漏れません。
  • ctx.supabaseAdmin はRLSを完全にバイパスします。 使ってよいのは「RLSでは表現できない、正当にユーザーを跨ぐ処理」だけ(例:管理者向けの集計、他ユーザーへの通知作成)。使う行にはコメントで理由を書き、レビューで必ず問う対象にしてください。

supabaseAdmin を「エラーが出たから」で使い始めた瞬間、RLSは飾りになります。これは実際に監査でよく見つかるアンチパターンです(service_role キーの扱いも参照)。

verify_jwt との関係

プラットフォーム側のJWT検証は config.toml で制御します。

[functions.hello-world]
verify_jwt = false
  • ユーザー向けの関数verify_jwt = true(既定)のままにし、auth: 'user' を使う。プラットフォームがハンドラ実行前にJWTを検証します
  • Webhook受信verify_jwt = false にし、auth: 'none' + 署名検証を自分で書く(第5章)
  • サーバー間verify_jwt = false + auth: 'secret'

第4章の署名鍵ローテーションを予定しているなら、Verify JWT 設定を使っている関数を先に洗い出してください。公式は「Verify JWT 設定を使っているEdge Functionsがある場合、ローテーションを続けるとアプリが壊れる可能性がある」と警告しています(Supabase Auth 実装ガイド)。


3. ルーティング:関数は増やさず、束ねる

関数を機能ごとに10本作るのは、多くの場合悪手です。公式も「複数のアクションを1つの関数にまとめることで、コールドスタートを減らし、1つのインスタンスを複数エンドポイントで温かく保てる」としています。関数数の上限(Free 100)にも効きます。

Honoが最も相性の良い選択です。

import { Hono } from 'jsr:@hono/hono@^4'

const app = new Hono()

app.get('/hello-world', (c) => {
  return c.json({ message: 'Hello World!' })
})

app.post('/hello-world', async (c) => {
  const { name } = await c.req.json()
  return c.json({ message: `Hello ${name}!` })
})

export default { fetch: app.fetch }

必ず踏む落とし穴が1つあります。公式の表現では「Edge Functions内では、パスは常に関数名で前置されなければならない」。つまりルートは /functions/v1/<関数名>/<パス> になり、Honoに登録するパスにも関数名を含めます。上の例で app.get('/hello-world', ...) となっているのはそのためです。ここを / にすると404になります。

Express(npm:express@^5)やOakも使えますが、Edge環境向けに軽いHonoか、依存を足したくないなら標準のURL Pattern APIで十分です。


4. シークレットと環境変数

Edge Functionsには既定でいくつかの環境変数が注入されます。

変数内容
SUPABASE_URLプロジェクトのAPIゲートウェイ
SUPABASE_DB_URLPostgresデータベースの URL
SUPABASE_ANON_KEYanon キー
SUPABASE_SERVICE_ROLE_KEYservice_role キー
SUPABASE_PUBLISHABLE_KEYS / SUPABASE_SECRET_KEYSAPIキーのJSON辞書

読み出しはDenoの標準APIです。

const stripeSecret = Deno.env.get('STRIPE_SECRET_KEY')
if (!stripeSecret) throw new Error('STRIPE_SECRET_KEY is not configured')

起動時に存在を確認するのが実務上の勘所です。未設定のシークレットは undefined として静かに流れ、外部APIから「認証エラー」が返って初めて気づく——という時間の無駄を防げます。

設定はこうです。

# ローカル: supabase/functions/.env(.gitignore へ!)
supabase functions serve --env-file .env.local

# 本番
supabase secrets set --env-file .env
supabase secrets set STRIPE_SECRET_KEY=sk_live_...
supabase secrets list

公式の警告をそのまま置きます。「.env ファイルを絶対にGitにコミットしないこと」。そして SUPABASE_SECRET_KEYS は「ブラウザでは絶対に使ってはならない」——RLSをバイパスするためです。


5. Webhook を冪等に受ける:中核パターン

Edge Functionsの最も価値の高い用途がWebhook受信です。そして最も壊れやすい用途でもあります。理由は単純で、Webhookは必ず再送されるからです。ネットワークの瞬断、タイムアウト、プロバイダ側のリトライ——同じイベントが2回届くのは異常ではなく仕様です。

正しい順序はこうです。

1. 署名を検証する            ← 偽イベントを弾く
2. イベントIDを一意制約でINSERT ← 二重配送をDBで弾く
3. 即座に 2xx を返す          ← プロバイダのリトライを止める
4. 重い処理は waitUntil へ    ← レスポンス後に続行

まず記録テーブルです。冪等性の判定をアプリのif文ではなくDBの一意制約に委ねるのが要点です。同時に2件届いても、通るのは1件だけになります。

create table public.webhook_events (
  -- プロバイダ側のイベントID。これが冪等キー
  event_id text primary key,
  provider text not null,
  received_at timestamptz not null default now(),
  processed_at timestamptz,
  payload jsonb not null
);

alter table public.webhook_events enable row level security;
-- ポリシーを1本も作らない = 誰も読めない。書き込みは service_role のみ

関数側です。

署名検証のAPI名はプロバイダのSDKに依存します。 以下はStripeを例にした骨格です。Denoランタイムでは同期的な署名検証ではなく非同期版Web Crypto ベースのプロバイダを使う点だけ、必ず利用中のSDKのバージョンに合わせて確認してください(バージョンによりヘルパー名が異なります)。骨格——「生ボディで検証 → 冪等キーでINSERT → 即2xx → 背景処理」——はプロバイダを問わず同じです。

import { withSupabase } from 'npm:@supabase/server@^1'
import type { SupabaseClient } from 'npm:@supabase/supabase-js@^2'
import Stripe from 'npm:stripe'

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
  // Denoではfetchベースのクライアントを使う
  httpClient: Stripe.createFetchHttpClient(),
})
// 署名検証にはWeb Crypto(SubtleCrypto)ベースのプロバイダを使う
const cryptoProvider = Stripe.createSubtleCryptoProvider()
const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')!

export default {
  // Webhookは verify_jwt = false + auth: 'none'。認証は署名検証で行う
  fetch: withSupabase({ auth: 'none' }, async (req, ctx) => {
    const signature = req.headers.get('stripe-signature')
    if (!signature) return new Response('missing signature', { status: 400 })

    const body = await req.text() // 署名検証には生のボディが必要

    let event: Stripe.Event
    try {
      // 非同期版。Denoでは constructEvent ではなくこちらを使う
      event = await stripe.webhooks.constructEventAsync(
        body,
        signature,
        webhookSecret,
        undefined,
        cryptoProvider,
      )
    } catch {
      // 検証失敗の詳細は返さない(攻撃者への情報提供になる)
      return new Response('invalid signature', { status: 400 })
    }

    // 冪等性: 一意制約違反 = 処理済み
    const { error } = await ctx.supabaseAdmin.from('webhook_events').insert({
      event_id: event.id,
      provider: 'stripe',
      payload: event as unknown as Record<string, unknown>,
    })

    if (error) {
      // 23505 = unique_violation。既に受信済みなので 200 を返してリトライを止める
      if (error.code === '23505') return new Response('duplicate', { status: 200 })
      // それ以外は記録できていない = 再送してほしいので 5xx
      return new Response('storage error', { status: 500 })
    }

    // 重い処理はレスポンス後に。プロバイダのタイムアウトを踏まない
    EdgeRuntime.waitUntil(handleEvent(ctx, event))

    return new Response('ok', { status: 202 })
  }),
}

async function handleEvent(ctx: { supabaseAdmin: SupabaseClient }, event: Stripe.Event) {
  try {
    switch (event.type) {
      case 'checkout.session.completed':
        // 業務処理
        break
      default:
        break
    }
    await ctx.supabaseAdmin
      .from('webhook_events')
      .update({ processed_at: new Date().toISOString() })
      .eq('event_id', event.id)
  } catch (err) {
    // processed_at が null のまま残る = 再処理対象として可視化される
    console.error(JSON.stringify({ level: 'error', event_id: event.id, message: String(err) }))
  }
}

このパターンの設計判断を明示します。

  • 署名検証を先にやる。 DBに書く前に弾かないと、偽イベントでテーブルを膨らませられます。
  • 23505(一意制約違反)を 200 で返す。 ここを 500 にすると、プロバイダが永久にリトライします。
  • 記録に失敗したら 5xx。 「受け取ったのに記録できていない」状態は、再送してもらうのが正しい。
  • processed_at が null のまま残った行が、そのまま再処理キューになる。 障害の可視化を別の仕組みに頼らずに済みます。
  • 202 Accepted を返す。 「受け取ったが処理はこれから」を正しく表すステータスです。

6. 背景タスク:レスポンスを返してから働く

EdgeRuntime.waitUntil(promise) は、渡したPromiseが完了するまで関数インスタンスを動かし続けます。公式の説明では「waitUntil に渡されたPromiseが完了するまで、関数インスタンスは動作を続ける」。

ハンドラの外でも中でも呼べます。

import { withSupabase } from 'npm:@supabase/server@^1'

export default {
  fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
    // レスポンスをブロックせずに背景処理を開始する
    EdgeRuntime.waitUntil(asyncLongRunningTask())
    return Response.json({ ok: true })
  }),
}

無制限ではありません。 公式の但し書きはこうです。

最大の実行時間は、ウォールクロック・CPU・メモリの制限に基づいて上限が決まる。いずれかの制限に達した時点で関数はシャットダウンする。

シャットダウンは検知できます。

addEventListener('beforeunload', (ev) => {
  console.log('Function will be shutdown due to', ev.detail?.reason)
  // 状態を保存する / 現在の進捗をログに残す
})

これはあってもなくてもよいコードではありません。背景タスクが途中で切られたとき、何も残さなければ「消えた処理」になります。beforeunload で進捗を記録しておけば、次回の実行で再開点が分かります。

ローカルで背景タスクを試すには設定が必要です。

[edge_runtime]
policy = "per_worker"

この設定は自動リロードを無効にするため、コード変更のたびに supabase functions serve を再起動します。

背景タスクの限界を超えたら

数分で終わらない処理は、Edge Functionsの仕事ではありません。受け取ってキューに積み、実行は別の場所でという形に切り替えます。Supabase内で完結させるならDB側のCronとQueuesが自然な受け皿です。この判断は「まだ動くから」と先送りするほど高くつくので、想定処理時間が制限の半分に近づいたら移行を検討してください。


7. ストリーミング・WebSocket・一時ファイル

WebSocket

Deno.upgradeWebSocket() が使えます。

export default {
  fetch: (req) => {
    const upgrade = req.headers.get('upgrade') || ''

    if (upgrade.toLowerCase() != 'websocket') {
      return Response.json(
        { error: "request isn't trying to upgrade to WebSocket." },
        { status: 400 }
      )
    }

    const { socket, response } = Deno.upgradeWebSocket(req)

    socket.onopen = () => console.log('socket opened')
    socket.onmessage = (e) => {
      console.log('socket message:', e.data)
      socket.send(new Date().toString())
    }
    socket.onerror = (e) => console.log('socket errored:', e.message)
    socket.onclose = () => console.log('socket closed')

    return response
  },
}

認証に注意が必要です。 ブラウザはWebSocket接続時にカスタムヘッダを送れないため、公式は「URLのクエリパラメータ、またはカスタムプロトコル経由で認証情報を渡す」としています。デプロイ時は --no-verify-jwt フラグが必要です。

ただし率直に言えば、クライアント間のリアルタイム通信は Supabase Realtime の仕事です。Edge FunctionsのWebSocketが本当に必要なのは、外部WebSocket APIへの中継(リレー)のような、Realtimeでは表現できないケースに限られます(Supabase Realtime 実装ガイド)。

一時ファイル(/tmp

const uploadId = crypto.randomUUID()
await Deno.writeFile('/tmp/' + uploadId, req.body)
const zipFile = await Deno.readFile('/tmp/' + uploadId)

容量はFreeで最大256MB、有料で最大512MB。そして最重要の性質がこれです。

エフェメラルストレージは呼び出しごとにリセットされる。つまり、ある呼び出し中に書いたファイルは同じ呼び出しの中でしか読めない。

/tmp をキャッシュとして使うことはできません。また Deno.statSync() のような同期API は「初期スクリプト評価中のみ」動作し、HTTPハンドラやコールバック内では使えません。


8. テスト:純粋関数を切り出せば、ミリ秒で回る

Edge Functionsのテストが難しいと感じるなら、それはロジックがハンドラに埋まっているサインです。公式のガイドも「副作用のない純粋関数としてビジネスロジックを書く」ことを推奨し、分離されたユニットテストは「ミリ秒で走る」としています。

推奨されるレイアウトはこうです。

supabase/
├── functions/
│   ├── _shared/
│   │   └── types.ts
│   ├── process-ticket/
│   │   ├── index.ts      ← HTTPの受け口だけ
│   │   └── pricing.ts    ← 純粋なロジック
│   └── tests/
│       ├── utils/
│       │   └── supabase_env.ts
│       └── process-ticket/
│           ├── pricing.test.ts
│           └── index.test.ts
├── config.toml
└── deno.json
// supabase/functions/process-ticket/pricing.ts — 純粋。I/Oなし
export interface TicketInput {
  readonly basePriceJpy: number;
  readonly quantity: number;
  readonly isMember: boolean;
}

const MEMBER_DISCOUNT_RATE = 0.1;

/** 合計金額を計算する。丸めは切り捨てで統一(会計側と一致させる) */
export function calculateTotalJpy(input: TicketInput): number {
  if (!Number.isInteger(input.quantity) || input.quantity < 1) {
    throw new RangeError("quantity must be a positive integer");
  }
  const subtotal = input.basePriceJpy * input.quantity;
  const discount = input.isMember ? Math.floor(subtotal * MEMBER_DISCOUNT_RATE) : 0;
  return subtotal - discount;
}
// supabase/functions/tests/process-ticket/pricing.test.ts
import { assertEquals, assertThrows } from 'jsr:@std/assert'
import { calculateTotalJpy } from '../../process-ticket/pricing.ts'

Deno.test('会員は10%引きになる(端数は切り捨て)', () => {
  assertEquals(calculateTotalJpy({ basePriceJpy: 1050, quantity: 3, isMember: true }), 2835)
})

Deno.test('数量が0以下なら例外', () => {
  assertThrows(() => calculateTotalJpy({ basePriceJpy: 1000, quantity: 0, isMember: false }))
})
deno test supabase/functions/tests/process-ticket/pricing.test.ts
deno test supabase/functions/tests/process-ticket/index.test.ts --allow-env
deno task test

統合テストでは globalThis.fetch をモックします。公式の表現では「本番コードを変更せずにSupabaseのRESTコールを傍受でき、本物のEdge Functionのコードパスをテストできる」。ハンドラを書き換えずに経路全体を通せるのが利点です。


9. 可観測性:printf ではなく構造化ログ

自動で記録されるのは、実行中のキャッチされなかった例外console.log / console.error / console.warn によるカスタムログ、そして起動・終了ログです。ダッシュボードのFunctionsセクションで、Invocations(ヘッダ・ボディ・ステータスコード・実行時間を含むリクエスト/レスポンス)とLogs(プラットフォームイベント・例外・カスタムログ)を確認できます。

制限が実装に効きます。

  • 1メッセージ最大10,000文字 — 巨大なオブジェクトをそのままダンプすると切れます
  • 10秒あたり100イベント — ループの中で console.log すると、必要なログが押し出されます

これを踏まえると、console.log(payload) は本番では役に立ちません。構造化ログにします。

type LogLevel = 'info' | 'warn' | 'error';

interface LogFields {
  readonly event: string;
  /** 相関ID。1リクエストを横断して追える */
  readonly requestId: string;
  readonly [key: string]: unknown;
}

/** PIIを載せない・1行JSON・レベル付き。この3点だけ守れば後から検索できる */
export function log(level: LogLevel, fields: LogFields): void {
  const line = JSON.stringify({ level, ts: new Date().toISOString(), ...fields });
  // 10,000文字上限を超えると切れるため、超過分は明示的に落とす
  const safe = line.length > 9_500 ? `${line.slice(0, 9_500)}…"truncated":true}` : line;
  if (level === 'error') console.error(safe);
  else if (level === 'warn') console.warn(safe);
  else console.log(safe);
}

計測すべきものは3つです。

  1. エラー率(関数別・エラー種別ごと)
  2. 実行時間の分布(p95がウォールクロック制限に近づいていないか)
  3. 背景タスクの完走率beforeunload の発火回数 ÷ 開始回数)

3番目は自分で計測しない限り絶対に見えません。そして背景タスクが静かに切られているのは、Edge Functionsで最も発見が遅れる障害です。


10. デプロイとCI/CD

name: Deploy Function

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest

    env:
      SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
      PROJECT_ID: your-project-id

    steps:
      - uses: actions/checkout@v4
      - uses: supabase/setup-cli@v1
        with:
          version: latest
      - run: supabase functions deploy --project-ref $PROJECT_ID

実務では、この前にテストを挟みます

      - uses: denoland/setup-deno@v2
        with:
          deno-version: v2.x
      - run: deno test supabase/functions/tests/ --allow-env
      - run: supabase functions deploy --project-ref $PROJECT_ID

SUPABASE_ACCESS_TOKEN はリポジトリのシークレットに置きます。アプリケーションのシークレット(STRIPE_SECRET_KEY 等)はここでは扱いません — それらは supabase secrets set でプロジェクトに設定するもので、CIのログに載る経路を作らないのが原則です。


11. コスト:回数課金であることの意味

項目FreePro
呼び出し回数月50万回 込み月200万回 込み、以降 100万回あたり $2

回数課金という性質が、そのまま設計指針になります。

  • ポーリングをやめる。 5秒ごとにステータスを問い合わせるクライアントが100人いれば、1日あたり約173万回。これだけで無料枠を超えます。同じことはRealtimeの購読で、呼び出し0回で実現できます。
  • 関数を束ねる。 第3章のルーティングは、コールドスタート削減と関数数上限だけでなく、運用の単純化にも効きます。
  • リトライを制御する。 クライアント側の無制限リトライは、障害時に呼び出し回数を指数的に増やします。指数バックオフと上限回数は必須です。

12. 本番前チェックリスト

  • その処理のCPU時間が2秒に収まる根拠がある(重い変換・集計を載せていない)
  • p95の実行時間がウォールクロック制限(Free 150秒 / 有料 400秒)に対して十分な余裕がある
  • withSupabaseauth モードが、その関数の呼び出し元と一致している
  • ctx.supabaseAdmin を使っている全ての箇所に、RLSをバイパスする正当な理由がコメントされている
  • Webhook関数は verify_jwt = false + auth: 'none' + 署名検証である
  • Webhookの冪等性がDBの一意制約で担保されている(アプリのif文ではない)
  • 重複配送に 200、記録失敗に 5xx を返している
  • 背景タスクに beforeunload のハンドラがあり、進捗を残している
  • 起動時に必須シークレットの存在を検証している
  • .env.gitignore にある
  • ログが構造化されており、PIIを含まず、10,000文字を超えない
  • ループ内で console.log していない(10秒100イベント制限)
  • ロジックが純粋関数として分離され、Deno.test で回っている
  • CIでテストを通してからデプロイしている
  • クライアントのポーリングをRealtimeに置き換え済み(呼び出し回数課金)

まとめ:制約が設計を教えてくれる

Edge Functionsの制約——CPU 2秒、メモリ256MB、実行時間150〜400秒——は、不便な制限ではなく設計の指針です。この枠に収まる処理は「入力を検証し、外部と話し、結果を記録する」という形をしています。それはまさに、アプリケーションのサーバーサイドが本来やるべき仕事の形です。

そして @supabase/serverwithSupabase は、その仕事から定型を取り除きます。認証の検証、クライアントの生成、RLSのスコープ——これまで関数ごとにコピーしていたコードが、auth モードの1行の宣言になる。残るのは業務ロジックだけです。

最後にもう一度だけ強調します。ctx.supabase を既定にし、ctx.supabaseAdmin は理由を書いてから使ってください。 Edge Functionsはサーバー側なので、何でもできてしまいます。何でもできる場所で、あえて権限を絞り続けられるかどうかが、そのシステムが数年後も安全であるかどうかを決めます。

よくある質問

Edge Functions に載せてよい処理と、載せてはいけない処理の境界は?
境界はCPU時間です。1リクエストあたりのCPU実行時間は2秒、メモリは256MBが上限で、実行時間(ウォールクロック)はFreeプランで150秒、有料プランで400秒です。CPUを実際に使う処理——大きな画像の変換、動画のエンコード、大規模なデータ集計——は向きません。逆に、外部APIの呼び出し、Webhookの受信、DBへの読み書き、LLMのストリーミング中継といったI/O待ちが主体の処理は、待っている間CPUを消費しないため相性が良好です。
withSupabase を使うと何が変わりますか?
認証の検証・クライアント生成・クレーム解析・CORSといった定型処理が消えます。auth モードを宣言すると、withSupabase が呼び出し元の資格情報をそのモードに照らして検証し、設定済みのクライアントを ctx に渡します。ctx.supabase は呼び出し元のRLSにスコープされ、ctx.supabaseAdmin はRLSをバイパスします。公式は「jose も JWKS の設定も手動のシークレット設定も不要」と説明しており、新しいキー体系への追従もパッケージ側が引き受けます。
レスポンスを返した後に重い処理を続けられますか?
できます。EdgeRuntime.waitUntil(promise) に Promise を渡すと、その Promise が解決するまで関数インスタンスが動き続けます。ハンドラ内で呼べばレスポンスをブロックしません。ただし無制限ではなく、公式は「最大の実行時間はウォールクロック・CPU・メモリの制限に基づいて上限が決まる。いずれかの制限に達した時点で関数はシャットダウンする」としています。beforeunload イベントを購読すれば、シャットダウン理由を受け取って進捗を記録できます。
Webhook を二重処理しないためにはどうすればいいですか?
配送側は再送するものと前提を置き、受信側を冪等にします。手順は、(1) 署名を検証する、(2) プロバイダのイベントIDを一意制約付きテーブルに INSERT する、(3) 一意制約違反なら既に処理済みとして 200 を返す、(4) 新規なら 2xx を先に返し、重い処理は EdgeRuntime.waitUntil に回す、の4段です。冪等性の判定をアプリのロジックではなくDBの一意制約に委ねるのが要点で、同時に届いた二重配送も1件しか通りません。
コストはどう決まりますか?
課金の軸は呼び出し回数です。Freeプランは月50万回、Proプランは月200万回が含まれ、超過分は100万回あたり$2です。回数課金なので、跳ねるのは「1リクエストあたりの処理が重い」ときではなく「呼び出しが多い」ときです。クライアントのポーリングをRealtimeの購読に置き換える、複数の小さなエンドポイントを1つの関数にルーティングで束ねる(コールドスタートも減る)といった設計が、そのまま費用に効きます。

参考文献

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の実装を、案件として承ります

Supabase を土台にしたアプリを、設計から本番運用まで承ります

Realtime の設計(Broadcast / Presence / Postgres Changes の使い分けと再接続時の整合)、Auth の認証フロー・JWT署名鍵・MFA、Edge Functions での冪等な Webhook 受信、Storage のアップロード経路とコスト設計まで。認可を RLS で DB に寄せた設計を、モバイル+Web の実プロダクトで単独構築した知見で、落ちない・追える・変更しやすいアプリを実装します。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

あわせて読みたい