0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Stripe-Webhook Guardian: 決済ロストと重複実行を根絶する堅牢なバックエンド・

0
Posted at

eyecatch

決済システムは、あらゆるウェブサービスにおける「命の循環」を司る心臓部です。システム全体が自律し、障害から自己修復する堅牢なエコシステムを築くためには、決済Webhookの処理においていかなる妥協も許されません。

これまでに多数のプロジェクトで決済基盤を構築・運用してきたCTOの視点から、StripeのWebhook運用において個人開発者や少人数チームが直面する「静かなる決済ロスト」と「高負荷時のデッドロック(二重課金)」を完全に防ぐための、泥臭く実戦的なベストプラクティスを解説します。


1. なぜWebhook実装で深夜のデバッグに陥るのか

ローカル環境(Stripe CLIを用いたモックテスト)では何の問題もなく動作していたWebhookの実装も、本番環境へデプロイし、リアルなトラフィックが流れ始めた瞬間に以下の現実に直面します。

  • ネットワークの気まぐれ: 瞬断やデプロイ時のコンテナ再起動により、Stripeからの checkout.session.completed がロストする。
  • Race Condition(競合状態): ユーザーの決済ボタン連打や、Stripe側の自動リトライ(504 Gateway Timeout等に起因)がミリ秒単位で同時到達する。

素朴な実装(単なる SELECT で存在確認してからの INSERT)のままでいると、データベース層で deadlock detected が発生して500エラーの無限ループに突入するか、ステータスが宙ぶらりんのままStripe側に 200 OK を返してしまい、「決済は完了したのに機能が解放されない」という致命的な障害を引き起こします。


2. 泥臭い失敗ログからの教訓

堅牢なシステムは、生々しい失敗の蓄積から生まれます。我々が実機検証や過去の運用で実際に直面した障害パターンを開示します。

ログ事例 A: 瞬間的高負荷時の重複実行(Race Condition)

[2026-08-10 14:22:01] ERROR [WebhookWorker] Unique constraint violation on table "subscriptions": 
Key (customer_id)=(cus_N9a8B7c6D5e4F3) already exists.
[2026-08-10 14:22:01] WARN  [StripeListener] Stripe retried webhook evt_1O2p3Q4r5S6t7U8v9W0x due to timeout (504 Gateway Time-out).

【技術的考察】
ユーザーが決済ボタンを連打した結果、StripeからのWebhookがミリ秒単位で複数同時到達。アプリケーション層での単なる SELECT チェックを複数のスレッドが同時にすり抜け、後続の INSERT が衝突しました。DBが例外を吐き500エラーを返した結果、StripeのExponential Backoff(指数的バックオフ)による自動リトライを誘発し、カスケード障害に発展しました。

ログ事例 B: ネットワーク瞬断によるイベント完全ロスト

[2026-08-10 18:45:12] INFO  [Nginx] 502 Bad Gateway upstream prematurely closed connection while reading response header from upstream

【技術的考察】
バックエンドのデプロイ(あるいはDBマイグレーション)とWebhook受信タイミングが衝突。中途半端に処理が進んだ状態でコネクションが切断され、アプリケーションはエラーログを吐かずに死没。しかし、NginxやAPI Gatewayのキャッシュレイヤーが不適切なレスポンス(またはリトライ上限)を返し、決済ステータスが宙に浮く結果となりました。


3. データベース層の防衛設計(PostgreSQL / Supabase)

外部サービス起因の遅延やリトライの嵐が自社データベースのコネクションプールを枯渇させ、全機能のダウンを誘発することを防ぐため、専用のプールと厳格な排他制御を適用します。

A. 最小限にして最強のSQLスキーマ

Webhookの受信ログと冪等性(Idempotency)を担保するための専用テーブルを設けます。

-- Webhookイベントの受信ログおよび冪等性担保テーブル
CREATE TABLE processed_stripe_events (
    event_id VARCHAR(255) PRIMARY KEY,
    event_type VARCHAR(100) NOT NULL,
    status VARCHAR(50) NOT NULL DEFAULT 'processing', -- processing, completed, failed, abandoned
    payload JSONB NOT NULL,
    error_message TEXT,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- インデックス:未処理イベントの強制同期CLIおよびステータス監視用
CREATE INDEX idx_processed_stripe_events_status ON processed_stripe_events(status);

B. Webhook専用コネクションプールの分離

通常APIとWebhook受信用で同じDBプールを共有すると、Webhookの処理遅延がメインAPI全体を道連れにします。Bulkhead(隔壁)パターンの思想に基づき、専用プールを定義します。

import { Pool } from 'pg';

// Webhook専用プール(枯渇防止のため最大接続数を厳格に絞る)
export const webhookPool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 5, // 同時実行数を制限し、メインAPIへの影響を遮断
  connectionTimeoutMillis: 2000, // 2秒でコネクション取得できなければ即座に失敗(Stripeのリトライに委ねる)
});

