CI/CD 파이프라인 · 같은 바이트가 나오는 빌드 · 실습
같은 소스로 두 번 빌드했더니 다이제스트가 달라졌다
목표
빌드 산출물의 비결정성을 바이트 수준에서 찾아내고, SOURCE_DATE_EPOCH 와 tar 옵션으로 두 번의 빌드가 바이트까지 같아지게 만든 뒤, 그것을 지키는 확인 스크립트와 게이트를 세웁니다.
왜 중요한가
해시를 이름으로 쓰는 것과 그 해시가 재현되는 것은 다른 문제입니다. 같은 커밋을 두 번 빌드했는데 산출물의 다이제스트가 다르면, 배포된 바이트가 그 소스에서 나왔다는 것을 아무도 증명할 수 없습니다 — 사고가 났을 때 되돌아갈 곳이 사라지고, 캐시는 매번 빗나가며, 서명은 '이 바이트' 만 보증할 뿐 '이 소스' 를 보증하지 못합니다. 비결정성은 거의 언제나 몇 가지 흔한 자리에서 옵니다: 파일의 수정 시각, 압축 헤더에 새겨지는 시각과 이름, 디렉터리 읽기 순서, 빌드 사용자의 uid 와 gid, 그리고 산출물에 적어 넣은 빌드 시각입니다. 그 자리를 하나씩 못박아 두면 빌드는 입력의 함수가 되고, 그때부터 다이제스트 하나가 소스 전체를 가리키는 이름이 됩니다.
단계
1. /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 에 저장하세요. 두 값은 달라야 합니다.
2. 두 산출물이 왜 다른지 증거를 남기세요. 첫째, /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진수로 적습니다.
3. /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 에 저장하세요. 두 값은 같아야 합니다.
4. /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 에 저장하세요.
5. /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 로 시작해야 합니다. 이 스크립트는 고치지 말고 그대로 둡니다 — 다음 단계와 마지막 단계에서 반례로 씁니다.
6. /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 를 쓰면 됩니다).
7. 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 는 달라야 합니다.
8. /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](https://reproducible-builds.org/docs/source-date-epoch/) · [Archives (reproducible-builds.org)](https://reproducible-builds.org/docs/archives/) · [tar(1)](https://man7.org/linux/man-pages/man1/tar.1.html) · [OCI descriptor (digest)](https://github.com/opencontainers/image-spec/blob/main/descriptor.md) · [GZIP file format (RFC 1952)](https://www.rfc-editor.org/rfc/rfc1952)
단계 8개
- 소스는 그대로인데 산출물의 다이제스트가 달라졌다
- 다른 바이트를 찾아 들어가면 수정 시각과 gzip 헤더가 나온다
- 순서와 시각과 소유자를 못박으니 바이트까지 같아졌다
- 재현되는지 묻는 일을 스크립트에게 넘긴다
- 빌드 시각을 산출물에 적었더니 확인 스크립트가 잡아냈다
- 출처 정보를 입력에서만 만들어 재현을 되찾는다
- 다이제스트는 바이트를 보증하지, 빌드를 보증하지 않는다
- 재현되지 않는 빌드를 파이프라인에서 세운다