왜 재현성이 문제가 되는가

같은 소스 코드를 같은 커밋에서 빌드했는데 바이너리 해시가 매번 달라진다면, "지금 배포된 이 아티팩트가 정말 그 커밋에서 나온 것인가"를 증명할 수 없다. 공급망 보안(SLSA), 인시던트 재현, 캐시 적중률, 감사 대응이 모두 여기서 무너진다. 결정론적 빌드란 동일 입력 → 동일 출력을 보장하는 빌드를 말한다. 실무에서 비결정성의 원인은 대부분 몇 가지로 좁혀진다.

비결정성의 주요 원인

원인증상대표 해법
타임스탬프빌드할 때마다 mtime 변화SOURCE_DATE_EPOCH 고정
파일 순서glob·os.listdir 순서 의존정렬 후 처리
절대 경로빌드 디렉터리 경로 임베딩경로 정규화·remap
버전 부동latest 태그, 범위 버전lockfile·다이제스트 핀
병렬성스레드 순서에 따른 출력 차이결정론적 병합

타임스탬프와 파일 순서 고정

대부분의 도구는 SOURCE_DATE_EPOCH 환경변수를 인식해 아카이브·컴파일 산출물의 시각을 통일한다. tar를 직접 만들 때도 mtime, uid/gid, 정렬을 명시해야 한다.

# 마지막 커밋 시각을 기준 epoch로 고정
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)

# 결정론적 tar: 정렬·소유자 제거·mtime 통일
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 release.tar ./dist

sha256sum release.tar

애플리케이션 코드에서도 마찬가지다. 디렉터리 순회 결과를 정렬하지 않으면 파일시스템 순서에 따라 출력이 바뀐다.

import hashlib, os

def manifest(root):
    # os.walk 순서는 파일시스템 의존적 -> 항상 정렬
    lines = []
    for dirpath, dirs, files in os.walk(root):
        dirs.sort()
        for name in sorted(files):
            p = os.path.join(dirpath, name)
            with open(p, "rb") as f:
                digest = hashlib.sha256(f.read()).hexdigest()
            # 절대 경로 대신 root 기준 상대 경로만 기록
            rel = os.path.relpath(p, root)
            lines.append(f"{digest}  {rel}")
    return "\n".join(lines)

의존성 고정: lockfile과 다이제스트

버전 범위나 latest 태그는 재현성의 가장 흔한 파괴자다. 언어 패키지는 lockfile을 커밋하고 CI에서 --frozen-lockfile/npm ci처럼 lockfile을 강제하는 명령을 쓴다. 컨테이너 베이스 이미지는 태그가 아니라 다이제스트로 핀한다.

# 태그는 언제든 재발행될 수 있다 -> 다이제스트로 고정
FROM python:3.12-slim@sha256:1a2b3c...  

# 빌드 시 결정론 옵션
ENV PYTHONHASHSEED=0 \
    PIP_NO_COMPILE=1

# 해시 검증까지 강제
COPY requirements.lock .
RUN pip install --require-hashes --no-deps -r requirements.lock

CI에서의 검증 파이프라인

결정론은 "설정했다"가 아니라 "검증한다"로 완성된다. 같은 커밋을 두 번 빌드해 해시를 비교하는 단계를 파이프라인에 넣으면 회귀를 조기에 잡는다.

jobs:
  reproducible:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build twice and compare
        run: |
          export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
          make build && sha256sum dist/app > a.sha
          rm -rf dist
          make build && sha256sum dist/app | awk '{print $1}' > b.sha
          diff <(awk '{print $1}' a.sha) b.sha \
            || { echo "빌드가 비결정적입니다"; exit 1; }

주의점

몇 가지 함정이 있다. 첫째, 링커와 컴파일러가 절대 경로를 디버그 정보에 심는다. GCC/Clang은 -ffile-prefix-map=$(pwd)=.로 remap하고, Go는 -trimpath를 쓴다. 둘째, 병렬 빌드의 산출물 병합 순서를 고정하지 않으면 정렬해도 소용없다. 셋째, 로케일과 타임존(LC_ALL=C, TZ=UTC)이 정렬·포맷 결과를 바꾼다. 넷째, 완벽한 비트 단위 재현이 항상 목표는 아니다. 코드 서명이나 의도적 랜덤 시드처럼 재현 불가능한 요소가 있다면, 서명 이전 단계까지의 재현성을 보장하고 그 경계를 문서화하는 편이 현실적이다.

정리

결정론적 빌드는 특별한 도구가 아니라 입력을 남김없이 고정하는 규율에서 나온다. 타임스탬프·파일 순서·경로·의존성·환경(로케일/타임존)을 명시하고, CI에서 두 번 빌드 비교로 상시 검증하라. 이 규율이 자리 잡으면 캐시 효율, 공급망 증명, 인시던트 재현이 부수적으로 따라온다.