🔬

Cloud RunのJavaの起動速度を60%改善した

2022/12/18に公開

はじめに

私はGCPのCloud Runがお気に入りのサーバレス環境なのですが、一番得意な言語であるJavaを使った場合のスピンアップタイムの悪さには頭を悩ませています。
ローカルでは1,2秒で起動する場合でも、何故かCloud Runに持って行くと他の言語と比べても劇的に遅くなるのですよね... GraalVMのネイティブイメージを使えばGoなどと遜色無く瞬時に起動するのですが、ネイティブイメージへの変換は癖も強いため通常のJVMでも改善できないかとチューニングを試してみました。

TL;DR

  • 最終的に60%程起動速度を改善
  • 9秒台が3秒台になっているので体感としては結構変わる
  • native-imageが使え無いケースでもこのくらいなら許容出来そう
  • もちろん完全停止状態以外のレスポンスは3秒とか言わずミリ秒オーダー

準備と基礎値の計測

まずはアプリケーションを準備する必要があります。今回はHelidon MPをQuickStartで以下から作成しました。Helidonはコンテナで動かすことを想定しているので例えばFatJarを使わないなど基本的な工夫がされているのでとても使いやすいです。
https://helidon.io/starter/3.0.2?flavor=mp

こちらを付属のDockerfileでビルドし実行すると以下のようになります。ログからローカル環境だとリクエストを付け付けれるようになるまでおよそ2秒程度かかったことが分かります。

$ docker run myapp:nooption
2022.12.18 08:00:46 INFO io.helidon.common.LogConfig Thread[main,5,main]: Logging at initialization configured using classpath: /logging.properties
2022.12.18 08:00:47 INFO io.helidon.microprofile.server.ServerCdiExtension Thread[main,5,main]: Registering JAX-RS Application: HelidonMP
2022.12.18 08:00:48 INFO io.helidon.webserver.NettyWebServer Thread[nioEventLoopGroup-2-1,10,main]: Channel '@default' started: [id: 0x88692026, L:/0.0.0.0:8080]
2022.12.18 08:00:48 INFO io.helidon.microprofile.server.ServerCdiExtension Thread[main,5,main]: Server started on http://localhost:8080 (and all other host addresses) in 1988 milliseconds (since JVM startup).
2022.12.18 08:00:48 INFO io.helidon.common.HelidonFeatures Thread[features-thread,5,main]: Helidon MP 3.0.2 features: [CDI, Config, Health, JAX-RS, Metrics, Open API, Server]

こちらをCloud Runにデプロイして実行したところアプリケーション上の時間は2秒だったものが、以下の表のように7秒以上と相当な時間が掛かるようになります。curlはtimeコマンドを使ったスピンアップとレスポンスタイムを含んだ値です。cloudrunはJVMが起動してからReadyになるまでの実行時間。レスポンスは200msのはずなので差分はJVM自体の起動時間と考えられます。

curl cloudrun
9.271 7.718
9.397 7.734
9.677 8.08

アイドルモードだともちろんサクサク動作しますが、完全にシャットダウンされた後だと初回アクセスは常にこのくらいかかってしまいます...
https://cloud.google.com/blog/ja/products/serverless/lifecycle-container-cloud-run

これを何とかするのが今回のモチベーションです。基本的には以下の公式ガイドをベースにためしてみました。Helidonなどは既に考慮済みの部分もありますね。Nested Jar(FatJar)とか。その中でも特に効果を感じたものをピックアップしています。
https://cloud.google.com/run/docs/tips/java

AppCDSを利用する

まず最初に試したのはAppCDS(JEP 310: Application Class-Data Sharing)です。CDSはJVMで使われる標準ライブラリ等のクラスロードをした状態をダンプしておき次回実行ではそれを単純にメモリにマップする仕組みです。これにより起動速度の改善がはかれます。AppCDSはそれをさらにユーザのロジックまで拡張したものです。これで起動速度を改善出来そうです。

2019年のkisさんの記事だとDynamic CDSより手動でAppCDSのダンプ作った方が高速とあったのですが、環境の違いかチューニングが進んだのか私がJava17で確認する限りでは差は無かったので手軽なDynamic CDSをしています。

こちらはDocker buildのマルチステージでAppCDSを利用するDockerfileとなっています。抜粋すると以下の通り。ビルド時にtimeoutを使って5秒ほど起動させそのタイミングでダンプを作っています。

RUN timeout -s INT 5 java -XX:ArchiveClassesAtExit=mn.jsa -jar myproject.jar; echo "generate cds"

