인프라를 코드로 관리하면 재현성이 좋아집니다. 하지만 secret까지 아무 생각 없이 Terraform으로 만들면 state 파일에 민감 값이 남을 수 있습니다.

이 글은 DB 계정과 secret 운영 경계를 어떻게 나눌지 고민한 기록입니다.

문제

Terraform은 리소스의 현재 상태를 state에 기록합니다. 이때 password나 secret value를 Terraform resource로 직접 생성하면 state에 민감 값이 남을 수 있습니다.

물론 Terraform state 자체도 보호해야 하지만, 운영 관점에서는 애초에 state에 secret value가 들어가지 않도록 경계를 나누는 것이 더 안전합니다.

정산 시스템에서는 다음 요구가 있었습니다.

Terraform이 맡을 일

Terraform은 인프라 뼈대를 만드는 데 집중합니다.

Terraform
  - VPC / subnet / security group
  - RDS instance
  - ECS cluster / service / task definition
  - IAM role / policy
  - KMS key
  - Secrets Manager secret container
  - Parameter Store path

중요한 점은 secret의 “그릇”은 Terraform이 만들 수 있지만, secret의 “값”은 Terraform이 만들지 않도록 하는 것입니다.

Bootstrap이 맡을 일

DB user 생성과 password 발급은 bootstrap script가 맡습니다.

bootstrap
  - connect with admin credential
  - generate DB user password
  - create or update app/readonly/admin DB users
  - grant least privileges
  - store generated password in Secrets Manager
  - verify connection and seed data

이렇게 하면 Terraform state에는 secret value가 남지 않습니다. Terraform은 앱 task role이 어떤 secret path를 읽을 수 있는지만 관리합니다.

RDS master와 앱 계정 분리

앱이 master 계정을 쓰면 운영 위험이 커집니다. 앱 런타임에 필요한 권한은 제한적이어야 합니다.

settlement_app
  - application runtime
  - 필요한 schema/table 접근만 허용

settlement_readonly
  - read-only access
  - 운영 조회나 분석 용도

settlement_admin
  - migration/bootstrap/admin operation
  - 제한된 환경에서만 사용

계정을 분리하면 credential 유출이나 코드 버그가 발생했을 때 피해 범위를 줄일 수 있습니다.

KMS와 애플리케이션 secret

CMS 계좌 연동처럼 민감한 외부 API credential은 단순 DB 암호화만으로 충분하지 않습니다. DB가 유출되더라도 암호키가 함께 유출되지 않도록 KMS envelope encryption을 적용했습니다.

개념적으로는 다음과 같습니다.

plaintext secret
  -> data key로 암호화
  -> data key는 KMS master key로 암호화
  -> ciphertext만 DB에 저장

애플리케이션은 IAM 권한을 통해 필요한 경우에만 KMS decrypt를 수행합니다. 이 구조에서는 DB만 유출되어도 민감 값을 바로 복호화할 수 없습니다.

배포 경계

stage/prod 인프라는 변경 빈도와 위험도를 기준으로 stack을 나누었습니다.

foundation
  - network
  - security group
  - KMS
  - base access boundary

data
  - RDS
  - ElastiCache
  - secret containers

app
  - ECS service
  - ALB
  - task roles
  - runtime environment

스택을 나누면 변경 영향 범위를 줄일 수 있습니다. 앱 배포를 위해 RDS나 network 계층을 매번 건드리지 않아도 되고, data 계층 변경은 더 신중하게 다룰 수 있습니다.

이 구조가 항상 유일한 정답은 아닙니다. 작은 서비스라면 하나의 root module로도 충분할 수 있습니다. 하지만 stage/prod처럼 장기 운영 환경이고, 네트워크/RDS/ECS의 변경 주기와 장애 영향도가 다르다면 foundation -> data -> bootstrap -> app처럼 계층을 나누는 편이 운영상 더 안전했습니다.

중요한 기준은 다음이었습니다.

아쉬웠던 점

이 구조에서 가장 아쉬웠던 부분은 data applyapp apply 사이의 bootstrap 단계였습니다.

Terraform이 RDS와 Secrets Manager secret container를 만든 뒤, 실제 앱을 띄우기 전에는 DB 내부 준비가 필요했습니다.

foundation apply
  -> network / KMS / base security

data apply
  -> RDS / ElastiCache / secret containers

bootstrap
  -> create database
  -> apply baseline schema and seed
  -> create app/readonly/admin DB users
  -> write generated passwords to Secrets Manager

app apply
  -> ECS service / ALB / task roles / runtime secrets

문제는 이 bootstrap 스크립트가 settlement-backendscripts/db 아래에 있었다는 점입니다. 인프라를 올리는 사람은 settlement-terraform을 보다가, 중간에 settlement-backend의 스크립트를 찾아 실행해야 했습니다.

