Node.js アプリケーションのレジリエンスを高める:リトライと指数バックオフの実践ガイド

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

「再試行」だけでは不十分な理由

ほとんどの Node.js アプリケーションは単独で完結しているわけではありません。Stripe(決済)、SendGrid(メール)、Twilio(SMS)といった外部サービスのネットワークに依存しています。しかし、ネットワークは不安定なものです。500ミリ秒の DNS の瞬断、コンテナの再起動、一時的なレート制限などによって、リクエストが失敗することがあります。もしコードが単にエラーを投げて終了してしまうなら、その代償を払うのはユーザーです。

キャリアの初期、私はすべての API 呼び出しを基本的な try-catch ブロックで囲んでいました。リクエストが失敗したら、クライアントに 500 エラーを返していました。機能はしていましたが、脆弱でした。やがて、多くの失敗は一時的なものであり、1秒待ってから再試行すれば解消されることに気づきました。しかし、どのようにリトライするかは, リトライそのものよりも重要であることが多いのです。

私はこれらのパターンを高トラフィックのプロダクション環境で実装してきました。その結果、バックグラウンドジョブの失敗が大幅に減少し、ピーク時にトラフィックを制限しがちなレガシーなサードパーティサービスとの連携も非常にスムーズになりました。

適切なリトライ戦略の選択

すべてのリトライロジックが同じように作られているわけではありません。コーディングを始める前に、システムが耐えられる負荷に合わせた戦略を選択する必要があります。

1. 即時リトライ

これは最も基本的なアプローチです。リクエストが失敗したら、すぐに再実行します。シンプルですが、危険を伴うことが多いです。外部サーバーがすでに高負荷で悲鳴を上げている場合、すぐに再試行すると火に油を注ぐことになります。これは、鍵のかかったドアを叩き続けるようなものです。中にいる人が忙しくて出られないのであれば、叩く速度を上げても解決しません。

2. 固定遅延リトライ

この方法では、次の試行までに 2 秒などの特定の待ち時間を設けます。これにより、リモートサーバーが回復する時間が確保されます。しかし、サービスが 30 秒間のデプロイ中であったり、大規模なボトルネックを解消中であったりする場合、2 秒という固定の時間は不十分なことが多いです。

3. 指数バックオフ

これが業界標準であるのには理由があります。一定の間隔ではなく、試行ごとに待ち時間を倍増させます。1秒、2秒、4秒、そして 8秒といった具合です。このアプローチは外部システムへの負荷を軽減しつつ、時間が経過するにつれてアプリケーションの成功率を高めることができます。

4. ジッター付き指数バックオフ

データベースのスパイクが原因で、1,000 個のマイクロサービスインスタンスが一斉に失敗したと想像してください。それらがすべて同じバックオフ計算を使用している場合、まったく同じミリ秒にリトライが実行されます。この「群衆の殺到(thundering herd)」現象は、回復しかけているサーバーを再びダウンさせる可能性があります。「ジッター(ランダムなノイズ)」を加えることで、これらのリトライを分散させ、システムが自社プロバイダーに対して意図せず DDoS 攻撃を仕掛けてしまうのを防ぐことができます。

リトライのトレードオフ

これらのパターンを実装するにはコストがかかります。アプリが消費するリソースと信頼性のバランスを取る必要があります。

  • メリット:
    • 自己修復: 503 や 429 エラーの多くは、開発者が対応せずとも自然に解決します。
    • 顧客満足度: 軽微なネットワークエラーの際に、ユーザーが「エラーが発生しました」という画面を見ることがなくなります。
    • サポート件数の削減: 決済の失敗や Webhook の欠落に関する問い合わせが減少します。
  • デメリット:
    • 蓄積されるレイテンシ: サービスが完全に停止している場合、4 回のリトライによって、ユーザーは最終的なエラーを見るまで 15 秒待たされる可能性があります。
    • メモリ負荷: リトライ待ちの間、Node.js プロセス内でメモリとソケット接続が保持され続けます。
    • ロジックの複雑化: 503(リトライ可能)と 400 Bad Request(リトライ不可)を正確に判別する必要があります。

プロダクション対応のユーティリティの構築

私がこれらのシステムを構築する際は、再利用可能な関数型のアプローチを好みます。堅牢な実装には、次の 3 つの要素が必要です。

  1. 遅延ユーティリティ: setTimeout をラップした Promise ベースの関数。
  2. バックオフ計算機: 指数関数的な増加とランダム性を処理するロジック。
  3. エラーフィルター: どの HTTP ステータスコードが再試行に値するかを判断するゲートキーパー。

ステップ 1:待ち時間の計算

まず、どれくらい待つかを計算する方法が必要です。ベースとなる遅延を使用し、予測不能にするために 20% のジッターを加えます。

const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

const getWaitTime = (attempt, baseDelay = 1000) => {
  // Math.pow(2, 0) = 1秒, Math.pow(2, 1) = 2秒 など
  const exponent = Math.pow(2, attempt);
  const delay = exponent * baseDelay;
  
  // 群衆の殺到(thundering herd)を防ぐために +/- 20% のジッターを追加
  const jitter = delay * 0.2 * Math.random();
  return delay + jitter;
};

ステップ 2:ロジックのラッパー

コア関数がループを管理します。操作を試行し、失敗した場合はリトライが適切かどうかを確認します。

async function withRetry(fn, maxRetries = 3) {
  let lastError;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      
      if (!isRetryable(error) || attempt === maxRetries - 1) {
        throw error;
      }

      const delay = getWaitTime(attempt);
      console.warn(`試行 ${attempt + 1} が失敗しました。${Math.round(delay)}ミリ秒後にリトライします...`);
      await sleep(delay);
    }
  }
  throw lastError;
}

function isRetryable(error) {
  if (error.response) {
    const { status } = error.response;
    // 429 (レート制限) または 5xx (サーバーエラー) の場合にリトライ
    return status === 429 || (status >= 500 && status <= 599);
  }
  // ネットワークタイムアウトや DNS 失敗は通常リトライする価値がある
  return true;
}

ステップ 3:実用的な例

CRM からデータを取得する場合の例です。5 秒のタイムアウトを設定している点に注目してください。これにより、1 つのリクエストがハングしてリトライロジックを無期限にブロックするのを防ぎます。

const axios = require('axios');

async function fetchUserData(userId) {
  return withRetry(async () => {
    const response = await axios.get(`https://api.crm-provider.com/v1/users/${userId}`, {
      timeout: 5000 
    });
    return response.data;
  });
}

fetchUserData('user_8842')
  .then(data => console.log('取得に成功しました:', data))
  .catch(err => console.error('すべての試行が失敗しました:', err.message));

最後に:ツールとアドバイス

独自のユーティリティを書くのは制御しやすくて良いですが、大規模なプロジェクトでは十分にテストされたライブラリを利用するのが有益です。すでに Axios を使っているなら、axios-retry は素晴らしいプラグアンドプレイの選択肢です。汎用的なロジックには、Node.js エコシステムの標準である p-retry が適しています。

重要なアドバイスを 1 つ:リトライの試行は必ずログに記録してください。ログが「試行 3 が失敗しました」で埋め尽くされているなら、それは危険信号です。通常、外部サービスが不安定になっているか、内部のタイムアウト設定が厳しすぎることを意味します。これらのパターンを監視することは、コードそのものと同じくらい重要です。こうしたセーフティネットを構築することで、依存している API の調子が非常に悪い日でも、サービスの稼働率 99.9% を維持することができました。

Share: