본문 바로가기
MegazoneCloud
← 전체 글
AWS

EKS 환경에서 DB 비밀번호 제거하기: Kubernetes Secret에서 AWS Secrets Manager + IRSA로의 전환기

초기 EKS 클러스터에 두 개의 마이크로서비스를 배포할 때, 빠른 실습을 위해 데이터베이스 비밀번호를 애플리케이션 설정에 직접 넣어 관리하곤 합니다. 단기 랩(Lab) 환경에서는 허용될 수 있지만, 프로덕션 환경에서는 심각한 보안 결함이 됩니다.

본 포스팅에서는 기존 방식의 문제점을 짚어보고, AWS Secrets Manager, KMS, Terraform, 그리고 IRSA(IAM Roles for Service Accounts)를 활용해 파드(Pod) 내부 메모리에만 비밀번호를 안전하게 로드하는 아키텍처 구축 과정을 다룹니다.

1. 핵심 개념: AWS Secrets Manager vs AWS KMS

본격적인 구축에 앞서 보안 인프라를 구성하는 두 핵심 서비스의 차이를 이해해야 합니다.

  • AWS Secrets Manager: 데이터베이스 자격 증명, API 키 등 민감한 정보를 안전하게 저장, 검색, 교체(Rotation)하는 완전 관리형 서비스입니다. Kubernetes Secret을 대체하여 외부 단일 진실 공급원(Single Source of Truth) 역할을 수행합니다.
  • AWS KMS (Key Management Service): 데이터 암호화/복호화에 사용되는 암호화 키를 생성하고 관리합니다. Secrets Manager에 저장된 데이터의 유휴(At-rest) 암호화 인프라를 담당합니다.
구분AWS Secrets ManagerAWS KMS
주요 역할비밀값의 저장, 버전 관리, 자동 교체(Rotation)암호화 키 관리 및 암호화/복호화 연산
저장 대상자격 증명 데이터 (문자열, JSON 등)암호화 마스터 키 (KMS Key)

두 서비스는 상호 보완적으로 동작합니다. Secrets Manager가 비밀값을 보관하고 라이프사이클을 관리하면, KMS는 해당 값을 안전하게 암호화하는 열쇠를 제공합니다.

2. 기존 배포 방식과 프로덕션에서의 위험성

기존 구성

Spring Boot 기반의 두 마이크로서비스는 ConfigMap에서 JDBC URL과 유저명을 가져오고, Kubernetes Secret(db)에서 비밀번호를 주입받았습니다.

YAML

envFrom:
  - configMapRef:
      name: app # URL 및 유저명 포함
env:
  - name: SPRING_DATASOURCE_PASSWORD
    valueFrom: 
      secretKeyRef:
        name: db # etcd에 저장되는 Secret 객체
        key: password 

이 구조에서는 Terraform 변수로 비밀번호를 입력받아 RDS를 프로비저닝하고, 운영자가 CLI를 통해 동일한 값을 클러스터에 주입했습니다.

Bash

# 셸 히스토리와 프로세스 목록에 평문 비밀번호 노출
kubectl create secret generic db -n csr-lab \
  --from-literal=password='<db_password>'

프로덕션에서 발생한 문제점

  1. 평문 노출 경로의 다변화: Terraform의 .tfstate 파일에 비밀번호가 평문 기록됩니다. 또한 로컬 .tfvars 파일 및 터미널 셸 기록(History)에 자격 증명이 잔존합니다.
  2. Kubernetes etcd 노출: Kubernetes Secret은 기본적으로 Base64 인코딩일 뿐 암호화되지 않습니다. 네임스페이스에 Secret 읽기 권한이 있거나, kube-apiserver/etcd 설정이 잘못 노출되면 데이터베이스 전체 권한이 탈취됩니다.
  3. 수동 교체(Rotation)의 한계: 비밀번호를 변경하려면 Terraform 변수 수정 → 배포 → Kubernetes Secret 재생성 → Pod 재시작 과정을 수동으로 진행해야 하며, 감사 추적(Audit Log)이 불가능합니다.

3. 개선된 아키텍처: Secrets Manager 기반 자동화

개선된 방식에서는 클러스터 설정에 비밀번호를 직접 두지 않고 Secrets Manager의 ARN(포인터)만 남깁니다.

  1. RDS가 마스터 패스워드를 자체 생성하고 Secrets Manager에 기록합니다.
  2. 클러스터 ConfigMap은 비밀번호 대신 시크릿의 ARN(DB_SECRET_ID)을 가집니다.
  3. Pod는 기동 시 IRSA를 통해 IAM 역할을 획득하고, GetSecretValue를 호출하여 비밀번호를 메모리 내부로만 로드합니다.

