LabHub
배우기 러닝패스 코스

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

해시로 이름을 붙였다고 재현되는 것은 아니다

LabHub 에서 이어서 보기

한 줄 요약

산출물에 해시로 이름을 붙이는 일과, 같은 소스에서 같은 바이트가 나오게 만드는 일은 서로 다른 이야기다. 앞의 것은 이름표를 정확히 붙이는 일이고, 뒤의 것은 빌드를 "입력이 같으면 출력이 같은 함수" 로 만드는 일이다. 이름표만 붙여 놓고 재현된다고 말하는 파이프라인이 아주 많다.

왜 이게 필요했나

"소스 해시로 태그를 붙였으니 재현된다" 는 말은 흔히 듣지만 증명된 적은 드물다. 같은 커밋을 두 번 빌드해 두 산출물의 SHA-256 을 직접 비교해 보면 대개 다르다. 그런데 이름은 같다. 이름이 같고 내용이 다른 산출물은 세 가지를 동시에 망가뜨린다. 캐시가 거짓말을 하고, 승격 모델이 무너지고, 사고 회고에서 "그때 나간 게 정확히 이건가" 라는 질문에 아무도 답하지 못한다.

재현성이 실제로 사 주는 것은 세 가지다. 첫째, 캐시를 믿을 수 있게 된다. 같은 열쇠면 같은 결과라는 전제가 성립해야 캐시 적중을 "빌드를 건너뛰어도 좋다" 의 근거로 쓸 수 있다. 둘째, 산출물의 출처를 다시 만들어 확인할 수 있다. 같은 소스와 같은 절차로 다시 빌드해 바이트가 일치하면, 그 산출물이 그 소스에서 나왔다는 것을 서명 없이도 대조할 수 있다. 셋째, 사고 때 그때의 산출물을 되살릴 수 있다. 저장소에서 지워졌어도 소스와 절차가 남아 있으면 같은 바이트를 다시 얻는다.

비결정성은 어디서 들어오나

출처는 거의 정해져 있다. 새 언어, 새 도구를 만나도 목록은 크게 달라지지 않는다.

어떻게 고정하나

핵심 약속이 SOURCE_DATE_EPOCH 다. 공식 문서는 이 변수를 "무언가의, 보통은 소스 코드의 마지막 수정 시각을 유닉스 에포크 이후의 초 수로 나타낸 값" 으로 정의한다. 빌드 도구가 시각을 적어야 할 때 현재 시각 대신 이 값을 쓰기로 한 것이다. 값은 대개 그 커밋의 시각에서 가져온다.

아카이브는 특히 조심해야 한다. 공식 문서가 권하는 형태는 다음과 같다.

export SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)"tar --sort=name \    --mtime="@${SOURCE_DATE_EPOCH}" \    --owner=0 --group=0 --numeric-owner \    --pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime \    -cf product.tar buildgzip -6 -n < product.tar > product.tar.gz   # -n 은 원본 이름과 시각을 빼고 압축한다

--sort=name 이 파일 순서를, --mtime 이 시각을, --owner/--group/--numeric-owner 가 소유자를, --pax-option 이 PAX 헤더에 섞여 들어가는 atime/ctime 을 없앤다. 이 조합은 GNU tar 1.28 이상이 필요하다. 그리고 판정은 눈이 아니라 해시로 한다. 두 번 만들어 sha256sum 이 같은지 보는 것, 그것 말고 재현성을 확인하는 방법은 없다.

다이제스트가 보장하는 것과 보장하지 않는 것

OCI 이미지 명세는 다이제스트를 "바이트에 대한 충돌 저항성 해시" 로 정의하고, 이것이 콘텐츠 주소 지정을 가능하게 한다고 적는다. 다이제스트를 안전한 경로로 전달받았다면, 신뢰할 수 없는 곳에서 받은 내용이라도 해시를 다시 계산해 대조함으로써 변조되지 않았음을 확인할 수 있다는 것이다. 형식은 알고리즘:인코딩된값 이고, 명세는 신뢰할 수 없는 출처의 내용은 쓰기 전에 다이제스트로 검증해야 한다고 권고한다.

이 성질은 저장 형식에도 그대로 박혀 있다. OCI 이미지 레이아웃 명세는 blobs/<알고리즘>/<인코딩된값> 에 놓인 내용이 반드시 다이제스트 알고리즘:인코딩된값 과 일치해야 한다고 요구한다. 즉 파일 이름이 위치가 아니라 내용 그 자체 를 가리킨다. 레이아웃에는 oci-layout 과 진입점 역할을 하는 index.json 도 함께 있어야 한다.

여기서 선을 정확히 그어야 한다. 다이제스트가 보장하는 것은 "내가 받은 바이트가 그 바이트가 맞다" 까지다. 그 바이트가 어느 소스에서 어떤 절차로 나왔는지는 전혀 말해 주지 않는다. 그 연결을 주장하려면 빌드가 무엇을 입력으로 무슨 절차를 거쳤는지 따로 기록해야 하고, 그것이 출처 증명(provenance)이 하는 일이다. 재현 가능한 빌드는 그 주장을 누구나 다시 만들어 검증할 수 있게 만든다는 점에서 짝을 이룬다.

현장에서 만나는 모습

참고

다음 실습에서 할 것

셸과 tar, sha256sum 만으로 같은 소스에서 같은 바이트가 나오는지 직접 대조한다. 먼저 아무 대책 없이 아카이브를 두 번 만들어 해시가 갈리는 것을 확인하고, 시각·순서·소유자·gzip 헤더를 하나씩 고정하면서 어느 항목이 몇 바이트를 움직였는지 눈으로 좇는다. 마지막에는 SOURCE_DATE_EPOCH 를 커밋 시각에서 뽑아 쓰는 빌드 스크립트로 정리하고, 두 번 돌린 결과의 해시가 같은지로 판정한다. 이 파드에서는 컨테이너를 띄울 수 없으므로 이미지 쪽은 skopeo 로 이미 있는 oci-archive 의 다이제스트를 읽어 대조하는 방식으로 다룬다.