CMD ["java", "-XX:SharedArchiveFile=mn.jsa", "-jar", "myproject.jar"]

これによってローカルでは起動時間が2秒から1.5秒へと変わり500ms近く速くなりました。体感ではもたつきが無くなる感じ。Cloud Runに実際デプロイして測った場合は以下のようになりました。

curl cloudrun
7.83 6.222
6.971 5.354
7.234 5.443

1~2秒くらい改善していますね! このレベルの1~2秒は体感的には差が出ますが、さすがにもう少し頑張って欲しい所。

最適化コンパイラをオフにする

続いて実施したのは 「最適化コンパイラをオフにする」 ことです。Javaは起動時に様々な最適化を行っていますが、こちらの記事にも書いている通り、Cloud Runのようなサーバレスはプーリングはあれど、常にプロセスを常駐させてリクエストを待ち構えてるとは限りません。本質的にはプロセスを毎回起動するようなCGIにモデルとしては近いです。そのため「長時間実行がされない」という観点で起動時の最適化より起動速度を優先するのはトレードオフとしては有用な事も多いです。

という分けで最適化コンパイラのオプションを無効にするためにTieredCompilationTieredStopAtLevel=1を指定します。

ENV JAVA_TOOL_OPTIONS "-XX:+TieredCompilation -XX:TieredStopAtLevel=1"
CMD ["java", "-jar", "myproject.jar"]

300msから400ms程度改善しました。以下がCloud Runでの実行結果です。

curl cloudrun
6.234 5.022
5.715 4.409
5.224 4.084

ちなみにAppCDSと両方組み合わせる事が出来、以下のようになりました。

curl cloudrun
5.187 3.95
4.628 3.459
4.563 3.387

かなり元より高速化されていますね!

CPU Boostを使う

JVMのアプローチとは別にCPU Boostも使ってみます。
https://cloud.google.com/run/docs/configuring/cpu#startup-boost
これはCloud Runの新機能で起動時だけ 「CPUのコア数を増量する」 というものです。クロックじゃないからあまり関係ないかな? と思ってたのですが思いのほか効果が大きかったです。

curl cloudrun
5.507 3.922
5.414 3.936
5.641 4.084

公称通り約50%近く改善していますね! コアを2個とかに増やしてBoostをしてもそこまで大きな変化はなかったのでJVMの起動やそれに伴う処理がシングルスレッドではキツイので2コアは欲しい、というところなのかもしれません。コストインパクトもほぼ無いので有効にしておきたいオプションですね。

さらにJVMの最適化も併せて行った結果は以下の通りです。

curl cloudrun
4.481 3.068
3.709 2.505
3.608 2.936

かなり改善しましたね!

分析サマリ

先ほどの実行結果をそれぞれ平均してグラフにしたものが以下となります。

Method curl Cloud Run
No Option 9.45 7.84
AppCDS 7.35 5.67
NoJIT 5.72 4.51
CPU Boost(1 -> 2) 5.52 3.98
AppCDS + NoJIT 4.79 3.60
AppCDS + NoJIT + CPU Boost 3.93 2.84

元の数字から60%近く改善していて体感では5秒近い差です。Webアクセスでの5秒は結構大きなUXの違いですから、これらのチューニングは是非行っておきたいですね。

ちなみにnative-imageを使った場合は1.7秒前後だったので、native-imageを作るための労力を考えると悪くないトレードオフに見えます。ただし、この辺はアプリでどのくらいクラスを持っているかにも強く依存するはずなので、一概には言えない部分でしょうけれど。

まとめ

特にCloud Runで問題になりやすい起動速度に関してどこまでチューニングで戦えるのかを確認してみました。GraalVMのネイティブイメージが使えればベストですが、チューニングだけでも3~4秒だとかなり高速化出来た感じがしますね。10秒だとUX的にツラかったけど4秒なら初回だけだしなんとか...

ちなみにAWSではAppCDS見たいな振舞いをより低レイヤでサポートするSnapStartという機能をLambdaに実装しているようで、最大10倍短縮可能とか。是非とも同等の機能がCloud Runにも入って欲しいですね>< 
https://aws.amazon.com/jp/blogs/news/new-accelerate-your-lambda-functions-with-lambda-snapstart/

え、そもそもRustやGoで書けば良いって? それはそう。ただ、過去資産や作業リソースの最適化もあるしね。そこはケースバイケース。
それではHappy Hacking!

Discussion