LoginSignup
4
1

More than 3 years have passed since last update.

Kotlin/Native on AWS Lambda その2 - カスタムランタイム

Last updated at Posted at 2020-12-12

要約

  • Kotlin/Native で書いたコードを AmazonLinux2 上でビルド
  • Kotlin/Native でカスタムランタイムを構築
  • SAM Local 上で動作確認

はじめに

こんにちは。 lasta です。

本記事は Kotlin Advent Calendar 2020 13日目の記事です。
昨日は mike_neck さんの あなたの知らない Gradle の kotlin-dsl プラグイン でした。

また、本記事は「Kotlin/Native on AWS Lambda」3部作の2作目になります。
Kotlin/Native を初めて触る方は Kotlin/Native on AWS Lambda その1 - 開発環境構築 を先にお読みいただくことを推奨します。

Kotlin/Native とサーバレスの現状

Kotlin 1.4 にて、 Kotlin/Native 含め Kotlin 及びその周辺に大幅なアップデートがありました。
詳細は公式ページ What's New in Kotlin 1.4.0 をご確認ください。

加えて kotlinx.serialization 1.0 がリリースされた (GA した) ことにより、 Kotlin/Native をプロダクション利用しやすくなりました。

一方で Kotlin/Native はまだまだ発展途上にあります。
JetBrains 公式のサーバレスフレームワークとして Kotless がありますが、 本記事執筆時点 (2020/12/12) では Kotlin-JVM しか対応していません。
GraalVM 向けはベータリリース段階であり、 Multiplatform 向け (JVM/JS/Native) は開発中です。

そのため、 AWS Lambda の Custom Runtime 上で Kotlin/Native を動かすことをゴールとして進めていきます。

周辺環境

  • MacBook Pro 2019
    • macOS Big Sur 11.0.1
  • :wrench: IntelliJ IDEA Ultimate 2020.3
  • :wrench: docker desktop 3.0.1
  • Kotlin 1.4.20
    • IntelliJ IDEA および Gradle が自動的に環境構築してくれるため、手動でのインストールは不要

:wrench: : 事前に同一またはそれ以降のバージョンのインストールが必要

プロジェクト構築等については、 前回の記事 に詳細の記載があります。

実装

ソースコード

実装の流れ

  1. プロジェクトの作成
  2. 開発環境の構築
  3. kotlinx.serialization の導入
  4. ktor client の導入
  5. template.yaml の作成
  6. AWS Lambda カスタムランタイム の実装
  7. 関数本体の実装

1. プロジェクトの作成

IntelliJ IDEA を用いて Kotlin の Native Application プロジェクトを作成します。
作成手順は 前回の記事 にて詳細に解説しているため、そちらを参照してください。

2. 開発環境の構築

AWS Lambda は Amazon Linux 2 で動作しているため、 Amazon Linux 2 向けのバイナリを作成する必要があります。
こちらも作成手順は 前回の記事 に詳細を記載しております。

Dockerfile
FROM amazonlinux:2
RUN yum -y install tar gcc gcc-c++ make ncurses-compat-libs
# for curl
RUN yum -y install libcurl-devel openssl-devel
RUN amazon-linux-extras enable corretto8
RUN yum clean metadata
# for gradle
RUN yum -y install java-1.8.0-amazon-corretto-devel
RUN yum -y install install which zip unzip
RUN curl -s http://get.sdkman.io | bash && \
    bash ${HOME}/.sdkman/bin/sdkman-init.sh && \
    source ${HOME}/.bashrc && \
    sdk install gradle

このあと作成する Lambda Function にて SSL 通信を行うため、 OpenSSL と libcurl もインストールしています。
Lambda の動作環境 には OpenSSL と libcurl が予め導入されています。

sh-4.2# echo $LD_LIBRARY_PATH
/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib

sh-4.2# ls /usr/lib64
# 一部抜粋
/usr/lib64/libcurl.so.4
/usr/lib64/libcurl.so.4.5.0
/usr/lib64/openssl

1 で作成したプロジェクトをビルドし実行できることを確認できたら OK です。

