Knative EventingのSourceにAWS SQSをつかいServiceを起動する

前回のエントリではKnativeの Buildの知見をまとめたが今回は Eventingをまとめる。試したところServerlessを体感できて、これぞKnativeの醍醐味ではという感想である。

モチベーション

KnativeのEventingを導入する際にAWS環境ではどのようなAWSサービスを使うことになるのか知りたかった。またEventingのアーキテクチャとAWSが提供するサービスが協調できるのか疑問でもあった。

調査したところSQSとMSK(Amazon Managed Streaming for Kafka)を使う選択になりそうという結果になった。ちなみにGCPであればGCP Cloud Pub/SubでEventingのアーキテクチャとバシッとハマるのでいいな〜という印象である。

このエントリではAWSサービスを使ってKnative Eventingを動かすまでをまとめていきたい。

※ 最初にお断りでローカルでサクッと確認するまでをまとめているのでMSKのインストールはせずにChannelにはIn-Memory Channelsを使っています。

Knative Eventing

Knative Eventingのアーキテクチャはdocsから確認できる。

github.com

キーワードはSourceChannelSubscriptionServiceである。このうちSourceとChannelはマネージドに合わせて数種類の選択肢が用意されている。

f:id:n_soushi:20181227094202p:plain

上記はSourceにGitHubSource、ChannelにKafkaを使った例である。GitHub(Source)のイベント(マージやプルリクエスト)を検知してKafka(Channel)に流しChannelをサブスクライブしているサービスへイベントを伝搬させている。サービスには特定のイメージを指定できる。

GitHubSourceとApache Kafka Channelsはサンプルが用意さている。

docs/eventing/samples/github-source at master · knative/docs · GitHub

eventing/config/provisioners/kafka at master · knative/eventing · GitHub

AWS SQS Sourceを使う

本題である。SourceにAWS SQSを使い特定のQueue URLに流れたデータをサービスで出力してみる。工程はサンプルに沿って進んでいるが一部誤りがあるので正しい工程をまとめていく。

※ Istio, Service, Eventingのインストールが済んでいれば knative/eventing-sourcesをチェックアウトまでSkipしてください。

Istioのインストール

$ kubectl apply --filename https://raw.githubusercontent.com/knative/serving/v0.2.2/third_party/istio-1.0.2/istio.yaml

Istio Injectorを有効にする

$ kubectl label namespace default istio-injection=enabled

Knative Servingをインストール

$ curl -L https://github.com/knative/serving/releases/download/v0.2.2/release-lite.yaml \
  | sed 's/LoadBalancer/NodePort/' \
  | kubectl apply --filename -

Eventingのインストール

kubectl apply --filename https://github.com/knative/eventing/releases/download/v0.2.1/release.yaml

knative/eventing-sourcesをチェックアウト

$ git clone git@github.com:knative/eventing-sources.git

※ 以降はeventing-sourcesのディレクトリで手順を実行します

Channelの作成

$ (eventing-sources) $ kubectl -n default apply -f samples/awssqs_source/channel.yaml

チャンネルは標準の in-memory-channelを利用している。AWSのProductionであればApache Kafkaのチャンネルを利用を検討している。

applyしたチャンネルは次のような定義である。

apiVersion: eventing.knative.dev/v1alpha1
kind: Channel
metadata:
  name: qux-1
  namespace: default
spec:
  provisioner:
    apiVersion: eventing.knative.dev/v1alpha1
    kind: ClusterChannelProvisioner
    name: in-memory-channel

nameにqux-1を設定しており後述するAwsSqsSourceとSubscriptionのリソースから参照される。

AWS Credentialsのリソース化

(eventing-sources) $ echo '[default]
aws_access_key_id = XXXXXXXX
aws_secret_access_key = XXXXXXXX' > ./cred.txt

(eventing-sources) $ kubectl -n knative-sources create secret generic awssqs-source-credentials --from-file=credentials=./cred.txt

ここでリソース化したAWS CredentialsをEventingのリソースがVolumeMountして利用することになる。アクセスとシークレットのキーを用意してリソース化する。

AwsSqsSourceを有効にする

(eventing-sources) $ export KO_DOCKER_REPO="ko.local"
(eventing-sources) $ ko apply -f config/default-awssqs.yaml

koのセットアップが済んでいる人はKO_DOCKER_REPOの変数を設定する必要はない。

SQSのQUEUE URLとQUEUE NAMEを書き換える

(eventing-sources) $ export QUEUE_URL="SQS QueueのURLをここに入れる"
(eventing-sources) $ sed -i -e "s|QUEUE_URL|$QUEUE_URL|" samples/awssqs_source/awssqs-source.yaml

(eventing-sources) $ export QUEUE_NAME="my-queue"
(eventing-sources) $ sed -i -e "s|QUEUE_NAME|$QUEUE_NAME|" samples/awssqs_source/awssqs-source.yaml

QUEUE_URLとQUEUE_NAMEを書き換えると下記のようになる。

apiVersion: sources.eventing.knative.dev/v1alpha1
kind: AwsSqsSource
metadata:
  name: my-queue-source
spec:
  awsCredsSecret:
    name: aws-credentials
    key: credentials
  queueUrl: "SQS QueueのURL"
  sink:
    apiVersion: eventing.knative.dev/v1alpha1
    kind: Channel
    name: qux-1

この awsqs-source.yamlはdefaultのネームスペースにapplyするのでAWS Credentialsを再度セットアップする。

(eventing-sources) $ kubectl -n default create secret generic aws-credentials --from-file=credentials=cred.txt

