LabHub
배우기 러닝패스 코스

정책을 코드로 · 태그는 이름이고 다이제스트는 신원이다 · 이론

이미지 참조 정책 — 태그, 다이제스트, 그리고 그 위에 얹는 것

LabHub 에서 이어서 보기

한 줄 요약

태그는 옮길 수 있는 이름표이고 다이제스트는 내용의 해시라 옮길 수 없으며, 이미지 정책의 거의 전부는 이 한 문장의 따름정리다.

왜 이게 필요했나

배포 사고 중에 재현이 가장 어려운 종류가 있다. 어제 잘 돌던 것이 오늘 다르게 돈다. 매니페스트는 그대로다. 코드도 그대로다. 그런데 다르다. 원인이 이미지일 때, 보통은 같은 태그가 다른 내용을 가리키게 됐기 때문이다.

쿠버네티스 공식 문서는 이 성질을 짧게 못 박는다. 태그는 다른 이미지를 가리키도록 옮길 수 있지만 다이제스트는 고정된다. 다이제스트는 이미지 내용의 해시이고 불변이다. 그러니 app:v1.2.3 은 약속이 아니라 관습이다. 레지스트리는 같은 태그에 새 이미지를 밀어 넣는 것을 막지 않는다(불변 태그 설정을 켠 레지스트리가 아니라면).

latest 는 이 문제의 극단이다. 태그를 안 적으면 쿠버네티스는 latest 를 뜻하는 것으로 본다. 그리고 한 가지 사실이 더 붙는다. 태그를 생략하거나 :latest 로 두고 imagePullPolicy 를 안 적으면 그 값이 자동으로 Always 가 된다. 즉 파드가 재시작될 때마다 그 순간 레지스트리가 주는 것을 받아 온다. 어제와 오늘이 다른 이유가 여기 있다.

어떻게 동작하나

이미지 참조는 세 조각이다.

registry.example.com/team/app:v1.2.3@sha256:1ff6c18f...└── 레지스트리 ──┘└ 이름 ┘└ 태그 ┘└─── 다이제스트 ───┘

셋을 다 적으면 어떻게 될까. 문서가 정확히 답한다. 태그와 다이제스트를 함께 적으면 가져올 때는 다이제스트만 쓴다. 그래서 실무에서 쓰는 형태가 나온다 — 사람이 읽으라고 태그를 남기고, 실제 신원은 다이제스트로 고정한다.

imagePullPolicy 의 기본값도 참조 모양이 정한다.

| 참조 | imagePullPolicy 를 안 적었을 때 |
| --- | --- |
| 다이제스트를 적음 | IfNotPresent |
| 태그가 :latest | Always |
| 태그 없음 | Always |
| 그 밖의 태그 | IfNotPresent |

다이제스트가 보장하는 것과 보장하지 않는 것. 여기서 오해가 많다.

보장하는 것은 하나다. 내용 동일성. 같은 다이제스트를 두 번 받으면 같은 바이트다. 어느 레지스트리에서 받든, 몇 년 뒤에 받든 같다.

보장하지 않는 것은 셋이다.

뒤의 두 가지를 채우는 것이 서명(누가 보증하는가)과 출처 증명(어떻게 만들어졌는가)이다. SLSA 의 provenance 명세가 후자의 표준 형식을 정의한다 — 빌더, 소스 저장소와 커밋, 빌드 파라미터를 서명된 문서로 남긴다. 다이제스트 고정은 이 위층들이 서는 바닥이다. 무엇에 서명할지 가리키는 것이 결국 다이제스트이기 때문이다. 이 실습 환경에는 cosign 같은 서명 도구가 없어서 서명과 출처 증명은 개념으로만 다룬다.

정책을 거는 두 자리.

*안쪽(어드미션).* 파드 스펙의 이미지 문자열을 본다. 허용 레지스트리 접두, latest 금지, 다이제스트 요구. 여기서 중요한 함정이 하나 있다. 이미지 문자열 검사는 문자열 검사라 쉽게 빠져나간다. registry.example.com 을 허용 목록에 넣고 접두 검사만 하면 registry.example.com.evil.net/x 가 통과한다. 호스트 경계(/ 앞부분)를 정확히 끊어서 비교해야 한다. 또 컨테이너는 containers 에만 있지 않다 — initContainersephemeralContainers 를 빠뜨린 정책이 흔하다.

*바깥쪽(빌드와 저장소).* Dockerfile 의 FROM, 매니페스트와 헬름 차트의 이미지 값, 빌드 산출물의 이미지 목록. 어드미션은 마지막 방어선이고, 여기는 사람이 고칠 수 있는 자리다. FROM alpine:3.20 이 저장소에 있으면 빌드할 때마다 다른 베이스가 들어올 수 있다. 같은 규칙을 양쪽에 걸어 두면, 개발자는 PR 에서 알게 되고 클러스터는 혹시 새어 나온 것을 막는다.

현장에서 만나는 모습

첫째, 태그가 덮어써진 날. CI 가 app:v1.4.0 을 두 번 밀어 넣었다. 클러스터의 파드는 안 바뀌었는데 그날 재시작된 파드만 새 내용으로 떴다. 같은 Deployment 안에서 파드마다 다른 코드가 도는 상태가 되고, 원인은 다이제스트를 비교해야만 보인다. kubectl get pod -o jsonpathstatus.containerStatuses[].imageID 를 뽑으면 실제로 받은 다이제스트가 나온다.

둘째, 롤백이 안 되는 롤백. 태그로만 배포한 팀이 "지난 판으로 되돌리자"고 결정했는데, 그 태그가 이미 덮어써져 있었다. 되돌릴 대상이 사라진 것이다. 다이제스트로 배포했다면 되돌릴 주소가 남아 있다.

셋째, 다이제스트 고정의 비용. 고정은 공짜가 아니다. 베이스 이미지 보안 패치가 자동으로 따라오지 않으므로, 고정한 값을 주기적으로 올리는 절차가 함께 있어야 한다. 그 절차가 없으면 몇 달 뒤 "재현은 완벽한데 전부 취약한" 상태가 된다. 고정과 갱신은 한 쌍이다.

넷째, 허용 목록의 구멍. 접두 비교로 쓴 정책이 비슷한 이름의 외부 호스트를 통과시킨 사고가 반복된다. 규칙을 쓸 때는 반드시 통과해야 할 표본과 막혀야 할 표본을 함께 두고, 특히 이름이 비슷한 공격 표본을 넣어 둔다.

참고 문서

다음 실습에서 할 것

이미지 참조를 직접 뜯어 보고 정책으로 검사한다. 오프라인 이미지 묶음에서 매니페스트와 다이제스트를 꺼내 태그와 다이제스트가 각각 무엇을 가리키는지 확인하고, 같은 내용을 다른 태그로 복사해 다이제스트가 그대로인 것을 본다. 그다음 허용 레지스트리·latest 금지·다이제스트 요구 세 규칙을 쓰고, 이름이 비슷한 호스트로 접두 비교를 빠져나가는 표본과 initContainers 에만 위반이 있는 표본을 넣어 규칙의 구멍을 직접 찾아 메운다.