KtorとKoinの組み合わでWebAPIを作る

はじめに

KotlinでWeb開発するときに、Springが選ばれることが多いと思うのですが、個人的な思いとしてはKotlin由来のライブラリーやフレームワークをなるべく使いたいという気持ちがあります。
KotlinでそのへんをやるにはKtorとWebフレームワークとKoinというDIコンテナを組み合わせて使うのが1つの大きな選択肢となると思います。KoinはKtorのサポートも行ってそうだったのでプロジェクトを作って簡単なWebアプリを作るまでをやってみようかと思います。

やってみる

環境

$ uname -srvmpio
Linux 5.4.0-77-generic #86-Ubuntu SMP Thu Jun 17 02:35:03 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

$ lsb_release -a
LSB Version:    core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.2 LTS
Release:        20.04
Codename:       focal


$ java --version
openjdk 11.0.10 2021-01-19
OpenJDK Runtime Environment 18.9 (build 11.0.10+9)
OpenJDK 64-Bit Server VM 18.9 (build 11.0.10+9, mixed mode)

$ mvn -v
Apache Maven 3.6.3
Maven home: /usr/share/maven
Java version: 11.0.10, vendor: Oracle Corporation, runtime: /home/someone/.sdkman/candidates/java/11.0.10-open
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-77-generic", arch: "amd64", family: "unix"

プロジェクトを作成

プロジェクトはGenerate Ktor projectを用いて作成します。

f:id:yuya_hirooka:20210718001631p:plain

f:id:yuya_hirooka:20210718001737p:plain

今回はMavenを使ってプロジェクトを作成します。
依存としてはRoutingだけ入れています。

Koinの依存を追加する

IDEか何かで、プロジェクトを開いてKoinの依存を追加します。
Pomに以下の依存を付け加えます。

<dependency>
    <groupId>io.insert-koin</groupId>
    <artifactId>koin-ktor</artifactId>
    <version>3.1.2</version>
</dependency>

これで、プロジェクトの準備はできました。

諸々の設定を行なう

まずは、Ktorがapplication.confを読み込むように修正します。

Application.ktを以下のように書き換えます。

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

次にハンドラーを1つ追加します。 HelloHandler.ktを作り以下のルーティングの定期を書きます。

import io.ktor.application.*
import io.ktor.response.*
import io.ktor.routing.*

fun Route.hello() {
    get("/hello") {
        call.respond("Hello, Koin")
    }
}

このハンドラーをRouteingとして登録します。
再びAplication.ktに戻り以下のように修正します。

import io.ktor.application.*
import io.ktor.routing.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*

fun Application.main() {
    install(CallLogging)

    routing {
        hello()
    }
}

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

application.confを作成してハンドラーの設定をモジュールとして読み込むようにします。
あと、今回は必要ないものもありますが、もそもろの設定もしておきます。

ktor {
  deployment {
    port = 8081
    port = ${?APP_PORT}
  }

  application {
    modules = [
      dev.hirooka.ApplicationKt.main,
    ]
  }

  environment = "test"
  environment = ${?KTOR_ENV}
}
$ mvn compile exec:java


$ curl localhost:8081/hello
Hello, Koin

KoinでDIする

Koinで依存を定義しDIをやってみます。
まずは、以下のようなサービスクラスとデータクラスを作成します。

data class Name(val value: String = "Moheji")

interface HelloService {
    fun greeting(): String
}

class HelloServiceImlp(private val name: Name) : HelloService {
    override fun greeting() = "Hello, ${name.value}"
}

関係性としては、HelloServiceインターフェースをHelloServiceImplが実装してNameデータクラスに依存しています。
DIの設定を記述していきます。
Application.ktを以下のように書き換えます。

import io.ktor.application.*
import io.ktor.features.*
import io.ktor.routing.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import org.koin.dsl.module
import org.koin.ktor.ext.Koin

fun Application.main() {
    install(DefaultHeaders)
    install(CallLogging)

    routing {
        hello()
    }
}

fun Application.koin() {

    install(Koin) {
        modules(
            module {
                single { Name() }
                single { HelloServiceImlp(get()) as HelloService }
            }
        )
    }
}

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

Application.koin()でモジュールを1つ追加しKoinの依存の設定を記述しています。Application.main()モジュールで書いても問題はないのですが、設定を分けて置けるとあとから読みやすかったりするので分けました。
上記ではNameデータクラスとHelloSerivceImplクラスをそれぞれコンテナに入れています、すでにコンテナに入っているものはget()で取り出すことが可能で、HelloSerivceImplインスタンスを生成する際のNameインスタンスをインジェクションする際に利用しています。
また、HelloSerivceImplHelloSerivce でキャストすることで利用時にHelloSerivce方でのコンテナからの取り出しを行えます。

application.confを書き換えモジュールを読み込むように変更します。

ktor {
  deployment {
    port = 8081
    port = ${?APP_PORT}
  }

  application {
    modules = [
      dev.hirooka.ApplicationKt.main,
      dev.hirooka.ApplicationKt.koin
    ]
  }

  environment = "test"
  environment = ${?KTOR_ENV}
}

それでは最後にDIコンテナに入れたHelloSerivceImplをハンドラーから利用します。
ハンドラーを以下のように書き換えます。

import io.ktor.application.*
import io.ktor.response.*
import io.ktor.routing.*
import org.koin.ktor.ext.inject

fun Route.hello() {

    val helloService by inject<HelloService>()

    get("/hello") {
        call.respond(helloService.greeting())
    }
}

コンテナからサービスを取り出す際にはinjectを利用します。
アプリケーションを起動し直して、アクセスします。

$ mvn compile exec:java

$ curl localhost:8081/hello -v
Hello, Moheji

一通りの使い方はこんな感じですね。

GitHub ActionsでGauge Test(Kotlin)を実行する

はじめに

仕事ではGaugeを使うことが多いのですが、GitHub Actionsを使って動かすにはどうすればいいんだろうかというところに興味が少しわきました。 そもそもGitHub Actionsをそんなに使ったことも無かったのでHello Worldも兼ねてやってみようかと思います。 また、おまけとしてWorkflowの実行結果をSlackに通知するようにしてみたいと思います。

やってみる

環境

Gauge(といくつかのプラグイン)は事前にインストールしています。
Gaugeのプライグインに関しては今回はgauge-javaだけでよい想定です。

$ uname -srvmpio
Linux 5.4.0-77-generic #86-Ubuntu SMP Thu Jun 17 02:35:03 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

$ lsb_release -a
LSB Version:    core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.2 LTS
Release:    20.04
Codename:   focal
yuya-hirooka@yuya-hirooka:~/source/sleepy

$ java --version
openjdk 11.0.10 2021-01-19
OpenJDK Runtime Environment 18.9 (build 11.0.10+9)
OpenJDK 64-Bit Server VM 18.9 (build 11.0.10+9, mixed mode)

$ gauge version
Gauge version: 1.3.1
Commit Hash: c76b761

Plugins
-------
html-report (4.0.8)
java (0.7.15)
screenshot (0.0.1)
xml-report (0.2.2)

プロジェクトを作成する

まずは、Gaugeのプロジェクトを作成します。
まずは、Maven Javaでプロジェクトを作成し、Kotlinで動作させるように変更します。
gauge initコマンドでプロジェクトの作成を行います。

$ mkdir gauge-kotlin
$ cd gauge-kotlin/
$ gauge init java_maven
Initializing template from https://github.com/getgauge/template-java-maven/releases/latest/download/java_maven.zip
.
Copying Gauge template java_maven to current directory ...
Successfully initialized the project. Run specifications with "mvn clean test" in project root.

プロジェクトができたら、src/test/javaspecs/example.specは消してしまって大丈夫です。

