大規模配信に耐える広告新商品「材料ジャック」の設計と開発

こんにちは! メディアプロダクト開発部の名渡山 ( @pndcat ) です。

業務では広告システム全般の新規開発・保守・運用を担当しています。 本稿では、クックパッドが企業向けに販売している広告商品の開発について紹介します。

クックパッドの広告プラットフォーム

クックパッドの広告は、ネットワーク広告と、自社でシステムを開発し、企業が枠を一定期間買い取り掲載をする 純広告 があります。 過去に Header Bidding 導入によるネットワーク広告改善の開発事情Prebid.js 導入による Header Bidding 改善の舞台裏 などでネットワーク広告に関する投稿があったのですが、今回はクックパッド純広告について紹介します。

クックパッド純広告の商品は多岐にわたります。 企業の商品紹介ページに遷移する通常のバナー広告はもちろん、商品を使ったレシピコンテストなど、クックパッドならではのメニューを揃えています。 中でも人気のある商品が「カテゴリジャックバナー」です。 これはクックパッドで特定のキーワードが検索されたとき、ページ内の広告すべてを1社独占 (ジャック) で表示するものです。

f:id:marin72_com:20210730140635p:plain:w170

材料ジャックとは

2019年に、純広告の商品として「材料ジャック」を開発しました。 材料ジャックは、ターゲティングしたい材料が使われているレシピページに広告をジャック配信する商品です。 例えば、お酢の広告を出したい場合でも、「お酢」というキーワードで検索するユーザーは少ないので、先述のカテゴリジャックバナーは不向きです。 しかし、検索キーワードではなく、レシピの材料に連動する材料ジャックならば、「お酢」が使われる様々な種類のレシピで広告を出すことができます。 調味料など検索頻度が少ないキーワードに対してターゲティングをしたいニーズに応えた商品が材料ジャックです。

f:id:marin72_com:20210730140807p:plain:w170

基本的な入稿と配信について

材料ジャックの設計に踏み込む前に、クックパッドの広告配信システムの概観について説明します。 ユーザに広告を表示するまでに必要なものは、広告の入稿と、広告の配信の2つがあります。

入稿

クックパッドではバナー広告やカテゴリジャックバナーの配信を行うために、入稿担当者が商品名や画像、リンク先、ターゲティング情報などを入稿します。 入稿用のアプリがこの情報をクライアント (Webブラウザなど) 用の JSON に変換し、配信 DB に保存します。

f:id:marin72_com:20210730001506p:plain:w500

配信

配信サーバーはクライアント (Web/Android/iPhone) から HTTP リクエストを受け、検索キーワードなどのターゲティングを考慮しながら広告を抽選します。 抽選された広告の JSON をクライアントに返し、クライアントが JSON からビューを組み立て、広告を画面に表示する、という仕組みになっています。

f:id:marin72_com:20210730001726p:plain:w300

材料ジャックの課題

冒頭でも述べましたが、材料ジャックは、レシピページの材料欄にターゲティングしたい材料があるときに広告を表示する商品です。 言葉にすると簡単そうですが、実際には

  • 既存の広告入稿・配信の仕組みを変更しない
  • 材料名の表記ゆれに対応する
  • 入稿者の作業内容を大幅に増やさないようにする
  • 既存の広告のレイテンシを保ったまま、材料ジャックも配信する
  • 材料ジャックの障害が起きたときに、通常の広告配信には影響が出ないようにすぐに切り戻しができる

といった大きな課題がいくつもありました。 また、今回は設計から実装まで1ヶ月の短期スケジュールでの爆速開発が求められたため、手戻りが起きないようにきちんと設計をする必要がありました。 これらの課題をどうやって解決したのかを紹介します。

入稿実装

材料名の表記ゆれへの対応

既存のカテゴリジャックでは「ジャックしたいキーワードが、検索キーワードに含まれている時広告を表示する」というルールベースの広告です。 例えば、きんぴらに対するカテゴリジャックを行う際は「きんぴら」「キンピラ」「金平」のキーワードを登録します。

材料ジャックは、レシピに書いてある材料を元に広告を表示するので、カテゴリジャックと比べてはるかに多様な揺れに対応できる必要がありました。 例えば、塩の場合は、「しお」や、漢字の「塩」や、別表記の「食塩」、記号が含まれている「★塩」など、250種類以上のパターンが存在します。 カテゴリジャックで用いられたようなルールベースの方法を用いると、さまざまなパターンの材料名で登録する必要があり、非常に手間が大きく困難な作業になります。

表記ゆれの対応をするために最初に思い浮かんだことは、材料名の中から記号を取り除くデータクレンジングを行い、形態素解析ツールである Mecab を使う手法でした。 しかし、機械学習グループに相談したところ、機械学習グループが開発した材料名正規化テーブル を使って名寄せすることを提案してもらいました。

材料名正規化テーブル

