같은 빌드에서 sha256이 두 개 나왔다
#sha256은 왜 두 개였을까
빌드 시간을 줄여 보려고 Jenkins 파이프라인 로그를 읽어 봤습니다.
#12 exporting manifest sha256:4e285a83…
└ 빌드가 만든 이미지
...
git-a1b2c3d: digest: sha256:44107a56…
└ 레지스트리에 올라간 이미지
빌드가 만든 이미지와 레지스트리에 올라간 이미지에 붙은 sha256이 달랐습니다. 같은 이미지인데 왜 다른지 몰랐어요.
digest 두 개는 사내 값이라 이 글의 데모가 쓰는 값으로 바꿔 적었습니다.
이유는 같은 이미지를 두 번 압축하고 있어서였습니다. 그걸 알고 나서 빌드하고 올리는 두 단계를 한 명령으로 합쳤습니다. 컨테이너 단계는 23초에서 7초가 됐어요.
#digest는 어떻게 정해지나
컨테이너 이미지는 파일 하나가 아닙니다. 레이어라고 부르는 tar 파일 몇 개와 JSON 문서 두 장이 서로를 가리키며 붙어 있어요. 한 장은 실행 설정을 담은 config고 다른 한 장은 그 전부를 나열한 목록인 manifest입니다.
git-a1b2c3d사람이 옮겨 붙이는 이름표sha256:4e285a83e8be92f3abf7d776900b07ea3e0d1bfe9ed9b9d981b841c6371884e4아래 전부의 목록sha256:189a3d6f110c9b6d17a6e69f302a8f3a137617cb865786682e595bc8522aed2e379BENV · ENTRYPOINT- base
sha256:0ef99b057473e28dd33d8bff0b36a46db6b6f9290ce2c38dc0c491d69c6a4ad968KBalpine - deps
sha256:4d5c0e2a885e6c28acf90ebc60b27365d9194bb0dcc0ab30e09b4ad423f7644d224KBnode_modules - app
sha256:97e2fc9921f9ad3adb43663b7eca76d24723edfeba10a91ca28bd48cbde4bfb930KBbuild output
화살표는 전부 digest 참조예요. 위에서 아래로 내용이 digest를 결정하고 태그만 그 바깥에서 손으로 옮겨 붙습니다.
레이어와 JSON 문서는 파일명 대신 digest로 서로를 가리킵니다. sha256은 파일을 넣으면 정해진 길이의 문자열 하나를 내놓는 함수예요. 같은 파일이면 늘 같은 문자열이 나오고 1바이트만 달라도 전혀 다른 문자열이 나옵니다. 그 문자열이 digest입니다. git 커밋 해시와 같은 방식이에요.
기준선: FROM alpine:3.20
달라진 자리 61/64 · 뒤집힌 비트 126/256
한 글자를 바꿨는데 절반쯤이 뒤집혔어요. 조금 비슷한 digest라는 건 없어서 digest 비교가 곧 내용 비교입니다.
태그만 예외입니다. 계산해서 나온 게 아니라 사람이 붙이는 이름표라서 다른 이미지로 옮겨 붙일 수 있어요. 그래서 latest는 어제와 오늘 다른 이미지를 가리킬 수 있습니다.
레이어는 보통 gzip으로 압축해서 올립니다. manifest에 적히는 레이어 digest는 그 압축을 끝낸 파일로 계산해요. 그래서 안에 든 파일이 같아도 압축한 결과가 1바이트라도 다르면 레이어 digest가 바뀝니다. manifest는 그 digest를 적어 둔 문서니까 내용도 바뀌고 manifest의 digest까지 따라 바뀝니다.
그러면 두 로그 사이에서 압축이 한 번 더 된 자리만 찾으면 됩니다.
#--load 경로를 따라가 보니
누가 압축하는지 찾으려면 어디서 빌드했고 어디로 내보냈는지를 보면 됩니다. 이미지를 만드는 쪽과 보관해서 올리는 쪽은 원래 다른 프로그램이에요. 빌드는 BuildKit이 하고 만든 이미지를 들고 있다가 레지스트리로 올리는 건 도커 데몬이 합니다. 제 파이프라인에서는 그 BuildKit이 데몬과 떨어진 컨테이너에 있었습니다. 그래서 --load로 결과물을 데몬으로 옮기는 배달 단계가 필요했고 그 흔적이 로그에 남아 있었어요.
#12 exporting to docker image format
└ OCI → docker 포맷 변환
#12 sending tarball 3.6s
└ BuildKit → 데몬 배달
#13 importing to docker 1.7s
└ 데몬이 저장
(그 뒤 docker push ~4s, docker rmi)
이미지가 두 번 압축되고 있었습니다. 로그 두 줄 사이에서 이런 일이 벌어졌어요.
- BuildKit이 레이어를 압축합니다. 압축한 파일로 레이어 digest가 정해지고 그 digest를 적은 manifest가
4e285a83이 됐어요. --load가 그 결과물을 데몬에 넘기고 데몬이 풀어서 넣어 둡니다. 데몬이 이미지를 보관하는 방식은 두 가지인데 클래식 저장소는 압축한 파일을 그대로 두지 않고 풀어서 들고 있어요.docker push가 올릴 때 데몬이 자기 방식으로 다시 압축합니다.- 압축하는 쪽이 바뀌면 결과 파일의 바이트도 바뀝니다. 그래서 레이어 digest가 바뀌고 데몬이 manifest를 새로 써서 레지스트리에는
44107a56이 남았습니다.
나머지 한 가지가 압축한 파일을 그대로 보관하는 containerd 저장소인데 그걸 쓰면 3번이 일어나지 않습니다. 저희 데몬은 클래식 저장소였어요. Docker Engine 29부터는 새로 설치하면 containerd 저장소가 기본이라 이 현상을 아예 안 볼 수도 있습니다.
git-a1b2c3d같음sha256:4e285a83e8be92f3abf7d776900b07ea3e0d1bfe9ed9b9d981b841c6371884e4같음sha256:189a3d6f110c9b6d17a6e69f302a8f3a137617cb865786682e595bc8522aed2e같음sha256:0ef99b057473e28dd33d8bff0b36a46db6b6f9290ce2c38dc0c491d69c6a4ad9같음sha256:4d5c0e2a885e6c28acf90ebc60b27365d9194bb0dcc0ab30e09b4ad423f7644d같음sha256:97e2fc9921f9ad3adb43663b7eca76d24723edfeba10a91ca28bd48cbde4bfb9같음압축하기 전 tar 내용의 digest는 두 경로에서 완전히 같아요
- sha256:c62809b5a2eb…
- sha256:8d8ecf49748f…
- sha256:3ceb43832add…
BuildKit이 만든 바이트가 그대로 올라가니까 빌드 로그의 digest와 레지스트리의 digest가 일치합니다.
컨테이너를 띄우면 같은 앱이 돕니다. 다만 압축만 달라진 건지는 이 글에서 가르지 않았어요. 데몬은 풀어 둔 파일에서 tar를 다시 만들기 때문에 압축하기 전 바이트부터 달랐을 수도 있습니다. 가르려면 두 경로의 rootfs.diff_ids를 비교하면 됩니다.
셀 하나가 바이트 하나예요. 빨간 셀이 기준선과 다른 바이트입니다.
다른 바이트 1개 · 오프셋 8(헤더 안) · 길이는 같음
달라진 건 헤더의 XFL 한 바이트뿐인데 digest는 완전히 갈려요. 그래서 두 레이어의 크기가 621B로 같은데 digest가 다릅니다.
--push는 이 중간 경로를 없앱니다. BuildKit이 압축한 그대로 레지스트리에 직접 올려요.
--load+docker push: 빌드 → 압축 → 압축 해제 → 데몬 배달 → 데몬이 다시 압축 → 올리기--push: 빌드 → 압축 → 올리기
빌드하고 나서 docker push를 따로 부르던 두 단계가 한 명령이 됩니다. 배달 왕복 ~5초가 사라지고 다시 압축할 쪽도 없어져요. 대신 데몬에 로컬 이미지가 생기지 않으니 끝의 docker rmi도 같이 없애야 합니다.
#증명서가 따라 붙습니다
속도만 보고 전환하면 놓치는 게 하나 더 있어요.
ARM 맥에서도 인텔 서버에서도 docker pull nginx가 되는 이유가 있습니다. 그 태그는 이미지 하나가 아니라 목차를 가리켜요. 목차에는 "amd64용은 이 manifest, arm64용은 저 manifest"가 적혀 있고 pull하는 쪽이 자기 것을 고릅니다. 이 목차가 image index예요.
BuildKit은 이 목차에 이미지가 아닌 것도 끼워 넣습니다. 이미지가 어떻게 빌드됐는지 담은 증명서(provenance)예요. 증명서에는 적을 플랫폼이 없기도 하고 pull하는 쪽이 이걸 이미지로 골라 가면 안 되니까 unknown/unknown이라는 가짜 플랫폼을 붙여 둡니다. 플랫폼을 보고 고르는 도구는 이 항목을 건너뜁니다.
--load를 쓰는 동안은 이걸 볼 일이 없었습니다. 데몬의 클래식 저장소는 태그 하나에 이미지 하나만 담는 모델이라 목차를 못 받고 증명서도 붙을 수 없었으니까요. --push로 바꾸는 순간 그 제약이 사라지면서 태그가 가리키는 대상이 바뀝니다.
--load: 태그 → manifest--push: 태그 → 목차 → manifest + 증명서
아무 설정도 안 바꿨는데 결과물의 모양이 달라집니다.
git-a1b2c3d태그 자체는 안 변해요sha256:e77bf33e29d7558089380f32630c6547983dd2bdbaa1412944a524b8cd2b2534666B태그가 이제 여기를 가리켜요sha256:4e285a83e8be92f3abf7d776900b07ea3e0d1bfe9ed9b9d981b841c6371884e4709Blinux/amd64 · 그대로sha256:b6bad6265921b57ad5759756017ec529d112881479d049c75a3560c440e9920a463Bunknown/unknown이라는 가짜 플랫폼태그가 가리키는 digest sha256:e77bf33e29d7…
- = 무영향k8s (containerd)목차에서 자기 플랫폼을 골라 pull하니까 배포는 안 깨져요
- = 무영향레지스트리 콘솔unknown/unknown이 목록에 뜹니다
- ≠ 걸림docker manifest inspect를 파싱하는 스크립트출력이 하나라는 가정이 깨집니다
- ≠ 걸림digest 대조 도구·스캐너가리키는 digest가 바뀝니다
빌드는 성공하고 배포도 안 깨집니다. 바뀐 건 태그가 가리키는 대상의 모양뿐이라 에러 없이 조용히 통과해요.
이건 전환 PR을 다 만들고 나서야 알아챘습니다. 플래그 하나를 바꿔도 전후 동작 차이를 목록으로 만들어 하나씩 지워 봤는데 끝까지 지워지지 않은 항목이 이 증명서였어요. 제 파이프라인에는 서명 검증 정책처럼 증명서를 읽는 쪽이 없어서 --provenance=false로 기존 형태를 고정했습니다. 빌드와 배포가 성공해도 결과물 모양은 이렇게 달라질 수 있습니다. 빌드 로그에 exporting attestation manifest 한 줄로 지나가는데 성공만 확인하고 넘기면 안 보여요.
#전환하면서 걸린 것들
전환하면 압축하는 쪽이 달라지니 같은 코드를 빌드해도 레이어와 manifest의 digest가 전부 새로 나옵니다. digest를 보고 "전에 봤던 이미지인지" 구분하는 시스템이 있다면 전환하는 그 시점에 모든 이미지가 처음 보는 것으로 보여요. 저희 배포 스크립트는 digest를 안 보고 태그만 보기 때문에 아무 일도 없었습니다.
반드시 손봐야 하는 건 하나였습니다. 이미지가 더는 로컬에 남지 않아요. 그래서 빌드 끝의 docker rmi는 지울 게 없어 No such image로 실패하고 로컬 이미지를 쓰던 단계(docker run 확인, 태그 추가)도 같이 깨집니다.
고치는 건 간단합니다. rmi는 지우고 태그가 더 필요하면 빌드할 때 -t를 여러 번 주면 됩니다. -t는 만들 이미지에 붙일 태그를 정하는 옵션이고 개수 제한이 없어요. 전에는 로컬 이미지에 docker tag로 태그를 덧붙였는데 이제 로컬 이미지가 없으니 빌드할 때 한꺼번에 지정합니다.
나머지 셋은 해당하는 것만 손보면 됩니다.
- digest를 읽어 저장하거나 비교하는 코드가 있으면
--metadata-file로 새 digest를 받아 넘겨줍니다. 증명서를 켜 두면 거기 담기는containerimage.digest는 manifest가 아니라 목차의 digest예요. - 증명서는 읽는 시스템이 있거나 도입할 계획이면 기본값대로 켜 둡니다. 저희처럼 아직 없으면
--provenance=false로 꺼서 지금과 같은 모양으로 둘 수 있어요. 단일 플랫폼 빌드 기준이고 multi-arch라면 목차는 어차피 남습니다. - "이미지 푸시" 같은 별도 단계가 사라지니 그 로그 문구를 찾아 울리던 알림이나 게이트가 있으면 갱신합니다.
#digest 두 개가 하나로
이제 처음의 두 로그로 돌아갑니다.
sha256:4e285a83e8be92f3abf7d776900b07ea3e0d1bfe9ed9b9d981b841c6371884e4 ≠ sha256:44107a568449de5a19583f457a34ba8a2103654ec6c29f6004af196e68e13f44 글을 연 그 두 줄이에요.
지금 운영 배포 로그에는 배달 두 줄(sending tarball, importing to docker)이 없고 데몬이 다시 압축하는 일도 없습니다.
컨테이너 단계는 23초에서 7초로 줄었습니다. 캐시 상태가 다른 빌드끼리 비교한 값이라 정확하지는 않습니다. 그래도 tar 배달 왕복 5.3초, 데몬이 다시 압축하던 일, docker rmi가 없어진 것은 확실해요.