株式会社ヘンリー エンジニアブログ

株式会社ヘンリーのエンジニアが技術情報を発信します

Server-Side Kotlinで書かれたCloud Runサービスのコールドスタートレイテンシを短縮する

株式会社ヘンリーでSREなどをしている戸田(id:eller)です。先日弊社のエンジニアが登壇したサーバーサイドKotlin LT大会 vol.11でSansan社の柳浦様がServer-Side KotlinアプリのCloud Run コールドスタートレイテンシを改善した話をされていました。

Server-Side Kotlin アプリのCloud Run コールドスタート レイテンシを改善した話 - Speaker Deck

本件はCloud Runを使ってServer-side Kotlinを運用している弊社でも関心が高い内容です。そこで弊社の事例も紹介させていただければと思います。

動機:コールドスタートレイテンシを改善すると何が嬉しいか

Cloud Runはスケールアウト時に10秒までリクエストを滞留させます。そして10秒でインスタンスが用意できなかった場合は429エラーが発生する可能性があります。エラーの発生はユーザ体験を悪化させるため、常に多めにインスタンスを起動しておくか、コンテナを10秒以内に起動させる必要があるわけです。

常に多めにインスタンスを起動させる方法は容易ではありますが、ランニングコストを増大させます。コールドスタートレイテンシを改善できればスケールアウトに頼った運用も採用しやすくなり、ランニングコスト圧縮効果を期待できます。

採用した施策

DB接続確立の遅延

弊社ではJDBC接続のプールにHikariCPを採用しています。HikariCPはデフォルトの設定では、DataSource作成時に接続の確立まで処理をブロックします。

設定を変更することでDataSource作成をすぐに終了して後続処理を進めることができますが、この場合はDataSourceからConnectionを取得する際に例外が出る可能性があります。このためstartup probeが呼び出されたときにデータベースに接続できることを確認する処理を入れておくことが良いでしょう。

logbackの設定を動的に組み立てるのをやめる

弊社ではそこまでlogbackの設定を複雑にしていませんが、それでも起動時に ch.qos.logback.classic.util.DefaultJoranConfigurator が1秒弱の処理時間を持っていっていました。これはXMLの解析や各種インスタンスの作成・設定に時間がかかっているからと思われます。

この問題を解決するためのパッケージが logback-tyler として公開されています。まだ安定バージョンに到達していませんが、Javaコード生成ツールなので生成されたコードに自分で責任を持てば良いだけです。

また生成されたコード自身は logback-tyler に依存しないため、ランタイムの依存は追加する必要ありません。むしろjaninoへの依存を削れるので、1MiB程度ですがコンテナも小さくできます。設定時間は弊社事例では100msと、10倍高速化まで持っていくことができました。

利用しているConfiguratorは以下のようなものです。生成されたコードは変数名などに課題が多く、またLogstashEncoderなどに対応していなかったため、けっこう手を加えています。

import ch.qos.logback.classic.AsyncAppender
import ch.qos.logback.classic.LoggerContext
import ch.qos.logback.classic.spi.Configurator
import ch.qos.logback.classic.spi.Configurator.ExecutionStatus
import ch.qos.logback.classic.spi.ILoggingEvent
import ch.qos.logback.classic.tyler.TylerConfiguratorBase
import ch.qos.logback.core.Appender
import ch.qos.logback.core.ConsoleAppender
import net.logstash.logback.encoder.LogstashEncoder
import net.logstash.logback.stacktrace.ShortenedThrowableConverter
import kotlin.system.measureTimeMillis

class TylerConfigurator : TylerConfiguratorBase(), Configurator {
    override fun configure(loggerContext: LoggerContext): ExecutionStatus {
        context = loggerContext
        val elapsed = measureTimeMillis {
            val asyncAppender = setupAsyncAppender()
            val loggerRoot = setupLogger("ROOT", "INFO", null)
            loggerRoot.addAppender(asyncAppender)
        }
        println("TylerConfigurator.configure() call lasted $elapsed milliseconds.")
        return ExecutionStatus.DO_NOT_INVOKE_NEXT_IF_ANY
    }

    private fun setupAsyncAppender() = AsyncAppender().apply {
        context = this@TylerConfigurator.context
        name = "ASYNC"
        queueSize = 1024

        addAppender(setupStdoutAppender())
    }.also {
        it.start()
    }

    private fun setupStdoutAppender() = ConsoleAppender<ILoggingEvent>().apply {
        context = this@TylerConfigurator.context
        name = "STDOUT"
        encoder = setupLogstashEncoder()

        addFilter(HealthCheckLogFilter())
    }.also {
        it.start()
    }

