マイクロサービスにして後悔した — 地獄の障害対応72時間を告白する

$ |
マイクロサービスにして後悔した — 地獄の障害対応72時間を告白する
$

マイクロサービスアーキテクチャは、スケーラビリティと開発チームの独立性を約束してくれる。しかし、その華やかな世界の裏には、分散トランザクションという深い闇が潜んでいる。本記事では、我々が実際に経験した障害事例を交えながら、分散トランザクションの課題とSaga Patternによる解決策を解説する。

モノリスを分割した日

2年前、我々はECプラットフォームのモノリスをマイクロサービスに分割した。注文サービス、在庫サービス、決済サービス、通知サービス——教科書通りのドメイン分割だった。最初の数ヶ月は順調だった。各チームが独立してデプロイでき、開発速度は確実に向上した。

問題が顕在化したのは、ブラックフライデーのセール時だった。

障害発生 — 在庫なし決済完了の悪夢

セール開始から30分後、カスタマーサポートに問い合わせが殺到した。「決済は完了したのに、注文確認メールが届かない」「マイページに注文が表示されない」。調査の結果、以下の事象が発生していた。

  • 決済サービスがクレジットカード課金を完了
  • 在庫サービスへの在庫引き当てリクエストがタイムアウト
  • 注文サービスがエラーをキャッチし、注文を「失敗」としてロールバック
  • しかし、決済サービスには返金指示が届かず、課金だけが残った

つまり、「お金は引かれたのに商品が届かない」状態が約200件発生したのだ。これは最悪のユーザー体験であり、信頼の根幹に関わる問題だった。

なぜこうなったのか — 2フェーズコミットの幻想

モノリス時代は、データベーストランザクションが全てを守ってくれた。BEGIN→処理→COMMIT。途中で失敗すればROLLBACK。シンプルで完璧だった。

マイクロサービスでは、各サービスが独立したデータベースを持つ。サービス間をまたぐトランザクションは、単一のBEGIN/COMMITでは管理できない。

2フェーズコミット(2PC)はこの問題の古典的な解決策だが、実用上の課題が多い。

  • パフォーマンス: 全参加者がロックを取得して待機するため、スループットが激減する
  • 可用性: コーディネーターが単一障害点になる
  • ネットワーク障害: 準備フェーズ完了後にネットワークが切断されると、参加者がロックを保持したまま待機し続ける

我々は2PCを採用していなかった。代わりに「各サービスを順番に呼び出し、失敗したら手動でリカバリ」という楽観的なアプローチを取っていた。これが悲劇の原因だった。

Saga Pattern — 救世主の登場

Saga Patternは、長時間トランザクションを一連のローカルトランザクションに分割するパターンだ。各ステップが成功したら次のステップへ進み、いずれかのステップが失敗したら、それまでに完了したステップの補償トランザクション(Compensating Transaction)を逆順に実行する。

オーケストレーション型Saga

我々が採用したのは、オーケストレーション型Sagaだ。中央のSagaオーケストレーターが全体のフローを管理する。

// saga-orchestrator.ts — 注文Sagaの例
interface SagaStep {
  execute: () => Promise<void>
  compensate: () => Promise<void>
}

class OrderSaga {
  private completedSteps: SagaStep[] = []

  async execute(orderId: string): Promise<void> {
    const steps: SagaStep[] = [
      {
        execute: () => this.reserveInventory(orderId),
        compensate: () => this.releaseInventory(orderId),
      },
      {
        execute: () => this.processPayment(orderId),
        compensate: () => this.refundPayment(orderId),
      },
      {
        execute: () => this.confirmOrder(orderId),
        compensate: () => this.cancelOrder(orderId),
      },
      {
        execute: () => this.sendNotification(orderId),
        compensate: () => Promise.resolve(), // 通知は補償不要
      },
    ]

    for (const step of steps) {
      try {
        await step.execute()
        this.completedSteps.push(step)
      } catch (error) {
        console.error(`Saga step failed: ${error}`)
        await this.rollback()
        throw error
      }
    }
  }

  private async rollback(): Promise<void> {
    for (const step of this.completedSteps.reverse()) {
      try {
        await step.compensate()
      } catch (error) {
        console.error(`Compensation failed: ${error}`)
        // 補償失敗はDead Letter Queueに送り、手動対応
        await this.sendToDeadLetterQueue(error)
      }
    }
  }
}

重要な設計原則

Saga Patternを実装する際の重要な原則をまとめる。

  • べき等性(Idempotency): 各ステップと補償トランザクションは、何度実行しても同じ結果になるように設計する。ネットワーク障害でリトライが発生した場合に、二重処理を防ぐ
  • 順序の設計: 取り消しやすいステップを先に、取り消しにくいステップを後に配置する。在庫引き当て→決済の順にすることで、在庫がない場合に無駄な決済を防げる
  • タイムアウトとリトライ: 各ステップに適切なタイムアウトとリトライ戦略を設定する。指数バックオフとジッターの組み合わせが定番
  • Dead Letter Queue: 補償トランザクションが失敗した場合のエスカレーションパスを必ず用意する

Outbox Pattern — イベントの確実な配信

Saga Patternと併せて導入したのがOutbox Patternだ。サービス間の通信にメッセージキュー(我々はAmazon SQSを使用)を使う場合、「DBへの書き込み」と「メッセージの送信」を原子的に行う必要がある。

// Outbox Pattern — DBトランザクション内でイベントを記録
async function reserveInventory(orderId: string) {
  await prisma.$transaction(async (tx) => {
    // 在庫を引き当て
    await tx.inventory.update({
      where: { productId },
      data: { reserved: { increment: quantity } },
    })

    // 同じトランザクション内でOutboxテーブルに記録
    await tx.outboxEvent.create({
      data: {
        aggregateId: orderId,
        eventType: 'INVENTORY_RESERVED',
        payload: JSON.stringify({ orderId, productId, quantity }),
        published: false,
      },
    })
  })

  // 別プロセスがOutboxテーブルをポーリングしてメッセージキューに送信
}

Outboxテーブルに記録されたイベントは、別のポーリングプロセスが定期的に読み取り、メッセージキューに発行する。DBトランザクションが成功すれば必ずイベントが記録されるため、「処理は成功したがイベントが消失する」問題を防げる。

教訓 — モノリスを甘く見るな

マイクロサービスへの移行で得た最大の教訓は、「モノリスは悪ではない」ということだ。小〜中規模のチームであれば、モジュラーモノリスの方が生産性が高い場合が多い。マイクロサービスは、組織のスケーリングが必要な場合、つまり複数チームが独立してデプロイしたい場合に初めて価値を発揮する。

「マイクロサービスは技術的な問題を解決するのではなく、組織的な問題を解決するためのものだ。技術的にはむしろ問題が増える。」— Martin Fowler

分散トランザクションの複雑さは、マイクロサービスの「隠れたコスト」だ。このコストを支払う覚悟があるか、そしてSaga PatternやOutbox Patternなどの対策を適切に実装できるか。その判断がプロジェクトの成否を分ける。

我々のシステムはSaga Pattern導入後、同様の障害は発生していない。しかし、運用の複雑さは確実に増した。監視ダッシュボードにはSagaの進行状況を表示するパネルが追加され、Dead Letter Queueのアラートが日常の一部になった。これが分散システムの現実だ。