クックパッドマートにおける宣言的ラベル生成

クックパッドマート流通基盤アプリケーション開発グループのオサ(@s_osa_)です。

少し前にクックパッドマートのラベル生成の仕組みを刷新したので紹介します。

クックパッドマートにおけるラベル

クックパッドマートは「美味しい食材を生産者や市場から直接ユーザーにお届けする」サービスです。

食材をユーザーのもとまで届けるためには流通の仕組みが欠かせません。クックパッドマートでは「1品から送料無料」をはじめとするサービスを実現するために独自の流通網を構築しています。

そんなクックパッドマートですが、流通の現場で実際に商品を運ぶドライバーに対しては、主に2つの手段で情報を提供しています。

ひとつはアプリであり、スマホ向けアプリの画面を通してその日の配送計画を伝えたり配送状況の追跡をおこなったりしています。

そして、もうひとつがラベルです。ラベルは何種類かありますが、たとえば商品に貼り付ける「商品ラベル」は以下のようなものです。

f:id:s_osa:20210817115720j:plain
商品ラベルの様子

流通の現場では多くの物理的な「モノ」を扱う必要がありますが、目の前にあるモノとアプリの画面を見比べながら配送業務をおこなうことは業務効率の観点から現実的ではありません。そこで、目の前のモノについての情報は印刷したラベルシールをモノ自体に貼り付けることによって伝えています。

ラベルとアプリという2種類の情報伝達手段ですが、商品名など目の前のモノに紐付く情報はラベル、ルート情報などの俯瞰的・概念的な情報はアプリ、という適材適所の使い分けをしています。ラベルとアプリの両輪による情報提供があってクックパッドマートの流通オペレーションは成り立っています。

既存のラベル生成が抱えていた問題

そんなラベルですが、重要なだけあってサービスの初期から使われています。一方、サービスを取り巻く環境が当時とは変わってきたこともあり、いくつかつらい点を抱えるようになっていました。

素朴な実装

ラベルプリンタへ送るデータは印字するテキストのほかに制御用のコマンドを含むバイナリなのですが、そのバイナリを素朴で手続き的な文字列操作で生成していました。単純化していますが、イメージとしては以下のようなものです。

binary = ""
binary << "#{item.name}\n"
binary << begin_bold_command
binary << "#{user.name}\n"
binary << end_bold_command

実際には、コードや DB では UTF-8 で扱っている文字列をプリンタが要求する Shift_JIS に変更するなどの処理も必要です。当初は素朴なラベル生成コードでしたが、サービスが成長するにつれて要求が複雑になり、少しずつ見通しの悪いコードになっていきました。

分離されていないデータ生成と印刷

ラベル生成と印刷はバッチや非同期ジョブなどでおこなっていたのですが、そのバッチやジョブの中にラベル生成のロジックがベタ書きされているケースがありました。

ベタ書きされていることによってテストを書きにくいほか、他の箇所で同じラベル生成ロジックを再利用することが困難になっていました。

複数実装の維持

当初はラベルプリンタによるラベル印刷だけをおこなっていましたが、同じ内容を通常のプリンタでも出力したいという要求が出てきました*1

しかし、上記の素朴な実装で生成したバイナリはラベルプリンタ用のデータであって通常のプリンタで印刷できるものではありません。そこで、ラベルと同じ内容を含む HTML を生成してから PDF に変換するという方法を取ることになりましたが、そのための HTML はラベルプリンタ用バイナリとは完全に別の仕組みで生成されることになりました。

結果として、ラベル生成ロジックに変更を加える際には2種類のラベル生成ロジックを不整合なく同時に変更する必要が生まれました。

変更しにくいラベル

流通オペレーションはラベルの存在を前提にして組まれているため、ラベルの生成ができない状況が発生すると流通が止まってしまいます。また、ラベルはその物理的な性質から、一度印刷されて流通に乗ってしまうと修正が(現実的には)不可能になります。

つまり、ラベル生成には不具合が発生した際の影響が大きい上にリカバリが難しいという性質があります。