Kotlinでコードを記述できるようにするために、Pomに以下の記述を追加します。

    <properties>
        <kotlin.compiler.incremental>true</kotlin.compiler.incremental>
        <java.version>11</java.version>
        <kotlin.version>1.5.20</kotlin.version>
        <kotlin.compiler.jvmTarget>${java.version}</kotlin.compiler.jvmTarget>
    </properties>

    <dependencies>
        // もともとあった依存は省略
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-stdlib</artifactId>
            <version>${kotlin.version}</version>
        </dependency>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-test</artifactId>
            <version>${kotlin.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
             // もともとあったGaugeのプラグインは省略
            <plugin>
                <groupId>org.jetbrains.kotlin</groupId>
                <artifactId>kotlin-maven-plugin</artifactId>
                <version>${kotlin.version}</version>
                <configuration>
                    <args>
                        <arg>-Xjsr305=strict</arg>
                    </args>
                    <jvmTarget>11</jvmTarget>
                </configuration>
                <executions>
                    <execution>
                        <id>compile</id>
                        <phase>compile</phase>
                        <goals>
                            <goal>compile</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>test-compile</id>
                        <phase>test-compile</phase>
                        <goals>
                            <goal>test-compile</goal>
                        </goals>
                        <configuration>
                            <sourceDirs>
                                <source>src/test/kotlin</source>
                            </sourceDirs>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

次にsrc/test/kotlinディレクトリを作成し、以下のクラスを作成します。

import com.thoughtworks.gauge.Step

class HelloGitHubActions {

    @Step("Hello, GitHub Actionの文字列を出力する")
    fun hello(){
        println("Hello, GitHub Action")
    }
}

対応するSpecを記述します。
specs/example.specを作成し以下の記述を行います。

# Hello GitHub Actions

## GitHub Actionsに入門する
* Hello, GitHub Actionの文字列を出力する

テストを実行します。

$ mvn test

(省略)

# Hello GitHub Actions
  ## GitHub Actionsに入門する   Hello, GitHub Action
 ✔

Successfully generated html-report to => /home/yuya-hirooka/source/kotlin/gauge-kotlin/reports/html-report/index.html
Specifications: 1 executed      1 passed        0 failed        0 skipped
Scenarios:      1 executed      1 passed        0 failed        0 skipped

Total time taken: 42ms
Updates are available. Run `gauge update -c` for more info.
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  8.569 s
[INFO] Finished at: 2021-07-10T15:46:47+09:00

ここまででProjectの準備は完了です。

GitHub Actionsの設定をおこなう

GitHub Actionsの概要

GitHub Actionsは、ほぼはじめてであるためまずは簡単に概要をまとめます。
GitHub Actionsは開発のライフサイクルを自動化してくれるSaaSです。
イベント駆動で、指定されたイベントが発生した際に定義してある一連のコマンドが実行されます。
GitHub Actionsを構成する要素として以下のような概念が存在します。

  • Workflow
    • リポジトリに追加する。一連の自動化されたプロセスです。1つ以上のJob(後述)で構成され、スケジュールかもしくは設定されたイベントの発起をトリガーに実行されます。Workflowでプロジェクトのビルド、テスト、デプロイ等を行なうことができます。
  • Event
    • Workflowのトリガーとなるアクティビティです。コミットのPush、Issueの作成 、プルリクなど様々なアクティビティがあります(他のアクティビティに関してはこちらをご覧ください)。
  • Job
    • 同一のRunnerで実行される一連のStep(後述)の集合です。Workflowが複数Jobを持つ場合はデフォルトでJobを並行で実行します。シーケンシャルにJobを実行させることも可能です。
  • Step
  • Job内で実行される単一のタスクです。StepはAction(後述)もしくはShellコマンドとなります。
  • Action
  • Stepに結合されているスタンドアローンなコマンド。Workflow内での最小の構成要素となります。独自アクションの作成が可能ですし、GitHubコミュニティによって提供されるアクションを利用することも可能です。
  • Runnler
    • Workflowが実行されるGitHub Action Runnerがインストールされているサーバ。自分でホストすることも可能ですし、GitHubでホストされているものを利用することも可能です。Workflowの各Jobは新しい仮想環境で実行されます。自分のRunnerをホストしたい場合はこちらを参照してください。

GitHub ActionsではWorkflowはyamlファイルで定義します。

name: learn-github-actions
on: [push]
jobs:
  check-bats-version:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v1
      - run: npm install -g bats
      - run: bats -v

例えば上記のyamlは以下のように定義されています。

  • name:Workflowの名前。Actionsタブに表示される
  • on: [push]:Workflowのトリガーとなるうイベント上記の場合Pushされた際に発火される。
  • jobs:Workflowを構成する一連のJobをグループ化する
  • check-bats-version:Jobの名前。
  • runs-on: ubuntu-latest:Jobが実行されるRunnerを定義。上記の場合Ubuntu Linuxのランナーで実行される。
  • steps:check-bats-versionJobで実行されるStepのグループ化する
    • uses: アクションの定義
    • actions/checkout@v2:コミュニティアクションの v2 を取得するようにジョブに指示。リポジトリをランナーにチェックアウトしてアクションを実行できるようにする。
    • actions/setup-node@v1:Nodeのソフトウェアパッケージをインストールしnpmコマンドを利用できるようにしている。
    • run: npm install -g bats:Runnnerでコマンドを実行する。上記の場合npmでbatsをインストールしている。
    • run: bats -v:Runnerでコマンドを実行する。上記の場合batsのバージョンを表示している。

ベースとなるJavaのVersionを表示するWorkflowを定義する。

一通り、まとめて早速使っていきたいと思います。
まずはリポジトリを作成して先程作成したプロジェクトをPushしておきます。 

次にベースとなるWorkflowを定義します。
.github/workflows/gauge-test.yamlを作成して以下のようなyamlを記述します。

name: gauge-test
on: [push]
jobs:
  gauge-test:
    runs-on: ubuntu-20.04
    steps:
    - uses: actions/checkout@v2
    - uses: actions/setup-java@v2
      with:
        distribution: 'adopt'
        java-version: '11'
    - run: java --version

上記のActionはリポジトリをチェックアウトして、Javaのセットアップを行い、Versionを表示しているだけです。
JavaのセットアップはSetup Java JDKを用いています。

f:id:yuya_hirooka:20210710202554p:plain

上記の図のように✓のバッチが付いているAcitonはGitHubがアクションの作成者をパートナーオーガナイゼーションとして認めたものになるみたいです。

このアクションは大きく以下のようなことを行ってくれます。

  • Javaのセットアップ
  • Mavenのセットアップ
  • Gradleのセットアップ

Javaに関してはZulu OpenJDKAdopt OpenJDK HotspotAdopt OpenJDK OpenJ9から選べるみたいで、またそのバージョンは 81115の中から選択可能なようです(その他の細かいJavaのバージョンはこちらで確認してください)。

このWorkflowをpushしGitHubリポジトリActionsセクションを確認します。

f:id:yuya_hirooka:20210710204538p:plain

何度かミスってしまってますが、無視してください。 最新の成功している実行を確認すると以下のように表示されます。

f:id:yuya_hirooka:20210710204736p:plain

ちゃんとJavaのバージョンが表示されていますね。 クリーンの処理も走っているみたいです。

Gaugeを実行するWorkflowを定義する

ベースとなるWorkflowはできたので、Gauge Testを実行するようにWorkflowを修正します。

name: gauge-test
on: [push]
jobs:
  gauge-test:
    runs-on: ubuntu-20.04
    steps:
    - uses: actions/checkout@v2
    - uses: actions/setup-java@v2
      with:
        distribution: 'adopt'
        java-version: '11'
    - run: curl -SsL https://downloads.gauge.org/stable | sh -s -- --location-[custom path]
    - run: gauge install java --version 0.7.15
    - run: gauge version

先程のWorkflowから gaugeをインストールしてバージョンを表示するように修正しています。
pushしてActionsの実行結果をみてみます。

f:id:yuya_hirooka:20210710210811p:plain

いい感じにできてるみたいですね。
最後に、テストを実行してみます。

name: gauge-test
on: [push]
jobs:
  gauge-test:
    runs-on: ubuntu-20.04
    steps:
    - uses: actions/checkout@v2
    - uses: actions/setup-java@v2
      with:
        distribution: 'adopt'
        java-version: '11'
    - run: curl -SsL https://downloads.gauge.org/stable | sh -s -- --location-[custom path]
    - run: gauge install java --version 0.7.15
    - run: gauge version
    - run: mvn test

pushしてActionsの実行結果をみてみます。

f:id:yuya_hirooka:20210710211157p:plain

テストの実行まで行えましたね。

Skaffoldを用いてローカルでk8sにデプロイするJavaアプリの開発を行なう

はじめに

最近身の回りでSkaffoldという名前をよく聞くようになりまして、ちょっと気になって調べたら面白そうだったし、今後使っていきそうな雰囲気を感じたので、ちょっとさわっておこうかと思います。

Skaffoldとは?

Skaffoldはk8sネイティブなアプリケーションの開発をサポートしてくれるコマンドラインツールです。
k8sに対するBuild、Push、Deploy等をサポートしてくれます。 大まかには以下のような機能や特徴があります。

  • ローカルでの開発において、のソースコードの変更を検知して、自動でBuild、Push、Deployまでのサポート。
  • ローカルの開発において、ログに対するサポートとポートフォワードのサポート
  • git cloneskaffold runの実行で様々な環境でアプリを動作させることが可能
  • Skaffoldのprofile, local user config, environment variables, flags などの機能を使って環境ごとの設定を組み込むことが可能
  • skaffold renderコマンドを用いてKubernetesマニフェストのテンプレートをレンダリングすることによって、GitOpsワークフローをサポート
  • Clusterは無くクライアント再度のみで独立している
  • skaffold.yamlファイルによって宣言的で、プラガブルな設定が可能

Skaffold自体はCI/CDにおけるワークフローのサポートも行っているようですが、今回のこのブログではローカルでのアプリケーション開発におけるいくつかの機能を試してみたいと思います。
また、今回はJavaアプリケーションで開発を行ってみようと思います。

使ってみる

動作環境

ローカルのクラスタはMinikube(Docker Drive)を用いて構築します。

$ uname -srvmpio
Linux 5.4.0-77-generic #86-Ubuntu SMP Thu Jun 17 02:35:03 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

$ lsb_release -a
LSB Version:    core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.2 LTS
Release:    20.04


$ minikube version
minikube version: v1.21.0
commit: 76d74191d82c47883dc7e1319ef7cebd3e00ee11

$ kubectl version -o yaml
clientVersion:
  buildDate: "2021-03-18T01:10:43Z"
  compiler: gc
  gitCommit: 6b1d87acf3c8253c123756b9e61dac642678305f
  gitTreeState: clean
  gitVersion: v1.20.5
  goVersion: go1.15.8
  major: "1"
  minor: "20"
  platform: linux/amd64
serverVersion:
  buildDate: "2021-05-12T12:32:49Z"
  compiler: gc
  gitCommit: 132a687512d7fb058d0f5890f07d4121b3f0a2e2
  gitTreeState: clean
  gitVersion: v1.20.7
  goVersion: go1.15.12
  major: "1"
  minor: "20"
  platform: linux/amd64

Skaffoldのインストール

Skaffoldをインストールするためには以下のコマンドを叩きます。

$ curl -Lo skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64 && \
   sudo install skaffold /usr/local/bin/

$ skaffold version
v1.27.0

その他、MacWindows、Dockerでのインストールはこちらをご確認ください。

サンプルアプリを作成しておく

Skaffoldを使ってデプロイやディバグなどの機能を試すために開発対象となるアプリを作っておきます。
特に深い意図はないのですがSpringを使ってやろうかと思います。
Spring Initializrで以下の設定でアプリを作成します。

f:id:yuya_hirooka:20210709030915p:plain

依存はWebだけを追加してます。

Skaffoldプロジェクトを初期化する

ダウンロードしてきたプロジェクトを解凍して、プロジェクトのルートに移動し以下のコマンドを実行します。
Skaffoldプロジェクトを初期化するには、skaffold initコマンドを用います。
skaffold initコマンドは実行するとプロジェクトをスキャンし、以下のようなファイルを見つけるとその構成に合わせた設定を行ってくれます。

  • Dockerfile
  • build.gradle/pom.xml
  • package.json
  • requirements.txt
  • go.mod

ちなみに500MB以上のファイルは無視されるようです。
例えば、こんかいのケースではいくつかの選択肢を提示してくれます。

$  skaffold init --generate-manifests
? Select port to forward for pom-xml-image (leave blank for none): 8080

--XXenableJibInitフラグや--XXenableBuildpacksInitフラグを使えば、それぞれJibやBuildpacksを用いた構成を作ることも可能なようです。
--generate-manifestsフラグはマニフェストの生成まで行ってもらうために使用しています。このフラグを使用しない場合は自分で作成したdeployment.yamlを用いることになります。
今回の場合はpom.xmlのみが検知され、Buildpackを用いた設定がされます。

$  skaffold init --generate-manifests
? Select port to forward for pom-xml-image (leave blank for none): 8080
adding manifest path deployment.yaml for image pom-xml-image
apiVersion: skaffold/v2beta18
kind: Config
metadata:
  name: skaffold-sample
build:
  artifacts:
  - image: pom-xml-image
    buildpacks:
      builder: gcr.io/buildpacks/builder:v1
deploy:
  kubectl:
    manifests:
    - deployment.yaml
portForward:
- resourceType: service
  resourceName: pom-xml-image
  port: 8080

deployment.yaml - apiVersion: v1
kind: Service
metadata:
  name: pom-xml-image
  labels:
    app: pom-xml-image
spec:
  ports:
  - port: 8080
    protocol: TCP
  clusterIP: None
  selector:
    app: pom-xml-image
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pom-xml-image
  labels:
    app: pom-xml-image
spec:
  replicas: 1
  selector:
    matchLabels:
      app: pom-xml-image
  template:
    metadata:
      labels:
        app: pom-xml-image
    spec:
      containers:
      - name: pom-xml-image
        image: pom-xml-image

? Do you want to write this configuration, along with the generated k8s manifests, to skaffold.yaml? Yes
Generated manifest deployment.yaml was written
Configuration skaffold.yaml was written
You can now run [skaffold build] to build the artifacts
or [skaffold run] to build and deploy
or [skaffold dev] to enter development mode, with auto-redeploy

skaffold.yamlは以下のようになります。

apiVersion: skaffold/v2beta18
kind: Config
metadata:
  name: skaffold-sample
build:
  artifacts:
  - image: pom-xml-image
    buildpacks:
      builder: gcr.io/buildpacks/builder:v1
deploy:
  kubectl:
    manifests:
    - deployment.yaml
portForward:
- resourceType: service
  resourceName: pom-xml-image
  port: 8080

上記のskaffold.yamlではビルドやデプロイ、ポートフォワードの設定が行われています。
その他、ここで使われていない項目や設定の説明はこちらをご覧ください。

次に進む前にイメージの名前がpom-xml-sampleだとあまりにもあまりになので以下のように書き換えておきます。
また、Buildkitを有効にしておきます。

apiVersion: skaffold/v2beta18
kind: Config
metadata:
  name: skaffold-sample
build:
  artifacts:
  - image: spring-app
    buildpacks:
      builder: gcr.io/buildpacks/builder:v1
deploy:
  kubectl:
    manifests:
    - deployment.yaml
portForward:
- resourceType: service
  resourceName: pom-xml-image
  port: 8080

作成されたk8sマニフェストpom-xml-sampleの部分もspring-appに書き換えておきます。

apiVersion: v1
kind: Service
metadata:
  name: spring-app
  labels:
    app: spring-app
spec:
  ports:
  - port: 8080
    protocol: TCP
  clusterIP: None
  selector:
    app: spring-app
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-app
  labels:
    app: spring-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: spring-app
  template:
    metadata:
      labels:
        app: spring-app
    spec:
      containers:
      - name: spring-app
        image: spring-app

devモードをで開発を行なう

前述したとおり、Skaffoldはローカルでの開発に置いてソースコードの変更を検知して、自動でBuild、Push、Deployまでのサポートまでをサポートしてくれます。
devモードで起動するとその機能が利用可能で、以下のコマンドでdevモードで起動します。

$ skaffold dev

(省略)

Starting test...
Tags used in deployment:
 - spring-app -> spring-app:e0b79f2a42356a8de0ba7b3da0f0f74903c0d9b99ddf1db39ed36a872a90d577
Starting deploy...
 - service/spring-app created
 - deployment.apps/spring-app created
Waiting for deployments to stabilize...
 - deployment/spring-app is ready.
Deployments stabilized in 1.129 second
Press Ctrl+C to exit
Watching for changes...
[spring-app] 
[spring-app]   .   ____          _            __ _ _
[spring-app]  /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
[spring-app] ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
[spring-app]  \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
[spring-app]   '  |____| .__|_| |_|_| |_\__, | / / / /
[spring-app]  =========|_|==============|___/=/_/_/_/
[spring-app]  :: Spring Boot ::                (v2.5.2)
[spring-app] 
[spring-app] 2021-07-08 17:17:30.213  INFO 20 --- [           main] d.h.s.SkaffoldSampleApplication          : Starting SkaffoldSampleApplication v0.0.1-SNAPSHOT using Java 11.0.11 on spring-app-6d5b5c74c4-2q6xs with PID 20 (/workspace/target/skaffold-sample-0.0.1-SNAPSHOT.jar started by cnb in /workspace)
[spring-app] 2021-07-08 17:17:30.215  INFO 20 --- [           main] d.h.s.SkaffoldSampleApplication          : No active profile set, falling back to default profiles: default
[spring-app] 2021-07-08 17:17:31.073  INFO 20 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
[spring-app] 2021-07-08 17:17:31.085  INFO 20 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
[spring-app] 2021-07-08 17:17:31.086  INFO 20 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.48]
[spring-app] 2021-07-08 17:17:31.156  INFO 20 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
[spring-app] 2021-07-08 17:17:31.157  INFO 20 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 880 ms
[spring-app] 2021-07-08 17:17:31.683  INFO 20 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
[spring-app] 2021-07-08 17:17:31.691  INFO 20 --- [           main] d.h.s.SkaffoldSampleApplication          : Started SkaffoldSampleApplication in 2.022 seconds (JVM running for 2.444)