AwsSqsSourceをデプロイする

(eventing-sources) $ ko -n default apply -f samples/awssqs_source/awssqs-source.yaml

Subscriberをデプロイする

最後にSubscriberをデプロイする。

(eventing-sources) $ ko -n default apply -f samples/awssqs_source/subscriber.yaml

デプロイしたsubscriberは次のような定義になっている。

# Subscription from the AWS SQS Source's output Channel to the Knative Service below.

apiVersion: eventing.knative.dev/v1alpha1
kind: Subscription
metadata:
  name: awssqs-source-sample
  namespace: default
spec:
  channel:
    apiVersion: eventing.knative.dev/v1alpha1
    kind: Channel
    name: qux-1
  subscriber:
    ref:
      apiVersion: serving.knative.dev/v1alpha1
      kind: Service
      name: message-dumper

---

# This is a very simple Knative Service that writes the input request to its log.

apiVersion: serving.knative.dev/v1alpha1
kind: Service
metadata:
  name: message-dumper
  namespace: default
spec:
  runLatest:
    configuration:
      revisionTemplate:
        spec:
          container:
            image: github.com/knative/eventing-sources/cmd/message_dumper

Subscriptionリソースを定義してchannelにqux-1をsubscriberにmessage-dumperを設定している。message-dumperはKnative ServingのServiceでありQueueが送信されなければPodを終了してくれる。

動作確認

SQSを送信する

$ export QUEUE_URL = "SQS QueueのURL"
$ aws sqs send-message --queue-url $QUEUE_URL --message-body "Hello World from sqs"

2つのポッドが起動しているのを確認する

$ kubectl get pod
NAME                                               READY     STATUS    RESTARTS   AGE
awssqs-my-queue-source-gzxg5-8756589d-bmft6        2/2       Running   0          8m
message-dumper-00001-deployment-7c787dfbfc-v547h   3/3       Running   0          6m
  • awssqs-my-queue-source
    • ポッド内にはreceive_adaperというコンテナが動いていた。SQSをポーリングしているようにみえる。
  • message-dumper
    • subsriber.yamlで定義したServiceから起動したポッドである。このポッドは一定時間にキューが流れてこなれば自動で終了してくれる。

ログを確認する

$ stern message-dumper -c user-container
...
省略
 {"Attributes":{"SentTimestamp":"UNIXTIME"},"Body":"Hello World from sqs", ... }

message-dumperのPodに上記のようなログが流れていれば成功である。

Severlessを体感する

最後のQueueから5分以上経過するPodが自動で終了することが確認できる。下記のログはSQSにキューが流れてPodが起動して終了するまでをwatchしている様子。最後にキューがを受け取ってから5分くらいで終了している。

$ kubectl get pod -w
NAME                                          READY     STATUS    RESTARTS   AGE
awssqs-my-queue-source-gzxg5-8756589d-bmft6   2/2       Running   0          14m
message-dumper-00001-deployment-7c787dfbfc-gchnn   0/3       Pending   0         0s
message-dumper-00001-deployment-7c787dfbfc-gchnn   0/3       Pending   0         1s
message-dumper-00001-deployment-7c787dfbfc-gchnn   0/3       Init:0/1   0         1s
message-dumper-00001-deployment-7c787dfbfc-gchnn   0/3       PodInitializing   0         2s
message-dumper-00001-deployment-7c787dfbfc-gchnn   2/3       Running   0         5s
message-dumper-00001-deployment-7c787dfbfc-gchnn   3/3       Running   0         6s
message-dumper-00001-deployment-7c787dfbfc-gchnn   3/3       Terminating   0         5m
message-dumper-00001-deployment-7c787dfbfc-gchnn   0/3       Terminating   0         5m
message-dumper-00001-deployment-7c787dfbfc-gchnn   0/3       Terminating   0         5m
message-dumper-00001-deployment-7c787dfbfc-gchnn   0/3       Terminating   0         5m        6s

まとめ

  • Knative EventingのSourceにAWS SQSをつかいKnative Eventingnを体験した。
  • SourceにAWS SQSが使えるようになったのでKnativeの採用が加速すると思う。
  • これまでSQSを利用するサービスはSQSのポーリングを独自に実装する必要があったがKnativeがその役割を担ってくれるので助かる。
  • SourceにはCronJobSourceもありcronでスケジュールすることができるので稼働時間が限られるようなバッチサービスに採用したい。

サーバサイドKotlinをはじめよう。Ktor Tips集をまとめた。

この記事はCyberAgent Developers Advent Calendar 2018の22日目の記事です。

Ktor

KtorはKotlin純正のWebフレームワークです。APIはラムダ式を使った関数呼び出しでKotlin DSLによりルーティングを宣言的なコードで保守できます。KtorアプリケーションはTomcatなどのServlet 3.0またはスタンドアローンなJetty、Nettyなどのサーブレットコンテナでホスティングできます。KotlinConf 2018(2018/10)で1.0.0のベータが紹介され11月にはKtor 1.0.0の正式リリースを発表しました。2018/12時点では1.0.1が利用できます。

Ktor - asynchronous Web framework for Kotlin

モチベーション

これまで私はサーバサイド Kotlinを開発するフレームワークにはSpring Bootを選択してきましたがマイクロサービスを俯瞰してみると軽量なマイクロフレームワークを採用したいという気持ちがありました。例えばDBアクセスが必要ないマイクロサービスであればAPIルーティングをストレスなく開発できるフレームワークのほうが向いているかもしれません。

