SkaffoldとHelmを使い環境の設定を切り替えてk8sリソースをデプロイする

はじめに

以前のブログでSkaffoldのローカルでの開発機能を試しました。もちろんSkaffoldはローカルでの開発をサポートするツールにとどまらず、テストやビルド、デプロイなどもサポートしています。
デプロイをおこない場合は環境ごとの変数をうまく切り替える必要があると思いますが、SkaffoldはHelmのサポートを行っているのでそいつを使えばうまくできそうだったので試してみようかと思います。
SkaffoldはProfilesという機能を持っておりコンテキストごとのデプロイ、テスト、ビルドを切り替えることができますが、今回はその機能は使わずにTemplated Fieldsの機能を使ってやってみたいと思います。

やってみる

Java/Springのアプリケーション作ってやってみようと思います。
ローカルのクラスターはMinikube(Docker Driver)を使って作成します。

環境

今回の実行環境は以下の通りです。

$ uname -srvmpio
Linux 5.4.0-80-generic #90-Ubuntu SMP Fri Jul 9 22:49:44 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

$ minikube version
minikube version: v1.22.0
commit: a03fbcf166e6f74ef224d4a63be4277d017bb62e

$ 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-06-16T12:53:14Z"
  compiler: gc
  gitCommit: 092fbfbf53427de67cac1e9fa54aaa09a28371d7
  gitTreeState: clean
  gitVersion: v1.21.2
  goVersion: go1.16.5
  major: "1"
  minor: "21"
  platform: linux/amd64

$ skaffold version
v1.29.0

Springアプリケーションの作成

今回はアプリケーションが環境ごとの変数の文字列を読み取ってリクエスト側に返すようなアプリケーションを作成します。
Spring Initializrで以下の設定でアプリを作成します。

f:id:yuya_hirooka:20210807143322p:plain

ダウンロードしたZipを適当なIDEなどで開いて、SkaffoldDeployApplicationを編集し以下の用にコントローラーを作成しプロパティファイルから読み込んだ値を返す用に指定しておきます。

@SpringBootApplication
@RestController
@PropertySource("classpath:application.properties")
public class SkaffoldDeployApplication {

    @Value("${skaffold.env}")
    private String env;

    @GetMapping("/envval")
    public String env() {
        return env;
    }

    public static void main(String[] args) {
        SpringApplication.run(SkaffoldDeployApplication.class, args);
    }
}

読み込むプロパティをapplication.proertiesに記述します。

skaffold.env=test

デフォルトではdevの文字列が変えるようになりますが、Spring Bootではこの値を環境変数SKAFFOLD_ENVで上書きすることができます。(変数の上書き順序に関してはこちらを確認してください’)

アプリケーションを起動して、cURLを叩いてみます。

$ mvn spring-boot:run

$ curl localhost:8080/envval
test

デフォルトの文字列であるdevが返ってきますね。

SkaffoldとHelmを初期化する

プロジェクトルートで、以下のコマンドでSkaffoldの初期化を行います。

$ skaffold init -k helm
apiVersion: skaffold/v2beta20
kind: Config
metadata:
  name: skaffold-deploy
deploy:
  kubectl:
    manifests:
    - helm

? Do you want to write this configuration to skaffold.yaml? Yes
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

この際にSkkafoldはマニフェストの位置を指定してやる必要があるため-kオプションで指定します。
このディレクトリは存在する必要なないので、とりあえず作りたい場合は適当に埋めておきます。

次にHelmの初期化を行います。
プロジェクトのルートで、以下のコマンドを実行し初期化します。

$ helm create helm
Creating helm

すると以下のようなファイルが作成されます。

$ tree helm
helm
├── Chart.yaml
├── charts
├── templates
│   ├── NOTES.txt
│   ├── _helpers.tpl
│   ├── deployment.yaml
│   ├── hpa.yaml
│   ├── ingress.yaml
│   ├── service.yaml
│   ├── serviceaccount.yaml
│   └── tests
│       └── test-connection.yaml
└── values.yaml

