広告配信サーバーにおける DynamoDB Accelerator (DAX) 活用事例の紹介

メディアプロダクト開発部マーケティングサービス開発グループの我妻謙樹です。クックパッドにおける広告開発システム全般の新規開発・保守・運用を担当しています。

マーケティング事業全般やチーム体制については、前回の記事でご紹介しました。こちらを読んで頂ければ、メディアプロダクト事業部をめぐる組織体制や、マーケティングサービス開発グループの技術スタックについて概要を掴んでいただけると思います。

今回は、その記事でも触れた広告配信サーバーの技術的な取り組みについてご紹介します。その中でも特に、Amazon DynamoDB Accelerator (DAX) の活用に焦点を絞ってお伝えします。

背景

従来、広告をアプリ側で表示させるためには、マーケティングサービス開発グループがオーナーとして開発している広告 SDK を、クックパッド本体アプリに組み込み、非同期に広告配信サーバーにリクエストを行うことで実現していました。

今回、iOS アプリにて大きな仕様変更が行われることになりました。その新しいバージョンでは、"モダンBFFを活用した既存APIサーバーの再構築" で紹介されている、Orcha と呼ばれる BFF Server を通じて、必要なレスポンスをクライアントに返すことが決まっていました。そこで、広告を表示させるにあたって、従来のように非同期で直接広告配信サーバーにリクエストするのではなく、BFF Server としての Orcha の立ち位置を利用し、Orcha から広告配信サーバーを問い合わせるようにできないか、という議論が要件定義フェーズで生じてきました。

f:id:itiskj:20200220122635j:plain
アーキテクチャ概要

広告配信システム概観についてまとめた前回の記事 の時点では、広告配信サーバは、iPhone/Android/Web からの HTTP リクエストを直接受け付けることを前提に書かれた Web サーバーでした。したがって、既存の広告配信サーバーに手を加え、gRPC によるサービス間通信を受け付けるような実装拡張を検討しました。

しかしながら、要件定義が進むにつれて、既存の広告配信サーバーの機能拡張がパフォーマンスの観点から実現が難しい、ということが明らかになっていきました。

Orcha から広告を問い合わせる場合、広告配信サーバーの Latency が、ユーザリクエスト全体の Latency に影響します。具体的に言うと、Orhca の Latency が A ms, 広告配信サーバーの Latency が B ms あった場合、全体の Latency は (A+B) ms となります。ですから、広告によって本体のユーザ体験に悪影響を与えないために、Orcha に可能な限り早く広告を返すことが求められました。そこで、広告配信サーバーの SLO を「Latency」、SLI を「p95 で 15ms」を目標としました。

ところが、既存の広告配信サーバーの Latency は、p95 で 100ms, p99 で 120-140ms 前後でした。そして、当初のデータモデルの都合上、どう Scale-up/Scale-out したところで、目標とする SLI/SLO を達成できないことがわかりました。

というのも、既存の広告配信サーバーでは、AWS RDS (MySQL) + Elasticache (memcached) という構成でした。memcached はそれなりに早く、1 回の get command に対して 1-2 ms で返すことができていました。しかし、取り組むべき本質的課題は、既存のデータモデルのリレーションにありました。既存のビジネスロジックでは、広告抽選をするために、構造上 30-40 回、多いときには 50-60 回程度、 memcached に get command を送る必要がありました。すなわち、p95 で 100ms だったとすると、そのうちほぼ半数以上が memcached 部分で占めていることが観測できていました。

この時点で既存の広告配信サーバーでは、目標とする SLI/SLO を達成できないと判断せざるを得ませんでした。

解決策

そこで、以下の順番で広告配信サーバーをリプレースすることを決断しました。

  • refactoring
    • a-1. 過去の技術的負債を極限まで返済し、コード自体を削減
    • a-2. 仕様そのものを見直し、コード自体を削減
  • re-architecture
    • b-1. アクセスパターンを全て洗い出した上で、データモデルを根本から見直す
    • b-2. RDBMS (MySQL) -> NoSQL (DynamoDB) に移行

