Testcontainers:Kubernetesオペレーターをテストする最も簡単な方法

Kubernetes Operator は、Kubernetes 自体を含む任意のアプリケーションをカスタム リソースで管理できるようにする Kubernetes のソフトウェア拡張機能です。オペレーターは、Kubernetesの制御ループメカニズムである コントローラーのルールを適用します。

Kubernetes Operator のテストは難しい傾向があります。 コンポーネントをモックしてテストできる場合でも、実際のKubernetes環境でテストするのとは違います。 しかし、テストプロセス用のKubernetes環境を設定するのは簡単ではありません。 Kubernetes の起動/停止アクションを自動化し、テストごとに適切に構成できるようにする必要があります。

ただし、 Testcontainers を使用すると、このテストプロセスを大幅に簡素化できます。 これは、統合テストで使用する特定のテクノロジを Docker コンテナで実行するのに役立つライブラリです。 これを使用すると、テストを開始する前に、Docker コンテナ内のコンテナ化されたアプリケーションインスタンスをすばやくスピンアップできます。 テストが終了すると、コンテナは停止します。

Testcontainers ライブラリは、言語やフレームワークごとに異なるクライアントで使用できます。 そのK3sモジュールは、軽量のKubernetes K3を使用して、開発者がKubernetesで実行されるすべてのもの(オペレーターを含む)の統合テストを作成するのを支援します。

この記事では、Testcontainersを使用してKubernetesオペレーターをテストする方法を学びます。 Quarkus ベースの Kubernetes オペレーターを Testcontainers の K3s モジュールを使用するように設定し、オペレーターコードが期待どおりに動作することを確認するためのテストをいくつか追加します。 このチュートリアルのコードは、この リポジトリにあります。

バナーtestcontainerskubernetesオペレーターをテストする最も簡単な方法

前提 条件

チュートリアルシナリオ:GameContainers Inc.のゲームアプリケーションの作成

このチュートリアルでは、GameContainers Inc.というゲーム開発会社で働き始めたばかりだと想像してください。 この会社には、Kubernetes上で実行され、Kubernetesリソースとして管理できる新しいゲームシステムが必要です。

幸いなことに、 KubeGame という名前のオープンソースプロジェクトは、ゲーミフィケーションのためのKubernetesオペレーターの足場を提供できます。 しかし、このプロジェクトにはテストが書かれていないことにすぐに気付いたため、実際のKubernetesクラスターでオペレーターを実行する統合テストから始めて、テストを追加することにしました。 このために、Testcontainers K3s モジュールを使用することにしました。

オペレーターと要求された状態の大まかなアーキテクチャ図を図 1に示します。

この図は、オペレーターのアーキテクチャとリクエストされた状態を示しており、左側にquarkusテストコンテキスト、右側にdockerコンポーネントが表示されています。
図 1: QuarkusベースのKubernetesオペレーターのアーキテクチャ。

次のセクションでは、要求されたアーキテクチャを実装する方法に関する実践的なチュートリアルを提供します。

KubeGame Kubernetes オペレーターの調査

テストを作成する前に、まずオペレーター コードを調べてみましょう。

まず、ソースコードをローカルマシンにクローンします。

git clone https://github.com/testcontainers-community/testcontainers-kubernetes-operator-demo

選択したエディターで testcontainers-kubernetes-operator-demo フォルダーを開きます。 IntelliJ IDEAVSCodeなど、JavaまたはQuarkusをサポートする任意のIDEを使用できます。

このアプリケーションは、 Java Operator SDK を使用する Quarkus Operator SDK を使用して開発された Operator です。

src/main/javaフォルダに移動すると、com.systemcraftsman.kubegameパッケージの下にさまざまなフォルダが表示されます。

パッケージコンテンツ
com.systemcraftsman.kubegame.customresourceオペレーターのカスタムリソース
com.systemcraftsman.kubegame.reconcilerオペレーターの調整器 (コントローラー ロジックを含む)
com.systemcraftsman.kubegame.サービスQuarkus サービス (アプリケーションスコープの Java Bean) です。 これらは、ビジネスロジックをオペレーターコントローラーから分離します。
com.systemcraftsman.kubegame.specカスタムリソースで使用される仕様クラス
com.systemcraftsman.kubegame.status(英語)カスタムリソースで使用されるステータスクラス

オペレーターの現在の状態には、2 つのカスタムリソースが必要です。

PostgreSQL データベースをデプロイし、その Kubernetes サービスを作成するゲーム カスタム リソースは、ゲームのメイン リソースです。

