はじめに
こんにちは。株式会社LegalOn TechnologiesでソフトウェアエンジニアをしているLiboです。
弊社のメインプロダクト「LegalOn」において、「エディター」と呼んでいる機能の基盤をAWS LambdaからKubernetesに移行した際、スケーラビリティの課題に直面しました。本記事では、一見不可解な挙動をどのように調査し解決へと導いたか、一連の流れをご紹介します。
※エディター機能とは、アップロードされた契約書(docxファイル)を、JSON形式のデータ構造に変換し、storageに保存する役割を持つコンポーネントです。フロントエンドは、その保存済みデータを読み込んで契約書を画面に表示します。
背景: なぜLambdaから移行したのか
「LegalOn」は、既存プロダクトのコンセプトを基に新たに開発されたプロダクトです。基となった既存プロダクトでは、主にAWS上でインフラを構築しており、アプリケーションもRubyで実装していました。エディター機能も、RubyとAWSを組み合わせて開発しており、大量の契約書を一度にアップロードするケースなど、バースト的なトラフィックを想定していたことから、即時スケーリング可能なAWS Lambdaを採用していました。
その後、「LegalOn」の開発を進めるにあたり、インフラ基盤をGoogle Cloudに統一する方針が決まりました。しかし、リリース速度を優先したことで、エディター機能については当面AWS Lambdaを使い続ける判断をしました。
<before>

インフラを統一したいという思いは以前からあったものの、このようなTech Debtを解消するには、何らかのきっかけが必要でした。その転機となったのが、Serverless Frameworkの有償化です。 これを機に、Lambdaの利用状況とコストをあらためて確認したところ、想像以上に費用が高いことが分かり、Google Cloudへの移行を本格的に決断しました。
移行作業は2024年10月にスタートし、同年12月の移行完了を目標に進めていきました。
<目標のafter>

移行の準備
移行の条件設定
今回の基盤移行においては、以下の条件を設定して移行を行いました。
- AWSからGoogle Cloudへと完全移行すること
- Rubyの使用を継続すること
- すべてのコードを書き換えるのはリスクが高く、工数も大きいため、Rubyのまま移行する方針にしました。
- Google Kubernetes Engine (GKE) を利用すること
- Cloud Runと比較検討した結果、「LegalOn」の他サービスが利用しているGKEに基盤を統一する意思決定を行いました。
- AWS時代もLambdaのコールドスタートを避けるため、常に関数を待機状態にしていました。関数が多くからこそ1台のGKEの大きめなサービスに統一すれば、リソースの最適化の見込みがありました。
gRPC通信への対応
「LegalOn」は基本的にgRPCを利用していますが、AWS LambdaはHTTPで通信していました。そのため、移行にあたりgRPCサーバを用意する必要があり、今回はGrufを採用しました。
Grufのメリット
- Gruf により抽象化されているため、gRPC Ruby Libraryを直接使うよりも実装が簡単。
- Grufが提供するエコシステムを利用できる。
- インターセプターを使い、リクエスト/レスポンスの前後に処理を挟むことができる。
- RSpecなどでのテスト実装も容易。
Grufのデメリット
- gRPCの低レイヤー機能はGrufでは提供されていないため、必要ならgRPC Ruby Libraryを用いた実装が必要。結果としてGrufとgRPC Ruby Libraryの両方でコードを書く場合があり、複雑になりやすい。
- 実際のところ、gRPCの低レイヤーに触る必要がなくて、杞憂に過ぎなかったです。
検証開始: 見えないボトルネックとの遭遇
Lambdaの強みとしてよく挙げられるのは、実質的に無制限にスケールできる点です。今回も、基本的な動作に問題がないことを確認したうえで、スケーラビリティを検証するためロードテストを実施しました。まず現在の実トラフィック量をもとに、妥当な負荷を生成し、QA環境で負荷試験を行いました。
しかし予想に反して、すぐに unavailable: no healthy upstreamが発生してしまいました。調査したところ、readiness probeが失敗し、その結果、サービス全体が利用不能になる状態に陥っていたことが分かりました。