책임 관점에서는 이상하지 않습니다. DB schema, seed, role grant는 애플리케이션이 가장 잘 아는 영역입니다. 반대로 Terraform repo로 이 스크립트를 옮기면 애플리케이션 스키마와 seed가 인프라 repo에 복사되어 drift가 생기기 쉽습니다.

다만 운영 관점에서는 사람이 두 repo를 오가며 순서를 맞추는 구조가 아쉽습니다. 그래서 더 나은 방향은 bootstrap 로직을 Terraform으로 옮기는 것이 아니라, backend artifact로 패키징하고 배포 파이프라인이 실행 순서를 책임지게 하는 것입니다.

Docker image로 bootstrap을 패키징한다는 뜻

여기서 Docker image로 만든다는 말은 bootstrap 스크립트를 별도 수동 파일로 실행하지 않고, backend의 특정 버전에 포함된 실행 명령으로 제공한다는 뜻입니다.

예를 들면 backend image 안에 다음 파일과 도구를 포함합니다.

backend bootstrap image
  - scripts/db/bootstrap-stag-db.sh
  - scripts/db/bootstrap-db-users.sh
  - db/bootstrap/*.sql
  - src/main/resources/db/migration/*.sql
  - init-db/*.sql
  - psql
  - aws cli
  - jq

그리고 image entrypoint나 wrapper command로 bootstrap을 실행합니다.

settlement-db-bootstrap schema
settlement-db-bootstrap users
settlement-db-bootstrap all

또는 Docker command 자체를 이렇게 잡을 수 있습니다.

docker run --rm \
  -e PGHOST=... \
  -e PGPORT=5432 \
  -e PGUSER=postgres \
  -e PGPASSWORD=... \
  -e APP_DB_SECRET_ID=... \
  -e READONLY_DB_SECRET_ID=... \
  -e ADMIN_DB_SECRET_ID=... \
  settlement-backend:<git-sha> \
  settlement-db-bootstrap all

핵심은 bootstrap 코드가 backend 버전과 함께 움직인다는 점입니다. V001__baseline_schema.sql이나 seed가 바뀌면 bootstrap image도 같은 commit sha로 만들어집니다. 배포 파이프라인은 settlement-backend repo의 파일 경로를 직접 알 필요 없이, “이 backend artifact의 bootstrap command를 실행한다”만 알면 됩니다.

실행 위치가 의미하는 것

실행 위치는 이 command가 실제로 어디에서 실행되느냐를 말합니다.

GitHub Actions를 CI/CD로 쓴다고 해도 command가 반드시 GitHub-hosted runner에서 직접 실행되어야 하는 것은 아닙니다. GitHub Actions는 오케스트레이터가 되고, 실제 bootstrap command는 AWS 내부의 CodeBuild, ECS task, SSM instance, self-hosted runner에서 실행될 수 있습니다.

bootstrap command를 실행하는 곳에는 두 가지 조건이 필요합니다.

그래서 실행 위치는 단순 취향이 아니라 보안 경계와 운영 난이도를 결정합니다.

실행 위치 방식 장점 단점 추천도
GitHub-hosted Actions runner GitHub Actions에서 직접 Docker command 실행 설정이 단순하고 기존 CI/CD와 붙이기 쉽습니다 private subnet RDS에 직접 접근하기 어렵습니다. RDS를 public으로 열거나 터널을 만들어야 해서 보안상 부담이 큽니다 낮음
GitHub Actions self-hosted runner in VPC VPC 안 EC2/ECS runner에서 workflow 실행 GitHub Actions UX를 유지하면서 private RDS에 접근 가능합니다 runner 운영, 패치, 권한 관리, 동시성 관리가 필요합니다 중간
AWS CodeBuild in VPC GitHub Actions가 CodeBuild를 트리거하고 CodeBuild가 bootstrap image 실행 managed service라 runner 운영 부담이 작고, VPC/IAM/CloudWatch 연동이 좋습니다 buildspec과 VPC endpoint/NAT 설정이 필요합니다 높음
ECS one-off task bootstrap image를 ECS task로 한 번 실행 실제 app과 비슷한 runtime, security group, task role을 쓸 수 있습니다 초기 구축 시 ECS cluster가 app stack에 있으면 app apply 전에는 실행 위치가 없을 수 있습니다 높음, 단 cluster 위치에 따라 다름
SSM admin instance private subnet의 admin EC2에서 SSM command로 실행 이미 운영용 SSM instance가 있다면 빠르고 RDS 접근도 쉽습니다 장기적으로는 instance 운영 부담과 수동 운영 습관이 남을 수 있습니다 중간
Step Functions + CodeBuild/ECS foundation/data/bootstrap/app 순서를 workflow로 모델링 승인, 재시도, 감사, 단계별 로그가 좋습니다 지금 규모에는 다소 무겁습니다 규모가 커질 때

내가 가장 선호하는 순서는 CodeBuild in VPC 또는 ECS one-off task입니다.

초기 구축에서 app stack 이전에 ECS cluster가 아직 없다면 CodeBuild가 더 현실적입니다. CodeBuild project를 VPC private subnet에 붙이고, bootstrap image를 pull해서 실행하면 됩니다. 반대로 ECS cluster를 foundation 쪽에 두거나 이미 존재하는 shared cluster가 있다면 ECS one-off task가 더 자연스럽습니다.

한 번만 실행하는 bootstrap과 비용

이 bootstrap은 일반적인 배포마다 실행할 작업이 아닙니다. 환경을 처음 만들 때 한 번 실행하고, 이후에는 애플리케이션 배포 파이프라인에서 건너뛰면 됩니다.

일반 배포
  - app image build
  - app deploy
  - bootstrap skip

최초 환경 구축
  - foundation apply
  - data apply
  - bootstrap run
  - app apply

특수 운영
  - DB user password rotation
  - bootstrap-users run

이 관점에서 CodeBuild는 비용 구조도 잘 맞습니다. CodeBuild project는 실행 정의만 가지고 있다가, start-build가 호출될 때 임시 build environment를 만듭니다. VPC 설정이 있다면 그 실행 환경이 private subnet에 ENI를 붙이고, bootstrap command를 실행한 뒤 종료됩니다.

평소
  - CodeBuild project definition만 존재
  - bootstrap container는 떠 있지 않음

실행 시
  - build environment 생성
  - private subnet ENI 연결
  - RDS / Secrets Manager 접근
  - bootstrap command 실행
  - CloudWatch Logs 기록

종료 후
  - build environment 종료
  - compute 과금 종료

즉, CodeBuild in VPC는 항상 켜져 있는 EC2나 ECS service가 아닙니다. 최초 구축처럼 드물게 실행하는 작업에 대해, 필요한 순간에만 컨테이너성 실행 환경을 빌려 쓰는 방식에 가깝습니다.

남는 비용은 주로 CloudWatch Logs 저장, ECR image 저장, NAT Gateway나 VPC Endpoint 같은 네트워크 주변 비용입니다. 하지만 bootstrap container 자체가 계속 떠 있어서 비용이 계속 나가는 구조는 아닙니다.

그래서 pipeline에는 bootstrap 단계를 항상 강제하기보다, 수동 입력이나 승인 조건을 두는 편이 좋습니다.

run_bootstrap = false

if run_bootstrap:
  start CodeBuild bootstrap
else:
  skip bootstrap

이렇게 하면 일반 배포는 빠르게 유지하면서, 최초 환경 구축이나 password rotation이 필요한 때만 같은 도구로 bootstrap을 실행할 수 있습니다.

최종적으로 원하는 모양

가장 이상적인 흐름은 다음과 같습니다.

deploy pipeline
  1. terraform foundation apply
  2. terraform data apply
  3. read terraform outputs
       - rds endpoint
       - app/readonly/admin secret ids
       - kms key arn
  4. run backend bootstrap image in VPC
       - create database
       - apply schema / seed
       - create DB users
       - write secret values
  5. terraform app apply or deploy app image

이렇게 하면 repo 책임은 유지하면서 운영 절차는 하나로 묶을 수 있습니다.

settlement-terraform
  - AWS resource boundary
  - RDS / Redis / KMS / secret container

settlement-backend
  - DB schema / seed
  - DB role and grant intent
  - bootstrap command packaged by backend version

pipeline
  - execution order
  - Terraform output 전달
  - bootstrap command 실행 위치 결정
  - approval / logs / retry

즉, 문제의 본질은 bootstrap 파일이 backend repo에 있다는 것이 아닙니다. 문제는 그 실행이 수동 runbook에 남아 있다는 것입니다. Terraform에 넣기보다, backend artifact로 패키징하고 pipeline에서 실행하게 만드는 편이 책임 경계를 더 잘 지킵니다.

운영 효과

이 구조에서 얻은 효과는 다음과 같습니다.

비용을 들여 거대한 보안 시스템을 만드는 것보다, secret이 지나가는 경로를 줄이는 것이 먼저입니다. Terraform은 강력하지만, 모든 것을 Terraform에 넣는 것이 항상 좋은 설계는 아니었습니다.

더 개선한다면

다음 단계에서는 secret rotation뿐 아니라 Terraform 운영 프로세스도 더 단단하게 만들 수 있습니다.

보안 설계와 IaC 설계는 한 번 만들고 끝나는 것이 아니라, 운영 중에 계속 회전하고 검증되는 구조로 발전해야 합니다. 이번 구조는 state에 secret value를 남기지 않고 blast radius를 줄이는 출발점이고, 다음 단계는 plan review, policy check, drift detection으로 변경 프로세스까지 코드화하는 것입니다.