多くのNode.jsチュートリアルでは、エラーハンドリングは後回しにされがちです。ルートハンドラーをtry/catchブロックで囲み、スタックトレースをログに記録して、500エラーを返す。そのような考え方が定着しているのは、HTTPリクエストの先に実在の人間が待っているからです。バックグラウンドジョブは異なります。キューシステムには、返答を待つせっかちなクライアントも、自動的なブラウザの更新もありません。そこにあるのは、ワーカー、ペイロード、そして静かにカウントアップしていくリトライカウンターだけです。何かがうまくいかなくなるとき、それはゆっくりと、そして一気に崩壊します。誤って分類された一つのエラーが、パイプライン全体を停滞させたり、午前3時にエンジニアを呼び出したりすることさえあります。
その乖離は単純です。リクエスト・レスポンス・サイクルは、素早く、かつ目立つ形で失敗します。しかし、キューの失敗は静かです。データベース接続が切れるまで、ワーカーは何百ものジョブを処理し続けているかもしれません。明確なハンドリングルールがなければ、ワーカーは即座にリトライを繰り返し、すでに負荷がかかっているデータベースを叩き続け、最終的にクラッシュします。ワーカーを直接監視している者がいないため、トラブルの最初の兆候は、多くの場合、連鎖的な滞留や、ログファイルで一杯になったディスクとして現れます。単なるcatchブロック以上のものが必要です。異なる失敗を異なる方法で扱い、単一の不良ジョブからシステム全体を守るための戦略が必要です。
2種類の失敗
まず、すべてのエラーを2つのバケツのいずれかに分類することから始めましょう。
リトライ可能なエラーは一時的なものです。サードパーティAPIへのネットワークタイムアウト、429(レート制限)レスポンス、あるいはプライマリから遅延している一時的なデータベースレプリカなどです。これらはバグではなく、負荷の兆候です。システムは30秒以内に自己回復するかもしれません。リトライ可能なジョブには再試行の価値がありますが、それは制御された条件下であるべきです。
永続的なエラーはミスです。ペイロード内の無効なJSON、欠落したユーザーID、ストレージに存在しない必須ファイルなどです。これらは、1回目と同じように100回目の試行でも失敗します。これらをリトライすることは、CPUサイクルを浪費し、キューのスロットを無駄にし、正常なジョブを遅延させる有害なバックプレッシャーを生み出します。永続的な失敗にとって唯一有用な場所は、ログ、アラート、またはデッドレターキュー(DLQ)です。リトライループの中に置くべきではありません。
意思決定エンジンを構築する
即座に分類してください。この決定をキューのフレームワークに任せてはいけません。エラーをキャッチした瞬間に、その運命を決定します。
実践的には、エラーを上位に伝播(bubble up)させる前に、失敗の内容を検査するカスタムエラークラスやラッパー関数を作成することを意味します。データベースドライバーが接続リセット(connection reset)を投げた場合、ハンドラーはそれを「リトライ可能」としてタグ付けすべきです。ペイロードのバリデーターがスキーマの不一致(schema mismatch)を投げた場合は、「永続的」としてタグ付けします。多くのジョブプロセッサは、デフォルトですべてをリトライするように設定されていますが、これは最もコストのかかる選択です。永続的なジョブは即座に拒否してください。破棄するか、メインのパイプラインを汚染しないようにデッドレターキューへリルートします。この一つの習慣が、いかなるインフラの変更よりも確実にスノーボール効果を防いでくれます。
バックオフ、しかしよりスマートに
リトライを行う場合は、決して即座に行ってはいけません。データベースがダウンしているときに、ワーカーが一斉に1秒ごとに叩きにいくと、内部からのDoS攻撃のように見えてしまいます。指数バックオフ(exponential backoff)を使用してください。1分待ち、次に5分、次に15分と待ちます。アップストリームのシステムが回復するための猶予を与えてください。
しかし、指数バックオフだけでは不十分です。サービスの再起動によって1,000個のジョブが同時に失敗した場合、それらのリトライスケジュールが同期してしまいます。サービスがオンラインに戻ったとき、それらが同時にアクセスするため、再びサービスをダウンさせてしまう可能性があります。ここで「ジッター(jitter)」を追加します。つまり、各遅延に小さなランダムなオフセットを加えるのです。リトライのタイミングを数秒間に分散させることで、同期した「スタンプード(一斉押し寄せ)」を防ぐことができます。計算は単純ですが、それによって得られる安定性は絶大です。
証拠を保存する
デッドレターキューは監査証跡(audit trail)であり、ゴミ箱ではありません。ジョブが最後のリトライを使い果たしたとき、単に削除してはいけません。エラーのコンテキストとリトライ履歴とともに、ペイロード全体をDLQに移動させてください。
これにより証拠が保存されます。人間がジョブを調査し、バグを修正し、必要に応じて手動で再実行することができます。さらに重要なのは、DLQの深さ(溜まっている量)を監視することです。デッドレターになったジョブの急増は、不適切なデプロイ、失敗したスキーマ変更、あるいは外部ベンダーの仕様変更などを示す最も早い警告であることが多いです。DLQの増加を「遅行指標」ではなく「先行指標」として扱ってください。DLQが埋まり始めているなら、アップストリームで何かが変わっており、バックログが広がる前にチームが知る必要があります。
リトライを前提に設計する
すべてのジョブは、2回実行される可能性があるものとして設計してください。実際に起こり得ることだからです。ワーカーは処理の途中で失敗し、再スケジュールされて再び実行されることがあります。もしジョブが顧客への課金、メール送信、あるいは在庫数のカウントアップなどを行う場合、単純なリトライは重複を生んでしまいます。
解決策は「冪等性(べきとうせい)」です。副作用を実行する前に、それがすでに完了していないかを確認してください。ジョブのペイロードに含まれる一意の識別子を、冪等性キー(idempotency key)として使用します。そのキーを、有効期限の短いキャッシュ、または一意性制約(uniqueness constraint)を持つデータベースのテーブルに保存します。もしキーが存在していれば、その処理をスキップして成功を返します。これにより、リトライはリスクから、無害な「何もしない操作(no-op)」へと変わります。数行のコードを追加するだけで済みますが、それによって「なぜか一晩で収益が2倍になった」という理由を財務部門に説明する羽目になる事態を防げます。
プロセスを保護する
制限のない Promise の拒否(rejection)や予期せぬ例外(exception)は、警告なしに Node.js プロセスを停止させることがあります。ワーカーにおいて、それはジョブの消失と、コンテナを再起動しようと奔走するオーケストレーターを意味します。
unhandledRejection と uncaughtException のグローバルハンドラを登録してください。それらの役割はアプリケーションを救済することではありません。必要な最小限のクリーンアップを行い、終了することです。Docker、Kubernetes、または systemd に任せて、クリーンなメモリ状態でワーカーを再起動させましょう。グローバルハンドラが作動した後に無理に動かし続けることは、メモリリークや状態の破損を招きます。ジョブを誤って処理し続ける低速なゾンビプロセスよりも、速く、クリーンに終了する方が安全です。オーケストレーターが復旧させてくれることを信頼してください。破損したランタイムを無理に制御しようとしてはいけません。
シグナルを尊重する
ワーカーは、デプロイ、スケーリング、ノードのローテーション中にシャットダウンされます。もし SIGTERM を受け取った瞬間にプロセスが終了してしまうと、現在実行中のジョブが中断されてしまいます。そのジョブは二度と完了せず、リトライ回数すらまだカウントアップされていないかもしれません。
SIGTERM と SIGINT をリッスンしてください。シグナルが届いたら、キューから新しいジョブを取り出すのを停止します。可能であれば、現在のジョブを完了させてください。30秒程度のハードタイムアウトを設定し、それを過ぎたら状況に関わらず終了するようにします。この「グレースフルシャットダウン(graceful shutdown)」は、キューを尊重し、誤った失敗判定を防ぎます。デプロイメントパイプラインは、クリーンに終了したワーカーを「正常」として扱い、クラッシュしたワーカーに対してはアラートを出すべきです。
真の教訓
信頼性の高いキュー処理とは、すべてのエラーをキャッチすることではありません。失敗のモードごとに、意図的な判断を下すことなのです。一時的なエラーには根気強くリトライし、永続的なエラーは速やかに処理(破棄)してください。ワーカーをスタンプ(大量アクセス)から守り、冪等性キーでデータを保護し、終了するプロセスはクリーンに終了させましょう。すべての失敗に対して定義された経路があれば、午前3時であっても、それは単なる日常の一コマに過ぎなくなります。パイプラインは動き続け、チームは眠り続けることができるのです。
