Why NextWhy Next
목록으로
💻 개발5분 읽기

한 버킷에 주인이 둘이면, 마지막으로 apply한 쪽이 이긴다

S3 lifecycle 규칙 하나를 추가했더니 terraform apply가 실패했다. 원인은 같은 버킷의 lifecycle 설정을 두 리소스가 각자 소유한 구조. S3 lifecycle은 규칙 단위가 아니라 문서 단위라, 마지막 승자가 상대 규칙을 조용히 지운다.

핵심 요약

presign만 되고 commit 안 된 업로드 객체를 회수하려고 tmp/ prefix와 lifecycle 만료 규칙을 설계했다. 규칙을 머지하자 staging apply가 실패했는데, 원인은 모듈과 환경 설정이 같은 물리 버킷의 lifecycle을 각자 소유한 것. S3의 lifecycle API는 문서 단위 전체 교체라 두 소유자는 apply마다 서로의 규칙을 덮어쓴다. 모듈 쪽 리소스를 removed 블록(destroy=false)으로 state에서만 잊게 해 단일 소유자로 만들었다. 문서 단위 리소스에는 소유자가 하나여야 한다.

목차

시작은 평범한 청소 문제였다. 사용자가 미디어 파일(녹음·이미지)을 올리는 경로는 presigned URL 방식이다. 서버가 업로드용 URL을 발급하고, 클라이언트가 S3에 직접 올린 뒤, commit API를 호출해 "이 키를 정식 자산으로 등록해 달라"고 알린다. 문제는 presign만 받고 commit을 안 하는 경우다. 앱이 중간에 죽거나, 네트워크가 끊기거나, 사용자가 화면을 떠나면 버킷에는 어디에도 등록되지 않은 객체가 남는다.

이걸 lifecycle 규칙으로 청소하고 싶었는데 걸 수가 없었다. 커밋된 객체와 미커밋 객체가 같은 prefix에 섞여 있어서, "오래된 것을 지워라"는 규칙이 정식 자산까지 지우기 때문이다. 일일 업로드 쿼터가 증식을 막아주고는 있었지만, 회수 경로가 없다는 사실은 그대로였다.

설계: 커밋되지 않은 것은 tmp에 산다

해법의 뼈대는 키 네임스페이스 분리다.

  1. presign이 tmp/ prefix로 임시 키를 발급한다.
  2. commit이 검증(HEAD로 존재 확인, Content-Type, 크기 상한)을 통과하면 CopyObject로 정식 키에 승격하고 tmp 원본을 지운다.
  3. commit이 끝내 오지 않은 객체는 tmp/에 남고, lifecycle 규칙이 7일 뒤 만료시킨다.

이러면 lifecycle은 tmp/만 보면 된다. 정식 자산은 처음부터 규칙의 사정거리 밖이다. 클라이언트는 바꿀 필요가 없었다. 업로드 플로우 세 개를 전부 읽어 확인했는데, 모두 commit 응답에 담긴 키를 후속 호출에 쓰고 있어서 서버가 키 모양을 바꿔도 모른다. 배포 시점에 날아와 있던 구 형식 키의 commit도 기존 경로로 그대로 처리된다.

여기서 함정 하나. 이 버킷은 버저닝이 켜져 있다. 버저닝된 버킷에서 expiration은 객체를 지우는 게 아니라 삭제 마커를 얹을 뿐이고, 원래 바이트는 noncurrent 버전으로 남는다. noncurrent_version_expiration(1일)을 쌍으로 걸지 않으면 청소를 해도 용량이 한 바이트도 줄지 않는다.

설계 리뷰도 값을 했다. 승격 로직에 리뷰를 붙였더니 확정 결함 세 개가 나왔다. 관리자용 커스텀 키 경로로 tmp/ 키를 최종 키로 commit하면 lifecycle이 라이브 자산을 지워버리는 구멍(공유 commit 경계에서 tmp 최종 키를 차단), 승격의 sourceKey가 임의 복사 프리미티브가 되는 문제(tmp와 최종 키의 등식을 강제), 같은 tmp 키를 동시에 commit하면 한쪽의 copy가 404로 죽는 멱등성 문제. 셋 다 코드로 고치고 넘어갔다.

그리고 apply가 실패했다

lifecycle 규칙을 terraform으로 추가하고 머지했다. staging apply가 실패했다.

에러는 lifecycle 설정 쓰기 타임아웃이었는데, 파고 보니 타임아웃은 증상이고 구조가 문제였다. 이 버킷의 lifecycle을 terraform 리소스 두 개가 각자 소유하고 있었다. 하나는 이미지 리사이즈 모듈 안에 있었고(모듈이 자기 용도의 규칙을 직접 걸었다), 다른 하나는 환경별 버킷 설정 파일에 있었다(이번에 tmp 규칙을 추가한 곳). 같은 물리 버킷, 소유자 둘.

S3의 lifecycle API에는 "규칙 하나 추가"가 없다. PutBucketLifecycleConfiguration은 문서 전체를 교체한다. terraform의 aws_s3_bucket_lifecycle_configuration도 그 위에 서 있어서, 이 리소스는 자기가 아는 규칙들로 버킷의 lifecycle 문서 전체를 덮어쓴다. 소유자가 둘이면 각자 자기 규칙만 담긴 문서를 서로 밀어 넣는다. 한 apply 안에서 둘이 만나면 동시 쓰기로 타임아웃이 나고, 따로 만나면 더 나쁘다. 에러 없이 성공하면서 마지막 승자가 상대의 규칙을 지운다.