材料名正規化テーブルでは、正規化後の材料名、ユーザが入力する材料名、正規化後の材料名に対して一意に定まるID (concept_id) の3つのカラムがあります。 このテーブルを使うことで、レシピの材料名から正規化後の材料名と concept_id を取得することができます。 正規化の精度*1 は70%〜ですが、調味料はほぼ100%になります。 よく使われる材料名はたくさんのパターンの材料名が登録されており、例えば塩に対応する材料名は251行存在します。

正規化後の材料名 ユーザ入力する材料名 concept_id
100
しお 100
食塩 100
★塩 100
材料名正規化テーブルを使った材料ジャック入稿

入稿時に材料名の表記ゆれパターンを網羅させるのは現実的ではないので、材料名正規化テーブルを採用しました。 材料名正規化テーブルを使うことで入構の手間を減らし、材料のカバー率も上げることができました。

広告 登録するジャック対象 ターゲティングするデータ例
カテゴリジャック 検索キーワード 塩(完全一致でカテゴリジャックが発動)
材料ジャック 材料の concept_id 100 (100に対応する「塩」「しお」など多くの表現で材料ジャックが発動)

最終的な材料ジャックの入稿実装

入稿の全体図はこのようになりました。 配信 DB には、広告の JSON を書き込みます。 配信キャッシュストアには、材料名をキー、concept_id を値とした材料名正規化テーブル(2万件)をまるごと書き込みます。 というのも、材料名正規化テーブルは Redshift 上にありオンラインでクエリをすることができないためです。 また、詳細は後述しますが、広告の抽選を高速化するためという狙いもありました。

f:id:marin72_com:20210730001749p:plain:w500

配信実装

先述したとおり、ターゲティング条件として登録されているのは材料名そのものではなく、材料の concept_id です。 広告の配信サーバーにおいては、いま表示しているレシピに含まれている材料名が材料ジャックを発動させるかを判定するために、広告を抽選する前に材料名を concept_id に変換する必要があります。 そこで、配信サーバーは先ほど配信用キャッシュストアに入れておいた材料名正規化テーブルを使って材料名から concept_id を引きます。こうすることで、DB を介することなく高速に材料名と concept_id の変換を行えます。 concept_id を求めたあとは、通常の広告の配信と同じフローになります。

f:id:marin72_com:20210730002011p:plain:w400

さらにキャッシュストアを活用した実装

材料名から concept_id をキャッシュストアから引くこと以外にも、

  • recipe_id から concept_ids (concept_id の配列 = 材料名の配列) を引く
  • recipe_id を key に、concept_ids を値にキャッシュする

処理を加えました。 この2つの工程を追加することで、さらにキャッシュを活用し高速に concept_id を求めることができました。

recipe_id を key に、concept_ids を値に持つようなテーブルとは、以下のような表です。

key (recipe_id) value (concept_ids) 含まれている材料
1 [100, 200, 300] 塩、豚バラ、サラダ油
2 [] 材料がない、もしくは、材料名正規化テーブルにはない表記の材料名のみ使われている

事前にすべての recipe_id の concepte_ids をキャッシュすることができればよいのですが、cookpad 全レシピ (350万以上) の concept_ids をキャッシュするのは現実的ではないため、アクセスがあったときにキャッシュをすることにしました *2

初回アクセス

配信サーバーはキャッシュストアに recipe_id から concept_ids を引きにいきますが、初回アクセスではキャッシュされていないため concept_ids は返ってきません (図: 赤線部)。 次に、材料名で concept_id を引きます。 次回以降 recipe_id から concept_id で引けるように、recipe_id をキー、concept_ids を値としたハッシュをキャッシュしておきます (図: 青線部)。 レシピに concept_id が一つも含まれていない場合は、空配列をキャッシュします。 最後は配信 DB に広告をリクエストし広告 JSON を受け取り、クライアントに返します。

2回目以降のアクセス

配信サーバーからキャッシュストアに recipe_id を投げると concept_ids が返ってきます (図:赤線部) 。 材料名を一つひとつ concept_id に変換することなく、すぐに配信サーバーは広告を抽選することができます。 また、recipe_id を投げて空配列が返ってくる際は、レシピに concept_id が含まれていないと判断できるため、空配列をキャッシュさせるのも重要です。 これらの工夫によって、材料ジャック広告追加後もレイテンシをキープすることに成功しました。

f:id:marin72_com:20210730002038p:plain:w400

材料ジャックの入稿から配信のすべての実装

こちらが入稿から配信までの全容になります。 いろいろな課題があった材料ジャックですが、材料名正規化テーブルの使用やキャッシュストアの利用により、レイテンシを保ちつつ、既存実装を変更なく追加実装のみで実現することができました。

f:id:marin72_com:20210730002112p:plain:w600

設計で考慮したこと

入稿・配信の実装を書きましたが、最後に設計時に考慮したことを紹介します。

使用するデータやミドルウェアの事前調査

