こんにちは、Tony Duongです。株式会社SpacelyでRailsバックエンドエンジニアとして働いており、Spacelyプラットフォームの開発・保守を担当しています。
本記事では、spacely_web Railsアプリケーションの中でも特に呼び出し回数が多く、CPU負荷の高いジョブである360°写真をキューブマップ(cubemap)に変換する処理を Sidekiq ワーカーから AWS Lambda に移行し、エンドツーエンドで約4倍高速化した取り組みを紹介します。
キューブマップとは?なぜ生成するのか
ユーザーがアップロードする360°写真(パノラマ画像)は、正距円筒図法(equirectangular)のJPEG — いわゆる「球を平面に展開した」横長の画像 — です。この形式は保存には適していますが、リアルタイム描画にはコストがかかります。

正距円筒図法のパノラマ画像は天井と床が引き伸ばされて湾曲しているのが分かります 。
そこで表示の前に、同じシーンを立方体の6つの面に再投影したキューブマップへ変換します。面は右・左・上・下・前・後の6枚(pano_r / pano_l / pano_u / pano_d / pano_f / pano_b)です。上のパノラマ画像から生成した6枚の面がこちらです:
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
同じ部屋を歪みのない6枚の正方形の面として表したもので、パノラマ画像とは異なり、各面は普通の平面写真です。まさにGPUが立方体に貼り付けたい形です。
この6枚の画像こそ、パノラマプレイヤーが実際に描画しているものです。ビューワはこれらをカメラを囲む立方体の内側にテクスチャとして貼り付けており、ユーザーが部屋を見回すときに目にしているのはこの平面の画像です。GPUは歪んだパノラマ画像よりも平面の画像の方がはるかに簡単にサンプリングできます。Spacely で見回せる360°ツアーは、すべてこのジョブが生成したキューブマップから描かれています。
Spacely の360°プレイヤーはこちらで見られます: https://info.spacely.co.jp/realestate-vr/
この変換は1日に数千回実行され、しかもアップロード処理の過程で実行されています。つまりその速度はユーザー体験に直結します。
課題:CPU負荷の高いジョブが Sidekiq プールを圧迫する
旧来のジョブ CreateCubeMapJob は、CPU負荷の高い krpano 1.1x による変換を Sidekiq ワーカー上で行い、結果をS3にアップロードしていました。

これには3つの課題がありました。
他の処理を圧迫する。 変換はCPU負荷が高く、他のジョブと同じ Sidekiq ワーカー上で動いていました。Spacely のプロジェクトはパノラマ画像を最大50枚までアップロードできるため、ユーザーが一度に多くのパノラマ画像をアップロードすると、このジョブが何十個も並列に走ってCPUを占有し、キューが詰まる間、無関係なジョブまで遅くなりました。また、Sidekiq にはスケーリングの実用的な限界があるため、バーストアップロードはそのままレイテンシのスパイクになりました。
処理時間が安定しない。 ジョブが他の処理とワーカーを共有しているため、1回の変換にかかる時間は、同時に動いている他のキューブマップジョブ(やその他のCPU負荷の高いジョブ)の数に左右されました。ワーカーに余裕があるときは5〜10秒ほどで終わりますが、混雑時には同じ変換が最大2分ほどかかることもありました。
krpano 1.1x は更新が必要なほど古かった。 2019年リリースのため、新しいライブラリで改善できると期待しましたが、後述のベンチマークのように、現実的な並行負荷では差はほとんどありませんでした。