一方、冒頭でも述べたとおり、クックパッドマートの流通においてラベルによる情報提供は非常に重要です。

これらの性質が複合した結果、ラベルは重要であるにもかかわらず、改善サイクルを回しにくいという状態になってしまっていました。

解決方法

方針

これまでに述べた問題を解決するために、以下の設計目標を立てました。

  • 見通しが良くメンテナンスしやすいラベル構造の定義
  • ラベル生成と印刷の分離
  • 単一実装によるラベルプリンタ用バイナリと HTML 両方の生成

これらの設計目標を満たすため、ラベル構造を表現する木構造のオブジェクトを組み立てて、そのオブジェクトからバイナリや HTML の表現を生成する方針にしました。

ここから先は実装の話なので、サンプルコードを中心に説明します。ただし、わかりやすさのために多少簡略化しています。

ラベル要素の実装

はじめに、ラベルに含まれる要素を表現するためのクラスを定義します。HTML におけるタグを思い浮べてもらうのがわかりやすいと思います*2

また、必要なクラスを定義していく際、すべてのクラスに to_binaryto_html メソッドを持たせます。

たとえば、ラベル中の文字列を表わす要素 Text だとこんな感じになります。下のサンプルコードにあるエンコーディングの変換や HTML のエスケープ処理のほか、実際には UTF-8 から Shift_JIS に変換できない文字の対処などもこのクラスで実行しています。

class LabelElement::Text << LabelElement::Base
  # @param text [String] UTF-8
  def initialize(text)
    @text = text
  end
  
  # @return [String] Binary
  def to_binary
    @text.encode(Encoding::Shift_JIS)
  end
  
  # @return [String] HTML string in UTF-8
  def to_html
    escaped_text = CGI.escapeHTML(@text)
    %Q|<span class="label-element__text">#{escaped_text}</span>|
  end
end 

また、太字を表わす要素 Bold などは基本的に木構造の内部ノードになるので、太字にする対象の子要素を持てるようにします。

class LabelElement::Bold << LabelElement::Base
  attr_reader :children

  def initialize
    @children = []
  end

  # @return [String] Binary
  def to_binary
    [
      begin_bold_command,
      @children.map(&:to_binary).join,
      end_bold_command,
    ].join
  end

  # @return [String] HTML string in UTF-8
  def to_html
    %Q|<span class="label-element__bold">#{@children.map(&:to_html).join}</span>|
  end

  # @param element [LabelElement::Base]
  def <<(element)
    @children << element
  end
end

これらのクラスのほか、クックパッドマートのラベルで利用している要素をそれぞれクラスとして定義しました。具体的には、改行、フォントサイズ変更、文字寄せ(左・中央・右)、上線、下線、白黒反転、QR コード、ラベルのカット、ラベルシートそのものなどです。

また、上のサンプルコードでは省略していますが、オブジェクト同士の値としての等価性を判定する == メソッドなども適切に定義します。

これらのクラスが準備できると以下のような形式でラベル構造を定義できるようになります。to_binary, to_html それぞれのメソッドが木構造の根ノードから葉ノードまで順に呼び出されていくことによって、最終的にバイナリ・HTML それぞれの表現が得られます。

label = LabelElement::Sheet.new
label.children << LabelElement::Text.new(item.name)
label.children << LabelElement::NewLine.new

bold = LabelElement::Bold.new
bold.children << LabelElement::Text.new("#{user.name}")
bold.children << LabelElement::NewLine.new

label.children << bold

label.to_binary # => ラベルのバイナリ表現
label.to_html # => ラベルの HTML 表現

ラベル構造の定義を簡単にする

上に書いたラベル構造の定義はお世辞にも読み書きしやすいものではないので、もう少し人間にやさしいインターフェイスを定義します。React.createElement() に対する JSX のようなイメージです。

今回の実装は Ruby でおこなっているのでブロックを用いて以下のように定義しました。jbuilder などと似た、Ruby ではわりとよく見る記法になっています。

label = LabelBuilder.new.build do |l|
  l.text_line(item.name)
  l.bold do
    l.text_line("#{user.name}")
  end
