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から確認できる。
キーワードはSource
、Channel
、Subscription
、Service
である。このうちSourceとChannelはマネージドに合わせて数種類の選択肢が用意されている。
上記は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
の拡張関数が用意されているので導入が簡単にできます。
次のような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が優位性を出すことを期待しています。
コード
このエントリで紹介したコードはこちらのレポジトリから参照できます。
Knative Buildのbuild templateにBazelをつかいビルドする
KnativeはKubernetesを基盤としたプラットフォームをビルド、デプロイを管理するためフレームワークを提供する。Serving、Build、そしてEventingの3つのコンポーネントで構成されている。これら3つが疎結合に連携する。
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はparameter
とsteps
を定義する。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コードは次のレポジトリに置いてあります。