Kotlin Spring boot에서 AWS Secret Manager 사용하기

2024. 3. 31. 23:59Java/Spring

AWS Secrets Manager는 데이터베이스, API 키, 토큰 등 secret 정보를 안전하게 저장하고 교체 및 관리할 수 있게 해주는 AWS 서비스입니다. 코드 상에 보안 유지가 필요한 정보를 노출하여 공개 원격저장소에 올라가게 된다면? 더 나아가 서비스 코드일 경우는 그 영향은 치명적일 것입니다. 민감한 정보들을 관리하는 다양한 솔루션들이 있습니다. 그 중에 AWS Secrets Manager을 소개합니다. 본 글은 AWS RDS를 사용하는 것을 전제로 합니다.

AWS에서 세팅하기

AWS 서비스 중 Secrets Manager를 이동합니다.

목록 우측 상단에 버튼을 클릭하여 신규로 생성합니다. Secret Type은 RDS를 선택하고 username과 secret name을 동일하게 /dev/practice/mysql로 하여 만들었습니다.

encryption key는 AWS KMS로 미리 만들어둔 aws/secretsmanager를 사용하였습니다.

Secret 생성 후에 Secret Value를 보고 수정할 수 있습니다. 다음 secret 에서 username, password, engine, host, port, dbname 관련 secret value를 관리하고 있는 것을 확인할할 수 있습니다.

Spring boot에 적용하기

spring boot에서 secret manager를 사용하고자 build.gradle.kts에 다음 dependency를 추가하여 사용할 수 있습니다.

// aws secret manager 관련
implementation("software.amazon.awssdk:secretsmanager")

// (선택) bootstrap을 사용한다면 추가
implementation("org.springframework.cloud:spring-cloud-starter-bootstrap:4.1.2")

// json 관련
implementation("com.google.code.gson:gson:2.10.1")

// (선택) aws secret manager에서 제공하는 jdbc 사용한다면 추가
implementation("com.amazonaws.secretsmanager:aws-secretsmanager-jdbc:1.0.8")

// (선택) app 자체적으로 mysql jdbc 관리 등 목적으로 사용한다면 추가
implementation("com.mysql:mysql-connector-j:8.3.0")

1) 첫번째 방식으로 AWS secrets manager에서 secret value를 가져올 수 있습니다. 이 때 필요한 dependency입니다

// aws secret manager 관련
implementation("software.amazon.awssdk:secretsmanager")

implementation("org.springframework.cloud:spring-cloud-starter-bootstrap:4.1.2")

implementation("com.amazonaws.secretsmanager:aws-secretsmanager-jdbc:1.0.8")
# application-dev.yml
spring:
  datasource:
    url: jdbc-secretsmanager:mysql://{db-url}
    username: dev/practice/mysql
    driver-class-name: com.amazonaws.secretsmanager.sql.AWSSecretsManagerMySQLDriver
...

# application.yml
cloud:
  aws:
    region:
      static: ap-northeast-2
...

AWS Secrets Manager에서 MySQL 외 다른 데이터베이스도 지원하고 있습니다. AWS Secrets Manager 공식문서

 

AWS Secrets Manager 보안 암호의 자격 증명을 사용하여 SQL 데이터베이스에 연결 - AWS Secrets Manager

기계 번역으로 제공되는 번역입니다. 제공된 번역과 원본 영어의 내용이 상충하는 경우에는 영어 버전이 우선합니다. AWS Secrets Manager 보안 암호의 자격 증명을 사용하여 SQL 데이터베이스에 연결

docs.aws.amazon.com

2) 두번째 방식은 app 레벨에서 Aws Secrets Manager secret 정보를 가져오는 부분을 직접 만들어 커스텀하게 사용할 수 있습니다. 이번 글에서는 직접 구현해서 secret 정보를 사용할 예정입니다. 이 떄 dependency입니다.

// aws secret manager 관련
implementation("software.amazon.awssdk:secretsmanager")

// json 관련
implementation("com.google.code.gson:gson:2.10.1")

// mysql jdbc 관련
implementation("com.mysql:mysql-connector-j:8.3.0")

이번 포스트에서는 직접 Secret Value를 들고오는 로직을 구현해서 datasource를 만드는 예제를 진행할 예정입니다.

