レシピページのOGP画像を動的に生成する

こんにちは、クックパッドでエンジニアをやっている @morishin です。入社してわりと長い間 iOS アプリやそのバックエンドの開発を中心にやってきましたが、最近は専らウェブフロントエンドとその基盤をいい感じにするというのをやっています。先日クックパッドウェブサイトのレシピページの OGP 画像を素敵に刷新したのでそのお話をしたいと思います。

※ ここで OGP 画像と呼んでいるのは Open Graph Protocol で定義されている og:image プロパティに指定する画像のことです。
※ OGP 画像と呼んでいますが厳密には今回変更したのは Twitter Card (twitter:image) 用の画像のみなので、その他の SNS に表示される画像 (og:image) は変わっていません。

できたもの

これまではレシピ作者さんがアップロードされた料理写真を単にクロップしたものが表示されていましたが、料理写真の横にクックパッドのロゴやレシピ・作者さんの名前を添えたいい感じの画像が表示されるようになりました!画像を見ただけでクックパッドのレシピページであること、なんという料理であるかなどの情報をパッと認識することができるようになっています。

Before After
f:id:morishin127:20220210160959p:plain f:id:morishin127:20220210161035p:plain

動機

SNS にシェアされたレシピがより魅力的に見えるようにしたいという思いと、クックパッドのレシピであることがひと目で伝わってほしいという気持ちから OGP 画像のデザインを変えられないかという話が挙がりました。開発はそれなりにかかりそうなので、まずはクックパッドの公式ツイッターアカウントから「画像ウェブサイトカード」(Twitter for Business の機能) を使って特定のレシピページに対して手作業で作ったいくつかのパターンの画像を当てて複数回プロモーションツイートを投稿し、パターンごとのエンゲージメント率などを見ながらどのデザインが良さそうかを検討しました。検証を経て現在のデザインに決まったので、次に実現方法の検討に移りました。

▼画像ウェブサイトカードの例

パターン例1 パターン例2

実装方針

OGP 画像用の URL はウェブページの HTML 中で次のような meta タグで指定します。

<meta property="og:image" content="<画像のURL>">

この content に指定する URL へのアクセスを受けるサーバは HTML でなく画像データを返さなければならないため、全てのレシピに OGP 画像を用意しようとするとレシピの数だけ画像データが必要になります。バッチ処理で事前に全レシピの画像を生成しておくことも不可能ではありませんが300万品以上のレシピに対して画像を生成するコストは重く、また実際に OGP 画像がリクエストされるのはそのうちのごく一部のレシピであるため無駄も大きいです。そのため今回は OGP 画像用の URL にリクエストがあったタイミングで動的に画像データを生成して返すアプリケーションを作成することにしました。

実現方法にはいくつか選択肢が考えられましたが、最終的にはこのようなアーキテクチャになりました。インフラには AWS のサービスを利用しています。

f:id:morishin127:20220210161124j:plain
アーキテクチャ図

OGP 画像として利用したいビューを HTML として生成するページをクックパッドのウェブアプリケーション上に作り、AWS Lambda 上で実行した puppeteer (headless chrome) でそのページへアクセスしてスクリーンキャプチャを撮ることで、HTML を画像データにします。Lambda のトリガーを API Gateway にして HTTP エンドポイントからアクセスできるようにし、レスポンスのキャッシュ用途でその前段に CloudFront を置いています。

処理がシンプルであること、メンテナンスコストが低いことからサーバレスなアーキテクチャを選択しましたが、リリース後に AWS の CostExplorer を確認したところ $1/day 未満のコストで済んでいるため、金銭面でも良い選択だったのではないかと思っています。

アーキテクチャの選定

上述の構成に決定するまでに検討した内容についてお話します。

他社のサービスで OGP 画像の動的生成を実現しているものを見かけてることはありましたが、どのように作っているのかは知りませんでした。しばらく調べてみた感じではどうやら次のような選択肢がありそうでした。

  • サードパーティの画像配信 CDN サービスを利用する
  • ImageMagick や node-canvas を使ってサーバーサイドで画像処理を行い生成する
  • HTML として描画したビューのキャプチャを撮ることで画像を生成する