host
$ docker build -t gradle-on-amazonlinux2:1.0 .
$ docker run --memory=3g -v "$(pwd)":/root/work -itd gradle-on-amazonlinux2:1.0
$ docker exec -it $(docker ps | grep 'gradle-on-amazonlinux' | awk '{print $1}') /root/work/gradlew -p /root/work/ clean build
# 動作確認
# 作成したプロジェクトによって実行可能ファイルの名前が変わります
$ docker exec -it $(docker ps | grep 'gradle-on-amazonlinux' | awk '{print $1}') /root/work/build/bin/native/releaseExecutable/study-faas-kotlin-3.kexe
Hello, Kotlin/Native!

3. kotlinx.serialization の導入

「6. AWS Lambda カスタムランタイム」で必要となるため、 kotlinx.serialization を導入します。
kotlinx.serialization は Gradle plugin として配布されています。

build.gradle.kts
plugins {
    kotlin("multiplatform") version "1.4.20"
    kotlin("plugin.serialization") version "1.4.20" // 追加
}

また、 プラグインの追加の後に、 Json シリアライザも導入する必要があります

build.gradle.kts
kotlin {
    sourceSets {
        @kotlin.Suppress("UNUSED_VARIABLE")
        val nativeMain by getting {
            dependencies {
                implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1")
            }
        }

        @kotlin.Suppress("UNUSED_VARIABLE")
        val nativeTest by getting {
            dependencies {
                implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1")
            }
        }
    }
}

(余談) kotlinx.serialization の使用方法と動作確認

src/nativeTest/kotlin/me/lasta/sample/serialization/SerializationSample.kt
package me.lasta.sample.serialization

import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals

@Serializable
data class Project(val name: String, val language: String)

class SerializationSample {

    @Test
    fun serialize() {
        val actual = Json.encodeToString(
            Project("kotlinx.serialization", "Kotlin")
        )
        assertEquals(
            expected = """{"name":"kotlinx.serialization","language":"Kotlin"}""",
            actual = actual
        )
    }

    @Test
    fun deserialize() {
        val actual = Json.decodeFromString<Project>(
            """{"name":"kotlinx.serialization","language":"Kotlin"}"""
        )
        assertEquals(
            expected = Project("kotlinx.serialization", "Kotlin"),
            actual = actual
        )
    }
}
build
$ docker exec -it $(docker ps | grep 'gradle-on-amazonlinux' | awk '{print $1}') /root/work/gradlew -p /root/work/ clean build
run_test
$ docker exec -it $(docker ps | grep 'gradle-on-amazonlinux' | awk '{print $1}') /root/work/build/bin/native/debugTest/test.kexe
[==========] Running 2 tests from 1 test cases.
[----------] Global test environment set-up.
[----------] 2 tests from me.lasta.sample.serialization.SerializationSample
[ RUN      ] me.lasta.sample.serialization.SerializationSample.serialize
[       OK ] me.lasta.sample.serialization.SerializationSample.serialize (1 ms)
[ RUN      ] me.lasta.sample.serialization.SerializationSample.deserialize
[       OK ] me.lasta.sample.serialization.SerializationSample.deserialize (0 ms)
[----------] 2 tests from me.lasta.sample.serialization.SerializationSample (1 ms total)

[----------] Global test environment tear-down
[==========] 2 tests from 1 test cases ran. (2 ms total)
[  PASSED  ] 2 tests.

gradle-test.png

4. ktor client の導入

Kotlin/Native に対応している Web フレームワークとして Ktor があります。
1日目の記事「Spring BootとKtorの実装比較(初級編)」 にて Spring Framework と対応させながら Ktor をわかりやすく解説されています。
また、11日目の記事を執筆された doyaaaaaken さんとともに Ktor のドキュメントを 日本語化しています。 (GitHub)

本記事では、 Ktor のサブプロジェクトである Ktor client を利用します。

build.gradle.kts
kotlin {
    sourceSets {
        @kotlin.Suppress("UNUSED_VARIABLE")
        val nativeMain by getting {
            dependencies {
                implementation("io.ktor:ktor-client-core:$ktor_version")
                implementation("io.ktor:ktor-client-curl:$ktor_version")
                implementation("io.ktor:ktor-client-cio:$ktor_version")
                implementation("io.ktor:ktor-client-json:$ktor_version")
                implementation("io.ktor:ktor-client-serialization:$ktor_version")
            }
        }

        @kotlin.Suppress("UNUSED_VARIABLE")
        val nativeTest by getting {
            dependencies {
                implementation("io.ktor:ktor-client-mock:$ktor_version")
            }
        }
    }
}
gradle.properties
ktor_version=1.4.3
  • ktor-client-core : Ktor client の既定ライブラリ
  • ktor-client-curl : libcurl を利用するクライアントエンジン
  • ktor-client-cio : Kotlin でネイティブ実装されたクライアントエンジン
  • ktor-client-json : Ktor 内で Json を扱う際に必要
  • ktor-client-serialization : Ktor 外とのやりとり (外部通信等) の際のシリアライズ処理を自動化するために必要