まず、配信サーバーが使っている ELB と Nginx のヘッダーサイズの制限と現在の使用量を確認しました。 ELB の制限は 16KB、Nginx の制限は 8KB で、現在のヘッダ使用量は 4〜5 KB であるため、材料ジャックにおいては最大 1KB 程度使用しても問題ないと結論しました。 次に、材料名に関する調査を行いました。 クライアントから配信サーバーへ材料名を送信するといっても、1レシピに対してどのくらいの材料があるのか、材料名を連結するとどのくらいの長さになるのかということはネットワーク通信を考える際に重要な項目です。 材料数20個以下のレシピが全体の約99%、材料名の連結バイト数は 350Byte 未満のレシピが全体の約99%であることがわかりました。 そこで今回は、材料数20個以下、材料名を 350Byte までの材料を配信サーバーに送信することにしました。

f:id:marin72_com:20210730002500p:plain:w300 f:id:marin72_com:20210730002447p:plain:w300

キャッシュ戦略

ひとくちに『キャッシュ』と言っても、何をキャッシュするか、生存期間をどの程度に設定するか、などによってそのパフォーマンスは大きく変わります。材料ジャックの設計にあたって、考慮した点を紹介します。

配信サーバーはクックパッドのアクセスと同量のリクエストがある、広告の表示とインプレッション・クリック数の集計機能をもつ重要なサービスです。 大量のリクエストを捌くために Amazon ElastiCache (Memcached) を使用しており、ほとんどのリクエストはキャッシュで返しています。 材料ジャックの実装で雑にキャッシュを使ってしまった結果、キャッシュが溢れて広告全体のレスポンスに影響があるということも考えられます。 そこで、AWS 公式の ElastiCache のモニタリングすべきメトリクス を参考に事前調査を行いました。 CPU Utilization, Swap Usage, Evictions, CurrConnections のすべてを調べたところ ElastiCache には余裕がありました。 そこで今回は、材料名正規化テーブル (2万件) と、アクセスのあった一部のレシピのみキャッシュをしました。 必要最低限のものをキャッシュすることで、キャッシュも溢れることなく、配信サーバーは高速にレシピから concept_ids へ変換することができました。

キャッシュを使ったメリット・デメリット

材料名正規化テーブルをキャッシュストアに全部乗せて、配信 DB との通信の抑制を図ったことに関するメリット・デメリットは以下のようになっています。

メリット
  • パフォーマンス観点:配信 DB に問い合わせることがないので、DB への負荷が変わらない
  • 監視運用観点:新しいミドルウェアを追加していないので、監視対象が増えない
  • リスク観点:材料ジャックで障害が起きても (実装ミスにより concept_id が引けない、recipe_id のキャッシュができないなど) 既存の純広告やネットワーク広告は表示できる
  • 開発効率観点:従来のターゲット配信の仕組みに乗せることができた
デメリット
  • 材料名を変更しても、キャッシュが切れるまでは材料ジャックに反映されない
  • 材料名正規化テーブルのキャッシュが消えた場合、材料ジャックが表示されなくなる

上記2点のデメリットがあるのですが、材料ジャックは長期売の商品であることと、新しいレシピの追加、既存レシピで材料の変更に即追従する必要はないという品質 (=条件にマッチした場合 100% 掲出保証ではない) の商品なので、材料名正規化テーブルはキャッシュにのみ乗せることにし、配信 DB へのアクセスは最低限に抑えることにしました。 今回の開発を通じて、商品の特性に合わせて設計をすることが大事だと学びました。

今回は実施しなかった他の案について

本実装では材料一覧をリクエストヘッダーに含める方針を取りましたが、Pantry (クックパッドの API サーバー) にアクセスをしてレシピ ID から材料一覧を取る手法もありました。 この方針であればリクエストヘッダーサイズは小さくできますが、引き換えに Pantry へのリクエストが急増してしまいます。Pantry はクックパッドのサービス全体を支える API サーバーなので、キャパシティの調整やパフォーマンスへの影響の検証を入念に行う必要が出てきてしまいます。 検討の結果、影響範囲の最小化と工数の削減のため、Pantry を使用せず、広告配信サーバーに材料名をパラメータで直接渡す手法を採用しました。

おわりに

本稿では、材料ジャックの設計と開発について紹介しました。商品の特性を理解し、使用しているミドルウェアや扱うデータ (レシピ) に関する調査を行うことで、設計後大きな手戻りもなく実装を行うことができました。

クックパッドの広告開発チームでは、大規模トラフィックをさばく配信サーバーの開発や純広告・ネットワーク広告の運用、管理画面の開発から、広告の新商品開発まで幅広い開発を行っています。
広告に興味がある! toB 向けの新商品を開発したい! お金儲けに興味がある! 大規模なトラフィックをさばくサービスを開発したい! に当てはまる方も当てはまらない方も、ぜひ一緒に広告開発をしてみませんか?

クックパッドでは一緒に働く仲間を募集しておりますので、ご興味ありましたらこちらのサイトをご覧ください。

info.cookpad.com

*1:2017年時テストデータで行った Encoder-Decoder の正答率

*2:現在のキャッシュの Expired の設定は24時間

/* */ @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;*/ /*}*/