로직 직접 구현해서 Secret 정보 사용하기

현재 팀에서 프로젝트 진행 시 local과 dev, stage, prod(product) 크게 2가지로 나눠 database config를 구성하고 있습니다. 그 이유는 보안중요성이 다르기 때문입니다. 즉, aws secrets manager에는 dev, stage, prod(product)에 대응하는 db secret 정보를 관리하고 있습니다.

모든 profile(local, dev, stage, prod)이 공통으로 사용하는 application 정보는 application.yml에서 관리하고 있습니다.  datasource 연결 시 mysql driver를 사용하기 때문에 application.yml에 명시해서 사용하고 있습니다. 그리고 현재 AWS 서비스를 ap-northeast-2(Seoul) region에서 사용하고 있기 때문에 region을 명시합니다.

# application.yml
spring:
    datasource:
        hikari:
            connection-init-sql: SELECT 1
            driver-class-name: com.mysql.cj.jdbc.Driver
    ...

cloud:
  aws:
    region:
      static: ap-northeast-2

로컬환경(즉,profile이 local)에서 개발 시 유연하게 대처하기 위해 application-local.yml에 직접 db url, username, password를 명시해서 사용합니다. 

# application-local.yml
spring:
  datasource:
    hikari:
      local:
        jdbc-url: jdbc:mysql://{my-host}:{my-port}/{my-dbname}?useSSL=false&rewriteBatchedStatements=true&profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=999999
        username: {my-username}
        password: {my-password}
  ...

 

이외 profile(dev, stage, prod)에서 각 profile에 대응하는 application-xxx.yml에, profile에 대응하는 AWS Secrets Manager에 저장한 dbSecretKey를 명시합니다.

# application-dev.yml
cloud:
  aws:
    secretsManager:
      dbSecretKey: dev/practice/mysql

본 글 예제는 profile dev 기준으로, 앞서 만든 AWS RDS secret 정보를 AWS Secrets Manager에서 불러와서 datasource를 만들어 사용하고자 합니다.

Aws Secret 컴포넌트에서 application.yml에 명시한 region에 있는 aws secrets manager client를 관리합니다. 그리고 secret client를 통해 secretName에 대응하는 secret 정보를 찾아 secret value를 가져오는 역할을 수행합니다. 이 때 발생하는 exception은 aws secrets manager 관련 exception으로 specific한 exception을 사용했습니다.

@Component
class AwsSecret : LoggerCreator() {

    @Value("\${cloud.aws.region.static}")
    private lateinit var region: String

    @Throws(AWSSecretsManagerException::class)
    fun secretManagerClient(): SecretsManagerClient =
        SecretsManagerClient.builder()
            .region(Region.of(region))
            .credentialsProvider(DefaultCredentialsProvider.create())
            .build()

    @Throws(AWSSecretsManagerException::class)
    fun secretValue(secretName: String): String =
        secretManagerClient().getSecretValue(
            GetSecretValueRequest.builder()
                .secretId(secretName)
                .build()
        ).secretString()
}

AwsAdapter 컴포넌트에서는 secret Name에 대응하는 secret value을 AwsSecret 컴포넌트를 통해 가져옵니다. 이 때 secret Value는 Json text 형태로, AwsAdapter 컴포넌트에서 gson 을 통해 dto 객체로 변환합니다. 그리고 AWS Secrets Manager에서 secret value을 가져오면서 발생한 exception을 처리합니다.

@Component
class AwsAdapter(
    private val awsSecret: AwsSecret
) : LoggerCreator() {
    fun getDbSecretValue(secretName: String): DbSecretValueDto? =
        try {
            GsonUtil.fromJson(awsSecret.secretValue(secretName), DbSecretValueDto::class.java)
        } catch (e: Exception) {
            log.error(FAIL_TO_GET_DB_SECERT_VALUE, e)
            null
        }
}

AwsAdapter 컴포넌트에서 변환하는 DbSecretValueDto입니다. gson util에서 singleton Gson 객체를 이용하여 gson 에서 지원하는 fromJson을 사용할 수 있도록 하였습니다.

data class DbSecretValueDto(
    val username: String?,
    val password: String?,
    val engine: String?,
    val host: String?,
    val port: String?,
    val dbname: String?
)
object GsonUtil {
    private val gson = GsonBuilder()
        .disableHtmlEscaping()
        .setPrettyPrinting()
        .serializeNulls()
        .create()

