Docker 레지스트리 정리로 비용 절감하기

우리 팀은 MFE 구조라 서비스마다 도커 이미지를 빌드해서 레지스트리에 푸시한다. 그런데 푸시만 했을 뿐 쌓인 이미지를 따로 정리하지는 않았다. 그렇게 계속 쌓이기만 하다 보니 어느새 스토리지가 570GB까지 차 있었다.

처음 든 의문은 단순했다. "이거 그냥 둬도 괜찮나?" 그리고 자동 빌드(CI)를 붙이면 더 빨리 쌓일 텐데, 그 전에 정리부터 해야 하나? 정리한다면 어떻게? 답을 찾는 과정에서 레지스트리가 어떻게 동작하는지 처음으로 제대로 들여다보게 됐다.

레지스트리는 스스로 지우지 않는다

1.0.0-dev 같은 태그로 두 번 푸시하면 이렇게 된다.

  1. 첫 푸시 → 이미지 A 생성, 1.0.0-dev 태그를 A에 붙임
  2. 두 번째 푸시 → 이미지 B 생성, 태그 1.0.0-dev를 A에서 떼서 B로 옮김
  3. 결과: B는 1.0.0-dev 태그 보유, A는 태그 없는 이미지(untagged)로 그대로 남음

태그 포인터는 즉시 최신으로 이동한다. 그래서 "항상 최신 걸 갖다 쓴다"는 정상 동작이다. 문제는 이름표만 떨어진 옛날 이미지 A가 사라지지 않고 창고에 계속 쌓인다는 점이다. 레지스트리는 콘텐츠 주소 기반 불변 저장소라, 푸시 시점에 옛것을 즉시 삭제하지 않는다.

용어부터 정리: 아티팩트 / digest / 태그

용어 정체 비유
아티팩트(=이미지) 실제 내용물(레이어+설정). 스토리지를 차지하는 알맹이 짐이 든 박스
digest (sha256:...) 알맹이의 고유 지문. 내용이 바뀌면 지문도 바뀜 (불변) 박스 고유번호
태그 (1.0.0, dev-abc) 알맹이를 가리키는 이름표. 떼서 다른 알맹이에 옮길 수 있음 (가변) 박스에 붙인 이름표 스티커

핵심은 아티팩트 ≠ 태그라는 것. 한 아티팩트에 태그가 여러 개일 수도, 0개(untagged)일 수도 있다. 용량을 먹는 건 박스(아티팩트)지 이름표(태그)가 아니다. 그래서 "untagged artifact 삭제"는 "이름표 떨어진 옛 이미지 알맹이를 지운다"는 뜻이었다.

두 개의 메커니즘: 보존 정책과 GC

레지스트리를 정리하는 장치는 두 개다. 처음엔 같은 거라고 생각했는데, 하는 일이 완전히 다르다.

Tag Retention Policy Garbage Collection (GC)
하는 일 어떤 태그를 남길지 결정 (예: 최근 3개) untagged / 참조 안 되는 이미지의 실제 디스크를 회수
효과 규칙 밖 태그를 떼어냄(untag) 디스크 용량을 실제로 비움
단독으로 쓰면 용량은 안 줄어듦 (태그만 떨어짐) 옛 버전 태그 관리는 안 됨

둘은 같이 걸어야 한다. Retention만 걸면 디스크는 그대로고, GC만 걸면 옛 버전 태그가 계속 쌓인다.

그래서 운영 방식에 따라 조합이 갈린다.

  • 버전별로 최근 N개만 유지하고 싶다 → 릴리스마다 버전을 올려 서로 다른 태그를 만들고 → Tag Retention(최근 3개) + GC
  • 같은 태그를 덮어쓰며 최신만 두고 싶다 → 태그 전략은 그대로 두고 → GC만 스케줄(untagged 삭제 ON)

보통 dev는 후자로 가볍게, 릴리스용은 전자로 가는 혼합이 깔끔하다. 한 가지 함정은, 버전을 안 올리고 1.0.0-dev 하나만 계속 덮어쓰면 "최근 3개 유지"가 의미 없다는 것이다. 태그 붙은 건 늘 1개뿐이라 솎을 게 없다. Retention은 태그 붙은 것만 세고, 같은 태그 반복 푸시로 생긴 untagged 잔해는 GC가 치우는 영역이다.

ECR은 이걸 하나로 합쳐놨다

우리 레지스트리는 AWS ECR이었다. ECR은 Harbor처럼 "Retention + GC 두 개 따로"가 아니라 Lifecycle Policy 하나로 합쳐서 자동 회수까지 한다. 별도 GC 스케줄이 없다. 규칙에 걸리면 ECR이 알아서 지우고 용량을 비운다.

