Inflearnのコンテンツページに動的OG画像を導入する
こんにちは、Inflabのフロントエンド開発者、Yoongdyです。
最近、Inflearnのユーザーの皆さんが心を込めて書いてくださった受講レビューや質問&回答のページをSNSでシェアしたときに真っ先に目に入るリンクプレビュー画像が、以前と変わったことにお気づきの方はいらっしゃるでしょうか?
まだ気づいていない方は、この記事を読む前に、ご自身がInflearnに投稿した自慢の受講レビューや質問&回答をSNSでシェアしてみてください。この記事をより楽しく読んでいただけるはずです。
それでは、OG画像を動的に生成する仕組みを開発する中で得た経験を共有していきます。
始める前に、OG画像とは?
オープングラフ(OG) 画像とは、ソーシャルメディア(例: FacebookやSlack)にリンクをシェアしたときに、Webページの情報をプレビューできるよう表示される代表画像です。リンクのプレビュー画像は、見る人の関心を視覚的に引きつけ、クリックを促すうえで大きな効果があります。
Inflearnにはなぜ動的に生成されるOG画像が必要なのか?
Inflearnには、講座の受講生や、講座を提供する講師の方々が自ら書いた良質なコンテンツがたくさんあります。こうしたコンテンツを外部に共有するときは、リンクをコピーしてSNSでシェアすることが多いですよね。
SNSでシェアされたInflearnのスタディ募集投稿の例
(スタディ募集の投稿なのに、OG画像にはInflearnのスローガンしか表示されず、募集の投稿であることが一目では分からない)
しかし実際には、上のように外部にシェアされたコンテンツは、リンクからInflearnのサイトにアクセスして中身を確認しない限り、どんな内容なのか一目では分からないことがよくあります。
受講レビュー詳細ページのreferrer指標
ちなみに、最近の受講レビュー詳細ページの流入元(referrer)指標を見ると、SNSでのシェア経由でInflearnのWebサイトにアクセスするユーザーが大きな割合を占めています。データでこれが裏付けられたことで、リンクシェア時に表示されるプレビュー画像を早急に改善したいという声が高まりました。
GeekNewsのOG画像の例
改善のために他の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="inflearn.com"
/>
</head>
動的OG画像を適用するページのHTMLで、<meta> タグの og:image 属性の値をOG画像生成APIのURLに変更するだけで完了です。
さらに、Twitterでのリンクの表示形式を決める twitter:card メタタグを summary_large_image に設定すると、ツイート内のリンクプレビュー画像がより目立つようになります。
twitter:card summary 設定時
twitter:card summary_large_image 設定時
とても簡単ですが、これで終わりにすると、リンクをSNSやSlackなどでシェアするたびに、新しいOG画像を生成するための不要なリクエストとレスポンスが発生してしまいます。
コンテンツの内容が変わっていないのに、毎回新しい画像を生成する必要はあるでしょうか?(ないですよね)
そこで、生成済みの画像をもう一度生成する無駄をなくし、画像をより速くユーザーに表示するために、Amazon CloudFrontというCDNサービスを使って改善しました。
動的に生成された画像のレスポンスをキャッシュする
Inflearnのサービスのアーキテクチャでは、途中にCloudFront(= CDN)を挟んでいます。
リクエストを毎回オリジンサーバーまで届けるのではなく、同じリクエストが来たときはCDNにキャッシュしたレスポンスを返すことで、必要なリソースを素早くユーザーに提供するためです。
CDNキャッシュの図解
それなら、動的に生成されるOG画像のリクエストもキャッシュできるのではないでしょうか? これが可能なら、一度生成したことのある画像はリクエストがオリジンサーバーまで届かず、CDN側ですぐに処理できます。
動的OG画像生成器のリクエストとレスポンスの図解
受講レビューの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のキャッシュ設定は次のとおりです。
Amazon CloudFrontの設定例
(CloudFront Cache Keyの設定例)
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="cdn.inflearn.com"
/>
キャッシュ適用の結果
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でサーバーにリクエストを送りながらプロファイリングすると、次のような結果が得られます。
Node.jsのCPUプロファイリング結果
プロファイリングの結果、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回だけ呼び出され、それ以降は呼び出されないことを確認しました。
Node.jsのCPUプロファイリング結果(最終)
wrkで負荷テストを行ったところ、Satoriのカスタムフォント(options.fonts)の設定を変数に切り出したことで、RPSが約1.8〜2倍以上向上しました。
おわりに
記事で紹介しきれなかった細かなコード最適化も合わせて、OG画像生成時のサーバー性能を1RPSから35RPSまで向上させることができました。
動的OG画像の生成という、一見簡単そうな作業に取り組む中で、次のようなことを実践しました。
サーバー側の作業では負荷テストなどをこまめに行い、性能を確認する
Node.jsのCPUプロファイリングなどで、コードのボトルネックを見つけて改善する
こうした取り組みを通じて、ユーザーにとっての使い心地を改善できたのは、良い経験になりました。
最後に、OG画像のPoCと性能最適化に一緒に取り組んでくれた、InflabのDevOpsチームのJakeとJaceに感謝を伝えて、本記事を締めくくります。
最後まで読んでいただき、ありがとうございました。🙏
※この記事は2024年2月29日に執筆されたものです。
Node.js vs Rust on Cloud Run: serving a 100 KB JSON from Redis
Hi! In this post I compare Node.js and Rust on Google Cloud Run to see which one is faster.
You could argue that most of a request's time is spent on I/O, so the language shouldn't matter much. That's a fair point, so I built the most I/O-bound service I could think of. Each API does exactly one thing: connect to Redis, read a string, and return it as JSON. The only I/O is the Redis call, and there is no other business logic.
The payload is a random JSON document of exactly 100 KB (102,400 bytes). The size was an arbitrary choice: big enough to make serialization and network copies matter, small enough to be realistic.
Everything is open source, so you can reproduce it: andersondanilo/cloudrun-benchmark.
The setup
Both APIs expose a single endpoint, GET /data, which returns the JSON stored under one Redis key. Everything runs in the same region (São Paulo, southamerica-east1), and the infrastructure is created with Pulumi.
| Component | Details |
|---|---|
| Node.js service | Cloud Run, Node.js 22 (Alpine), Fastify 5, ioredis |
| Rust service | Cloud Run, Rust, Poem 3 (tokio), redis-rs, distroless image |
| Cloud Run config (both) | 1 vCPU, 512 MiB, exactly 1 instance (min = max = 1), concurrency 20, southamerica-east1 |
| Redis | e2-micro VM, Debian 12, redis-server with password, maxmemory 512mb, persistence off, same zone as the services, reached over its internal IP via Direct VPC egress |
| Load generator | e2-micro VM, Debian 12, same zone, wrk |
| wrk parameters | 2 threads, 20 connections (matches the Cloud Run concurrency), 5 s warm-up, then a 30 s measured run |
| Payload | Random JSON, exactly 102,400 bytes |
Running a single instance per service keeps the comparison fair: both get the same CPU and memory, and neither can scale out to hide its limits.
Results
| | Node.js (Fastify) | Rust (Poem) |
|---|---|---|
| Requests/sec | 237.72 | 467.93 |
| Transfer/sec | 23.26 MB | 45.79 MB |
| Latency (avg) | 83.80 ms | 42.59 ms |
| Latency p50 | 81.34 ms | 42.16 ms |
| Latency p90 | 95.26 ms | 48.79 ms |
| Latency p99 | 112.70 ms | 58.97 ms |
| Requests in 30 s | 7,139 | 14,051 |
Rust reached about double the requests per second (1.97x) with about half the latency at every percentile. With a fixed number of connections the two numbers are linked: 20 connections at roughly 84 ms per request is about 238 requests per second, and 20 connections at roughly 43 ms is about 468.
Caveats
- This is one test on one machine setup, so treat the numbers as a comparison between these two stacks, not as a universal verdict on Node.js vs Rust.
- Both VMs are e2-micro, which is a shared-core machine type. The Redis VM or the load generator may be limiting the results, especially for Rust. I didn't test whether a bigger load generator or Redis VM pushes Rust further.
- Each service ran on a single Cloud Run instance with 1 vCPU. With more instances, both would scale out, and the gap per instance is what would matter for cost.
Conclusion
Even for a service that only reads a string from Redis and returns it, the runtime made a clear difference: Rust served twice the traffic at half the latency on the same Cloud Run configuration. My guess is that handling a 100 KB payload on every request (reading it from Redis and writing it to the socket) costs more CPU than it seems, but I didn't profile it, so that part is a hypothesis.
If you want to run it yourself, the repo has everything: both APIs, the Pulumi infrastructure, and the benchmark script.
Mentions
Inflearnのコンテンツページに動的OG画像を導入する こんにちは、Inflabのフロントエンド開発者、Yoongdyです。 最近、Inflearnのユーザーの皆さんが心を込めて書いてくださった受講レビューや質問&回答のページをSNSでシェアしたときに真っ先に目に入るリンクプレビュー画像が、以前と変わったことにお気づきの方はいらっしゃるでしょうか? まだ気づいていない方は、この記事を読む前に、ご自身がInflearnに投稿した自慢の受講レビューや質問&回答をSNSでシェアしてみてください。この記事をより楽しく読んでいただけるはずです。 それでは、OG画像を動的に生成する仕組みを開発する中で得た経験を共有していきます。 始める前に、OG画像とは? オープングラフ(OG) 画像とは、ソーシャルメディア(例: FacebookやSlack)にリンクをシェアしたときに、Webページの情報をプレビューできるよう表示される代表画像です。リンクのプレビュー画像は、見る人の関心を視覚的に引きつけ、クリックを促すうえで大きな効果があります。 Inflearnにはなぜ動的に生成されるOG画像が必要なのか? Inflearnには、講座の受講生や、講座を提供する講師の方々が自ら書いた良質なコンテンツがたくさんあります。こうしたコンテンツを外部に共有するときは、リンクをコピーしてSNSでシェアすることが多いですよね。 SNSでシェアされたInflearnのスタディ募集投稿の例 (スタディ募集の投稿なのに、OG画像にはInflearnのスローガンしか表示されず、募集の投稿であることが一目では分からない) しかし実際には、上のように外部にシェアされたコンテンツは、リンクからInflearnのサイトにアクセスして中身を確認しない限り、どんな内容なのか一目では分からないことがよくあります。 受講レビュー詳細ページのreferrer指標 ちなみに、最近の受講レビュー詳細ページの流入元(referrer)指標を見ると、SNSでのシェア経由でInflearnのWebサイトにアクセスするユーザーが大きな割合を占めています。データでこれが裏付けられたことで、リンクシェア時に表示されるプレビュー画像を早急に改善したいという声が高まりました。 GeekNewsのOG画像の例 改善のために他の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="inflearn.com" /> </head> 動的OG画像を適用するページのHTMLで、<meta> タグの og:image 属性の値をOG画像生成APIのURLに変更するだけで完了です。 さらに、Twitterでのリンクの表示形式を決める twitter:card メタタグを summary_large_image に設定すると、ツイート内のリンクプレビュー画像がより目立つようになります。 twitter:card summary 設定時 twitter:card summary_large_image 設定時 とても簡単ですが、これで終わりにすると、リンクをSNSやSlackなどでシェアするたびに、新しいOG画像を生成するための不要なリクエストとレスポンスが発生してしまいます。 コンテンツの内容が変わっていないのに、毎回新しい画像を生成する必要はあるでしょうか?(ないですよね) そこで、生成済みの画像をもう一度生成する無駄をなくし、画像をより速くユーザーに表示するために、Amazon CloudFrontというCDNサービスを使って改善しました。 動的に生成された画像のレスポンスをキャッシュする Inflearnのサービスのアーキテクチャでは、途中にCloudFront(= CDN)を挟んでいます。 リクエストを毎回オリジンサーバーまで届けるのではなく、同じリクエストが来たときはCDNにキャッシュしたレスポンスを返すことで、必要なリソースを素早くユーザーに提供するためです。 CDNキャッシュの図解 それなら、動的に生成されるOG画像のリクエストもキャッシュできるのではないでしょうか? これが可能なら、一度生成したことのある画像はリクエストがオリジンサーバーまで届かず、CDN側ですぐに処理できます。 動的OG画像生成器のリクエストとレスポンスの図解 受講レビューの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のキャッシュ設定は次のとおりです。 Amazon CloudFrontの設定例 (CloudFront Cache Keyの設定例) 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="cdn.inflearn.com" /> キャッシュ適用の結果 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でサーバーにリクエストを送りながらプロファイリングすると、次のような結果が得られます。 Node.jsのCPUプロファイリング結果 プロファイリングの結果、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回だけ呼び出され、それ以降は呼び出されないことを確認しました。 Node.jsのCPUプロファイリング結果(最終) wrkで負荷テストを行ったところ、Satoriのカスタムフォント(options.fonts)の設定を変数に切り出したことで、RPSが約1.8〜2倍以上向上しました。 おわりに 記事で紹介しきれなかった細かなコード最適化も合わせて、OG画像生成時のサーバー性能を1RPSから35RPSまで向上させることができました。 動的OG画像の生成という、一見簡単そうな作業に取り組む中で、次のようなことを実践しました。 サーバー側の作業では負荷テストなどをこまめに行い、性能を確認する Node.jsのCPUプロファイリングなどで、コードのボトルネックを見つけて改善する こうした取り組みを通じて、ユーザーにとっての使い心地を改善できたのは、良い経験になりました。 最後に、OG画像のPoCと性能最適化に一緒に取り組んでくれた、InflabのDevOpsチームのJakeとJaceに感謝を伝えて、本記事を締めくくります。 最後まで読んでいただき、ありがとうございました。🙏 ※この記事は2024年2月29日に執筆されたものです。
Node.js vs Rust on Cloud Run: serving a 100 KB JSON from Redis Hi! In this post I compare Node.js and Rust on Google Cloud Run to see which one is faster. You could argue that most of a request's time is spent on I/O, so the language shouldn't matter much. That's a fair point, so I built the most I/O-bound service I could think of. Each API does exactly one thing: connect to Redis, read a string, and return it as JSON. The only I/O is the Redis call, and there is no other business logic. The payload is a random JSON document of exactly 100 KB (102,400 bytes). The size was an arbitrary choice: big enough to make serialization and network copies matter, small enough to be realistic. Everything is open source, so you can reproduce it: andersondanilo/cloudrun-benchmark. The setup Both APIs expose a single endpoint, GET /data, which returns the JSON stored under one Redis key. Everything runs in the same region (São Paulo, southamerica-east1), and the infrastructure is created with Pulumi. | Component | Details | |---|---| | Node.js service | Cloud Run, Node.js 22 (Alpine), Fastify 5, ioredis | | Rust service | Cloud Run, Rust, Poem 3 (tokio), redis-rs, distroless image | | Cloud Run config (both) | 1 vCPU, 512 MiB, exactly 1 instance (min = max = 1), concurrency 20, southamerica-east1 | | Redis | e2-micro VM, Debian 12, redis-server with password, maxmemory 512mb, persistence off, same zone as the services, reached over its internal IP via Direct VPC egress | | Load generator | e2-micro VM, Debian 12, same zone, wrk | | wrk parameters | 2 threads, 20 connections (matches the Cloud Run concurrency), 5 s warm-up, then a 30 s measured run | | Payload | Random JSON, exactly 102,400 bytes | Running a single instance per service keeps the comparison fair: both get the same CPU and memory, and neither can scale out to hide its limits. Results | | Node.js (Fastify) | Rust (Poem) | |---|---|---| | Requests/sec | 237.72 | 467.93 | | Transfer/sec | 23.26 MB | 45.79 MB | | Latency (avg) | 83.80 ms | 42.59 ms | | Latency p50 | 81.34 ms | 42.16 ms | | Latency p90 | 95.26 ms | 48.79 ms | | Latency p99 | 112.70 ms | 58.97 ms | | Requests in 30 s | 7,139 | 14,051 | Rust reached about double the requests per second (1.97x) with about half the latency at every percentile. With a fixed number of connections the two numbers are linked: 20 connections at roughly 84 ms per request is about 238 requests per second, and 20 connections at roughly 43 ms is about 468. Caveats - This is one test on one machine setup, so treat the numbers as a comparison between these two stacks, not as a universal verdict on Node.js vs Rust. - Both VMs are e2-micro, which is a shared-core machine type. The Redis VM or the load generator may be limiting the results, especially for Rust. I didn't test whether a bigger load generator or Redis VM pushes Rust further. - Each service ran on a single Cloud Run instance with 1 vCPU. With more instances, both would scale out, and the gap per instance is what would matter for cost. Conclusion Even for a service that only reads a string from Redis and returns it, the runtime made a clear difference: Rust served twice the traffic at half the latency on the same Cloud Run configuration. My guess is that handling a 100 KB payload on every request (reading it from Redis and writing it to the socket) costs more CPU than it seems, but I didn't profile it, so that part is a hypothesis. If you want to run it yourself, the repo has everything: both APIs, the Pulumi infrastructure, and the benchmark script.