0. Contents


1. Overview

CFNCloudFormation 장부스택 kukie-dev-db · AWS 안에 보관1VPC2subnet a3subnet c4IGW5IGW 연결6라우트 테이블7기본 경로8연결 a9연결 c10Aurora 클러스터11writer12subnet group13파라미터 그룹14DB 보안 그룹imports.tf짝짓기import {to = aws_vpc.thisid = "vpc-…"} … 13개첫 /apply 때 옮겨 적고 삭제TFdev/network.tfstateS3 보관 · 명찰 network-applyaws_vpc.this → vpc-…aws_internet_gateway.this → igw-…aws_subnet.public[a, c] → subnet-…… 8개10-17 /apply 기록TFdev/database.tfstateS3 보관 · 명찰 database-applyaws_rds_cluster.this → cluster…instance.writer → writeraws_security_group.db → sg-…… 5개+ 새 규칙 3개 (생성)10-17 /apply 기록
장부 한 권의 열네 개를 두 권으로 옮겨 적는다 — 리소스 정의는 main.tf 에 남고, 짝은 장부가 기억한다

VPC·Aurora 와 Terraform 의 기존 경계

  • kukie-dev 의 VPC 와 Aurora 는 Terraform 보다 먼저 있었다
    • 2026-09-06 CloudFormation 스택 하나로 만들어졌다
    • 그 위에 Terraform 으로 EKS 와 NAT 를 얹었다(03편)
  • 그동안은 “CloudFormation 이 만든 것은 Terraform 이 건드리지 않는다” 는 규칙으로 둘을 나눠 왔다

계기: DB 보안 그룹의 5432 규칙 한 줄

  • 앱 배포를 앞두고 이 경계가 처음으로 걸렸다
  • 클러스터의 Pod 가 Aurora 에 붙으려면 DB 보안 그룹에 5432 규칙 한 줄이 필요했다
  • 그 보안 그룹의 주인은 CloudFormation 이었다
  • 한 줄을 CloudFormation 으로 더할 수도 있었지만, 배포 전에 스택 전체를 Terraform 으로 옮기기로 했다

이번 편: 전환의 기록

  • 리소스 열넷의 분할
  • 옮기는 일을 할 역할을 좁힌 방법
  • 적용 전에 리뷰가 찾아낸 IAM 의 빈틈들
  • 스택을 지우고 Terraform 으로 가져온 적용 과정(7장)

2. Background — 두 도구가 섞인 경위

CloudFormationTerraform09-0609-2410-1210-1510-16스택 kukie-dev-dbVPC · subnet · IGW · Aurora그대로 유지 · 콘솔에서 규칙 하나 추가state · OIDC배포 역할EKS · NAT같은 VPC 안앱 준비클러스터 → DB 5432전환결정배포 전CFN →Terraform
먼저 있던 스택 위에 Terraform 이 올라왔다 — 클러스터가 DB 에 붙어야 할 때 두 도구가 처음 맞닿았다
2026-09-06
일
CloudFormation 스택 kukie-dev-db 생성. VPC, public subnet 2개, IGW, 라우팅, Aurora Serverless v2, DB 보안 그룹. 팀원 PC 에서 개발용으로 쓰려고 만든 것이라 보안 그룹은 공인 IP 만 허용
2026-09-24
일
Terraform 도입. state, OIDC, 배포 역할
2026-10-12
일
같은 VPC 안에 EKS, private subnet, NAT 를 Terraform 으로 추가
2026-10-16
일
앱이 DB 에 붙어야 할 시점. 스택 전환 결정

옮기기로 한 이유 세 가지

  • 스택 템플릿 원본이 조직의 어느 저장소에도 없었다
  • DB 보안 그룹이 템플릿과 달랐다(콘솔에서 추가된 허용 규칙 하나)
  • 그 변경들이 PR, plan, /apply 흐름 밖에서 일어났다
  • 문제는 CloudFormation 이 나쁜 도구라는 점이 아니었다
    • 나머지가 모두 Terraform 인데 하나만 다른 도구라는 점이었다

3. Import Plan — 리소스 열넷의 분할

3.1 Two Roots — network 와 database

root 단위의 분할

  • 스택의 리소스 열넷을 Terraform root 둘로 나눴다
  • root 는 코드 폴더 하나, state 파일 하나, 배포 역할 하나가 한 묶음이다
  • 나눈다는 건 이 셋을 같이 나눈다는 뜻이다
코드 폴더state 파일 (S3)배포 역할networkterraform/dev/network/VPC · subnet · IGW · 라우트dev/network.tfstate가져온 8개만 기록network-apply5개 ARN · 삭제 거부databaseterraform/dev/database/Aurora · DB 보안 그룹dev/database.tfstate가져온 5개 + 새 규칙 3database-applyDB만 · 비밀번호 해제 거부root 하나 = 폴더 · 장부 · 명찰 한 묶음. network PR 의 plan 에는 DB 가 보이지 않고, network 명찰로는 DB 를 바꾸지 못한다
나눈 것은 코드만이 아니다 — 장부와 명찰까지 함께 나눴다
dev/network
리소스
VPC, public subnet 2, IGW 와 연결, public 라우트 테이블, 기본 경로, subnet 연결 2
나눈 이유
EKS 와 Aurora 가 모두 그 위에 있다. 바뀌는 일이 거의 없다
dev/database
리소스
Aurora 클러스터, writer, subnet group, 파라미터 그룹, 보안 그룹
나눈 이유
데이터가 있는 곳이라 다른 root 와 state, 역할을 나눈다

