はじめに

こんにちは!株式会社MG-DXで「薬急便(やっきゅうびん)」のバックエンド開発をしている藤垣です。

この半期に通知機能のマイクロサービス化のタスクに取り組んだので、本記事では通知マイクロサービスの設計についてお伝えします。

背景・課題

まず、リアーキテクチャ前のアーキテクチャ図を以下に示します。

リアーキテクチャ前のアーキテクチャ図
リアーキテクチャ前のアーキテクチャ図

弊社はアプリケーションをGKE上で、タイトルの通りマイクロサービスアーキテクチャで実装しています。リアーキテクチャ前は各マイクロサービスで通知のインフラ層の実装をしていました。

※上図の通知先は一部分のみ記載していますが、実際には計5種類の通知先があります。

このアーキテクチャには主に実装コストと拡張性という面で課題がありました。

実装コスト

リアーキテクチャ前の場合、通知を利用するマイクロサービスを追加する時はインフラ層まで実装する必要がありました。マイクロサービスの数が少ないサービス初期段階では、この点は許容できていましたが、通知機能を新たに実装する場合や通知先が増えた場合、通知処理のコード変更や動作確認に相当な工数をかけなければならない状況でした。

また、通知失敗時のリトライ処理やレート制限への対応といった非機能要件を考え始めるとインフラ層の実装は想像以上に工数がかかりそうです。

拡張性

リーキテクチャ前では通知機能の拡張性が非常に低い状態でした。今後登録アカウントごとに通知設定を変更できるようにする必要があったのですが、このままだと通知機能を実装しているすべてのマイクロサービスに対して変更を加えなければならないという状況になりました。

アーキテクチャ

リアーキテクチャ後の通知サービス全体のアーキテクチャは以下の通りです。

リアーキテクチャ後のアーキテクチャ図
リアーキテクチャ後のアーキテクチャ図

大きな変更点は以下の3つです。

  • 全マイクロサービスの通知をCloud Pub/Subに集約
  • 通知失敗時のエラーログをBigQuery SubscriptionからBigQueryにプッシュ
  • 通知の責務をnotifierに集約

この3点について詳しく説明します。

Cloud Pub/Sub

私が今回Pub/Subを選定した理由は主に以下の3つです。

  • 非同期通信
  • 再配信
  • デッドレター

非同期通信

Pub/Subを使うことで、利用側サービスは通知サービスの動作に依存することなく、ドメインロジックを実行することができます。

ただ、バリデーションに引っかかるような異常なデータがpublishされた場合、通知不可能にも関わらず利用サービス側のAPIはクライアントに成功したレスポンスを返してしまうので、そこは考慮する必要があります。

再配信

通知サービスで最も重要なことは確実にユーザーに通知を届けることです。正常なデータは限りなく100%に近い確率で届ける必要があります。Pub/SubにはSubscriberからAck(メッセージの受信確認)を返さない限りメッセージを再配信する機能が備わっています。何らかの理由で通知に失敗した場合、再配信機能で通知処理をリトライすることでユーザーに通知を届けられる確率が高まると考えました。また、アプリケーション側でリトライ処理を実装せずに済むのも選定した理由の一つです。

デッドレター

Pub/Subにはデッドレター機能があります。デッドレターを利用することで指定した回数失敗すると、そのメッセージはデッドレタートピックに配信され、以後再配信されることはなくなります。

デッドレターを設定しないとPub/Subのメッセージ保持期間の間再配信され続けてしまう可能性があるため、ほぼ必須で有効にした方が良い機能です。今回デッドレタートピックの送り先としてBigQueryに直接データを書き込むようにしました。これにより、失敗したデータを永続化することができ、どのようなデータが失敗しているのかを分析することができるような状態になります。(Too Matchかもしれないですが)

※デッドレターが失敗した場合に再配信され続けてしまうので注意

BigQuery Subscription

デッドレタートピックに送られたデータをBigQueryにプッシュするための機能として、今回BigQuery Subscriptionを選定しました。

BigQuery サブスクリプション  |  Cloud Pub/Sub ドキュメント  |  Google Cloud

これを使うことで、BigQueryにデータをプッシュするためのアプリケーションをわざわざ用意することなく容易にデータを取り込むことが可能になります。