2 つ目は World カスタム リソースです。 このカスタムリソースのインスタンスが Kubernetes で作成されると、PostgreSQL データベースにアクセスし、world テーブルが存在しない場合は作成し、そのレコードをその名前で挿入します。

図 2 は、オペレーターがコンポーネントをどのように操作し、管理するかを示しています。

kubegameオペレーターの仕組みの図で、左側にゲームリコンサイラーとワールドリコンサイラーが表示され、ゲームxとワールドa、b、cへの制御線と、ワールドカスタムリソースyamlsとpostgresqlデータベースへの接続が示されています。
図 2: KubeGame オペレーターの動作の概要。

Testcontainers のセットアップ

Quarkusは、 Dev Servicesと呼ばれる開発者ヘルパーサービスで、すでにTestcontainersを舞台裏で使用しています。 たとえば、Kubernetes または Apache Kafka インスタンスが必要な場合、テストまたはアプリケーションを実行する前にインスタンスがスピンアップされ、テスト環境または開発環境で使用できるようになります。

ただし、QuarkusはまだTestcontainersの公式 のK3sContainer モジュールをサポートしていないため、このチュートリアルではDev Servicesを使用しません。 代わりに、Testcontainers の構成をより詳細に制御でき、公式のコンテナーを使用できるため、Testcontainers を直接使用します。

Kubernetes Client Dev Services を非アクティブ化するには、 src/main/resources/application.properties ファイルで次のプロパティを構成します。

%test.quarkus.kubernetes-client.devservices.enabled=false

次に、テストで使用する独自の Testcontainers コンテナインスタンスを作成する必要があります。

次の Testcontainers 依存関係を pom.xml ファイルに追加します。

<dependency>
     <groupId>org.testcontainers</groupId>
     <artifactId>junit-jupiter</artifactId>
     <version>${testcontainers.version}</version>
     <scope>test</scope>
</dependency>
<dependency>
     <groupId>org.testcontainers</groupId>
     <artifactId>k3s</artifactId>
     <version>${testcontainers.version}</version>
     <scope>test</scope>
</dependency>

これらの依存関係により、Testcontainers とその K3s モジュールを使用できます。

次に、com.systemcraftsman.kubegame.test パッケージの下に K3sResource.java という Java クラス ファイルを作成し、次の内容を追加します。

package com.systemcraftsman.kubegame.test;
import io.quarkus.test.common.QuarkusTestResourceLifecycleManager;
import org.testcontainers.k3s.K3sContainer;
import org.testcontainers.utility.DockerImageName;
import java.util.Collections;
import java.util.Map;
public class K3sResource implements QuarkusTestResourceLifecycleManager {
   //Initializes the K3sContainer instance.
   //It uses the Docker image "rancher/k3s:v1.24.12-k3s1"
   static K3sContainer k3sContainer = new K3sContainer(DockerImageName.parse("rancher/k3s:v1.24.12-k3s1"));
   //Start method is one of the methods in the interface QuarkusTestResourceLifecycleManager
   //This method runs when a test lifecycle is started
   //In this case, it is used for starting the container and setting the kubeConfigYaml value into a property.
   @Override
   public Map<String, String> start() {
       k3sContainer.start();
       return Collections.singletonMap("kubeConfigYaml", k3sContainer.getKubeConfigYaml());
   }
   //Stop method is one of the methods in the interface QuarkusTestResourceLifecycleManager
   //This method runs when a test lifecycle is stopped
   //In this case, it is used for stopping the container
   @Override
   public void stop() {
       k3sContainer.stop();
   }
}

コード内のコマンドからわかるように、 K3sResource クラスは、 QuarkusTestResourceLifecycleManager インターフェイスとその start() メソッドと stop() メソッドを実装することで、テストのライフサイクルを担当します。 

このクラスをテスト リソースとして使用すると、テストが停止するまですぐに使用できる K3のクラスターが得られます。 start() メソッドは、コンテナーを起動するだけでなく、K3s クラスターの Kubernetes 構成を というプロパティ値 kubeConfigYamlに割り当てます。

同じディレクトリには、以前に作成した OperatorFunctionalTest というクラスがあり、これがテスト クラスです。 作成した K3sResource クラスをこのテストで使用できるようにするには、 OperatorFunctionalTest.java クラスを開き、クラス定義の上に次の注釈を追加します。

K3sContainer
@QuarkusTestResource(K3sResource.class)
...code omitted...
public class OperatorFunctionalTest {
  ...
  ...
}

これで、テストを開始する前に K3s クラスターをスピンアップできるようになりました。