4. バックエンド実装:悲観的ロックによる排他制御ミドルウェア

Stripeの署名検証、レートリミット、および SELECT ... FOR UPDATE による行レベルの悲観的ロックを統合したミドルウェアです。

楽観的ロック(バージョンカラムを用いる手法)も存在しますが、Webhookのように同じイベントIDに対するリトライが集中するユースケースでは、リトライ時のDB例外を確実に抑え込むために**悲観的ロック(Pessimistic Locking)**が有効です。

import { Request, Response } from 'express';
import Stripe from 'stripe';
import { webhookPool } from './db';
import rateLimit from 'express-rate-limit';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2025-02-28.acacia' });

// レートリミット設定(DDoS・ブルートフォース対策)
export const webhookRateLimiter = rateLimit({
  windowMs: 1 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later.' },
});

export async function handleStripeWebhookSecure(req: Request, res: Response) {
  const sig = req.headers['stripe-signature'] as string;
  if (!sig) {
    console.warn(`⚠️ Security Warning: Missing stripe-signature header from IP: ${req.ip}`);
    return res.status(400).send('Webhook Error: Missing signature');
  }

  let event: Stripe.Event;
  try {
    const rawBody = (req as any).rawBody;
    if (!rawBody) {
      console.error(`❌ Security Error: Raw body not captured for request from IP: ${req.ip}`);
      return res.status(400).send('Webhook Error: Raw body missing');
    }
    // 1. 署名検証(改ざん・不正リクエストの排除)
    event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err: any) {
    console.error(`🚨 Security Alert: Webhook signature verification failed: ${err.message}`);
    return res.status(400).send(`Webhook Error: Signature verification failed`);
  }

  const client = await webhookPool.connect();
  try {
    // 2. トランザクション分離レベルの明示と悲観的ロック
    // READ COMMITTEDにより、他のトランザクションのコミット待ちを行う
    await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');

    const checkQuery = `
      SELECT status FROM processed_stripe_events 
      WHERE event_id = $1 
      FOR UPDATE;
    `;
    const existing = await client.query(checkQuery, [event.id]);

    if (existing.rows.length > 0) {
      const status = existing.rows[0].status;
      await client.query('COMMIT');
      
      // 処理中または処理済みの場合はStripeに200を返し、重複処理を遮断
      if (status === 'completed' || status === 'processing') {
        console.log(`ℹ️ Duplicate or concurrent event detected and ignored: ${event.id}`);
        return res.status(200).json({ received: true, status: 'already_processed_or_running' });
      }
    }

    // 3. 新規イベントの登録(processing)
    // ON CONFLICTを利用して、万が一のRace ConditionでもUPDATEにフォールバックさせる
    await client.query(`
      INSERT INTO processed_stripe_events (event_id, event_type, status, payload)
      VALUES ($1, $2, 'processing', $3)
      ON CONFLICT (event_id) DO UPDATE SET status = 'processing', updated_at = CURRENT_TIMESTAMP;
    `, [event.id, event.type, event]);

    await client.query('COMMIT');

    // 4. ビジネスロジックの実行
    await executeBusinessLogicSafely(event);

    // 5. 完了ステータスの更新
    await webhookPool.query(
      `UPDATE processed_stripe_events SET status = 'completed', updated_at = CURRENT_TIMESTAMP WHERE event_id = $1`,
      [event.id]
    );

    return res.status(200).json({ received: true, status: 'success' });

  } catch (error: any) {
    await client.query('ROLLBACK').catch(() => {});
    console.error(`❌ Webhook processing error for event ID [${event.id}]:`, error.message);

    // 失敗ステータスの記録(自己治癒CLIが後でリカバリできるようにする)
    await webhookPool.query(
      `UPDATE processed_stripe_events SET status = 'failed', error_message = $1, updated_at = CURRENT_TIMESTAMP WHERE event_id = $2`,
      [error.message, event.id]
    ).catch(dbErr => console.error(`DB fail-log error:`, dbErr.message));

    return res.status(500).json({ error: 'Webhook processing failed' });
  } finally {
    client.release();
  }
}

