🖼️

Cloudflare WorkersとKVを使ってパフォーマンスを上げる話

2024/04/10に公開

先日行われたCloudflareのDeveloper Week 2024の記事の中にPicsartというサービスの事例記事がありました。

https://blog.cloudflare.com/picsart-move-to-workers-huge-performance-gains

よくよく読んでみると「Cloudflareを導入したらパフォーマンス上がった」という簡単なセリフでは済まされない努力と工夫が書かれていました。非常に面白いので、自分なりに噛み砕いた要約を書いてみます。コードはあまり出てきませんがテクニカルな内容になっています。

Picsartとは

そもそもPicsartとは?ということなんですが、iOSとAndroidのモバイルデバイスとWebに対応した、世界的なオンラインの写真編集サービスです。

https://picsart.com/ja/

Wikipediaによると180カ国で10億ダウンロードを超えているとのことです。

問題

Picsartには固有の問題がありました。

  • 各アプリの起動時にクライアントデバイスは1.5MBの設定ファイルをダウンロードする
  • クライアントのOSやセッションになどに応じて変わるためキャッシュができない
  • 完了までに1500msかかっていた

これまでやっていたことは3つです。

  1. デバイスから中央サーバーへリクエスト飛ぶ
  2. サーバーはユーザー属性、メタデータなどを元にペイロードを作成
  3. 中央サーバーからユーザーへ

これを解決するために、真っ先に浮かぶのは複数サーバーをユーザーの近い場所に配置することです。しかしPicsartはCloudflareのWorkersとWorkers KVを使ってよりよく解決しました。

KVとは

ここで注釈。そもそもCloudflare KVとは?

Cloudflare KVとは最低限の機能を備えた非常に素朴はKey-Value Storeです。Workersから簡単に扱え、APIは非常に親しみやすいです。

export default {
  fetch: async (req, env) => {
    // 値の書き込み
    await env.KV.put('foo', 'bar')

    // 値の読み取り
    const foo = await env.KV.get('foo') // foo is bar

    return new Response()
  }
}

特徴としては読み取りが非常に速いということです。なのでキャッシュ用途やアセットの配信に使われます。ただ、書き込みは即座に行われない場合もあり最大60秒かかってしまう可能性があります。

計測

まず効果を測定するために計測をすることにしました。最初はダミーでいいので新しいエンドポイントを作成して、既存のエンドポイントと同時にリクエストしレスポンスタイムを計測しました。これをWorkersでやります。

const prodUrl = 'https://prod.example.com/'
const devUrl = 'https://new.example.com/'

// メトリクスを集計する関数
const collectMetrics = (duration) => {
  console.log('Request duration:', duration)
  // …
}

// かかった時間を測りつつデータをフェッチする
const fetchData = async (url, options) => {
  const startTime = performance.now()
  try {
    const response = await fetch(url, options)
    const endTime = performance.now()
    const duration = endTime - startTime
    collectMetrics(duration)
    return await response.json()
  } catch (error) {
    console.error('Error fetching data:', error)
  }
}

// 両方のエンドポイントからデータをダウンロードする
async function fetchDataFromBothEndpoints() {
  try {
    const result1 = await fetchData(prodUrl, { method: 'POST' })
    console.log('Result from endpoint 1:', result1)

    // Fetching data from the second endpoint without awaiting its completion
    fetchData(devUrl, { method: 'POST' })
  } catch (error) {
    console.error('Error fetching data from both endpoints:', error)
  }
}

fetchDataFromBothEndpoints()

これはあくまでサンプルのコードですが、流れは同じです。

データの分割

当初は100MBのファイルを"blob"として管理していました。サーバーレス、つまりWorkersの環境だとアイソレートが死ぬたびに毎回メモリにロードされるので効率が悪いです。そこで100MBからそれぞれのユーザーに関係の無いデータを取り除くようにしました。例えば、米国からのユーザーに対して他の国のデータは必要がない。そこで、「各国」x「デバイス」の組み合わせ別々にKVに保存しました。