ただし、テストを実行する必要がある Quarkus コンテキストは、K3のクラスターについて認識している必要があり、そうでない場合はテストは失敗します。

Quarkus は、Dev Services で Kubernetes 設定を設定するために KubernetesConfigProducer Java Bean を使用します。 この Bean をオーバーライドすることで、設定した K3のクラスターを使用できます。

これを行うには、次の内容で com.systemcraftsman.kubegame.test パッケージに K3sConfigProducer という Java クラスを作成します。

package com.systemcraftsman.kubegame.test;
import io.fabric8.kubernetes.client.Config;
import io.quarkus.arc.Priority;
import io.quarkus.kubernetes.client.runtime.KubernetesClientBuildConfig;
import io.quarkus.kubernetes.client.runtime.KubernetesConfigProducer;
import io.quarkus.runtime.TlsConfig;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import javax.enterprise.inject.Alternative;
import javax.enterprise.inject.Produces;
import javax.inject.Singleton;
@Alternative
@Priority(1)
@Singleton
public class K3sConfigProducer extends KubernetesConfigProducer {
   //Injects the kubeConfigYaml that you've set in the K3sResource
   @ConfigProperty(name = "kubeConfigYaml")
   String kubeConfigYaml;
   //Returns the kubeConfigYaml as the config
   @Singleton
   @Produces
   public Config config(KubernetesClientBuildConfig buildConfig, TlsConfig tlsConfig) {
       return Config.fromKubeconfig(kubeConfigYaml);
   }
}

OperatorFunctionalTestクラスでテストを実行すると、Quarkusは上記のbeanクラスを使用して自身のクラスをオーバーライドし、テストを実行するために設定したKクラスターの設定3使用できるようにします。

Game リソースのテストの追加

テスト設定が機能するかどうかを確認するには、いくつかのテストを追加する必要があります。

まず、テストクラスにいくつかのテストを追加して、セットアップの動作を確認し、オペレーターをテストしましょう。

OperatorFunctionalTestクラスを開きます。すでに 3 つのメソッドが作成されていることに注意してください。 @TestInstance(TestInstance.Lifecycle.PER_CLASS)@TestMethodOrder(MethodOrderer.OrderAnnotation.class) OperatorFunctionalTest クラスの注釈により、テストのライフサイクルがクラスごとになり、テストがメソッドごとに順序付けられます。テストメソッドの @Order 注釈は、その順序を定義します。

このテスト クラスで実行される最初のテスト メソッドである testGame() メソッドに移動します。 次のコンテンツを追加します。

//Apply the oasis.yaml resource, which is a Game resource.
client.resources(Game.class).inNamespace(NAMESPACE)
       .load(getClass().getResource("/examples/oasis.yaml").getFile()).create();
//Get the Game instance for "oasis"
Game game = gameService.getGame("oasis", NAMESPACE);
//Assert the "oasis" game is not null.
Assert.assertNotNull(game);
//Postgres deployment and its being ready takes time.
//Wait for at most 60 seconds for the assertions.
await().atMost(60, TimeUnit.SECONDS).untilAsserted(() -> {
   //Get the Postgres deployment instance of the related game.
   Deployment postgresDeployment = gameService.getPostgresDeployment(game);
   //Assert the deployment object's being not null
   Assert.assertNotNull(postgresDeployment);
   //Assert if the deployment is ready
   Assert.assertEquals(Integer.valueOf(1), postgresDeployment.getStatus().getReadyReplicas());
   //Assert if the "oasis" game status is ready
   Assert.assertTrue(gameService.getGame(game.getMetadata().getName(), NAMESPACE).getStatus().isReady());
});

上記のコードに対するコメントを使用すると、メソッドの動作を調べることができます。 このメソッドは、YAMLファイルからゲームリソースを適用して、システム内に「oasis」というゲームを作成し、Kubernetes上でオアシスゲームの存在を確認します。 存在する場合は、PostgreSQL デプロイメントとそのサービスなどの依存リソースが存在し、その状態が準備完了であるかどうかを確認します。 ゲームインスタンスは依存関係の前に準備が整うことができないため、テストでは最終的にゲームインスタンスの準備状況を確認します。

次に、テストを実行して、実際の動作を確認します。 プロジェクトディレクトリで、次のコマンドを実行します。 ./mvnw test

テストが完了するまでに数分かかる場合があり、成功したテスト出力は次のようになります。

...output omitted...
2023-04-19 15:04:07,017 INFO  [org.tes.DockerClientFactory] (pool-4-thread-1) Connected to docker:
 Server Version: 20.10.21
 API Version: 1.41
 Operating System: Docker Desktop
 Total Memory: 7859 MB
