こんにちは、Inflabのフロントエンド開発者、Yoongdyです。
最近、Inflearnのユーザーの皆さんが心を込めて書いてくださった受講レビューや質問&回答のページをSNSでシェアしたときに真っ先に目に入るリンクプレビュー画像が、以前と変わったことにお気づきの方はいらっしゃるでしょうか?
まだ気づいていない方は、この記事を読む前に、ご自身がInflearnに投稿した自慢の受講レビューや質問&回答をSNSでシェアしてみてください。この記事をより楽しく読んでいただけるはずです。
それでは、OG画像を動的に生成する仕組みを開発する中で得た経験を共有していきます。
始める前に、OG画像とは?
オープングラフ(OG) 画像とは、ソーシャルメディア(例: FacebookやSlack)にリンクをシェアしたときに、Webページの情報をプレビューできるよう表示される代表画像です。リンクのプレビュー画像は、見る人の関心を視覚的に引きつけ、クリックを促すうえで大きな効果があります。
Inflearnにはなぜ動的に生成されるOG画像が必要なのか?
Inflearnには、講座の受講生や、講座を提供する講師の方々が自ら書いた良質なコンテンツがたくさんあります。こうしたコンテンツを外部に共有するときは、リンクをコピーしてSNSでシェアすることが多いですよね。
(スタディ募集の投稿なのに、OG画像にはInflearnのスローガンしか表示されず、募集の投稿であることが一目では分からない)
しかし実際には、上のように外部にシェアされたコンテンツは、リンクからInflearnのサイトにアクセスして中身を確認しない限り、どんな内容なのか一目では分からないことがよくあります。
ちなみに、最近の受講レビュー詳細ページの流入元(referrer)指標を見ると、SNSでのシェア経由でInflearnのWebサイトにアクセスするユーザーが大きな割合を占めています。データでこれが裏付けられたことで、リンクシェア時に表示されるプレビュー画像を早急に改善したいという声が高まりました。
改善のために他のWebサイトのOG画像を参考にしていたところ、毎週Slackで共有されるGeekNewsのリンクのOG画像こそ、この問題を解決してくれそうだと感じました。
というのも、普段GeekNewsで読む記事を選ぶとき、OG画像にあるタイトルと概要を見て、興味のあるテーマなら自然とリンクをクリックしていたからです。
そこで、Inflearnのユーザーコンテンツページにも、OG画像を動的に生成して適用することに決めました。
コンテンツごとに動的にOG画像を生成するには?
OG画像を動的に作る方法をいろいろ調べる中で、Vercelが @vercel/og というオープンソースライブラリを提供していることを知りました。HTML/CSSをSVGに変換し、ソーシャル画像を高速かつダイナミックに作成できるライブラリです。
@vercel/og ライブラリを使う
Vercelが提供するライブラリ @vercel/og の使い方はとても簡単です。
公式ドキュメントに丁寧に書かれているとおり、まずNext.jsプロジェクトにライブラリをインストールします。あとは、プロジェクトのルーター方式に合わせてAPIルートを作成し、ライブラリのインターフェースに沿ってコードを書けば完了です。
import { ImageResponse } from '@vercel/og';
export const config = {
runtime: 'edge',
};
export default async function handler() {
return new ImageResponse(
(
<div
style={{
fontSize: 40,
color: 'black',
background: 'white',
width: '100%',
height: '100%',
padding: '50px 200px',
textAlign: 'center',
justifyContent: 'center',
alignItems: 'center',
}}
>
👋 Hello
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
style: "normal",
name: "Pretendard",
data: boldFontBuffer,
weight: 700,
}
...
]
},
);
}
@vercel/og ライブラリを使う際の注意点は次のとおりです。
- Node.js 16以上のみをサポートしています。
-
@vercel/ogはNode.jsランタイムをサポートしていません。Edgeランタイムでのみ動作します。 - 内部のコアエンジンであるSatoriが許容する一部のHTML/CSS機能のみをサポートしています。
👀 ライブラリの使い方の詳細は、
@vercel/ogの公式ドキュメントをご確認ください。
Webページに動的OG画像を適用する
動的に生成されるOG画像をWebページに適用するのも、とても簡単です。
<head>
<title>Inflearn - 受講レビュー詳細ページ</title>
<meta
property="og:image"
content="https://inflearn.com/api/og/review/{id}"
/>
</head>
動的OG画像を適用するページのHTMLで、<meta> タグの og:image 属性の値をOG画像生成APIのURLに変更するだけで完了です。
さらに、Twitterでのリンクの表示形式を決める twitter:card メタタグを summary_large_image に設定すると、ツイート内のリンクプレビュー画像がより目立つようになります。
とても簡単ですが、これで終わりにすると、リンクをSNSやSlackなどでシェアするたびに、新しいOG画像を生成するための不要なリクエストとレスポンスが発生してしまいます。
コンテンツの内容が変わっていないのに、毎回新しい画像を生成する必要はあるでしょうか?(ないですよね)
そこで、生成済みの画像をもう一度生成する無駄をなくし、画像をより速くユーザーに表示するために、Amazon CloudFrontというCDNサービスを使って改善しました。
動的に生成された画像のレスポンスをキャッシュする
Inflearnのサービスのアーキテクチャでは、途中にCloudFront(= CDN)を挟んでいます。
リクエストを毎回オリジンサーバーまで届けるのではなく、同じリクエストが来たときはCDNにキャッシュしたレスポンスを返すことで、必要なリソースを素早くユーザーに提供するためです。
それなら、動的に生成されるOG画像のリクエストもキャッシュできるのではないでしょうか? これが可能なら、一度生成したことのある画像はリクエストがオリジンサーバーまで届かず、CDN側ですぐに処理できます。
受講レビューのIDを基準にCDNキャッシュを有効にした場合、レビューの内容が変更されたときに、キャッシュされた画像をどうやって更新すればよいでしょうか?
DBには、updatedAt のような最終更新日時を保存しています。
変更があるたびに、このカラムの値を更新しています。
この値をCDNのキャッシュキーとして使い、og:image に設定するURLに含めればよいのです。
🧞♂️ ちょっとしたヒント
og:imageの値に設定するURLがエンコードされていないと、Slackのリンクプレビュー画像が表示されないことがあるので注意してください。
- レビューの内容が変更された場合、
updatedAtフィールドが更新され、og:imageの値に設定したURLのupdateKeyも同じ値に変わります。 - その後ユーザーがSNSにリンクをシェアすると、最新の
updateKeyを持つog:imageのURLにリクエストが送られます。 - CDNにキャッシュされた古い
updateKeyの画像は、もうリクエストされなくなります。
この仕組みによって、画像レスポンスの状態や履歴を別途DBに保存しなくても、常に最新のコンテンツ内容を反映したOG画像をユーザーに提供できるようになりました。
Amazon CloudFrontのキャッシュ設定は次のとおりです。
-
TTL(Time To Live)
- Min: 0
- Max: 1年
- Default: 0
- defaultとminを0にしているのは、cache-controlヘッダーを返さないリクエストをキャッシュしないためです。
-
Cache Key Settings
- Query Strings - ALL
- どんなクエリ文字列でもキャッシュキーとして機能します。
- 同じURIの画像であっても、クエリ文字列が異なれば別のキャッシュ対象とみなします。
最後に、Webページの <meta> タグの og:image 属性の値を変更します。
<meta
property="og:image"
content="https://cdn.inflearn.com/api/og/review/{id}?updateKey={updateKey}"
/>
キャッシュ適用の結果
CDNキャッシュミス時: 1.271082084s
GET /community/api/v1/og/review/41887 HTTP/1.1
HTTP/1.1 200 OK
cache-control: no-cache, no-store
X-Cache: Miss from cloudfront
Elapsed time: 1.271082084s
CDNキャッシュヒット時: 0.102158167s
GET /community/api/v1/og/review/41887?updatedAt=1 HTTP/1.1
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
X-Cache: Hit from cloudfront
Elapsed time: 0.102158167s
キャッシュにヒットしたかどうかで、画像生成リクエストのレスポンス速度がこれだけ違うのが分かるでしょうか?
DBのようなリソースを別途構築しなくても、CDNを挟むだけで不要なリクエストとレスポンスが減り、およそ13倍速くなりました。ただし、惜しい点が一つあります。CDNにキャッシュされる前、つまり初回の画像生成のレスポンスが遅すぎることです。
これを改善するため、ターミナルから手軽にサーバーの負荷テストと性能測定ができるツールwrkを使い、毎秒n件の画像を生成したときにサーバーがどの程度の負荷に耐えられるかを測定しました。あわせて、Node.js V8 Inspectorのデバッグツールで画像生成処理のボトルネックを見つけ、初回の画像生成速度を改善するといった作業を行いました。
その取り組みを一つずつ紹介していきます。
画像生成の最適化
Inflabでは、Vercelプラットフォームを使わず、Next.jsをAWSクラウド上に自前で構築して運用しています。動的画像生成器も、standaloneコンテナとしてAmazon ECSクラスターにデプロイされます。
そのため、Edgeランタイムでのみ動作する @vercel/og の性能面のメリットを得られず、むしろ性能が落ちるうえ、次のような制約もありました。
- EdgeランタイムはすべてのNode.js APIをサポートしているわけではないため、一部の
npmパッケージが動作しない可能性があります。- 例:
lodash-es、fs
- 例:
- Vercelにデプロイした場合、Edgeランタイムはサーバーレス関数として実行されます。一方、AWSのようなクラウドサービスでセルフホスティングする場合は、Edgeランタイムへの切り替え時にオーバーヘッドなどによる性能低下が起こる可能性があります。
- 社内で使っているDatadog APMとの連携やモニタリングが難しくなります。
そこで、Node.jsランタイムでも動作するライブラリに置き換え、性能を改善しつつモニタリングの効率も高めることにしました。
standaloneについて気になる方は、Rallitにstandaloneを適用した話を参考にしてください。
Satoriを直接使う
Node.jsランタイムでHTML + CSSをSVGに変換するには、@vercel/og が内部で使っているSatoriライブラリを直接使う必要があります。
Satoriの使い方は、@vercel/og と大きくは変わりません。
@vercel/og はSVGからPNGへの変換などの細かい処理を抽象化しているので、追加で必要なのは、Satoriが返したSVGをPNG画像に変換する処理だけです。
import satori from "satori";
export default async function handler(
request: NextApiRequest,
response: NextApiResponse
) {
const svg = await satori(
(
<div
style={{
fontSize: 40,
color: 'black',
background: 'white',
width: '100%',
height: '100%',
padding: '50px 200px',
textAlign: 'center',
justifyContent: 'center',
alignItems: 'center',
}}
>
👋 Hello
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
style: "normal",
name: "Pretendard",
data: boldFontBuffer,
weight: 700,
}
...
]
},
);
}
Resvgの代わりにsharpを使う
@vercel/og はSVGをPNGに変換する処理にResvgライブラリを使っているため、私たちも同じくResvgを使いました。
import { Resvg } from '@resvg/resvg-js'
...
const resvg = new Resvg(svg, {});
const pngData = resvg.render();
const pngBuffer = pngData.asPng();
return response.setHeader("content-type", "image/png").send(pngBuffer);
SVGをPNG画像に変換するところまでは成功です。性能も改善されているだろうと期待しながら、負荷テストを行いました。
しかし結果は… @vercel/og を使ったときと同様で、サーバーは1RPS(Requests per Second)すら耐えられませんでした。
ほかのPNG変換ライブラリを探していたところ、sharpでPNGに変換しているサンプルコードを見つけたので、試してみることにしました。
import sharp from "sharp";
...
const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer();
return response.setHeader("content-type", "image/png").send(pngBuffer);
結果は…? 0.4RPSから13RPSまで改善されました。
簡単に調べたところ、resvg-jsとsharpは似た処理を行いますが、用途が異なることが分かりました。
-
resvg-js
- SVGのグラフィック要素を高解像度でレンダリングして高品質な画像を生成するなど、ラスター画像への変換が主な用途
-
sharp
- Node.jsでの画像のリサイズ、回転、切り抜きなどの画像処理が主な用途
InflearnのOG画像に必要なのは、高解像度の画像生成ではなく単なるSVGからPNGへの変換なので、sharpのほうが適していました。性能もresvg-jsを使ったときより10倍以上良かったので、sharpを選ばない理由はありませんでした。
resvg-jsのGitHub issueにも、両者の性能差についての投稿があります。気になる方はご覧ください。sharp is faster for me when mass converting SVGs to HQ PNGs
コード内のボトルネックを見つける
Node.js inspectorを使うと、Chrome DevToolsからWebSocketで接続したNode.js(上で動くNext.js)サーバーのCPUを追跡できます。
Performanceタブで記録ボタンを押し、wrkでサーバーにリクエストを送りながらプロファイリングすると、次のような結果が得られます。
プロファイリングの結果、addFonts() という関数がリクエストのたびに呼び出されていることが分かりました。
この問題をデバッグした流れは次のとおりです。
- Satoriライブラリの内部で、一度メモリにロードしたフォントをキャッシュしていないのではないかと疑う
- Satoriの内部コードで、
options.fontsオブジェクトのメモリアドレスをkeyとしてWeakMapをグローバル変数に宣言し、一度ロードしたフォントをキャッシュするロジックを確認 - しかしプロファイリング結果では
addFonts()が毎回呼び出されており、キャッシュしたフォントを再利用していないのではないかと疑う - ライブラリをデバッグし、
fontCache.set()だけが実行されてfontCacheにエントリが溜まる一方で、実際にはキャッシュが使われていないことを確認
Satoriのこの問題についてはissueを立てましたが、すぐに解決されるとは期待できません。そこで、自分たちのコード側で解決する方法を探しました。
まず、私たちが突き止めた問題の原因は次のとおりです。
-
options.fontsオブジェクトのメモリアドレスがfontCacheのkeyになっていることが根本的な原因 -
satori()の呼び出し時に渡すoptions.fontsオブジェクトのメモリアドレスがリクエストのたびに変わっている - 呼び出すたびにメモリアドレスが変わるので、
fontCacheに設定したkeyも変わり、キャッシュが見つからない - 毎回
addFonts()が無駄に呼び出され、ボトルネックになっている
そこで「options.fonts オブジェクトのメモリアドレスを固定すればよいのでは?」という結論に至り、オプションの値をあらかじめ変数として宣言し、それを渡すようにコードを修正しました。
Satori optionsの修正前
...
const svg = await satori(
(
<div
style={{
fontSize: 40,
color: 'black',
background: 'white',
width: '100%',
height: '100%',
padding: '50px 200px',
textAlign: 'center',
justifyContent: 'center',
alignItems: 'center',
}}
>
👋 Hello
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
style: "normal",
name: "Pretendard",
data: boldFontBuffer,
weight: 700,
},
...
],
}
);
...
Satori optionsの修正後
const satoriOptions: SatoriOptions = {
width: 1200,
height: 630,
fonts: [
{
style: "normal",
name: "Pretendard",
data: boldFontBuffer,
weight: 700,
},
...
],
};
...
const svg = await satori(
(
<div
style={{
fontSize: 40,
color: 'black',
background: 'white',
width: '100%',
height: '100%',
padding: '50px 200px',
textAlign: 'center',
justifyContent: 'center',
alignItems: 'center',
}}
>
👋 Hello
</div>
),
satoriOptions
);
...
性能改善の結果
Node.jsのプロファイリングの結果、addFonts() は1回だけ呼び出され、それ以降は呼び出されないことを確認しました。
wrkで負荷テストを行ったところ、Satoriのカスタムフォント(options.fonts)の設定を変数に切り出したことで、RPSが約1.8〜2倍以上向上しました。
おわりに
記事で紹介しきれなかった細かなコード最適化も合わせて、OG画像生成時のサーバー性能を1RPSから35RPSまで向上させることができました。
動的OG画像の生成という、一見簡単そうな作業に取り組む中で、次のようなことを実践しました。
- サーバー側の作業では負荷テストなどをこまめに行い、性能を確認する
- Node.jsのCPUプロファイリングなどで、コードのボトルネックを見つけて改善する
こうした取り組みを通じて、ユーザーにとっての使い心地を改善できたのは、良い経験になりました。
最後に、OG画像のPoCと性能最適化に一緒に取り組んでくれた、InflabのDevOpsチームのJakeとJaceに感謝を伝えて、本記事を締めくくります。
最後まで読んでいただき、ありがとうございました。🙏
※この記事は2024年2月29日に執筆されたものです。