原因調査

この問題の原因として、次のような可能性を検討しました。
- メモリ不足でサーバーが落ちている可能性
- Google Cloudがメモリを解放する速度を上回るペースでリクエストが到達すると、メモリ不足になるのかもしれません。
- CPUの利用率が高すぎる可能性
- どこかでロックが発生し、ロック待ちになっている可能性
- リクエスト数に対してサーバのThreadPoolが小さく、さばききれていない可能性
しかし、実際のメモリ使用率は50%以下で、CPU使用率もメトリクス上は50%程度に留まっていました。また、コードベースからも明確なロック箇所は見つからず、ThreadPoolサイズを30から100に増やしても挙動は改善しませんでした。
Traceで手がかりを探る
問題の傾向をよりはっきりさせるため、Traceを導入してみることにしました。「LegalOn」全体としては、OpenTelemetryを利用していますが、エディター機能にはまだ導入されていませんでした。そこで、この機会に問題がありそうな箇所へOpenTelemetryを組み込み、そのうえでもう一度負荷試験を行いました。

ロックの発生を疑って各処理を確認しましたが、特に待ちが発生している箇所はありませんでした。ただし、各段階の処理が全体的になんとなく遅いようにも感じました。この時点では、「ネットワーク処理を並行化すべきか?」とも考えましたが、そもそもサーバが落ちる原因を特定するには至りませんでした。
そんな中、Traceを詳細に眺めていると、気になる点を発見しました。
送信側では30秒かかっているように見える処理が、
受信側では23秒しか処理にかかっていないように見えます。
![]()
つまり、7秒の差がありました。
仮説:リクエストを受け付けられるまでに異常な遅延が発生しており、ここに何らかの問題があるのでは?
この差分を見て、以前Pythonで経験したGIL (Global Interpreter Lock)によるスループット低下のことを思い出しました。 「Rubyでも似たような仕組みがあるのでは?」と調べてみたところ、やはりRubyにも同等の仕組みが存在することがわかりました。
ボトルネックの正体はGVL (Global VM Lock)
Rubyでは、コード内で複数のスレッドを作成できるものの、Ruby VMの仕様として同時にCPUを使えるスレッドは1つだけという制約があります。これはPythonにおけるGIL(Global Interpreter Lock)と非常によく似ています。 そのため、Rubyの処理は単一スレッドで1.0 CPU相当しか使えないという状況になります。
ネイティブスレッドを用いて実装されていますが、現在の実装では Ruby VM は Giant VM lock (GVL) を有しており、同時に実行されるネイティブスレッドは常にひとつです。ただし、IO 関連のブロックする可能性があるシステムコールを行う場合には GVL を解放します。その場合にはスレッドは同時に実行され得ます。また拡張ライブラリから GVL を操作できるので、複数のスレッドを同時に実行するような拡張ライブラリは作成可能です。 (引用元:https://docs.ruby-lang.org/ja/latest/doc/spec=2fthread.html)
これを踏まえ、実際にGVLがボトルネックになっているかどうかを可視化するため、以下のツール・資料を参考に検証を行いました。

実際にGVLの挙動を可視化してみたところ、多くのスレッドがほぼずっとGVLの取得待ち状態になっていることが確認できました。グラフ上でも、本来「実行中」を示す青色(running)はほとんど表示されず、ほぼ待機状態という結果でした。
これでボトルネックの正体が判明しました。
問題の整理
今回遭遇したGKEでのスケーラビリティ問題を整理します。
失敗の主因
最も大きな理由は、CPUが不足している状態で処理が行われ続けた結果、タイムアウト(60秒)に達してしまったことです。
KubernetesのメトリクスではCPU使用率が低く見えるのはなぜか

実際には、エディターのmain containerが0.5CPU、sidecarも0.5CPUを使う前提でPod全体が1.0CPUに設定されていました。
そのため、
- main container が使える CPU の上限は 0.5 CPU(= Pod 全体の 50%)
普段 sidecar の CPU はほぼ 0 に近い
→ メトリクス上は「Pod 全体で 50%」に見える
→ 実際は main container が割り当て上限である 0.5 CPU を使い切っていた
という状況でした。
「no healthy upstream」エラーが出た理由
- 処理が GVL によってブロックされている間、health check に応答できなくなっていました
- その結果、Kubernetes の scheduler によって Pod が “unhealthy” と判断され、再起動対象にされてしまっていました
AWS Lambdaではなぜ発生しなかったのか
- Lambda は 1 リクエストにつき 1 インスタンスを起動するため、GVL が問題になりません
- インスタンス内にスレッド競合が発生しない構造のため、今回のような GVL ボトルネックは露呈しませんでした
対策と移行の効果
実施した対策
今回の問題を解決するために、以下の対策を行いました。
- main container の CPU を 0.5 → 1.0 に引き上げる
- 単一スレッド(GVL)の Ruby が 1 コアをフルに使えるようにするため
- HPA(Horizontal Pod Autoscaler)の導入
- main container の CPU 使用率が 75% を超えたら自動で Pod を増やす
- 送信側でリトライ可能にする
- アルゴリズム改善
- Trace を導入したことで内部処理のボトルネックも特定できたため、その部分を最適化
- 200 ページ超の長い契約書の処理が「2 分以上 → 約 8 秒」まで高速化
これらの対応を行ったうえで再度負荷テストを実施したところ、問題は解消されました。
問題が解消されたことで無事にリリースでき、ついに AWS Lambda からの移行を完了することができました!
移行により得られた効果
AWS LambdaからGKEへと基盤を移行したことで、以下の効果が得られました。
- インフラの統一
- AWS側の監視が必要がなくなり、運用負荷も軽減されました
- コスト削減
- 移行以前のLambdaの費用は、日本向けの環境だけで3,300ドル/月でした
- 一方、移行後の日環境のエディター機能に関連するGoogle Cloud費用は200ドル/月
- 結果として、約環境毎に3,100ドル/月のコスト削減につながりました!
- 技術スタックのシンプル化
- Serverless Frameworkへの依存を解消し、GKEでデプロイが完結するようになりました
- サードパーティツールの有償化リスクからも解放されました
まとめ
当初は2か月程度で完了するはずだったこの移行プロジェクトですが、実際には2024年10月から2025年4月まで、約半年にわたる取り組みとなりました。 インフラ移行はやはり想定以上に時間がかかるものであることをまた実感しました。
AWS Lambdaの基盤は当時SREが少ない中で構築されたものであり、限られたリソースで素早くスケーラブルな基盤を実現できた当時としてはいい選択でした。実際、この数年間、エディター機能を安定して支え続けてくれました。 しかし時間の経過とともに、組織やインフラの変更、Serverless Frameworkの有償化など、状況は大きく変化しました。かつての妥当な選択が徐々にTech Debtへと変わっていくのが仕方がないです。今回適切なタイミングでこれを解消できたことは良かったと感じています。移行の過程で直面した予想外の課題からも多くを学ぶことができました。
謝辞
- 今回の移行作業でご協力いただいた、黒田佑太さん、米屋孝貢さん
- 本ブログをレビューいただいた、翁松齢さん、黒田佑太さん、堀次真梨子さん、時武佑太さん、水谷穣さん
に深く感謝いたします。ありがとうございました!
仲間募集
この記事では主にRubyの話題を扱いましたが、現在の「LegalOn」では開発言語をGoとPythonに統一していく方針で進めています。とはいえ、言語に関係なく、深い技術的課題の調査や解決にわくわくできる仲間を募集しています。少しでもご興味のある方は以下からご応募ください。
難しい問題を一緒に楽しめるエンジニアを募集しています!