eks-platform 과도 나눈 이유

  • eks-platform 에 넣지 않은 것도 같은 이유다
  • EKS 는 노드 교체와 버전 업그레이드로 자주 바뀐다
  • 한 state 에 두면
    • EKS 작업의 plan 에 DB 가 함께 걸린다
    • 넓은 eks-platform 역할의 실수가 DB 까지 번진다

3.2 Import Block — 실물을 건드리지 않는 가져오기

import 블록의 동작

  • Terraform 1.5 부터 import 블록으로 이미 있는 리소스를 코드로 가져온다
  • plan 에서 “가져오기” 로 표시된다
  • 코드가 실물과 같으면 바꾸는 것이 없다
import {
  to = aws_vpc.this
  id = "<VPC ID>"
}
보통 · 새로 만들 때main.tf 만 있음 → 장부에 없으니 만든다GitHubmain.tfVPC 는 이런 모습Terraform 이 직접 만들어ID 를 받아 바로 적는다→ import 필요 없음+ createplanID 를돌려받아 기록AWSVPC 새로 생성새 ID 가 생김S3 장부짝을 기록vpc.this = vpc-…지금 · 이미 있는 것을 가져올 때main.tf + imports.tf → 이미 있으니 기록만 한다GitHubmain.tfVPC 는 이런 모습imports.tf= 실물 vpc-…CloudFormation 이 만들어Terraform 은 ID 를 모른다→ 사람이 imports.tf 로 알려 줌importplanAWS기존 VPC 그대로태그 등만 맞춤S3 장부짝을 기록vpc.this = vpc-…가져온 뒤부터는 왼쪽과 같다main.tf 를 고쳐 /apply 하면 실물이 바뀐다 · 짝은 장부가 기억하므로 imports.tf 는 지운다main.tf 의 resource 에는 ID 칸이 없다 (ID 는 AWS 가 정한다) · ID 로 쓰는 data 블록은 읽기만 하고 관리하지 않는다
main.tf 는 같다 — 옆에 imports.tf 가 있으면 "만들기" 가 "가져오기" 가 된다

import 가 필요한 리소스

  • Terraform 이 직접 만든 리소스: 만들 때 AWS 가 돌려준 ID 를 바로 장부에 적는다 → import 가 필요 없다
  • CloudFormation 이 만든 리소스: Terraform 이 ID 를 모른다 → 사람이 import 로 알려 준다
  • 다른 블록으로는 ID 를 알려 줄 수 없다
    • resource 블록에는 ID 를 적는 칸이 없다
    • ID 로 쓰는 data 블록은 읽기만 할 뿐 관리하지 않는다

위험: 코드와 실물의 차이

  • 가져오기의 위험은 코드와 실물이 다를 때다
  • 예: CIDR 이 한 글자라도 다르면 가져오기가 아니라 교체(삭제 후 생성) 가 된다
  • 이 VPC 안에 클러스터가 있다 → 교체는 곧 재구성이다
  • 대비
    • VPC, subnet, IGW, Aurora 에 prevent_destroy 를 건다
    • 모의 테스트가 CIDR 을 CloudFormation 값과 비교한다

3.3 Local Plan — 적용 전 실측

8가져오기0생성5변경0삭제network5가져오기3생성5변경0삭제database
생성은 새 규칙뿐이고 삭제는 0 — 변경은 대부분 기본 태그 ManagedBy 추가다

가져오기 · 변경 · 교체의 차이

  • 가져오기: 실물을 state 에 연결만 하고 AWS 는 바꾸지 않는다
  • 변경: 연결한 뒤 코드와 다른 부분(대부분 태그)을 실물에 그 자리에서 맞추는 일이다
  • 교체: 지우고 다시 만드는 일로, 변경과는 다르다

로컬 state 로 돌린 plan

  • 코드를 레포에 넣기 전에 관리자 자격증명으로 로컬 state 를 써서 plan 만 돌렸다
  • AWS 는 바꾸지 않는다

plan 결과

  • network: 가져오기 8 · 생성 0 · 변경 5 · 삭제 0
  • database: 가져오기 5 · 생성 3 · 변경 5 · 삭제 0
  • 변경은 대부분 Terraform 의 기본 태그 ManagedBy 를 붙이는 일이었다

실물과 값이 달랐던 두 곳

  • writer 인스턴스의 promotion_tier: 실물 1, 코드 기본값 0. 코드를 1로 맞췄다
  • 파라미터 그룹의 rds.force_ssl = 1: CloudFormation 템플릿에 적혀 있었지만 PostgreSQL 17 의 기본값과 같아 사용자 값으로 기록되지 않았다. 실제 값은 이미 1이라 바뀌는 것은 없다

database 의 생성 3: 새 보안 그룹 규칙

  • 관리자 접속 규칙 둘
  • 클러스터 → DB 5432 규칙 하나
  • 옛 관리자 규칙 둘은 가져오지 않았다
    • Terraform 주소의 키가 IP 가 된다 → 가져오려면 코드에 공인 IP 를 적어야 한다
    • 스택 정리 단계에서 회수하고 같은 목록을 새로 만든다

Aurora 의 manage_master_user_password

  • import 는 이 값을 다시 읽어 오지 못한다
  • 그래서 첫 plan 이 “비밀번호 관리를 RDS 에 맡겨라” 는 변경을 다시 보내려 했다
  • 이미 그렇게 되어 있지만 비밀번호에 닿는 호출이다 → ignore_changes 로 아예 보내지 않게 했다

4. Apply Roles — 지우지 못하는 배포 역할

