초기 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 Manager | AWS 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>'
프로덕션에서 발생한 문제점
- 평문 노출 경로의 다변화: Terraform의
.tfstate파일에 비밀번호가 평문 기록됩니다. 또한 로컬.tfvars파일 및 터미널 셸 기록(History)에 자격 증명이 잔존합니다. - Kubernetes etcd 노출: Kubernetes Secret은 기본적으로 Base64 인코딩일 뿐 암호화되지 않습니다. 네임스페이스에 Secret 읽기 권한이 있거나, kube-apiserver/etcd 설정이 잘못 노출되면 데이터베이스 전체 권한이 탈취됩니다.
- 수동 교체(Rotation)의 한계: 비밀번호를 변경하려면 Terraform 변수 수정 → 배포 → Kubernetes Secret 재생성 → Pod 재시작 과정을 수동으로 진행해야 하며, 감사 추적(Audit Log)이 불가능합니다.
3. 개선된 아키텍처: Secrets Manager 기반 자동화
개선된 방식에서는 클러스터 설정에 비밀번호를 직접 두지 않고 Secrets Manager의 ARN(포인터)만 남깁니다.
- RDS가 마스터 패스워드를 자체 생성하고 Secrets Manager에 기록합니다.
- 클러스터 ConfigMap은 비밀번호 대신 시크릿의 ARN(
DB_SECRET_ID)을 가집니다. - 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. 마이그레이션 시 주의사항 및 점검 리스트
- 기존
.tfvars정리:manage_master_user_password = true를 적용할 때 기존 변수 파일에 선언되어 있던db_password를 제거해야 합니다. 남아있으면 미정의 변수 에러가 발생합니다. - 무중단 적용 주의: 이미 수동 비밀번호가 설정된 RDS 인스턴스에
manage_master_user_password를 적용하면 마스터 비밀번호가 즉시 새 값으로 로테이션됩니다. 구버전 Pod는 인증 에러가 발생하므로 새 훅이 적용된 이미지 빌드 및 배포 일정을 일치시켜야 합니다. - 영구 자격 증명 배제: Git 커밋,
.tfstate, CLI 히스토리, etcd 어디에도 비밀번호가 남지 않고, 오직 Pod의 런타임 메모리에만 안전하게 상주하게 됩니다.
참고자료 : https://docs.aws.amazon.com/secretsmanager/latest/userguide/retrieving-secrets_cache-java.html