「a-1」および「a-2」は、調査的リファクタリング(exploratory refactoring)1の一環として、既存のビジネスロジックを理解すること、及び次の re-architecture のフェーズのリスクと作業コストを削減するための前準備として行いました。仕様そのものの要不要をディレクター陣と調整するといった、地道だが必要不可欠な取り組みも行いました。

「b-1」フェーズでは、既存の広告配信サーバーにおける全ての SQL 発行パターン、及び入稿から配信までの一連のデータフローの INPUT/OUTPUT を整理しました。そうして非正規化されたデータモデルを、「b-2」のフェーズに置いて DynamoDB に格納し、広告抽選を行うために 1 回の BatchGetItem を発行するだけで、既存のロジックを実現できるようになりました。

DynamoDB 自体は水平方向でのスケール性に優れ、数 ms でレスポンス結果を返してくれます。しかしながら、広告配信のターゲティング機能における DynamoDB の利用実績より、Latency に不安定性であることがわかっていました。具体的には、Max の Latency が 数百 ms 以上まで跳ねあがることが一日に数回の頻度で発生していました。それだけでなく、アプリケーションの性質上、読み込みの回数が多いため、DynamoDB への直接の操作回数を減らしてインフラコストを抑える必要がありました。更には、将来のサービスの成長に伴って、 容易に Read を scale-out できるようにしておく必要もありました。

以上の理由から、DAX に白羽の矢が立ちました。

DAX とは

DAX については、以下の特徴があげられます。

  • DynamoDB をバックエンドとすることに特化した in-memory cache store。
  • Single-leader 構成。Primary node がすべての Write を受け付ける。Replica nodes が Item を複製する。
  • DynamoDB への書き込みは Write-through, すなわち DynamoDB への書き込みリクエストの結果まで同期的に行う
  • キャッシュ戦略としては Least Recently Used (LRU) の他、Negative cache や TTL など必要最低限は実装されている

その他の詳細については、official developer guide の他、チームに展開した際の以下資料を参考にしてください

結果

結果からいうと、平常時で 5ms 前後でレスポンスを返すことができています。旧広告配信サーバーの平均 100~120ms と比較し、概ね 20x の改善を実現することができました。レイテンシが不安定でスパイクになっているのは、Cache の TTL が切れるタイミングだと判断しています。その場合でも DynamoDB にリクエストをしてせいぜい 10-15ms で返せています。

下記は負荷試験実行時の結果であり、まだ既存の全広告商品をリプレース後のサーバーが捌いているわけではないとはいえ、仕組み的には広告商品が増えても DAX/DynamoDB へのリクエスト数は増えません。したがって、ECS task の CPU/Memory や DAX instance type を、キャッシュに乗せるアイテム数(working set) に応じて正しく設定さえしていれば、この値からおおきくブレない想定です。

f:id:itiskj:20200220122716j:plain
Grafana Dashboard 抜粋

また、完全な移行によってインフラコストも大きくコストダウンできることを見込んでいます。詳細な計算は伏せますが、旧配信サーバーで利用している AWS RDS + AWS ElastiCache (memcached) から DynamoDB + DAX に移行した結果、月間で 100,000 JPY 前後のコスト最適化が実現できます。更に、アプリケーションサーバーも Ruby (Rails) から Go への移行をしているので、ECS Service の Running Tasks 数の削減によるコスト低下も見込んでいます。

コスト最適化の取り組みは、"インフラのコスト最適化の重要性と RI (リザーブドインスタンス) の維持管理におけるクックパッドでの取り組み"にて紹介されているように、SRE チームが技術力を結集して、最適化に必要な基盤の整備や情報提供を行ってくれています。この取組のおかげで、アプリケーション開発者としても要件を達成しつつインフラコストを最適化するための土壌が養われつつあります。

また、DAX Cluster が再起動中であったり、万一疎通不可能であった場合を考慮し、DynamoDB へリクエストを Fallback する仕組みを実装しています。DAX への操作は DynamoDB と透過的であるため、必要以上にアプリケーションコードに複雑性を持ち込むこと無く実現できたのも、DAX を選択下からの特長でしょう。

