LabHub
배우기 러닝패스 코스

CI/CD 파이프라인 · 같은 바이트가 나오는 빌드 · 실습

같은 소스로 두 번 빌드했더니 다이제스트가 달라졌다

LabHub 에서 이어서 보기

목표

빌드 산출물의 비결정성을 바이트 수준에서 찾아내고, 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.txtdiff <(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.targzip -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.sha256sha256:<64자리 16진수> 한 줄로 적으세요. 그다음 같은 아카이브를 OCI 레이아웃 /root/repro/oci/alpine 으로 두 번 복사해 태그 v1v2 를 만드세요(두 태그의 다이제스트는 같아야 합니다). 마지막으로 /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 에 남기세요.

참고

단계 8개

  1. 소스는 그대로인데 산출물의 다이제스트가 달라졌다
  2. 다른 바이트를 찾아 들어가면 수정 시각과 gzip 헤더가 나온다
  3. 순서와 시각과 소유자를 못박으니 바이트까지 같아졌다
  4. 재현되는지 묻는 일을 스크립트에게 넘긴다
  5. 빌드 시각을 산출물에 적었더니 확인 스크립트가 잡아냈다
  6. 출처 정보를 입력에서만 만들어 재현을 되찾는다
  7. 다이제스트는 바이트를 보증하지, 빌드를 보증하지 않는다
  8. 재현되지 않는 빌드를 파이프라인에서 세운다