つまり、Android、米国のユーザーにはそれ固有のKVのみをフェッチすればよくなりました。結果、600のKVレコードが作成され、それぞれは10MBに。するとデータは非正規化されるがパフォーマンスは上がります。KVは全世界にあるために種類が増えてもキャッシュ効率は変わりません。KVリードの99.5%がキャッシュから返却されるようになりました。

Before

Key Size
settings_part1.json 25MB
settings_part2.json 25MB
... ...

After

Key Size
com.picsart.studio_apple_us.json 6.1MB
com.picsart.studio_apple_de.json 6.1MB
com.picsart.studio_android_us.json 5.9MB
... ...

これは2つの良い点をもたらします。

  1. ユーザーの属性と場所によって最適化されるのでパフォーマンスの向上
  2. 新機能を追加するときにKVレコードを追加するだけでいいので柔軟性がある

データを不変にする

KVは更新頻度は低いが、読み取り頻度が高い場合に最適です。しかし、設定を即座に反させなくては いけないという要件がありました。TTLを短くして最小値の60sにしても動的であるとは言えないしキャッシュヒット率が悪くなってしまいます。

そこでKVレコードのTTLを非常に長く持ち不変にするというアプローチをとります。既存のレコードを更新するかわりに、設定変更のたびにレコードを追加するのです。これによりキャッシュヒット率を上げました。

こんなイメージです。

Before

Key TTL
com.picsart.studio_apple_us.json 60s
... ...

After

Key TTL
com.picsart.studio_apple_us_b58b59.json 86400s
com.picsart.studio_apple_us_273678.json 86400s
... ...

そして今どのレコードを参照しなくてはいけないかを知る必要がありますが、それを環境変数で設定することにしました。すると、Workersのデプロイはほんの数秒であるため変更はほぼ瞬時にグローバルに行われるのです!

JSONのシリアライズにまつわる工夫

まだ改善できる点はありました。

データの構成は1.設定データと2.ペイロードのJSONの値で構成されています。以前はどちらもJSONとして扱い毎回KV内でパースが走っていました。これでは効率が悪い。

KVのget()メソッドではtypeという値を指定できます。KVのページにはこう書かれています。

For large values, the choice of type can have a noticeable effect on latency and CPU usage. For reference, the type can be ordered from fastest to slowest as stream, arrayBuffer, text, and json.

つまり、type=textだとテキストとして返されますが、type=jsonだとパースされCPU負荷が高いです。

そこで前者を300KBのメタデータレコード、後者を9.7MBのペイロードに分けてレコードにしました。
前者だけをJSONとしてパースし、後者をテキストで取得、対象のペイロードは後者の行番号で指定するのです。これで、計算リソースの最適化もできました。

導入する

これで、レスポンスタイムは1s以上短縮しました。また、Web版ではrel=preconnectヘッダを使いパフォーマンスを向上。モバイルでは接続を確立する前の処理の効率、HTTPクライアントを先に初期化することをして、200msの改善ができた。

いよいよ移行し、ロールアウトすると大幅に改善されています!配信されたファイルは50%から85%に増加。レスポンスタイムの中央値は1500msから280msに短縮されました。また、Webではモバイルより短いので70msになった。

いい感じになった

これでいい感じになりました。Picsartはより多くのユーザーにデータ主導のパーソナライズされた体験をもたらしているのです。さらにDurable Objectsを使ってユーザーレコードを分散で保存し、パフォーマンスに影響なく配信をすることを考えています。Durable Objectsはユーザーデータをユーザーの近いリージョン内に保存するからできます。また外部のB2B顧客向けのソリューションをCloudflareで作ることを考えているとのことです。

話のまとめ

以上、PicsartがCloudflareのWorkersとKVを工夫して使ってパフォーマンスを改善した話を要約してみました!KVは個人的にすごく好きなプロダクトで、パフォーマンス・チューニングの勉強にもなりました。よかったです。

Discussion