async function executeBusinessLogicSafely(event: Stripe.Event) {
  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session;
      const customerEmail = session.customer_email || session.customer_details?.email;
      // 実装のポイント: ユーザーへのプロビジョニング処理等を行う。
      // 機密情報のログ出力は避けつつ、トレーサビリティを確保する。
      console.log(`💎 [Secure Execution] Granting access for session ID: ${session.id} to ${customerEmail}`);
      break;
    }
    default:
      console.log(`ℹ️ Unhandled event type ${event.type}`);
  }
}

5. 未処理イベントの強制同期 & 自己治癒 CLI スクリプト

いくらアプリケーション側で防衛線を張っても、プロセスごとクラッシュした場合などは failed にすらならず processing のまま残る可能性があります。Stripe APIを真実のソース(Single Source of Truth)として突き合わせ、異常な状態のイベントを安全にリカバリ・隔離する運用スクリプトです。

Cron等で定期実行することで、システムの自己治癒(Self-healing)能力を担保します。

#!/usr/bin/env python3
import os
import sys
import time
import stripe
import psycopg2
from psycopg2.extras import RealDictCursor

stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
DATABASE_URL = os.getenv("DATABASE_URL")

def sync_failed_events():
    if not DATABASE_URL:
        print("Error: DATABASE_URL environment variable is not set.", file=sys.stderr)
        sys.exit(1)

    conn = psycopg2.connect(DATABASE_URL)
    cursor = conn.cursor(cursor_factory=RealDictCursor)

    try:
        # ステータスが failed、または processing のまま30分以上経過したゾンビイベントを対象とする
        cursor.execute(
            """
            SELECT event_id, payload 
            FROM processed_stripe_events 
            WHERE status = 'failed' 
               OR (status = 'processing' AND updated_at < NOW() - INTERVAL '30 minutes')
            ORDER BY created_at ASC;
            """
        )
        failed_events = cursor.fetchall()

        print(f"🔍 Found {len(failed_events)} pending/failed events to re-sync.")

        for row in failed_events:
            event_id = row['event_id']
            print(f"🔄 Re-syncing event: {event_id}")
            
            try:
                # Stripe APIから最新のイベントオブジェクトを直接取得(真実のソース)
                live_event = stripe.Event.retrieve(event_id)
                
                # ここで再処理のビジネスロジック関数やエンドポイントを呼び出す
                # reprocess_event(live_event)
                
                cursor.execute(
                    "UPDATE processed_stripe_events SET status = 'completed', error_message = NULL, updated_at = CURRENT_TIMESTAMP WHERE event_id = %s",
                    (event_id,)
                )
                conn.commit()
                print(f"✅ Successfully recovered event: {event_id}")

            except stripe.error.InvalidRequestError as e:
                conn.rollback()
                print(f"⚠️ Stripe API reported invalid event {event_id} (Skipping): {str(e)}")
                cursor.execute(
                    "UPDATE processed_stripe_events SET status = 'abandoned', error_message = %s, updated_at = CURRENT_TIMESTAMP WHERE event_id = %s",
                    (str(e), event_id)
                )
                conn.commit()

            except stripe.error.RateLimitError as e:
                # Stripe APIのレートリミット対策
                conn.rollback()
                print(f"⏳ Rate limited. Sleeping for 2 seconds...")
                time.sleep(2)

            except Exception as e:
                conn.rollback()
                print(f"❌ Failed to recover event {event_id}: {str(e)}", file=sys.stderr)

    finally:
        cursor.close()
        conn.close()

if __name__ == "__main__":
    sync_failed_events()

6. システムアーキテクチャ図

本構成の全体像をMermaid記法で示します。


7. 発展的な考察:メッセージキューを用いた非同期アーキテクチャへの移行

事業が成長し、Webhookのリクエスト数が秒間数十〜数百を超えるスケールに到達した場合、上記の「同期的」な処理アーキテクチャではデータベースのI/Oがボトルネックになり得ます。

そのフェーズに到達した際は、Webhook受信用エンドポイントでは署名検証とSQS / Redis等へのエンキューのみを行い、即座にStripeへ 200 OK を返却する非同期アーキテクチャへのリファクタリングを推奨します。今回紹介した冪等性の担保ロジックは、キューからメッセージを取り出すワーカーノード内でそのまま再利用することが可能です。

本アーキテクチャが、未知のWebhook障害に怯えてログを監視し続ける夜を終わらせ、皆様がプロダクトのコア価値の創造に専念できる一助となれば幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?