EKS · 노드private subnet · NAT가져온 네트워크VPC · IGW · public RT · public subnetDB 방화벽Aurora 보안 그룹Aurora클러스터 · writerinfra-applyeks-platform 역할 · ec2:*사용 중만들고 관리고치기 · 지우기 거부KeepAdoptedResourcesKeepSubnetAssociations고치기 · 지우기 거부KeepAdoptedResources권한 없음network-applynetwork root첫 /apply 전읽기만태그 · 속성 · 경로삭제 거부 KeepNetworkCore연결 변경 권한 없음읽기만권한 없음database-applydatabase root첫 /apply 전읽기만읽기만규칙 관리SG 삭제 거부 KeepDatabase수정 · 태그삭제 거부 KeepDatabase비밀번호 관리 해제 거부관리거부 규칙으로 막음읽기만 · 권한 없음세 역할 모두 main 브랜치에서만 빌릴 수 있다
같은 VPC 를 쓰지만 초록 칸은 각자 자기 구역에만 있다 — 남의 구역은 거부 규칙이나 빈 권한으로 막혀 있다

배포 흐름: GitHub Actions 와 OIDC

  • 이 레포에서 Terraform 은 사람이 아니라 GitHub Actions 가 돌린다
  • PR 에 /apply 댓글을 달면 그 root 의 배포 역할이 OIDC 로 임시 키를 받는다(01편, 02편)
  • root 를 추가하려면 그 역할부터 관리자가 만들어야 한다
network-apply
할 수 있는 것
가져온 VPC, subnet, IGW, 라우트 테이블의 태그, 속성, public 경로, IGW 연결
거부
삭제와 IGW 분리(KeepNetworkCore), subnet 연결 변경
database-apply
할 수 있는 것
Aurora 네 리소스의 수정과 태그, DB 보안 그룹 규칙
거부
삭제 다섯 가지(KeepDatabase), RDS 관리 비밀번호 해제(KeepManagedMasterPassword), Secret 값 읽기
eks-platform 역할 (기존)
할 수 있는 것
EKS, 노드, NAT. EC2 는 ec2:*
거부
가져온 여섯 리소스(KeepAdoptedResources), subnet 연결 교체와 해제(KeepSubnetAssociations)

두 역할의 공통 제한

  • 두 역할 모두 main 브랜치에서만 쓸 수 있다
  • 코드의 prevent_destroy 와 RDS 의 삭제 방지가 이미 있다
  • 그래도 역할 자체에도 삭제를 막아 두었다 → 코드를 고쳐서 우회할 수 없게 하려는 것이다

5. IAM Pitfalls — 리뷰가 찾은 빈틈

1234567891011P1 4 · P2 4 · 코드 1 · 문서 3리뷰 응답 순서 · 마지막: 필수 수정 없음
한 바퀴마다 한 층 깊은 우회 경로 — 적용 전에 나온 것만으로도 첫 apply 가 멈추거나 비밀번호가 새는 길이 둘이었다

리뷰 여덟 라운드

  • 권한 PR 은 여덟 번 리뷰를 돌았다
  • Claude 와 Codex 가 번갈아 지적했고, 모두 고친 뒤 마지막 라운드는 “필수 수정 없음” 이었다
  • 지적 수: 심각도 P1 넷, P2 넷, 코드 지적 하나, 문서 지적 셋
  • P1 넷과 Null 조건 지적은 그대로 적용했다면 실제로 뚫리거나 멈췄을 것이었다

5.1 Route Table Association — 라우트 테이블만 보는 권한 확인

도로 · 인터넷건물 · VPC대문 · IGW앞쪽 방public subnet · NAT안쪽 방private subnet · EKS 노드표지판도로는 대문으로public RT · → IGW표지판앞쪽 방을 거쳐서private RT · → NAT경비원 · IAM 확인표지판(RT)만 본다 → 통과① 관리인 Anetwork-apply 역할② 관리인 Beks-platform 역할 · ec2:*
① 앞쪽 표지판을 안쪽 방에
노드 subnet 의 길이 NAT 대신 IGW 로
조치 · network 역할에서 연결 권한 제거
② 앞쪽 방 표지판 바꿔 달기 · 떼기
public subnet 의 NAT 가 인터넷을 잃음
조치 · eks-platform 역할 교체 · 해제 전역 거부
방 하나에 표지판 하나 — 경비원은 손에 든 표지판만 보고, 어느 방에 붙이는지는 보지 않는다

표지판 · 명찰 · 경비원으로 본 연결 검사

  1. 표지판 두 개 — 같은 종류, 다른 내용 — public 과 private 라우트 테이블은 같은 리소스(aws_route_table)다. 다른 것은 적힌 길과 주인이다
    • public: 0.0.0.0/0 → IGW. public subnet(NAT, ALB)에 붙어 있고, CloudFormation 이 만든 것을 network root 가 가져왔다
    • private: 0.0.0.0/0 → NAT. EKS 노드가 있는 private subnet 에 붙어 있고, eks-platform root 가 만들었다
  2. 명찰 두 개 — network 역할(kukie-terraform-network-apply)과 eks-platform 역할(kukie-terraform-infra-apply, ROOTS 키가 infra 라 이름이 다르다). 둘 다 GitHub Actions 가 OIDC 로 빌려 /apply 때 단다
  3. 경비원 — AWS IAM — 검사는 요청을 받는 AWS 쪽에서 한다. 규칙의 내용은 우리가 foundation root 의 정책으로 쓰고, 어떤 요청에서 무엇을 대조할지는 AWS 가 정한다
  4. 경비원이 보는 것 — 표지판 주소만 — subnet 연결 동작 셋(붙이기 AssociateRouteTable, 바꿔 달기 ReplaceRouteTableAssociation, 떼기 DisassociateRouteTable)에서 IAM 은 라우트 테이블 ARN 으로 명찰마다 허용과 거부를 가른다. 요청에 들어 있는 subnet ARN 은 대조하지 않는다