コマンドを実行するとビルドが始まり少し待つとKubernetes上にデプロイされます。
また、devモードで起動するとローカルマシンへのポートフォワードも自動的に行ってくれます

ここまででServiceとDeploymentがローカルのMinikubeで作ったクラスタに作成されリソースが作られている状態でかつホストマシンへのポートフォワードまで行われています。

$ kubectl get svc
NAME         TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)    AGE
kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP    7d3h
spring-app   ClusterIP   None         <none>        8080/TCP   4m48s


$ kubectl get deploy
NAME         READY   UP-TO-DATE   AVAILABLE   AGE
spring-app   1/1     1            1           5m17s

8080ポートフォワードされているのでcURLでアクセスしてみます。

$ curl localhost:8080
{"timestamp":"2021-07-08T17:28:57.961+00:00","status":404,"error":"Not Found","path":"/"}

現状はコントローラーを作成していないので404が返ってきます。
以下のクラスを作成してコントローラーを1つ作ってみます。

@RestController
public class SampleController {

    @GetMapping("/")
    public String helle(){
        return "Hello, Skaffold";
    }
}

コードを修正すると自動でビルドが走りクラスターにデプロイされます。
再度、cURLでアクセスすると今度はHello, Skaffoldの文字列が返ってきます。

$ curl localhost:8080
Hello, Skaffold

debugモードで起動して、IntelliJを用いてDebugする

Skaffoldのdebugモードはdevモードと同じように動作しますが、debug用のPodが立ち上がりlanguage runtimeに応じたdebug用のポートがホストにポートフォワードされます。
Javaの場合はJDWPを用いてdebugが可能となるようです。
ここで、debug自動デプロイ機能が無効になるので注意が必要です。
以下のコマンドでdebugモードで起動します。

$ skaffold debug
(省略)
[spring-app] Picked up JAVA_TOOL_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,address=5005,suspend=n,quiet=y
(省略)
Port forwarding pod/spring-app-7b74d66d9-42wns in namespace default, remote port 5005 -> 127.0.0.1:5005

ログに出力されているように出力されJWDPのポートが5005で公開されてるのがわかります。

InteliJからリモートdebug用のプロセスに接続します。
Run/Debug Configurationを開き左上の+ボタンからRemote JVM Debugを選択します。

f:id:yuya_hirooka:20210709025735p:plain

基本はデフォルトのままの設定で大丈夫ですが、名前の部分だけspring-appにしておきます。
Applyを押してIntelliJをDebug実行をすると起動します。
先程のコントローラーにブレークポイントを置いておきます。

f:id:yuya_hirooka:20210709030116p:plain

この状態で再度cURLでリクエストを投げると置いたブレークポイントで停止することが確認できます。

f:id:yuya_hirooka:20210709030224p:plain

OperatorSDKでCustom ResourceとCustom Controllerを作る

はじめに

k8sではいくつかの拡張ポイントが用意されています。その中でも、APIをカスタムするためのCustom Resourceや調整ループによってリソースオブジェクトの状態管理を行い、宣言的なAPIを可能にするCustom Controllerがあります。
まえまえからこの2つには興味がありつつも触れることができてなかったのですが、先日k8sのブログに「Writing a Controller for Pod Labels」というのを見かけて、OperatorSDKを使えばいい感じにできそうであるということに気がついたので試してみたいと思います。

OperatorSDKとは

そもそもOperatorとはなんぞやという部分なのですが、 Custom Resourceを使ってアプリケーションとそのコンポーネントを管理するソフトウェアの拡張で、OperatorそのものはControllerパターンに則ってリソースオブジェクトを管理します。

OperatorSDKはその名の通りでOperatorを作る際のSDKです。
Operatorを作る際の高次元なAPIを提供していたり、テスト、ビルド、パッケージングのサポートをしていたりします。
また、Operatorやカスタムリソースを作成する際のベースとなるテンプレートプロジェクトの作成などもしてくれるようです。
OperatorSDKでは以下の方法でOperatorの作成を行えるようです。

今回はGoでOperator(Memcached Operator)を作る方法を試してみます。
基本的にはここをたどって気になったところを深堀りする感じでやっていこうと思います。

使ってみる

環境

動作環境は以下の通り

$ uname -srvmpio
Linux 5.4.0-77-generic #86-Ubuntu SMP Thu Jun 17 02:35:03 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

$ lsb_release -a
LSB Version:    core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.2 LTS
Release:    20.04
Codename:   focal

$ go version
go version go1.16 linux/amd64


# インストールに必要っぽい
$ gpg --version
gpg (GnuPG) 2.2.19
libgcrypt 1.8.5
Copyright (C) 2019 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
(省略)


$ minikube version
minikube version: v1.21.0
commit: 76d74191d82c47883dc7e1319ef7cebd3e00ee11

$ kubectl version -o yaml
clientVersion:
  buildDate: "2021-03-18T01:10:43Z"
  compiler: gc
  gitCommit: 6b1d87acf3c8253c123756b9e61dac642678305f
  gitTreeState: clean
  gitVersion: v1.20.5
  goVersion: go1.15.8
  major: "1"
  minor: "20"
  platform: linux/amd64