이게 이 사건에서 제일 서늘한 부분이다. 타임아웃은 오히려 요란한 실패라 다행이었다. 이 구조가 계속 살아 있었다면, 다음에 이미지 리사이즈 모듈을 건드리는 아무 apply가 방금 넣은 tmp 만료 규칙을 조용히 지웠을 것이다. plan에는 그 버킷 리소스의 in-place update 한 줄로만 보였을 테고, 미커밋 객체는 다시 영원히 쌓이기 시작했을 것이다. 청소 규칙이 사라진 걸 알아차리는 모니터링은 어디에도 없었다.

사실 이 지점은 코드 리뷰 단계에서 "두 리소스가 같은 버킷을 만지는 것 같다"는 의심으로 한 번 지적됐었다. 그때는 잠재 위험으로 분류했는데, 머지 직후 apply가 실패하면서 잠재가 현실이 되는 데 몇 분이 안 걸렸다.

해법: 소유자를 하나로

수정 방향은 명확했다. 버킷당 lifecycle 소유자는 하나여야 한다. 모듈에서 lifecycle 리소스를 제거하고, 환경별 설정 파일을 단일 소유자로 만들어 모듈이 걸던 규칙까지 그쪽으로 옮겼다.

이때 쓴 도구가 terraform의 removed 블록이다. 모듈에서 리소스 선언을 그냥 지우면 terraform은 실물 lifecycle 설정을 destroy하려 든다. 지금 라이브로 돌고 있는 규칙을 삭제했다가 다시 만드는 창이 생기는 것이다. removed 블록에 destroy = false를 주면 terraform은 그 리소스를 state에서만 잊는다. 실물은 그대로 두고 장부에서만 지우니, 소유권이 환경 설정 쪽으로 무중단 이전된다.

수정 후 staging apply가 통과했고, prod까지 반영한 뒤 AWS API로 실제 버킷 상태를 직접 확인했다. 두 버킷 모두 tmp 만료 규칙이 살아 있고, 기존 규칙 세 개도 온전했다. terraform plan만 보고 끝내지 않은 이유는 이 사건 자체가 가르쳐준 것이다. plan은 각 소유자의 장부일 뿐이고, 문서를 덮어쓰는 구조에서는 장부 두 개가 서로를 모른다.

남긴 것

문서 단위 리소스에는 소유자가 하나여야 한다. S3 lifecycle만이 아니다. 버킷 정책, CORS 설정, 알림 설정처럼 "전체 교체" API 위에 선 terraform 리소스는 전부 같은 성질이 있다. 같은 대상을 가리키는 선언이 모듈과 환경 설정에 하나씩 있으면, 컴파일 에러 대신 러닝타임의 조용한 덮어쓰기를 얻는다. 새 규칙을 추가하기 전에 그 문서의 소유자가 이미 있는지부터 찾아야 한다.

또 하나. 리소스 선언을 지우는 것과 실물을 지우는 것은 다른 일이고, terraform은 그 구분을 removed 블록으로 표현한다. 소유권 이전이 목적일 때 destroy를 거치는 순간 무중단이 깨진다.

마지막으로, 회수 경로는 키 설계에서 시작한다. "커밋된 것과 안 된 것"처럼 수명이 다른 데이터가 같은 네임스페이스에 살면, 어떤 청소 규칙도 안전하게 걸 수 없다. 수명이 다르면 사는 곳을 먼저 나눠야 한다. 참고로 이 방식은 과거에 이미 쌓인 미커밋 객체까지 소급 회수하지는 못한다. 그 객체들은 정식 자산과 구분할 방법이 없어서(그게 이 문제의 출발점이었다) 신규 업로드부터 적용하는 범위를 의도적으로 택했다.

자주 묻는 질문

S3 lifecycle 규칙이 terraform apply 후에 사라져요. 왜 그런가요?

aws_s3_bucket_lifecycle_configuration은 버킷의 lifecycle 설정 문서 전체를 소유하는 리소스입니다. 같은 버킷을 가리키는 이 리소스가 두 군데(예: 모듈과 환경 설정) 있으면, 각자 자기가 아는 규칙만으로 전체 문서를 교체하므로 마지막으로 apply된 쪽이 상대의 규칙을 지웁니다. 같은 apply 안에 둘 다 있으면 동시 쓰기로 타임아웃이 나기도 합니다. 버킷당 소유 리소스를 하나로 통합하세요.

버저닝된 S3 버킷에서 lifecycle 만료를 걸었는데 용량이 안 줄어요.

버저닝된 버킷에서 expiration은 객체를 지우는 게 아니라 삭제 마커를 얹고 기존 버전을 noncurrent로 만듭니다. noncurrent_version_expiration을 함께 걸지 않으면 바이트는 영원히 회수되지 않습니다. 만료 규칙과 noncurrent 버전 만료를 한 쌍으로 설계해야 합니다.

관련 글

댓글 0