1. 개요
컨테이너 레지스트리는 안 치우면 무한히 쌓인다. 그래서 정리 워크플로를 넣는다. 그 정리 워크플로가 배포 중인 이미지를 지웠다. ArgoCD 가 NotFound 를 내고, Recreate 전략에 replicas 1 이라 서비스가 통째로 내려갔다. revert 하려니 이전 태그도 지워져 있었다. 롤백 경로가 없었다. 이 글은 그 원인과, 레지스트리 주변에서 밟은 함정 셋이다.
2. 핵심 내용
2-1. min-versions-to-keep 은 untagged 매니페스트도 센다
GitHub Packages 정리 액션의 min-versions-to-keep: 3 을 “최근 태그 3개는 남긴다” 로 읽었다. 틀렸다. 이 숫자는 버전 객체 를 센다. 버전 객체에는 태그 없는 매니페스트도 들어간다.
docker buildx 로 멀티 플랫폼 이미지를 밀면 태그 하나에 매니페스트가 여럿 생긴다. 매니페스트 리스트 하나 + 플랫폼별 이미지 매니페스트 + attestation. 태그는 리스트에만 붙고 나머지는 untagged 다. 태그 하나 = 버전 객체 3~4개.
그러니 min-versions-to-keep: 3 은 실효 태그 1개 다. 정리가 돌면 가장 최근 태그 하나만 남고 배포 중이던 직전 태그가 지워진다. 그 다음 정리에서 배포 중인 것도 위험하다.
# 이렇게 바꿨다
with:
keep-n-tagged: 30 # 태그 붙은 것 30개
delete-untagged: true # 태그 없는 것은 정리
delete-tags: "sha-*" # PR 빌드 태그는 별도 규칙으로태그 기준으로 세는 옵션을 쓰고, untagged 는 따로 정리한다. 그리고 보존 개수는 “롤백 세대 몇 개가 필요한가” 로 정한다. 하루 배포 횟수 × 며칠.
2-2. NotFound 와 unauthorized 는 다른 문제다
ArgoCD 가 이미지를 못 받을 때 두 에러가 비슷하게 보인다. unauthorized 는 pull secret 문제다. 토큰 만료, 권한 부족. NotFound 는 태그가 없다. 정리됐거나 빌드가 안 됐다. 둘을 섞어서 pull secret 을 재발급하며 시간을 쓴 적이 있다. 에러 문자열을 먼저 읽는다.
2-3. PR 빌드가 레지스트리의 절반을 채웠다
태그 목록을 세어 보니 98개 중 46개가 sha-* 였다. PR 빌드 이미지다. 아무 데도 배포되지 않는다. 그런데 keep-n-tagged: 30 은 태그 종류를 구분하지 않으니, 이 46개가 롤백 세대 슬롯을 먹는다. 실효 prod 세대는 30 이 아니라 14 였다.
원인은 워크플로의 push: true 가 조건 없이 걸려 있던 것. on.push 는 master 만이라 나머지 빌드는 전부 pull_request 인데, push 가 항상 true.
- uses: docker/build-push-action@<sha>
with:
push: ${{ github.event_name != 'pull_request' }}한 줄이다. PR 에서는 빌드만 하고 push 하지 않는다. 검증은 PR 자체가 pull_request 이벤트라 CI 결과가 곧 동작 확인이다.
더 큰 문제가 뒤에 있었다. 백엔드 이미지는 빌드 직전에 운영 설정 파일을 시크릿에서 받아 이미지에 굽는다. PR 빌드도 같은 시크릿을 썼다. 운영 자격증명이 든 이미지가 PR 마다 레지스트리에 올라가고 있었다. 프론트는 PR 용 시크릿이 등록돼 있지 않아 빈 .env 로 굽고 있었다. 없는 시크릿은 빈 문자열이다. 에러가 아니다.
이미 나간 이미지의 값은 삭제로 무효화되지 않는다. pull 한 사람이 있을 수 있다. 조치는 회전 이다.
2-4. 연속 머지가 가운데 빌드를 버린다
같은 저장소에 PR 을 연달아 머지하면 main 빌드가 연달아 시작된다. 워크플로에 concurrency 그룹이 브랜치 단위로 잡혀 있으면, 큐에서 대기 중인 실행이 새 실행에 밀려 취소된다. 첫 빌드는 이미 돌고 있어 살고, 셋째 빌드가 시작되면서 둘째가 취소된다.
둘째 커밋의 이미지는 영영 없다. PR 은 초록 그대로다. 그 커밋이 feat 이면 릴리스는 그 버전 이미지 없이 진행된다.
concurrency:
# push 는 커밋마다 그룹을 나눠 취소되지 않게
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}PR 은 같은 브랜치의 옛 실행을 취소해도 된다(새 커밋이 옛 커밋을 대체한다). main push 는 커밋마다 이미지가 필요하니 그룹을 커밋 SHA 로 나눠 취소되지 않게 한다.
그래도 셀프호스트 러너가 운영 노드와 디스크를 같이 쓰면 연속 빌드 자체가 운영에 부하다. 실제로 운영 DB 디스크가 읽기 전용으로 굳은 적이 있다. 운영 규칙으로 “같은 저장소의 다음 머지는 앞 main 빌드가 끝난 뒤” 를 뒀다.
2-5. 커밋 해시 = 이미지가 아니다
paths-ignore: ["**.md"] 로 문서만 고친 커밋은 빌드가 없다. 그 커밋의 prod-<sha7> 태그는 존재하지 않는다. 배포 워크플로에 git log 에서 고른 SHA 를 넣으면 NotFound.
배포할 태그는 git log 가 아니라 레지스트리에서 고른다. 또는 배포 워크플로가 push 전에 manifest 존재를 확인한다(fail-closed 태그 치환).
3. 마무리
요약
min-versions-to-keep은 untagged 매니페스트도 센다. 3 이면 실효 태그 1개. 태그 기준 옵션을 쓴다.NotFound는 태그 부재,unauthorized는 pull secret. 다른 문제.- PR 빌드는 push 하지 않는다. 한 줄. 이미 나간 자격증명은 회전.
- main push 의 concurrency 그룹은 커밋마다. 취소되면 그 커밋 이미지가 없다.
- 커밋 해시 = 이미지가 아니다. 태그는 레지스트리에서 고른다.