--- title: Amazon Aurora DSQLにSpring BootでアクセスするTips tags: ["Spring Boot", "Aurora DSQL", "Java", "AWS", "PostgreSQL", "Spring Retry"] categories: ["Programming", "Java", "org", "springframework", "boot"] date: 2025-05-30T06:26:32Z updated: 2025-09-26T01:16:04.494369Z --- > [!NOTE] 2025-09-25追記 本記事執筆後[Aurora DSQL JDBC Connector](https://github.com/awslabs/aurora-dsql-jdbc-connector)がリリースされているので、こちらを使ったほうがいいかもしれません [Amazon Aurora DSQL](https://docs.aws.amazon.com/aurora-dsql/)をSpring Bootから使用してみましたので、いくつかのTipsをまとめておきます。 サンプルアプリのコードは[こちら](https://github.com/making/demo-dsql)です。 サンプルアプリの動かし方はREADMEを参照してください。 ### 依存ライブラリの追加 DSQLにアクセスするには、PostgreSQL JDBCやSpring JDBCなどの依存ライブラリに加えて、AWS SDKが必要です。これは、DSQLのパスワード(トークン)をAWS SDKから動的に取得する必要があるためです。 AWS Credentialsの管理を簡素化するために[Spring Cloud AWS](https://docs.awspring.io/spring-cloud-aws/docs/3.3.1/reference/html/index.html)を使用します。Core機能の`io.awspring.cloud:spring-cloud-aws-starter`だけで十分です。DSQLのトークンを取得するために、`software.amazon.awssdk:dsql`も追加します。 必須ではありませんが、筆者は`aws sso login`を使ってCredentialsを取得しているため、SSO対応のために`software.amazon.awssdk:sso`も追加しています。 ```xml io.awspring.cloud spring-cloud-aws-starter software.amazon.awssdk sso commons-logging commons-logging software.amazon.awssdk dsql commons-logging commons-logging ``` Spring Cloud AWSは次のBOMを使います。 ```xml io.awspring.cloud spring-cloud-aws-dependencies 3.3.1 pom import ``` この設定により、`aws` CLI用のCredentialsを使用してDSQLのパスワードを動的に取得できます。 > [!NOTE] > 開発環境ではなく、本番環境にデプロイする場合は[その他のCredentialsプロバイダー](https://docs.awspring.io/spring-cloud-aws/docs/3.3.1/reference/html/index.html#credentials)を検討してください。 ### DataSourceの設定 DSQLはコンソールで作成済みとします。記事執筆時点では、Tokyo(ap-northeast-1)ではシングルリージョンしか選択できませんでした。 今回は、ローカル環境からpublic endpointにアクセスすることを想定しています。また、adminユーザーを使用します。 ![image](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1852/6200eda3-528b-4f38-9c48-c641ba7bcae3.png) コンソールからpublic endpointを取得し、以下のように`application.properties`に設定します。 ```properties spring.datasource.url=jdbc:postgresql:///postgres?sslmode=verify-full&sslfactory=org.postgresql.ssl.DefaultJavaSSLFactory spring.datasource.username=admin # ~/.aws/config にregionが設定されていたり、AWS上で実行する場合は、以下の設定は不要です。 spring.cloud.aws.region.static=ap-northeast-1 ``` > [!NOTE] > `sslmode=verify-full`の場合、デフォルトの`sslfactory`である`org.postgresql.ssl.jdbc4.LibPQFactory`では`$HOME/.postgresql/root.crt`にサーバーのCA証明書が必要となります。
> `org.postgresql.ssl.DefaultJavaSSLFactory`を使用すると、JavaのTrustStoreが使用されます。
> `sslmode=require`であれば`sslfactory`は不要ですが、MitM攻撃のリスクが残るため、パブリックエンドポイントにアクセスする場合は`sslmode=verify-full`を使用することをお勧めします。 DSQLのトークンを取得してDataSourceに設定するため、以下のような`DataSourceConfig`を作成します。このクラスでは、DSQLのパスワードを定期的に更新するためのタスクのスケジューリングや、DSQLで楽観的排他制御エラーが発生した際に`OptimisticLockingFailureException`に変換するための`SQLExceptionTranslator`の登録も行っています。 ```java /* * Copyright (C) 2025 Toshiaki Maki * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.config; import com.zaxxer.hikari.HikariDataSource; import com.zaxxer.hikari.SQLExceptionOverride; import java.sql.SQLException; import java.time.Duration; import java.time.Instant; import java.util.function.Consumer; import java.util.function.Supplier; import javax.sql.DataSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.dao.DataAccessException; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.jdbc.support.JdbcTransactionManager; import org.springframework.jdbc.support.SQLExceptionTranslator; import org.springframework.jdbc.support.SQLStateSQLExceptionTranslator; import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler; import org.springframework.util.StringUtils; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.regions.providers.AwsRegionProvider; import software.amazon.awssdk.services.dsql.DsqlUtilities; import software.amazon.awssdk.services.dsql.model.GenerateAuthTokenRequest; @Configuration(proxyBeanMethods = false) @Profile("!testcontainers") public class DsqlDataSourceConfig { private final Logger logger = LoggerFactory.getLogger(DsqlDataSourceConfig.class); private final Duration tokenTtl = Duration.ofMinutes(60); @Bean @ConfigurationProperties("spring.datasource") DataSourceProperties dsqlDataSourceProperties() { return new DataSourceProperties(); } @Bean Supplier dsqlTokenSupplier(DataSourceProperties dsqlDataSourceProperties, AwsRegionProvider awsRegionProvider, AwsCredentialsProvider credentialsProvider) { Region region = awsRegionProvider.getRegion(); DsqlUtilities utilities = DsqlUtilities.builder() .region(region) .credentialsProvider(credentialsProvider) .build(); String username = dsqlDataSourceProperties.getUsername(); String hostname = dsqlDataSourceProperties.getUrl().split("/")[2]; return () -> { Consumer request = builder -> builder.hostname(hostname) .region(region) .expiresIn(tokenTtl); return "admin".equals(username) ? utilities.generateDbConnectAdminAuthToken(request) : utilities.generateDbConnectAuthToken(request); }; } @Bean @ConfigurationProperties("spring.datasource.hikari") HikariDataSource dsqlDataSource(DataSourceProperties dsqlDataSourceProperties, Supplier dsqlTokenSupplier) { HikariDataSource dataSource = dsqlDataSourceProperties.initializeDataSourceBuilder() .type(HikariDataSource.class) .build(); String token = dsqlTokenSupplier.get(); if (StringUtils.hasText(dataSource.getPassword())) { logger.warn("Overriding existing password for the datasource with DSQL token."); } dataSource.setPassword(token); dataSource.setExceptionOverrideClassName(DsqlExceptionOverride.class.getName()); return dataSource; } @Bean DsqlSQLExceptionTranslator dsqlSQLExceptionTranslator() { return new DsqlSQLExceptionTranslator(); } @Bean JdbcTransactionManager transactionManager(DataSource dataSource, DsqlSQLExceptionTranslator dsqlSQLExceptionTranslator) { JdbcTransactionManager jdbcTransactionManager = new JdbcTransactionManager(dataSource); jdbcTransactionManager.setExceptionTranslator(dsqlSQLExceptionTranslator); return jdbcTransactionManager; } @Bean SimpleAsyncTaskScheduler taskScheduler(SimpleAsyncTaskSchedulerBuilder builder) { return builder.build(); } @Bean InitializingBean tokenRefresher(DataSource dataSource, Supplier dsqlTokenSupplier, SimpleAsyncTaskScheduler taskScheduler) throws Exception { HikariDataSource hikariDataSource = dataSource.unwrap(HikariDataSource.class); Duration interval = tokenTtl.dividedBy(2); return () -> taskScheduler.scheduleWithFixedDelay(() -> { try { String token = dsqlTokenSupplier.get(); hikariDataSource.getHikariConfigMXBean().setPassword(token); hikariDataSource.getHikariPoolMXBean().softEvictConnections(); } catch (RuntimeException e) { logger.error("Failed to refresh DSQL token", e); } }, Instant.now().plusSeconds(interval.toSeconds()), interval); } // https://catalog.workshops.aws/aurora-dsql/en-US/04-programming-with-aurora-dsql/02-handling-concurrency-conflicts private static final String DSQL_OPTIMISTIC_CONCURRENCY_ERROR_STATE = "40001"; static class DsqlSQLExceptionTranslator implements SQLExceptionTranslator { SQLStateSQLExceptionTranslator delegate = new SQLStateSQLExceptionTranslator(); @Override public DataAccessException translate(String task, String sql, SQLException ex) { if (DSQL_OPTIMISTIC_CONCURRENCY_ERROR_STATE.equals(ex.getSQLState())) { throw new OptimisticLockingFailureException(ex.getMessage(), ex); } return delegate.translate(task, sql, ex); } } public static class DsqlExceptionOverride implements SQLExceptionOverride { @java.lang.Override public Override adjudicate(SQLException ex) { if (DSQL_OPTIMISTIC_CONCURRENCY_ERROR_STATE.equals(ex.getSQLState())) { return Override.DO_NOT_EVICT; } return Override.CONTINUE_EVICT; } } } ``` 有効期限を超えたトークンを使用してコネクションを作成しようとすると認証エラーが発生するため、常駐アプリケーションの場合は定期的にローテートする必要があります。HikariCPでは、`HikariConfigMXBean`を使用して[実行時にパスワードを変更](https://github.com/brettwooldridge/HikariCP/wiki/FAQ#q-can-i-change-the-usernamepassword-or-other-pool-properties-at-runtime)することが可能です。また、`HikariPoolMXBean`の`softEvictConnections`を使用することで、アイドル状態のコネクションを破棄し、アクティブなコネクションはプールに戻ったタイミングで破棄されます。 > [!NOTE] > Aurora DSQLのサンプルコードを見ても、トークンのローテーションについては言及されていませんでした。AWS Lambdaでの使用を想定しているためでしょうか? デフォルトの`SQLExceptionTranslator`を使用した場合、楽観的排他制御エラーが発生すると[`CannotAcquireLockException`](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/dao/CannotAcquireLockException.html)がスローされます。この例外をそのままハンドリングすることも可能ですが、`CannotAcquireLockException`は`PessimisticLockingFailureException`を継承しており、悲観的排他制御エラー(SELECT FOR UPDATEなど)を想定した例外クラスです。そのため、より適切な`OptimisticLockingFailureException`をスローするためにDSQL専用の`SQLExceptionTranslator`を作成しました。 > [!NOTE] > Spring Boot 3.5では https://github.com/spring-projects/spring-boot/pull/43511 により、`SQLExceptionTranslator`がBean登録されると自動的に`JdbcTemplate`や`HibernateJpaDialect`に設定されるようになりました。
> ただし、3.5.0時点では`JdbcTransactionManager`には自動設定されないため、`DsqlDataSourceConfig`クラス内で手動設定しています。
> 今後、Pull Requestを提出してこの設定を自動化する予定です。
### 楽観的排他制御エラーのリトライ 楽観的排他制御エラーが発生した場合は、アプリケーション側でリトライする必要があります。リトライ処理は[Spring Retry](https://github.com/spring-projects/spring-retry)を使用すると簡単に実装できます。 上記の設定により、楽観的排他制御エラーが発生した場合に`OptimisticLockingFailureException`がスローされるようになります。`OptimisticLockingFailureException`に対するリトライ設定は、`@Retryable`アノテーションを使用して行います。 ```java @Service @Transactional @Retryable(retryFor = OptimisticLockingFailureException.class, maxAttempts = 4, backoff = @Backoff(delay = 100, multiplier = 2, random = true)) public class CartService { // ... } ``` 注意すべき点は、この`OptimisticLockingFailureException`がトランザクションコミット時に発生することです。単純に`@Transactional`と`@Retryable`を組み合わせるだけでは不十分で、`@Transactional`アノテーションが付いたメソッドがネストしている場合は、外側の`@Transactional`メソッドでリトライを設定する必要があります。 READMEに記載していますが、サンプルアプリを使用して以下の手順で楽観的排他制御エラーを発生させることができます。負荷テストには[`vegeta`](https://github.com/tsenart/vegeta)コマンドを使用します。 ```bash # Create a cart if not exists curl -s "http://localhost:8080/api/v1/carts?userId=user123" | jq . # Clear the cart curl -s -X DELETE "http://localhost:8080/api/v1/carts/items?userId=user123" | jq . # Add an item to the cart curl -s -X POST "http://localhost:8080/api/v1/carts/items?userId=user123" \ --json '{ "productId": "product-001", "productName": "iPhone 15", "price": 999.99, "quantity": 1 }' | jq . ITEM_ID=$(curl -s "http://localhost:8080/api/v1/carts?userId=user123" | jq -r ".items[0].id") cat < body.json { "quantity": 3 } EOF # Run the attack echo "PATCH http://localhost:8080/api/v1/carts/items/${ITEM_ID}?userId=user123" | vegeta attack -duration=10s -rate=30 -body=body.json -header='Content-Type: application/json' | vegeta report ``` ### その他の注意点 Spring Bootとは直接関係ありませんが、アプリケーションを実装する際に気になったDSQL使用時の現在の制約をいくつか挙げておきます。 * 外部キー制約が使えない * シーケンスが使えない * extensionが使えない 主キーにはUUIDを使用するのが適しているでしょう。 既存のPostgreSQLアプリケーションをそのままDSQLに移行することは困難と思われます。 とはいえ、無料枠も充実しているため、様々な機能を試すことができます。