tfstate에 시크릿이 평문으로 저장됐을 때: 키 회전과 재발 방지
RDS 비밀번호나 API 키를 테라폼으로 다루다 tfstate 파일을 열어 보고 놀라는 순간이 있습니다. sensitive로 표시한 값이 전부 평문으로 들어 있기 때문입니다. 결론을 먼저 적으면, tfstate의 평문 저장은 버그가 아니라 설계이고, 노출 가능성이 있다고 판단되면 해야 할 일의 순서는 state 정리가 아니라 키 회전이 먼저입니다. 회전이 끝나면 유출된 값은 죽은 값이 되고, 그다음에 재발 방지를 설계합니다. 이 대응 순서는 Git에 비밀키를 커밋했을 때와 같은 원칙이고, 이 글은 테라폼 특유의 부분을 다룹니다.
왜 평문인가: sensitive는 가리개일 뿐입니다 #
테라폼은 다음 apply에서 변경을 판정하려면 현재 값을 알아야 합니다. 비밀번호 인수가 바뀌었는지 비교하려면 이전 비밀번호가 state에 있어야 하는 것입니다. 그래서 state는 리소스의 모든 속성을 값 그대로 기록합니다. sensitive = true가 하는 일은 plan 출력과 콘솔 표시에서 값을 가리는 것뿐이고, 파일 내용에는 아무 영향이 없습니다.
결론이 하나 나옵니다. state 파일 접근 권한은 곧 시크릿 접근 권한입니다. state를 읽을 수 있는 사람·역할·파이프라인의 목록이 시크릿 열람자 목록과 같다는 전제로 운영해야 하고, 이것이 아래 재발 방지의 출발점입니다.
사고 대응: 회전 → 히스토리 → state 순서입니다 #
1. 노출 범위를 판단합니다. state가 어디에 있었고 누가 읽을 수 있었는지가 질문입니다. 로컬 tfstate가 git에 커밋되어 사내 리포지토리에 있었는가, 공개 리포지토리였는가, S3 백엔드 버킷의 정책이 넓게 열려 있었는가. 공개 노출이면 즉시 유출로 간주하고, 사내 한정이면 조직 기준에 따르되 회전을 기본값으로 두는 것이 안전합니다.
2. 시크릿을 회전합니다. DB 비밀번호 변경, API 키 재발급, IAM 액세스 키 교체까지, 노출된 값을 무효화하는 것이 대응의 본체입니다. state에서 값을 지우는 어떤 작업도 이미 복제됐을 가능성을 없애 주지 못하므로, 회전 없이 정리만 하는 대응은 대응이 아닙니다.
3. git 히스토리를 정리합니다. tfstate가 커밋된 적이 있다면 filter-repo로 히스토리에서 제거하고 force push 절차를 밟습니다. 절차와 주의점은 비밀키 커밋 대응 글의 그것과 동일하고, 동시에 .gitignore에 *.tfstate*를 추가해 재커밋을 차단합니다. 애초에 로컬 state를 쓰지 않고 원격 백엔드로 옮기는 것이 구조적 해결이며, 이는 기초 #9의 주제입니다.
4. state의 값은 회전이 끝나면 자연히 갱신됩니다. 새 비밀번호로 코드·변수를 바꾸고 apply하면 state의 값도 새것으로 교체됩니다. 옛 값이 state 백엔드의 과거 버전(S3 버저닝)에 남는 것이 마음에 걸린다면 해당 객체 버전 삭제까지 하되, 이것은 회전이 끝난 뒤의 뒷정리이지 회전의 대체가 아닙니다.
재발 방지 1: 값이 state에 남지 않는 경로를 씁니다 #
테라폼 1.10〜1.11에서 이 문제의 구조적 해법이 들어왔습니다. ephemeral 값과 write-only 인수입니다. ephemeral 리소스로 읽은 시크릿은 실행 중에만 존재하고 state와 plan에 기록되지 않습니다. write-only 인수(예: aws_db_instance의 password_wo)는 값을 provider에 한 번 써 넣기만 하고 state에 저장하지 않으며, 값 교체는 password_wo_version 같은 버전 트리거로 지시합니다.
즉 신규 코드라면 “비밀번호를 변수로 받아 인수에 넣고 state에 남는” 기존 경로 대신, Secrets Manager나 SSM에서 ephemeral로 읽어 write-only 인수로 흘리는 경로를 기본값으로 삼을 수 있습니다. 이 구성의 실제 코드와 SSM vs Secrets Manager 선택은 실전 #6 시크릿 관리에서 다뤘습니다. 더 단순한 회피로, 시크릿을 테라폼이 아예 만지지 않고 앱이 런타임에 SSM에서 읽게 하는 설계도 여전히 유효합니다. 테라폼은 참조(ARN)만 다루면 state에는 참조만 남습니다.
재발 방지 2: state 자체를 시크릿 저장소로 취급합니다 #
값이 남는 경로를 다 없앨 수는 없으므로(생성된 리소스의 속성 등), state 쪽 방어도 함께 갑니다.
- 저장 암호화: S3 백엔드에 SSE-KMS를 켜고, KMS 키 정책으로 복호화 가능 주체를 좁힙니다. 기본 SSE-S3보다 접근 통제가 한 겹 늘어납니다.
- 접근 최소화: state 버킷의 IAM·버킷 정책을 “테라폼 실행 역할만"으로 줄입니다. 넓은 read 권한이 걸린 state 버킷은 시크릿이 전사 공개된 것과 같습니다.
- 버저닝과 로그: 버저닝은 복구 수단이면서 노출 조사 수단이기도 합니다. S3 액세스 로그·CloudTrail 데이터 이벤트를 켜 두면 사고 시 “누가 읽었는가"에 답할 수 있습니다.
- 출력 최소화: output으로 시크릿을 내보내면 상위 스택 state와 CI 로그로 전파 범위가 넓어집니다. output은 참조만 담는 원칙을 세웁니다.
정리 #
- tfstate는 설계상 모든 값을 평문으로 담습니다. sensitive는 표시를 가릴 뿐이므로, state 접근 권한을 시크릿 접근 권한과 동일하게 취급합니다.
- 노출 대응의 본체는 키 회전입니다. state 정리와 히스토리 정리는 회전 뒤의 뒷정리이지 대체 수단이 아닙니다.
- tfstate가 git에 커밋된 적 있다면 filter-repo 정리와 .gitignore 차단, 그리고 원격 백엔드 이전까지가 한 세트입니다.
- 신규 코드는 ephemeral + write-only 인수로 값이 state에 남지 않는 경로를 기본값으로 삼습니다. 앱이 런타임에 읽는 설계도 유효합니다.
- state 쪽 방어는 SSE-KMS, 접근 최소화, 버저닝·액세스 로그, output 최소화의 네 가지가 기본기입니다.