注文は1件なのに、なぜワークフローは2回実行されたのか
一言でいうと
イベントを受け取ったという事実、ワークフローが実行されたという事実、注文が帳簿に反映されたという事実は、それぞれ別の証拠です。3つを同じ成功として扱うと、障害を見逃し、重複処理を生むことがあります。
なぜ必要なのか
架空の注文受付サーバーを運用していると考えてみましょう。ユーザーは注文ボタンを1回押しましたが、ネットワークが不安定で、レスポンスを見られませんでした。画面は再試行するよう案内し、ユーザーは同じ注文をもう1回送ります。サーバーには2つのHTTPリクエストが届きます。2つのリクエストを、それぞれ新しいイベントとして処理するイベントシステムでは、別々の実行が作られることがあります。ボタンを1回押したというユーザー体験が、サーバーでの1回の実行を保証するわけではありません。
逆方向の誤解もあります。Webhookは200で応答したのに、業務が開始されませんでした。フィルターが入力を拒否したのかもしれませんし、SensorにWorkflowを作る権限がないのかもしれません。この場合、クライアントのレスポンスだけを見て成功と報告すると、次の障害調査者は、最初から誤った仮定の上から出発することになります。
このラボは、実際の決済ではなく、小さな注文帳簿です。誤ったパスをわざと適用し、想定していない入力を送り、送信権限を抜いてみます。そのあとで、同じ注文を2回送って、業務の反映回数を比較します。失敗を経験することが目的ではなく、失敗した層を証拠で絞り込む方法を学ぶことが目的です。
どう動くのか
準備シグナルにも境界がある
EventSourceはHTTPリクエストを受けてイベントバスへ送り、Sensorはこれを購読して条件を評価します。Deploymentが準備できたというのは、Podの準備条件であって、特定のイベントの購読までがすべて終わったという意味ではありません。プローブでは、Podの準備直後に送った正常な対照群まで、実行されませんでした。このバージョンのJetStream接続の実装は、新しいコンシューマーが購読を開始したあとのメッセージを対象にします。したがって、最初のイベントを送る前に、該当するSensorのPodと購読状態をあわせて確認する必要があります。
ログの時制も読む必要があります。Subscribingは試行中という意味で、購読の呼び出しより前に記録されます。成功した購読のログと、実際のコンシューマーの待機状態を確認してから、最初の業務イベントを送ります。単に数秒を余計に寝かせる方法は、遅い環境で再び失敗することがあります。この確認は、ラボの出発点を合わせる手順であって、本番環境のすべてのイベント損失を防ぐ保証ではありません。
フィルターは入力契約の一部である
Webhookイベントの、HTTP本文は、イベントデータの中のbodyに入ります。本文にkindがあれば、データフィルターのパスはbody.kindです。body.event.kindと書いたなら、HTTP本文にeventという入れ子のオブジェクトが必要です。パスがないときの拒否を見るには、同じSensorが受け入れる正常な対照群も、一緒に成功させる必要があります。ワークフローが1つもないという事実だけで、フィルターが正確だと結論づけることはできません。
文字列フィルターは、正規表現で評価されます。order.createdというパターンのドットは、文字どおりのドットではなく、始まりと終わりも固定されません。実測では、pre-orderXcreated-tailも通過しました。正確な種類を1つ許可するには、ドットを文字として扱い、文字列全体を制限する必要があります。入力が増えるほど、許可の集合を広げる変更は、特に慎重に確認します。
type: boolも、厳密なJSONスキーマ検査と同じではありません。このラボのバージョンでは、ブール値のtrueだけでなく、文字列のtrueと数値の1も通過しました。JSONのブール値だけを許可する必要があるなら、変換結果ではなく、元の値の型まで確認します。ラボでは、Luaフィルターでこれを区別します。これは特定のフィールドの型の契約であって、リクエスト全体のスキーマ検証の代わりにはなりません。
複数の条件を組み合わせるとき、ORは1つ満たせば通過し、ANDはすべて満たす必要があります。種類が違っても、enabledがtrueだからという理由で、実行されてはならない業務なら、2つの間の演算子を検討する必要があります。存在しないフィールドのエラーも、1つの条件の拒否にすぎず、ORで結ばれた別の条件の承認を無効にすると仮定しないでください。
誰が作り、誰が実行するのか
SensorがWorkflowを作成するIDと、Workflowの中の作業が使うIDは、分けます。今回のKubernetesリソース作成トリガーは、送信アカウントにWorkflowのcreateだけを許可します。業務アカウントは、必要な作業結果の記録権限を持ちます。権限エラーをなくすために、2つのアカウントの両方に管理者ロールを結び付けると、どの権限が実際に必要なのかを学べず、被害範囲も広がります。
拒否されたイベントのHTTPレスポンスは、200のことがあります。そのため、Sensorの実際のforbiddenメッセージ、リクエストのトレースID、作成されたWorkflowの有無をあわせて見ます。最小権限で直したあとは、新しいイベントで復旧を確認します。すでに失敗した過去のイベントが、自動で再処理されると言うには、その過去のIDを別に観測する必要があります。
到着ID、実行ID、業務キー
同じ注文の2つのHTTPリクエストが、別々の到着IDを受け取るのは、不思議ではありません。2つのWorkflow UIDが違うという事実も、2回の注文を意味しません。業務キーは、リトライしても、同じ注文を指す必要があります。今回の帳簿は、注文IDを業務キーとして使い、すでに反映済みのキーかどうかの確認と、金額の反映を、1つのデータベーストランザクションにまとめます。
単純な実装では、2つの実行がどちらも金額を加算します。改善した実装では、2つの実行がどちらも正常に終了できますが、片方だけが帳簿を変更し、もう片方は重複として処理します。同じキーなのに金額が違う場合は、正常なリトライとして隠さず、衝突として拒否します。成功のレスポンスを読めなかったとしても、コミットした記録が残っていれば、次のリクエストが、業務の効果を増やさないことがあります。
現場での姿
業務ダッシュボードに、成功数を1つだけ置くと、どんな成功なのかがわかりにくくなります。HTTPの受付数、承認されたイベントの数、Workflowの完了数、業務の反映数に分けて考えてみてください。重複リクエストがあった時間には、これらの数字が違っていながら、システムは意図どおりに動作していることがあります。逆に、すべて同じ数字でも、誤った入力がすべて承認されていたなら、業務は間違っています。
今回の単一VMの帳簿は、高可用性データベースや、実際の決済システムではありません。同じSQLiteトランザクションの外で、メールを送ったり決済を呼び出したりすると、その外部の効果までは、アトミックにまとめられません。そのような要件には、外部サービスの冪等キー、outboxなど、別の配信の契約を検討する必要があります。ここで確認したのは、このラボのローカルの帳簿の効果であり、セッション終了後の保持や、すべての外部副作用の1回の実行を保証するものではありません。
次のラボですること
フィルターのパス・正規表現・型・論理演算を壊して、正常な対照群と比較します。実際の権限の拒否を、最小のRoleで直したあとで、重複リクエストでの2つの実行と、1つの業務効果を、それぞれ確認します。最後に、どの層を観測したのかと、まだ証明していないことを区別して、短い障害レポートを残します。
公式の参考: Data filter、Script filter、ServiceAccount、固定バージョンの購読実装、SQLiteトランザクション。