serverVersion:
  buildDate: "2021-01-13T13:20:00Z"
  compiler: gc
  gitCommit: faecb196815e248d3ecfb03c680a4507229c2a56
  gitTreeState: clean
  gitVersion: v1.20.2
  goVersion: go1.15.5
  major: "1"
  minor: "20"
  platform: linux/amd64

OperatorSDKのインストール

インストトールは以下の3つの方法があります。

今回はInstall from GitHub releaseでインストールしてみます。
インストールにはcurlgpgのversion 2.0以上が必要みたいです。
バイナリをダウンロードします。

$ echo $ARCH
amd64

$ export OS=$(uname | awk '{print tolower($0)}')
$ echo $OS
linux

$ curl -LO https://github.com/operator-framework/operator-sdk/releases/download/v1.9.0/operator-sdk_${OS}_${ARCH}

次にバイナリの検証を行います。

$  gpg --keyserver keyserver.ubuntu.com --recv-keys 052996E2A20B5C7E

$ curl -LO https://github.com/operator-framework/operator-sdk/releases/download/v1.9.0/checksums.txt

$ curl -LO https://github.com/operator-framework/operator-sdk/releases/download/v1.9.0/checksums.txt.asc

$  gpg -u "Operator SDK (release) <cncf-operator-sdk@cncf.io>" --verify checksums.txt.asc
gpg: 署名されたデータが'checksums.txt'にあると想定します
gpg: 2021年06月18日 08時21分40秒 JSTに施された署名
gpg:                RSA鍵8613DB87A5BA825EF3FD0EBE2A859D08BF9886DBを使用
gpg: "Operator SDK (release) <cncf-operator-sdk@cncf.io>"からの正しい署名 [不明の]
gpg: *警告*: この鍵は信用できる署名で証明されていません!
gpg:          この署名が所有者のものかどうかの検証手段がありません。
主鍵フィンガープリント: 3B2F 1481 D146 2380 80B3  46BB 0529 96E2 A20B 5C7E
     副鍵フィンガープリント: 8613 DB87 A5BA 825E F3FD  0EBE 2A85 9D08 BF98 86DB

公開鍵自体が信用できるかわからないという警告が出ていますね。 ただ、checksum.txtの検証自体はうまく言ってるみたいなのでここでは先に進もうと思います。

$ grep operator-sdk_${OS}_${ARCH} checksums.txt | sha256sum -c -
operator-sdk_linux_amd64: OK

チェックサムもOKみたいです。
実行権限を付与して、Pathがとおっているところに配置します。

$ chmod +x operator-sdk_${OS}_${ARCH} && sudo mv operator-sdk_${OS}_${ARCH} /usr/local/bin/operator-sdk

これでインストールは完了です。

$ operator-sdk version
operator-sdk version: "v1.9.0", commit: "205e0a0c2df0715d133fbe2741db382c9c75a341", kubernetes version: "1.20.2", go version: "go1.16.5", GOOS: "linux", GOARCH: "amd64"

プロジェクトを作成する

まずは、プロジェクトを作成します。

$ mkdir operatorsdk-sample
$ cd operatorsdk-sample/
$ operator-sdk init --domain hirooka.dev --repo github.com/samuraiball/settings

operator-sdk initコマンドではGo modulesベースのプロジェクトを作成します。
$GOPATH/src以外のところでプロジェクトをInitする場合は--repoフラグでリポジトリを指定する必要があるみたいです。
--domainフラグではDockerレジストリもしくは、Docker Hubのnamespace(ユーザ名)を指定します。
今回この最初の部分で色々しくってレジストリでもユーザ名でもないものを指定してしまったのですが、Makefileの記述を変えればうまく行くので一旦ここでは先に進みます。
さておきコマンドを実行すると諸々の設定がすんだプロジェクトが出来上がります。

ディレクトリ構造は以下のような感じ。

$ tree
.
├── Dockerfile
├── Makefile
├── PROJECT
├── config
│   ├── default
│   │   ├── kustomization.yaml
│   │   ├── manager_auth_proxy_patch.yaml
│   │   └── manager_config_patch.yaml
│   ├── manager
│   │   ├── controller_manager_config.yaml
│   │   ├── kustomization.yaml
│   │   └── manager.yaml
│   ├── manifests
│   │   └── kustomization.yaml
│   ├── prometheus
│   │   ├── kustomization.yaml
│   │   └── monitor.yaml
│   ├── rbac
│   │   ├── auth_proxy_client_clusterrole.yaml
│   │   ├── auth_proxy_role.yaml
│   │   ├── auth_proxy_role_binding.yaml
│   │   ├── auth_proxy_service.yaml
│   │   ├── kustomization.yaml
│   │   ├── leader_election_role.yaml
│   │   ├── leader_election_role_binding.yaml
│   │   ├── role_binding.yaml
│   │   └── service_account.yaml
│   └── scorecard
│       ├── bases
│       │   └── config.yaml
│       ├── kustomization.yaml
│       └── patches
│           ├── basic.config.yaml
│           └── olm.config.yaml
├── go.mod
├── go.sum
├── hack
│   └── boilerplate.go.txt
└── main.go

config/ディレクトリには起動時の設定が諸々用意されて、Kustomizeyamlファイルが入っています。

  • config/manager:コントローラーをPodとして起動する設定
  • config/rbac:作成するコントローラーを操作する際のパーミッションの設定

その他にもCRDやWebhookの設定も入っているようです。

また、main.goがOperatorのエントリーポイントとなるみたいです。

func init() {
    utilruntime.Must(clientgoscheme.AddToScheme(scheme))

    //+kubebuilder:scaffold:scheme
}

func main() {
    var metricsAddr string
    var enableLeaderElection bool
    var probeAddr string
    flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
    flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
    flag.BoolVar(&enableLeaderElection, "leader-elect", false,
        "Enable leader election for controller manager. "+
            "Enabling this will ensure there is only one active controller manager.")
    opts := zap.Options{
        Development: true,
    }
    opts.BindFlags(flag.CommandLine)
    flag.Parse()

    ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))

    mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
        Scheme:                 scheme,
        MetricsBindAddress:     metricsAddr,
        Port:                   9443,
        HealthProbeBindAddress: probeAddr,
        LeaderElection:         enableLeaderElection,
        LeaderElectionID:       "6a59cba3.hirooka.dev",
    })
    if err != nil {
        setupLog.Error(err, "unable to start manager")
        os.Exit(1)
    }

    //+kubebuilder:scaffold:builder

    if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
        setupLog.Error(err, "unable to set up health check")
        os.Exit(1)
    }
    if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
        setupLog.Error(err, "unable to set up ready check")
        os.Exit(1)
    }

    setupLog.Info("starting manager")
    if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
        setupLog.Error(err, "problem running manager")
        os.Exit(1)
    }
}

ここではControllerのセットアップ、実行のトラッキング、キャッシュの制御、CRDのスキーマ登録等を行ってくれるManagerを初期化する処理が書かれています。
ManagerではControllerがResorceを監視するネームスペースを制限することができます。

mgr, err := ctrl.NewManager(cfg, manager.Options{Namespace: namespace})

デフォルトではネームスペースはOperatorが動いているネームスペースのものを監視するようになります。
もしすべてのネームスペースを監視するようにしたい場合はNamespace: ""と空の文字列を入れる必要があるようです。
ここではなにも触らずに、デフォルトのままで先に進みます。

新しいCRDとControllerを追加する

足場となるコードを自動生成する

次に新しいCRDとControllerを追加します。
以下のコマンドを実行します。

$ operator-sdk create api --group cache --version v1alpha1 --kind Memcached --resource --controller 

これで、groupがcache、versionがv1alpha1、KindがMemcachedの足場が完成します。 今回深くはおいませんが、groupなどのそれぞれの意味はこちらをご確認ください。

実行してGitでどんな感じの変更が入っているかをみてみます。

$ git status
ブランチ master
Your branch is up to date with 'origin/master'.

コミット予定の変更点:
  (use "git restore --staged <file>..." to unstage)
    new file:   ../../docker/Dockerfile
    new file:   ../opentracing/deployment.yaml
    new file:   ../opentracing/gateway.yaml
    new file:   ../opentracing/service.yaml
    new file:   ../opentracing/tracing.yaml
    new file:   ../opentracing/virtual-service.yaml
    modified:   PROJECT
    new file:   api/v1alpha1/groupversion_info.go
    new file:   api/v1alpha1/memcached_types.go
    new file:   api/v1alpha1/zz_generated.deepcopy.go
    new file:   config/crd/kustomization.yaml
    new file:   config/crd/kustomizeconfig.yaml
    new file:   config/crd/patches/cainjection_in_memcacheds.yaml
    new file:   config/crd/patches/webhook_in_memcacheds.yaml
    new file:   config/rbac/memcached_editor_role.yaml
    new file:   config/rbac/memcached_viewer_role.yaml
    new file:   config/samples/cache_v1alpha1_memcached.yaml
    new file:   config/samples/kustomization.yaml
    new file:   controllers/memcached_controller.go
    new file:   controllers/suite_test.go
    modified:   go.mod
    modified:   main.go

新しいファイルがいくつかできているのとmain.goなどが書き換わってますね。

Custom Resourceを編集する

ここで、注目すべきはapi/v1alpha1/memcached_types.gocontrollers/memcached_controller.goでこれがCustom ResourceとCustom Controllerのベースのコードとなります。
まずはmemcached_types.goの方から修正していきます。
Memcachedとそれに関係する構造体が定義されているのがわかります。

type Memcached struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   MemcachedSpec   `json:"spec,omitempty"`
    Status MemcachedStatus `json:"status,omitempty"`
}

Memached構造体が持つMemcachedSpec.Sizeという、デプロイされるCustom Resourceの数を設定するフィールドと、MemcachedStatus.NodesではCustom Resourceで作られるPodの名前を保存するフィールドを追加します。

// MemcachedSpec defines the desired state of Memcached
type MemcachedSpec struct {
    //+kubebuilder:validation:Minimum=0
    // デプロイされるMemcachedの数
    Size int32 `json:"size"`
}

// MemcachedStatus defines the observed state of Memcached
type MemcachedStatus struct {
    // MemcachedのPodの名前を保存する
    Nodes []string `json:"nodes"`
}

次にMemached構造体に+kubebuilder:subresource:statusマーカーを追加します。
Status Subresourceを追加することによりControllerが他のCustom Resourceオブジェクトに変更を加えること無くCustom Resourceのステータスを更新できるようになるみたいです。