end

label.to_binary # => ラベルのバイナリ表現
label.to_html # => ラベルの HTML 表現  

要素を直接作るスタイルと比べて、人間が読み書きしやすいインターフェイスになったと思います。

ドメインに基づいたラベルを定義する

ここまでで汎用的なラベル生成の仕組みができました。

実際のアプリケーションではドメイン内のモデルからラベルを作りたいことがほとんどなので、そのためのテンプレートを作成します。テンプレートの中身は上のブロックを用いたインターフェイスで書きます。

商品ラベルであれば主に OrderItem というクラスのインスタンスから生成されるので、テンプレートを使って以下のように生成できるようにします。

template = LabelTemplate::OrderItemLabel.new(order_item)
label = template.build_label

label.to_binary # => ラベルのバイナリ表現
label.to_html # => ラベルの HTML 表現  

このテンプレートができたことにより、ラベル生成ロジックが一元化され、アプリケーションの任意の箇所で簡単にラベルデータを生成できるようになります。また、それぞれのラベルの構造を知りたいときはブロック記法で把握できるようになっています。

効果

上記の実装により以下のような効果が得られました。

宣言的に記述されたラベル構造

ブロック記法を用いてラベルのテンプレートを定義したことにより、文字列とコマンドの羅列ではなく、階層化された構造としてラベルを読み書きできるようになりました。

また、ラベル構造を記述する際には「どういう内容をどういう装飾で表示するか」を記述するだけで良くなり、そのデータを「どのように生成するか」は考える必要がなくなりました。

ラベル生成と印刷の完全な分離

ラベル生成のロジックをテンプレートに切り出して宣言したことにより、ラベル生成は印刷から完全に分離され、アプリケーションの任意の箇所で再利用できるようになりました。

単一実装による複数表現の生成

単一のラベル構造から to_binary, to_html という2つのメソッドを用いて2種類の表現を生成できるようになりました。さらに、他の表現形式としてテストや簡易的なログ用途のプレーンテキスト表現が欲しくなったのですが、各ラベル要素に to_plain_text というメソッドを追加することで簡単に実現できています。

また、すべてのラベルに無料で HTML 表現がついてくるようになったため、管理画面上で印刷プレビューを表示したり印刷ログを HTML でも保存・表示したりといったことが簡単にできるようになりました。これは開発時の簡易的なチェックやデバッグに便利なだけでなく、流通オペレーションやカスタマーサポートなどの運用・調査でも参照する機会が多く、非常に役立っています。

f:id:s_osa:20210817170236p:plain:w300
プレビューの様子

変更しやすいラベル

個々のラベルがテンプレートに切り出されたことによりテストがしやすくなったほか、ラベル生成の仕組み自体を具体的なラベルから切り離してテストすることが可能になりました。

さらに、ラベル生成と印刷が完全に分離されたことにより、今後印刷する予定のラベル生成を dry run で走らせることが可能になりました。dry run の実現によって、万一変更内容に問題があった際にも実際に問題が起こって流通が止まる前に対処できるようになり、ラベル変更にともなうリスクが大きく下がったため、改善サイクルを回しやすくなりました。

厳密な比較ではなく参考程度の情報にはなりますが、刷新の前後で pull request の数を調べてみたところ、同じ期間あたりの pull request 数が2倍以上になっています。

おわりに

サービス開発初期のすべてが不確かな状況で書かれたラベル生成コードを現在の状況に合わせて書き直したという話を書いてきました。当初はこのエントリのサンプルコードにあたる部分を OSS として公開しようと考えていたのですが、絶妙に業務ロジックが絡みついていて公開できる形に抽象化できなかったのでサンプルコードでの紹介になりました。

流通という領域ではここまで書いてきたようなコードによる問題解決のほか、実際にモノを動かす現場のオペレーションも非常に重要です。事実、今回のラベル生成刷新も現場オペレーションの改善サイクルを高速化するための刷新です。