1명찰이 보낸 요청2경비원이 보는 요청3규칙 확인 · Deny 먼저4결과명찰 · network 역할public RT를노드 subnet에 붙여하면 안 되는 요청표지판만 읽는다public RT를안 봄에 붙여Deny 목록에 public RT?✗ 없음Deny 줄 자체가 없다Allow 목록에 public RT?✓ 있음VPC · IGW · public RT · public subnet허용노드가밖으로 못 나감명찰 · eks-platform 역할private RT를public subnet로 바꿔 달아하면 안 되는 요청표지판만 읽는다private RT를안 봄로 바꿔 달아Deny 목록에 private RT?✗ 없음VPC · IGW · public RT · public subnetAllow 목록에 private RT?✓ 있음ec2:* · 모든 리소스허용NAT 가인터넷을 잃음subnet 은 2에서 가려진다 — 그래서 3의 목록에 subnet 이 있든 없든 결과는 같다
연결 요청에서 IAM 이 확인하는 것은 라우트 테이블 하나 — Deny 는 일치할 때만 이긴다
  1. 구멍 ① network 역할 — public 라우트 테이블에 붙이기 권한이 있었다. 노드 subnet 에 public 표지판을 붙이면 노드는 NAT 를 건너뛰고 IGW 로 바로 나가려 한다. 노드에는 공인 IP 가 없어 바깥으로 나가지 못한다
정상VPCprivate subnetpublic subnetprivate RT · → NATpublic RT · → IGW노드공인 IP 없음NAT공인 IP(EIP) 보유IGW인터넷대신 내보냄구멍 ① · 노드 subnet 에 public 표지판VPCprivate subnetpublic subnetpublic RT · → IGWpublic RT · → IGW노드공인 IP 없음NAT공인 IP(EIP) 보유IGW인터넷NAT 를 건너뛰어 IGW 로 — 공인 IP 가 없어 못 나감
IGW 는 문만 열어 준다 — 공인 IP 가 없는 노드는 NAT 를 거쳐야 밖으로 나간다
  1. 구멍 ② eks-platform 역할 — ec2:* 라 자기 private 라우트 테이블로 public subnet 의 연결을 바꿔 달거나 뗄 수 있었다. public subnet ARN 에 걸어 둔 거부는 대조되지 않는다. public subnet 이 IGW 길을 잃으면 그 안의 NAT 도 인터넷을 잃고, 결국 노드도 나가지 못한다
구멍 ② · public subnet 의 표지판을 private 것으로VPCprivate subnetpublic subnetprivate RT · → NATprivate RT · → NAT노드공인 IP 없음NAT공인 IP(EIP) 보유IGW인터넷NAT 가 있는 방의 표지판이 IGW 를 가리키지 않아 NAT 도 못 나감
노드는 NAT 까지 가지만, NAT 가 있는 방의 표지판이 바뀌어 NAT 도 인터넷에 닿지 못한다 (떼어 내도 같다)
  1. 조치 — 두 역할 모두 연결을 바꿀 수 없게 했다
붙이기
network 역할
권한 제거 (두 연결은 가져온 뒤 바꿀 일이 없다)
eks-platform 역할
유지. public subnet 에는 이미 연결이 있어 AWS 가 거부한다
바꿔 달기 · 떼기
network 역할
권한 제거
eks-platform 역할
대상과 상관없이 거부 (KeepSubnetAssociations)

5.2 ForAllValues — 키가 없을 때 참인 조건

요청조건 1 · ForAllValues키가 전부 목록 안?조건 2 · Null = false키가 하나라도 있나?결과수정 전 → 수정 후이번에 더한 조건CreateTags키: Project · ManagedBy✓ 참✓ 참허용→허용의도대로CreateTags키: Name✗ 거짓— 볼 필요 없음거부→거부의도대로DeleteTags키: (없음)= 태그 전부 삭제✓ 참 · 0개라서✗ 거짓허용 ⚠→거부구멍 → 막힘
"전부 목록 안" 은 키가 0개일 때도 참이다 — 그래서 "키가 꼭 있어야 한다" 를 함께 건다

태그 키 조건의 함정과 조치

  1. 막으려던 것 — database 역할에 DB 보안 그룹 규칙의 태그를 달고 떼는 권한을 줬다. 아무 태그나 건드리지 못하게, 태그 키를 Project, Environment, ManagedBy 셋으로 묶었다
  2. ForAllValues:StringEquals 의 뜻 — “요청에 들어 있는 태그 키가 전부 목록 안에 있어야 한다”
  3. 함정 — 키 0개는 참 — 키가 하나도 없으면 목록 밖의 키도 없다. 그래서 “전부 목록 안” 이 참이 된다. 손님이 0명이면 “전원 명단에 있음” 이 되는 것과 같다
  4. 0개가 위험한 이유 — EC2 DeleteTags 는 키를 적지 않으면 그 리소스의 태그를 전부 지운다. 키 없는 DeleteTags 가 조건을 통과해, 규칙의 태그를 통째로 지울 수 있었다
  5. 조치 — 키가 꼭 있어야 한다 — Null 조건을 함께 걸었다. "aws:TagKeys": "false" 는 “태그 키가 비어 있지 않아야 한다” 는 뜻이다. AWS 문서도 이 조합을 권한다
  6. 이후 — 5.4 에서 규칙 태그를 한 번 더 좁혔다. 규칙을 만들 때만 태그를 허용하고, 규칙의 DeleteTags 는 아예 뺐다. Null 조건은 그대로 남았다
"Condition": {
  "ForAllValues:StringEquals": { "aws:TagKeys": ["Project", "Environment", "ManagedBy"] },
  "Null": { "aws:TagKeys": "false" }
}