デザインの自由度が高く作成・変更が容易であって欲しい、また金銭コストも気になるというところでまずはサードパーティサービスではなく自作ができないかを考えました。ImageMagick や node-canvas で画像を作るのは単純に実装が難しく消耗しそう、また変更も大変そうに思ったので3つ目の HTML を画像化する手法を第一に試すことにしました。Vercel 社の vercel/og-image がこの方針を取っていること、GitHub 社の新しい OGP に関するブログ記事でもこの方法でやっていることが窺えたので、その事実にも後押しされました。オープンな文化™️、めっちゃ助かる。

上述のブログ記事と OSS である vercel/og-image の実装を参考にすることで、puppeteer で HTML のキャプチャ画像を生成するところは実現できました。これが Lambda で動かせれば ok です。Lambda 上で puppeteer を実行するためには chromium のバイナリを含むコードを Lambda 上にデプロイする必要がありますが、バイナリのサイズが大きいため通常の Lambda のアップロードサイズ上限 (250MB) に引っかかってしまいます。幸い Docker イメージとしてデプロイする方式だとサイズ上限が 10GB まで引き上げられる仕様であったため、Lambda 上で動かすアプリケーションは Docker イメージとして作成しました。細かい話ですが vercel/og-image が依存している chrome-aws-lambda パッケージは Lambda の 250MB 制限に引っかからないように chromium バイナリを Brotli 圧縮したものが使われていましたが、今回はサイズ上限を気にしないでよくなったのでこのこのパッケージは使わず素の puppeteer を利用しています。

デプロイフロー

インフラリソースの構築とアプリケーションコードのデプロイには AWS CDK を利用しました。クックパッドではほとんどの AWS リソースは Terraform で管理されており (参考: AWS リソース管理の Terraform 移行 - クックパッド開発者ブログ) 個々のアプリケーションとは別に Terraform 定義用のリポジトリがあります。しかし今回作ったアプリケーションでは例えば API Gateway のエンドポイントの定義であったり CloudFront のキャッシュポリシーであったり Lambda 実行環境のスペックであったり、そういった AWS リソースの設定値をアプリケーションのソースコードと同列に扱いたくて、また頻繁に手を加えるようにも思ったので、アプリケーションコードと同一のリポジトリに置いておきたいと考えました。そこでインフラリソースの定義を CDK で書き、そのソースコードはアプリケーションと同一のリポジトリに置いて管理することにしました。実際、リリースした次の日にはキャッシュポリシーを変更したりしていて、変更・デプロイが楽にできたと感じました。

CDK のスタック定義の実装としては AWS が用意してくれている AWS Solutions Constructs@aws-solutions-constructs/aws-cloudfront-apigateway を使い回す形でほぼ実現できたのですが、細かいところで API Gateway の REST API ではなく HTTP API (参考) を利用したかったため一部実装に手を加えて利用する形になりました。典型的なインフラの構成がソースコードとして配布されていて利用することができる点が CDK の大きな利点に感じました。あと TypeScript などで記述した場合 JSON や YAML と違い型定義がありエディタの機能で定義にジャンプして、リソースがどういうプロパティを取りうるかがパッと分かるのが良いですね。毎度ググってドキュメントを見に行かなくても済みます。

まとめ

OGP 画像を動的に生成するアプリケーションをサーバレスな構成で実現してみました。結果として運用コストが低く金銭的コストも低い、またデザインの変更も容易な設計になったと思っていて、おおむね満足しています。仕組みとしては汎用的なものでレシピページに限らず他のページ向けにも OGP 画像を生成させることができるので、今後も活用していきたいと思っています。

クックパッドでは、技術とサービス作りが大好きなエンジニアを募集しています!
実装についてもっと詳しい話を聞きたい方、クックパッドでエンジニアとして働くことに興味のある方、よければオンラインでカジュアルにお話しませんか🙋‍♂️
🔜 カジュアル面談 申し込みフォーム

Twitter などで雑にお声がけいただいても大丈夫です。クックパッドはエンジニアを積極採用中でございます。

【宣伝】

https://www.youtube.com/playlist?list=PLGT7Exkshx4gQwDgEM1a2wRJgAv2yuzIB というサービス開発者向けのライブ配信イベントをやっていて、次回は 2月24日(木) 20:30~ に「数千万レコードをリアルタイムに捌くクックパッドマートの開発」というタイトルでやるのでよかったら観にきてください!
👇️👇️👇️👇️👇️👇️
cookpad.connpass.com

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