같은 빌드에서 sha256이 두 개 나왔다
#sha256은 왜 두 개였을까
회사 Jenkins 배포 파이프라인을 더 줄일 수 있을지 뜯어보다가 이상한 걸 발견했다. 장애가 난 것도 아니고 멀쩡히 성공한 빌드인데, 로그 안에 sha256이 두 개 찍혀 있었다.
#12 exporting manifest sha256:4e285a83…
└ 빌드가 만든 이미지
...
git-a1b2c3d: digest: sha256:44107a56…
└ 레지스트리에 올라간 이미지
조작도 에러도 아니었다. 빌드는 성공했고 배포도 멀쩡했다. 그런데 빌드가 만든 이미지와 푸시된 이미지의 주소는 달랐다. 하나의 결과에 왜 두 주소가 붙는지 알려면 먼저 이미지가 무엇인지부터 다시 봐야 한다.
#내용이 주소를 정한다
컨테이너 이미지는 파일 하나가 아니다. JSON 문서와 tar 뭉치가 서로를 가리키는 그래프다. 아래 해부도에서 먼저 전체 모양을 잡을 수 있다.
git-a1b2c3d옮겨 붙는 가변 포인터sha256:4e285a83e8be92f3abf7d776900b07ea3e0d1bfe9ed9b9d981b841c6371884e4아래 전부의 목록sha256:189a3d6f110c9b6d17a6e69f302a8f3a137617cb865786682e595bc8522aed2e379BENV · ENTRYPOINT- base
sha256:0ef99b057473e28dd33d8bff0b36a46db6b6f9290ce2c38dc0c491d69c6a4ad968KBalpine - deps
sha256:4d5c0e2a885e6c28acf90ebc60b27365d9194bb0dcc0ab30e09b4ad423f7644d224KBnode_modules - app
sha256:97e2fc9921f9ad3adb43663b7eca76d24723edfeba10a91ca28bd48cbde4bfb930KBbuild output
화살표는 전부 digest 참조다. 위에서 아래로 내용이 주소를 결정하고, 태그만 그 바깥에서 손으로 옮겨 붙는다.
이 그래프를 지배하는 규칙은 하나다. digest = 그 바이트열의 sha256. 정확히는 알고리즘을 고를 수 있지만, 실무에서 만나는 건 사실상 전부 sha256이다. 내용이 1바이트라도 달라지면 digest도 달라진다. git 커밋 해시와 똑같은 원리다. 내용이 주소를 정하고, 주소가 같으면 내용도 같다.
기준선: FROM alpine:3.20
달라진 자리 61/64 · 뒤집힌 비트 126/256
한 글자를 바꿨는데 절반쯤이 뒤집힌다. 조금 비슷한 주소라는 건 없어서, digest 비교가 곧 내용 비교다.
그래서 latest는 어제와 오늘 다른 이미지를 가리킬 수 있다. 정확히 그 바이트가 필요할 때 image@sha256:…으로 pull하는 이유다.
하나만 더 알아두면 준비가 끝난다. manifest에 적히는 레이어의 지문은 포장(압축)까지 마친 뒤의 지문이다. 내용물이 같아도 포장을 다시 하면 지문이 바뀐다. 이제 두 로그 사이 어디서 포장이 다시 됐는지 추적하면 된다.
#--load 경로를 따라가 보니
사실 순서는 반대였다. 저 두 줄을 파헤친 건 발견 직후가 아니라, 배달 왕복을 없애려고 --push 전환을 준비하면서다. 플래그 하나 바꾸는 변경이라도 동작 차이가 정말 없는지는 스스로에게 답할 수 있어야 했다. 그 답을 찾는 과정이 이 글이다.
확인할 축은 둘이었다. 어디서 빌드했고, 어디로 내보냈는가. 내 파이프라인은 별도 컨테이너에서 빌드한 뒤 --load로 데몬에 내보냈다. 산출물이 데몬 밖에 있었기 때문에 배달 단계가 필요했고, 그 흔적은 로그에 그대로 남아 있었다.
#12 exporting to docker image format
└ OCI → docker 포맷 변환
#12 sending tarball 3.6s
└ 빌더 → 데몬 배달
#13 importing to docker 1.7s
└ 데몬이 수납
(그 뒤 docker push ~4s, docker rmi)
요약하면, 이미지가 두 번 포장되고 있었다. BuildKit이 레이어를 압축하고 그 지문들로 manifest 4e285a83를 썼다. 그런데 --load가 그 포장을 풀어 데몬에 넘겼고, docker push가 올리면서 데몬의 gzip으로 다시 포장했다. gzip은 만든 쪽의 구현과 설정에 따라 바이트가 달라진다. 레이어의 지문이 바뀌었으니 그 지문들을 나열한 manifest도 새로 써졌고, 레지스트리에는 44107a56가 기록됐다. 압축본을 보존하는 최신 containerd 저장소에서는 다를 수 있는데, 우리 데몬은 클래식 저장소였다.
git-a1b2c3d동일sha256:4e285a83e8be92f3abf7d776900b07ea3e0d1bfe9ed9b9d981b841c6371884e4동일sha256:189a3d6f110c9b6d17a6e69f302a8f3a137617cb865786682e595bc8522aed2e동일sha256:0ef99b057473e28dd33d8bff0b36a46db6b6f9290ce2c38dc0c491d69c6a4ad9동일sha256:4d5c0e2a885e6c28acf90ebc60b27365d9194bb0dcc0ab30e09b4ad423f7644d동일sha256:97e2fc9921f9ad3adb43663b7eca76d24723edfeba10a91ca28bd48cbde4bfb9동일비압축 tar 내용의 digest는 두 경로에서 완전히 동일하다
- sha256:c62809b5a2eb…
- sha256:8d8ecf49748f…
- sha256:3ceb43832add…
BuildKit이 만든 바이트가 그대로 올라가므로 빌드 로그의 digest와 레지스트리의 digest가 일치한다.
파일시스템 내용은 그대로라 컨테이너를 띄우면 같은 앱이 돈다. 달라진 건 포장지의 지문이다. 재압축이 실제 바이트에 남기는 차이는 다음 데모가 맡는다.
셀 = 바이트. 빨간 셀이 기준선과 다른 바이트다.
다른 바이트 1개 · 오프셋 8(헤더 안) · 길이는 같다
달라진 건 헤더의 XFL 한 바이트뿐인데 주소는 완전히 갈린다. 이래서 두 레이어의 크기가 621B로 같은데 digest가 다르다.
--push는 이 중간 경로를 없앤다. BuildKit이 압축한 그대로 레지스트리에 직접 올린다. 배달 왕복 ~5초가 사라지고, 다시 포장할 주체 자체가 없어진다. 대신 데몬에 로컬 이미지가 생기지 않으니 끝의 docker rmi도 함께 없애야 한다.
#뜻밖의 산출물, image index
속도만 보고 전환하면 다른 함정을 놓친다.
ARM 맥에서도 인텔 서버에서도 docker pull nginx가 되는 이유가 있다. 그 태그는 이미지 하나가 아니라 목차를 가리킨다. 목차에는 "amd64용은 이 manifest, arm64용은 저 manifest"가 적혀 있고, pull하는 쪽이 자기 것을 고른다. 이 목차가 image index다.
BuildKit은 이 목차에 이미지가 아닌 것도 끼워 넣는다. 이미지가 어떻게 빌드됐는지 담은 증명서(provenance)를 unknown/unknown이라는 가짜 플랫폼용 manifest로 위장해 동봉하는 것이다. BuildKit v0.11 세대부터 이 동봉이 기본값이 됐다.
그런데 --load를 쓰는 동안은 이걸 볼 일이 없었다. 데몬의 클래식 저장소는 태그 하나에 이미지 하나만 담는 모델이라 목차 자체를 못 받고, 그래서 증명서가 붙을 수가 없었다. --push로 바꾸는 순간 그 제약이 사라지면서 태그가 manifest 대신 목차를 가리키게 된다. 아무 설정도 안 바꿨는데 산출물의 모양이 달라지는 것이다.
git-a1b2c3d여기는 안 변한다sha256:e77bf33e29d7558089380f32630c6547983dd2bdbaa1412944a524b8cd2b2534666B태그가 이제 이걸 가리킨다sha256:4e285a83e8be92f3abf7d776900b07ea3e0d1bfe9ed9b9d981b841c6371884e4709Blinux/amd64 · 그대로sha256:b6bad6265921b57ad5759756017ec529d112881479d049c75a3560c440e9920a463Bunknown/unknown으로 위장태그가 가리키는 digest sha256:e77bf33e29d7…
- = 무영향k8s (containerd)index에서 자기 플랫폼을 골라 pull하므로 배포는 안 깨진다
- = 무영향레지스트리 콘솔unknown/unknown이 목록에 뜬다
- ≠ 걸린다docker manifest inspect를 파싱하는 스크립트단일 출력 가정이 깨진다
- ≠ 걸린다digest 대조 도구·스캐너가리키는 digest가 바뀐다
빌드는 성공하고 배포도 안 깨진다. 바뀐 건 태그가 가리키는 대상의 모양뿐이라, 에러 없이 조용히 통과한다.
이 함정은 전환 PR을 다 만들고 나서야 잡았다. 플래그 하나를 바꿔도 전후 동작 차이를 목록으로 만들어 하나씩 지워봤는데, 끝까지 지워지지 않은 항목이 attestation이었다. 내 파이프라인에는 서명 검증 정책 같은 증명서 소비자가 없었고, 그래서 --provenance=false로 기존 형태를 고정했다. 빌드와 배포가 성공해도 산출물 모양은 조용히 달라질 수 있다. 이 선택은 로그의 성공 여부만으로 대신할 수 없었다.
#전환 전 체크리스트
아래 넷은 전환 PR에서 실제로 밟은 것들이다. 첫 번째만 필수고, 나머지는 각자의 파이프라인 상황을 보고 고르는 항목이다.
하나 미리 알아둘 것. 전환하면 압축하는 쪽이 바뀌니 같은 코드를 빌드해도 레이어와 manifest의 digest가 새 값이 된다. digest로 "전에 봤던 이미지인지"를 판별하는 시스템이 있다면 전환 시점에 한 번, 모든 이미지가 낯선 것으로 보인다. 우리는 태그로만 배포해서 아무 일도 없었다. 어디로든 옮겨 붙는 태그의 성질이 여기서는 보호막이 됐다.
- 이미지가 더는 로컬에 남지 않는다. 빌드 끝의
docker rmi는 지울 게 없어No such image로 실패하고, 로컬 이미지를 쓰던 단계(docker run확인, 태그 추가)도 같이 깨진다. rmi는 지우고, 태그가 더 필요하면 빌드할 때-t를 여러 번 준다. - digest를 읽어 저장하거나 비교하는 코드가 있는지 grep한다. 있다면
--metadata-file로 새 digest를 받아서 넘겨준다. - 증명서(provenance)를 붙일지는 선택이다. 읽는 시스템이 있거나 도입할 계획이면 기본값대로 켜 두면 되고, 우리처럼 아직 없다면
--provenance=false로 꺼서 산출물 모양을 지금과 같게 둘 수 있다. 단일 플랫폼 빌드 기준이고, multi-arch라면 목차는 어차피 남는다. - "이미지 푸시" 같은 별도 단계가 사라진다. 그 로그 문구를 찾아 울리던 알림이나 게이트가 있다면 갱신한다.
#해소: 두 지문에서 하나의 digest로
이제 처음의 두 로그로 돌아갈 수 있다.
sha256:4e285a83e8be92f3abf7d776900b07ea3e0d1bfe9ed9b9d981b841c6371884e4 ≠ sha256:44107a568449de5a19583f457a34ba8a2103654ec6c29f6004af196e68e13f44 글을 연 그 두 줄이다.
전환은 dev에서 한 번 돌려 확인했다. 로그에서 배달 두 줄(sending tarball, importing to docker)이 사라졌고, 불일치를 만들던 세 번째 지문은 찍힐 자리 자체가 없어졌다. 컨테이너 단계는 23초에서 7초로 줄었는데, 캐시 상태가 다른 빌드끼리라 엄밀한 대조는 아니다. 배포는 정상이었고, 이 PR은 머지되어 지금은 운영까지 같은 경로로 배포된다.
빌드 방식을 바꾸면 두 가지가 조용히 달라질 수 있다. 이미지의 지문, 그리고 태그가 가리키는 대상의 모양. 배포가 태그만 본다면 안전하다. 남은 일은 누가 digest와 목차를 읽는지 grep해 보는 것뿐이다.
태그는 이름일 뿐이고, 보증은 언제나 digest가 한다.