2023-04-19 15:04:07,086 INFO  [Docker.3.4]] (pool-4-thread-1) Creating container for image: testcontainers/ryuk:0.3.4
...output ommited...
2023-04-19 15:04:33,390 INFO  [io.jav.ope.Operator] (main) Operator SDK 4.2.7 (commit: 5d8c567) built on Fri Feb 10 20:07:04 TRT 2023 starting...
2023-04-19 15:04:33,391 INFO  [io.jav.ope.Operator] (main) Client version: 6.3.1
2023-04-19 15:04:33,393 INFO  [io.jav.ope.pro.Controller] (Controller Starter for: gamereconciler) Starting 'gamereconciler' controller for reconciler: com.systemcraftsman.kubegame.reconciler.GameReconciler, resource: com.systemcraftsman.kubegame.customresource.Game
...output ommited...
Apr 19, 2023 3:05:26 PM io.quarkus.bootstrap.runner.Timing printStopTime
INFO: kubegame stopped in 0.045s
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  01:36 min
[INFO] Finished at: 2023-04-19T15:05:29+03:00
[INFO] ------------------------------------------------------------------------

ご覧のとおり、K3s コンテナが正常に起動し、その後、オペレータがその上で実行を開始します。 最終的に、テストが実行され、アサーションに応じてテストが成功します。 [INFO] BUILD SUCCESSメッセージは、3 つのテストすべて (ゲーム テストと残りの空のテスト) が正常に実行されたことを意味します。

手記: このターンでは、空のテスト方法 ( testWorld()testDeletion() ) をスキップしなかったため、テスト結果は次のように表示されます。 [INFO] Tests run: 3

World リソースのテストの追加

Game リソースが期待どおりに動作し、関連する依存関係が問題なく作成されることがわかったので、World リソースのテストを追加できます。

テスト・クラスの testWorld() メソッドに移動し、次の内容を追加します。

//Apply the YAML resources for the worlds "archaide", "incipio", "chthonia"
client.resources(World.class).inNamespace(NAMESPACE)
   .load(getClass().getResource("/examples/archaide.yaml").getFile()).create();
client.resources(World.class).inNamespace(NAMESPACE)
   .load(getClass().getResource("/examples/incipio.yaml").getFile()).create();
client.resources(World.class).inNamespace(NAMESPACE)
   .load(getClass().getResource("/examples/chthonia.yaml").getFile()).create();
//Get the world instances "archaide", "incipio", "chthonia"
World worldArchaide = worldService.getWorld("archaide", NAMESPACE);
World worldIncipio = worldService.getWorld("incipio", NAMESPACE);
World worldChthonia = worldService.getWorld("chthonia", NAMESPACE);
//Assert the world instances checking they are not null
Assert.assertNotNull(worldArchaide);
Assert.assertNotNull(worldIncipio);
Assert.assertNotNull(worldChthonia);
//Assert the world instances expecting their status is "Ready"
//Wait for at most 60 seconds for the assertions.
await().atMost(60, TimeUnit.SECONDS).untilAsserted(() -> {
   Assert.assertTrue(worldService.getWorld(worldArchaide.getMetadata().getName(), NAMESPACE).getStatus().isReady());
   Assert.assertTrue(worldService.getWorld(worldIncipio.getMetadata().getName(), NAMESPACE).getStatus().isReady());
   Assert.assertTrue(worldService.getWorld(worldChthonia.getMetadata().getName(), NAMESPACE).getStatus().isReady());
});
//Get the game from one of the worlds
Game game = gameService.getGame(worldArchaide.getSpec().getGame(), worldArchaide.getMetadata().getNamespace());
//Run a select query against the postgres instance for the World table
ResultSet resultSet = postgresService.executeQuery(
   gameService.getPostgresServiceName(game) + ":" + GameService.POSTGRES_DB_PORT,
   "postgres", game.getSpec().getDatabase().getUsername(), game.getSpec().getDatabase().getPassword(),
   "SELECT * FROM World WHERE game=?",
   game.getMetadata().getName());
//Iterate over the result set and assert if the records are in the database
int resultCount = 0;
while(resultSet.next()){
   resultCount++;
}
Assert.assertEquals(3, resultCount);

このテストでは、Archaide、incipio、chthonia の 3 つの異なるワールド オブジェクトを Kubernetes インスタンスのゲーム oasis に追加し、その存在を確認します。 次に、PostgreSQL データベースに対してクエリを実行し、オペレーターが oasis ゲーム用に自動的に作成します。

