Diary of a Perpetual Student

Perpetual Student: A person who remains at university far beyond the normal period

SendGrid Event Webhookをcloudwatch-logs-aggregatorでメトリック化する(後編)

arthur-1 Mackerel Advent Calendar 2023ラソン17日目の記事です。(12/17に投稿予定でしたが都合がつかず本日12/18の投稿となります。)

以下の記事の後編として、cloudwatch-logs-aggregatorを使いたくなる用途やその未来、またcloudwatch-logs-aggregatorに求める機能拡張について書きます。

blog.arthur1.dev

1分未満の頻度で発生するイベントを集計してメトリック化したい

今回はSendGrid Event Webhookの情報をメトリック化する手法について紹介しました。しかし、ただメトリック化するだけなら、受け取ったWebhookの情報をもとににCloudwatch Logsを挟まずに同期的にメトリックを投稿してしまえば良いのでは、と思った方もおられるでしょう。

Mackerelのメトリックは1分粒度の解像度となっています。仮に1分間に2度メトリックを投稿しても、片方の情報は失われてしまい、すでに投稿された値との平均値や合計として投稿されるような挙動もありません。

ではSendGrid Event Webhookはどうかというと、Event1回につき1リクエストというほどではないですが、流量が多いと1分間の間にたくさんのリクエストが送られるようです。

Event Webhook - ドキュメント | SendGrid

イベントは現状30秒毎またはバッチサイズが768KBに達するか、いずれか早いタイミングでPOSTされます。これはサーバごとのため、Webhook URLは毎秒数十回~数百回のPOSTを受信する場合があります。

そこで、1分間に起こる多数のイベントを1分間の集計値としてMackerelに投稿するために、値をある程度保持しておく必要があります。Redisなどのデータベース持っておいて定期的にflushしつつ集計してMackerelに送るのも一つの手段ですが、Redisを用意するのは単純に高コストです。また、Webhookを受けるサーバーのインメモリに持っておくと、スケールアウト時に困ります。

そのためのバッファとして今回Cloudwatch Logsを選択したというわけです。cloudwatch-logs-aggregatorは単に既存のログを集計してメトリック化する目的だけでなく、高頻度で起こるイベントなどの情報を1分間隔で集計してMackerelに投稿するための手段としてもとても便利に使えます。

同様の手段は他にもある

同様に、1分粒度の集計値にしてからMackerelに投稿する手段はいくつかあります。

CloudWatchではないログ基盤でも同様に利用できる方法として、fluentd-plugin-mackerelがあります。バッファリングの役割をFluentdが担う形で、基本的な雰囲気はcloudwatch-logs-aggregatorと変わらないと思います。

github.com

2022年秋に開催されたMackerelチームの開発合宿で生まれたmackerel-statsdというアプリケーションもあります。 StatsDプロトコルでメトリックを収集し、集計してからMackerelに投稿するものです。

github.com

そして、現代のメトリック収集と言えばOpenTelemetry Collectorでしょう。mackerel-statsd同様にStatsDプロトコルでメトリックを集計するreceiverであるStatsD Receiverがopentelemetry-collector-contribに入っています。

github.com

Mackerelは(現在クローズドベータ版としての提供ですが)OTLP/gRPCプロトコルをサポートしており、OpenTelemetry Collectorで収集したメトリックをラベル対応メトリックとしてexportすることが可能です。

docs.google.com

また、将来はStatsDなどの特定のreceiverに頼らずにメトリックを集計するprocessorが実装されcontribの中に入るかもしれません。GitHubMetric Aggregation ProcessorのProposalが出ています。

cloudwatch-logs-aggregatorのここが惜しい

SendGrid Event Webhookでは、以下のようなイベントのJSON Objectが配列となって送られてきます:

{
  "email":"john.doe@sendgrid.com",
  "timestamp": 1337197600,
  "smtp-id":"<4FB4041F.6080505@sendgrid.com>",
  "sg_event_id":"sendgrid_internal_event_id",
  "sg_message_id":"sendgrid_internal_message_id",
  "event": "processed"
}

ここにtimestampというフィールドがありますが、今回作ったsendgrid-events-to-mackerelではこの値を尊重していません。ログに書き込まれた時刻をもとにCloudwatch Logsに問い合わせを行いメトリックにするための情報を得ています。

しかし、イベントが起こった時刻とWebhookを受け取ってログが書き込まれる時刻は一般に異なります。1分未満の誤差であれば気にすることはないのですが、大きな差が生じた場合には意図しない形でメトリック化されてしまうでしょう。たとえばSendGridの障害でWebhookの送信が滞留した場合、実際にイベントが起こった時刻とログの時刻に大きなずれが生じます。その結果グラフにも影響が現れるというわけです。

我々はログを書き込んだ時刻ではなくイベントが起こった時刻が知りたいです。このように自分たちが制御できない事象でメトリックが乱れてしまうと監視対象にするには心許ないです。

さて、cloudwatch-logs-aggregatorが時刻でログを絞り込む実装は以下のようなコードになっています。

mackerelio-labs/mackerel-monitoring-modules/cloudwatch-logs-aggregator/lambda/main.go#L270-L281

func withFilterByTimeRange(query string, timeRange *QueryTimeRange) string {
    var b strings.Builder
    b.WriteString("filter ")
    startMillis := timeRange.StartTime.UnixNano() / int64(time.Millisecond)
    b.WriteString(strconv.FormatInt(startMillis, 10))
    b.WriteString(" <= tomillis(@timestamp) and tomillis(@timestamp) < ")
    endMillis := timeRange.EndTime.UnixNano() / int64(time.Millisecond)
    b.WriteString(strconv.FormatInt(endMillis, 10))
    b.WriteString(" | ")
    b.WriteString(query)
    return b.String()
}

@timestampでフィルタしているところを拡張し、@timestampである程度のフィルタをかけて検索対象を絞った上で、実際のタイムスタンプは構造化ログの他のフィールドを見にいくようにして分ごと集計するような機能拡張ができたら、より便利に使えるかもしれません。