terraform plan은 성공했는데 apply가 실패할 때: 원인과 부분 적용 복구
terraform plan은 깨끗하게 통과했는데 apply가 중간에 죽는 상황은 테라폼 운영에서 가장 당황스러운 순간입니다. 결론을 먼저 적으면, plan은 시뮬레이션이고 apply는 실제 API 호출이라 검증 범위 자체가 다르며, 실패 후에 가장 먼저 할 일은 에러 수정이 아니라 “어디까지 만들어졌는가"의 확인입니다. apply는 트랜잭션이 아니어서 실패 시점까지 만든 리소스는 그대로 남고, 그 상태를 잘못 만지면 고아 리소스와 state 불일치가 생깁니다. plan이 변경을 계산하는 원리는 기초 #5 state의 정체에서 다뤘습니다.
plan이 못 잡는 것: 검증 범위의 비대칭 #
plan이 검증하는 것은 구성 문법, 타입, 리소스 간 참조, 그리고 현재 state와의 차이입니다. provider가 로컬에서 아는 범위의 스키마 검증도 포함됩니다. 반면 AWS 쪽에서만 판정할 수 있는 것들은 apply에서 실제 API를 부를 때 처음 검증됩니다. 이 틈에서 나오는 전형 원인이 다섯 가지입니다.
| 유형 | 전형 에러 | plan이 못 잡는 이유 |
|---|---|---|
| 권한 부족 | AccessDenied, UnauthorizedOperation | plan은 읽기 권한만 쓰므로 쓰기 권한 구멍이 안 드러남 |
| IAM 전파 지연 | InvalidParameterValue (role not found 류) | 방금 만든 역할·정책이 다른 서비스에 아직 전파 전 |
| 서비스 쿼터 | LimitExceeded, AddressLimitExceeded | 한도는 API가 생성 시점에 판정 |
| 전역 이름 충돌 | BucketAlreadyExists, already in use | 다른 계정까지 포함한 유일성은 생성 시점에만 확인 가능 |
| 파라미터 조합 | Unsupported, InvalidParameterCombination | 특정 AZ의 인스턴스 타입 미지원 같은 서버 측 규칙 |
첫 번째 유형이 실무에서 가장 흔합니다. CI에서 plan 잡은 읽기 전용 역할, apply 잡은 쓰기 역할로 분리하는 구성이 표준인데, apply 역할에 새 리소스 타입의 권한을 추가하는 것을 잊으면 정확히 이 패턴이 됩니다. 두 번째 유형은 IAM 역할을 만들고 같은 apply 안에서 바로 참조할 때 나옵니다. provider가 대부분 재시도로 흡수하지만, 전파가 유난히 느리면 실패로 떨어집니다. 이 경우는 그냥 재실행이 정답입니다.
실패 직후: 상태 파악이 먼저입니다 #
apply가 리소스 10개 중 6번째에서 죽었다면, 1〜5번은 이미 만들어져 있고 state에도 기록되어 있습니다. 테라폼은 리소스 하나의 생성이 성공할 때마다 state를 갱신하므로, 대부분의 실패는 “성공한 것은 state에 있고, 실패한 것부터 없는” 깔끔한 상태로 남습니다. 이 경우 복구는 간단합니다.
- 에러 메시지로 원인을 수정합니다: 권한 추가, 쿼터 증설 요청, 이름 변경 등 원인 유형에 맞는 조치를 합니다.
- plan을 다시 실행합니다: 이미 만들어진 리소스는 변경 없음으로 나오고, 실패했던 것과 그 뒤 리소스만 생성 대상으로 나옵니다. 이 plan이 예상과 같은지 확인합니다.
- apply를 다시 실행합니다: 남은 것만 만들어지며 원래 목표 상태에 도달합니다. 처음부터 다시 만들 필요도, 만들어진 것을 지울 필요도 없습니다.
주의할 것은 이 사이에 다른 조치를 하고 싶은 유혹입니다. 실패했다고 콘솔에서 리소스를 손으로 만들어 채우면 다음 apply에서 이름 충돌이 나고, 만들어진 리소스를 콘솔에서 지우면 state와 실물이 어긋나 refresh 때 혼란이 커집니다. 복구의 기본은 원인 수정 후 재실행이고, 콘솔 개입은 마지막 수단입니다.
“이미 존재합니다” 에러: state에 없는데 실물이 있는 경우 #
재실행했는데 BucketAlreadyExists나 already exists 류의 에러가 나면 더 성가신 케이스입니다. API 호출은 성공해서 실물이 만들어졌는데, 응답을 받기 전에 프로세스가 죽는 등의 이유로 state 기록만 누락된 상태입니다. 네트워크 단절이나 CI 잡 강제 종료 직후에 나옵니다.
선택지는 두 가지입니다. 실물을 코드로 편입하려면 import 블록으로 state에 붙입니다. 실물이 불완전하게 만들어졌다고 판단되면 콘솔이나 CLI로 지우고 apply로 다시 만듭니다. 어느 쪽이든 “state와 실물을 다시 일치시킨다"가 목표이고, 판단 기준은 그 리소스가 생성 후 데이터를 가졌는가입니다. 빈 리소스면 지우고 재생성이 단순하고, 데이터가 이미 들어갔으면 import가 안전합니다. state 수술 도구 전반은 운영 #2 drift와 state 수술에서 다뤘습니다.
-target으로 특정 리소스만 골라 적용하는 방법은 이 국면에서 자주 권해지지만, 부분 적용 상태를 하나 더 만드는 도구이기도 합니다. 의존성 일부만 적용된 스택은 다음 사람이 이해하기 어려우므로, 전체 plan이 깨끗해지는 방향으로 복구하고 -target은 격리가 꼭 필요한 예외에만 씁니다.
재발 방지 #
- apply 역할의 권한을 릴리스 절차에 넣습니다: 새 리소스 타입을 도입하는 PR은 CI apply 역할의 정책 갱신을 함께 검토합니다. plan 통과가 권한 검증이 아니라는 사실을 팀 공통 인식으로 만듭니다.
- 쿼터를 미리 봅니다: VPC, EIP, EC2 vCPU처럼 걸리기 쉬운 한도는 Service Quotas 콘솔에서 확인하고, 대량 생성 전에 증설을 신청해 둡니다.
- 전역 유일 이름에는 접미사를 붙입니다: S3 버킷 등은 계정 ID나 랜덤 접미사를 관례로 넣어 충돌 자체를 없앱니다.
- CI 잡의 강제 종료를 줄입니다: 타임아웃을 apply 소요보다 넉넉히 잡고, 잡 취소 대신 완주 후 되돌리는 운영이 state 누락 사고를 줄입니다.
정리 #
- plan은 스키마·참조·state 차이만 검증하고, 권한·쿼터·이름 유일성·파라미터 조합은 apply의 실제 API 호출에서 처음 판정됩니다.
- apply는 트랜잭션이 아니지만, 성공한 리소스는 state에 기록되므로 대부분의 실패는 원인 수정 후 재실행만으로 복구됩니다.
- 실패 직후 콘솔에서 손대는 것이 사태를 키웁니다. 재실행 plan이 예상대로인지 확인하는 것이 복구의 중심입니다.
- already exists 에러는 state 기록 누락 신호입니다. 데이터가 있으면 import, 빈 리소스면 삭제 후 재생성으로 state와 실물을 다시 맞춥니다.
- plan 통과를 권한 검증으로 오해하지 않는 것, 쿼터·이름 관례를 미리 정리하는 것이 이 사고의 대부분을 예방합니다.