不要なファイルもいくつかありますが、今はは一旦そのままにして先に進みます。

Helmのマニフェストを書き換える

helm createでできあがったテンプレートを書き換えたり不要なファイルを削除したりします。

今回はServiceとDeploymentテンプレートのみで構成する簡単な環境を構築しようと思います。
ingress.yamlserviceaccount.yamltest/test-connection.yaml_helpers.tplNOTES.txtを削除します。

次に、deployment.yamlのテンプレートを以下のように書き換えます。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-app
  labels:
    app: spring-app
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: spring-app
  template:
    metadata:
      labels:
        app: spring-app
    spec:
      containers:
        - name: spring-app
          image: {{ .Values.image }}
          ports:
            - name: http
              containerPort: {{ .Values.app.port }}
              protocol: TCP
          env:
          - name: SKAFFOLD_ENV
            value: {{ .Values.app.env }}
          - name: SERVER_PORT
            value: {{ .Values.app.port }}

変数としてSKAFFOLD_ENVとPod数、イメージを変えられるように設定しています。 同じようにservice.yamlも書き換えます。

apiVersion: v1
kind: Service
metadata:
  name: spring-app
  labels:
    app: spring-app
spec:
  type: ClusterIP
  ports:
    - port: {{ .Values.service.port }}
      targetPort: {{ .Values.app.port }}
      protocol: TCP
      name: http
  selector:
    app: spring-app

ここではポートだけがDevelopmentの方の設定と同じ用になるように設定しています。
次にテンプレートに対するで、デベロップメントと用とプロダクション用の2種類のvalues.yamlを用意します。

まずは、デベロップメントようのvalues-dev.yamlです

replicaCount: 2

image: spring-app

app:
  port: 8081
  env: dev

service:
  type: NodePort
  port: 8081

Podのレプリカ数を2に設定し、環境をdevで設定しています。
imageはSkaffoldでビルドするイメージを使うようにしておきます。 また、このブログではあまり重要ではありませんが、デベロップメント環境へのデプロイということでServiceのtypeもNodePortにしています。

次に、プロダクション用のvalues-prod.yamlを用意します。

replicaCount: 4

image: spring-app

app:
  port: 8081
  env: prod

service:
  type: ClusterIP
  port: 8081

先程のデベロップメントとの違いでいえば、レプリカ数を4に変え、環境をprodで指定しています。
また、こちらも重要ではありませんが、プロダクション環境へのデプロイということでServiceのtypeはClusterIPにしています。

Skaffold側でHelmを使うように設定する

Helmの方の設定が終わったのでSkaffoldから利用する設定を記述します。
skaffold.yamlを以下のように書き換えます。

apiVersion: skaffold/v2beta20
kind: Config
metadata:
  name: skaffold-deploy
build:
  artifacts:
    - image: spring-app
      buildpacks:
        builder: gcr.io/buildpacks/builder:v1
deploy:
  helm:
    releases:
      - name: spring-app
        namespace: default
        artifactOverrides:
          image: spring-app
        chartPath: helm
        valuesFiles:
          - "{{ .VALUES_FILE }}"
portForward:
- resourceType: service
  resourceName: spring-app
  port: 8081

buildディレクティブではBuildpacksを使ってアプリのBuildを行っています。
ここでのイメージ名を先程のvalues-*.yamlで書いた値と合わせておきます。
今回はローカルでビルドしたイメージを使うためこのような構成にしていますが、本来的にはDockerりぽじとりを使うことになると思います。

次に、deployディレクティブですがHelmの設定を行っています。注目すべきはchartPathvaluesFiles部分でそれぞれHelm ChartとValuesファイルの置き場所を指します。
valuesFilesには"{{ .VALUES_FILE }}"という記述をしており、これはSkaffoldのTemplated Fieldsという機能を利用しています。 これで環境変数VALUES_FILEで指定されるvalue-*.yamlが実際のデプロイ時に使われるようになります。

最後のportForwardはk8sのPort Fowordの設定です。書いてあるとおりですが、名前がspring-appであるServiceに対して8081:8081でPort Forwordを行います。