Spring BootもRouter Functional DSLやSpring Fuが登場するなどニーズにあった選択ができるようになってきています。しかしKotlin Nativeの登場によってiOS/Android/Serverのリソースが共有できる環境になると純正Kotlinを採用しているKtorを採用したほうが今後の潮流に順応できそうです。またマルチプラットフォームにKtorのライブラリが採用されているドキュメントなどが今後更に増えていくことを考慮するとKtorを採用することは理にかなっていると言えるでしょう。

このエントリではWebサーバのAPI開発のユースケースがKtorではどのように解決されているのかをまとめていきます。サーバサイド Kotlinをはじめようとする皆さんにとって、これから更に注目されるKtorを利用する手助けになれば幸いです。

ユースケースをまとめる

新しいフレームワークでWebサーバのAPI開発を行うときには次のようなユースケースがどのようにサポートされているか気になります。

  • APIルーティング
  • 環境変数マッピング
  • インターセプター
  • エラーハンドリング
  • 認証処理(今回はJWT)
  • DI
  • APIテスト

Ktorではどのように実装するのでしょうか。順を追って見ていきましょう。

APIルーティング

簡単なEchoサーバを作っていきましょう。

fun main(args: Array<String>) {
    embeddedServer(Netty, 8008) {
        routing {
            get("echo") {
                call.apply {
                    respond(request.queryParameters["q"] ?: "none")
                }
            }
        }
    }.start(wait = true)
}

/echo?q=helloにGETリクエストを送るとレスポンスにhelloと返すAPIが出来上がりました。

APIをモジュール化する

上記のコードではmain関数にroutingを宣言していて今後APIが増えていくことを考慮すると見通し良いコードに改善すべきです。
KtorではAppicationクラスに拡張関数を生やすことでAPIをモジュール化できます。

fun Application.echoModule() {
    routing {
        get("/echo") {
            call.apply {
                respond(request.queryParameters["q"] ?: "none")
            }
        }
    }
}

routingもApplicationクラスの拡張関数のためechoModuleの拡張関数の中でroutingが宣言できます。 このechoModuleをresources/application.confに追加しましょう。

ktor {
  deployment {
    port = 8080
  }
  application {
    modules = [ com.github.soushin.ktorsample.ApplicationKt.echoModule ]
  }
}

application.confドリブンでKtorを動くように定義したのでmain関数は次のように変化します。

fun main(args: Array<String>) {
    embeddedServer(Netty, commandLineEnvironment(args)).start(wait = true)
}

このようにAPIをモジュール化することでマイクロサービス内のAPIルーティングをエンドクライアントごとに分けることができます。InternalModuleやExternalModuleなど内部向け、外部向けのAPIをモジュールで切り出すことでコードの保守性が高まります。

Routingを見通しよくする

echoModule()ではroutingのDSLを直接記述していましたがRouteクラスに拡張関数を生やすことでroutingをパスごとに切り出せます。

@Location("/echo")
class Echo(val q: String? = null)

fun Route.echo() {
    get<Echo> {
        call.apply {
            respond(request.queryParameters["q"] ?: "none")
        }
    }
}

パスをget("/echo")のようにStringで定義していましたが@Locationを使うことでタイプセーブにパスを定義できます。 echo()拡張関数を次のようにechoModule()に定義します。

fun Application.echoModule() {
    install(Locations)
    routing {
        echo()
    }
}

タイプセーフにLocationsクラスを利用したことでKtorの機能をモジュールにインストールする必要があります。install(Locations)でLocations機能をインストールしています。

Application Features

Ktorは多くの機能(ApplicationFeature)をラインナップしています。後述するユースケースではApplicationFeatureとともに紹介していきます。

https://ktor.io/servers/features/index.html

環境変数マッピング

コンテナでアプリケーションを動かす場合には開発環境や本番環境などのDB設定値を環境変数に入れてアプリケーション側から環境変数を参照することで環境を切り分けます。Ktorでは環境変数をアプリケーションでどのように参照するのかまとめていきます。

application.confに変数を定義する

app {
    github {
      baseUrl = "https://api.github.com"
      token = "token"
      token = ${?KTOR_APP_GITHUB_TOKEN}
    }
}

上記のようにアプリケーションで参照する変数をapplication.confに定義しました。app.github.tokenはデフォルト値としてtokenが定義され環境変数のKTOR_APP_GITHUB_TOKENが参照できればその値で上書きされます。

Applicationクラスで環境変数を参照する

APIルーティングで作成したechoModuleはApplicationクラスの拡張関数なのでenviromentプロパティのApplicationConfigを参照することができます。

fun Application.echoModule() {
    val token = environment.config.property("app.github.token").getString()
}

上記のようにenvironmentを参照できますがenvironmentはApplicationクラスのプロパティなので次のような拡張プロパティを定義できます。

val Application.gitHubToken
        get() = environment.config.property("app.github.token").getString()

拡張プロパティを定義することで冗長なコードを防ぎコードの短縮化の恩恵も受けれました。

import com.github.soushin.ktorsample.config.Config.gitHubToken

fun Application.echoModule() {
    val token = gitHubToken
}

インターセプター

APIにリクエストが来て本処理をする前や本処理をした後などに処理を挟むインターセプターの実装方法を見ていきましょう。このエントリではリクエストログを出力することを目的にリクエスト内容に含まれる情報をインターセプターで取得してみます。

ApplicationのEchoモジュールにinterceptを追加しましょう。