5.3 Managed Master Password — 수정 권한으로 내려놓는 비밀번호

RDS 관리형 비밀번호RDS 가 만들어 Secrets Manager(금고)에 넣고 7일마다 바꾼다 · 사람은 값을 모른다database 역할의 요청통과하면 일어나는 일수정 전 → 수정 후GetSecretValue금고 열기비밀번호 값을 읽음거부→거부원래 막혀 있음ModifyDBCluster용량 · 파라미터 수정설정만 바뀜 · 비밀번호는 그대로허용→허용정상 applyModifyDBClusterRDS 관리 끄기 + 새 비밀번호ManageMasterUserPassword = false금고가 지워지고비밀번호 = 요청자가 정한 값허용 ⚠→거부KeepManagedMasterPassword
금고를 열지 못해도 자물쇠를 바꿀 수 있었다 — "관리 끄기" 가 담긴 수정만 골라 막았다

비밀번호 우회로와 조치

  1. 관리형 비밀번호 — Aurora master 비밀번호는 RDS 가 만들어 Secrets Manager 에 넣고 7일마다 바꾼다. 사람은 값을 모른다
  2. 막아 둔 것 — database 역할은 Secret 값을 읽지 못한다
  3. 우회로 — rds:ModifyDBCluster 에는 “RDS 관리를 끄고 새 비밀번호를 지정” 하는 옵션이 있다. 이 요청이 통과하면 관리 Secret 이 지워지고, 비밀번호는 요청을 보낸 쪽이 정한 값이 된다. Secret 을 읽지 않고도 비밀번호를 손에 넣는 길이다
  4. 조치 — rds:ManageMasterUserPassword = false 인 ModifyDBCluster 만 골라 거부했다(KeepManagedMasterPassword). AWS 문서도 생성 시점에 관리형을 강제하는 것만으로는 부족하니 수정도 막으라고 한다
  5. 정상 적용은 그대로 — database root 는 이 값을 ignore_changes 로 두어 보내지 않는다. 용량이나 파라미터 같은 다른 수정은 그대로 허용된다
{
  "Sid": "KeepManagedMasterPassword", "Effect": "Deny", "Action": "rds:ModifyDBCluster", "Resource": "*",
  "Condition": { "Bool": { "rds:ManageMasterUserPassword": "false" } }
}

5.4 Security Group Rule Tags — 그룹을 물을 수 없는 리소스

태그 요청처음 조건Aurora SG 소속인가?최종 조건규칙을 만드는 요청 안인가?결과처음 → 최종ec2:CreateActionec2:SecurityGroupIDA · 새 규칙 만들며 같이Authorize + 태그 · Aurora SG✗ 확인 불가✓ 예거부 ⚠→허용처음엔 apply 가 멈췄을 것B · 있는 규칙에 따로CreateTags · 아웃바운드 규칙✗ 확인 불가✗ 아니오거부→거부→ 이 규칙은 관리에서 뺌C · 다른 방화벽에 새 규칙Authorize + 태그 · 다른 SG✗ 확인 불가규칙 만들기부터 거부거부→거부SG 권한이 Aurora 하나뿐
규칙 줄에는 소속을 물을 수 없다 — 그래서 소속 대신 "만드는 순간" 으로 묶었다

규칙 태그 조건: 소속에서 시점으로

  1. 규칙 줄도 리소스 — 보안 그룹의 규칙 한 줄 한 줄이 따로 ARN 을 가진 리소스다. Terraform 은 기본 태그(default_tags) 때문에 규칙을 만들 때 태그도 붙인다. 태그는 동작에 영향 없는 이름표지만, 붙이는 일(CreateTags)에는 권한이 필요하다
  2. 처음 쓴 조건 — 소속으로 묶기 — ec2:SecurityGroupID 로 “Aurora 보안 그룹의 규칙에만” 태그를 허용했다. 다른 방화벽 규칙의 태그는 건드리지 못하게 하려는 것이다
  3. 문제 — 소속을 물을 수 없다 — 규칙 리소스는 이 조건 키를 제공하지 않는다. 키가 없으면 StringEquals 는 거짓이라 정상 태그까지 거부되고, 첫 /apply 가 태그 단계에서 멈췄을 것이다. 5.1~5.3 과 반대로, 너무 좁아서 생긴 문제다
  4. 조치 — 시점으로 묶기 — 규칙을 만드는 요청 안에서 함께 붙는 태그만 허용했다(ec2:CreateAction 이 AuthorizeSecurityGroupIngress 또는 Egress). 규칙 만들기는 보안 그룹 권한을 따로 검사하고, 그 권한은 Aurora 보안 그룹 하나뿐이다. 그래서 태그도 자연히 Aurora 쪽 새 규칙에만 붙는다
  5. 여파 — 아웃바운드 규칙은 관리 제외 — 이미 있는 규칙에 따로 태그를 붙이는 요청은 거부된다. CloudFormation 이 만든 아웃바운드 규칙을 가져오면 첫 적용이 거기에 기본 태그를 붙이려다 막힌다. ignore_changes 로도 provider 기본 태그 차이는 숨겨지지 않았고(실제 plan 으로 확인), 기본 “모두 허용” 규칙이라 관리할 값도 없어 Terraform 관리에서 뺐다

5.5 그 밖의 지적

리뷰의 나머지 지적

  • 가져온 리소스 보호에 ec2:Update* 가 빠져 규칙 설명 수정이 통과했다(DeleteTags 는 ec2:Delete* 에 이미 포함)
  • 규칙 수정(ModifySecurityGroupRules)은 보안 그룹과 규칙 리소스를 둘 다 확인해, 규칙 리소스에도 허용이 필요했다
  • 파라미터를 지우면 provider 가 ResetDBClusterParameterGroup 을 따로 부른다
  • 문서 지적 넷: 역할 표, state 키 목록, plan 개수, 상설 참조 절

