UPSIDER Tech Blog

「あれ?Localでは動くのにDockerではKtorが起動しない」原因を追ってOSSに貢献した話 〜Ktor ConfigLoaderのハマりポイントと解決まで〜

こんにちは!PRESIDENT CARDというプロダクトのエンジニアをしていますRyuheiです! 最近の個人的ニュースは、Kubestronautになれたことです。ジャケットが届いたら、それを着て散歩かランニングでもしてみようかなと思います。 prtimes.jp

Ktor

突然ですが、UPSIDERでは、BackendのフレームワークにKtorを利用して新規Webアプリケーションを開発することがあります。 このブログではそのKtor Serverの設定ミスで時間を溶かした直近の実体験について紹介します。

Ktor Serverを設定する方法は以下の2種類があります。

  • HOCON (.confファイル)
  • YAML (.yamlファイル)

詳しくはKtor公式のConfiguration in a fileを参照してください。

直面した問題

私たちは以下のようにHOCON形式で環境に応じた設定ファイルを用意していました。

app/src/main/
├── kotlin ... アプリケーションコード
└── resources
    ├── application_local.conf
    ├── application_prod.conf
    └── application_staging.conf

ある日、「あれ、Localではアプリケーションが起動できるけど、Dockerでの起動ができないな」という事象に遭遇しました。そのエラーが以下です。

Exception in thread "main" java.lang.IllegalArgumentException: Neither port nor sslPort specified. Use command line options -port/-sslPort or configure connectors in application.conf
        at io.ktor.server.engine.CommandLineKt.CommandLineConfig(CommandLine.kt:74)
        at io.ktor.server.netty.EngineMain.createServer(EngineMain.kt:39)
        at io.ktor.server.netty.EngineMain.main(EngineMain.kt:24)

エラーの中身からして、私たちがカスタムした設定ファイルではなく、デフォルトの設定パスを見にいっているようでした。 この時の build.gradle.kts および Dockerfile は以下のような感じです。

plugins {
    alias(libs.plugins.kotlin.jvm)
    alias(libs.plugins.ktor)
    application
}

application {
    mainClass.set("io.ktor.server.netty.EngineMain")
}

repositories { mavenCentral() }

dependencies {
    // Ktor
    implementation(libs.bundles.ktor.server)
    implementation(libs.bundles.ktor.client)

    // Configuration
    implementation(libs.ktor.server.config.yaml)

    // Other dependencies
    ...
}
# See https://ktor.io/docs/docker.html#prepare-docker
# Stage 1: Cache Gradle dependencies
FROM gradle:latest AS cache
RUN mkdir -p /home/gradle/cache_home
ENV GRADLE_USER_HOME=/home/gradle/cache_home
COPY build.gradle.* gradle.properties /home/gradle/app/
COPY gradle /home/gradle/app/gradle
WORKDIR /home/gradle/app
RUN gradle clean build -i --stacktrace

# Stage 2: Build Application
FROM gradle:latest AS build
COPY --from=cache /home/gradle/cache_home /home/gradle/.gradle
COPY --chown=gradle:gradle . /home/gradle/src
WORKDIR /home/gradle/src
# Build the fat JAR, Gradle also supports shadow
# and boot JAR by default.
RUN gradle buildFatJar --no-daemon

