Toxiproxy と Testcontainers を使用した回復力のあるアプリケーションの開発

今日のアプリケーションの信頼性をどのように確保できますか? 1つの方法は、TestcontainersとToxiproxyを使用して、テストスイートにカオスエンジニアリングプラクティスをシームレスに追加することです。

Testcontainers は、テストや開発中に使用する使い捨てコンテナを提供する有名なライブラリです。 また、お気に入りのデータベース、メッセージブローカー、またはコンテナで実行されているすべてのものに対して、すぐに使用できる定義も提供します。

Toxiproxy は、Shopifyによるオープンソースプロジェクトです。 これにより、ネットワーク障害シナリオのテストとシミュレーションが可能になります。 シミュレートできるネットワークの問題の例としては、遅延、帯域幅の制限、完全な障害などがあります。

toxiproxyとtestcontainersを使用したレジリエントなアプリケーションの開発バナー

TestcontainersとToxiproxyを組み合わせることで、開発者はさまざまなネットワーク条件や障害シナリオでアプリケーションがどのように動作するかをテストできます。 このプロセスは、本番環境に到達する前に問題を防ぐのに役立ち、したがって、より信頼性と回復力のあるアプリケーションの開発が保証されます。

Spring Bootアプリケーションを構築しましょう。 初期プロジェクトをダウンロードすることから始めることができます。これは、この例に必要な依存関係を持つ Spring Boot 3 を使用する Maven プロジェクトです。 Spring Boot アプリケーションに統合テストを追加する方法の詳細については、このトピックに関する 以前の記事 を参照してください。

次に、別の Testcontainers モジュールの依存関係を追加して、Toxiproxy コンテナの抽象化を含めましょう。

<dependency>
   <groupId>org.testcontainers</groupId>
   <artifactId>toxiproxy</artifactId>
   <scope>test</scope>
</dependency>

この例では、従来のリレーショナルデータベースにアクセスするために、Spring BootアプリケーションとR2DBCを使用します。 なぜR2DBCなのか? それは、データベースの相互作用中に発生する可能性のあるエラーや例外に対処するためのユーザーフレンドリーなリアクティブAPIを提供するためです。

手記: 簡潔にするために、またデモンストレーションの目的で、再試行を処理するためのコードをテスト コードに追加しました。 実際には、このようなコードは通常、アプリケーション コードの一部である必要があります。

まず、 PostgreSQLContainerToxiproxyContainer を使用してコンテナデータベースを定義しましょう。 また、両方のコンテナを同じネットワークに配置します。

private static final Network network = Network.newNetwork();
@Container
private static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
   .withCopyFileToContainer(MountableFile.forClasspathResource("db.sql"), "/docker-entrypoint-initdb.d/")
   .withNetwork(network)
   .withNetworkAliases("postgres");
@Container
private static final ToxiproxyContainer toxiproxy = new ToxiproxyContainer("ghcr.io/shopify/toxiproxy:2.5.0")
   .withNetwork(network);

ここで db.sql コンテンツは次のとおりです。

CREATE TABLE IF NOT EXISTS pokemon(id serial primary key, name varchar(255) not null);
INSERT INTO pokemon (name) VALUES ('Bulbasaur'), ('Squirtle'), ('Charmander');

次に、ネットワーク障害を注入するために使用されるPostgreSQLデータベースにアクセスするための ToxiproxyClient とプロキシを作成しましょう。

@DynamicPropertySource
static void sqlserverProperties(DynamicPropertyRegistry registry) throws IOException {
   var toxiproxyClient = new ToxiproxyClient(toxiproxy.getHost(), toxiproxy.getControlPort());
   postgresqlProxy = toxiproxyClient.createProxy("postgresql", "0.0.0.0:8666", "postgres:5432");
   var r2dbcUrl = "r2dbc:postgresql://%s:%d/%s".formatted(toxiproxy.getHost(), toxiproxy.getMappedPort(8666), postgres.getDatabaseName());
   registry.add("spring.r2dbc.url", () -> r2dbcUrl);
   registry.add("spring.r2dbc.username", postgres::getUsername);
   registry.add("spring.r2dbc.password", postgres::getPassword);
}
  1. toxiproxyClient を使用して構築されています toxiproxy.getHost()toxiproxy.getControlPort() コンテナインスタンスから。
  2. postgresqlProxy は、ネットワーク障害の挿入に使用されるプロキシです。 ポート 8666 は ToxiproxyContainer によってすでに公開されており、PostgreSQL 接続のエントリポイントとして使用されます。
  3. R2DBC の URL は、PostgreSQL のホストとポートではなく、プロキシのホストとポートを使用して構築されます。
  4. R2DBC の構成プロパティが設定されています。

さらに、データモデルクラスが必要です。 現在のサンプルでは、これらは Pokemon エンティティと PokemonRepositoryです。 これらはテストで使用されます。

public record Pokemon(Long id, String name) {
}
public interface PokemonRepository extends R2dbcRepository<Profile, Long> {
}

最初のテストでは、通常の条件下ですべてが期待どおりに機能していることを確認します:データベースをクエリし、見つかったレコードの予想数を確認します。

@Test
void normal() {
   StepVerifier.create(this.pokemonRepository.findAll()).expectNextCount(3).verifyComplete();
}

次に、クエリの実行時に表示される接続にレイテンシを追加しましょう。