fun Application.echoModule() {
    val logger = KotlinLogging.logger("RequestLogger")
    install(Locations)
    intercept(ApplicationCallPipeline.Monitoring) {
        call.attributes.put(
            loggerAttributeKey,
            RequestLogBuilder()
                .name(call.request.uri)
                .userAgent(call.request.userAgent())
                .remoteAddr(call.request.header("X-Forwarded-For") ?: "-")
        )
        try {
            proceed()
        } catch (e: Exception) {
            logger.error { call.logBuilder.userId(call.user?.id).build() }
            throw e
        }
        logger.info { call.logBuilder.success(true).userId(call.user?.id).build() }
    }
    routing {
        echo()
    }
}

interceptの引数にはPipelinePhaseオブジェクトを渡します。ApplicationCallPipeline.Monitoringはロギングやメトリクス向きのフェーズ定義です。
KtorのPipelineは適切な場所で機能を追加することができる拡張メカニズムです。KtorアプリケーションにはSetup, Monitoring, Feature, Call, Fallbackの5つの主要なフェーズが定義されています。

リクエスト本処理前のインターセプト処理

call.attributes.putではApplicationCallのattributeプロパティに自作のRequestLogBuilderをセットしています。ここまででリクエスト本処理の直前のインターセプト処理を挟んでいます。インターセプト処理はRequestLogBuilderにリクエストURIやユーザエージェントなどをセットアップしています。 loggerAttributeKeyは次のようにどこからでも参照できる形で変数として定義しています。

val loggerAttributeKey = AttributeKey<RequestLogBuilder>("loggerAttributeKey")

このloggerAttributeKeyをルーティングのほうで参照することでインターセプト処理でセットしたRequestLogBuilderを取得できるようにしています。

fun Route.echo() {
    get<Echo> {
        call.apply {
            attributes.getOrNull(loggerAttributeKey)?.elem("q" to request.queryParameters["q"])
            respond(request.queryParameters["q"] ?: "none")
        }
    }
}

echoルーティングでApplicationCallのattributeプロパティからRequestLogBuilderを取得してクエリストリングをセットしています。

call.attributes.getOrNull〜は冗長なコードになりそうなのでApplicationCallに拡張プロパティを生やしましょう。

val ApplicationCall.logBuilder
    get() = this.attributes[loggerAttributeKey]
fun Route.echo() {
    get<Echo> {
        call.apply {
            logBuilder.elem("q" to request.queryParameters["q"]) // 拡張プロパティ logBuilderを参照する
            respond(request.queryParameters["q"] ?: "none")
        }
    }
}

リクエスト本処理後のインターセプト処理

次にリクエスト本処理後のインターセプト処理を見ていきましょう。

fun Application.echoModule() {
    // -- 省略 --
    intercept(ApplicationCallPipeline.Monitoring) {
        // -- 省略 --
        try {
            proceed()
        } catch (e: Exception) {
            logger.error { call.logBuilder.userId(call.user?.id).build() }
            throw e
        }
        logger.info { call.logBuilder.success(true).userId(call.user?.id).build() }
    }
    // -- 省略 --
}

proceed()がリクエストの本処理になります。proceedをtry-catchで囲みエラーが発生したらErrorレベルでログを出力してExceptionをスローしています。エラーにならなければInfoレベルでログを出力しています。

エラーハンドリング

アプリケーション開発ではアーキテクチャに沿ってレイヤーを用意することが一般的です。各レイヤーでエラーが発生した時にはその場で例外クラスをスローしてアプリケーションがよしなにクライアントにエラーレスポンスを返すことができればコードの見通しがよくなります。
次のコードは極端な例外クラスをスローしているルーティングコードです。

@Location("/errors")
class Errors() {
    @Location("/404")
    class NotFound

    @Location("/400")
    class BadRequest

    @Location("/500")
    class InternalServerError
}

fun Route.error() {
    get<Errors.NotFound> { throw NotFoundException("not found") }
    get<Errors.BadRequest> { throw BadRequestException("bad request") }
    get<Errors.InternalServerError> { throw InternalServerErrorException("internal server error") }
}

/errors/404へGETリクエストを送るとアプリケーションはNotFoundExceptionの例外クラスをスローします。このような例外クラスをアプリケーションの各レイヤーでスローして例外クラスに応じたエラーレスポンスを返す処理を集約させることができます。

StatusPagesをつかう

errorModuleにStatusPagesの機能をインストールします。exceptionの型パラメーターに例外クラスを渡してラムダで例外クラスを処理しています。ラムダ内ではApplicationCallクラスを参照できるのでレスポンスを組み立てることができます。

fun Application.errorModule() {
    install(Locations)
    install(ContentNegotiation) {
        jackson {
            configure(SerializationFeature.INDENT_OUTPUT, true)
        }
    }
    install(StatusPages) {
        exception<SystemException> { cause ->
            call.response.status(cause.status)
            call.respond(cause.response())
        }
    }
    routing {
        error()
    }
}

SystemExceptionクラスは次のようにNotFoundExceptionクラスの基底クラスとしています。

abstract class SystemException(message: String, ex: Exception?) : RuntimeException(message, ex) {
    abstract val status: HttpStatusCode
    fun response() = HttpError(code = status.value, message = message ?: "error")
}

class NotFoundException : SystemException {
    constructor(message: String) : super(message, null)
    override val status: HttpStatusCode = HttpStatusCode.NotFound
}

SystemExceptionクラスを基底クラスにすることで例外クラスの分岐が1つだけになりエラーハンドリングがシンプルになりました。

