Two builds from the same source, two different digests
한국어 원문으로 표시합니다.
목표
빌드 산출물의 비결정성을 바이트 수준에서 찾아내고, SOURCE_DATE_EPOCH 와 tar 옵션으로 두 번의 빌드가 바이트까지 같아지게 만든 뒤, 그것을 지키는 확인 스크립트와 게이트를 세웁니다.
왜 중요한가
해시를 이름으로 쓰는 것과 그 해시가 재현되는 것은 다른 문제입니다. 같은 커밋을 두 번 빌드했는데 산출물의 다이제스트가 다르면, 배포된 바이트가 그 소스에서 나왔다는 것을 아무도 증명할 수 없습니다 — 사고가 났을 때 되돌아갈 곳이 사라지고, 캐시는 매번 빗나가며, 서명은 '이 바이트' 만 보증할 뿐 '이 소스' 를 보증하지 못합니다. 비결정성은 거의 언제나 몇 가지 흔한 자리에서 옵니다: 파일의 수정 시각, 압축 헤더에 새겨지는 시각과 이름, 디렉터리 읽기 순서, 빌드 사용자의 uid 와 gid, 그리고 산출물에 적어 넣은 빌드 시각입니다. 그 자리를 하나씩 못박아 두면 빌드는 입력의 함수가 되고, 그때부터 다이제스트 하나가 소스 전체를 가리키는 이름이 됩니다.
단계
/root/repro/src에 소스 트리를 만드세요 —src/app.py,src/lib/util.py,src/conf/app.conf,src/README.md, 그리고src/VERSION(내용은1.4.0한 줄). 그다음/root/repro/naive-build.sh <출력파일> [소스디렉터리]를 만드세요. 소스디렉터리 기본값은/root/repro/src이고, 소스를 임시 스테이징 디렉터리로 복사한 뒤tar -czf <출력파일> -C <스테이징> .로 묶습니다. 실행 권한을 주고 1초 이상 사이를 두고 두 번 돌려/root/repro/out/naive-1.tar.gz와/root/repro/out/naive-2.tar.gz를 만든 뒤,/root/repro에서sha256sum out/naive-1.tar.gz out/naive-2.tar.gz의 출력을 그대로/root/repro/naive.sha256에 저장하세요. 두 값은 달라야 합니다.- 두 산출물이 왜 다른지 증거를 남기세요. 첫째,
/root/repro/tar-diff.txt에diff <(tar --full-time -tvf /root/repro/out/naive-1.tar.gz) <(tar --full-time -tvf /root/repro/out/naive-2.tar.gz)의 출력을 그대로 저장합니다(비어 있으면 안 됩니다). 둘째, gzip 헤더를 확인합니다 —/root/repro/out/inner-1.tar에gzip -dc /root/repro/out/naive-1.tar.gz의 결과를 저장한 뒤, 같은 파일을 두 가지로 압축해/root/repro/out/hdr-keep.gz(원본 이름과 시각을 남기는 기본 압축)와/root/repro/out/hdr-none.gz(이름과 시각을 빼는 압축)를 만드세요. 셋째,/root/repro/gzip-header.txt에 두 줄을<파일이름> <FLG> <MTIME>꼴로 적으세요. 파일이름은hdr-keep.gz·hdr-none.gz순서이고, FLG 는 4번째 바이트(0부터 세면 3번)를 16진수 두 자리로, MTIME 은 5번째부터 4바이트를 리틀엔디언 부호 없는 10진수로 적습니다. /root/repro/build.sh <출력파일> [소스디렉터리]를 만드세요. 소스디렉터리 기본값은/root/repro/src입니다. naive-build.sh 처럼 스테이징으로 복사하되,SOURCE_DATE_EPOCH를 쓰고(바깥에서 주지 않으면 기본값1735689600), tar 를 이름 순으로 정렬해 묶고, 모든 멤버의 수정 시각을 그 에포크로, 소유자와 그룹을 숫자 0 으로 고정하고, 압축은 gzip 헤더에 원본 이름과 시각이 남지 않게 합니다. 실행 권한을 주고 1초 이상 사이를 두고 두 번 돌려/root/repro/out/det-1.tar.gz와/root/repro/out/det-2.tar.gz를 만든 뒤,/root/repro에서sha256sum out/det-1.tar.gz out/det-2.tar.gz의 출력을/root/repro/det.sha256에 저장하세요. 두 값은 같아야 합니다./root/repro/verify-repro.sh <빌드스크립트>를 만드세요. 받은 스크립트를 임시 디렉터리에 두 번(사이에 1초 이상) 돌려 산출물의 sha256 을 견줍니다. 같으면SAME <다이제스트>한 줄을 내고 0 으로, 다르면 첫 줄에DIFF <다이제스트1> <다이제스트2>를 내고 이어서 두 산출물의tar -tvf차이를 보인 뒤 1 로, 빌드 자체가 실패하면 첫 줄이ERROR로 시작하는 줄을 내고 2 로 끝냅니다. 작업 디렉터리에 임시 산출물을 남기지 않습니다. 실행 권한을 준 뒤/root/repro/build.sh에 돌린 결과를/root/repro/verify-good.txt에,/root/repro/naive-build.sh에 돌린 결과를/root/repro/verify-naive.txt에 저장하세요./root/repro/bad-build.sh <출력파일> [소스디렉터리]를 만드세요. build.sh 와 똑같이 결정적으로 묶되, 묶기 직전에 스테이징 디렉터리에BUILDINFO파일을 하나 더 넣습니다. 내용은 세 줄built_at=<나노초까지 찍은 UTC 시각>,built_on=<호스트 이름>,nonce=<난수>입니다. 실행 권한을 주고/root/repro/verify-repro.sh /root/repro/bad-build.sh의 출력을/root/repro/verify-bad.txt에 저장하세요. 첫 줄은DIFF로 시작해야 합니다. 이 스크립트는 고치지 말고 그대로 둡니다 — 다음 단계와 마지막 단계에서 반례로 씁니다./root/repro/build.sh를 고쳐, 묶기 직전에 스테이징에BUILDINFO를 넣되 입력에서만 나오는 값으로 채우세요. 세 줄이고 순서도 이대로입니다 —version=<소스디렉터리의 VERSION 내용>,source_tree_sha256=<소스 트리 해시>,source_date_epoch=<쓰고 있는 에포크>. 소스 트리 해시는 소스디렉터리에서LC_ALL=C find . -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum을 돌린 값의 앞 64자리입니다. 고친 뒤/root/repro/verify-repro.sh /root/repro/build.sh가 여전히SAME을 내는지 확인하고, 새로 빌드한 산출물에서 꺼낸BUILDINFO를/root/repro/buildinfo.txt에 그대로 저장하세요 (/root/repro/out/prov.tar.gz로 한 번 빌드한 뒤tar -xOf /root/repro/out/prov.tar.gz ./BUILDINFO를 쓰면 됩니다).skopeo로/opt/images/alpine_3.20.tar(oci-archive)를 읽습니다. 매니페스트 원문을/root/repro/manifest.json에 그대로 저장하고, 그 파일의 sha256 을/root/repro/manifest.sha256에sha256:<64자리 16진수>한 줄로 적으세요. 그다음 같은 아카이브를 OCI 레이아웃/root/repro/oci/alpine으로 두 번 복사해 태그v1과v2를 만드세요(두 태그의 다이제스트는 같아야 합니다). 마지막으로/root/repro/layer-recompress.txt에 세 줄을<이름> <64자리 16진수>꼴로 적으세요 — 첫 줄original은 매니페스트가 가리키는 레이어 블롭 파일의 sha256, 둘째 줄uncompressed는 그 블롭을gzip -dc로 푼 바이트의 sha256, 셋째 줄recompressed는 푼 바이트를gzip -n -9로 다시 압축한 바이트의 sha256 입니다. original 과 recompressed 는 달라야 합니다./root/repro/repro-gate.sh <빌드스크립트> <보고서파일>을 만드세요. 빌드를 두 번(사이에 1초 이상) 돌려, 같으면REPRODUCIBLE <다이제스트>를 화면과 보고서 첫 줄에 내고 0 으로, 다르면 화면과 보고서 첫 줄에DIFFERENT <다이제스트1> <다이제스트2>를 내고 3 으로, 빌드가 실패하면ERROR로 시작하는 줄을 내고 1 로 끝냅니다. 다를 때 보고서에는 첫 줄 뒤에 두 산출물의tar -tvf차이와, 풀어서 견준 내용 차이(diff -ru)와, 처음 달라지는 바이트 위치를 함께 적습니다. 보고서 파일의 상위 디렉터리가 없으면 만들어야 합니다. 실행 권한을 준 뒤/root/repro/build.sh로 돌려 보고서를/root/repro/report/good.txt에,/root/repro/bad-build.sh로 돌려 보고서를/root/repro/report/bad.txt에 남기세요.
참고
- 이 실습 파드에서는 컨테이너를 띄울 수 없습니다 — seccomp 가 새 사용자 네임스페이스 만들기를 막습니다.
docker·podman run·buildah는 쓰지 않습니다. 대신 GNU tar 1.35·gzip·sha256sum·od·cmp·diff·jq·python3.12·skopeo 1.13.3 으로 산출물 자체를 다룹니다. - 실측:
tar -czf는 gzip 에 stdin 으로 넘기므로 gzip 헤더의 시각이 0 입니다. 그래서 이 실습의 첫 비결정성은 압축이 아니라 tar 안의 수정 시각에서 옵니다. 반대로 산출물을 따로gzip <파일>하면 그 파일의 이름과 시각이 헤더에 들어갑니다. /opt/images/*.tar는 oci-archive 형식입니다:skopeo inspect --raw oci-archive:/opt/images/alpine_3.20.tar. 아키텍처가 기계마다 arm64/amd64 로 다르니 다이제스트를 외워 적지 말고 그 자리에서 계산하세요.- 흔한 실수: 스크립트에
set -e를 걸어diff·cmp가 차이를 찾은 순간(종료 코드 1) 스크립트가 먼저 죽는 것. - 흔한 실수: 두 번의 빌드를 같은 초에 끝내 놓고 '재현됐다' 고 판단하는 것. 수정 시각의 해상도는 1초입니다.
- SOURCE_DATE_EPOCH · Archives (reproducible-builds.org) · tar(1) · OCI descriptor (digest) · GZIP file format (RFC 1952)
소스는 그대로인데 산출물의 다이제스트가 달라졌다
/root/repro/src 에 소스 트리를 만드세요 — src/app.py, src/lib/util.py, src/conf/app.conf, src/README.md, 그리고 src/VERSION(내용은 1.4.0 한 줄). 그다음 /root/repro/naive-build.sh <출력파일> [소스디렉터리] 를 만드세요. 소스디렉터리 기본값은 /root/repro/src 이고, 소스를 임시 스테이징 디렉터리로 복사한 뒤 tar -czf <출력파일> -C <스테이징> . 로 묶습니다. 실행 권한을 주고 1초 이상 사이를 두고 두 번 돌려 /root/repro/out/naive-1.tar.gz 와 /root/repro/out/naive-2.tar.gz 를 만든 뒤, /root/repro 에서 sha256sum out/naive-1.tar.gz out/naive-2.tar.gz 의 출력을 그대로 /root/repro/naive.sha256 에 저장하세요. 두 값은 달라야 합니다.
임시 디렉터리는 mktemp -d 로 만들고 trap ... EXIT 으로 지웁니다. 복사에 cp -r 를 쓰면 복사본의 수정 시각이 '지금' 이 됩니다 — 그 값이 어디에 기록되는지가 이 실습의 출발점입니다. 1초를 두는 이유는 수정 시각의 해상도가 1초이기 때문입니다.
다른 바이트를 찾아 들어가면 수정 시각과 gzip 헤더가 나온다
두 산출물이 왜 다른지 증거를 남기세요. 첫째, /root/repro/tar-diff.txt 에 diff <(tar --full-time -tvf /root/repro/out/naive-1.tar.gz) <(tar --full-time -tvf /root/repro/out/naive-2.tar.gz) 의 출력을 그대로 저장합니다(비어 있으면 안 됩니다). 둘째, gzip 헤더를 확인합니다 — /root/repro/out/inner-1.tar 에 gzip -dc /root/repro/out/naive-1.tar.gz 의 결과를 저장한 뒤, 같은 파일을 두 가지로 압축해 /root/repro/out/hdr-keep.gz(원본 이름과 시각을 남기는 기본 압축)와 /root/repro/out/hdr-none.gz(이름과 시각을 빼는 압축)를 만드세요. 셋째, /root/repro/gzip-header.txt 에 두 줄을 <파일이름> <FLG> <MTIME> 꼴로 적으세요. 파일이름은 hdr-keep.gz·hdr-none.gz 순서이고, FLG 는 4번째 바이트(0부터 세면 3번)를 16진수 두 자리로, MTIME 은 5번째부터 4바이트를 리틀엔디언 부호 없는 10진수로 적습니다.
tar -tvf 의 기본 출력은 분까지만 보여 줘서 1초 차이가 묻힙니다 — 그래서 --full-time 이 필요합니다. od -An -tx1 -j3 -N1 <파일> 이 FLG 한 바이트를, od -An -tu4 -j4 -N4 <파일> 이 MTIME 4바이트를 사람이 읽는 수로 바꿔 줍니다. 공백은 tr -d ' ' 로 지웁니다. gzip 은 원본 이름을 남길 때 FLG 에 비트 하나를 세웁니다. RFC 1952 의 헤더 그림을 떠올리세요.
순서와 시각과 소유자를 못박으니 바이트까지 같아졌다
/root/repro/build.sh <출력파일> [소스디렉터리] 를 만드세요. 소스디렉터리 기본값은 /root/repro/src 입니다. naive-build.sh 처럼 스테이징으로 복사하되, SOURCE_DATE_EPOCH 를 쓰고(바깥에서 주지 않으면 기본값 1735689600), tar 를 이름 순으로 정렬해 묶고, 모든 멤버의 수정 시각을 그 에포크로, 소유자와 그룹을 숫자 0 으로 고정하고, 압축은 gzip 헤더에 원본 이름과 시각이 남지 않게 합니다. 실행 권한을 주고 1초 이상 사이를 두고 두 번 돌려 /root/repro/out/det-1.tar.gz 와 /root/repro/out/det-2.tar.gz 를 만든 뒤, /root/repro 에서 sha256sum out/det-1.tar.gz out/det-2.tar.gz 의 출력을 /root/repro/det.sha256 에 저장하세요. 두 값은 같아야 합니다.
GNU tar 의 --sort, --mtime, --owner, --group, --numeric-owner 를 찾아보세요. tar 의 -z 는 gzip 을 stdin 으로 부르지만, 헤더에서 이름과 시각을 빼는 옵션을 직접 주려면 tar 출력을 파이프로 gzip 에 넘겨야 합니다. 정렬은 로케일에 따라 달라질 수 있으니 LC_ALL=C 를 붙이는 편이 안전합니다.
재현되는지 묻는 일을 스크립트에게 넘긴다
/root/repro/verify-repro.sh <빌드스크립트> 를 만드세요. 받은 스크립트를 임시 디렉터리에 두 번(사이에 1초 이상) 돌려 산출물의 sha256 을 견줍니다. 같으면 SAME <다이제스트> 한 줄을 내고 0 으로, 다르면 첫 줄에 DIFF <다이제스트1> <다이제스트2> 를 내고 이어서 두 산출물의 tar -tvf 차이를 보인 뒤 1 로, 빌드 자체가 실패하면 첫 줄이 ERROR 로 시작하는 줄을 내고 2 로 끝냅니다. 작업 디렉터리에 임시 산출물을 남기지 않습니다. 실행 권한을 준 뒤 /root/repro/build.sh 에 돌린 결과를 /root/repro/verify-good.txt 에, /root/repro/naive-build.sh 에 돌린 결과를 /root/repro/verify-naive.txt 에 저장하세요.
빌드 스크립트의 계약은 앞 단계에서 정해졌습니다 — 첫 인자가 출력 파일 경로입니다. set -e 를 걸면 diff 가 차이를 찾은 순간 스크립트가 먼저 죽습니다. 목록이 같은데 바이트가 다를 수도 있으니 그때 무엇을 보여 줄지도 정해 두세요.
빌드 시각을 산출물에 적었더니 확인 스크립트가 잡아냈다
/root/repro/bad-build.sh <출력파일> [소스디렉터리] 를 만드세요. build.sh 와 똑같이 결정적으로 묶되, 묶기 직전에 스테이징 디렉터리에 BUILDINFO 파일을 하나 더 넣습니다. 내용은 세 줄 built_at=<나노초까지 찍은 UTC 시각>, built_on=<호스트 이름>, nonce=<난수> 입니다. 실행 권한을 주고 /root/repro/verify-repro.sh /root/repro/bad-build.sh 의 출력을 /root/repro/verify-bad.txt 에 저장하세요. 첫 줄은 DIFF 로 시작해야 합니다. 이 스크립트는 고치지 말고 그대로 둡니다 — 다음 단계와 마지막 단계에서 반례로 씁니다.
date -u +%FT%T.%NZ 는 나노초까지 찍습니다. 초 단위로만 찍으면 두 빌드가 같은 초에 끝나 우연히 통과할 수 있습니다. 산출물이 여전히 열리는 정상 tar.gz 여야 한다는 점을 잊지 마세요 — 망가진 빌드와 재현되지 않는 빌드는 다른 문제입니다.
출처 정보를 입력에서만 만들어 재현을 되찾는다
/root/repro/build.sh 를 고쳐, 묶기 직전에 스테이징에 BUILDINFO 를 넣되 입력에서만 나오는 값으로 채우세요. 세 줄이고 순서도 이대로입니다 — version=<소스디렉터리의 VERSION 내용>, source_tree_sha256=<소스 트리 해시>, source_date_epoch=<쓰고 있는 에포크>. 소스 트리 해시는 소스디렉터리에서 LC_ALL=C find . -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum 을 돌린 값의 앞 64자리입니다. 고친 뒤 /root/repro/verify-repro.sh /root/repro/build.sh 가 여전히 SAME 을 내는지 확인하고, 새로 빌드한 산출물에서 꺼낸 BUILDINFO 를 /root/repro/buildinfo.txt 에 그대로 저장하세요 (/root/repro/out/prov.tar.gz 로 한 번 빌드한 뒤 tar -xOf /root/repro/out/prov.tar.gz ./BUILDINFO 를 쓰면 됩니다).
해시는 스테이징이 아니라 소스디렉터리에서 계산해야 합니다 — BUILDINFO 자신이 해시에 섞이면 값이 무엇에 대한 것인지 알 수 없게 됩니다. 커밋 해시처럼 입력에 딸려 오는 값은 넣어도 되고, 빌드 시각·호스트 이름·난수는 안 됩니다. 채점기는 소스를 한 글자 바꾼 사본으로도 빌드해 해시가 따라 바뀌는지 봅니다.
다이제스트는 바이트를 보증하지, 빌드를 보증하지 않는다
skopeo 로 /opt/images/alpine_3.20.tar(oci-archive)를 읽습니다. 매니페스트 원문을 /root/repro/manifest.json 에 그대로 저장하고, 그 파일의 sha256 을 /root/repro/manifest.sha256 에 sha256:<64자리 16진수> 한 줄로 적으세요. 그다음 같은 아카이브를 OCI 레이아웃 /root/repro/oci/alpine 으로 두 번 복사해 태그 v1 과 v2 를 만드세요(두 태그의 다이제스트는 같아야 합니다). 마지막으로 /root/repro/layer-recompress.txt 에 세 줄을 <이름> <64자리 16진수> 꼴로 적으세요 — 첫 줄 original 은 매니페스트가 가리키는 레이어 블롭 파일의 sha256, 둘째 줄 uncompressed 는 그 블롭을 gzip -dc 로 푼 바이트의 sha256, 셋째 줄 recompressed 는 푼 바이트를 gzip -n -9 로 다시 압축한 바이트의 sha256 입니다. original 과 recompressed 는 달라야 합니다.
skopeo inspect --raw oci-archive:<파일> 이 매니페스트 원문을 그대로 냅니다 — 다이제스트는 바로 그 바이트의 sha256 입니다. 복사는 skopeo copy --insecure-policy oci-archive:<파일> oci:<디렉터리>:<태그> 이고, 상위 디렉터리가 먼저 있어야 합니다. 레이아웃 안에서 블롭 파일 이름은 그 블롭 자신의 다이제스트입니다. 아키텍처가 기계마다 다르니 값을 외우지 말고 그 자리에서 계산하세요.
재현되지 않는 빌드를 파이프라인에서 세운다
/root/repro/repro-gate.sh <빌드스크립트> <보고서파일> 을 만드세요. 빌드를 두 번(사이에 1초 이상) 돌려, 같으면 REPRODUCIBLE <다이제스트> 를 화면과 보고서 첫 줄에 내고 0 으로, 다르면 화면과 보고서 첫 줄에 DIFFERENT <다이제스트1> <다이제스트2> 를 내고 3 으로, 빌드가 실패하면 ERROR 로 시작하는 줄을 내고 1 로 끝냅니다. 다를 때 보고서에는 첫 줄 뒤에 두 산출물의 tar -tvf 차이와, 풀어서 견준 내용 차이(diff -ru)와, 처음 달라지는 바이트 위치를 함께 적습니다. 보고서 파일의 상위 디렉터리가 없으면 만들어야 합니다. 실행 권한을 준 뒤 /root/repro/build.sh 로 돌려 보고서를 /root/repro/report/good.txt 에, /root/repro/bad-build.sh 로 돌려 보고서를 /root/repro/report/bad.txt 에 남기세요.
앞 단계의 verify-repro.sh 를 그대로 쓰지 말고, 세 가지 결말과 보고서를 이 스크립트가 직접 다루게 만드세요 — 게이트는 실패할 때 무엇이 다른지 남겨야 쓸모가 있습니다. 내용 차이를 보려면 두 산출물을 각각 임시 디렉터리에 풀어야 합니다. 채점기는 스스로 만든 정상·비결정적·망가진 빌드 스크립트 셋으로 이 게이트를 시험합니다.