これでプロジェクトの作成と諸々の設定は完了です。

アプリをデプロイする

それでは、アプリをデプロイしていきます。
Skaffoldにはdeployコマンドや、buildとdeployを合わせたrunコマンドなどがありますが、今回は検証のためにPort Forwordを行いたいためdevコマンドを使います。
まずは、デベロップメントを想定したデプロイです。
以下のコマンドを実行します。

$ VALUES_FILE=./helm/values-dev.yaml skaffold dev

初回起動に時間がかかりますが、起動すれば、アプリケーションのログが流れ始めます。
これでvalues-dev.yamlがテンプレートに反映されたリソースがデプロイされているはずです。
cURLでリクエストを送ったり、Podの数を確認したりしてみましょう。

$ curl localhost:9000/envval
dev

$ kubectl get deployment/spring-app 
NAME         READY   UP-TO-DATE   AVAILABLE   AGE
spring-app   2/2     2            2           45m

Podが2個起動され、cURLdevの値が返ってきているので、想定通りですね。

次にプロダクションを想定したデプロイです。

$ VALUES_FILE=./helm/values-prod.yaml skaffold dev

アプリケーションログが流れ始めたら。 先ほどと同じように確認してみます。

$ curl localhost:8081/envval
prod

$ kubectl get deployment 
NAME         READY   UP-TO-DATE   AVAILABLE   AGE
spring-app   4/4     4            4           114s

本番用の設定でデプロイされていますね。

jcmdで何ができるかをまとめる

はじめに

jcmdは起動しているJavaプロセスのGCの統計情報をとったり、JFR起動したり諸々のことを行なうのによく使われるツールかと思います。
今まで必要になったコマンドを調べて使うぐらいしかしたことなかったのですが、実際どのくらいのことができるのか、ツールの全体感を把握できてなかったのでまとめてみようかと思います。  また、その中でJVMについても薄く学べればよいかと思います。
基本的にはオラクルのドキュメントやツールのマニュアルを元に書いていますが、あくまで自分の理解をまとめているものと捉えていただけるとありがたいです。
そして、もし間違え等あればご指摘いただけると嬉しいです。

環境

今回、動作確認に用いるJDKやその動作環境は以下のとおりです。

$ java --version
openjdk 16 2021-03-16
OpenJDK Runtime Environment (build 16+36-2231)
OpenJDK 64-Bit Server VM (build 16+36-2231, mixed mode, sharing)

$ uname -srvmpio
Linux 5.4.0-80-generic #90-Ubuntu SMP Fri Jul 9 22:49:44 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

jcmd

そもそもjcmdってなんなのか?

manコマンドで出力されるマニュアルにはシンプルに以下のように書かれます。

Sends diagnostic command requests to a running Java Virtual Machine (JVM).

JVMに対して診断用のコマンドを送るためのツールだそうです。このコマンドをつかうとJVMに関する様々な情報を取得できたり、JVMに対する指示を送ったりすることができます。具体的にに何ができるかはあとでみていきます。
基本的にjcmdはJVMが起動しているマシンと同一マシンかつJVMを起動したユーザと同じもしくはユーザグループに所属している必要があるみたいです。
ただ、DiagnosticCommandMBeanインターフェースを使えば外部プロセスからも診断コマンドを送ることが可能になるみたいですが、まずは基本的な使い方をまとめたいのでこのブログでは深くはおいません。
JDKにはjcmd以外にもjstackjmapのようなコマンドも用意されていますが、基本的にはjcmd1つでなんでもできるみたいです。

基本的な使い方

jcmdコマンドは以下の構成を取ります。

jcmd pid|main-class PerfCounter.print
jcmd pid|main-class -f filename
jcmd pid|main-class command[ arguments]

使用方法は大きく3つのパターンがありますが、第一引数にJavaのpidもしくはメインクラスの名前が来る点は変わりません。 メインクラス指定を行なうと同じ名前のメインクラスを持つすべてのプロセスの情報を取得するようです。
また、pidに0を指定するとすべてのJavaプロセスに診断コマンドを送信します。 また、引数なし、もしくは-lオプション使って実行すると実行中のJavaプロセスの一覧を出力します。