認証処理

KtorではAPIリクエストの認証処理を簡単に行えるApplicationFeatureが用意されています。今回は認証にJWTを使ったコードをまとめます。
JWTのトークンにはユーザIDが含まれている想定です。

Authenticationを使う

authModuleにAuthenticationします。Authentication.Configurationの拡張関数であるjwtを引数に認証処理を定義しています。

fun Application.authModule() {
    install(Authentication) {
        jwt {
            verifier(JWT.require(Algorithm.HMAC256(jwtSecret)).build())
            validate { credential ->
                User.toUser(JWTPrincipal(credential.payload))
            }
        }
    }
    routing {
        auth()
    }
}

verifierでJWTのVerificationをビルドします。jwtSecretはApplicationクラスの拡張プロパティで環境変数で定義したJWTのsecretを参照しています。
validateは認証処理が成功した後で複合された認証情報から独自のUserクラスを生成しています。

data class User(val id: String): Principal {
    companion object {
        fun toUser(principal: JWTPrincipal) = User(
            principal.payload.getClaim("id")?.asString() ?: throw IllegalStateException("unauthorized")
        )
    }
}

独自のUserクラスにPrincipalのインターフェースをマークすることでJWTのvalidateを通してApplicationCall#principal()にUserクラスがセットされます。

リクエストに認証処理を追加する

authModuleのroutingに定義したauthルーティングのコードです。

class Auth() {
    @Location("/who")
    class Who
}

fun Route.auth() {
    authenticate {
        get<Auth.Who> { call.respond("Success, userId=${call.user?.id}") }
    }
}

authenticateはRouteクラスの拡張関数です。authenticate { }で括ることで認証処理が追加できます。
call.userはApplicationCallの拡張プロパティで先述したprincipalにセットされたUserクラスを参照しています。

val ApplicationCall.user
    get() = this.authentication.principal<User>()

DI

DIにはKoinを使います。KoinではなくてもDIできますがKtorにはApplicationクラスにinstallKoinの拡張関数が用意されているので導入が簡単にできます。

github.com

次のようなUseCaseクラスにDIする手順を確認していきましょう。

interface PullRequestRepository {
    fun getPullRequests(owner: String, repo: String, state: String, perPage: Int):
            List<PullRequest>
}

PullRequestRepositoryインターフェースクラスを用意します。このPullRequestRepositoryを実装したクラスが次のPullRequestRepositoryImplクラスです。

class PullRequestRepositoryImpl(private val client: GitHubClient) : PullRequestRepository {
    override fun getPullRequests(owner: String, repo: String, state: String, perPage: Int) =
        client.get(GetPullRequests(owner, repo, state, perPage))
}

PullRequestRepositoryImplのコンストラクタにはGitHubClientのインスタンスが必要になります。GitHubClientは次のようなに定義しています。

class GitHubClient(
    override val baseUrl: String,
    override val token: String,
    override val client: FuelManager = FuelManager.instance
) : Client() {
    override fun error(response: Response) {
        when {
            response.isClientError -> throw BadRequestException("GitHubClient error, ${response.responseMessage}")
            else -> throw InternalServerErrorException("GitHubClient error, ${response.responseMessage}")
        }
    }
}

PullRequestRepositoryがGitHubClientに依存しています。この依存関係をKoinで定義しましょう。

val usecaseModule = module {
    // client
    single { GitHubClient(getProperty("github_base_url"), getProperty("github_auth_token")) }
    // repository
    single<PullRequestRepository> { PullRequestRepositoryImpl(get()) }
}

KoinはDI定義をmodule毎に分割できます。moduleに名前をつけて依存解決をする時に名前を参照できます。今回のエントリでは1つのmoduleを定義しています。
singleで単一インスタンスを保証するsingletonを定義できます。getPropertyはKoinの機能でmodule内のプロパティを一意に保ちます。依存注入時にプロパティに変数を定義します。そして最後にget()です。get()はmodule内の依存関係を判断して型推論してくれます。

DiModuleをつくる

ここまではKoinの利用方法を見てきました。ここからはKtorに定義したDI Moduleを使うコードを見ていきましょう。

次のようにApplicationクラスの拡張関数としてdiModuleを作ります。

fun Application.diModule() {
    val usecaseModule = module {
        // client
        single { GitHubClient(getProperty("github_base_url"), getProperty("github_auth_token")) }
        // repository
        single<RepoRepository> { RepoRepositoryImpl(get()) }
        single<PullRequestRepository> { PullRequestRepositoryImpl(get()) }
        // useCase
        single<RepositoryUseCase> { RepositoryUseCaseImpl(get(), get()) }
    }
    installKoin(
        listOf(usecaseModule),
        extraProperties = mapOf(
            "github_base_url" to gitHubBaseUrl,
            "github_auth_token" to gitHubToken
        )
    )
}

先程のコードから少し依存関係が増えましたが新しく利用している機能はありません。
installKoinがKtorで利用できる拡張関数でKoinの利用に必要なstartKoin()を抽象化しています。installKoinの第一引数には定義したモジュールを渡します。名前付き引数のextraPropertiesでgetPropertyで参照する変数を定義しています。

なぜモジュールで切り出すのか?

DI定義をdiModuleに切り出している理由は次の章で紹介するKtorのテストに関連します。モックを用いてテストするためにDI定義をモジュールで切り出してテスト時にはモック化したインスタンスで依存性の注入を行います。

モジュールで切り出したのでapplication.confに追加します。