現場のオペレーションは、良い仕組みを考えたと思っても実際には実行が困難だったり、そうでなくても人間は間違えたりします。そういったソフトウェアだけに留まらない問題に対して、ソフトウェアを軸に挑んでいくのは困難であるとともに挑戦的で楽しいことだと感じています。

流通という裏側の仕組みはイメージしにくいところも多いと思いますが、少しでも興味が湧いた方がいたらご連絡ください。採用サイトからの正規ルートでももちろん良いですし、@s_osa_ まで雑に DM していただくなどでも大丈夫です。よろしくお願いします。

info.cookpad.com

*1:万一ラベルプリンタが故障しても出荷・流通が止まらないようにしたいというサービス可用性の観点のほか、販売者数拡大のためにラベルプリンタなしでの出店を可能にしたいといった動機が背景です。

*2:厳密には DOM element のほうがメタファーとして適切だと思います。

ECS インフラの変遷

技術部 SRE グループの鈴木 (id:eagletmt) です。
クックパッドでは Amazon ECS をオーケストレータとして Docker を利用しています。Docker 自体は2014年末から本番環境にも導入を始めていましたが当時はまだ ECS が GA になっておらず、別のしくみを作って運用していました。2015年4月に GA となった ECS の検討と準備を始め、2016年より本格導入へと至りました。クックパッドでは当初から Hako というツールを用いて ECS を利用しており、Hako の最初のコミットは2015年9月でした。
https://github.com/eagletmt/hako/commit/7f95497505ef78491f3f68e9d648204c7c9bb5e2
当時は ECS に機能が足りずに自前で工夫していた部分も多かったのですが、ECS やその周辺サービスのアップデートにより不要になったものもいくつかあります。今回はその中からいくつか紹介しようと思います。

クレデンシャルを環境変数に設定する

データベースに接続するときのパスワードのように、アプリケーションコードにハードコードすべきではなく、安全に保存しなければならないクレデンシャルが存在します。そういった値を扱うために、クックパッドではクレデンシャルを Vault に保存し、Hako の定義ファイル上で Vault に保存された値を参照する記法を導入し、デプロイ時には Hako が Vault から値を取得して ECS のタスク定義に注入するようにしました。 f:id:eagletmt:20210805113108j:plain このようにすることでクレデンシャルが Git リポジトリ内に入ることを防ぎ、また ECS のタスク定義を閲覧する権限を絞ることでクレデンシャルに対する権限管理を行うようにしました。

このしくみは長いこと運用されてきており今もまだ残っていますが、現在では ECS のアップデートによりクレデンシャルを与えるための機能が追加されたので、それを利用するようになっています。
https://docs.aws.amazon.com/AmazonECS/latest/userguide/specifying-sensitive-data.html
これにより Parameter Store や Secrets Manager、KMS によってクレデンシャルの安全な保存と権限管理を達成でき、タスク定義の閲覧を制限する必要がなくなったり、Vault を自前で運用したり Hako のようなツールで工夫する必要もなくなりました。

1つのロードバランサーを複数のアプリで共有する

ECS には ELB と連携する機能が当初からありましたが、1つの ECS サービスに対して1つのロードバランサーしか関連付けることができませんでした。つまり1つのアプリに対して1つのロードバランサーが必要でした。これは機能的には問題無いのですが、ELB のロードバランサーには数に比例した料金が設定されており、どんなに利用が少なくても一定の料金がかかります。公開されている Web アプリの場合は24時間アクセスがきますが、社員が時々使うだけのようなスタッフ向けアプリの場合はロードバランサーの分の料金の割合が高くなります。そのため、できるだけロードバランサーを共有して料金を抑えたいという事情がありました。一方でロードバランサーを共有するとロードバランサー毎のログやメトリクスが複数のアプリで混ざったものになってしまう問題があるため、これを許容できるようなワークロードに限ってロードバランサーを共有することにしました。

そこでロードバランサーを共有するケースでは ECS と ELB の連携機能を使わず、独自にサービスディスカバリを実装し1つのロードバランサーから複数のスタッフ向けアプリにプロキシするようなしくみを作りました。1つのロードバランサーから EC2 上に起動した nginx にプロキシし、その nginx の設定は consul-template から生成されるようにしてホスト名に応じて各アプリにプロキシされるようにしています。 f:id:eagletmt:20210805113305j:plain このしくみのもう少し詳しい説明は https://speakerdeck.com/eagletmt/ecs-woli-yong-sitadepuroihuan-jing?slide=28 にあります。