@Test
void withLatency() throws IOException {
   postgresqlProxy.toxics().latency("postgresql-latency", ToxicDirection.DOWNSTREAM, 1600).setJitter(100);
       StepVerifier.create(this.pokemonRepository.findAll()).expectNextCount(3).verifyComplete();
}
  1. postgresqlProxyを使用すると、サーバーからクライアントへの途中で 1600 ミリ秒 +/- 100 ミリ秒の遅延が発生します。
  2. クエリが実行され、その後、レコードは通常どおり返されます。

今回は、タイムアウトが設定されたテストを書いてみましょう。 データベース操作が長時間ハングしないようにします。

@Test
void withLatencyWithTimeout() throws IOException {
   postgresqlProxy.toxics().latency("postgresql-latency", ToxicDirection.DOWNSTREAM, 1600).setJitter(100);
       StepVerifier.create(this.pokemonRepository.findAll().timeout(Duration.ofMillis(50)))
           .expectError(TimeoutException.class)
           .verify();
}
  1. postgresqlProxyを使用すると、サーバーからクライアントへの途中で 1600 ミリ秒 +/- 100 ミリ秒の遅延が発生します。
  2. リアクティブコードでは 50 ミリ秒のタイムアウトが設定されており、レイテンシが長くなるため、テストによって TimeoutException が生成され、キャプチャされます。

Toxiproxyを使用した3番目のテストは、データベース操作の再試行をテストすることです。

@Test
void withLatencyWithRetries() throws IOException {
   Latency latency = postgresqlProxy.toxics().latency("postgresql-latency", ToxicDirection.DOWNSTREAM, 1600).setJitter(100);
   StepVerifier.create(this.pokemonRepository.findAll()
                   .timeout(Duration.ofSeconds(1))
                   .retryWhen(Retry.fixedDelay(2, Duration.ofSeconds(1))
                           .filter(throwable -> throwable instanceof TimeoutException)
                           .doBeforeRetry(retrySignal -> logger.info(retrySignal.copy().toString()))))
           .expectSubscription()
           .expectNoEvent(Duration.ofSeconds(4))
           .then(() -> {
               try {
                   latency.remove();
               } catch (IOException e) {
                   throw new RuntimeException(e);
               }
           })
           .expectNextCount(3)
           .expectComplete()
           .verify();
}
  1. postgresqlProxyを使用すると、サーバーからクライアントへの途中で 1600 ミリ秒 +/- 100 ミリ秒の遅延が発生します。
  2. このリアクティブ コードでは、 1秒のタイムアウトが構成されています。 また、最大試行回数が 2 回の再試行で、遅延が 1TimeoutException 秒のみの再試行が設定されています。
  3. 約 4 秒間、イベントは発生しないと予想されます。
  4. 最後に、レイテンシーが解消されるため、レコードが取得されます。

Toxiproxy Javaクライアントのおかげで、100ミリ秒のジッターで1600ミリ秒の遅延を注入できます。つまり、 1500 ミリ秒から 1700 ミリ秒の間で実行できます。 また、方向は Downstream に設定されているため、遅延はクライアントに対する方向サーバーにのみ影響します。

最後に、サーバーとクライアント間の帯域幅をカットする別の有毒物質を使用しましょう。

@Test
void withConnectionDown() throws IOException {
   postgresqlProxy.toxics().bandwidth("postgres-cut-connection-downstream", ToxicDirection.DOWNSTREAM, 0);
   postgresqlProxy.toxics().bandwidth("postgres-cut-connection-upstream", ToxicDirection.UPSTREAM, 0);
   StepVerifier.create(this.pokemonRepository.findAll().timeout(Duration.ofSeconds(5)))
       .verifyErrorSatisfies(throwable -> assertThat(throwable).isInstanceOf(TimeoutException.class));
   postgresqlProxy.toxics().get("postgres-cut-connection-downstream").remove();
   postgresqlProxy.toxics().get("postgres-cut-connection-upstream").remove();
   StepVerifier.create(this.pokemonRepository.findAll()).expectNextCount(3).verifyComplete();
}
  1. ダウンストリーム帯域幅は、サーバーからクライアントまで完全にカットされます
  2. アップストリーム帯域幅は、クライアントからサーバーまで完全にカットされます
  3. テストのデータベース操作は、 5秒のタイムアウトで構成されています。 ネットワーク接続が事実上存在しないため、 TimeoutException が生成されます。
  4. 最初に作成された両方の毒物が除去されるため、接続が復元されます
  5. データベースからレコードを正常に取得できます。

ご覧のとおり、テストは合格しましたが、少し時間がかかり、約 5秒かかりました。

これは、Toxiproxyを使用して、ネットワーク遅延、スループットを操作したり、接続を完全に切断したりするのにどれほど簡単かを示しています。 この柔軟性をTestcontainersベースのセットアップと組み合わせることで、構成が簡素化され、テストで導入するネットワーク障害の種類と種類を正確に制御できます。

結論

この記事では、カオスエンジニアリングのプラクティスを追加し、プログラムでネットワーク障害をテストに注入する方法について説明しました。 TestcontainersとToxiproxyの組み合わせには多くの相乗効果があります:Testcontainersを使用すると、サンプルアプリケーションで使用したPostgreSQLデータベースのような実際の依存関係で単体テストを実行できます。 Toxiproxyは、真のネットワーク障害の影響を実装します。

最新のソフトウェア開発では、これらのネットワーク影響を受けるシナリオをテストすることは、何かが失敗してもアプリケーションが適切に動作していることを確認するために不可欠です。 また、Testcontainersベースのテストでは、プロセスに個別の複雑なセットアップや手動で管理されたテスト環境は必要ありません。

さらに詳しく