クエリは、データベースから world テーブルのレコードを選択します。 3 つの World リソース オブジェクトが作成されているため、レコードが 3 つあるかどうかも検証されます。

このテストの動作を確認するには、前と同じ test コマンドでテストを実行します。 ./mvnw test

数分後、出力は次のようになります。

...output omitted...
2023-04-19 15:55:02,003 WARN  [io.fab.kub.cli.dsl.int.VersionUsageUtils] (InformerWrapper [worlds.kubegame.systemcraftsman.com/v1alpha1] 142) The client is using resource type 'worlds' with unstable version 'v1alpha1'
2023-04-19 15:55:02,083 INFO  [io.jav.ope.pro.Controller] (Controller Starter for: worldreconciler) 'worldreconciler' controller started
...output omitted...
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  01:40 min
[INFO] Finished at: 2023-04-19T15:56:04+03:00
[INFO] ------------------------------------------------------------------------

手記: テスト出力に java.net.SocketException: Socket closed が表示される場合があります。 このエラーは、オペレーターが停止した後も実行し続けるポート転送メカニズムがあるために発生する可能性があります。

リソース削除のテストの追加

オペレーターのテストに成功し、ゲームとワールドオブジェクトの作成時にオペレーターがどのように管理されるかを確認したので、リソースを削除したときにオペレーターが期待どおりに動作するかどうかを見てみましょう。

リソースの削除をテストするには、 testDeletion() メソッドを実装します。 このメソッドに移動し、次のコンテンツを追加します。

//Delete the worlds "archaide", "incipio", "chthonia"
client.resources(World.class).inNamespace(NAMESPACE)
   .load(getClass().getResource("/examples/archaide.yaml").getFile()).delete();
client.resources(World.class).inNamespace(NAMESPACE)
   .load(getClass().getResource("/examples/incipio.yaml").getFile()).delete();
client.resources(World.class).inNamespace(NAMESPACE)
   .load(getClass().getResource("/examples/chthonia.yaml").getFile()).delete();
//Assert the world instances are deleted
//Wait for at most 60 seconds for the assertions.
await().atMost(60, TimeUnit.SECONDS).untilAsserted(() -> {
   Assert.assertNull(worldService.getWorld("archaide", NAMESPACE));
   Assert.assertNull(worldService.getWorld("incipio", NAMESPACE));
   Assert.assertNull(worldService.getWorld("chthonia", NAMESPACE));
});
//Delete the "oasis" game
client.resources(Game.class).inNamespace(NAMESPACE)
   .load(getClass().getResource("/examples/oasis.yaml").getFile()).delete();
//Assert the game instance, its postgres instance and the service of it is deleted
//Wait for at most 60 seconds for the assertions.
await().atMost(60, TimeUnit.SECONDS).untilAsserted(() -> {
   Assert.assertNull(gameService.getGame("oasis", NAMESPACE));
   Assert.assertNull(client.apps().deployments().inNamespace(NAMESPACE).withName("oasis-postgres").get());
   Assert.assertNull(client.services().inNamespace(NAMESPACE).withName("oasis-postgres").get());
});

このテスト方法では、最初にワールドインスタンスを削除し、それらが削除されるかどうかを確認します。 次に、ゲームインスタンス「oasis」を削除し、インスタンスとそのPostgreSQL関連の依存関係の両方が削除されたことを確認します。

次に、前と同じコマンドで 3 つのテストすべてを順番に実行します。 ./mvnw test

テスト結果は成功する必要があります。

[INFO] Results:
[INFO]
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  01:17 min
[INFO] Finished at: 2023-04-19T21:33:52+03:00
[INFO] ------------------------------------------------------------------------

万丈!Testcontainers を使用して K3s クラスターをスピンアップし、実際の Kubernetes クラスターですべてのテストを実行しました。 これで、オペレーターがカスタムリソースを作成し、その依存関係を検証し、テストの完了時にリソースインスタンスを削除する方法をテストするテストライフサイクルができました。

結論

この記事では、Testcontainers を使用して Kubernetes オペレーターをテストする方法を学びました。 QuarkusベースのKubernetesオペレーターを調査し、TestcontainersのK3sモジュールを使用するように設定しました。 また、オペレーター コードが期待どおりに動作し、テスト結果が正常に取得されたことを確認するためのテストも追加しました。 このチュートリアルの完全なソリューションは、この リポジトリの 'solution' ブランチにあります。

Testcontainers-java は、 Go や Quarkus で Operator を記述し、Java でテストしている場合でも、Kubernetes Operator を確実にテストする最も簡単な方法です。 

さらに詳しく