6. Guard by ARN — 태그 대신 주소

6.1 Tag and ARN — 스택 삭제 전후

GUARD / ADOPTED RESOURCESeks-platform 역할 · ec2:*① 스택 삭제 전VPCCFNARNIGWCFNARNpublic RTCFNARNsubnet aCFNARNsubnet cCFNARNDB SGCFNARN빨간 딱지 = 태그 aws:cloudformation:stack-name노란 명판 = 리소스 주소 ARN태그 규칙CloudFormation 태그가 있으면 거부✓ 막음ARN 규칙KeepAdoptedResources · 주소로 거부✓ 막음② 스택 삭제 후VPC?ARNIGW?ARNpublic RT?ARNsubnet a?ARNsubnet c?ARNDB SG?ARN빨간 딱지 = 태그 aws:cloudformation:stack-name노란 명판 = 리소스 주소 ARN태그 규칙CloudFormation 태그가 있으면 거부? 딱지가 사라지면 무력ARN 규칙KeepAdoptedResources · 주소로 거부✓ 막음스택 삭제
딱지(태그)는 스택과 함께 떨어질 수 있다 — 주소(ARN)는 리소스가 남아 있는 한 그대로다

원래 규칙: 태그 기준 거부

  • eks-platform 역할이 CloudFormation 리소스를 건드리지 못하게 한 원래 규칙은 태그 기준이었다
  • CloudFormation 이 붙이는 aws:cloudformation:stack-name 태그가 있으면 거부한다
  • aws: 로 시작하는 태그는 사람이 지울 수 없다 → 믿을 만했다

스택 삭제 뒤의 불확실성

  • 전환하면 이 스택을 지운다
  • 스택을 지운 뒤에도 리소스에 그 태그가 남는지는 확실하지 않았다
  • 태그가 사라지면 ec2:* 를 가진 eks-platform 역할이 VPC 와 subnet 을 바꿀 수 있게 된다

보완: ARN 으로 한 번 더

  • 같은 동작을 리소스 ARN 으로 한 번 더 막았다(KeepAdoptedResources)
  • 주소는 스택이 사라져도 그대로다

6.2 Inline Policy Limit — 10,240자

infra-apply.tf IAMkukie-terraform-infra-apply
1 // 이름 하나 = Statement 하나 · 각각 Effect, Action, Resource (필요하면 Condition)
2 // state 장부
3 AllowReadState… · ListStateBucket · WriteInfraState { … }
4 AllowLockInfraState · Create/ReadDeploymentClaim { … }
5 // 만드는 것 — EC2 · EKS · IAM 역할 · OIDC · S3 · Secret 껍데기
6 AllowManageNetworkAndInstances { … } // ec2:*
7 AllowManageCluster · CreateCluster { … }
8 AllowCreate/Manage/PassClusterRoles { … }
9 AllowManageLgtmBuckets · ManageClusterOIDCProvider { … }
10 AllowManageNatProfile… · …ServiceLinkedRole · ManageSecretContainers { … }
11 // 막는 것
12 DenyKeepClusterRoleBoundary · KeepSecretRecoveryWindow { … }
13 DenyNoRoleAssumption · ClusterAccessForAdminOnly · NoCreatorAdmin { … }
14−DenyKeepCloudFormationNetwork { … }← 태그 기준 · 역할에서 뺌
15+DenyKeepAdoptedResources { … }← ARN 기준 · #51
16+DenyKeepSubnetAssociations { … }← 연결 교체·해제 · #51
Statement 합계 크기 (공백 제외, 개념 비율)IAM 한도 10,240자 ┆
#51 두 줄 추가 직후
초과
태그 규칙 뺀 뒤
통과
같은 리소스를 두 번 막던 태그 규칙을 역할에서 빼서 한도 안에 맞췄다 — 바깥 경계(permissions boundary)에는 둘 다 남겼다

한도 초과: inline 정책 합계

  • 연결 거부 규칙까지 더하자 eks-platform 역할의 inline 정책이 한도를 넘었다
  • IAM 은 역할 하나의 inline 정책 합계를 공백 제외 10,240자로 제한한다

정리: 겹친 규칙 빼기

  • 정책을 다시 보니 같은 리소스를 두 번 막고 있었다
    • 태그 규칙이 막는 EC2 리소스(VPC, subnet, IGW, 라우트 테이블, DB 보안 그룹)는 ARN 규칙이 전부 덮는다
    • ARN 규칙은 동작도 하나 더 많다
  • 역할에서는 태그 규칙을 뺐다
  • 크기 여유가 있는 경계(permissions boundary)에는 둘 다 남겼다

6.3 Mutation Tests — 실패해야 할 때 실패하는 테스트

✕✕✕✕✕✕✕✕✕✕✕✕✕✕✕✕✕✕✕✕✕21 / 21 실패정책을 일부러 넓히면 테스트가 잡는다network 5database 6리뷰 반영 10
통과하는 테스트보다 실패해야 할 때 실패하는 테스트 — 변이 하나마다 빨간불을 확인했다

통과만으로 부족한 테스트

  • 정책 테스트는 Terraform 모의 테스트로 정책 JSON 을 검사한다
  • 테스트가 통과하는 것만으로는 부족하다 → 검사가 느슨하면 잘못된 정책도 통과한다
  • 고친 곳마다 정책을 일부러 되돌리거나 넓힌 변이를 만들어 테스트가 실패하는지 확인했다