バースト時には、CPU負荷の高い krpano ジョブ(オレンジ)が各 Sidekiq タスクを飽和させ、無関係なジョブ(グレー)は順番待ちになります。結果としてキューブマップ処理も、それ以外の処理も遅くなりました。
一見すると解決策に見えるが、そうではないもの:さらなるスケールアップ
解決策として最初に思いつくのは、ハードウェアを増やすことです。Sidekiq ワーカーのCPUを増強し(垂直スケール)、台数を増やす(水平スケール)とすると、余裕は生まれますし、ある程度までは有効な手です。しかしこれは問題を根本的には解決しません。変換処理は依然として他のジョブと同じワーカーを奪い合っているので、負荷が新しく確保したキャパシティを超えた瞬間、また同じ競合と同じ不安定な処理時間に逆戻りします。スケールは壁を遠ざけるだけで、壁から離れさせてはくれません。
より良い解決策はアーキテクチャの変更です。しかも大規模なものではありません。ロジックを書き換えるのではなく、重い部分をそれに適した環境へ委譲するのです。CPU負荷の高い高コストな変換は、レイテンシに敏感なジョブと共有するワーカープールではなく、隔離されたオンデマンドの計算環境(Lambda)にこそ置くべきものです。重要なのは、これをインターフェースを変えずに行った点です。新しいジョブは旧来のものと同じ入力・同じ副作用を持ちます。同じソースを読み、同じ6枚の画像を同じS3の場所に書き込みます。インターフェースを変えないことで、実装を差し替えても影響範囲が最小限にとどまると確信できました。下流のどこからも、どちらのジョブがキューブマップを生成したのかは区別がつきません。
この記事の残りは、その変更の2つの側面である変換エンジンの評価と、それをワーカーから追い出すことについてです。
変換エンジンの評価
3つの変換エンジンをベンチマークしました。現行の krpano 1.1x(2019年リリース)、新しい krpano 1.2x(2025年リリース)、そしてオープンソースのPythonライブラリ py360convert です。テストは現実的なバーストを再現しました — 50枚のパノラマ画像を同時にアップロード(短時間に Sidekiq へ50ジョブが一斉にエンキューされる)条件です。インフラは変更せず、Sidekiq をホストしているのと同じ ECS サーバー・同じ CPU・メモリのまま、変換ライブラリだけを差し替えました。1画像あたりの結果(p50 = 中央値):
| エンジン | p50 | p95 |
|---|---|---|
| krpano 1.1x | 19.6s | 39.4s |
| krpano 1.2x | 17.6s | 55.5s |
| py360convert | 21.4s | 38.3s |
ライブラリの差し替えだけでは、目に見える改善はありませんでした。 3者とも並行負荷下では同程度の速度帯に収まり、共有 Sidekiq ワーカー上で何十個もの変換が同時に走るときの CPU 競合の方が、エンジン間の差より大きかったのです。ここだけでは、どれを選ぶべきか判断できません。では、基盤のアーキテクチャを変えたとき、性能はどう変わるのかを見てみましょう。
新しい設計:Lambda へのオフロード
変換を AWS Lambda に移し、隔離された・水平スケールする環境で実行することで、50並列の変換が1個と同じ速度で終わるようにしました。
新しいジョブ CreateCubeMapV2Job はオーケストレーターとして動きます。Sidekiq は軽いまま、各変換が隔離された Lambda 上で実行されます。画像バイトは S3 と Lambda の間を直接流れ、ワーカーには通りません。

ワーカーは調整役に徹し、各変換は専用の Lambda で実行されます。画像データは Sidekiq を通りません

画像のバイト列はワーカーを通りません。 サーバーサイドのS3コピーのあと、Lambda へ presigned URL を渡します。Lambdaではソース読み取り用の GET と、各出力書き込み用の PUT が実行されます。このようにして画像データは S3 → Lambda → S3 と直接流れます。
Lambda 上でのエンジン別ベンチマーク
CPU 競合がなくなった状態で、同じ50枚同時アップロードのテストを Lambda 上で再実行しました。今回は krpano 1.2x と py360convert を比較(krpano 1.1x は省略し、新しいバージョンでテスト)しました。各変換は隔離された Lambda(2048MB)上で走り、変換ライブラリだけを差し替えました。1画像あたりの結果(p50 = 中央値):
5K(5376×2688)
| エンジン | p50 | p90 |
|---|---|---|
| krpano 1.2x | 6.99s | 8.33s |
| py360convert | 5.71s | 10.95s |
11K(11008×5504)
| エンジン | p50 | p90 |
|---|---|---|
| krpano 1.2x | 21.26s | 25.17s |
| py360convert | 10.24s | 16.44s |
2048MB で計測。11K ではどちらのエンジンも、それ以上メモリや vCPU を増やしても性能は改善せず 2048MB がスイートスポットでした。
上の Sidekiq の表と比べてください。Sidekiq ではライブラリに関係なく3者とも18〜21秒台に収まっていました。ここから2つの結論が得られます。
アーキテクチャの変更が決定的な改善だった。 共有ワーカーから隔離された Lambda へ移すだけで、5K では1画像あたり18〜21秒 から一桁秒台へ短縮されました。Sidekiq 上でのライブラリ差し替えだけではほとんど差が出なかったのに対し、隔離が効きました。
Lambda 上では
py360convertが明確に優位で解像度が上がるほど差が広がる。 5K では krpano 1.2x よりわずかに速く(p50 5.71秒 vs 6.99秒)、11K では差がおよそ2倍に(p50 10.24秒 vs 21.26秒)。
本番では py360convert を採用しました。特に重要なユースケースである大きなパノラマで最速であり、外部バイナリもライセンスも不要なPython標準ライブラリであるためです。出力画像を同じ解像度(キューブ面の解像度)に揃えて比較したところ、それぞれの出力品質は見た目で区別がつきませんでした。
変換そのもの — Lambda ハンドラー内の py360convert 呼び出し:
def _convert_faces(image: Image.Image, face_width: int = None) -> Dict[str, Image.Image]: np_img = np.array(image) ... faces = py360convert.e2c(np_img, face_w=face_width, cube_format="dict") ...
py360convert 向け Lambda メモリのチューニング
Lambda では、メモリ設定は単なる容量ではなく実質的にパフォーマンスのつまみで、メモリを増やすとCPUも比例して増えます(1,769MB で1 vCPU 相当が割り当てられます)。つまり唯一の正解はありません。少なすぎれば大きなパノラマ画像がメモリ不足になり、多すぎれば速度に寄与しない余剰分にお金を払うことになります。そこで各画像サイズについて複数のメモリ設定を横断的にベンチマークし、それぞれのスイートスポットを探しました。
5376×2688 のパノラマでは、1024MB から 4096MB までテストしました。
| Lambda メモリ | p50 | p90 |
|---|---|---|
| 1024 MB | 9.10s | 15.48s |
| 2048 MB | 5.71s | 10.95s |
| 4096 MB | 5.97s | 10.69s |
性能は 1024→2048MB で大きく改善し、その後は頭打ちになります 。より大きな 11008×5504(約6000万画素)のパノラマでも同じ形ですが、こちらは 1024MB ではそもそも動きません。
| Lambda メモリ | p50 | p90 |
|---|---|---|
| 1024 MB | メモリ不足 | — |
| 2048 MB | 10.24s | 16.44s |
| 4096 MB | 10.12s | 16.94s |

