
はじめに
エブリーでヘルシカのサーバーサイドの開発をしている赤川です。
ヘルシカでは、APIサーバーの監視をSentryを用いて行っており、開発中のプッシュ通知基盤にもSentryを導入することになりました。通知基盤が一度に処理するデータ量は、ユーザーの数に対して線形に多くなっていきます。そのため、どのように監視を行うかは重要な検討事項となります。
本記事では、今回実装したプッシュ通知基盤のアラート設計における自分の考えについて述べようと思います。
背景
ヘルシカのプッシュ通知基盤は、AWS LambdaがFirebase Cloud Messagingにメッセージを送る構成になっており、まとまったメッセージの集合をfor文で処理しています。通常の実装では、エラーが発生した時点で即座にループを停止することもしばしばですが、プッシュ通知ではできるだけ多くのユーザーに通知を届けることが重要です。そのため、一部のメッセージでエラーが発生しても、すべてのメッセージに対して処理を継続する設計を採用しています。しかしこの設計で単純にエラーごとにアラートを出してしまうと、一度に出るアラートの数が膨大になる危険があります。
アラート設計のアプローチ
これらの課題を解決するため、以下の2つの方針でアラート設計を行いました。これらはどちらか1つというものではなく、相互に関連し合うものだと考えています。
適切なエラーのグルーピング: エラーの種類ごとに適切に分類し、同じ種類のエラーを一つのIssueにまとめることで、問題の全体像を把握しやすくする
アラート数の最小化: エラーが大量に発生しても、アラートの数は最小限に抑える
適切なエラーのグルーピング
SentryのIssueグルーピングの仕組み
SentryはIssueという単位でエラーをグルーピングします。Issueを識別するのがfingerprintというキーで、通常はエラーメッセージやスタックトレースから自動生成されます。
大規模なプロジェクトでは、エラーの種類が多様でfingerprintを自前で管理することは現実的ではありません。対して今回のLambdaの実装は小規模で、エラーの種類も限定的です。そのため、fingerprintを明示的に管理することで、安定したグルーピングを実現しました。
// fingerprintの設定 type ErrorKey string // 例 const ( ErrorKeyFCMSend ErrorKey = "fcm_send_failed" ErrorKeyDDBSaveRequest ErrorKey = "ddb_save_request_failed" )
sentry-goパッケージでは、sentry.WithScope内でSetFingerprintを使うことでfingerprintを設定できます。
import "github.com/getsentry/sentry-go" sentry.WithScope(func(scope *sentry.Scope) { // fingerprintの設定 // fingerprintの型は`[]string`で、複数設定も可能 scope.SetFingerprint(fingerprint) // Sentryにエラーを送信 sentry.CaptureException(err) })
エラーメッセージの改善
その他、外部SDKを多用しているため、エラーメッセージだけでは何が起きたのか分からない場合があります。そのため、エラーメッセージにErrorKeyをプレフィックスとして付与し、エラーの種類を明確にしました。
アラート数の最小化
前述のとおり、エラーのたびにアラートを出していてはキリがありません。そこで、forループ内ではエラーを記録するだけでアラートは送信せず、処理完了後に1回だけ集約したエラーをSentryに送信する方式を採用しました。
初期案: errors.Join
最初に考えたのはerrors.Joinを使用する方法でした。
var aggErr error = nil for _, message := range messages { err := message.Send() aggErr = errors.Join(aggErr, err) } return aggErr
しかし、この方法には問題があります。例えば、10件のメッセージが2つのバッチで処理され、それぞれ3件と2件が失敗した場合は以下のようなエラーになります。
// バッチ1(3件失敗) failed to send message failed to send message failed to send message // バッチ2(2件失敗) failed to send message failed to send message
fingerprintでグルーピングはできますが、エラーメッセージが冗長で、影響度の把握が困難です。
改善案: エラー集約構造体
そこで今回は、エラー集約専用の構造体を設計しました。
type ErrorSample struct { RequestID string NotificationType string Error error } type ErrorAggregate struct { Count int Sample ErrorSample } type ErrorAggregates map[ErrorKey]*ErrorAggregate
この構造体により、エラーの発生回数や詳細をを効率的に管理できます。既にグルーピングは実現できているため、サンプルはひとまず1件で十分だという考えのもと構造体を定義しています。
メソッドの実装
構造体には以下の3つのメソッドを実装しました。
AddSample: サンプルが未設定の場合のみ、エラーサンプルを保存Increment: エラーカウントを1増加Emit: 集約結果をエラー種別ごとに1イベントだけSentryに送信
Emitは大体以下のように実装しています。SentryのTagを活用することで、影響度や通知種別などを追えるようにしました。
func (m ErrorAggregates) Emit() { for errKey, agg := range m { tags := map[string]string{ "error_key": string(errKey), "count": strconv.Itoa(agg.Count), "sample_request_id": agg.Sample.RequestID, "sample_notification_type": agg.Sample.NotificationType, } CaptureErrorWithContext( agg.Sample.Error, tags, []string{"push_to_fcm", string(errKey)}, ) } }
まとめ
今回のアラート設計により、以下を実現しました。
- アラート数の大幅削減: エラーが大量発生しても、アラート数は最小限に抑制
- 問題の迅速な特定: エラーの種類と影響度を一目で把握可能
システム監視は広大な分野であり、自分もまだまだ知らないことが多くあります。今後もより良いアラート設計を追求していけたらと思います。
最後までお読みいただきありがとうございました。