ktor {
  deployment {
    port = 8080
  }
  application {
    modules = [ com.github.soushin.ktorsample.ApplicationKt.diModule, com.github.soushin.ktorsample.ApplicationKt.mainModule ]
  }
}

mainModuleは次のセクションで説明します。

Injectする

最後に定義した依存関係を利用するコードを紹介します。application.confのmodulesに追加したmainModuleは次のようになります。

fun Application.mainModule() {

    val usecase by inject<RepositoryUseCase>()

    routing {
        repository(usecase)
    }
}

diModuleで定義したsingle<RepositoryUseCase> { RepositoryUseCaseImpl(get(), get()) }のsingletonがinject<RepositoryUseCase>()によって参照できます。

APIテスト

APIテストにはTestApplicationEngineを利用します。テストコード内でTestApplicationEngineのインスタンスを生成して拡張関数のhandleRequestを呼び出しレスポンスを検証します。実際のコードをまとめていきます。

Echoサーバをテストする

「APIルーティング」のところで実装したEchoサーバをテストします。

JUnitを利用したテストコードです。

class EchoTest {

    private lateinit var engine: TestApplicationEngine

    @Before
    fun before() {
        engine = TestApplicationEngine().apply {
            start(wait = false)
            application.echoModule()
        }
    }

    @Test
    fun testEcho() {
        with(engine) {
            handleRequest(HttpMethod.Get, "/echo?q=hello").response.apply {
                assertEquals("hello", content)
                assertEquals(HttpStatusCode.OK, status())
            }
        }
    }
}

beforeでTestApplicationEngineのインスタンスを生成しengine変数に格納しています。apply内で定義済みのEchoモジュールであるapplication.echoModule()を呼び出しています。これによりテストコード内のTestApplicationEngineにEchoモジュールが有効となります。

testEcho() は/echo/q=helloのエンドポイントをテストしているコードです。with(engine)を用いることでTestApplicationEngineをレシーバとしてメソッドを呼び出すことができます。拡張関数のhandleRequestを呼び出しレスポンスを検証しています。

KtorのAPIテストの大枠はテストコード内でTestApplicationEngineの変数を定義してTestApplicationEngineのインスタンス生成時にAPIモジュールを呼び出すことでテストを行いたいAPIを有効にしたengineを用いてエンドポイントの検証を行います。

DIが必要なAPIのテスト

先述のようなEchoサーバはシンプルなAPIですが一般的なAPIはDIを用いてビジネスロジックは別のクラスで行いルーティング層のコードはビジネスロジックのクラスを呼び出すような形になります。このようなDIが必要なAPIのテストはどのように行うのかまとめていきます。

「DI」のところで実装したコードをテスト対象にします。また外部APIをコールすることになりますモックが必要です。モックにはmockkを利用します。

「DI」のところではdiModuleを切り出した理由がここにあります。次のコードのようにDIが絡むAPIモジュールにはテスト用のDIモジュールをテストコード内で定義します。そして先述したとおりTestApplicationEngineのインスタンス生成時にテスト用のdiModuleを呼び出します。コードは次のようになります。

class RepositoryTest {

    private lateinit var engine: TestApplicationEngine

    // ①
    fun Application.diModuleForTest() {

        val gitHubClientForRepository = mockk<Client>().apply {
            every { executeRequest(any()).statusCode } returns 200
            every { executeRequest(any()).data } returns REPOSITORIES_RESPONSE.toByteArray()
        }.let {
            FuelManager().apply { client = it } // ②
        }.let {
            GitHubClient(GITHUB_BASE_URL, GITHUB_TOKEN, it)
        }

        val gitHubClientForPullRequest = mockk<Client>().apply {
            every { executeRequest(any()).statusCode } returns 200
            every { executeRequest(any()).data } returns PULL_REQUESTS_RESPONSE.toByteArray()
        }.let {
            FuelManager().apply { client = it } // ②
        }.let {
            GitHubClient(GITHUB_BASE_URL, GITHUB_TOKEN, it)
        }

        // ③
        val usecaseModule = module {
            // useCase
            single<RepositoryUseCase> {
                RepositoryUseCaseImpl(
                    RepoRepositoryImpl(gitHubClientForRepository),
                    PullRequestRepositoryImpl(gitHubClientForPullRequest)
                )
            }
        }

        // ④
        installKoin(
            listOf(usecaseModule),
            extraProperties = mapOf(
                "github_base_url" to gitHubBaseUrl,
                "github_auth_token" to gitHubToken
            )
        )
    }

    @Before
    fun before() {
        engine = TestApplicationEngine().apply {
            // ⑤
            (environment.config as MapApplicationConfig).apply {
                // Set here the properties
                put("app.github.baseUrl", GITHUB_BASE_URL)
                put("app.github.token", GITHUB_TOKEN)
            }
            start(wait = false)
            application.diModuleForTest() // ⑥
            application.mainModule()
        }
    }