ただ、BigQuery Subscriptionがデータを受け取るためにはPub/Subのスキーマ定義が必要です。スキーマ定義はProtobufとAvroのどちらかで定義することができます。弊社ではgRPC通信でProtobufを使用しているサービスがいくつかあるので、Protobufで定義することにしました。

BigQuery Subscriptionはプロダクトで初の試みだったのですが、アプリケーションを実装せずにBigQueryに書き込めるのは大きな利点だと思いました。

通知マイクロサービス

通知の責務を通知マイクロサービス(notifier)に集約させました。基本的にインフラ層は既存の実装をそのまま流用することができました。

Pub/Subのサブスクライバーを実装する上で気をつけるべきポイントは以下の2つです。

  • エラーハンドリング
  • 排他制御

エラーハンドリング

サブスクライバーではエラーハンドリングが非常に重要です。Pub/SubはAckを返さない限りメッセージ保持期間の間再配信されます。エラーの中にはAckを返すべきエラーとそうでないエラーがあります。例えば、バリデーションエラーは再配信されても確実にエラーになってしまうものなので、再配信されるべきではありません。エラーログを出力してAckを返すのが理想的です。再配信されるべきメッセージは確実にエラーになってしまうもの以外のエラーに絞るべきです。

これらを踏まえてエラーのパターンを洗い出して、それぞれエラーでAckを返すべきかそうでないかを判断する必要があります。Go言語で実装した例を以下に示します。

// 排他制御をWrapする.
err := s.locker.NamedLock(
	ctx,
	msg.ID,
	timeoutSeconds,
	func(ctx context.Context) error {
		// 通知処理
		return s.usecase.Notify(ctx, dn, time.Now())
	},
)
switch {
case
	// 通知処理成功後のステータス変更に失敗した場合は再実行しない.
	errors.Is(err, domain.ErrNotUpdateStatusCompleted),
	// 異常な認証情報であった場合、再実行しても失敗するだけなので再実行しない.
	errors.Is(err, domain.ErrInvalidNotificationAuthorization),
	return errors.Wrap(lib.ErrDoesNotRetry, err.Error()) // Ack
case
	// ロック取得済みは想定内の処理で異常系ではないため, エラーログは出力しない.
	// ただしもう一方のプロセスが正常終了する保証はないため, ackは返却しない.
	errors.Is(err, domain.ErrLockAlreadyUsedTimeout),
	// 通知送信に失敗した場合は再実行する.
	errors.Is(err, domain.ErrCannotSendNotification):
	return errors.Wrap(lib.ErrNoFatal, err.Error()) // Nack
}

排他制御

Pub/Subを扱う上で、排他制御を行う必要がある状況が考えられます。例えば、Pub/Subでは確認応答期限が切れた際に自動的に再配信を行うため、同じメッセージが複数のサブスクライバーで処理される状況が発生します。

今回はMySQLのGET_LOCKを用いて排他制御を行いました。GET_LOCKを用いた排他制御は、シンプルな手法ですが、課題もあります。

メリット

  • MySQLのビルトイン関数を利用できるため、容易に実装することができる
  • 異なるプロセス間でも排他制御可能

デメリット

  • ロックの獲得と解放を繰り返し行うため、パフォーマンスが低下する
  • 単一のMySQLサーバーに多くのコネクションを張る必要があるのでスケーラビリティに制約がかかる

感想

良かったこと

マイクロサービス化直後に開発した新機能でメール通知を利用することになったのですが、わざわざ通知のインフラ層を実装することなく、通知内容をpublishするだけでメール通知できたので、開発効率が良くなったのを実感できました。

改善点

notifierでは通知を送ることのみの責務を持っているため、どの通知先に送るのかという判定は現状では利用側サービスが行っており、通知先が増えるたびにその判定が複雑になってきたので、通知先の判定処理を別のマイクロサービス上で行うことも検討した方が良いと思っています。

まとめ

  • 全マイクロサービスの通知をCloud Pub/Subに集約
  • 通知失敗時のエラーログをBigQuery SubscriptionからBigQueryにプッシュ
  • 通知の責務をnotifierに集約

今回は上記の方針で設計しましたが、ベストプラクティスと言えるかはわかりません。ただ、途中にも記載したように、リアーキテクチャの効果も出てきています。これからさらに改善してより良いサービスを作っていきたいです。

最後までご覧いただきありがとうございました。

お気づきの点ございましたら@eiaou_fまでご連絡くださいませ。