以前はこのような工夫でロードバランサーの料金を抑えていたのですが、2016年に ALB が登場し、2019年に ALB が Host ヘッダによってルーティングを変更できるようになったことで状況が変わりました。これにより ECS サービスに対応するターゲットグループを1つのロードバランサーに複数関連付け、Host ヘッダの値でルールを作成することで、ロードバランサーを複数アプリで共有することができます。 f:id:eagletmt:20210805113432j:plain このようなしくみにすることですべて AWS の機能で済ませることができるようになり、Consul を運用したり各アプリにプロキシする nginx を運用したりする必要がなくなりました。

gRPC サーバを ECS で動かす

クックパッドではマイクロサービス化の過程で gRPC を導入し、Ruby や Go 等で実装された gRPC サーバが動いています。gRPC サーバを動かし始めた当時は ALB がサポートしていなかったため、gRPC サーバと通信するためのしくみを自前で作る必要がありました。ここで必要なものは gRPC サーバのサービスディスカバリとプロキシで、前述のロードバランサーを共有したい状況と似ています。しかしこの時点で社内にサービスメッシュが整備されていたため、gRPC のプロキシには Envoy を利用できそうでした。gRPC サーバ向けに Envoy の SDS API *1 を実装すればサービスディスカバリについても実現できそうだったので、そのようなしくみを作って gRPC サーバを ECS で動かすようにしました。また、非 gRPC のサーバではエラーレートやレイテンシといった基本的なメトリクスが ALB に依存していたので、gRPC サーバの手前にも Envoy を置くことで Prometheus にメトリクスを入れることにしました。 f:id:eagletmt:20210805113504j:plain このしくみの詳細は https://techlife.cookpad.com/entry/2018/05/08/080000https://logmi.jp/tech/articles/320715 を参照してください。

このように gRPC サーバを ECS で動かすには工夫が必要だったのですが、2020年に ALB が gRPC をサポートしたことにより独自のしくみ無しで gRPC サーバを動かせるようになりました。現時点で ALB では gRPC レベルのステータスコードをメトリクスからもログからも確認することができないため、間に Envoy を置いてメトリクスやログをとっている点は変わってませんが、https://github.com/cookpad/sds やそれに登録するためのしくみを運用することなく gRPC サーバを ECS で動かせるようになっています。 f:id:eagletmt:20210805113614j:plain

まとめ

ここまでで紹介したもの以外にも、コンテナインスタンスの drain がサポートされたことにより ECS クラスタのスケールインが容易になったり、タスク内のコンテナ間に依存関係を定義できるようになったことでサイドカーコンテナのヘルスチェックが通るまでメインのコンテナの起動を遅らせることができるようになったり、daemon scheduling strategy でデプロイしたタスクが drain 対象になったときに最後に停止されるようになったことでたとえば daemon scheduling strategy でデプロイしている cAdvisor で最後までメトリクスをとれるようになったりと、ECS が GA になった当時と比べるととても使いやすくなっています。

このようにクックパッドでは AWS を活用しつつも自分たちのニーズに合わせてしくみを自作し、社内の基盤を構築してきました。そして AWS のアップデートにより自作のしくみが不要になったときは積極的に自作を廃止し、できるだけ AWS の機能だけで済むようにしてきました。自分たちの目的のために必要であれば自作できることも大切ですが、メンテナンスや運用の手間をできるだけ減らすために、目的を達成できる範囲内でできるだけ自作を減らすことも大切だと考えています。我々 SRE グループはそのようなバランス感覚を持ちながら必要なものを開発していける仲間を募集しています。
https://cookpad.jobs

*1:v1 当時の名前。現在では v3 EDS API になっている

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

こんにちは! メディアプロダクト開発部の名渡山 ( @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;*/ /*}*/