$ jcmd -l
11234 com.intellij.idea.Main
11791 jdk.jcmd/sun.tools.jcmd.JCmd -l

上記はIntelliJのpidとjcmd自身のpidが出力されています。

PerfCounter.printは指定したプロセス(もしくはクラス)のパフォーマンスカウンターを出力します。

$ jcmd 11234 PerfCounter.print
11234:
java.ci.totalTime=65102836647
java.cls.loadedClasses=55377
java.cls.sharedLoadedClasses=0
java.cls.sharedUnloadedClasses=0
java.cls.unloadedClasses=178
(省略)

jcmd pid|main-class command[ arguments]のパターンでは第二引数に診断コマンドを受け取ります。
詳細は後ほどまとめますが、GC.heap_infoをコマンドとして渡すと以下のようにGCの一般的な情報が出力されます。

$ jcmd 11234 GC.heap_info
11234:
 par new generation   total 261120K, used 118880K [0x0000000080000000, 0x0000000091b50000, 0x00000000a9990000)
  eden space 232128K,  51% used [0x0000000080000000, 0x0000000087418068, 0x000000008e2b0000)
  from space 28992K,   0% used [0x000000008e2b0000, 0x000000008e2b0000, 0x000000008ff00000)
  to   space 28992K,   0% used [0x000000008ff00000, 0x000000008ff00000, 0x0000000091b50000)
 concurrent mark-sweep generation total 579960K, used 293996K [0x00000000a9990000, 0x00000000ccfee000, 0x0000000100000000)
 Metaspace       used 373150K, capacity 388822K, committed 394296K, reserved 1388544K
  class space    used 48366K, capacity 54385K, committed 56128K, reserved 1048576K

jcmd pid|main-class -f filenameでファイル名を指定して渡すとふくすうのコマンドをJavaプロセスに送信することができます。 例えば以下のようなファイルを用意します。

$ cat command.txt 
# コメント
GC.heap_info
VM.flags

このファイルを以下のようにコマンドに渡してやると、ヒープの情報とフラグの情報を取得することができます。

$ jcmd 11234 -f command.txt 
11234:
Command executed successfully
 par new generation   total 261120K, used 192941K [0x0000000080000000, 0x0000000091b50000, 0x00000000a9990000)
  eden space 232128K,  83% used [0x0000000080000000, 0x000000008bc6b410, 0x000000008e2b0000)
  from space 28992K,   0% used [0x000000008e2b0000, 0x000000008e2b0000, 0x000000008ff00000)
  to   space 28992K,   0% used [0x000000008ff00000, 0x000000008ff00000, 0x0000000091b50000)
 concurrent mark-sweep generation total 579960K, used 293996K [0x00000000a9990000, 0x00000000ccfee000, 0x0000000100000000)
 Metaspace       used 373154K, capacity 388822K, committed 394296K, reserved 1388544K
  class space    used 48366K, capacity 54385K, committed 56128K, reserved 1048576K
-XX:CICompilerCount=2 -XX:ErrorFile=/home/yuya-hirooka/java_error_in_idea_%p.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/yuya-hirooka/java_error_in_idea_.hprof -XX:InitialHeapSize=134217728 -XX:MaxHeapSize=2147483648 -XX:MaxNewSize=697892864 -XX:MaxTenuringThreshold=6 -XX:MinHeapDeltaBytes=196608 -XX:NewSize=44695552 -XX:NonNMethodCodeHeapSize=5825164 -XX:NonProfiledCodeHeapSize=122916538 -XX:OldSize=89522176 -XX:-OmitStackTraceInFastThrow -XX:ProfiledCodeHeapSize=122916538 -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:SoftRefLRUPolicyMSPerMB=50 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseConcMarkSweepGC -XX:+UseFastUnorderedTimeStamps 

コマンドの一覧

jcmdで使えるコマンドには以下のようなものがあります。
(※IntteliJのプロセスに対するリストなので、完璧なリストではないかも知れません)

