HCL, state, metadata와 ZIP으로 퍼진 비밀값
변수 기본값 한 줄이 여섯 곳으로 복제된 경로와, 이력 재작성이 되돌리지 못한 범위
Terraform 변수에 기본값을 넣으면 편하다. 매번 -var를 붙이지 않아도 되고,
terraform.tfvars를 따로 관리하지 않아도 된다. 2025년 구성은 그 편의를 비밀값에도
적용했다.
variable "discord_bot_token" {
description = "디스코드 봇 토큰"
type = string
default = "<DISCORD_BOT_TOKEN>"
}
이 파일에는 봇 토큰 하나, webhook URL 세 개, RCON 비밀번호가 같은 방식으로 들어 있었다. 값을 한 곳에 적었지만 값은 그 자리에 머물지 않았다. 저장소의 코드와 state를 대조해 값이 복제된 위치를 추적했다.
아래 인용은 모두 자리 표시자다. 실제 값은 이 글 어디에도 없고, 앞뒤 몇 글자를 남기는 마스킹도 하지 않았다. 길이와 접두사만으로도 값의 성격이 드러나기 때문이다.
값 하나가 도착한 여섯 곳
flowchart TB V["variables.tf 기본값"] --> V2["다른 루트의 변수 파일"] V --> S["세 스크립트의 폴백 상수"] V --> Z["discord-v2.zip 안의 .env"] Z --> M V --> P["plan"] P --> ST["terraform.tfstate"] ST --> B["state 백업 파일"] B --> G["Git 커밋"] V --> M["VM metadata"] M --> E["VM 디스크의 .env"]
값이 도착한 곳을 순서대로 짚으면 이렇다. 먼저 선점 복구 디렉터리의 변수 파일에도 같은 webhook URL이 기본값으로 적혀 있었다. 한 곳을 바꾸면 다른 곳은 그대로 남는 구조다.
스크립트 본문에도 있었다. startup.sh, backup.sh, shutdown.sh가 각각 다른
webhook URL을 상수로 들고 있었고, 세 값 모두 변수 기본값과 같았다.
WEBHOOK_URL=$(get_md "instance/attributes/discord-webhook-url")
if [ -z "$WEBHOOK_URL" ]; then
warn "discord-webhook-url 메타데이터 없음—폴백 사용"
WEBHOOK_URL="<DISCORD_WEBHOOK_URL>"
fi
변수에서 값을 지워도 이 줄은 남는다. 게다가 backup.sh와 shutdown.sh는 metadata
키를 언더스코어로 조회하는데 Terraform은 하이픈으로 보냈다. 두 스크립트는 언제나
빈 값을 받고 이 폴백 상수만 썼다. 변수 기본값만 지우면 이 상수는 코드에 남는다.
VM으로도 올라갔다. 토큰, RCON 비밀번호, webhook 세 개가 인스턴스 metadata 키로 전달됐다. metadata는 비밀 저장소가 아니다. 인스턴스 구성을 읽을 권한이 있는 주체와 VM 안의 프로세스가 이 값에 접근할 수 있다.
시작 스크립트는 metadata에서 읽은 값으로 디스크에 환경 파일을 만들었다. 권한은 0600으로 제한했지만, 이 시점까지만 세어도 값은 코드, metadata, 디스크의 세 곳에 있다.
애플리케이션 번들에도 같은 값이 들어갔다. Discord 봇 소스를 묶은 discord-v2.zip
안에는 .env 파일이 있고, 그 안의 봇 토큰은 변수 기본값과 같은 값이다. 배포할
때마다 배치 파일이 이 ZIP을 다시 만들고, Terraform이 filebase64로 읽어 metadata에
싣는다.
같은 값은 state에도 평문으로 들어갔다.
state는 리소스 속성을 그대로 기록한다. 당시 구성에서 sensitive = true는 출력만
가렸고 파일 안의 값은 평문이었다. 원격 backend도 쓰지 않아 state가 작업 디렉터리에
파일로 있었다.
Terraform은 local state를 바꿀 때 백업 파일을 남길 수 있다. 이 저장소에는
terraform.tfstate.backup 외에도 타임스탬프가 붙은 스냅숏이 남아 있지만, 실행 로그가
없어 어느 명령이 각 파일을 만들었는지는 확인할 수 없다.
.gitignore 뒤에 남은 state 백업 다섯 개
저장소의 .gitignore에는 필요한 규칙이 들어 있다.
*.tfstate
*.tfstate.backup
규칙만 보면 state는 Git에 올라가지 않는다. 2026년 8월 이 글을 쓰며 확인한 시점에도 state 백업 다섯 개가 추적되고 있었다.
restart-preempted-instance/terraform.tfstate.<epoch>.backup
restart-preempted-instance/terraform.tfstate.<epoch>.backup
restart-preempted-instance/terraform.tfstate.<epoch>.backup
restart-preempted-instance/terraform.tfstate.<epoch>.backup
start-minecraft-server/terraform.tfstate.<epoch>.backup
.gitignore는 아직 추적되지 않은 파일에만 적용된다. 이미 한 번 커밋된 파일은
규칙을 나중에 추가해도 계속 추적된다. 규칙을 넣은 뒤에도 이 다섯 파일은 그대로
추적됐다.
추적 중인 서버 state 백업 하나는 22만 바이트가 넘는다. 값을 읽지 않고 패턴만 세어 보면 안에 무엇이 있는지 드러난다.
| 패턴 | 건수 |
|---|---|
| Discord webhook URL | 4 |
| 봇 토큰 형태 문자열 | 1 |
| RCON 비밀번호 키 | 6 |
| SSH 공개키 | 1 |
이 표는 평문으로 보이는 값만 센 결과다. 같은 파일 안에는
discord-bot-zip metadata 값이 base64 문자열로 들어 있고, 그것을 풀면 앞에서 본
.env가 그대로 나온다. 토큰 한 벌이 더 있는 셈인데 패턴 검색에는 걸리지 않는다.
비밀값을 찾을 때 인코딩된 필드를 함께 풀어 보지 않으면 놓친다.
변수 기본값에 적은 값은 계획을 거쳐 state에 평문으로 들어갔다. 그 state의 백업이
커밋되면서 값도 함께 저장소에 남았다. 과거 이력에는 .backup이 아닌 state 본체가
커밋된 적도 있다.
이력 재작성이 되돌리는 범위
저장소에는 git filter-repo를 실행한 흔적이 남아 있다. 이력을 다시 쓰는 작업을
한 번 했다는 뜻이다.
위의 다섯 파일은 여전히 추적된다. 재작성 범위에서 빠졌는지, 재작성한 뒤 다시 커밋됐는지는 저장소만으로 알 수 없다. 확실한 것은 이력을 고치는 일이 한 번의 명령으로 끝나지 않는다는 사실이다.
이력 재작성 뒤에도 다음 범위는 남는다.
- 이미 클론했거나 포크한 사본은 바뀌지 않는다
- 재작성 전에 값을 본 사람의 기억은 되돌릴 수 없다
- 원격 호스팅이 캐시한 객체가 남을 수 있다
- 무엇보다 값 자체는 여전히 유효하다
이력 재작성은 이후에 만든 클론에서 값을 제거한다. 이미 복제된 값의 사용 권한은 무효화하지 못한다.
무효화가 코드 정리보다 먼저다
비밀값이 퍼진 것을 발견하면 순서를 지켜야 한다.
값을 무효화한다. 토큰을 재발급하고 webhook을 폐기하고 비밀번호를 바꾼다. 값이 여섯 곳에 흩어진 뒤에는 어느 사본을 놓쳤는지 증명할 방법이 없다. 증명할 수 없으면 남은 방법은 값 자체를 쓸모없게 만드는 것뿐이다.
그다음 코드와 이력을 정리한다. 변수 기본값을 지우고, 스크립트의 폴백 상수를
없애고, 추적 중인 파일을 git rm --cached로 뺀다. .gitignore만 고치면 이미
추적된 파일은 그대로 남는다는 것을 위에서 봤다. 필요하면 이력도 다시 쓴다.
마지막으로 다시 생기지 않게 만든다. 값을 코드에 두지 않는 구조로 바꾼다.
순서를 뒤집으면 코드를 정리하는 동안에도 옛 토큰과 webhook이 계속 살아 있다.
지금 구성이 다르게 하는 것
구축 트랙의 공개 예제는 RCON과 Discord 연동을 제외하고 실제 입력 파일과 state의 보관 위치를 분리했다.
게임 서버에 비밀값이 필요 없게 만들었다. RCON을 열지 않으므로 RCON 비밀번호가 없다. Discord 연동을 넣지 않으므로 토큰과 webhook도 없다. 관리 접근은 OS Login으로 처리해서 서버에 키를 심지 않는다. 이 구성에는 재발급하거나 폐기할 값 자체가 없다.
입력값은 예시 파일과 실제 파일을 나눴다. 저장소에는 terraform.tfvars.example만
있고, 필수 값은 전부 replace-로 시작하는 자리 표시자다. 실제 값이 들어가는
terraform.tfvars와 backend.hcl은 처음부터 Git에서 제외된다. 실제 값이 든
파일이 만들어지기 전에 제외 규칙이 먼저 들어 있다.
state를 원격으로 옮겼다. state 버킷은 접근을 IAM으로 통제하고 버전 관리를 켠다. state가 작업 디렉터리에 없으면 실수로 커밋할 일도 없다. state에 민감한 속성이 들어간다는 사실 자체는 달라지지 않으므로, 보관 위치와 접근 권한으로 다룬다.
비밀값이 필요한 구성이라면 Secret Manager에 값을 두고 VM이 실행 시점에 자기 서비스 계정으로 가져오게 할 수 있다. Terraform에는 비밀값 대신 secret의 리소스 식별자만 전달한다. Terraform data source로 비밀 버전의 값을 읽어 다른 리소스에 넘기면 그 값이 다시 plan과 state에 들어갈 수 있으므로 피한다. VM 서비스 계정에는 필요한 secret 버전을 읽는 권한만 준다.
값이 한 번 코드에 들어가면 plan, state, metadata, 디스크로 복제될 수 있다. 그러면 코드 수정만으로는 정리가 끝나지 않고, 값의 폐기와 각 사본의 삭제 여부까지 확인해야 한다.
참고
Comments
아직 댓글이 없습니다. 첫 댓글을 남겨주세요.
검토 대기 중