# 이미지 태그는 포인터다 - 같은 태그를 다시 밀었을 때 클러스터에서 벌어지는 일
CI가 빌드한 이미지를 myapp:dev 태그로 밀고, 클러스터에서 그 태그를 참조합니다. 배포했는데 코드가 안 바뀐 것 같아 파드를 다시 띄우면 그제야 반영됩니다. 어떤 날은 파드 세 개 중 두 개만 새 코드로 돕니다.
원인은 태그가 무엇인지에 있습니다. 태그는 이미지가 아니라 이미지를 가리키는 이름표이고, 언제든 다른 이미지를 가리키도록 옮길 수 있습니다. 이 글에서는 가변 태그가 배포 파이프라인의 어느 지점을 어긋나게 하는지, 다이제스트 고정이 무엇을 해결하고 무엇을 대신 요구하는지 정리합니다.
# 1. 태그와 다이제스트가 서로 다른 것을 가리킨다
OCI Distribution 스펙은 매니페스트를 받아오는 참조자를 이렇게 정의합니다.
The
<tag-or-digest>MUST be either (a) the digest of the manifest or (b) a tag.
그리고 태그의 성격을 이렇게 설명합니다.
a manifest digest may have zero, one, or many tags referencing it
한 이미지에 태그가 여러 개 붙을 수도 있고 하나도 없을 수도 있습니다. 관계가 일대일이 아닙니다. 다이제스트는 반대입니다.
a unique identifier created from a cryptographic hash of a Blob's content
내용의 해시이므로 같은 다이제스트는 항상 같은 바이트를 가리킵니다. 정리하면 이렇습니다.
| 태그 | 다이제스트 | |
|---|---|---|
| 정체 | 사람이 붙인 포인터 | 콘텐츠 해시 |
| 가리키는 대상 변경 | 가능 | 불가능 |
| 사람이 읽기 | 쉬움 | 어려움 |
| 같은 문자열 = 같은 이미지 | 보장 안 됨 | 보장됨 |
마지막 줄이 이 글의 전부입니다. myapp:dev라는 문자열이 어제와 오늘 같은 이미지를 뜻한다는 보장이 어디에도 없습니다.
# 2. 매니페스트가 안 바뀌면 롤아웃도 없다
가장 먼저 깨지는 것은 배포 자체입니다. Deployment의 이미지 필드가 myapp:dev로 고정돼 있으면, CI가 그 태그로 새 이미지를 밀어도 파드 템플릿 해시가 그대로입니다. 컨트롤러가 볼 때 바뀐 것이 없으므로 롤아웃이 시작되지 않습니다. 새 이미지는 레지스트리에만 있고 클러스터는 아무 일도 하지 않습니다.
그래서 흔히 kubectl rollout restart로 강제합니다. 여기서 두 번째 문제가 나옵니다. 파드가 새로 뜰 때 이미지를 다시 받아올지는 imagePullPolicy가 정하고, 그 기본값은 이렇게 결정됩니다.
| 지정한 것 | 기본 imagePullPolicy |
|---|---|
| 다이제스트 | IfNotPresent |
태그가 :latest | Always |
| 태그 생략 | Always |
| 그 외 태그 | IfNotPresent |
myapp:dev는 :latest가 아니므로 IfNotPresent입니다. 문서의 설명은 이렇습니다.
IfNotPresent: the image is pulled only if it is not already present locally.
이미 그 태그의 이미지를 받아 둔 노드는 다시 받지 않습니다. 캐시가 있는 노드는 옛날 이미지를, 캐시가 없는 노드는 새 이미지를 실행합니다. 같은 Deployment의 파드들이 서로 다른 코드를 도는 상태가 여기서 만들어집니다. 파드 세 개 중 두 개만 반영된 것처럼 보인 이유입니다.
여기에 문서가 명시한 함정이 하나 더 붙습니다.
The value of
imagePullPolicyof the container is always set when the object is first created, and is not updated if the image's tag or digest later changes.
정책은 객체를 처음 만들 때 확정됩니다. 나중에 이미지 태그를 :latest로 바꿔도 정책은 IfNotPresent로 남아 있습니다. "latest로 바꿨으니 이제 매번 받아오겠지"라는 기대가 틀리는 지점입니다. 정책에 의존할 생각이라면 명시적으로 적어야 합니다.
# 3. 롤백할 대상이 남지 않는다
세 번째 문제는 사고가 난 다음에 드러납니다. myapp:v1.4.2를 배포했다가 문제를 발견하고 v1.4.1로 되돌리려는데, v1.4.1 태그가 그동안 재빌드로 옮겨졌다면 되돌아간 곳이 예전의 그 이미지가 아닙니다. 되돌릴 좌표가 사라진 상태입니다.
kubectl rollout undo에도 같은 한계가 있습니다. ReplicaSet에 기록된 것은 이미지 문자열이지 다이제스트가 아닙니다. 태그가 움직였다면 undo는 "같은 문자열로 다시 배포"할 뿐입니다.
이 셋을 한 줄로 묶으면 이렇습니다. 가변 태그는 배포를 트리거하지 못하고, 노드 간 일관성을 보장하지 못하며, 롤백 좌표를 남기지 않습니다.
# 4. 다이제스트로 고정하면 무엇이 달라지는가
Kubernetes는 이미지 필드에 다이제스트를 직접 쓸 수 있고, 태그와 함께 쓰는 형식도 지원합니다. 문서가 형식별 의미를 이렇게 정리해 두었습니다.
registry.k8s.io/pause@sha256:1ff6c18f...- Image name with digest.
registry.k8s.io/pause:3.5@sha256:1ff6c18f...- Image name with tag and digest. Only the digest will be used for pulling.
마지막 형식이 실용적입니다. 태그는 사람이 읽는 용도로 남고, 실제 판정은 다이제스트가 합니다. 매니페스트만 보고도 어느 버전인지 알 수 있으면서 내용은 고정됩니다.
containers:
- name: myapp
# 태그는 읽기용, 실제로 받아오는 것은 다이제스트
image: registry.example.com/myapp:1.4.2@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2
imagePullPolicy: IfNotPresent
이 형식에서는 IfNotPresent가 오히려 정확한 선택입니다. 다이제스트가 같으면 내용이 같음이 보장되므로 다시 받아올 이유가 없고, 다이제스트가 바뀌면 매니페스트가 바뀌므로 롤아웃이 자동으로 일어납니다. 앞의 세 문제가 한꺼번에 사라집니다.
멀티 아키텍처 이미지도 그대로 동작합니다. 매니페스트 리스트(인덱스)의 다이제스트를 지정하면 리스트가 아키텍처별 매니페스트를 가리키므로, 노드마다 자기 아키텍처에 맞는 이미지를 받습니다. 아키텍처별 다이제스트를 직접 박으면 안 됩니다. 그 순간 해당 아키텍처의 노드에서만 뜨는 이미지가 됩니다.
# 5. 대신 무엇을 감당해야 하는가
다이제스트 고정이 공짜는 아닙니다.
첫째, 사람이 쓸 수 없습니다. 손으로 매니페스트를 고치는 방식으로는 유지되지 않습니다. 빌드가 끝난 뒤 다이제스트를 읽어 매니페스트에 써 넣는 단계가 파이프라인에 있어야 합니다.
# 빌드·푸시 후 방금 올린 이미지의 다이제스트를 얻는다
DIGEST=$(docker buildx imagetools inspect \
registry.example.com/myapp:1.4.2 --format '{{.Manifest.Digest}}')
# 또는 crane으로
DIGEST=$(crane digest registry.example.com/myapp:1.4.2)
echo "$DIGEST" # sha256:45b23dee...
docker push의 출력에도 다이제스트가 찍히지만, 파이프라인에서는 출력 파싱보다 위 명령이 안정적입니다. buildx를 쓴다면 --metadata-file로 받는 방법도 있습니다.
docker buildx build --push \
-t registry.example.com/myapp:1.4.2 \
--metadata-file /tmp/meta.json .
jq -r '."containerimage.digest"' /tmp/meta.json
둘째, 베이스 이미지 갱신이 자동으로 안 따라옵니다. FROM python:3.12-slim@sha256:...처럼 베이스까지 고정하면 재현성은 완벽해지지만, 업스트림 보안 패치가 반영되지 않습니다. 갱신 주체를 사람이 아니라 도구(Renovate, Dependabot 같은 다이제스트 갱신 PR)로 두지 않으면 몇 달 뒤에 오래된 베이스가 남습니다. 재현성과 패치 적용은 서로 반대 방향이고, 어느 쪽도 공짜가 아닙니다.
셋째, 다이제스트만 있는 이미지는 지워지기 쉽습니다. 레지스트리 정리 정책이 대개 "태그 없는 매니페스트를 N일 뒤 삭제"로 되어 있습니다. 다이제스트로만 참조하는 이미지에 태그를 안 붙여 두면 정리 정책에 걸려 사라지고, 그 시점부터 파드가 ImagePullBackOff로 떨어집니다. 태그와 다이제스트를 함께 쓰라는 이유가 여기에도 있습니다.
# 6. 태그 규칙을 어떻게 정할 것인가
다이제스트로 고정한다고 해서 태그가 필요 없어지지는 않습니다. 태그는 사람과 정리 정책이 읽는 인터페이스입니다. 성격에 따라 나누면 이렇습니다.
| 태그 종류 | 예 | 이동 여부 | 용도 |
|---|---|---|---|
| 불변 빌드 태그 | 1.4.2, git-a1b2c3d | 고정 | 배포 참조·롤백 좌표 |
| 이동 태그 | 1.4, 1, stable | 이동 | 사람이 최신을 가리킬 때 |
| 브랜치 태그 | main, dev | 이동 | 개발용 최신 |
latest | 이동 | 사실상 의미 없음 |
핵심 규칙은 하나입니다. 배포 매니페스트가 참조하는 태그는 절대 이동하지 않아야 합니다. 이동 태그는 사람이 docker run으로 편하게 쓰라고 두는 것이고, 클러스터가 참조할 대상이 아닙니다.
커밋 SHA를 태그에 넣으면 불변성을 자연스럽게 얻습니다. 같은 커밋을 재빌드해도 다이제스트는 달라질 수 있지만(타임스탬프·의존성 해석 차이), 태그가 겹쳐 덮이는 일은 없습니다.
# 7. 레지스트리에서 강제하기
규칙을 사람의 주의력에 맡기면 언젠가 무너집니다. 레지스트리가 막아 줄 수 있습니다. ECR의 태그 불변성 설정은 이렇게 동작합니다.
You can prevent image tags from being overwritten by turning on tag immutability in a repository. After tag immutability is turned on, the
ImageTagAlreadyExistsExceptionerror is returned if you push an image with a tag that is already in the repository. Tag immutability affects all tags. You cannot make some tags immutable while others aren't.
전부 아니면 전무라는 점이 문제였습니다. latest나 dev 같은 이동 태그를 하나도 못 쓰게 되기 때문입니다. 그래서 예외 필터가 있습니다.
Immutable – ... Additionally, to enable tag updates for a few immutable tags, enter tag names or use wildcards (*) to match multiple similar tags in the Immutable tag exclusion text box.
즉 "기본은 불변, 지정한 패턴만 이동 허용"을 만들 수 있습니다. 이것이 현실적인 설정입니다.
# 신규 리포지터리를 불변 + 예외 패턴으로 생성
aws ecr create-repository --repository-name myapp \
--image-tag-mutability IMMUTABLE_WITH_EXCLUSION \
--image-tag-mutability-exclusion-filters filterType=WILDCARD,filter="dev-*" \
--region ap-northeast-2
# 기존 리포지터리에 적용
aws ecr put-image-tag-mutability --repository-name myapp \
--image-tag-mutability IMMUTABLE_WITH_EXCLUSION \
--image-tag-mutability-exclusion-filters filterType=WILDCARD,filter="dev-*" \
--region ap-northeast-2
주의할 점이 하나 있습니다. 문서가 언급하듯 풀스루 캐시를 쓰는 리포지터리는 캐시 갱신을 위해 태그를 덮어써야 하므로 불변 설정과 충돌합니다. 캐시용과 자체 빌드용 리포지터리를 섞지 않는 편이 낫습니다.
# 8. Always를 쓰면 되는 것 아닌가
imagePullPolicy: Always로 두면 태그가 움직여도 매번 최신을 받아오니 해결된 것처럼 보입니다. 실제 동작은 문서에 이렇게 적혀 있습니다.
Always: every time the kubelet launches a container, the kubelet queries the container image registry to resolve the name to an image digest. If the kubelet has a container image with that exact digest cached locally, the kubelet uses its cached image; otherwise, the kubelet pulls the image with the resolved digest.
레이어를 통째로 다시 받는 것이 아니라 다이제스트를 확인하는 것이 핵심입니다. 그래서 비용이 크지 않습니다.
The caching semantics of the container runtime make even
imagePullPolicy: Alwaysefficient, as long as the registry is reliably accessible.
마지막 조건절이 트레이드오프의 전부입니다. Always는 컨테이너가 시작될 때마다 레지스트리를 한 번 찌릅니다. 즉 레지스트리 가용성이 파드 시작의 의존성이 됩니다. 레지스트리가 죽으면 이미 노드에 이미지가 있어도 파드가 안 뜹니다. 노드가 통째로 재부팅되는 상황과 레지스트리 장애가 겹치면 복구가 막힙니다. Docker Hub처럼 요청 수 제한이 있는 레지스트리라면 대규모 재시작이 제한에 걸릴 수도 있습니다.
정리하면 이렇습니다.
태그 + Always | 다이제스트 + IfNotPresent | |
|---|---|---|
| 새 이미지 반영 | 파드 재시작 필요 | 매니페스트 변경으로 자동 |
| 노드 간 일관성 | 시점에 따라 다를 수 있음 | 보장 |
| 롤백 좌표 | 없음 | 다이제스트 |
| 레지스트리 장애 시 | 파드 시작 실패 | 캐시로 버팀 |
| 매니페스트 가독성 | 좋음 | 도구 필요 |
Always는 태그가 움직인다는 전제를 받아들이고 그 대가를 레지스트리 의존성으로 치르는 선택입니다. 다이제스트 고정은 전제 자체를 없앱니다.
# 9. 직접 확인하는 방법
지금 도는 파드가 실제로 어떤 이미지인지는 imageID에 다이제스트로 남습니다.
# 파드별 실제 실행 중인 이미지 다이제스트
kubectl get pods -l app=myapp \
-o custom-columns='POD:.metadata.name,SPEC:.spec.containers[0].image,ACTUAL:.status.containerStatuses[0].imageID'
# 다이제스트가 몇 종류인지 - 1이 아니면 파드 간에 코드가 섞여 있다
kubectl get pods -l app=myapp \
-o jsonpath='{range .items[*]}{.status.containerStatuses[0].imageID}{"\n"}{end}' \
| sort -u | wc -l
# 레지스트리의 현재 태그가 가리키는 다이제스트
crane digest registry.example.com/myapp:1.4.2
# 매니페스트 리스트라면 어떤 아키텍처가 들어 있는지
docker buildx imagetools inspect registry.example.com/myapp:1.4.2
두 번째 명령의 결과가 2 이상이면 2절에서 설명한 상태입니다. 세 번째 명령의 결과를 .status.containerStatuses[0].imageID에 남은 다이제스트와 비교하면 태그가 언제 움직였는지도 알 수 있습니다.
# 10. 트러블슈팅
| 증상 | 원인 | 조치 |
|---|---|---|
| 이미지를 밀었는데 배포가 안 일어남 | 매니페스트가 그대로라 템플릿 해시 불변 | 다이제스트를 매니페스트에 반영 |
| 파드마다 동작이 다름 | IfNotPresent + 노드별 캐시 차이 | imageID 비교 후 다이제스트 고정 |
latest로 바꿨는데 계속 캐시를 씀 | 정책은 생성 시점에 고정됨 | imagePullPolicy를 명시적으로 작성 |
| 롤백했는데 같은 문제가 재현 | 태그가 이동해 좌표가 사라짐 | 불변 태그 + 다이제스트 기록 |
갑자기 ImagePullBackOff | 태그 없는 매니페스트가 정리 정책에 삭제됨 | 태그와 다이제스트 병기 |
| 레지스트리 장애 때 파드가 안 뜸 | Always가 레지스트리를 의존성으로 만듦 | 다이제스트 + IfNotPresent로 전환 |
| 재빌드가 태그를 덮어씀 | 레지스트리가 가변 태그 허용 | 태그 불변성 + 예외 패턴 설정 |
| ARM 노드에서만 안 뜸 | 아키텍처별 다이제스트를 직접 박음 | 매니페스트 리스트 다이제스트 사용 |
# 11. 마무리
- 태그는 이미지가 아니라 포인터입니다. 같은 문자열이 같은 이미지를 뜻한다는 보장이 스펙 어디에도 없습니다.
- 가변 태그는 세 가지를 동시에 망가뜨립니다. 롤아웃이 트리거되지 않고, 노드 간에 실행 코드가 갈리고, 롤백 좌표가 남지 않습니다.
name:tag@sha256:...형식이 실용적인 절충입니다. 태그는 읽기용, 판정은 다이제스트가 합니다. 다이제스트만 쓰면 정리 정책에 삭제될 위험이 있습니다.imagePullPolicy: Always는 레지스트리 가용성을 파드 시작의 의존성으로 바꿉니다. 편의를 얻는 대신 장애 시 복구 경로가 하나 줄어듭니다.- 규칙은 레지스트리에서 강제해야 유지됩니다. "기본 불변 + 개발 태그만 예외"가 현실적인 설정입니다.
이미지가 어떻게 만들어지는지, 여러 이미지가 베이스를 공유할 때 CI를 어떻게 구성하는지는 공유 베이스 이미지와 파생 이미지 CI (opens new window)에 이어서 정리했습니다. 빌드 자체의 재현성 문제는 개발용 컨테이너 이미지가 어제와 다르게 빌드되는 이유 (opens new window)를 참고할 수 있습니다.