//+kubebuilder:subresource:status
type Memcached struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   MemcachedSpec   `json:"spec,omitempty"`
    Status MemcachedStatus `json:"status,omitempty"`
}

*_types.goは必ず以下のコマンドを実行して、Resource Typeの自動生成されるコードを更新する必要があります。

$ make generate

Makefileで定義されたこのコマンドはcontroller-genを実行して、api/v1alpha1/zz_generated.deepcopy.goをアップデートします。

また、 SpecやStatusフィースドにCRD validationマーカーが付いている場合、以下のコマンドでそのCRDのマニフェストを生成することができます。

$ make manifests

config/crd/bases/cache.example.com_memcacheds.yamlに以下のようなCRDのマニフェストが生成されます。

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  annotations:
    controller-gen.kubebuilder.io/version: v0.4.1
  creationTimestamp: null
  name: memcacheds.cache.hirooka.dev
spec:
  group: cache.hirooka.dev
  names:
    kind: Memcached
    listKind: MemcachedList
    plural: memcacheds
    singular: memcached
  scope: Namespaced
  versions:
  - name: v1alpha1
    schema:
      openAPIV3Schema:
        description: Memcached is the Schema for the memcacheds API
        properties:
          apiVersion:
            description: 'APIVersion defines the versioned schema of this representation
              of an object. Servers should convert recognized schemas to the latest
              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
            type: string
          kind:
            description: 'Kind is a string value representing the REST resource this
              object represents. Servers may infer this from the endpoint the client
              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
            type: string
          metadata:
            type: object
          spec:
            description: MemcachedSpec defines the desired state of Memcached
            properties:
              size:
                description: デプロイされるMemcachedの数
                format: int32
                minimum: 0
                type: integer
            required:
            - size
            type: object
          status:
            description: MemcachedStatus defines the observed state of Memcached
            properties:
              nodes:
                description: MemcachedのPodの名前を保存する
                items:
                  type: string
                type: array
            required:
            - nodes
            type: object
        type: object
    served: true
    storage: true
    subresources:
      status: {}
status:
  acceptedNames:
    kind: ""
    plural: ""
  conditions: []
  storedVersions: []

コントローラーの実装

コントローラーの実装はここのものを一旦そのまま使います。
まず、SetUpWithManagerメソッドではManagerがセットアップされ、ControllerがどのようにCustom Resourceを管理するかを設定します。

func (r *MemcachedReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&cachev1alpha1.Memcached{}).
        Owns(&appsv1.Deployment{}).
        Complete(r)
}

NewControllerManagedByはコントローラーのビルダーを提供しており、様々なコントローラーの設定を行なうことができます。
例えば上記の例では以下のような設定を行ってます。

  • For(&cachev1alpha1.Memcached{}
    • MecahedをプライマリーResourceとして指定しています。Add/Update/Deleteのそれぞれのイベントが発火されたタイミングで調整ループの中でRequestMemcachedオブジェクトを操作するために送られます。
  • Owns(&appsv1.Deployment{})
    • Deploymentをセカンダリなリソースとして管理することを定義しています。DeploymentAdd/Update/Deleteイベントのタイミングで調整のRequestがDeploymemtのオーナーの調整ループ(Reconcileメソッド)にマップされます。今回の場合はMemcachedオブジェクトになります。

Controllerを初期化する際の様々な設定が用意されています。詳細はこちらをご確認ください。
例えば以下のように設定すると、調整ループの最大の並行数を2に設定し特定条件のイベントを無視するようになります。

func (r *MemcachedReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&cachev1alpha1.Memcached{}).
        Owns(&appsv1.Deployment{}).
                // WithOptionとWithEventFilerを追加
        WithOptions(controller.Options{MaxConcurrentReconciles: 2}).
        WithEventFilter(ignoreDeletionPredicate()).
        Complete(r)
}

func ignoreDeletionPredicate() predicate.Predicate {
    return predicate.Funcs{
        UpdateFunc: func(e event.UpdateEvent) bool {
            // メタデータが変更されていない場合はUpdateを無視する
            return e.ObjectOld.GetGeneration() != e.ObjectNew.GetGeneration()
        },
        DeleteFunc: func(e event.DeleteEvent) bool {
            // オブジェクトがDeleteとなっている場合はfalseで評価される
            return !e.DeleteStateUnknown
        },
    }
}

WithEventFilterPredicateを渡すことでイベントをフィルターすることができるようです。

次に、 Reconcileで調整ループの実装の部分をみていきます。

func (r *MemcachedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {

    memcached := &cachev1alpha1.Memcached{}
    err := r.Get(ctx, req.NamespacedName, memcached)

       // 省略
}

Reconcile関数はCustom Resourceを実際のシステムで理想な状態にする役割を持ちます。
イベントが発火されるたびにReconcile関数が呼ばれ、調整が行われます。
Reconcile関数はRequestを引数として受け取り、RequestNamespace/Nameの鍵を持っており、リクエストに対応するオブジェクトをLookUpするのに利用されます。
上記の例では、調整リクエストに対応するMemcachedをLookUpしてます。

また、Reconcile関数は以下の戻り値を返すことが可能です。

  • return ctrl.Result{}, err:エラー
  • return ctrl.Result{Requeue: true}, nil:エラーなし
  • return ctrl.Result{}, nil:調整の停止
  • return ctrl.Result{RequeueAfter: nextRun.Sub(r.Now())}, nil:調整をX時間後に再度行なう

最後にContorollerはRBACのパーミンションを持つ必要があります。
以下のようにRBACマーカーを付与します。

//+kubebuilder:rbac:groups=cache.hirooka.dev,resources=memcacheds,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=cache.hirooka.dev,resources=memcacheds/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=cache.hirooka.dev,resources=memcacheds/finalizers,verbs=update
//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch
func (r *MemcachedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
//省略
}

以下のコマンドを叩くとconfig/rbac/role.yamlに上記のマーカーからマニフェストを自動生成してくれます。

$ make manifests

OperatorのイメージをDocker HubにPushする

作成したOperatorのイメージをDockerレジストリにPushしておく必要があります。
Makefileの以下の部分を書き換えます。

-IMG ?= controller:latest
+IMG ?= $(IMAGE_TAG_BASE):$(VERSION)

IMAGE_TAG_BASEでInitのところで指定したDockerレジストリを取得できるみたいです。
しかし、前述の通り私はちょっとinitのところでしくってしまったのでこのブログでは以下のように書き換えます。

IMG ?= hirohiroyuya/sample-controller:latest

次のコマンドでイメージをビルドしてPushします。

$ make docker-build docker-push

クラスターにデプロイする

作ったCDRとControllerをクラスターにデプロイします。
デプロイにはいくつかの方法があるようですが今回はこの中で「Run as a Deployment inside the cluster」の方法を試してみます。
具体的には以下のコマンドを実行します。

$ make deploy

この際クラスターはカレントContextで指定されるものが利用されるみたいです(このブログではminikubeにしてます)。
コマンドの実行が成功するとデフォルトでは<project-name>-systemのネームスペースでOperatorが実行されています。

$ kubectl get deployment -n operatorsdk-sample-system 
NAME                                    READY   UP-TO-DATE   AVAILABLE   AGE
operatorsdk-sample-controller-manager   1/1     1            1           16m

いい感じに動いてくれてるみたいですね。

Memcached Recourceを作成する

Operatorのデプロイまでできたので、いよいよMemcached Resourceをクラスタにデプロイしてみます。
config/samples/cache_v1alpha1_memcached.yamlを以下のように書き換えます。’

apiVersion: cache.hirooka.dev/v1alpha1
kind: Memcached
metadata:
  name: memcached-sample
spec:
  size: 3

applyしてリソースが作成されていることを確認します。

$ kubectl apply -f config/samples/cache_v1alpha1_memcached.yaml
memcached.cache.hirooka.dev/memcached-sample created


$ kubectl get deployment
NAME               READY   UP-TO-DATE   AVAILABLE   AGE
memcached-sample   3/3     3            3           91s


$  kubectl get pods
NAME                                READY   STATUS    RESTARTS   AGE
memcached-sample-6c765df685-6mzjj   1/1     Running   0          2m12s
memcached-sample-6c765df685-l9jpr   1/1     Running   0          2m12s
memcached-sample-6c765df685-t2wkw   1/1     Running   0          2m12s

いい感じで動いてくれてそうですね!!

Isito、k8sでカナリアデプロイをする

はじめに

先月の5月18日にIstioが1.10のリリースがされたみたいです。
その中でStable Revision Labelsと言う機能を使ってカナリアップデートを行なう例が乗っていて、最初言葉尻を完全に読み間違えていてアプリのカナリアデプロイが容易になると思ったのですがそうでは無く、リビジョン付きの複数のコントロールプレーンをデプロイするサポートが入ったって感じっぽいですね。
まぁ、1.10は置いておいても、k8sやIstioを使ったカナリアリデプロイってやったことなかったので、ちょっと試してみようかと思います。
基本的にはここに乗っているのに基づいてやろうと思います。
かなり古いブログですが、これ以上に新しいものをみつけることができなかったので、もしもっと良いやり方がありそうであればコメントなどで教えてくれると嬉しいです。

やっていく

環境

今回の動作環境は以下のようになってます。

$ uname -srvmpio
Linux 5.4.0-74-generic #83-Ubuntu SMP Sat May 8 02:35:39 UTC 2021 x86_64 x86_64 x86_64 GNU/Linu

$ lsb_release -a
LSB Version:    core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.2 LTS
Release:    20.04
Codename:   focal


$ minikube version
minikube version: v1.21.0
commit: 76d74191d82c47883dc7e1319ef7cebd3e00ee11


$ kubectl version  -o yaml
clientVersion:
  buildDate: "2021-03-18T01:10:43Z"
  compiler: gc
  gitCommit: 6b1d87acf3c8253c123756b9e61dac642678305f
  gitTreeState: clean
  gitVersion: v1.20.5
  goVersion: go1.15.8
  major: "1"
  minor: "20"
  platform: linux/amd64
serverVersion:
  buildDate: "2021-01-13T13:20:00Z"
  compiler: gc
  gitCommit: faecb196815e248d3ecfb03c680a4507229c2a56
  gitTreeState: clean
  gitVersion: v1.20.2
  goVersion: go1.15.5
  major: "1"
  minor: "20"
  platform: linux/amd64

$ istioctl version
client version: 1.10.1
control plane version: 1.10.1
data plane version: 1.10.1 (1 proxies)

また、このブログでは特に明記しない場合k8sのコンテキストはminikubeを使っています。

下準備

下準備として特定の文字列を返すnginxのコンテナを作成しておきます。
まずは、以下のコマンドを実行して、Dockerのコンテキストをminikubeのものに変えておきます。

$ eval $(minikube docker-env)

これでDockerクライアントはMinikubeのコンテキストで動くようになりました。  

次に以下の簡単なDokcerfileを用意します。

Dockerfile-v1

FROM nginx:1.19.6

RUN echo "Hello, Canary v1" > /usr/share/nginx/html/index.html

Dockerfile-v2

FROM nginx:1.19.6

RUN echo "Hello, Canary v2" > /usr/share/nginx/html/index.html

それぞれをビルドしておきます。

$ docker build ./ -t hello-nginx-v2 -f Dockerfile-v2
$ docker build ./ -t hello-nginx-v1 -f Dockerfile-v1

$ docker images | grep hello-nginx
hello-nginx-v2                            latest     9defac4edce6   2 minutes ago   133MB
hello-nginx-v1                            latest     abd78992aeba   8 minutes ago   133MB

最後にあとで使うのでMinikubeのIPを調べておきます。

$ minikube ip
192.168.49.2

これで下準備は完了です。

どんなことを行なうか

今回は、以下のパターンをやってみようかと思います。

Kubernetesのみでのカナリアデプロイをやってみる
・Istioを使ったカナリアリデプロイをやってみる

k8sを使ったカナリアデプロイ

k8s単体でもカナリアデプロイを行なうこと自体は可能です。 それを行なうには以下の2つの方法取るようです。

selectorを用いたカナリアデプロイ

selectorを用いてカナリアデプロイをやってみようと思います。
こいつの考え方としては、古いバージョンと新しいバージョンのPod数を変更することで、新しいVersionのPodへルーティングされる割合を調整します。
実際にやってみます。 まずは、2つのDeploymentを用意します。

$ kubectl create deployment hello-nginx --image=hello-nginx-v1 --dry-run=client -o yaml > deployment.yaml
$ echo --- >> deployment.yaml 
$ kubectl create deployment hello-nginx --image=hello-nginx-v2 --dry-run=client -o yaml >> deployment.yam

できたデプロイメントが以下のようになります。

apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: hello-nginx
  name: hello-nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hello-nginx
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: hello-nginx
    spec:
      containers:
      - image: hello-nginx-v1
        name: hello-nginx-v1
        resources: {}
status: {}
---
apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: hello-nginx
  name: hello-nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hello-nginx
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: hello-nginx
    spec:
      containers:
      - image: hello-nginx-v2
        name: hello-nginx-v2
        resources: {}
status: {}

それぞれを見分けるためnamenginx-hello-v1nginx-hello-v2のに変え環境を示すラベル(track)追加します。
また、nginx-hello-v1の方はレプリカ数も変えておきます。

apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: hello-nginx      
    track: stable               # 環境を示すラベルを追加
  name: hello-nginx-v1          # nameにVersionを追加
spec:
  replicas: 3                   # レプリカ数を3に
  selector:
    matchLabels:
      app: hello-nginx
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: hello-nginx
    spec:
      containers:
      - image: hello-nginx-v1
        name: hello-nginx-v1
        resources: {}
status: {}
---
apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: hello-nginx
    track: canary               # 環境を示すラベルを追加
  name: hello-nginx-v2          # nameにVersionを追加
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hello-nginx
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: hello-nginx
    spec:
      containers:
      - image: hello-nginx-v2
        name: hello-nginx-v2
        resources: {}
status: {}

deploymentをapplyします。

$ kubectl apply -f deployment.yaml 
deployment.apps/hello-nginx-v1 created
deployment.apps/hello-nginx-v2 created

$ kubectl get po
NAME                              READY   STATUS    RESTARTS   AGE
hello-nginx-v1-7b8665d769-grsz5   1/1     Running   0          7s
hello-nginx-v1-7b8665d769-rdwg5   1/1     Running   0          7s
hello-nginx-v1-7b8665d769-zvlsg   1/1     Running   0          7s
hello-nginx-v2-86467b54f9-ghdnm   1/1     Running   0          7s

そして、これらのデプロイメントに対してapp: hello-nginxのラベルに対してルーティングを行なうServiceを記述してやります。   

$ kubectl create service nodeport hello-nginx --tcp=8081:80 --dry-run=client -o yaml > service.yaml

できたServiceが以下の通りになります。

apiVersion: v1
kind: Service
metadata:
  creationTimestamp: null
  labels:
    app: hello-nginx
  name: hello-nginx
spec:
  ports:
  - name: 8081-80
    port: 8081
    protocol: TCP
    targetPort: 80
  selector:
    app: hello-nginx
  type: NodePort
status:
  loadBalancer: {}

applyしてNodePortのポートをチェックしておきます。

$ kubectl  apply -f service.yaml

$ kubectl get svc/hello-nginx
NAME          TYPE       CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
hello-nginx   NodePort   10.100.167.221   <none>        8081:32697/TCP   68m

これでおおよそ1/4程度のリクエストがv2にルーティングされるようになっているはずです。
ロードテストツールであるk6で以下のスクリプトを書いてチェックしてみます。

$ cat 100requests.js 
import http from 'k6/http';
import { sleep, check } from 'k6';

export let options = {
  vus: 10,
  duration: '10s',
};

export default function () {
  
  let res = http.get('http://192.168.49.2:32697');
  sleep(1);

  check(res, {
    'v1 responded': (r) => r.body == 'Hello, Canary v1\n',
    'v2 responded': (r) => r.body == 'Hello, Canary v2\n',
  });
}

ここでk6の使い方を詳細には説明しませんが、10並列で10秒ごとに計100回のリクエストを送り、そのレスポンスボディをチェックするスクリプトになっています。
k6に関しては過去にブログを書いているので、よろしければそちらもみてみてください)