    private fun setupLogstashEncoder() = LogstashEncoder().apply {
        context = this@TylerConfigurator.context
        throwableConverter = ShortenedThrowableConverter().apply {
            context = this@TylerConfigurator.context
            maxDepthPerThrowable = 50
            shortenedClassNameLength = 20
        }
        fieldNames.apply {
            level = "severity"
            logger = "loggerName"
            thread = "threadName"
            levelValue = "[ignore]"
        }
    }
}

JITコンパイラ最適化

Sansan様の資料にも記載されているものです。Cloud Runのドキュメントでも紹介されていますので、すでに試された方も多いかもしれません。

k6による負荷テストを行ったところ、弊社サービスではパフォーマンスへの影響も限定的と判断できました。これにより1秒近い高速化効果が得られました。

CPU boost

Sansan様の資料にも記載されているとおり、起動時にCPUを追加するCPU boostがとても強力です。JavaやKotlinのサーバはDB接続の確立やDIコンテナの初期化を行ってはじめて接続を受け付けることになりますが、DIコンテナの初期化すなわち依存関係の算出やインスタンス生成はCPUを多く使う処理です。

とはいえ普通にコードを書いていてはCPUコア数増大のメリットは受けにくいようにも感じます。JITやGCでの活用はできているはずですが…。本当はDIコンテナによるインスタンス生成をcoroutineを使って並列化したいのですが、弊社で使っているKoinにはまだこうした機能がまだありません。 lazyModule を使った遅延初期化がexperimentalな機能として実装中とのことで期待しています。

また弊社事例では該当しませんでしたが、CPUコア数が動的に変化するということは Runtime.availableProcessors() を参照して動的になにかを決定するような実装がある場合には注意が必要かもしれません。

適用を見送った手法

コンテナを小さくする

ベースイメージにalpineを使ったり、jlinkで不要な機能を削ぎ落としたJVMを作ったりしてコンテナを小さくできます。場合によっては大きな高速化効果を得られることもありますが、今回のケースでは0.5秒程度の短縮にとどまったため、ビルド工程をシンプルにするために採用しませんでした。

OpenTelemetryの自動計装を諦める

Sansan様の資料にはSplunkのagentの設定変更で充分に回避できると記載されていましたが、弊社はopentelemetry-javaagentを使っている関係からか、必要な自動計装だけを有効にしても10秒の壁を超えられませんでした。-Dotel.javaagent.debug=true オプションを有効にして調査したところ、bytecode manipulationの準備のために編集対象となるClassを探しているようで、ここがかなり遅そうでした。TypeInstrumentationのJavadocによるとクラス名以外の実装や親クラス・インタフェースによって挙動を変えるinstrumentationの場合にここが遅くなるようです。

公式GitHub Issuesにも似たような指摘と議論が複数存在します。こちらのIssueからリンクされているので、気になる方はご覧ください。昔は40秒とかかかってたんですね、さすがに今はそういったレベルではないですがパフォーマンスに課題があるのは変わらないようです。

一応extensionを用意して特定のクラスを対象から外すことはできるようですが、有効にしたい計装があまり多くないことからjavaagentによる自動計装を諦めることも充分に検討できそうです。

AppCDS

javaagentの利用を諦めるなら、AppCDSも検討できます。今回はAppCDS無しでも目標を達成できたので、ビルド工程をシンプルにするために採用しませんでした。

CRaC

JVMアプリケーションコンテナの起動高速化と言えばCRaC、という雰囲気はありますが、弊社で使っているgRPCサーバなどのフレームワークでは公式の対応を謳っているものがなく、検証工数が大きくなると判断して採用していません。Cloud RunでCRaCを使うこと自体は可能なようなので、こちらの記事などを参考に挑戦しても良いかもしれません。

まとめ

Cloud Runのコールドスタートレイテンシを改善すると何が嬉しいかについて述べ、弊社で採用した施策をいくつか紹介いたしました。よく紹介されている手法でもあまり効果がなかったり、新しく開発された技法が有効だったりと、検証のしがいがある技術領域だと感じます。特に logback-tyler はまだ情報が少ないので、この記事が皆さんの参考になれば幸いです。

弊社ではこれらの施策により、18秒近くかかっていたコールドスタートレイテンシが10秒程度に短縮されています。もっと短縮したいとは思っていますが、OpenTelemetry agentによる自動計装、CRaCなどの基盤技術に手を入れないと難しそうです。JVMアプリケーションやServer-side Kotlinの高速化なら任せろという方、ぜひ弊社の採用サイトにも足を伸ばしていただけると幸いです。

jobs.henry-app.jp