4. Terraform을 통한 인프라 프로비저닝

RDS 인스턴스: manage_master_user_password 활성화

Terraform 코드에서 password 인수를 완전히 제거하고 RDS 자체 관리를 활성화합니다.

Terraform

resource "aws_db_instance" "app" {
  identifier           = "csr-lab-mysql"
  engine               = "mysql"
  engine_version      = "8.0"
  instance_class       = "db.t3.micro"
  username             = var.db_username

  # RDS가 비밀번호를 생성하고 Secrets Manager에 직접 기록
  # 비밀번호가 Terraform state나 코드에 기록되지 않음
  manage_master_user_password = true

  vpc_security_group_ids = [aws_security_group.rds.id]
  skip_final_snapshot    = true
}

output "db_master_user_secret_arn" {
  value = aws_db_instance.app.master_user_secret[0].secret_arn
}

생성된 시크릿은 다음과 같은 JSON 형식으로 Secrets Manager에 KMS 암호화되어 보관됩니다.

JSON

{
  "username": "labadmin",
  "password": "...", 
  "engine": "mysql",
  "host": "csr-lab-mysql.xxx.eu-west-1.rds.amazonaws.com", 
  "port": 3306, 
  "dbInstanceIdentifier": "csr-lab-mysql"
}

IAM OIDC 및 IRSA 역할 설정

오직 클러스터의 특정 ServiceAccount만 해당 시크릿을 읽을 수 있도록 최소 권한 원칙을 적용합니다.

Terraform

# ServiceAccount가 IAM 역할을 수임할 수 있도록 Trust Policy 설정
data "aws_iam_policy_document" "db_secret_assume_role" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    effect  = "Allow"

    condition {
      test     = "StringEquals"
      variable = "${local.eks_oidc_host}:sub"
      values = [
        "system:serviceaccount:csr-lab:productservice",
        "system:serviceaccount:csr-lab:couponservice"
      ]
    }

    principals {
      identifiers = [aws_iam_openid_connect_provider.eks.arn]
      type        = "Federated"
    }
  }
}

resource "aws_iam_role" "db_secret" {
  name               = "csr-lab-db-secret"
  assume_role_policy = data.aws_iam_policy_document.db_secret_assume_role.json
}

# 시크릿 읽기 권한 정책 바인딩
resource "aws_iam_role_policy" "db_secret_access" {
  name = "db-secret-access"
  role = aws_iam_role.db_secret.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = [
          "secretsmanager:GetSecretValue",
          "secretsmanager:DescribeSecret"
        ]
        Resource = aws_db_instance.app.master_user_secret[0].secret_arn
      }
    ]
  })
}

5. Kubernetes 매니페스트 및 애플리케이션 연동

ConfigMap 및 ServiceAccount 설정

ConfigMap에는 비밀번호 대신 Secret ARN을 주입하고, 각 ServiceAccount에 IAM Role ARN을 어노테이션으로 매핑합니다.

YAML

apiVersion: v1
kind: ConfigMap 
metadata:
  name: app
  namespace: csr-lab
data:
  SPRING_DATASOURCE_URL: jdbc:mysql://csr-lab-mysql.eu-west-1.rds.amazonaws.com:3306/servicedb
  SPRING_DATASOURCE_USERNAME: labadmin 
  # terraform output -raw db_master_user_secret_arn 출력값 입력
  DB_SECRET_ID: "arn:aws:secretsmanager:eu-west-1:123456789012:secret:rds-db-..." 
  ARN_REGION: eu-west-1
  COUPONSERVICE_URL: http://couponservice:9091/couponapi/coupons
---
apiVersion: v1
kind: ServiceAccount 
metadata:
  name: productservice
  namespace: csr-lab 
  annotations: 
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/csr-lab-db-secret
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: productservice
  namespace: csr-lab
spec:
  template:
    spec:
      serviceAccountName: productservice
      containers: 
        - name: productservice 
          image: productservice:latest
          envFrom:
            - configMapRef:
                name: app # DB_SECRET_ID 주입 (비밀번호 없음)

Spring Boot 애플리케이션 구동 로직

데이터소스가 생성되기 전 실행되는 시작 훅(Startup Hook / ApplicationContextInitializer)을 구현합니다. Pod에 마운트된 Web Identity 토큰을 AWS SDK가 자동으로 인식하므로 애플리케이션 코드에 AWS 키를 보관할 필요가 없습니다.

Java

public class SecretsManagerEnvironmentPostProcessor implements EnvironmentPostProcessor {