(余談) クライアントエンジン

ktor-client-core は HTTP / HTTPS 通信を行うインタフェースのみ定義されているため、内部処理を外部から注入する必要があります。
この機構により、ビジネスロジックは共通化しながらプラットフォームごとにことなる通信まわりの内部処理を切り離すことに成功しています。

Kotlin/Native on Mac OS X / Linux では CIO (Coroutine-based I/O) と、CUrl の2つから選択することができます。

エンジン 特徴 導入容易性 SSL対応
CIO Ktor 独自に Kotlin のみで実装されているためプラットフォーム完全非依存 build.gradle.kts に書くだけなので簡単 非対応
Curl HTTP 通信デファクトスタンダードの Curl を利用 別途インストールが必要な場合がある 対応

CIO は導入が簡単ですが、 SSL 非対応 です。
対応予定があるのかどうか Kotlin 公式 Slack Workspace#ktor チャンネルで質問しましたが、しばらく先になりそうです。 (JetBrains の中の方からの回答)

Lambda カスタムランタイムを作成するだけであれば SSL 対応は不要ですが、このあと作成する Lambda 関数の内部で HTTPS 通信を行いたいため、本記事では Curl エンジンを採用します。
幸いにも AWS Lambda のランタイムには libcurl が予めインストールされているため、大きな問題はありません。
ですがビルド環境である Docker イメージ amazonlinux:2 にはデフォルトでインストールされていないため、 Dockerfile 内で OpenSSL と Curl をインストールしています。

5. template.yaml の作成

この時点では必須ではありませんが、 template.yaml の作成補助機能がついているため、この段階で AWS SAM CLI をインストールします。
インストール手順は 公式のドキュメント を参照してください。

本記事では SAM CLI を用いてローカル上で実行することをゴールと定めているため、必要最小限の事項のみ記載します。

sam/template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: 'SAM Template for study-faas-kotlin-3'

Globals:
  Function:
    Timeout: 5

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: './'
      Handler: handler
      Runtime: provided.al2
      Events:
        CatchAll:
          Type: Api
          Properties:
            Path: '/'
            Method: GET

今回はカスタムランタイムで作成するため、ランタイムとして provided.al2 (Amazon Linux 2 ベース) を指定します。

(余談) 簡単に template.yaml の動作確認をする

下記スクリプトを作成します。

bootstrap
#!/usr/bin/env bash
echo "Hello, SAM Local!"

このとき、下記3点のルールを厳守する必要があります。

  • ファイル名は bootstrap
    • 完全一致, 拡張子の追加も NG
  • bootstrap は template.yaml から見た相対パスに配置
    • CodeUri に指定した相対パスに配置
  • 実行権限を付与

この状態で、ローカルで実行します。

$ sam local start-api -t sam/template.yaml
Mounting HelloWorldFunction at http://127.0.0.1:3000/ [GET]
You can now browse to the above endpoints to invoke your functions. You do not need to restart/reload SAM CLI while working on your functions, changes will be reflected instantly/automatically. You only need to restart SAM CLI if you update your AWS SAM template
2020-12-12 22:43:23  * Running on http://127.0.0.1:3000/ (Press CTRL+C to quit)

無事待機状態になったら、ログに従いブラウザ等から http://127.0.0.1:3000/ にアクセスします。
まだカスタムランタイムを何も実装していないため、 502 エラーが返却されますが気にしないことにします。

response
{
  "message": "Internal server error"
}

実行ログに Hello, SAM Local! が出力されていれば、動作確認 OK です。

6. AWS Lambda カスタムランタイム の実装

ようやく本題です。

下記の2つのページを参考にしながら進めていきます。

エントリポイントの作成

前節で bootstrap という実行可能ファイルを作成しました。
これに相当するファイルを作成します。

今回は me.lasta.studyfaaskotlin3.entrypointmain という関数を作成し、これをエントリポイントとします。

src/nativeMain/kotlin/me/lasta/studyfaaskotlin3/entrypoint/main.kt
package me.lasta.studyfaaskotlin3.entrypoint