# Stage 3: Create the Runtime Image
FROM amazoncorretto:22 AS runtime
EXPOSE 8080
RUN mkdir /app
COPY --from=build /home/gradle/src/build/libs/*.jar /app/application.jar
ENTRYPOINT ["sh", "-c", "java -jar /app/application.jar -config=${CONFIG_FILE}"]   # CONFIG_FILEに起動時のHOCONファイルを環境変数として設定する

JARファイル内で規定のパスにファイルが置かれていることは以下のコマンドで確認済みです。

$ jar tf /app/application.jar | grep application_local.conf
application_local.conf

また、ローカルの起動コマンドは以下のようなものです。

$ java -classpath .../ktor-server-core-jvm-3.3.0.jar .../ktor-server-config-yaml-jvm-3.3.0.jar -config application_local.conf

みなさんはこのアプリケーションのDockerイメージが起動できない問題の原因について検討がついたでしょうか?私はこれで数時間は溶かしました。

原因の発覚

HOCON形式のファイルが読めなくなる原因は、io.ktor:ktor-server-config-yamlを依存関係に追加していることにあります。
このライブラリを含めてJARファイルを作成すると、io.ktor:ktor-server-coreに含まれるHoconConfigLoaderではなく、YamlConfigLoaderConfigLoaderインタフェースの実装として利用されます。

io.ktor:ktor-server-config-yamlが含まれたJARファイルの中身は以下のようになっていました。

$ jar -tf build/libs/hocon-but-yaml-included-all.jar | grep "ConfigLoader"

META-INF/services/io.ktor.server.config.ConfigLoader
io/ktor/server/config/yaml/YamlConfigLoader.class
io/ktor/server/config/ConfigLoader$Companion.class
io/ktor/server/config/ConfigLoader.class
io/ktor/server/config/ConfigLoadersJvmKt.class
io/ktor/server/config/HoconConfigLoader.class

META-INF/services/io.ktor.server.config.ConfigLoaderで登録されているConfigLoaderは以下になります。

$ jar xf hocon-but-yaml-included/build/libs/hocon-but-yaml-included-all.jar META-INF/services/io.ktor.server.config.ConfigLoader

$ cat META-INF/services/io.ktor.server.config.ConfigLoader
io.ktor.server.config.yaml.YamlConfigLoader

これが原因の本質です。

この辺りは、Ktor固有の仕様というよりは、JVMのServiceLoaderの仕組みに起因するようです。 ServiceLoaderはクラスパス上の.classファイルを直接スキャンするのではなく、META-INF/services/io.ktor.server.config.ConfigLoaderに記載されたクラス名を読み取り、そこに登録されている実装をインタフェースの実装として利用します。 buildFatJarshadowJarなど実行時に、サービスが競合してしまうということですね。

一方、ローカルでの起動時はktor-server-corektor-server-config-yamlが別々のJARとしてクラスパス上に存在しています。 ServiceLoader は両方のJARのMETA-INF/services/io.ktor.server.config.ConfigLoaderファイルを読み込めたので、.conf形式のファイルも正しく読み込めていたというわけです。

個人的には、io.ktor:ktor-server-config-yamlが依存関係にあっても、HOCON形式のファイルの読み取りの挙動には影響を与えないとばかり思っていたので、知見になりました。

そしてContributionへ

この挙動については、過去にもKtorのSlackコミュニティやYouTrackで同様に困っている開発者の投稿をいくつか見つけました。

自分もこの挙動にハマった身として、起動時のConfigurationファイルの読み取りログを出力するContributionをしている最中です。

まずは、GitHubの公式ktorio/ktorRepositoryのREADME.mdとCONTRIBUTING.mdを読み、Contributionの仕方やKtorチームのContributionに対する考え方を学びました。

次に、いきなりPRを上げるのではなく、KtorのSlackコミュニティで投稿してみました。

その際に、自分のRepositoryで再現手順なども簡単にまとめてから提出するようにしました。 再現Repositoryはこちらになります。

現在はYouTrackでIssueを作り、ログ出力を実装してPRをあげています。 執筆時点ではReviewいただいている最中なのでどうなるかはわかりませんが、少しでも自分の経験の発信がKtorコミュニティの役に立てばと思っています。

終わりに

このブログでは以下のことを書きました。

  • Ktor Serverの設定で、io.ktor:ktor-server-config-yamlが依存関係に入っていることにより、指定した.confファイルを読めなくなるという事象に遭遇しました。
  • 自分のようにこの挙動で時間を溶かす人が少しでも減るよう、現在KtorにContributionをしています。次のリリースに採用されると嬉しいです。

同じように悩む人がいたら、この経験が少しでも助けになれば嬉しいです。
それでは、みなさんも Happy な Ktor Life を!

Reference

We Are Hiring !!

UPSIDERでは現在積極採用をしています。 ぜひお気軽にご応募ください。

herp.careers

herp.careers

UPSIDER Engineering Deckはこちら📣

speakerdeck.com