$ docker run --network=host -i loadimpact/k6 run  - < 100requests.js

          /\      |‾‾| /‾‾/   /‾‾/   
     /\  /  \     |  |/  /   /  /    
    /  \/    \    |     (   /   ‾‾\  
   /          \   |  |\  \ |  (‾)  | 
  / __________ \  |__| \__\ \_____/ .io

  execution: local
     script: -
     output: -

  scenarios: (100.00%) 1 scenario, 10 max VUs, 40s max duration (incl. graceful stop):
           * default: 10 looping VUs for 10s (gracefulStop: 30s)


(省略)


     ✗ v1 responded
      ↳  80% — ✓ 80 / ✗ 20
     ✗ v2 responded
      ↳  20% — ✓ 20 / ✗ 80

(省略)

ここでは大体20%ぐらいのリクエストがV2に振られたみたいですね。
もう一度テストを実行すると70%、30%の割合になったので、おおよそ25%の割合で新しいVersionのPodにリクエストが割り振られているのがわかります。
Podが少ない状態である程度新しいバージョンの動作に確証が取れてきたらscaleコマンドでPodの数を調整します。

$ kubectl scale --replicas=2  deployment/hello-nginx-v2
deployment.apps/hello-nginx-v2 scaled
$ kubectl scale --replicas=2  deployment/hello-nginx-v1
deployment.apps/hello-nginx-v1 scaled

$ kubectl get po
NAME                              READY   STATUS    RESTARTS   AGE
hello-nginx-v1-7b8665d769-khzfp   1/1     Running   0          67m
hello-nginx-v1-7b8665d769-wjfxj   1/1     Running   0          67m
hello-nginx-v2-86467b54f9-ghdnm   1/1     Running   0          84m
hello-nginx-v2-86467b54f9-nsr99   1/1     Running   0          37s


$ docker run --network=host -i loadimpact/k6 run  - < 100requests.js

(省略)

     ✗ v1 responded
      ↳  50% — ✓ 50 / ✗ 50
     ✗ v2 responded
      ↳  50% — ✓ 50 / ✗ 50

(省略)

最終的にv1のスケールを0にしてv2を必要な数までスケールさせるとデプロイ完了です。
ロールバックしたい場合は、v2のスケールを0にして、v1のスケールを元の数まで戻せば良いです。

rolloutを用いたカナリアデプロイ

今度はk8sのrollout/pauseコマンドを用いてカナリアデプロイを行ってみようと思います。
考え方としては、selectorと似ており、Podの数を調整することで新しいVersionにルーティングされる割合を少しずつ増やしていき、カナリアデプロイを実現します。
具体的には、imageのアップデートが行われた際に実行されるrolloutを途中でpauseすることでより、比較的少ない新しいVersionのPodを起動して、少しずつデプロイして行きます。
selectorの例で用いていたDeploymentとServiceを削除して次のDeploymentを使用します。

deployment-rp.yaml

$ cat deployment-rp.yaml 
apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: hello-nginx      
  name: hello-nginx
spec:
  replicas: 4
  selector:
    matchLabels:
      app: hello-nginx
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: hello-nginx
    spec:
      containers:
      - image: hello-nginx-v1
        name: hello-nginx-v1
        resources: {}
        imagePullPolicy: IfNotPresent
status: {}

applyしてPodが起動するのを確認します。

$ kubectl apply -f deployment-rp.yaml 
deployment.apps/hello-nginx create

$ kubectl get po
NAME                           READY   STATUS    RESTARTS   AGE
hello-nginx-59849bb7d7-cgkhw   1/1     Running   0          40s
hello-nginx-59849bb7d7-lz8bf   1/1     Running   0          40s
hello-nginx-59849bb7d7-mgrrb   1/1     Running   0          40s
hello-nginx-59849bb7d7-vl7lg   1/1     Running   0          40s

次にイメージをアップデートして、すぐにpauseコマンドを実行します。

$ kubectl rollout pause deployment/hello-nginx

$ kubectl set image deployment/hello-nginx hello-nginx=hello-nginx-v2
deployment.apps/hello-nginx image updated
$ kubectl rollout pause deployment/hello-nginx
deployment.apps/hello-nginx paused

$ kubectl get po
NAME                           READY   STATUS    RESTARTS   AGE
hello-nginx-59849bb7d7-cgkhw   1/1     Running   0          2m46s
hello-nginx-59849bb7d7-lz8bf   1/1     Running   0          2m46s
hello-nginx-59849bb7d7-mgrrb   1/1     Running   0          2m46s
hello-nginx-5d8dc5f978-4nx4g   1/1     Running   0          11s
hello-nginx-5d8dc5f978-d5p8s   1/1     Running   0          11s

今回の場合新しいPodが2つ起動されていますね。
この新しいPodはイメージが新しいものが使われているのがわかります。

$ kubectl describe po hello-nginx-5d8dc5f978-4nx4g 
Name:         hello-nginx-5d8dc5f978-4nx4g
Namespace:    default
Priority:     0
Node:         minikube/192.168.49.2
Start Time:   Sun, 20 Jun 2021 15:11:33 +0900
Labels:       app=hello-nginx
              pod-template-hash=5d8dc5f978
Annotations:  <none>
Status:       Running
IP:           172.17.0.8
IPs:
  IP:           172.17.0.8
Controlled By:  ReplicaSet/hello-nginx-5d8dc5f978
Containers:
  hello-nginx:
    Container ID:   docker://0988812d8fa7b3ccd1eb91c5fe48bbfed265ec0964407b9064e9f3dbb7801bc3
    Image:          hello-nginx-v2
    Image ID:       docker://sha256:9defac4edce66768e5023868544942142630a22e35b0770a222b973083fde7da
    Port:           <none>
    Host Port:      <none>
    State:          Running
      Started:      Sun, 20 Jun 2021 15:11:34 +0900
    Ready:          True
    Restart Count:  0
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from default-token-2zfxv (ro)
Conditions:
  Type              Status
  Initialized       True 
  Ready             True 
  ContainersReady   True 
  PodScheduled      True 
Volumes:
  default-token-2zfxv:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  default-token-2zfxv
    Optional:    false
QoS Class:       BestEffort
Node-Selectors:  <none>
Tolerations:     node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
                 node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
  Type    Reason     Age   From               Message
  ----    ------     ----  ----               -------
  Normal  Scheduled  100s  default-scheduler  Successfully assigned default/hello-nginx-5d8dc5f978-4nx4g to minikube
  Normal  Pulled     100s  kubelet            Container image "hello-nginx-v2" already present on machine
  Normal  Created    100s  kubelet            Created container hello-nginx
  Normal  Started    100s  kubelet            Started container hello-nginx

もし、リリース中に問題があった場合は、rollbackコマンドを実行し、リビジョンを1つ前のものに戻します。

$ kubectl rollout resume deployment/hello-nginx
deployment.apps/hello-nginx resumed

$ kubectl rollout undo deployment/hello-nginx
deployment.apps/hello-nginx rolled back

Istioを使ったカナリアデプロイ

k8sを使ったカナリアデプロイでは限定的で以下のようなカナリアリデプロイを行なう際には課題があります。

  • 全体の1%のリクエストだけ新しいVersionのPodにルーティングしたい場合(Podを100個起動する必要がある)
  • 特定のクライテリアを満たすリクエストを新しいVersionにルーティングしたい

Istioを用いることで、上記のような、より複雑な条件でのデプロイを簡単に行なうことができるようになります。
一度、rolloutで使ったdeploymentを削除して、

deployment-istio.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: hello-nginx      
  name: hello-nginx-v1
spec:
  replicas: 3
  selector:
    matchLabels:
      app: hello-nginx
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: hello-nginx
        version: v1
    spec:
      containers:
      - image: hello-nginx-v1
        name: hello-nginx-v1
        resources: {}
        imagePullPolicy: IfNotPresent
status: {}
---
apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: hello-nginx
  name: hello-nginx-v2
spec:
  replicas: 3
  selector:
    matchLabels:
      app: hello-nginx
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: hello-nginx
        version: v2
    spec:
      containers:
      - image: hello-nginx-v2
        name: hello-nginx-v2
        resources: {}
        imagePullPolicy: IfNotPresent
status: {}

selectorのところで用いたdeploymentと少し似ていますが、Podに対して、versionラベルを追加しているのとPodの数を3つずつ、2つのDeploymentで計6個のPodを起動しています。

$ kubectl apply -f deployment-istio.yaml

$ kubectl get po
NAME                              READY   STATUS    RESTARTS   AGE
hello-nginx-v1-7b8665d769-bzgzg   1/1     Running   0          99s
hello-nginx-v1-7b8665d769-j569c   1/1     Running   0          99s
hello-nginx-v1-7b8665d769-wbthk   1/1     Running   0          99s
hello-nginx-v2-86467b54f9-d9txr   1/1     Running   0          8s
hello-nginx-v2-86467b54f9-hdsx9   1/1     Running   0          99s
hello-nginx-v2-86467b54f9-m8gmk   1/1     Running   0          8s

次に外部からアクセスを可能にするため、Gatewayリソースを作成します。

gateway.yaml

apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: hello-nginx
spec:
  selector:
    istio: ingressgateway 
  servers:
  - port:
      number: 80
      name: http
      protocol: HTTP
    hosts:
    - "*"

そして、きもとなるVertualServiceとDestinationRouleを作成します。

destination-rule.yaml

apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: hello-nginx
spec:
  host: hello-nginx
  subsets:
  - name: v1
    labels:
      version: v1
  - name: v2
    labels:
      version: v2

DestinationRouleでPodに付与したversionのラベルを指定することで、ルーティング先をsubsetとして定義しています。

virtual-service.yaml

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: hello-nginx
spec:
  hosts:
  - "*"
  gateways:
  - hello-nginx
  http:
  - route:
    - destination:
        host: hello-nginx
        subset: v1
      weight: 90
    - destination:
        host: hello-nginx
        subset: v2
      weight: 10

ここでは、Gatewayに対するどんなHostのリクエストに対してもsubsetのv1v2に対してルーティングを行なうように設定しています。
また、weightを設定し、リクエストの10%がv2へルーティングされるようにしています。

これで準備は完了です、k6からGateway経由でリクエストを送るようにするためにGatewayのport番号を取得してk6スクリプトを修正します。

$ kubectl -n istio-system get service istio-ingressgateway -o jsonpath='{.spec.ports[?(@.name=="http2")].nodePort}'
31424

取得したPortでリクエストを送るようにk6スクリプトを修正したら、テストを実行します。

$ docker run --network=host -i loadimpact/k6 run  - < 100requests.js

(省略)

     ✗ v1 responded
      ↳  90% — ✓ 90 / ✗ 10
     ✗ v2 responded
      ↳  10% — ✓ 10 / ✗ 90

(省略)

先ほどと違い、Podの数とは関係なくルーティングが行われていることが確認できました。
より小さなリクエストの数で、新しいVersionの動作確認が取れたらweightを修正して新しいVersionにのみリクエストが送られていくように変更していきます。
今回は、Podの数は固定で行いましたが、Podの数で割合を調整する必要がなくなったので、Deploymentのオートスケールの機能と合わせて利用することも可能です。

http4sのHTTP Serverを使ってみる

はじめに

FS2に依存するライブラリはいくつかありますが、http4sというHTTPサーバとクライアントを用意してくれているライブラリがあり、ちょっと興味が湧いたので触ってみようかと思います。
http4sはFS2(と、もちろんCats Effects)をベースにしているので、FunctinnalでStreamingでFunctionalというのが特徴みたいです。

やってみる

環境

$ scala --version
Scala code runner version 2.13.6 -- Copyright 2002-2021, LAMP/EPFL and Lightbend, Inc.

$ sbt --version
[info] 1.2.7
sbt script version: 1.5.2

プロジェクトの作成

まずはベースとなるプロジェクトを作成します。
 

$ sbt new sbt/scala-seed.g8
[info] welcome to sbt 1.5.2 (Oracle Corporation Java 1.8.0_292)
[info] loading global plugins from /home/yuya-hirooka/.sbt/1.0/plugins
[info] set current project to new (in build file:/tmp/sbt_adb8af4/new/)

A minimal Scala project. 

name [Scala Seed Project]: http4s-example

作成されたプロジェクトのbuild.sbtに以下の依存を追加します。

libraryDependencies += "org.http4s" %% "http4s-dsl" % "0.21.23",
libraryDependencies += "org.http4s" %% "http4s-blaze-server" % "0.21.23",
libraryDependencies += "org.http4s" %% "http4s-blaze-client" % "0.21.23",

今回はhttp4sの現在のstableバージョンである0.21.x系を利用しようと思います。

名前を受け取って挨拶を返すサーバを作成する

最初にhttp4s-blaze-serverを使って、サーバーの方を作成します。
最終的には名前をJsonで受け取って挨拶を返すサーバを作成しようと思います。

helloの文字列を返す

まずは、単純なGETリクエストに対して"hello"という文字列を返すだけのサーバを作ります。
そのためにはRouterBlazeServerBuilderを使います。
Routerを使ってルートの定義を作成します。

import cats.effect._
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.implicits.http4sKleisliResponseSyntaxOptionT
import org.http4s.server.Router

object SampleRouters {

  def helloHandler() = IO("hello")

  val helloRouter = HttpRoutes.of[IO] {
    case GET -> Root / "hello" => helloHandler().flatMap(Ok(_))
  }

  def createApp = Router("/" -> helloRouter).orNotFound

}

HttpRoutes http4s-dsl,を使ってルータを定義します。
みてすぐわかるかも知れませんが、パターンマッチングでGETと/helloのパスにマッチした場合helloHandr()を呼び出し、その戻り値であるIO("")をflatMapして取り出しOk()レスポンスで返すRouterを定義しています。
1番最後の行ではRouter objectを作成策定していますが、ここでは定義したルータのルートに当たるパスを指定することができます。

Routeの作成はできたので、次にアプリケーションのエントリーポイントとなるobjectを作ります。

import cats.effect.{ExitCode, IO, IOApp}
import org.http4s.server.blaze.BlazeServerBuilder

import scala.concurrent.ExecutionContext.Implicits.global


object AppStarter extends IOApp {

  override def run(args: List[String]): IO[ExitCode] =
    BlazeServerBuilder[IO](global)
      .bindHttp(8081, "localhost")
      .withHttpApp(SampleRouters.createApp)
      .serve
      .compile
      .drain
      .as(ExitCode.Success)

}

http4sは様々なバックエンドをサポートしているようですが(サポートはここを確認してください。
BlazeServerBuilderを使って、 もし、IOApp以外の場所でBuilderを使いたい場合は以下のimplicitを定義する必要があります。

implicit val cs: ContextShift[IO] = IO.contextShift(global)
implicit val timer: Timer[IO] = IO.timer(global)

アプリケーションを実行するし、cURLでアプリケーションにリクエストを送ってみます。

$ curl localhost:8081/greeting -v
*   Trying 127.0.0.1:8081...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8081 (#0)
> GET /greeting HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.68.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=UTF-8
< Date: Fri, 11 Jun 2021 14:20:49 GMT
< Content-Length: 5
< 
* Connection #0 to host localhost left intact
hello

うまく起動できてるみたいですね。

パスパラーメータで値を受け取る

それでは次に、パスパラメータ名前を受け取ってその名前に対して挨拶を返す用にしてみます。
新しく、Routerを作成します。

object SampleRouters {

  def helloHandler() = IO("hello")

  val helloRouter = HttpRoutes.of[IO] {
    case GET -> Root / "hello" => helloHandler().flatMap(Ok(_))
  }

  def greetingSomeone(name: String) = IO(s"Hello, $name")

  val greetingRouter = HttpRoutes.of[IO] {
    case GET -> Root / "greeting" / name => helloHandler().flatMap(Ok(_))
  }

  def createApp = Router("/" -> helloRouter, "/v1" -> greetingRouter).orNotFound

}

ここではgreetingRouterを新たに定義してます。
DSLのなかでパスの位置い変数を置くことで、パスパラメータを束縛することができます。
また、上記のようにRouter object作成時には複数のRouterを登録することができます。

cURLでリクエストを送ってみます。

$ curl localhost:8081/v1/greeting/Moheji -v
*   Trying 127.0.0.1:8081...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8081 (#0)
> GET /v1/greeting/Moheji HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.68.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Content-Type: text/plain; charset=UTF-8
< Date: Fri, 11 Jun 2021 14:46:01 GMT
< Content-Length: 13
< 
* Connection #0 to host localhost left intact
Hello, Moheji

Jsonで値を受け取る。Jsonの値を返す

それでは最後にJsonの値を扱うようなPathを追加してみようと思います。
まずですが、http4sでJsonを扱うためには以下の依存を追加する必要があります。

libraryDependencies += "org.http4s" %% "http4s-circe" % "0.21.23"
libraryDependencies += "io.circe" %% "circe-generic" % "0.13.0"

circe-genericの依存はオプショナルっぽいですが、JsonScalaのクラスとの変換を知れてくれるみたいなので追加しておきます。

それでは追加したhttp4s-circecirce-genericを使ってgreetingRouterに新たなRouteを書き加えてみます。

import io.circe.generic.auto._
import io.circe.syntax.EncoderOps
import org.http4s._
import org.http4s.circe.CirceEntityCodec._
//他のImportは省略


object SampleRouters {
  // 他の実証は諸略  

  case class Name(name: String)

  case class Greeting(hello: String)

  val greetingRouter = HttpRoutes.of[IO] {
    case req@POST -> Root / "greeting" => for {
      n <- req.as[Name]
      resp <- Created(Greeting(n.name).asJson)
    } yield resp
    case GET -> Root / "greeting" / name => greetingSomeone(name).flatMap(Ok(_))
  }

  def createApp = Router("/" -> helloRouter, "/v1" -> greetingRouter).orNotFound

}

POSTリクエストで名前を受け取るためにDSLを使います。
req@のように記述刷ることでreqにリクエストの情報をバインドできるみたいです。
リクエストを受け取るためのcase

// リクエスト用のcase class
case class Name(name: String)

// レスポンス用のcase class
case class Greeting(hello: String)

本来はこれらに対してエンコーダーデコーダーを作ってimplicitとして定義刷る必要があるようですが。 以下の2つをインポートしていればそれぞれが自動でimplicitされるみたいです。

import io.circe.generic.auto._
import org.http4s.circe.CirceEntityCodec._

それではリクエストを送ってみましょう。

$ curl localhost:8081/v1/greeting -d '{"name":"Moheji"}' -v
*   Trying 127.0.0.1:8081...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8081 (#0)
> POST /v1/greeting HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.68.0
> Accept: */*
> Content-Length: 17
> Content-Type: application/x-www-form-urlencoded
> 
* upload completely sent off: 17 out of 17 bytes
* Mark bundle as not supporting multiuse
< HTTP/1.1 201 Created
< Content-Type: application/json
< Date: Fri, 11 Jun 2021 15:51:56 GMT
< Content-Length: 18
< 
* Connection #0 to host localhost left intact
{"hello":"Moheji"}

これでちょっと触った程度ですが一応、サーバができましたね。

fs2を試す。

はじめに

ScalaでReactorみたいなリアクティブなプログラミングをするにはどうすれば良い?みたいなのをScalaに詳しい同僚に聞いてみたところ。
FS2というライブラリを教えて頂いたので試してみようかと思います。
ただ、この時期ではリアクティブのところまでは行かずにまずは基本的な使い方を確認します。

FS2とは

純粋な関数型の多形性を持つストリームプロセスをサポートするライブラリです。
I/O(netwrking, flies)のストリーム処理をリソースセーフに実行することを可能とします。
Scala 2.12、 2.13、3で利用できるみたいです。 また、FS2はCatsCats Effectを利用しているライブラリで、逆にhttp4sskunkdoobieに用いられるようです。
ちなみに、名前はFunctional Streams for ScalaでFS2もしくはFSSらしいです。

動かしてみる

環境

ソースコードの実行環境は以下の通り。

$ uname -srvmpio
Linux 5.4.0-72-generic #80-Ubuntu SMP Mon Apr 12 17:35:00 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

$ lsb_release -a
LSB Version:    core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.2 LTS
Release:    20.04
Codename:   focal

$ sbt --version
[info] 1.2.7
sbt script version: 1.5.2


$ scala --version
Scala code runner version 3.0.0 -- Copyright 2002-2021, LAMP/EPFL

プロジェクトの作成

sbtを使ってプロジェクトを作成します。
以下のコマンドを実行します。

$ sbt new sbt/scala-seed.g8
[info] welcome to sbt 1.5.2 (Oracle Corporation Java 1.8.0_292)
[info] set current project to new (in build file:/tmp/sbt_c0223d2e/new/)

A minimal Scala project. 

name [Scala Seed Project]: fs2-example

出来上がったbuild.sbtに依存をつかします。

lazy val root = (project in file("."))
  .settings(
    name := "fs2",
    libraryDependencies += scalaTest % Test,
    // 以下を追加
    libraryDependencies += "co.fs2" %% "fs2-core" % "3.0.0",
    libraryDependencies += "co.fs2" %% "fs2-io" % "3.0.0",
    libraryDependencies += "co.fs2" %% "fs2-reactive-streams" % "3.0.0"
  )

Streamの作成と副作用を持つStreamの実行

FS2ではStream[F, 0]というクラスが基本のデータクラスっぽいです。
ここでF エフェクト型と呼ばで、Oはアウトプット型と呼ばれるようです。
Streamは以下のようにして作成します。

import cats.effect.{IO, IOApp}
import fs2.{INothing, Pure, Stream}

object Fs2Example {
  
  val streamEmpty: Stream[Pure, INothing] = Stream.empty
  val streamOne: Stream[Pure, Int] = Stream.emit(1)
  val streamThree: Stream[Pure, Int] = Stream(1, 2, 3)
  val streamList: Stream[Pure, List[Int]] = Stream(List(1, 2, 3))
}

例えば、上記のstreamOneStream[Pure, Int]を持っており、アウトプット型がIntでエフェクト型がPureとなります。
Pureは実行時に副作用が存在しないことを示すようです。

StreamはtoListtoVectorをそれぞれ呼び出すことで、ListVectorに変換できるようです。
またStreamはlist-likeな以下のようなメソッドを持っているようです。

val filteredStream: Stream[Pure, Int] = Stream(1, 2, 3, 4, 5, 6).filter(_ > 0)
val foldSum: List[Int] = Stream(1, 2, 3).fold(0)(_ + _).toList
val repeat: Stream[Pure, Int] = Stream(1, 2, 3).repeat.take(9)
val collect: Stream[Pure, Int] = Stream(None, Some(0), Some(1)).collect { case Some(i)=> 1}

副作用を含んたStreamの作成も行なうことができます。

val eff: Stream[IO, Int] = Stream.eval(IO {
  println("Hello! FS2"); 1 + 1
})

IOはエフェクト型で、この型を作ること自体は副作用を起こしません。
また、Stream.evalも作成されたタイミングでは何もしません。
この副採用を含んだストリームを実行するためにはまずcompileメソッドを呼び出してIOを取り出します。
compileは更に以下のようなメソッドを持ちます。

val vector: IO[Vector[Int]] = eff.compile.toVector
private val drain: IO[Unit] = eff.compile.drain
private val value: IO[Int] = eff.compile.fold(0)(_ + _)

toVectorはすべてのアウトプットを1つのベクターに入れ込み、drainはすべての実行結果を捨て、 foldは結果を畳み込みます。
compileを実行するだけではまだ、実行はおこなわれません。
これを実行するには以下のように

import cats.effect.unsafe.implicits.global

object Main extends App {

  val foldResult: IO[Int] = eff.compile.fold(0)(_ + _)
  vector.unsafeRunSync
}

import cats.effect.unsafe.implicits.globalに関してはまだ理解が甘いですが、今回利用するIORumtimeクラスの指定しているようです。
実行結果は以下の通り。

Hello! FS2

Streamの基本操作

Streamには便利なオペレーターが用意されています。
代表的なものには++mapflatMapbracketのようなものがあります。

object MainOperator extends App {

  val result = (Stream(Some(1), Some(2)) ++ Stream(Some(3), Some(4), None))
    .flatMap(i => Stream(i, i))
    .map {
      case Some(i) => i
      case _ => -1
    }.map(_ + 1)

  result.toList.foreach(println)
}

これの実行結果は以下のようになります。

2
2
3
3
4
4
5
5
0
0

エラーハンドリング

Streamでエラーを投げたい場合はStream.raseErrorを使います。
以下のような使い方ができるようです。

//
val err = Stream.raiseError[IO](new Exception("Oops! 1"))

val err2 = Stream(1, 2, 3) ++ (throw new Exception("Oops! 2"))

val err3 = Stream.eval(IO(throw new Exception("Oops! 3")))

このハンドリングは以下のように行えます。

  try err.compile.toList.unsafeRunSync() catch {
    case e: Exception => println(e)
  }

  try err2.toList catch {
    case e: Exception => println(e)
  }

  try err3.compile.drain.unsafeRunSync() catch {
    case e: Exception => println(e)
  }
}

実行結果は以下の通り

java.lang.Exception: Oops! 1
java.lang.Exception: Oops! 2
java.lang.Exception: Oops! 3