    // -- 省略 --

}
  • ①でテストコード専用のDIモジュールの拡張関数のdiModuleForTest()を定義しています。
  • ②でHTTPクライアントのリクエストをモック化したインスタンスを生成しています。
  • ③でKoin moduleの定義を行い①で生成したHTTPクライアントを引数に渡しています。
  • ④でstartKoin()`を抽象化したinstallKoinを呼び出しています。
  • ⑤で必要なapplication変数を設定しています。
  • ⑥でdiModuleForTest()を呼び出しテストコードのengine変数(TestApplicationEngine)にテスト用のDIモジュールを有効にします。

上記のコードによりHTTPクライアントのリクエストがモック化されたRepositoryクラスをDIしたmainModule()がテストコード内で有効になりました。

class RepositoryTest {

    // -- 省略 --

    @Test
    fun testRepository() {
        with(engine) {
            handleRequest(HttpMethod.Get, "/repository").response.apply {
                assertEquals(HttpStatusCode.OK, status())
            }
        }
    }

    // -- 省略 --
}

with(engine)を用いてmainModule()のエンドポイントを検証しています。

まとめ

  • WebサーバのAPIにおけるユースケースごとにKtorではどのように実装するのかをまとめました。
  • Ktorはマイクロフレームワークであり似たようなフレームワークは引き続き多く登場するでしょう。しかしKotlin/Nativeの観点からKtorが優位性を出すことを期待しています。

コード

このエントリで紹介したコードはこちらのレポジトリから参照できます。

github.com

Knative Buildのbuild templateにBazelをつかいビルドする

KnativeはKubernetesを基盤としたプラットフォームをビルド、デプロイを管理するためフレームワークを提供する。Serving、Build、そしてEventingの3つのコンポーネントで構成されている。これら3つが疎結合に連携する。

cloud.google.com

Kubernetesを基盤にサービスをデプロイするにはDeploymentやService、Ingressなどの理解が必要であるがKnativeはそれらを抽象化してよりフレームワークに沿った扱い方を提供してくれている印象がある。

モチベーション

今回のエントリでは3つのコンポーネントのうち Buildに注目していきたい。これまでBazelをつかいGoプロジェクトをビルドする方法をまとめてきた。KnativeのBuildにはbuild templateという概念がありテンプレートの1つにBazelがサポートされている。KnativeのBuildのbuild templateにBazelをつかいGoプロジェクトをビルドする手順をまとめる。

インストール

Kubernetes環境が整っていれば次のリソースをapplyすればKnative Buildの環境がセットアップできる。

istioのインストール

# Install istio
$ kubectl apply --filename https://raw.githubusercontent.com/knative/serving/v0.2.2/third_party/istio-1.0.2/istio.yaml
# Label the default namespace with istio-injection=enabled:
$ kubectl label namespace default istio-injection=enabled

istio関連のPodがRunningかCompletedになれば完了

$ kubectl get pods --namespace istio-system --watch

NAME                                        READY     STATUS              RESTARTS   AGE
istio-citadel-84fb7985bf-shfdl              0/1       ContainerCreating   0          54s
istio-cleanup-secrets-zg9n6                 0/1       Completed           0          1m
istio-egressgateway-bd9fb967d-z7gfr         0/1       ContainerCreating   0          54s
istio-galley-655c4f9ccd-d9mgh               0/1       ContainerCreating   0          54s
istio-ingressgateway-688865c5f7-2p5gk       0/1       ContainerCreating   0          54s
istio-pilot-6cd69dc444-ts67c                0/2       ContainerCreating   0          54s
istio-policy-6b9f4697d-d4ndh                0/2       ContainerCreating   0          54s
istio-sidecar-injector-8975849b4-8x722      0/1       ContainerCreating   0          54s
istio-statsd-prom-bridge-7f44bb5ddb-qwdqv   1/1       Running             0          55s
istio-telemetry-6b5579595f-g2vm4            0/2       ContainerCreating   0          54s

参考: https://github.com/knative/docs/blob/master/install/Knative-with-any-k8s.md

Knative Buildのインストール

$ kubectl apply --filename https://raw.githubusercontent.com/knative/serving/v0.2.2/third_party/config/build/release.yaml

Build関連のPodがRunningになれば完了

kubectl get pods --namespace knative-build --watch
NAME                                READY     STATUS              RESTARTS   AGE
build-controller-747b8fd966-dwbdd   0/1       ContainerCreating   0          16s
build-webhook-6dc78d8f6d-vvkqm      0/1       ContainerCreating   0          16s

knative-buildのnamespaceが追加されている。以降に追加するリソースはknative-buildのnamespaceに追加していくのでcontextを切り替える。

Knative Buildを理解するための4つの要素

Knative Buildを理解するために次の4つの要素は抑えておきたい。

  • Build
  • BuildTemplate
  • Builder
  • ServiceAccount

Build

docs/builds.md at master · knative/docs · GitHub

BuildはBuildTemplate、Builder、ServiceAccountを集約した概念であり、それらをまとめたmanifestを作成する。

BuildTemplate

docs/build-templates.md at master · knative/docs · GitHub

BuildTemplateはparameterstepsを定義する。stepはビルド手順をまとめビルドに必要な変数をparameterで定義する。テンプレートはBazelがサポートされている。今後もその他のサポートが追加されることが期待される。

GitHub - knative/build-templates: A library of build templates.

Builder

docs/builder-contract.md at master · knative/docs · GitHub

BuilderはBuildTemplateでも触れたstepsの概念である。ビルドするために複数の手順があればstepsに追加していく。ビルドの完了までにBazelのtargerを複数実行したい場合はstepsにtargetを定義していく。

ServiceAccount

docs/auth.md at master · knative/docs · GitHub

ServiceAccountはKnative Buildにおける新概念ではなくビルドする過程で必要な認証をsecretリソースと一緒にセットアップすることでBuildリソースにアサインする。GitHubからソースのチェックアウトやリモートレジストリへのプッシュなどに必要な認証アカウントをServiceAccountリソースとして定義する。

Knative BuildでBazelをつかいビルドする

先述した4つの概念を頭に入れてBazelでビルドする方法をまとめていく。

ServiceAccountを作成する

BazelのtargetにはDocker hubにイメージをプッシュする工程が含まれているのでDocker hubに認証するSecretリソースを追加する。

# pkg/ops/k8s/knative-build/secret.yaml

apiVersion: v1
kind: Secret
metadata:
  name: docker-hub-account
  annotations:
    build.knative.dev/docker-0: https://index.docker.io/v1/
type: kubernetes.io/basic-auth
stringData:
  username: "<username>" # not base64 encoded
  password: "<password>" # not base64 encoded

リソースを追加する。

(bazel-multiprojects) $ kubectl apply -f pkg/ops/k8s/knative-build/secret.yaml
secret/docker-hub-account created

次にServiceAccountリソースを追加する。

# pkg/ops/k8s/knative-build/service-account.yaml

apiVersion: v1
kind: ServiceAccount
metadata:
  name: docker-hub-account
secrets:
  - name: docker-hub-account

リソースを追加する。

(bazel-multiprojects) $ kubectl apply -f service-account.yaml
serviceaccount/docker-hub-account created

これでDocker hubに認証するSecretリソースを参照しているdocker-hub-accountのServiceAccountが追加できた。

BuildTemplateリソースの追加

BuildTemplateリソースを追加する。

# pkg/ops/k8s/knative-build/build-template-bazel.yaml

apiVersion: build.knative.dev/v1alpha1
kind: BuildTemplate
metadata:
  name: bazel
spec:
  parameters:
  - name: TARGET
    description: The name of the Bazel "container_push" target to run
  - name: IMAGE_TAG
    description: The tag of image

  steps:
  - name: build-and-push
    image: gcr.io/cloud-builders/bazel
    args: ['run', '--platforms=@io_bazel_rules_go//go/toolchain:linux_amd64', '--define', 'IMAGE_TAG=${IMAGE_TAG}', '${TARGET}']

bazel runコマンドのオプションを変数化して必要な変数をparameterとして定義している。

リソースを追加する。

(bazel-multiprojects) $ kubectl apply -f pkg/ops/k8s/knative-build/build-template-bazel.yaml
buildtemplate.build.knative.dev/bazel created

これでBuildTemplateリソースのbazelが追加できた。

Buildリソースの追加

Buildリソースを定義する。

# pkg/ops/k8s/knative-build/bazel-build.yaml

apiVersion: build.knative.dev/v1alpha1
kind: Build
metadata:
  name: bazel-build
spec:
  serviceAccountName: docker-hub-account
  source:
    git:
      url: https://github.com/soushin/bazel-multiprojects
      revision: master
  template:
    name: bazel
    arguments:
    - name: TARGET
      value: //pkg/public_go:container_push
    - name: IMAGE_TAG
      value: latest

spec.serviceAccountNameに追加したServiceAccount docker-hub-accountを参照している。そしてspec.template.nameには追加したBuildTemplate bazelを参照している。また//pkg/public_go:container_pushのBazel targetを実行するためのソースをspec.sourceに定義している。

ビルドの実行

Buildリソースであるpkg/ops/k8s/knative-build/bazel-build.yamlをapplyしてビルドを実行する。

(bazel-multiprojects) $ kubectl apply -f pkg/ops/k8s/knative-build/bazel-build.yaml
build.build.knative.dev/bazel-build created

ポッドが起動しているのを確認する。

(bazel-multiprojects) $ kubectl get pods --namespace knative-build --watch
NAME                                READY     STATUS     RESTARTS   AGE
bazel-build-9jzhc                   0/1       Init:2/3   0          1m
build-controller-747b8fd966-dwbdd   1/1       Running    0          1h
build-webhook-6dc78d8f6d-vvkqm      1/1       Running    0          1h

Completedになればビルド完了。

(bazel-multiprojects) $ kubectl get pods --namespace knative-build --watch
NAME                                READY     STATUS      RESTARTS   AGE
dbuild-controller-747b8fd966-dwbdd   1/1       Running     0          3h
build-webhook-6dc78d8f6d-vvkqm      1/1       Running     0          3h
bazel-build-9jzhc   0/1       Pending   0         0s
bazel-build-9jzhc   0/1       Pending   0         0s
bazel-build-9jzhc   0/1       Init:0/3   0         0s
bazel-build-9jzhc   0/1       Init:1/3   0         1s
bazel-build-9jzhc   0/1       Init:1/3   0         3s
bazel-build-9jzhc   0/1       Init:2/3   0         4s
bazel-build-9jzhc   0/1       Init:2/3   0         6s
bazel-build-9jzhc   0/1       PodInitializing   0         5m
bazel-build-9jzhc   0/1       Completed   0         5m

まとめ

  • Knative Buildのbuild templateにBazelをつかいビルドする方法をまとめた
  • ビルドを実行するとPodが立ち上がりPod内でビルドが行われる。BazelやDockerといったビルドに必要なコンポーネントのセットアップをせずにビルドできるのは便利である。
  • ビルドが完了するとPodがCompletedになるが再度同じようにapplyを実行してもビルドが行われない。Podを削除した後にapplyを実行すると再度ビルドができた。
    • もしかすると定義したBuildはEventingと結合して進化を発揮するのかしれない。EventigはGitHubのイベントをソースに扱える。マージのイベントでKnative Buildを実行するイメージである。
    • Evenitngの理解がまだ浅いので引き続きEventingの理解も進めていく。

コード

紹介したyamlコードは次のレポジトリに置いてあります。

github.com