制約条件

しかしながら、DAX はもちろん銀の弾丸ではありません。癖の強いミドルウェアですので、本番に導入する際は、以下の制約条件を十分に吟味してから検討してください。

  • DynamoDB 以外のデータストアをキャッシュすることは不可能
    • ただし、複数 DynamoDB Table を使うことはできる
  • DAX SDK の品質およびサポート状況の精査
    • 例えば、Ruby の SDK は存在しないため、自前実装が必要
    • 例えば、aws-dax-go には実装されていない API が多数存在する 2
  • Single-leader 構成のため、書き込みワークロードが求められるアプリケーションの場合は性能に注意
    • 許されるなら Write-Around と呼ばれる書き込み戦略をとることはできる
  • TTL はすべての Item に共通であり、個別に TTL を設定することはできない
    • 例えば、「この Item は TTL 1min, この Item は TTL 60min」といった柔軟な TTL の設定ができない
  • TTL の変更のために Parameter Group を更新する場合、実行中の instance に適用することができない
    • ダウンタイム無しに適用する場合、別 Cluster を立てた上で徐々にリクエストを切り替え、旧 Cluster を落とす、といった運用が必須

運用・保守

次に、DAX を用いたアプリケーションの可用性を中長期目線で担保するための取り組みについて、以下の観点から紹介します。

  • Monitoring
  • Alerting
  • Runbook
  • Maintenance

Monitoring

DAX の CloudWatch metrics 一覧については Developer Guide から確認できます。そのうち、アプリケーションの性質および事業の優先度から、以下の metrics を優先的に監視することにしています。

Metrics Description
CPUUtilization % of CPU utilization
CacheHitRatio ItemCacheHits / (ItemCacheHits + ItemCacheMisses)
ThrottlingRequestCount # of requests throttled by the node or cluster
FailedRequestCount # of requests that resulted in an error reported
EvictedSize Check whether the working set is increasing or not

Metrics 一覧については、Grafana Dashboard にまとめています。ここでは、DAX に限らずアプリケーションの状態を一覧できるような状態を作っています。デプロイ前後の監視体制時や、障害時の原因切り分けに利用することが目的です。

f:id:itiskj:20200220125257j:plain
Grafana Dashboard DAX 関連パネル

また、策定した SLI/SLO も Grafana Dashboard に表示させています。これによって、コンテキストがわからない新規メンバーでもアプリの正常状態を判断し、中長期的な改善の良し悪しの判断に利用できる状態の達成を目指しています。

その他、github.com/prometheus/client_golang 3 を利用したアプリケーションの状態も Monitoring しています。DAX 関連でいうと、DAX への問い合わせ時に Goroutine/Channel を利用した実装をしているため、Goroutine の挙動や GC の状態なども同じ Dashboard から閲覧できるようにしています。必要に応じて custom metrics も計測できるので、CloudWatch metrics だけでは測れない項目を監視フローに導入するのも容易です。

f:id:itiskj:20200220122811j:plain
Grafana Dashboard Goroutine 数パネル

Grafana に CloudWatch Metrics を表示させる場合、Dashboard TemplateGrafana Labs > Dashboard に公開したので、そちらを参考にしてください。なお、一点注意としては、Grafana Dashboard 作成時における DAX metrics の補完機能は v6.6.0 にて追加 されています。

Alerting

Monitoring だけでは、ある日突然 working set が増加しアプリケーションが応答しづらくなったり、Goroutine を利用した実装不具合よる memory leak を発生させたり、といった事象に気づけません。そこで、以下の Alerting を導入しています。

CloudWatch Alarm

以下の 2 つの Metrics について CloudWatch Alarm を設定しています。

  • CPUUtilization
    • CloudWatch Metrics の値をそのまま利用
  • CacheHitRatio

通知については、よくあるパターンですが、以下の経路で開発メンバーの Slack channel に通知しています。