network 5
변이 예
eks-platform 의 ARN 보호 제거, ec2:DeleteVpc 허용, 라우트 테이블 삭제 거부 제거
database 6
변이 예
rds:DeleteDBCluster 허용, Secret 값 읽기 추가, ARN 보호에서 DB 보안 그룹 제외
리뷰 반영 10
변이 예
연결 동작 복원, Null 조건 제거, 비밀번호 거부 조건 뒤집기, 규칙 태그에 DeleteTags 허용

결과: 변이 스물하나 모두 실패

  • 스물하나 모두 실패했다
  • 하나라도 통과했다면 그 검사는 아무것도 지키지 않는 것이다

7. Stack Deletion — 적용 기록

권한주인 바꾸기 · CloudFormation주인 바꾸기 · Terraform배포foundation역할 · 권한관리자 로컬10-16 완료CFN Retain남기기 표시 · 옛 규칙 회수관리자 터미널10-17 완료스택 삭제장부만 지움 · 리소스 남음관리자 터미널10-17 완료PR #50network가져오기 8 · 변경 5/apply10-17 완료PR #53database가져오기 5 · 규칙 3/apply10-17 완료앱 배포Secret · ArgoCD SYNCGitOps적용 완료남음CloudFormation 단계는 변경 세트를 읽은 뒤 관리자가 직접 실행했다
권한이 먼저, 주인 바꾸기가 다음, 배포는 마지막

7.1 Foundation — 명찰 먼저

foundation 적용 결과

  • #51 을 병합한 뒤 관리자 자격증명으로 foundation 을 적용했다
  • plan: 생성 4 · 변경 3 · 삭제 0 — 예상과 같았다
  • 적용 뒤 다시 돌린 plan: No changes
  • network · database 명찰이 이때 생겼다

7.2 Retain Update — 남기고 떠나라는 통보

Retain 업데이트 단계

  1. 템플릿 읽기 — 스택의 원래 템플릿을 조회해 14개 모두에 DeletionPolicy · UpdateReplacePolicy: Retain 을 붙였다. Aurora 클러스터만 원래 Snapshot 이었다. DB 보안 그룹의 템플릿 인바운드 규칙은 비웠다. 수정본에는 관리자 IP 가 남지 않는다
  2. 변경 세트 먼저 — 바로 업데이트하지 않고 변경 세트를 만들어 읽었다. 14개 모두 Modify, 교체 없음, 추가 · 삭제 없음, 속성이 바뀌는 것은 DB 보안 그룹 하나였다
  3. 실행 — 이 단계는 세션의 자동 권한 검사가 공유 리소스 변경으로 막았다. 명령을 터미널에서 직접 실행했다. UPDATE_COMPLETE 뒤 템플릿을 다시 읽어 14개 모두 Retain 인 것을 확인했다
  4. 남은 규칙 하나 — 옛 관리자 규칙 둘 중 템플릿에 있던 하나만 회수됐다. 나머지 하나는 콘솔에서 따로 추가된 규칙이라 CloudFormation 이 몰랐다. 규칙 ID 로 직접 회수해 인바운드를 0 으로 만들었다. 남겨 두면 database 적용 때 같은 IP 의 새 규칙이 중복으로 거부될 수 있었다

7.3 Stack Deletion — 장부만 지우기

스택 삭제 뒤 남은 리소스

  • 스택을 지웠다
  • 모두 남았다: VPC, subnet, IGW, 라우팅, Aurora 클러스터와 writer, subnet group, 파라미터 그룹, DB 보안 그룹
  • Aurora 는 available, 삭제 방지도 그대로였다

6.1 절 질문의 답: 태그는 남았다

  • 스택을 지운 뒤에도 aws:cloudformation:stack-name · stack-id · logical-id 태그는 리소스에 남아 있었다
  • 그래도 사람이 지울 수 없는 이 태그가 언제까지 남는지는 보장되지 않는다
  • 그래서 보호를 ARN 에 둔 판단은 그대로다

관리 공백

  • 이때부터 14개는 어느 도구도 관리하지 않는다

7.4 Network Apply — 첫 가져오기

#50 plan 댓글: 빠진 가져오기 수

  • 첫 PR plan 댓글은 생성 0 · 변경 5 · 삭제 0 이었다 → 가져오기 8 이 보이지 않았다
  • 원인: 배포 스크립트가 change.actions 만 세고 no-op 을 건너뛰었다
    • import 는 change.importing 에 따로 표시된다
  • 리뷰가 이것을 짚었다
  • 수정: 스크립트가 가져오기 수를 따로 세고 목록에 가져오기 작업을 남기도록 고쳤다
  • 다시 돌린 plan 댓글: 가져오기 8 · 생성 0 · 변경 5 · 삭제 0

network 적용 결과

  • 병합 뒤 main 의 최종 plan 도 같은 숫자였다
  • /apply 결과도 가져오기 8 · 변경 5 였다
  • S3 에 dev/network.tfstate 가 생겼다
  • VPC 에 ManagedBy = terraform 태그가 붙었다
  • 네트워크는 이제 Terraform 소유다

7.5 Database Apply — 클러스터에서 DB 로

#53 최종 plan

  • 가져오기 5 · 생성 3 · 변경 5 · 삭제 0
  • 생성 셋: 관리자 접속 규칙 둘과 클러스터 → DB 5432 규칙 하나
  • 관리자 규칙은 주소에 IP 가 들어가 댓글에서 이름이 숨겨진다