どちらの曲線も同じ位置で折れ曲がります。2048MB までは大きく下がり、その後は横ばいです。それ以上のメモリ(とそれに伴うCPU)はもうボトルネックではなくなっています。
結論として、典型的なパノラマでは 2048MB がスイートスポットで、過大な入力のために大きめの階層を予備として用意しています。本番では CreateCubeMapV2Job がピクセル数に応じて3つの Lambda エンドポイントへ振り分けます — 同じハンドラーコード、メモリサイズだけが異なります。

振り分けルール: ≤ 11K → 2048MB · ≤ 16K → 3072MB · > 16K → 4096MB。どの画像も必要十分なメモリを得られ、使い切れない余剰分を支払うこともありません。
コスト
ピーク時でも費用は控えめに収まります。繁忙日には1日あたり約19,000回(月あたり約570,000回)の変換が走ります。2048MB・平均約17秒の実行で、東京リージョンの料金(Lambda 料金)では:
- コンピュート: 570,000 × 2 GB × 17秒 = 19,380,000 GB秒 × \$0.0000166667 ≈ 月 \$323
- リクエスト: 570,000 × \$0.20 / 1M ≈ 月 \$0.11
- これに API Gateway の費用が数ドル加わります。
合計しても最悪ケースで月 \$350 程度 で、重くバースト的なワークロードを共有ワーカープールから切り離し、安定して予測可能なレイテンシを得る対価としては妥当です。
成果
現実的な最悪ケースをテストしました。大きな(11008×5504)パノラマ画像50枚を一度にアップロードし、本番同等のインフラで、アップロードからプレイヤーが使える状態になるまでを計測しました。
| パイプライン | エンドツーエンド |
|---|---|
| Sidekiq + krpano 1.1x(変更前) | 約8分50秒 |
| Lambda + py360convert(変更後) | 約1分50秒 |
エンドツーエンドで約4倍の高速化です。そして同じくらい重要なのは、Lambda の数値が安定していることです。Sidekiq ではワーカーの混雑具合でジョブ時間が大きくブレましたが、Lambda では隔離実行のおかげで50並列でも1個とほぼ同じ時間で終わります。
ロールアウトは 企業単位のフィーチャーフラグ で段階的に進めています — リクエストの一定割合を流すカナリア配信ではなく、企業ごとに新パスを有効化する段階的リリースです。

- 限定リリース — 一部の企業だけフラグを ON にし、性能とコストを観察。残りは Sidekiq + krpano 1.1x のまま。
- 全企業 — Lambda + py360convert を全社で有効化。
- クリーンアップ — 新パスの実績が確認できたら、旧 krpano ジョブとそのツール群を完全に削除。
まとめ
- CPU負荷が高くバースト的な処理は、共有ワーカープールから切り離す。 決定的な改善は Sidekiq 上でのライブラリ差し替えではなく、アーキテクチャの隔離でした。Lambda 上では
py360convertが特に大きな(11K)パノラマで優位となりました。 - リモート側は薄く保ち、presigned URL を渡す。 Lambda が直接S3を読み書きするため、画像バイトがアプリサーバーを通りません。
- リモート側の計算リソースを入力に合わせて最適化する。 ソースのピクセル数で Lambda のメモリ階層を選ぶことで、巨大なパノラマ画像でのメモリ不足も、小さな画像での過剰な支払いも避けられました。
Spacely では、Rails / Sidekiq を中心としたバックエンド開発を行っており、今回のようなパフォーマンスやアーキテクチャの課題をチームで議論しながら日々改善しています。こうした課題を一つずつ改善していく仕事に興味がある方と、一緒により良いプロダクトを作っていけたら嬉しいです。