CloudWatch Alarm --> SNS Topic --> AWS Lambda --> Slack

社内では Terraform によって AWS リソースが管理されており、適用のみ一部の権限があるユーザーに絞っている、メンバーであれば誰でも閾値を変更したり、追加・削除したりできる状態を実現できています。

Runbook

チームでは Runbook を活用し、障害発生時でも、オーナーシップを持つ実装者以外でも Scale-up や Scale-out などができるような状態を目指しています。

Alerting 通知の際に Runbook URL を含めることによって、障害発生時において、ビジネス影響の確認から障害対応までの一連のフローを事前に分かる範囲でドキュメント化し、誰でも対応できる状態の達成を目指しています。

Alerting とは、通知の設定をすればいい、というものでは決してありません。アプリケーションの可用性を向上させ、事業の成長に貢献してこそ意味があります。Alerting が通知された後、適切なアクションやアプリケーションの修正を通じて初めて品質が向上するのであり、そこまでセットで考えてチームに導入しないと意味がありません。4

筆者にも、苦い思い出があります。以前、"cookpad storeTV の広告配信を支えるリアルタイムログ集計基盤" でご紹介したとおり、storeTV の広告配信ログ基盤において、ストリーム処理における Best Practices に則り、遅延ログの検出および Alerting の仕組みを構築しました。私がシステム構築後運用を他のメンバーに移譲した後、通知は来るものの、システムの設計思想や背景、アクションプランを適切に引き継ぎできていなかったため、割れ窓となってしまい、見るべきエラーを見落としてしまう、という状況を作ってしまっていました。

その時の反省を活かし、誰でも最低限の運用保守はできるようなチームの文化を築くことを目標に置きました。具体的な手順は勉強会で共有したり、その際のハマりどころを知見として展開すると行った取り組みも合わせて展開しています。更に、一度設定した閾値も、チームの状況、メンバーのスキル、アプリケーションの性質、サービスの成長に伴って柔軟に削除・チューニングできるよう、Metrics の意味の共有と目線合わせも、勉強会などの手段を通じて行っています。

Maintenance

DAX では、メンテナンス時のイベントを SNS Topic に通知させることができます。

When a maintenance event occurs, DAX can notify you using Amazon Simple Notification Service (Amazon SNS). To configure notifications, choose an option from the Topic for SNS notification selector. You can create a new Amazon SNS topic, or use an existing topic. https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DAX.cluster-management.html#DAX.cluster-management.custom-settings

Scale-up や Scale-out、TTL の変更のための Parameter Group 更新による再起動時など、DAX Cluster に何らかの構成変化がある場合、アプリケーションの監視体制に入る必要があります。こちらも CloudWatch Alarm と類似の以下の構成で開発メンバーの Slack channel に通知しています。

DAX Events --> SNS Topic --> AWS Lambda --> Slack

まとめ

以上、広告配信サーバーにおける DAX の活用事例について紹介してきました。

マーケティング領域は、技術的にチャレンジングな課題も多く、かつ事業の売上貢献に直結することが多い、非常にエキサイティングな領域です。また、アドネットワークではなく、自社の事業で専用の配信サーバーとユーザーデータを保持するからこその事業の面白さもあるため、事業開発に興味・関心が高い人にとっても活躍の可能性が大いにある場です。

メディアプロダクト開発部では、一緒に働いてくれるメンバーを募集しています。少しでも興味を持っていただけたら、以下からエントリーをしてください。



  1. 『レガシーソフトウェア改善ガイド』より

  2. d.unImpl() で確認できる関数は現在未実装

  3. https://grafana.com/grafana/dashboards/6671 に Prometheus client を利用して取得できる Metrics の Grafana Dashboard テンプレートが存在します

  4. 『入門 監視』3 章にも「監視とは、あるシステムやそのシステムのコンポーネントの振る舞いや出力を観察しチェックし続ける行為である。アラートは、この目的を達成するための1つの方法でしか無いのである」とあるとおり、Alerting という手段自体が目的とならないように意識したい

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/