도커 실전 강좌 #5 레지스트리 푸시와 태그 전략 — :latest의 함정
#4가 빌드까지였다면, 이번 글은 푸시 후 단계입니다. 어디로 푸시할지, 어떤 태그를 달지, 오래된 이미지를 어떻게 정리할지를 다룹니다.
도커 실전 강좌에서 이번 글의 위치:
- #1 FastAPI 컨테이너화
- #2 Django + PostgreSQL compose
- #3 React/Next.js 빌드 컨테이너
- #4 CI에서 이미지 빌드
- #5 레지스트리 푸시와 태그 전략 — :latest의 함정 ← 이번 글
- #6 클라우드 배포 — Fly.io / Railway / ECS
태그 한 글자가 운영을 흔드는 일이 많습니다. 글자만 보면 사소해 보이지만, 운영 직전에 한 번 정리해 두면 두고두고 편합니다.
레지스트리 — 어디에 둘 것인가 #
선택지는 크게 다섯입니다.
| 레지스트리 | 주요 용도 | 무료 한도 | 인증 |
|---|---|---|---|
| Docker Hub | 가장 오래된, public의 표준 | private 1개, pull 한도 있음 | 계정 + PAT |
| GHCR (GitHub Container Registry) | GitHub 저장소와 자연스럽게 묶임 | 거의 무제한 (public/private) | GITHUB_TOKEN |
| AWS ECR | AWS 인프라 안에 두는 게 깔끔할 때 | 500MB 무료 / 이후 GB당 과금 | IAM |
| Google Artifact Registry | GCP 측 표준 | 0.5GB 무료 / 이후 과금 | gcloud / WIF |
| 사설 레지스트리 | regulated 환경 | — | 자체 |
선택 기준 한 줄 요약:
- GitHub에서 코드 호스팅 중이면 GHCR. 별도 인증 셋업 없이
GITHUB_TOKEN이 그대로 동작. 무료 한도가 사실상 의식할 필요가 없음. - AWS 위에서 배포 중(ECS/EKS)이면 ECR. 같은 리전이면 풀이 빠르고, IAM으로 권한 관리가 깔끔함.
- 공개 OSS 라면 Docker Hub. 검색/발견성이 압도적. 단,익명 pull 한도(시간당 100회/IP)가 있어서 CI에서 베이스 이미지 받을 때 한도 걸리는 사고 종종 발생.
이 글은 GHCR 기준으로 갑니다. ECR은 AWS 트랙 #2 ECR에서 자세히 다뤘습니다.
태그가 왜 어렵나 #
도커의 “태그” 는 사실 이미지에 붙는 레이블 일 뿐, 어떤 강제도 없습니다. myapp:1.2.3이라고 푸시했다가 다음 날 다른 이미지를 같은 태그로 덮을 수 있습니다. 이게 모든 함정의 시작입니다.
docker push ghcr.io/me/app:1.2.3 # 첫 푸시
# ... 코드 변경 ...
docker push ghcr.io/me/app:1.2.3 # 같은 태그로 다른 이미지를 덮음이게 운영에서 무엇을 의미하는지가 다음 절들의 핵심입니다.
:latest가 위험한 이유
#
:latest는 도커가 부여하는 특별한 의미가 없습니다. 단지 태그를 생략하면 자동으로 붙는 기본값일 뿐입니다. 그런데 사람들은 무의식적으로 “최신 버전” 의 의미로 받아들입니다.
docker pull nginx # 사실은 docker pull nginx:latest
docker run myapp # 사실은 docker run myapp:latest운영에서 :latest를 쓰면 이런 문제가 생깁니다.
1. 어떤 이미지가 도는지 알 수 없습니다. “production의 web 컨테이너는 어떤 코드가 도는 거지?” → “myapp:latest입니다” 라고 답하면 답이 아닙니다. :latest는 시간이 지나면서 가리키는 대상이 계속 바뀝니다.
2. 롤백이 안 됩니다. 새 배포가 깨졌을 때 “이전 :latest로 돌려” 가 의미가 없습니다. 이전 :latest는 이미 사라진 라벨입니다.
3. 디버깅이 깨집니다. 같은 태그에 대해 노드마다 다른 이미지가 캐시돼 있을 수 있습니다. A 노드의 :latest와 B 노드의 :latest가 다른 동작을 하는 사고가 일어납니다.
4. 캐시 무효화가 안 됩니다. Kubernetes의 imagePullPolicy: IfNotPresent는 이미지 태그가 같으면 다시 안 받습니다. :latest를 덮어 푸시해도, 이미 캐시에 있는 노드는 옛 이미지를 계속 씁니다. 전 노드가 동일한 이미지로 도는 가정이 깨집니다.
5. 빌드와 배포가 분리되지 않습니다. “빌드한 게 정확히 그 배포물인가?” 를 보장하기 어렵습니다. 특히 :latest와 함께 :sha-abc1234 같은 immutable 태그도 같이 푸시하지 않으면, 시간이 지나면 빌드와 이미지의 연결이 끊깁니다.
그러면 :latest는 언제 쓰나? 로컬 개발/실험. CI 도구(예: actions/cache)의 베이스. 그리고 README의 “한 줄로 시작하기” 같은 튜토리얼 맥락. 운영 배포에는 절대 쓰지 마세요.
태그 전략 — 한 이미지에 여러 태그 #
정석은 한 이미지에 여러 태그를 같이 다는 것입니다. 같은 이미지가 여러 라벨로 동시에 보이게 됩니다.
ghcr.io/me/app:sha-a1b2c3d ← immutable, 항상 이 커밋
ghcr.io/me/app:1.4.2 ← semver, 정확한 버전
ghcr.io/me/app:1.4 ← semver minor (자주 갱신)
ghcr.io/me/app:1 ← semver major
ghcr.io/me/app:main ← 브랜치 (자주 갱신)
ghcr.io/me/app:latest ← (내부용 / 튜토리얼)각 태그의 의미:
sha-...— immutable. 한 번 푸시되면 절대 안 바뀜. 운영 배포의image:필드에 적는 건 이걸 권장. 정확히 어떤 코드가 도는지 추적 가능.1.4.2— semver patch. 릴리스 태그를 푸시했을 때 만들어짐. 사람이 읽기 좋음.1.4,1— semver minor/major. 자동 갱신 태그. “1.4의 최신 patch를 받겠다” 같은 의도일 때 사용. 한 곳에 고정하지 말고 운영에서는1.4.2처럼 정확한 버전을 쓰세요.main— 브랜치. 개발/스테이징 환경에서 자동 배포할 때 유용.latest— 위에서 말한 의미.
#4의 metadata-action이 이 전부를 한 번에 만들어 줍니다.
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=sha-,format=short # 항상
type=ref,event=branch # main, dev
type=ref,event=pr # pr-123
type=semver,pattern={{version}} # v1.2.3 push 시: 1.2.3
type=semver,pattern={{major}}.{{minor}} # 1.2
type=semver,pattern={{major}} # 1
type=raw,value=latest,enable={{is_default_branch}}Immutable 태그 — 정책으로 강제 #
GHCR은 같은 태그로 덮어 푸시가 가능합니다. 정책으로 막을 순 없습니다. 그래서 컨벤션으로 immutable 태그를 정의하고 그것만 운영 배포에 씁니다.
- 우리 약속:
sha-*와^v?\d+\.\d+\.\d+$는 immutable. 한 번 푸시되면 안 덮음. - 자동 태그(
main,latest,1.4)는 의도적으로 mutable.
다른 레지스트리에는 정책으로 강제할 수 있는 곳도 있습니다.
- AWS ECR은
tagMutability: IMMUTABLE옵션이 있어서, 정말로 같은 태그 재푸시를 거부합니다. 운영 레지스트리에 권장. - GCP Artifact Registry도 비슷한 설정 가능.
- GHCR / Docker Hub는 정책 강제 없음. 컨벤션 + CI 단계에서 검증해야 함.
CI에서 검증하는 건 단순합니다.
- name: Check tag uniqueness
if: startsWith(github.ref, 'refs/tags/v')
run: |
TAG="${GITHUB_REF#refs/tags/}"
if docker manifest inspect ghcr.io/${{ github.repository }}:${TAG#v} 2>/dev/null; then
echo "Tag ${TAG} already exists — refusing to overwrite" >&2
exit 1
fi어떤 태그를 운영에 적을까 — 결론 #
운영 배포 매니페스트(예: ECS task definition, Kubernetes Deployment, compose)의 image: 필드에는:
image: ghcr.io/me/app:sha-a1b2c3d이렇게 SHA 태그를 적는 게 정석입니다. 이유:
- 정확히 어떤 코드인지 명확.
- 같은 이미지를 가리키는 다른 태그가 덮어 푸시돼도 안전.
- 롤백이 단순 — 이전 SHA로 매니페스트 한 줄만 바꾸면 됨.
- 노드 간 일관성 보장.
semver 태그는 사람이 보는 readme/changelog와 외부 사용자(라이브러리 이미지)를 위한 것. 운영 매니페스트 안에 적는 용도는 아닙니다.
이미지 사이즈와 retention #
태그 전략과 함께 얼마나 오래 보관할지도 운영 직전에 정하는 게 좋습니다. CI가 매 PR/푸시마다 이미지를 만들면 한 달이면 수백 개가 쌓입니다. 한 이미지가 100MB 면 30GB가 누적입니다.
레지스트리별 정리 방법:
GHCR — 저장소 settings → Packages에서 retention policy 설정. 또는 actions/delete-package-versions를 워크플로우에 넣어 자동화.
name: Clean up old images
on:
schedule:
- cron: '0 3 * * 0' # 매주 일요일 03:00 UTC
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- uses: actions/delete-package-versions@v5
with:
package-name: 'app'
package-type: 'container'
min-versions-to-keep: 20 # 최소 20개는 유지
delete-only-untagged-versions: false
ignore-versions: '^(latest|main|v\d+\.\d+\.\d+)$' # 이 태그들은 제외ECR — lifecycle policy가 콘솔에 있어서 가장 깔끔. “untagged 이미지 7일 후 삭제”, “특정 prefix 태그 30개만 유지” 식으로 룰 작성.
Docker Hub — public은 무제한. private는 한 개만 무료라 거의 항상 의식해야 함.
retention 룰을 짤 때 빠뜨리면 위험한 것 — immutable 태그(sha-..., vX.Y.Z)는 보호. 운영 매니페스트가 sha 태그로 적혀 있는데 그 태그가 정리되면 롤백이 안 됩니다. 위 워크플로우의 ignore-versions가 그 용도입니다.
이미지 사이즈 자체를 줄이기 — 회수 #
태그 전략과 별개로 한 번 정리해 둘 가치가 있는 주제입니다. 중급 #1의 패턴을 다시 짚으면:
- 알파인 베이스 —
python:3.14-slim(~50MB) vspython:3.14-alpine(~25MB). 단, glibc → musl 차이로 일부 패키지가 안 깔리는 일이 있습니다. FastAPI/Django는 alpine이 자주 골치아픔(빌드 의존성), Node는 alpine이 무난. - distroless — Google의 baseless 이미지.
gcr.io/distroless/python3-debian12. 셸도 없어서 디버깅이 힘들지만, 공격면이 가장 작음. - 멀티스테이지 — devDeps/build tool이 final에 가지 않게.
.dockerignore— 빌드 컨텍스트 자체를 줄임.
빌드 후 사이즈 확인:
docker images ghcr.io/me/app
docker history ghcr.io/me/app:sha-a1b2c3d --no-trunc
# 각 레이어가 얼마나 차지하는지 한 줄씩레이어 단위로 분석하고 싶으면 dive 같은 도구가 좋습니다.
brew install dive
dive ghcr.io/me/app:sha-a1b2c3d환경별 이미지 분리 — :dev vs :prod
#
#3에서 짚은 내용처럼 Next.js처럼 빌드 시점에 환경변수가 굳어버리면 환경별로 다른 이미지가 됩니다. 태그를 환경 prefix로 분리하는 게 깔끔.
ghcr.io/me/app:prod-sha-a1b2c3d ← 프로덕션 빌드 (NEXT_PUBLIC_API_URL=prod)
ghcr.io/me/app:stage-sha-a1b2c3d ← 스테이징 빌드 (NEXT_PUBLIC_API_URL=stage)CI에서 환경별 빌드를 잡는 패턴.
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
env: [stage, prod]
include:
- env: stage
api_url: https://api.stage.example.com
- env: prod
api_url: https://api.example.com
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
flavor: |
prefix=${{ matrix.env }}-,onlatest=true
tags: |
type=sha,prefix=sha-,format=short
- uses: docker/build-push-action@v6
with:
context: .
push: true
build-args: |
NEXT_PUBLIC_API_URL=${{ matrix.api_url }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha,scope=${{ matrix.env }}
cache-to: type=gha,mode=max,scope=${{ matrix.env }}서버 사이드만 환경변수가 갈리는 백엔드(FastAPI/Django)는 한 이미지로 모든 환경 가능 — 이런 분리가 필요 없습니다.
흔한 함정 #
:latest가 어쩐지 옛 이미지 — 캐시 문제. 노드의 캐시 클리어 또는 imagePullPolicy 조정. 처음부터 sha 태그로 갔으면 안 마주쳤을 문제.
한 PR의 이미지가 다른 PR을 덮음 — metadata-action의 type=ref,event=pr만 쓰면 pr-123 식으로 잘 분리됨. 직접 짜다가 사고나는 경우가 많음.
untagged이미지가 1만 개 — delete-only-untagged-versions: true 같은 자동 정리가 없으면 이렇게 됨. retention policy 한 번만 설정해두면 사라지는 문제.
ECR 풀이 갑자기 거부 — IAM 권한 문제 아니면, 이미지가 retention으로 정리됐을 가능성. lifecycle policy 다시 확인.
Docker Hub pull rate limit (CI에서) — 익명 pull 한도. 베이스 이미지를 GHCR에 mirror 하거나, Docker Hub 계정으로 로그인해 한도 풀기.
정리 #
- 운영 배포의
image:필드에는 SHA 태그(sha-a1b2c3d)를 적는 게 정석. immutable, 추적 가능, 롤백 단순. :latest는 운영에 절대 금지. 시간이 지나면 가리키는 게 바뀌고, 캐시/롤백/일관성이 모두 깨짐.- 같은 이미지에 여러 태그를 동시에 다는 게 표준 패턴.
metadata-action이 자동화. - semver 자동 갱신 태그(
1.4,1)는 사용자가 “최신 patch 받음” 같은 의도일 때만. 운영에는 정확한 버전을. - 레지스트리는 GitHub이면 GHCR, AWS 면 ECR, OSS 면 Docker Hub. 큰 골격은 그 정도.
- Retention policy는 운영 직전에 한 번만 짜두면 두고두고 편함.
sha-*/vX.Y.Z는 보호 룰에. - 이미지 사이즈는 멀티스테이지 +
.dockerignore로 시작. alpine/distroless는 그 다음 단계.
다음 글(#6 클라우드 배포)에서는 트랙의 마지막 — 이렇게 빌드/태그된 이미지를 실제 클라우드에 올리는 단계입니다. Fly.io / Railway / ECS 세 옵션의 갈림길과 각각의 흐름을 정리합니다.