위에서 정리한 두 시나리오가 그대로 규칙 두 개로 들어간다.

{
  "rules": [
    {
      "rulePriority": 1,
      "description": "untagged 잔해(같은 태그 덮어쓰기로 생긴 것) 1일 후 삭제",
      "selection": {
        "tagStatus": "untagged",
        "countType": "sinceImagePushed",
        "countUnit": "days",
        "countNumber": 1
      },
      "action": { "type": "expire" }
    },
    {
      "rulePriority": 2,
      "description": "dev 태그는 최근 N개만 유지",
      "selection": {
        "tagStatus": "tagged",
        "tagPrefixList": ["dev"],
        "countType": "imageCountMoreThan",
        "countNumber": 10
      },
      "action": { "type": "expire" }
    }
  ]
}
  • rule 1 → 같은 태그 반복 푸시로 생긴 untagged 잔해를 청소. Harbor의 "GC + delete untagged"에 해당.
  • rule 2 → 최근 N개만 유지. tagPrefixList로 dev만 솎고 release/prod 태그는 안 건드리게 분리할 수 있다.

ECR은 기본도 리포지토리 단위라 "전역 한 방"은 없다. 다만 (a) GC라는 두 번째 축이 아예 없고, (b) Repository Creation Template로 앞으로 생길 repo는 정책을 자동 상속시킬 수 있고, (c) IaC면 for_each로 일괄 적용된다. 운영상 손은 확실히 덜 간다.

그래서 비용은 얼마나 아끼나

솔직히 김 빠지는 부분이다. ECR private 스토리지는 $0.10/GB-월. 570GB 기준으로 계산해봤다. (환율 $1 = ₩1,400, 전송비는 같은 리전 pull이라 무시)

구분 용량 월 비용 연 비용
현재 570GB ~$57 (₩8만) ~$684 (₩96만)
70% 정리 후 171GB ~$17 (₩2.4만) ~$205 (₩29만)
절감액 399GB ~$40 (₩5.6만) ~$479 (₩67만)

연 ₩67만. 회사 인프라 전체에서 보면 큰 라인은 아니다. 비용 절감만 명분으로 들면 "그 정도면 그냥 둬도..." 소리 듣기 딱 좋은 금액이다.

그런데 정리하면서 깨달은 건, 진짜 이유가 금액이 아니라는 점이었다.

  1. 문제는 스냅샷이 아니라 증가율이다. 정책이 없으면 570GB는 1년 뒤 1TB가 된다. 정책은 한 번 걸면 끝인데 효과는 계속 누적된다. 절감액은 "지금 570GB"가 아니라 "안 막았을 때의 미래 증가분"으로 봐야 한다.
  2. 저장 백엔드가 볼륨이면 디스크 풀 = 배포 장애다. S3/ECR은 무한 확장이라 돈만 더 내면 되지만, EBS 같은 볼륨이면 꽉 차는 순간 푸시가 실패한다. 그럼 배포 파이프라인이 멈춘다. 이건 비용이 아니라 운영 사고라, 절감액과 비교할 성질이 아니다.
  3. 설정 비용이 거의 0이다. Lifecycle Policy JSON 한 번 등록이 끝. 월 절감이 ₩2만이어도 들이는 노력 대비 손해 볼 게 없다.

한 가지 주의. dedup 때문에 위 절감액은 상한 추정이다. 이미지를 지워도 잔존 이미지가 같이 쓰는 레이어는 안 지워진다. 공유 레이어가 많으면 실제 회수량은 이보다 적다. 정확한 회수 가능량은 정책을 넉넉한 보존 기간으로 먼저 걸어보고 Cost Explorer 추이를 1~2주 보면 확정된다.

비용은 ECR 콘솔이 아니라 Billing의 Cost Explorer에서 본다. Service를 EC2 Container Registry로 필터하고 Usage Type으로 group by 하면 TimedStorage-ByteHrs 항목이 스토리지 비용이다. 570GB가 돈으로 환산된 자리가 거기다.

정리하며

레지스트리는 푸시할 때 옛 이미지를 자동으로 지우지 않는다. 정리하려면 정책을 따로 걸어야 한다. Harbor는 보존 정책과 GC를 같이, ECR은 Lifecycle Policy 하나로 처리한다.

비용보다는 용량이 끝없이 늘고 디스크가 꽉 차 배포가 멈추는 걸 막는 게 실제 이유였다. 설정은 30분이면 된다.

참고