fun main() {

}

このファイルをエントリポイントとし、実行可能なファイルが作成されるよう build.gradle.kts も修正します。

build.gradle.kts
kotlin {
    nativeTarget.apply {
        binaries {
            executable("bootstrap") {
                entryPoint = "me.lasta.studyfaaskotlin3.entrypoint.main"
            }
        }
    }
}

カスタムランタイムの仕様

カスタムランタイムは下記の仕様に則り実装する必要があります。

カスタムランタイムの実装

前述の仕様に則り実装します。
具体的なコードは こちら を参照してください。

カスタムランタイム全体
src/nativeMain/kotlin/me/lasta/studyfaaskotlin3/lambdaruntime/LambdaCustomRuntime.kt
suspend inline fun <reified T> exec(block: (LambdaCustomRuntimeEnv) -> T) {
    lateinit var lambdaEnv: LambdaCustomRuntimeEnv
    try {
        while (true) { /* (1) */
            lambdaEnv = initialize() /* (2) */

            val response = try {
                block(lambdaEnv) /* (3) */
            } catch (e: Exception) {
                e.printStackTrace()
                sendInvocationError(lambdaEnv, e) /* (4) */
                null
            } ?: continue

            sendResponse(lambdaEnv, response) /* (5) */
        }
    } catch (e: Exception) {
        e.printStackTrace()
        sendInitializeError(lambdaEnv, e) /* (6) */
    }
}

引数に関数 block を受け取ります。
AWS Lambda で実装すべき関数そのものです。
最終的にシリアライズする際に具体的な型を保持し続ける必要があるため、 reified で型パラメータ T の型情報を維持します。

(1) 新しい呼び出しを待ち続けるため、無限ループで待ち続ける
(2) 「次の呼び出し API」 を呼ぶ
(3) 「次の呼び出し API」から受け取った情報をもとに、ビジネスロジック (block) を実行する
(4) ビジネスロジックの実行に失敗 (block 内で例外が発生) したため、実行に失敗した旨を送出する
(5) ビジネスロジックの実行に成功したため、返却値を送出する
(6) 「次の呼び出し API」の呼び出しに失敗したため、その旨を送出する

ここまででやるべきことを整理できたので、各 API を呼ぶ処理を実装していきます。

これ以降のコードでは、記事の都合上エラーハンドリングを省略しています。

次の呼び出し API /runtime/invocation/next
src/nativeMain/kotlin/me/lasta/studyfaaskotlin3/lambdaruntime/LambdaCustomRuntime.kt
suspend inline fun initialize(): LambdaCustomRuntimeEnv = httpClient.use { client ->
    LambdaCustomRuntimeEnv(client.get("$baseUrl/invocation/next"))
}

特筆すべきことはありません。
「次の呼び出し API」を呼び、結果を LambdaCustomRuntimeEnv に詰めて返却するだけです。

呼び出しレスポンス API /runtime/invocation/AwsRequestId/response
src/nativeMain/kotlin/me/lasta/studyfaaskotlin3/lambdaruntime/LambdaCustomRuntime.kt
suspend inline fun <reified T> sendResponse(
    lambdaEnv: LambdaCustomRuntimeEnv,
    response: T
): HttpResponse = httpClient.use { client ->
    client.post {
        url("http://$lambdaRuntimeApi/2018-06-01/runtime/invocation/${lambdaEnv.requestId}/response")
        body = TextContent(
            Json.encodeToString(ResponseMessage(body = Json.encodeToString(response))),
            contentType = ContentType.Application.Json
        )
    }
}

結果を body に詰めて返却します。

呼び出しエラー API /runtime/invocation/AwsRequestId/error
src/nativeMain/kotlin/me/lasta/studyfaaskotlin3/lambdaruntime/LambdaCustomRuntime.kt
suspend inline fun sendInvocationError(
    lambdaEnv: LambdaCustomRuntimeEnv,
    error: Exception
): HttpResponse = httpClient.use { client ->
    client.post {
        url("http://$lambdaRuntimeApi/2018-06-01/runtime/invocation/${lambdaEnv.requestId}/error")
        body = TextContent(
            Json.encodeToString(
                mapOf(
                    "errorMessage" to error.toString(),
                    "errorType" to "InvocationError"
                )
            ),
            contentType = ContentType.Application.Json
        )
    }
}

