도커 실전 강좌 #5 레지스트리 푸시와 태그 전략 — :latest의 함정

9 분 소요

#4가 빌드까지였다면, 이번 글은 푸시 후 단계입니다. 어디로 푸시할지, 어떤 태그를 달지, 오래된 이미지를 어떻게 정리할지를 다룹니다.

도커 실전 강좌에서 이번 글의 위치:

태그 한 글자가 운영을 흔드는 일이 많습니다. 글자만 보면 사소해 보이지만, 운영 직전에 한 번 정리해 두면 두고두고 편합니다.

레지스트리 — 어디에 둘 것인가 #

선택지는 크게 다섯입니다.

레지스트리주요 용도무료 한도인증
Docker Hub가장 오래된, public의 표준private 1개, pull 한도 있음계정 + PAT
GHCR (GitHub Container Registry)GitHub 저장소와 자연스럽게 묶임거의 무제한 (public/private)GITHUB_TOKEN
AWS ECRAWS 인프라 안에 두는 게 깔끔할 때500MB 무료 / 이후 GB당 과금IAM
Google Artifact RegistryGCP 측 표준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는 도커가 부여하는 특별한 의미가 없습니다. 단지 태그를 생략하면 자동으로 붙는 기본값일 뿐입니다. 그런데 사람들은 무의식적으로 “최신 버전” 의 의미로 받아들입니다.

태그 생략 → :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 — 위에서 말한 의미.

#4metadata-action이 이 전부를 한 번에 만들어 줍니다.

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 ECRtagMutability: IMMUTABLE 옵션이 있어서, 정말로 같은 태그 재푸시를 거부합니다. 운영 레지스트리에 권장.
  • GCP Artifact Registry도 비슷한 설정 가능.
  • GHCR / Docker Hub는 정책 강제 없음. 컨벤션 + CI 단계에서 검증해야 함.

CI에서 검증하는 건 단순합니다.

immutable 태그 보호
- 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: 필드에는:

권장 — SHA 태그
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를 워크플로우에 넣어 자동화.

GHCR 자동 정리
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) vs python: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 같은 도구가 좋습니다.

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-actiontype=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 세 옵션의 갈림길과 각각의 흐름을 정리합니다.

X