コマンド名 説明 インパク
Compiler.CodeHeap_Analytics コード・ヒープ(JITコンパイルされたネイティブコードとスペースマネジメントに必要な関数のストレージ)の分析結果を出力する 低:ヒープサイズとコンテントに影響される
Compiler.codecache コード・ヒープのキャッシュのレイアウトとバインドの情報を出力
Compiler.codelist コード・ヒープのキャッシュに保存されているコンパイルされた行きているメソッド
Compiler.directives_[add remove clear | print])|コンパイラー・ディレクティブ(JITコンパイラーへの指示)を追加(add)、最後に追加された指示の削除(remove)、すべての指示の削除(clear)、出力(print)。Json形式のファイルを引数に与える|低
Compiler.queue コンパイルのキューに積まれたメソッドの出力
GC.class_histogram ヒープの使用率の統計情報を出力 高:ヒープサイズとコンテントに影響される
GC.class_stats Javaクラスのメタデータに関する統計情報を出力 高:ヒープサイズとコンテントに影響される
GC.finalizer_info ファイナライザーのキューに関する情報を取得
GC.heap_dump HPROFフォーマットのJavaのヒープダンプを取得する 高:ヒープサイズとコンテントに影響される。-allフラグが指定されない場合はフルGCがリクエストされる
GC.heap_info ヒープサイズ、利用率などのヒープに関する一般的な情報を出力する。
GC.run java.lang.System.gc()を実行
GC.run_finalization java.lang.System.runFinalization()を実行 中:コンテントによって影響される
JFR.check 記録中のJFRの記録をチェックする
JFR.configure JFRの設定を行なう
JFR.dump JFRの記録をファイルに出力する。<key>=<value>の形式でオプションを指定する。
JFR.start JFRの記録を開始する 低〜高:レコードのないようによって影響される
JFR.stop JFRの記録を停止する
JVMTI.agent_load JVMTI(JVM Tool Interface。開発ツールや監視ツールで使用されるインターフェース)のネイティブエージェントをロードする
JVMTI.data_dump JVMTIに対するデータダンプのリクエス
ManagementAgent.start リモートマネジメントエージェントをスタートする。 低:影響なし
ManagementAgent.start_local ローカルのマネジメントエージェントをスタートする 低:影響なし
ManagementAgent.status マネジメントエージェントのステータスを出力 低:影響なし
ManagementAgent.stop リモートのマネジメントエージェントを停止する 低:影響なし
Thread.print すべてのスレッドのスタックトレースを出力する 中:スレッドの数によって影響される
VM.class_hierarchy すべてのロードされているクラスのヒエラルキーをツリーで出力する 中:ロードされているクラスの数によって影響される
VM.classloader_stats すべてのクラスローダーの統計情報を出力する
VM.classloaders すべてのクラスローダーをツリーで出力する 中:クラスローダーの数によって影響される
VM.command_line VMを起動する際に実行されたコマンドラインを出力する
VM.dynlibs ロードされたダイナミクなライブラリーを出力する
VM.events VMのイベントログを出力する
VM.flags 現在利用されているVMのフラグを出力する
VM.info VMの環境とステータスを出力する
VM.log ログ、ログの設定を出力する 低:影響なし
VM.metaspace メタスペースに関する統計情報を出力する 中:ロードされているクラスの数によって影響される
VM.native_memory ネイティブメモリの使用率を出力する
VM.print_touched_methods JVMのライフタイムで一度も触れれていないメソッドを出力する。-XX:+LogTouchedMethodsを有効化する必要がある
VM.set_flag VMのオプションをセットする
VM.stringtable Stringテーブルをダンプする 中:コンテントによって影響される
VM.symboltable シンボルテーブルをダンプする 中:コンテントによって影響される
VM.system_properties システムプロパティを出力する
VM.version VMのバージョンを出力する
VM.uptime VMの起動時間を出力する

参考資料

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も兼ねてやってみようかと思います。

やってみる

環境

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のオートスケールの機能と合わせて利用することも可能です。