要約
- 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 on AWS Lambda その1 - 開発環境構築
- Kotlin/Native on AWS Lambda その2 - カスタムランタイム (本記事)
- Kotlin/Native on AWS Lambda その3 - 外部ライブラリ (Sentry) の導入 (執筆中)
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
-
IntelliJ IDEA Ultimate 2020.3
- Community 版でもおそらく可能 (機能比較)
- docker desktop 3.0.1
- Kotlin 1.4.20
- IntelliJ IDEA および Gradle が自動的に環境構築してくれるため、手動でのインストールは不要
: 事前に同一またはそれ以降のバージョンのインストールが必要
プロジェクト構築等については、 前回の記事 に詳細の記載があります。
実装
実装の流れ
- プロジェクトの作成
- 開発環境の構築
- kotlinx.serialization の導入
- ktor client の導入
- template.yaml の作成
- AWS Lambda カスタムランタイム の実装
- 関数本体の実装
1. プロジェクトの作成
IntelliJ IDEA を用いて Kotlin の Native Application プロジェクトを作成します。
作成手順は 前回の記事 にて詳細に解説しているため、そちらを参照してください。
2. 開発環境の構築
AWS Lambda は Amazon Linux 2 で動作しているため、 Amazon Linux 2 向けのバイナリを作成する必要があります。
こちらも作成手順は 前回の記事 に詳細を記載しております。
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 です。
$ 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 として配布されています。
plugins {
kotlin("multiplatform") version "1.4.20"
kotlin("plugin.serialization") version "1.4.20" // 追加
}
また、 プラグインの追加の後に、 Json シリアライザも導入する必要があります。
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 の使用方法と動作確認
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
)
}
}
$ 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/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.
4. ktor client の導入
Kotlin/Native に対応している Web フレームワークとして Ktor があります。
1日目の記事「Spring BootとKtorの実装比較(初級編)」 にて Spring Framework と対応させながら Ktor をわかりやすく解説されています。
また、11日目の記事を執筆された doyaaaaaken さんとともに Ktor のドキュメントを 日本語化しています。 (GitHub)
本記事では、 Ktor のサブプロジェクトである Ktor client を利用します。
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")
}
}
}
}
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 を用いてローカル上で実行することをゴールと定めているため、必要最小限の事項のみ記載します。
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
の動作確認をする
下記スクリプトを作成します。
#!/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 エラーが返却されますが気にしないことにします。
{
"message": "Internal server error"
}
実行ログに Hello, SAM Local!
が出力されていれば、動作確認 OK です。
6. AWS Lambda カスタムランタイム の実装
ようやく本題です。
下記の2つのページを参考にしながら進めていきます。
エントリポイントの作成
前節で bootstrap
という実行可能ファイルを作成しました。
これに相当するファイルを作成します。
今回は me.lasta.studyfaaskotlin3.entrypoint
に main
という関数を作成し、これをエントリポイントとします。
package me.lasta.studyfaaskotlin3.entrypoint
fun main() {
}
このファイルをエントリポイントとし、実行可能なファイルが作成されるよう build.gradle.kts
も修正します。
kotlin {
nativeTarget.apply {
binaries {
executable("bootstrap") {
entryPoint = "me.lasta.studyfaaskotlin3.entrypoint.main"
}
}
}
}
カスタムランタイムの仕様
カスタムランタイムは下記の仕様に則り実装する必要があります。
- 無限ループで、 Lambda が呼ばれた際のコンテキストを取得 (GET) し続ける
- 次の呼び出し API
/runtime/invocation/next
- 実際に Lambda が呼ばれた際に、コンテキストを取得できる
- 次の呼び出し API
- 受け取ったコンテキストに対し処理した結果を返却 (POST) する
- 関数の処理の続行が不可能 (例外発生等) になった場合、その旨を返却 (POST) する
- 初期化に失敗した場合、その旨を返却 (POST) する
カスタムランタイムの実装
前述の仕様に則り実装します。
具体的なコードは こちら を参照してください。
カスタムランタイム全体
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
suspend inline fun initialize(): LambdaCustomRuntimeEnv = httpClient.use { client ->
LambdaCustomRuntimeEnv(client.get("$baseUrl/invocation/next"))
}
特筆すべきことはありません。
「次の呼び出し API」を呼び、結果を LambdaCustomRuntimeEnv
に詰めて返却するだけです。
呼び出しレスポンス API /runtime/invocation/AwsRequestId/response
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
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
@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 をお借りします。
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)
}
}
}
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"}
無事実行できました。
今後の展望
今回作成したカスタムランタイムはプロトタイプです。
プロダクトとして利用するためには、少なくとも下記の対応が必要と考えられます。
- 内部エラーが発生した際に適切にハンドリングする
- Sentry へ通知等
- 複数の API を作成する場合、エントリポイントを自動的に検知しバイナリを生成する
-
buildSrc
で特定のパッケージ配下のmain
関数を探す等 - 複数バイナリを一度に生成する例
-
- 「次の呼び出し 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 さんです。