はてなのシステムプラットフォームチームで SRE として働いている id:KashEight です。
この記事は、はてなの SRE が毎月交代で書いているSRE連載の 5 月号です。今回の記事は前回の 4 月号の記事 DMARC レポートを Mackerel + OpenTelemetry でいい感じに可視化する - 作成に至るまでの続きとなります。
前回:
Mackerel へ投稿 (前段階)
今回から Mackerel へ投稿するための実装を行います。 実装のための前段階の話から進めていきましょう。
投稿する要素を選ぶ
まずは Mackerel に投稿する要素を選びます。 投稿するにあたって、レポートデータ内容全部を投稿するわけにはいきません。 というのも、無作為にデータを投稿してもラベル数が多くなるほか、人が見るにあたって必要ないデータが増加し利用価値は低くなります。 そこで、現在ある Grafana ダッシュボードからどのデータが欲しいかというのを事前にヒアリングしました。 その結果が以下となります:
SPF Alignment の割合 - N日で何割みたいな感じで見たい
DKIM Alignment の割合 - N日で何割みたいな感じで見たい
DMARC Passage の割合 - N日で何割みたいな感じで見たい
Published Policies (as reported) - pct が正しく設定されているかを見たい
SPF Alignment Details - あると嬉しい。無くても困らない
DKIM Alignment Details - あると嬉しい。無くても困らない
DMARC Forensic - 定期的にここは監視しておきたい
Reporting Organisations - どのメールサービスで登録しているのかの指標として見ておきたいが、無くても困らない。
それぞれ簡易的に、該当する Grafana ダッシュボードの画像付きで説明します。 内容一つ一つの詳細は長くなるので RFC 7489 やそのほか前回の記事で参照しているサイト等を読んでいただければ幸いです。 また、画像内に含まれるドメイン等の一部センシティブな情報は都合上黒塗りにしております。
SPF Alignment, DKIM Alignment, DMARC Passage
SPF 及び DKIM の認証結果 (Result) やアライメント (Alignment)、DMARC が成功しているかを示す項目です。 割合データに関しては円グラフ、推移に関しては折れ線グラフで表現されています。
Published Policies (as reported)
DMARC レポートで公開されている DMARC ポリシーです。 こちらはテーブルデータと棒グラフで表現されています。
Alignment Details
SPF, DKIM 認証、アライメントについての詳細です。 こちらもテーブルデータと棒グラフで表現されています。
DMARC Forensic
Forensic Report の内容となります。 こちらはテーブルデータのみです。
Reporting Organisations
DMARC レポートを送信しているメール事業者となります。 Published Policies (as reported)、Alignment Details と同様、テーブルデータと棒グラフで表現されています。
parsedmarc で使用できる出力方法
parsedmarc では様々な出力が可能です。 例えば、json ファイルとしてローカルに書き出すほか、json ファイルを指定した S3 バケットに日付がついたプレフィックス付きで保存することが可能です。 json ファイルの出力例は以下の parsedmarc に関するドキュメントにも記載されております:
json ファイルのほか、今まで利用していた OpenSearch のほか Splunk、Kafka、syslog 等が利用できます。 実際に使える出力は以下から確認できます:
構成
上記を踏まえて構成を考えた結果、(このときは) 以下のような構成になりました:
まず、ECS 上で動いている parsedmarc が DMARC レポートを受け取ると json ファイルに変換して S3 バケットに格納します。
次に EventBridge Scheduler + ECS Task を使用して 1 日に 1 回、日本時間午前 9 時に S3 バケットに保存されている 3 日前の DMARC レポート (json ファイル) 群を取得、データ集計をします*1。
3 日前のレポートを取得するのは、DMARC レポートが送られる時間がメール事業者によって異なっている (場合によっては 1, 2 日遅れてくることがある) ためその対策です。
実際に傾向を調べた結果、起動開始時間の 3 日前に関する DMARC レポートは漏れなく全て揃っていました。
集計したデータは、OpenTelemetry に準拠したメトリック (ラベル付きメトリック) に変換して Mackerel に投稿します。
EventBridge Scheduler + ECS Task のほかに EventBridge Scheduler + Lambda、ecschesule、otel-collector を利用して集計する方法がありましたが、以下の理由から EventBridge Scheduler + ECS Task を採用しました:
- ECS Task だと parsedmarc が動いている ECS クラスタにまとめることができる。
- Lambda の場合、Lambda 用のハンドラ関数の用意、ランタイムのサポート期限等があるので、今後も含めたメンテナンスがあんまり良くない。
- スケジュール機能だけの場合、AWS は EventBridge Scheduler を推奨している。
- ecschedule は EventBridge Rules を利用しているので、消費するクラウドリソースが EventBridge Scheduler に比べ大きい。
- 同時に、ecschedule はタスク定義の更新に難がある (逆に言えばタスク定義の更新ができる ecspresso で要件的には十分であった)。
- スケジュールの作成/削除等は Terraform で IaC 化したので、スケジュールの変更も Terraform 側に集約したかった。
- otel-collector を利用する場合、コストが高くなる。
- アプリケーション + otel-collector という構成になるため、otel-collector 分のコストがかかる。
- otel-collector を使用するとなれば、スケジュールタスクにするより ECS サービスとして常時起動する構成の方が適している。
- しかし、投稿は 1 日 1 回のため何もしていない時間の方が長く、常時起動する必要性がかなり薄い*2。
Mackerel へ投稿 (実装)
構成が決まったところで実装へ入ります。 実装は S3 バケットから parsedmarc で出力された json ファイルを取得し、OpenTelemetry SDK を用いてメトリックとして集計、 Mackerel に投稿する実装となります。 また otel-collector を使用しない構成のため、Mackerel に直接投稿する実装になっております*3。 実装言語は、エコシステムやコード例も含めて一番充実しているであろう Go を選択しています。
OpenTelemetry の Reader を ManualReader にする
OpenTelemetry SDK には ManualReader と PeriodicReader があります。
前者はその名の通りメトリックを送出 (Export) する場合のロジックを開発者側で実装することができます。
ただし、メトリック集計と送出も開発者側で明示的に呼ぶ必要があります。
一方で後者は、開発者が決めた一定の周期でメトリックを送出してくれます。
ただし、こちらは一部設定 (Aggregation、Temporality 等) には ManualReader とは別の手法をとる必要があります。
今回は OneShot なメトリックを送出するアプリケーションということで ManualReader を選択しました。
以下のようなコードで Reader を作成しています:
import ( metricsdk "go.opentelemetry.io/otel/sdk/metric" ) func main() { meterExporter, err := newExporter(ctx) // 中略 reader := metricsdk.NewManualReader(metricsdk.WithAggregationSelector(meterExporter.Aggregation), metricsdk.WithTemporalitySelector(meterExporter.Temporality)) // ... }
また、ここで説明していない Exporter や Aggregation、Temporality については id:lufiabb さんが以下の Mackerel ブログで説明しております。
もっと詳細な情報は以下の OpenTelemetry SDK の仕様等にも記述してあります。
メトリックの集計
今回使用するメトリックの集計関数は全て Int64Counter を使用しています。 前述に書いたように OneShot なアプリケーションなので、非同期にする必要はありません。
また、メトリックの集計項目は以下のように分割しました:
- レポート送信者に関するメトリック
- Reporting Organisations を担う
- 送信ポリシーに関するメトリック
- Published Policies (as reported) を担う
- DMARC に関するメトリック
- DMARC Passage を担う
- SPF に関するメトリック
- SPF Alignment、SPF Alignment Details を担う
- DKIM に関するメトリック
- DKIM Alignment、DKIM Alignment Details を担う
同時に、メトリックの属性 (attribute / ラベル) にはレポートで報告されている内容に加え、service.domain というラベルでドメイン名を入れています。
これの効果は後述するとして、実際に S3 バケットから取得した json データをラベルに変換するコードの一部を以下に抜粋します。
import ( "strconv" "go.opentelemetry.io/otel/attribute" ) func (r *DMARCReportJSON) GetAttributeForPolicyPublished() []attribute.KeyValue { var adkim, aspf string if r.PolicyPublished.ADKIM == "r" { adkim = "relaxed" } else { adkim = "strict" } if r.PolicyPublished.ASPF == "r" { aspf = "relaxed" } else { aspf = "strict" } pct, _ := strconv.Atoi(r.PolicyPublished.Pct) return []attribute.KeyValue{ attribute.String("service.domain", r.PolicyPublished.Domain), attribute.String("domain", r.PolicyPublished.Domain), attribute.String("adkim", adkim), attribute.String("aspf", aspf), attribute.String("p", r.PolicyPublished.P), attribute.String("sp", r.PolicyPublished.SP), attribute.Int64("pct", int64(pct)), attribute.String("fo", r.PolicyPublished.Fo), } }
この GetAttributeForPolicyPublished メソッドは Published Policies (as reported) を担うラベル付けを行っています (わかりやすいように adkim、aspf の値を正式名称にしたり、pct を Int64 として扱ったりしてます)。
このようなラベル付けを上記の集計項目全てに行いました。
Mackerel カスタムダッシュボードの作成
実装が終わったら Mackerel のカスタムダッシュボードを作成し、実際に見れる形にします。 ラベル付きメトリックとして投稿しているので式やグラフは PromQL を用いたクエリを使用して作成します。 実際に以下のようなさまざまな指標を Mackerel のダッシュボードから見ることができます。
ダッシュボードでは、service.domain ラベルを付与したことでドメインごとに指標を作成することができます。
上記の画像でも service.domain="<ドメイン名>" というクエリでドメインを絞っていることがわかります。
また、推移となる折れ線グラフは offset -3d とすることで基準時間を 3 日分ずらし、現実の DMARC レポートが送信された日と合わせている工夫もしております*4。
カスタムダッシュボードの IaC 化
作成したカスタムダッシュボードを DMARC レポートが送られた全てのドメインに対して作成するのは手でやろうと思うと骨が折れます。 加えて、今後メールアドレスとして使用するドメインが増えたり減ったりする可能性があり、その度に作成作業や削除作業を繰り返さないといけないと考えると、カスタムダッシュボード作成は自動化したいです。
そこで、terraform-provider-mackerel を使用してカスタムダッシュボード作成等を IaC 化しました。
ドメイン名は以下のようなローカル変数として定義することで、terraform apply だけでカスタムダッシュボードの作成や削除を簡単にできるようにしてます。
locals { service_domain = ["foo.example", "bar.example"] }
追加する場合は以下のように変更するだけです:
locals { service_domain = ["foo.example", "bar.example", "baz.example"] }
躓き点
ここからは実装にあたり躓きが発生した部分をピックアップします。
ラベルの組み合わせ爆発
前章にある項、投稿する要素を選ぶにて以下のような記述をしました:
無作為にデータを投稿してもラベル数が多くなる
このことに配慮してデータを選んだのですが、それでもラベルの組み合わせが多くなってしまい、場合によっては 1 つのデータから 100 個以上のメトリックになってしまうことがありました。 これに関しては特に SPF や DKIM 周りのメトリックで顕著に現れました。
原因としては、当初 SPF、DKIM メトリックのラベルには発信元のメールサーバである Envelope From を示す envelope_from というラベルをつけていたのですが、これの数が多く、いわゆる組み合わせ爆発を引き起こしていました。
今回の場合、Envelope From を提示する必要性は薄いと判断してラベルから外したことで、メトリック数を大きく削減させることで対策しました。
Envelope From を提示する必要性は薄いと判断したのは以下の理由です:
- アライメントが fail している場合、Envelope From と Header From が異なることがわかるため。
- 異なることがわかるならば、ラベル数が少ない Header From のみで十分なはず。
- Envelope From を提示されるメリットとメトリック数が多くなるデメリットを比較したとき、後者が大きかったため。
- 今回の場合、利用者側は Header From との差異がすぐわかる、発信元のメールサーバがわかるというメリットありますが、そこまでして欲しいかどうかと考えたところ、わかったところで何か次のアクションに繋げるまでいかないのではと判断しました。
Mackerel での表示粒度
サービス提供初期、当初の構成通りに EventBridge Scheduler を 1 日 1 回の起動にしていました。 しかし、表示期間を 5 日以上にするとデータが正常に表示されないという現象が発生しました。
これの理由として実際に Mackerel チームに尋ねたところ、表示粒度の問題で投稿頻度が少ないとデータ集約の関係上データが無いものとして扱われるという回答を受けました。 そして、一旦投稿頻度を 5 分おきにすることを提案されました。 実際 5 分おきに投稿するようにしたところ、表示期間を変更しても正常に表示されるようになりました。
ただ、5 分おきに投稿するとなるとアライメントの成功率が変わったりや S3 バケットからのダウンロード量が増えたりします。
前者は成功率等の計算をするべきかを決めるラベル calculatable を新しく付与して、true の場合のみ計算させるという仕様に変更しました。
このラベルは 1 日で投稿されるメトリックのうち 1 個しかつきません。
後者は、キャッシュを利用することで従来の約 4 分の 1 の転送量で S3 バケットから転送できる仕様にしました。
以下の画像を見れば一目瞭然ですが、一定間隔でダウンロード量が一気に上昇、降下した後値に変化がないことがわかります。
ダウンロード量のスパイクが起きる時間は基本的に日本時間午前 9 時、GMT 基準で午前 0 時になってます。 新しいデータを取得し、その後キャッシュとして保存、利用する形となっております。
RFC 非準拠なレポート
これに関しては少々悩ましい話です。 これが一番顕著に現れている例は DKIM セレクタです。 この DKIM セレクタを例にこの躓きを説明します。
前章にあるカスタムダッシュボード画像や Grafana ダッシュボードの DKIM Alignment Details を見た際、selector に none という文字で pass しているものがかなりあります。
これは実際には設定されているものではないため、本来なら fail するべきものです。
実際に none が出ている理由について、parsedmarc のソースコードを読んだところ以下の部分が該当しそうとなりました。
for result in auth_results["dkim"]: if "domain" in result and result["domain"] is not None: new_result = OrderedDict([("domain", result["domain"])]) if "selector" in result and result["selector"] is not None: new_result["selector"] = result["selector"] else: new_result["selector"] = "none"
これだけ見ると、selector が指定されてない場合 none となるため、順当に考えれば普通な処理ですがそれにしても pass するのは非常におかしいです。
そこで、DMARC レポートそのものについて調査したところ以下の記事が引っかかりました。
DMARC レポートが RFC 7489 に準拠しているかを調査した記事になります。 実際にデータとして集計しており、今回送信されてきたメール事業者もこの一覧に入っています。 それで確認したところ、複数の事業者で以下の文言がありました:
Missing elements or invalid values
- DKIMAuthResultType: selector
つまり、selector がない場合や不正な状態でレポートが送られてきます。
これなら、none になるのも納得できる…わけではありません。
この記事は 2019 年 7 月に書かれた記事ですが、今でもそれが続いているということです。
RFC 7489 は Informational であって Internet Standard でないことはわかりつつも色々もやもやします。
ただ、これに関してはメール事業者が対応するべき話であるため、当面の間 none セレクタのレポートは許容するしかなさそうです *5。
Future Work / 今後の課題
ここでは、今回の実装でできなかったことや今後ここは改善できそうなことを述べていきます。
ECS Task が失敗したら再試行する
S3 バケット -> Mackerel への投稿は前述のように EventBridge Scheduler + ECS Task を使用していますが、ECS Task の場合、ECS Task の実行に失敗したかどうかは CloudTrail のログからはわかるものの、再試行自体は EventBridge Scheduler からはやってくれません。 これは途中で Step Functions を挟むことで解決したいと考えています*6。
Forensic Report の通知
今回の実装では (必要と言われているのに) Forensic Report の扱いは実はできていません。 これは Forensic Report は余程のことがない限り送られてくることはなく、加えて、実際のメールをデバッグすることの難易度の高さから実装できなかったというのが理由です。 実際、実装中は Forensic Report はこなかったため、S3 バケットにも 1 つも保存されていない状態でした。 また、デバッグのためのアカウントを作成しようと思っても、そのためにアカウントを新しく吐き出し、DMARC レポートの転送設定をする必要やアカウントパスワードの保存先、誰がアクセスできるか等の問題もあるためこれに関しては実装するのは大変そうと考えています。
おわりに
長くなりましたが DMARC レポートを Mackerel + OpenTelemetry でいい感じに可視化するまでどのような経緯があったのか、そして、どのように作成したのかを書きました。 実際に作成した感想として、案外すんなりいけるもんかなと手を出したはいいんですが、DMARC、OpenTelemetry などなどそれぞれに様々なハマりどころがあって作成期間が自分の予想より伸びてしまったと感じております。 しかしながら、これを通して普段触ることの少ない DMARC を含めたメールの知識や、今後の業務で使うであろう OpenTelemetry の知識、そのほか「プロダクト」として何をどのように進めるべきかというのを強く学べる機会だったかと思っております。 この DMARC レポートの可視化は社内でもっと有効活用されるように改善を進めていきたいですし、他の人も巻き込んで利用できるようなものに今後なってほしい気持ちです。
*1:日本時間午前 9 時なのは、GMT に合わせるためです。ただ後述しますが、現在は 5 分に 1 回に変更しております。
*2:ECS サービスとしてリアルタイムで投稿、Mackerel 側で PromQL を利用した表示も考えましたが PromQL だけでは実現できないとわかったため断念しました。
*3:参考: No Collector | OpenTelemetry
*4:前章の節、構成の部分で説明した通り、DMARC レポートが完全に収集される期間が 2, 3 日かかることを考慮しそれに基づいた実装を行なったため、このように基準時間を変更しています
*5:実際に、読み方に関する社内ドキュメントでセレクタに関してどうしてこうなっているかの説明を付与しております。
*6:参考: Step Functions で ECS タスク(Fargate)を定期実行してみる | DevelopersIO