    @Override
    public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
        String secretId = environment.getProperty("DB_SECRET_ID");
        if (secretId == null || secretId.isBlank()) {
            // 로컬 개발 환경: application.yml의 빈 비밀번호 유지
            return; 
        }

        // Pod에 마운트된 토큰을 이용해 AWS Secrets Manager 호출
        SecretsManagerClient client = SecretsManagerClient.builder()
                .region(Region.of(environment.getProperty("ARN_REGION", "eu-west-1")))
                .build();

        String secretString = client.getSecretValue(r -> r.secretId(secretId)).secretString();
        
        try {
            ObjectMapper mapper = new ObjectMapper();
            String password = mapper.readTree(secretString).get("password").asText();

            // OS 환경변수나 Kubernetes Secret이 아닌, JVM 런타임 메모리에만 등록
            environment.getPropertySources().addFirst(
                    new MapPropertySource("secrets-manager-db",
                            Map.of("spring.datasource.password", password)));
        } catch (JsonProcessingException e) {
            throw new IllegalStateException("Failed to parse DB secret JSON", e);
        }
    }
}

6. 심화: Java Client-Side Caching 라이브러리 활용

단순 기동 시점 로드 외에, 런타임에 Secrets Manager를 빈번하게 조회하거나 커넥션 풀 갱신 시 비밀번호를 다시 확인해야 하는 시나리오라면 클라이언트 측 캐싱 라이브러리 도입을 적극 고려해야 합니다.

왜 캐싱 라이브러리가 필요한가?

  • 비용 절감: Secrets Manager API(GetSecretValue) 호출 시 비용이 발생합니다. 캐싱을 통해 불필요한 API 요청을 차단할 수 있습니다.
  • 지연 시간(Latency) 단축: 매번 AWS 엔드포인트를 호출하는 네트워크 왕복 비용을 없애고 로컬 메모리에서 즉시 값을 가져옵니다.
  • 캐시 기본 동작: LRU(Least Recently Used) 교체 정책을 사용하며, 기본적으로 1시간마다 시크릿을 백그라운드에서 자동 갱신합니다.

1) 의존성 추가 (Maven)

XML

<dependency>
    <groupId>com.amazonaws.secretsmanager</groupId>
    <artifactId>aws-secretsmanager-caching-java</artifactId>
    <version>1.0.2</version>
</dependency>

필요 IAM 권한: 캐싱 라이브러리는 시크릿의 최신 버전 메타데이터를 비교하기 위해 secretsmanager:GetSecretValue 외에 secretsmanager:DescribeSecret 권한을 함께 요구합니다. 위 Terraform IAM 정책에 두 권한이 모두 포함되어 있는지 확인하세요.

2) SecretCache 구현 예시

SecretCache 객체를 생성하여 시크릿 문자열을 가져오도록 구성합니다. 싱글톤 또는 Spring Bean으로 관리하면 인스턴스가 불필요하게 재생성되는 것을 방지할 수 있습니다.

Java

import com.amazonaws.secretsmanager.caching.SecretCache;

public class SecretProvider {

    // 애플리케이션 수명 동안 재사용되는 단일 캐시 인스턴스
    private final SecretCache cache = new SecretCache();

    public String getDatabasePassword(String secretId) {
        // 캐시된 시크릿 반환 (기본 TTL: 1시간, 만료 시 자동 백그라운드 갱신)
        String secretJson = cache.getSecretString(secretId);
        
        try {
            return new ObjectMapper().readTree(secretJson).get("password").asText();
        } catch (JsonProcessingException e) {
            throw new RuntimeException("시크릿 파싱 실패", e);
        }
    }
}

7. 마이그레이션 시 주의사항 및 점검 리스트

  1. 기존 .tfvars 정리: manage_master_user_password = true를 적용할 때 기존 변수 파일에 선언되어 있던 db_password를 제거해야 합니다. 남아있으면 미정의 변수 에러가 발생합니다.
  2. 무중단 적용 주의: 이미 수동 비밀번호가 설정된 RDS 인스턴스에 manage_master_user_password를 적용하면 마스터 비밀번호가 즉시 새 값으로 로테이션됩니다. 구버전 Pod는 인증 에러가 발생하므로 새 훅이 적용된 이미지 빌드 및 배포 일정을 일치시켜야 합니다.
  3. 영구 자격 증명 배제: Git 커밋, .tfstate, CLI 히스토리, etcd 어디에도 비밀번호가 남지 않고, 오직 Pod의 런타임 메모리에만 안전하게 상주하게 됩니다.

참고자료 : https://docs.aws.amazon.com/secretsmanager/latest/userguide/retrieving-secrets_cache-java.html

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다