前回記事で書いたように、お届けチームの扱うシステム領域ではさまざまな非同期処理が行われています。
この記事では
- 非同期処理の採用するモチベーション
- 非同期処理の実現方法
を書いています。
- 非同期処理の採用するモチベーション
- 実現するためのoverview
- publish side
- subscriber side
- メッセージによる非同期処理を本番導入するまでに
- サンキューEventarc
- 次回に続く
非同期処理の採用するモチベーション
主には次の2つのような目的がある箇所で非同期処理を行なっています。
- 領域間をまたぐため
- 同期的な処理をミニマルにするため
非同期処理を積極的に採用するにあたり、同期処理とのトレードオフや監視すべきメトリクス等、運用する際のポイントはいくつかありますが、これらは後続の記事で詳しく解説する予定です。
「領域間をまたぐ」
まず前提として「ピックパック」「お客様注文」「配達」のような領域を見出したとします。 その領域を「お客様の注文内容に応じてピックパックが指示される」「ピックパックで商品を品切れにしたら、お客様との取り引き中の商品を品切れにより、お届け数を減らす」というようなケースで「領域間をまたぐ」としています。
領域をまたぐ際にはサービスが目指す方向性と相談し、狙って領域同士を疎結合にするメリットがあれば非同期処理を採用しています。
とくにカスケード障害がさけられるメリットの大きい箇所は進んで非同期処理にしています。
「同期処理をミニマルにしたい」
「同期処理をミニマルにしたい」ケースだと、
- ピックした商品をパック予定のものへ追加する
- 特定時間(便)におけるピッキングの進捗をサマリする
ようなケースがあります。
予定に追加では同期的にしても意味的には問題ないですが、ピックのAPIはとにかく素早くレスポンスを返したいので、クライアントアプリから呼び出されるRPCではピックのみ行い、パック予定への追加は非同期にしています。
RPCではパッキング用のデータは不要で、ピッキング用のデータは読み込み/書き込みで済みます。
- 「ピックができたら必ずパック予定の追加できる」ような前提条件を意図的に作る。
- 予定の追加が結果整合で問題ないかをUIや業務手順上確認する。
など、下準備があって非同期処理を実現できます。 おかげでピックのRPCは低いレイテンシを保てています。
サマリというのは主にFirestoreでデータ集計をするには都度クエリを実行では実現が難しく、事前に集計しておきたいケースです。 イベント駆動による非同期処理導入前ではRPCをハンドリングしたプロセス上でバックグラウンド処理を実行していました。 サマリというだけあり、データ操作の競合も多いのとサーバが停止するとサマリされないケースもありました。 単一プロセス内での非同期処理からシステム単位での非同期処理になったことで確実に処理することを可能にしました。
こういったイベント駆動な非同期処理はFirestoreとEventarc、Cloud Runの組み合わせで実現されています。
https://cloud.google.com/eventarc/docs/run/route-trigger-cloud-firestore?
実現するためのoverview
EventarcによってFirestoreの書き込みをトリガーをにして、ドキュメントを非同期メッセージとしてCloud Runに送りつけていますが、EventarcはCloud Pub/Subで成り立っています。 なので、監視やリトライ設定はCloud Pub/Subへの設定で行なっています。
またCloud Pub/SubのSubscriptionはPush Subscriptionで作られるので、Cloud RunへはプレーンなhttpリクエストとしてCloudEventsの形式にそって送られます。 簡単なメトリクス確認はCloud Run用のダッシュボードで賄えます。
一文で流れを書いてみると、「Firestoreに書き込んだドキュメントが、非同期メッセージとしてCloud Pub/SubにPushされ、CloudEventsの形式でCloud Runへhttpリクエストで送信されます」のようになります。
publish side
シンプルにFirestoreへ書き込みます。 非同期メッセージとして扱われるので基本的にCreateのみ起きるようにしています。 場合によってはDeleteをトリガーにしますが、非同期メッセージをイベントとして扱う際はCreateです。 また、非同期メッセージとして扱うドキュメントはイミュータブルなモノとして扱いためUpdateをしないようにしています。 イベントして扱う場合には発行時間を必ずペイロードに含める、イベントが起きた操作を追跡可能にする識別子を含めるなど一定型にはめて、イベントの発生を非同期メッセージで表せるようにしています。
subscriber side
http requestのbodyがCloudEvents の形式にそっていることを前提にしています。 subscriberにFirestoreのドキュメントを受けとることを前提にサーバを実装できる小さなライブラリを用意してます。 そのライブラリではpublishした際に付与する一意性のある識別子を利用してメッセージを重複して処理しないように制御したり、メッセージを順序正しく処理できるようなユーティリティも実装しています。 なので実装側はシンプルに処理したいイベントと処理そのものをペアでコードが書けるようになっています。
メッセージによる非同期処理を本番導入するまでに
今回説明した非同期処理は機能実装の最初のデプロイ時から利用していた訳ではありません。 大雑把に以下3ステップで本番環境での実現しました。
- gRPCのリクエストハンドラ内で同期的にイベントハンドラを実行する
- gRPCのリクエストハンドラ内で非同期的にイベントハンドラを実行する
- gRPCのリクエストハンドラ内ではイベントの永続化だけし、別プロセスでイベントハンドラを実行する ← 今回紹介した仕組み
1. gRPCのリクエストハンドラ内でプログラム上、同期的にイベントハンドラを実行する
この段階ではこの記事で説明している「非同期を導入するメリット」を受けられないわけです。
とはいえリクエスト数が多くなかったり、リクエストのパターンによって問題なく動作します。
ここでのミソは将来非同期処理を導入していこうと画策することでイベント
を処理するイベントハンドラ
で実装することです。
イベントハンドラと呼び出し側でイベント以外を共有しないことで、イベントハンドラの実行場所が変わっても同じロジックがそのまま動きます。
2. gRPCのリクエストハンドラ内でプログラム上、非同期でイベントハンドラを実行する
この段階は3. への準備段階です。 事前にイベント(メッセージ)の流量が本番同等で実行し続けられるか検証はしていましたが、3. の導入はどんなリスクがあるのか未知数でした。
そこでfeature flagを利用して2. と3. の切り替え(切り戻し)は即時でできるようにしました。 findy-tools.io
2, 3の並走している場合、二重処理が問題になるならイベントハンドラ側の仕組みで二重処理は抑制されます。
結果的に3の仕組みに問題がなかったので切り戻しをすることはありませんでした。
3. gRPCのリクエストハンドラ内ではイベントの永続化だけし、別プロセスでイベントハンドラを実行する
この段階に来ると記事内で紹介しているようにFirestoreへの永続化をトリガーにEventarc経由でCloud Runによりイベントハンドラが実行されます。 2ではプログラム上非同期であるものの、システム的には同期的にイベントハンドラが実行されていました。 それが3ではシステム的にも非同期になります。 なのでイベントを発生させるロジックからは完全に後続の処理を気にすることはなくなります。
サンキューEventarc
メッセージによる非同期処理を実現するのために参考としているのはアウトボックスパターンです。
https://microservices.io/patterns/data/transactional-outbox.html
FirestoreがEventarcを利用することでドキュメントの書き込みをトリガーをするのが容易で非常に助かったところでした。 MessageRelay実現のためにクラウド側の仕組みのトリガーによってやり取りできるのはデータベースをポーリングするより運用しているうえで手間や気にすることが少なくありがたいです。 サンキューEventarc!
次回に続く
次回も引き続きお届けチームによるイベント駆動設計への取り組みを紹介していきます。
お届けチームでは絶賛エンジニアを募集中です。カジュアル面談もwelcomeです。 ご応募お待ちしております。