Kotlinで書いたサーバアプリケーションのDockerイメージ構築パターン

f:id:a-yamada:20170101191753p:plain

Kotlinで書いたAPIサーバをDockerコンテナで運用する場合、どのような方式が実用的かを考えてみた。パターンと言っても今のとこ今回の一種くらい。

サンプルアプリ

今回書いたサンプルはこちら。

github.com

以前書いたSpark Framework with Kotlinの延長線上で、今回もSpark FrameworkをKotlinで書いてます。

エンドポイントはこれだけ。

package io.stormcat

import spark.Spark.*

fun main(args: Array<String>) {

    get("/echo", { req, res ->
        "Hello, ${req.queryParams("name")}!"
    })

}

GradleでJarをビルドする

KotlinなのでもちろんGradleを使ってビルドします。重要なのは、作成したアプリケーションをSpringBootのように一つのJARファイルにしてしまう、javaコマンドに-jarを渡すだけで実行できるようにすることです。雑にベタッと貼りましょう。

group 'io.stormcat'

buildscript {
    ext.kotlin_version = '1.0.4'

    repositories {
        mavenCentral()
    }
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

apply plugin: 'java'
apply plugin: 'kotlin'
apply plugin: "idea"
apply plugin: 'application'

mainClassName = "io.stormcat.ApplicationKt"

processResources.destinationDir = compileJava.destinationDir
compileJava.dependsOn processResources

kapt {
    generateStubs = false
}


idea {
    module {
        inheritOutputDirs = false
        outputDir = file("$buildDir/classes/main/")
    }
}


sourceCompatibility = 1.8
targetCompatibility = 1.8

repositories {
    jcenter()
    maven { url "http://repository.jetbrains.com/all" }
    mavenCentral()
}

dependencies {
    compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
    compile "com.sparkjava:spark-core:2.5.4"
    testCompile "junit:junit:4.11"
}

jar {
    exclude 'META-INF/*.RSA', 'META-INF/*.SF','META-INF/*.DSA'
    manifest {
        attributes "Main-Class" : "io.stormcat.ApplicationKt"
    }
    from configurations.compile.collect { it.isDirectory() ? it : zipTree(it) }
}

sourceSets {
    main.kotlin.srcDirs += 'src/main/kotlin'
    main.java.srcDirs += 'src/main/java'
}

ポイントは

  • applicationプラグインを使う
  • アプリケーションを実行するためのmainクラスを指定する。mainメソッドはapplication.ktに作っているので、Javaのクラス上mainクラスはApplicationKtになる
  • jarに何か証明書が紛れ込んでアプリケーションの起動に失敗するので、RSA/SF/DSA拡張子のファイルをマニフェスト領域から除去する

Dockerfileを作る

Alpine Linux

一般的にJavaアプリケーションを作ると、様々のライブラリに依存するためそこそこサイズが大きくなります。ここで利用するのは軽量なディストリビューションでお馴染みのAlpine LinuxベースのDockerイメージです。AlpineをDockerイメージに使う手法は昨年一気にデファクトになった感あるので、知らない方はググってみてください。

ちなみにDockerの各種公式イメージもAlpine化がどんどん進んできていて、JavaのイメージもAlpineベースのものが用意されてます。

f:id:a-yamada:20170101201143p:plain

サイズは約50MBで、AlpineじゃないJavaのイメージは260MBとかあるのでかなりスリムになっているのがわかります。今回はjava:openjdk-8-jdk-alpineを利用。

Dockerfile内でビルド

このベースイメージを使ってこのサンプルアプリのDockerイメージを作ります。こんな感じに。

FROM java:openjdk-8-jdk-alpine

COPY . /spark-kotlin-docker

RUN apk update && \ 
    apk add --virtual build-dependencies build-base bash curl && \
    cd /spark-kotlin-docker && ./gradlew clean && \
    cd /spark-kotlin-docker && ./gradlew build && \
    mkdir -p /usr/local/spark-kotlin-docker/lib && \
    cp -R /spark-kotlin-docker/build/libs/* /usr/local/spark-kotlin-docker/lib/ && \
    apk del build-dependencies && \
    rm -rf /var/cache/apk/* && \
    rm -rf ~/.gradle && \
    rm -rf /spark-kotlin-docker
  • リポジトリをガサッとdockerコンテナ内にコピー(.dockerignoreを使って、build/.git/をコピーしないということもできる)
  • AlpineのパッケージマネージャAPKのリポジトリをアップデート
  • ビルドに必要なbuild-basebash(gradlewがbashに依存しているため)、curl等をインストール
  • gradleでビルドしてJarを作り、適当な場所に置く
  • ビルドで利用した産業廃棄物を削除

これが基本的なビルドプロセス。RUNを1回で数珠つなぎにしてるのは、RUNの度に生成されるDockerイメージのレイヤーを少しでも減らすため。

jolokia

運用を考えて、ちゃんとJVMのメトリクスを取れるようにしておきます。ここで選択するのは安定のjolokia。

jolokia.org

jolokiaはDockerfile内でダウンロードするようにします。最初にAPKでcurlをインストールしたのがここで活きます。

RUN apk update && \ 
    apk add --virtual build-dependencies build-base bash curl && \
    cd /spark-kotlin-docker && ./gradlew clean && \
    cd /spark-kotlin-docker && ./gradlew build && \
    mkdir -p /usr/local/spark-kotlin-docker/lib && \
    cp -R /spark-kotlin-docker/build/libs/* /usr/local/spark-kotlin-docker/lib/ && \
    curl -o /usr/local/spark-kotlin-docker/lib/jolokia-jvm-agent.jar https://repo1.maven.org/maven2/org/jolokia/jolokia-jvm/1.3.5/jolokia-jvm-1.3.5-agent.jar && \
    apk del build-dependencies && \
    rm -rf /var/cache/apk/* && \
    rm -rf ~/.gradle && \
    rm -rf /spark-kotlin-docker

ENTRYPOINTでアプリケーションを起動する処理を記述しますが、ついでに-javaagentオプションを設定できるようにしておいてjolokiaを有効にします。ポート8778にHTTPリクエストを投げるとメトリクスを取得できます。

ENTRYPOINT java $JAVA_OPTS -javaagent:/usr/local/spark-kotlin-docker/lib/jolokia-jvm-agent.jar=port=8778,host=0.0.0.0 -jar /usr/local/spark-kotlin-docker/lib/spark-kotlin-docker.jar

最後に、ポートをバインドしておわり。4567はSparkのデフォルトポート、8778はjolokia。

FROM java:openjdk-8-jdk-alpine

COPY . /spark-kotlin-docker

RUN apk update && \ 
    apk add --virtual build-dependencies build-base bash curl && \
    cd /spark-kotlin-docker && ./gradlew clean && \
    cd /spark-kotlin-docker && ./gradlew build && \
    mkdir -p /usr/local/spark-kotlin-docker/lib && \
    cp -R /spark-kotlin-docker/build/libs/* /usr/local/spark-kotlin-docker/lib/ && \
    curl -o /usr/local/spark-kotlin-docker/lib/jolokia-jvm-agent.jar https://repo1.maven.org/maven2/org/jolokia/jolokia-jvm/1.3.5/jolokia-jvm-1.3.5-agent.jar && \
    apk del build-dependencies && \
    rm -rf /var/cache/apk/* && \
    rm -rf ~/.gradle && \
    rm -rf /spark-kotlin-docker

ENTRYPOINT java $JAVA_OPTS -javaagent:/usr/local/spark-kotlin-docker/lib/jolokia-jvm-agent.jar=port=8778,host=0.0.0.0 -jar /usr/local/spark-kotlin-docker/lib/spark-kotlin-docker.jar

EXPOSE 4567 8778 

Dockerビルドして動かしてみる。

ビルドして、コンテナ起動。

$ docker build -t stormcat24/spark-kotlin-docker .
$ docker run -d -p 4567:4567 -p 8778:8778 stormcat24/spark-kotlin-docker

アプリケーションのエンドポイントにリクエストを投げる。

$ curl -s http://localhost:4567/echo?name=nekotan
Hello, nekotan!%

jolokiaのメトリクスはこんなふうに取れる。

$ curl -s http://localhost:8778/jolokia/read/java.lang:type=Memory | jq .

{
  "request": {
    "mbean": "java.lang:type=Memory",
    "type": "read"
  },
  "value": {
    "ObjectPendingFinalizationCount": 0,
    "Verbose": false,
    "HeapMemoryUsage": {
      "init": 33554432,
      "committed": 32505856,
      "max": 466092032,
      "used": 4764040
    },
    "NonHeapMemoryUsage": {
      "init": 2555904,
      "committed": 15204352,
      "max": -1,
      "used": 14577264
    },
    "ObjectName": {
      "objectName": "java.lang:type=Memory"
    }
  },
  "timestamp": 1483267842,
  "status": 200
}

Dockerイメージのサイズ

サンプルアプリにほとんど機能がないため、依存してるのはKotlinとSparkだが約160MBになった。

$ docker images | grep spark-kotlin-docker
stormcat24/spark-kotlin-docker        latest                 44f28ceaa7b9        40 minutes ago      162.2 MB

色々機能を追加していって依存が増えれば、200-250MB近辺くらいでしょうか。JVM系言語を使うとある程度大きくなるのは避けられないですが、JVM系でこれくらいにおさえられるならまあ及第点かなという印象。

まとめ

まあまあいいんじゃないの。