    fun <T> fromJson(json: String, classOfT: Class<T>): T = gson.fromJson(json, classOfT)
}

 

 Database config에서 local 외 profile일 경우 (dev, stage, prod) AwsAdapter을 통해 DB Secret Value 정보 DbSecretValueDto를 활용하여 datasource를 만듭니다.

@Configuration
class DatabaseConfig (
    private val awsAdapter: AwsAdapter
): LoggerCreator() {
    @Value("\${cloud.aws.secretsManager.dbSecretKey}")
    private lateinit var secretName: String

    /**
     * local 외 DataSource bean 생성 함수
     */
    @Primary
    @Bean(name = ["defaultDataSource"])
    @Profile("dev", "stg", "prd")
    fun defaultDataSource(hikariProperties: HikariProperties): DataSource? = try {
        // DB 정보 획득
        val dbSecret = secretName.let {
            awsAdapter.getDbSecretValue(it)
        } ?: throw Exception(FAIL_TO_GET_VALUE_FROM_APPLICATION)

        // JDBC URL 생성
        val jdbcUrl = buildString {
            append("jdbc:mysql://")
            append("${dbSecret.host}:${dbSecret.port}/${dbSecret.dbname}")
            append("?allowUrlInLocalInfile=true&allowLoadLocalInfile=true&rewriteBatchedStatements=true")
        }

        // DataSource 생성
        hikariDataSource(HikariConfig().apply {
            this.jdbcUrl = jdbcUrl
            this.username = dbSecret.username
            this.password = dbSecret.password
        })
    } catch (var7: Exception) {
        log.error(ERROR_DATA_CONFIG, var7)
        var7.printStackTrace()
        null
    }

     /**
     * local DataSource bean 생성 함수
     */
    @Primary
    @Bean(name = ["defaultDataSource"])
    @Profile("local")
    fun defaultDataSourceLocal(hikariProperties: HikariProperties): DataSource = hikariDataSource(HikariConfig().apply {
        jdbcUrl = hikariProperties.local!!.jdbcUrl
        username = hikariProperties.local.username
        password = hikariProperties.local.password
    })

    private fun hikariDataSource(hikariConfig: HikariConfig) = HikariDataSource(hikariConfig).also { it.validate() }

}

추가로 Spring boot프로젝트에서 Jpa를 사용한다면 transaction 관련 config를 추가해서 사용해주면 됩니다. 다음 config는 database config에서 설정된 dataSource bean을 활용합니다. 

@EnableJpaRepositories(
    basePackages = ["com.study.my.*.domain"],
    entityManagerFactoryRef = "defaultEntityManagerFactory",
    transactionManagerRef = "defaultTransactionManager"
)
@Configuration
@EnableTransactionManagement
class TransactionConfig(
    @Qualifier("defaultDataSource") private val dataSource: DataSource?,
    private val appConfig: AppConfig,
    private val jpaProperties: JpaProperties,
    private val hibernateProperties: HibernateProperties
) : LoggerCreator() {

    @Value("\${my-project.config.jpa.default-base-packages:com.study.my.*.domain}")
    private lateinit var BASE_PACKAGES: String

    @Value("\${my-project.config.jpa.default-packages-to-scan:com.study.my.*.domain.*.entity}")
    private lateinit var PACKAGES_TO_SCAN: String

    @Primary
    @Bean(name = ["defaultEntityManagerFactory"])
    fun defaultEntityManagerFactory(
        builder: EntityManagerFactoryBuilder
    ): LocalContainerEntityManagerFactoryBean = hibernateProperties.determineHibernateProperties(
        jpaProperties.properties, HibernateSettings()
    ).let {
        builder
            .dataSource(dataSource)
            .packages(*arrayOf(PACKAGES_TO_SCAN))
            .persistenceUnit("defaultEntityManager")
            .properties(it)
            .build()
    }

    @Primary
    @Bean(name = ["defaultTransactionManager"])
    fun defaultTransactionManager(@Qualifier("defaultEntityManagerFactory") entityManagerFactory: EntityManagerFactory): PlatformTransactionManager =
            JpaTransactionManager(entityManagerFactory)

}