下記のフォーマットで POST します。

{
  "errorMessage": "エラーメッセージ",
  "errorType": "エラー種別"
}
初期化エラー API /runtime/init/error
src/nativeMain/kotlin/me/lasta/studyfaaskotlin3/lambdaruntime/LambdaCustomRuntime.kt
@KtorExperimentalAPI
suspend inline fun sendInitializeError(
    lambdaEnv: LambdaCustomRuntimeEnv,
    error: Exception
): HttpResponse = httpClient.use { client ->
    client.post {
        url("http://$lambdaRuntimeApi/2018-06-01/runtime/init/error")
        body = TextContent(
            Json.encodeToString(
                mapOf(
                    "errorMessage" to error.toString(),
                    "errorType" to "InvocationError"
                )
            ),
            contentType = ContentType.Application.Json
        )
    }
}

呼び出しエラーとパスが異なること以外は同じです。

下記のフォーマットで POST します。

{
  "errorMessage": "エラーメッセージ",
  "errorType": "エラー種別"
}

これでカスタムランタイムの実装の最低限の実装は完了です。
エラー発生時に Sentry や CloudWatch 等へ通知したり、「次の呼び出し API」から受け取った値をより扱いやすくパースしたりしていくことで、より堅牢なシステムになります。

7. 関数本体の実装

サンプル API として、外部 API から取得したレスポンスを詰め直して返却する API を作成していきます。
外部 API は JSONPlaceholder をお借りします。

src/nativeMain/kotlin/me/lasta/studyfaaskotlin3/entrypoint/main.kt
fun main() {
    runBlocking {
        LambdaCustomRuntime().exec(fetchUserArticle)
    }
}

val fetchUserArticle: (LambdaCustomRuntimeEnv) -> UserArticle = { _ ->
    runBlocking {
        HttpClient(Curl) {
            install(JsonFeature) {
                serializer = KotlinxSerializer()
            }
        }.use { client ->
            println("request: $URL")
            client.get(URL)
        }
    }
}
src/nativeMain/kotlin/me/lasta/studyfaaskotlin3/entity/UserArticle.kt
import kotlinx.serialization.Serializable

@Serializable
data class UserArticle(
    val userId: Int,
    val id: Int,
    val title: String,
    val body: String
)

動作確認

いよいよ動作確認を行います。

まずはビルドします。

docker exec -it $(docker ps | grep 'gradle-on-amazonlinux' | awk '{print $1}') /root/work/gradlew -p /root/work/ clean build

ビルド結果の bootstrap.kexe を適切なパスに配置します。

cp build/bin/native/bootstrapReleaseExecutable/bootstrap.kexe sam/bootstrap

なお、生成される実行可能ファイル名にて拡張子 kexe をつけない設定は存在しません。 (GitHub issue, YouTrack)
そのため、 Makefile 等を作成することをおすすめします。

続いて、ローカルで実行します。

$ sam local start-api -t sam/template.yaml

最後に、 API へリクエストします。

$ curl -s 'http://localhost:3000/
{"userId":1,"id":1,"title":"sunt aut facere repellat provident occaecati excepturi optio reprehenderit","body":"quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"}

無事実行できました。

今後の展望

今回作成したカスタムランタイムはプロトタイプです。
プロダクトとして利用するためには、少なくとも下記の対応が必要と考えられます。

  • 内部エラーが発生した際に適切にハンドリングする
  • 複数の API を作成する場合、エントリポイントを自動的に検知しバイナリを生成する
  • 「次の呼び出し API」のレスポンスを使いやすくパースする
  • ランタイムをライブラリ (klib) 化し、ポータビリティ性を高める
  • API テスト や CloudWatch Alarm 、単体テストなどを整備し、十分に監視・運用・テストができるようにする

おわりに

Kotlin/Native を AWS Lambda 上で動かすことができました。
Kotless が Native 対応するまでの間は有用となる情報であると考えています。
一方で、 Kotlin/Native のビルドが他の言語 (Go 言語等) と比較すると遅いこと、Kotlin Multiplatform は Alpha 版であること、 Ktor 1.4 系はまだ開発中であることなど、本番投入は時期尚早ととられ兼ねない段階であることを理解しておかなければなりません。

次回は発展編、ネイティブライブラリ (Sentry-native) を sam local 上で動かせるようにする予定です。

明日は lethe2211 さんです。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1