같은 빌드에서 SHA-256이 두 개 나왔다
#같은 앱을 올렸는데 값이 달랐다
Jenkins 빌드 시간을 줄이려고 로그를 읽다가 두 줄이 눈에 들어왔습니다.
#12 exporting manifest sha256:4e285a83…
...
git-a1b2c3d: digest: sha256:44107a56…첫 줄은 빌드 도구가 만든 manifest의 digest, 두 번째는 docker push가 출력한 digest였습니다. 한 번 빌드한 결과를 올렸는데 왜 값이 달라졌을까요?
확인해 보니 이미지가 레지스트리에 바로 올라가지 않았습니다. BuildKit에서 만든 결과를 도커 데몬에 넣은 뒤, 데몬이 다시 레지스트리에 올리고 있었어요. 이 경로를 --push로 합치면서 로컬 저장과 별도 푸시 단계를 없앴습니다.
컨테이너 단계는 변경 전 23초, 변경 후 7초로 관측됐습니다. 다만 캐시 상태가 달랐으므로 16초 전부를 이 변경의 효과라고 볼 수는 없습니다.
로그의 digest는 사내 값 대신 이 글의 데모에서 계산한 값으로 바꿨습니다. 데모는 원리를 설명하는 예시이며, 당시 이미지의 바이트를 재현한 것은 아닙니다.
#먼저, 빌드 결과를 올리는 경로를 바꿨다
기존 명령을 단순화하면 이렇습니다. BuildKit은 이미지를 빌드하는 도구이고, 도커 데몬은 로컬 이미지를 저장하고 실행하는 프로그램입니다. 저희는 BuildKit을 별도 컨테이너에서 실행하고 있었습니다.
# 변경 전: 빌드 결과를 로컬 데몬에 넣고, 따로 올린다
docker buildx build --load -t registry.example.com/app:git-a1b2c3d .
docker push registry.example.com/app:git-a1b2c3d
docker rmi registry.example.com/app:git-a1b2c3d--load는 빌드 결과를 로컬 도커 데몬에 넣습니다. --push는 결과를 레지스트리에 직접 올립니다. 이 차이를 이용해 명령을 하나로 바꿨어요.
# 변경 후: 빌드 도구가 레지스트리에 직접 올린다
docker buildx build --push --provenance=false \
-t registry.example.com/app:git-a1b2c3d .로컬에 이미지를 넣지 않으니 마지막 docker rmi도 지웠습니다. --provenance=false는 저희의 단일 플랫폼 빌드에서 기존 결과물 형태를 유지하려고 넣은 설정입니다. 이 옵션이 필요한 이유는 뒤에서 설명하겠습니다.
이제 경로는 단순해졌습니다. 남은 질문은 로컬 데몬을 거치는 것이 digest와 무슨 관계가 있느냐입니다.
#digest가 가리키는 것은 앱이 아니라 바이트다
SHA-256은 입력 바이트로 해시값을 계산하는 함수입니다. 그 결과를 digest라고 부릅니다. 같은 바이트에서는 같은 값이 나오지만, 입력이 조금만 달라도 값이 크게 달라집니다.
컨테이너 이미지는 파일 하나가 아닙니다. 앱과 실행 환경의 파일을 담은 레이어, 실행 설정을 담은 config, 이들을 나열하는 manifest로 구성됩니다.
git-a1b2c3d사람이 옮겨 붙이는 이름표sha256:4e285a83e8be92f3abf7d776900b07ea3e0d1bfe9ed9b9d981b841c6371884e4아래 전부의 목록sha256:189a3d6f110c9b6d17a6e69f302a8f3a137617cb865786682e595bc8522aed2e379BENV · ENTRYPOINT- base
sha256:0ef99b057473e28dd33d8bff0b36a46db6b6f9290ce2c38dc0c491d69c6a4ad968KBalpine - deps
sha256:4d5c0e2a885e6c28acf90ebc60b27365d9194bb0dcc0ab30e09b4ad423f7644d224KBnode_modules - app
sha256:97e2fc9921f9ad3adb43663b7eca76d24723edfeba10a91ca28bd48cbde4bfb930KBbuild output
화살표는 전부 digest 참조예요. 위에서 아래로 내용이 digest를 결정하고 태그만 그 바깥에서 손으로 옮겨 붙습니다.
manifest에는 각 레이어의 digest가 적혀 있습니다. 레이어를 gzip으로 압축해 올린다면 이 값은 압축된 파일의 바이트로 계산합니다. 압축 전 내용이 같더라도 압축 결과가 달라지면 레이어 digest가 달라지고, 그 값을 적은 manifest의 내용과 digest도 달라집니다.
압축된 레이어의 바이트가 달라짐
→ 레이어 digest가 달라짐
→ manifest에 적힌 값이 달라짐
→ manifest digest가 달라짐그래서 같은 앱이 실행된다는 것과 manifest digest가 같다는 것은 별개의 조건입니다.
태그는 계산된 값이 아니라 사람이 붙인 이름입니다. latest 같은 태그는 다른 manifest로 옮겨 붙일 수 있어요. 또 docker images에 보이는 IMAGE ID는 config의 해시입니다. 로그의 SHA-256을 비교할 때도 레이어, config, manifest 중 무엇의 값인지 먼저 맞춰야 합니다. OCI 이미지 명세에서 이 구분을 확인할 수 있습니다.
#--load 뒤에서 레이어가 다시 압축됐다
저희 도커 데몬은 클래식 이미지 저장소를 사용했습니다. 이 저장소는 압축된 레이어를 그대로 보관하는 대신, 풀어 놓은 레이어를 저장합니다. 이후 docker push가 레이어를 올릴 때 압축이 다시 필요합니다.
경로를 풀어 쓰면 이렇습니다.
변경 전: BuildKit → 로컬 도커 데몬에 저장 → 데몬이 레이어를 압축해 푸시
변경 후: BuildKit → 레지스트리에 직접 푸시빌드와 푸시 사이에 레이어를 다시 만드는 과정이 있었습니다. 그 과정에서 바이트가 달라지면 manifest digest도 달라질 수 있습니다.
git-a1b2c3d같음sha256:4e285a83e8be92f3abf7d776900b07ea3e0d1bfe9ed9b9d981b841c6371884e4같음sha256:189a3d6f110c9b6d17a6e69f302a8f3a137617cb865786682e595bc8522aed2e같음sha256:0ef99b057473e28dd33d8bff0b36a46db6b6f9290ce2c38dc0c491d69c6a4ad9같음sha256:4d5c0e2a885e6c28acf90ebc60b27365d9194bb0dcc0ab30e09b4ad423f7644d같음sha256:97e2fc9921f9ad3adb43663b7eca76d24723edfeba10a91ca28bd48cbde4bfb9같음압축하기 전 tar 내용의 digest는 두 경로에서 완전히 같아요
- sha256:c62809b5a2eb…
- sha256:8d8ecf49748f…
- sha256:3ceb43832add…
BuildKit이 만든 바이트가 그대로 올라가니까 빌드 로그의 digest와 레지스트리의 digest가 일치합니다.
다만 당시 두 이미지의 압축 전 tar까지 비교하지는 않았습니다. digest 차이가 압축 설정에서만 왔는지, tar를 다시 만드는 과정에서도 왔는지는 이 로그만으로 구분할 수 없습니다. 확인하려면 두 이미지의 config에 담긴 rootfs.diff_ids를 비교해야 합니다. 이 값은 압축 전 tar의 digest입니다. OCI의 DiffID 정의가 비교 기준입니다.
같은 내용을 압축해도 바이트가 달라지는 예시
같은 tar에 gzip 압축 수준을 다르게 적용한 예시입니다. 두 압축 파일을 풀면 같은 내용이지만, 압축된 바이트와 digest는 달라집니다. 당시 도커 데몬의 압축 설정을 재현한 실험은 아닙니다.
셀 하나가 바이트 하나예요. 빨간 셀이 기준선과 다른 바이트입니다.
다른 바이트 1개 · 오프셋 8(헤더 안) · 길이는 같음
달라진 건 헤더의 XFL 한 바이트뿐인데 digest는 완전히 갈려요. 그래서 두 레이어의 크기가 621B로 같은데 digest가 다릅니다.
이 설명은 클래식 저장소를 거친 저희 경로에 해당합니다. containerd 이미지 저장소는 압축된 레이어도 보관하므로 경로가 다릅니다. Docker Engine 29의 새 설치에서는 containerd 저장소가 기본이고, 이전 버전에서 업그레이드한 데몬은 기존 저장소를 계속 쓸 수 있습니다. Docker 저장소 문서의 docker info -f '{{ .DriverStatus }}'로 사용 중인 저장소를 확인할 수 있습니다.
#--push로 바꾸니 provenance도 따라왔다
직접 푸시하면서 확인한 차이가 하나 더 있었습니다. BuildKit은 기본적으로 이미지가 어떻게 빌드됐는지 기록한 provenance를 붙입니다. Docker의 빌드 메타데이터 문서에 설명된 동작입니다.
이 메타데이터는 image index에 이미지 manifest와 함께 들어갑니다. index는 여러 manifest를 나열한 목차입니다. 여러 플랫폼의 이미지를 하나의 태그로 묶을 때도 이 목차를 사용해요.
클래식 저장소로 --load할 때는 이 메타데이터를 보존할 수 없었습니다. --push로 직접 올리면서 태그가 가리키는 대상이 달라졌습니다.
기존: 태그 → 이미지 manifest
기본 provenance를 붙여 직접 푸시: 태그 → index → 이미지 manifest + provenance manifestgit-a1b2c3d태그 자체는 안 변해요sha256:e77bf33e29d7558089380f32630c6547983dd2bdbaa1412944a524b8cd2b2534666B태그가 이제 여기를 가리켜요sha256:4e285a83e8be92f3abf7d776900b07ea3e0d1bfe9ed9b9d981b841c6371884e4709Blinux/amd64 · 그대로sha256:b6bad6265921b57ad5759756017ec529d112881479d049c75a3560c440e9920a463Bunknown/unknown이라는 가짜 플랫폼태그가 가리키는 digest sha256:e77bf33e29d7…
- = 무영향k8s (containerd)목차에서 자기 플랫폼을 골라 pull하니까 배포는 안 깨져요
- = 무영향레지스트리 콘솔unknown/unknown이 목록에 뜹니다
- ≠ 걸림docker manifest inspect를 파싱하는 스크립트출력이 하나라는 가정이 깨집니다
- ≠ 걸림digest 대조 도구·스캐너가리키는 digest가 바뀝니다
빌드는 성공하고 배포도 안 깨집니다. 바뀐 건 태그가 가리키는 대상의 모양뿐이라 에러 없이 조용히 통과해요.
provenance manifest의 플랫폼은 unknown/unknown으로 표시됩니다. 실행할 이미지를 고르는 도구가 이 항목을 선택하지 않도록 하는 표시입니다. Docker의 attestation 저장 형식에서 확인할 수 있습니다.
저희는 provenance를 읽는 검증 정책이 없었고, 이번 변경에서는 기존 형태를 유지하기로 했습니다. 그래서 --provenance=false를 넣었습니다. 단일 플랫폼이며 별도 SBOM을 붙이지 않는 저희 설정에 해당하는 선택입니다. 여러 플랫폼을 묶는 빌드라면 이 옵션을 써도 index가 남습니다.
index의 digest와 그 안의 이미지 manifest digest는 서로 다른 문서의 값입니다. provenance를 켠 상태라면 둘이 다른 것은 정상입니다. 처음의 두 로그를 비교할 때도 이 구분이 필요합니다.
#전환할 때 함께 확인한 것들
명령 하나를 바꾸면서, 로컬 이미지나 기존 결과물 형태에 의존하는 단계가 있는지도 확인했습니다.
- 로컬 이미지 사용:
docker run으로 빌드 결과를 검사한다면 직접 푸시한 이미지를 다시 pull하거나, 검증 단계를 별도로 유지해야 합니다.docker rmi는 로컬 이미지가 없으므로 제거했습니다. - 태그 추가: 로컬에서
docker tag를 호출하던 작업은 빌드 명령에-t를 여러 번 넣는 방식으로 바꿀 수 있습니다. - digest 전달: 값을 읽어 저장하는 코드가 있다면
--metadata-file로 빌드 결과를 전달할 수 있습니다.containerimage.digest가 index인지 manifest인지 함께 확인해야 합니다. - 결과물 형태와 알림: provenance와 multi-arch 지원 여부를 확인하고, 별도 푸시 단계의 로그를 읽던 알림이나 게이트도 갱신해야 합니다.
저희 배포 스크립트는 태그를 사용했습니다. digest를 저장하거나 비교하는 시스템에서는 전환 전후의 값이 달라질 수 있으니 별도로 확인해야 합니다.
#없어진 단계와, 아직 측정하지 못한 것
변경 뒤 운영 로그에서 sending tarball과 importing to docker가 사라졌습니다. 기존 로그에서 두 단계는 각각 3.6초와 1.7초였습니다. 로컬 데몬에 결과를 넣는 과정, 데몬의 별도 푸시, 마지막 docker rmi를 제거한 것은 확인했습니다.
컨테이너 단계가 23초에서 7초로 줄어든 관측값은 있지만, 같은 캐시 조건으로 반복 비교한 결과는 없습니다. 따라서 정확한 단축 폭은 아직 측정하지 못했습니다.
처음엔 SHA-256 두 줄이 눈에 들어왔습니다. 그 값을 따라가다 보니 빌드 뒤에 남아 있던 중간 단계가 보였어요. 값을 같게 만드는 데서 끝내지 않고, 왜 그 경로가 필요했는지 다시 볼 수 있었습니다.