병합 전 리뷰의 추가 지적 두 가지

  1. prevent_destroy 빈자리 — 가져온 다섯 중 subnet group 과 파라미터 그룹만 빠져 있었다. 둘 다 이름이나 설명을 바꾸면 교체되는 리소스라, 교체 plan 이 apply 도중 KeepDatabase 거부로 멈추는 대신 plan 단계에서 멈추도록 같은 보호를 붙였다
  2. 엔진 버전과 자동 업그레이드 — 클러스터는 17.10 으로 적었는데 writer 는 마이너 자동 업그레이드가 켜져 있다. AWS 가 17.11 로 올리면 다음 plan 이 다운그레이드 요청이 된다. engine_version 을 ignore_changes 에 넣었다

database 적용 결과

  • /apply 결과도 가져오기 5 · 생성 3 · 변경 5 · 삭제 0 이었다
  • S3 에 dev/database.tfstate 가 생겼다
  • Aurora 는 available, 삭제 방지와 엔진 버전도 그대로였다
  • 보안 그룹 인바운드는 세 줄, 모두 ManagedBy = terraform 이다

7.6 Verification — 숫자 말고 연결

연결 확인 — 적용 숫자가 맞아도 실제로 도는지는 따로 확인했다

  • 관리자 PC → DB 5432 — 연결된다. 새 관리자 규칙과 KUKIE_ADMIN_CIDRS 의 IP 가 맞다는 뜻이다
  • 클러스터 Pod → DB 5432 — 임시 Pod 하나를 띄워 TCP 연결만 시도하고 지웠다. 연결된다. 이 한 줄이 전환을 시작한 이유였다
  • 노드 → NAT → 인터넷 — 같은 임시 Pod 가 공개 이미지를 받아 왔으니 함께 확인됐다
  • 후속 plan — imports.tf 를 지우는 정리 PR 의 plan 이 network · database 모두 생성 0 · 변경 0 · 삭제 0 이었다. 코드와 실물이 같다

전환 완료와 남은 일

  • 이제 스택에 있던 열넷은 모두 Terraform 소유다
  • 남은 것은 앱 배포다
    • member 전용 DB 계정
    • Secrets Manager 값
    • ArgoCD 의 첫 동기화

8. Discussion

리뷰가 찾은 구멍의 공통 모양

  • 리뷰가 찾은 구멍은 대부분 같은 모양이었다
  • 정책은 “이 리소스만” 이라고 적었는데
    • AWS 가 그 동작에서 그 리소스를 확인하지 않거나(라우트 테이블 연결, 규칙 태그)
    • 조건이 키가 없을 때 참이거나(ForAllValues)
    • 다른 동작이 같은 결과를 내는(ModifyDBCluster 로 비밀번호 관리 해제) 경우다
  • 이번 PR 이 남긴 것: 정책에 적은 대상과 AWS 가 실제로 평가하는 대상이 다를 수 있다

순서: 코드 리뷰와 실측 plan

  • 리뷰 지적 중 둘은 적용했다면 첫 /apply 가 권한 거부로 멈췄을 것이었다
  • 하나는 로컬 plan 을 다시 돌려서야 보였다(아웃바운드 규칙 태그)
  • 코드 리뷰와 실측 plan 이 서로 다른 것을 잡았다

핵심 메시지

두 root
바뀌는 빈도와 위험이 달라 network 와 database 로 나눴다
지우지 못하는 역할
삭제와 비밀번호 관리 해제는 역할에서 거부
평가 대상
정책에 적은 리소스와 AWS 가 확인하는 리소스가 다를 수 있다
ARN 보호
스택이 사라져도 남는 주소로 막는다
변이 테스트
정책을 일부러 넓혀 테스트가 실패하는지 확인한다

도구가 먼저 생긴 순서대로 두 개가 남아 있었고, 클러스터가 DB 에 붙어야 할 때 처음 맞닿았다. 옮기는 역할은 지우지 못하게, 기존 역할은 옮긴 것을 건드리지 못하게 만들었다. 스택은 사라지고 리소스와 태그는 남았다 — 장부를 옮겨 적은 뒤 클러스터에서 DB 까지 연결로 확인했다.


9. References

ForAllValues 의 동작은 IAM 사용 설명서의 다중 값 조건 키 문서에 있다.
"It also returns true if there are no context keys in the request."
[해석] "요청에 컨텍스트 키가 없을 때도 참을 돌려준다." — 5.2 에서 키 없는 DeleteTags 가 조건을 통과한 근거다. 같은 문서가 Allow 와 함께 쓸 때 Null 조건을 넣으라고 권한다.
→ AWS IAM — Single-valued vs. multivalued context keys
RDS 관리 비밀번호를 강제하는 정책 예시 아래의 주의 문단이다.
"However, you can still disable Secrets Manager integration and manually set a master password by modifying the instance."
[해석] "그래도 수정으로 Secrets Manager 연동을 끄고 master 비밀번호를 직접 정할 수 있다." — 5.3 에서 ModifyDBCluster 를 조건부로 거부한 근거다.
→ Amazon RDS — Password management with Secrets Manager
역할 inline 정책 크기 한도는 IAM 할당량 문서의 문자 수 제한 표에 있다.
"Role policy size can't exceed 10,240 characters."
[해석] "역할 정책 크기는 10,240자를 넘을 수 없다." — 6.2 에서 태그 규칙을 역할에서 뺀 근거다. 같은 표에 공백은 세지 않는다고 적혀 있다.
→ AWS IAM — IAM and AWS STS quotas
이미 있는 리소스를 Terraform 으로 가져오는 방법은 import 문서에 있다.
"requires an import block that specifies the unique infrastructure resource ID to import"
[해석] "가져올 인프라 리소스의 고유 ID 를 지정한 import 블록이 필요하다." — 3.2 의 가져오기 방식이다.
→ Terraform — Import