One line changed in the lock file, and the cache brought the old dependency back
한국어 원문으로 표시합니다.
목표
파이프라인 캐시의 열쇠를 손으로 지어 보며 적중·빗나감·접두 대체를 만들고, 캐시와 산출물의 차이를 삭제 실험으로 확인하고, 열쇠에 런타임 판과 브랜치를 넣지 않았을 때 무엇이 오염되는지 재현한 뒤, 적중률 보고서와 신뢰 게이트까지 만듭니다.
왜 중요한가
캐시는 파이프라인에서 가장 쉽게 넣고 가장 오래 틀린 채로 남는 장치입니다. 열쇠가 너무 좁으면 매번 빗나가 캐시가 없는 것과 같고, 너무 넓으면 바뀐 의존성·다른 런타임·남의 브랜치가 만든 내용을 그대로 되살립니다. 뒤쪽 실패가 훨씬 나쁜데, 빌드가 성공해 버리기 때문에 아무도 신고하지 않습니다. "캐시를 지우면 고쳐진다" 는 말이 농담이 아닌 이유가 여기 있습니다 — 그 말은 캐시가 숨은 입력이 되었다는 뜻입니다. 그래서 캐시 설계는 세 가지를 정하는 일입니다. 무엇이 바뀌면 열쇠가 바뀌어야 하는가, 정확히 맞는 것이 없을 때 무엇까지 빌려와도 되는가, 그리고 누가 캐시에 쓸 수 있는가. 이 셋을 정해 두면 도구가 GitHub Actions 든 GitLab CI 든 BuildKit 이든 같은 설정을 옮겨 적기만 하면 됩니다.
단계
/root/cache-lab아래에 실습 바탕을 만드세요. (1)/root/cache-lab/env/runtime.txt에python3.12한 줄. (2)/root/cache-lab/deps.lock에<이름> <판>여섯 줄을 이 순서로 적습니다:left-pad 1.3.0,fast-json 2.1.4,tiny-http 0.9.2,color-util 4.0.1,date-fmt 3.2.2,crypto-lite 1.0.7. (3)/root/cache-lab/seed-repo.sh <작업디렉터리>는 잠금 파일을 읽어 패키지마다<작업디렉터리>/pkgrepo/<이름>/<판>/f01.txt부터f12.txt까지 12개를 만들고, 각 파일에는<이름> <판> <번호두자리>한 줄만 씁니다. (4)/root/cache-lab/install.sh <작업디렉터리>는 잠금 파일을 읽어 패키지마다sleep 0.05로 내려받기 비용을 흉내 낸 뒤pkgrepo/<이름>/<판>을vendor/<이름>으로 복사하고, 마지막에vendor/_built-with.txt에env/runtime.txt의 내용을 적고installed=<개수> ms=<밀리초>한 줄을 출력합니다(vendor 는 매번 새로 만듭니다). (5) 두 스크립트를 차례로 돌린 뒤/root/cache-lab/reports/cold.json에files·bytes·ms세 숫자를 적으세요. files 는find /root/cache-lab/vendor -type f | wc -l, bytes 는 그 파일들의 크기 합, ms 는 install.sh 가 알려 준 값입니다.- 캐시 저장·복원의 뼈대를 만드세요.
/root/cache-lab/cache-save.sh <작업디렉터리> <열쇠>는vendor디렉터리를<작업디렉터리>/cache/<열쇠>.tar.gz로 묶고(tar czf ... -C <작업디렉터리> vendor),<작업디렉터리>/cache/index.txt에<시각><탭><열쇠>한 줄을 덧붙인 뒤saved <열쇠>를 출력합니다./root/cache-lab/cache-restore.sh <작업디렉터리> <열쇠>는 그 tar 가 있으면 vendor 를 지우고 풀어 낸 뒤hit <열쇠>와 종료 코드 0, 없으면 vendor 를 건드리지 않고miss <열쇠>와 종료 코드 1 로 끝냅니다. 그다음 열쇠manual-v1로 저장하고,vendor를 통째로 지우고, 같은 열쇠로 복원하세요. 복원 전후의 지문을/root/cache-lab/reports/roundtrip.txt에before=<지문>·after=<지문>·same=yes세 줄로 적습니다. 지문은cd /root/cache-lab/vendor && find . -type f -name '*.txt' | LC_ALL=C sort | xargs sha256sum | sha256sum | cut -c1-16로 계산합니다. /root/cache-lab/cache-key.sh <작업디렉터리>를 만드세요. 출력은 한 줄이고deps-<잠금해시12자>꼴입니다. 잠금 해시는sha256sum < <작업디렉터리>/deps.lock | cut -c1-12입니다. 열쇠에는/가 들어가면 안 되고(파일 이름이 됩니다), 반드시 잠금 해시 12자로 끝나야 합니다 — 뒤 단계에서 앞쪽에 항목을 더 붙일 것이기 때문입니다. 이어서/root/cache-lab/cache-run.sh <작업디렉터리>를 만드세요. 열쇠를 짓고 복원을 시도해 적중하면 그대로 끝내고, 빗나가면install.sh를 돌린 뒤 그 열쇠로 저장합니다. 그리고<작업디렉터리>/reports/runs.jsonl에{"key":"<열쇠>","result":"hit|miss","ms":<밀리초>}한 줄을 덧붙이고, 화면에는<결과> <열쇠> <밀리초>ms를 출력합니다. vendor 와 캐시를 지운 상태에서cache-run.sh를 두 번 돌려 첫 번째가 빗나가고 두 번째가 적중하는 것을 확인한 뒤,/root/cache-lab/reports/keys.txt에 네 줄을 적으세요:run1=<첫 결과>,run2=<두 번째 결과>,lock-sha12=<지금 잠금 해시 12자>, 그리고 fast-json 을 2.1.4 에서 2.2.0 으로 바꿨다고 가정했을 때의 잠금 해시 12자를bumped-lock-sha12=<값>으로 적습니다(잠금 파일 자체는 바꾸지 않고 계산만 합니다).cache-restore.sh에 셋째 인자<접두>를 더하세요. 정확한 열쇠가 없고 접두가 주어졌으면cache/index.txt에 기록된 열쇠 중 그 접두로 시작하고 tar 가 실제로 있는 가장 나중에 저장된 것을 풀어 내고partial <그열쇠>와 종료 코드 2 로 끝냅니다. 접두도 없거나 맞는 것이 없으면 전과 같이miss와 1 입니다.cache-run.sh는 접두를"${KEY%-*}-"로 계산해 넘기고, 결과가partial이면 반드시install.sh를 다시 돌린 뒤 정확한 열쇠로 저장합니다(runs.jsonl 의result에partial이 추가됩니다). 이제 위험을 재현합니다.cp -a /root/cache-lab /root/cache-lab-bump로 사본을 뜨고, 사본의deps.lock에서fast-json 2.1.4를fast-json 2.2.0으로 바꾼 뒤seed-repo.sh로 사본의 저장소를 채우세요. 사본에서cache-run.sh를 돌리기 직전에 접두 대체만 일으켜(정확한 열쇠는 없으므로) 그때vendor/fast-json/f01.txt에 적힌 판을 확인하고, 그다음cache-run.sh를 끝까지 돌려 판이 바로잡히는지 확인합니다. 결과를/root/cache-lab/reports/stale.txt에 네 줄로 적으세요:exact=miss,fallback=<접두 대체로 가져온 열쇠>,before-install=fast-json <그때 판>,after-install=fast-json <끝난 뒤 판>. 원본/root/cache-lab의 잠금 파일은 건드리지 않습니다./root/cache-lab/build.sh <작업디렉터리>를 만드세요. vendor 아래 모든*.txt의sha256sum을 경로 순으로 모아<작업디렉터리>/dist/bundle.txt에 쓰고, 그 파일의 sha256 앞 16자를<작업디렉터리>/dist/bundle.id에 씁니다(( cd vendor && find . -type f -name '*.txt' | LC_ALL=C sort | xargs sha256sum )를 그대로 쓰면 됩니다). 시각·난수를 섞지 마세요./root/cache-lab에서cache-run.sh와build.sh를 돌려 산출물을 만든 뒤,cp -a /root/cache-lab /root/cache-lab-nocache로 사본을 뜨고 사본의cache디렉터리와vendor·dist를 통째로 지우세요. 사본에서 다시cache-run.sh와build.sh를 돌립니다./root/cache-lab/reports/cache-drop.txt에 네 줄을 적으세요:with-cache=<원본의 bundle.id>,without-cache=<사본의 bundle.id>,same=yes,artifact-in-cache=no. 마지막 줄은/root/cache-lab/cache의 모든 tar 를tar tzf로 열어dist/로 시작하는 항목이 하나도 없음을 확인한 결과입니다.- 먼저 사고를 재현합니다.
cp -a /root/cache-lab /root/cache-lab-py313로 사본을 뜨고 사본의env/runtime.txt를python3.13으로 바꾼 뒤, 지금의 (런타임을 모르는) 열쇠로cache-run.sh를 돌리세요. 결과가 적중이고vendor/_built-with.txt에는python3.12가 들어 있을 것입니다. 그다음/root/cache-lab/cache-key.sh를 고쳐 열쇠 앞에 운영체제·아키텍처·런타임 판을 넣으세요:<uname -s 소문자>-<uname -m>-<런타임을 영숫자 외에는 - 로 바꾼 값>-deps-<잠금해시12>. (python3.12는python3-12가 됩니다. 끝이 잠금 해시 12자라는 규칙은 그대로입니다.) 고친 스크립트를 사본에도 복사하고 사본에서cache-run.sh를 다시 돌려 이번에는 빗나가고 3.13 으로 새로 설치되는지 확인하세요./root/cache-lab/reports/toolver.txt에 여섯 줄을 적으세요:blind-key=,blind-result=,blind-built-with=,versioned-key=,versioned-result=,versioned-built-with=. 앞 셋은 런타임을 모르는 열쇠로 돌렸을 때, 뒤 셋은 고친 열쇠로 돌렸을 때의 값입니다(열쇠·결과·vendor 의 판 기록). /root/cache-lab를 git 저장소로 만드세요(git init -b main, 사용자 이름·메일 설정,vendor/·cache/·dist/·reports/를.gitignore에 넣고 커밋)./root/cache-lab/env/default-branch.txt에main한 줄을 적습니다./root/cache-lab/cache-policy.sh <작업디렉터리> <브랜치>를 만드세요. 브랜치가default-branch.txt의 값과 같으면save와 종료 코드 0, 다르면nosave와 2, 기본 브랜치를 읽을 수 없으면unknown으로 시작하는 줄과 1 입니다(브랜치 이름을 스크립트에 박아 넣지 마세요).cache-key.sh는 열쇠 맨 앞에 브랜치를 더합니다:<브랜치를 영숫자 외에는 - 로 바꾼 값>-<uname -s 소문자>-<uname -m>-<런타임>-deps-<잠금해시12>. 둘째 인자로 브랜치를 주면 그 브랜치의 열쇠를 계산합니다.cache-run.sh는 읽기를 넓게(내 브랜치 열쇠 → 내 브랜치 접두 → 기본 브랜치 열쇠 → 기본 브랜치 접두), 쓰기를 좁게(cache-policy.sh가save일 때만) 합니다. runs.jsonl 줄에"saved":"yes|no"를 더합니다. 확인은 사본에서 합니다.cp -a /root/cache-lab /root/cache-lab-feature, 그 안에서git checkout -b feature/spike,deps.lock끝에md5-lite 0.4.0을 더하고seed-repo.sh로 저장소를 채운 뒤cache-run.sh를 돌리세요./root/cache-lab/reports/branch.txt에 다섯 줄을 적으세요:branch=feature/spike,key=<사본의 열쇠>,result=<그 실행의 결과>,policy=nosave,feature-tarballs=<사본의 cache 에 생긴 feature- 로 시작하는 tar 개수>./root/cache-lab/cache-report.sh <작업디렉터리>를 만드세요.<작업디렉터리>/reports/runs.jsonl과reports/cold.json을 읽어<작업디렉터리>/reports/summary.json을 쓰고 화면에도 출력합니다. 필드는 여섯 개입니다:runs(줄 수),hits·partial·misses(result 별 줄 수),hit_rate(hits × 100 ÷ runs 를 내림한 정수),saved_ms(result 가hit인 줄마다cold.json의ms에서 그 줄의ms를 뺀 값을 더한 것, 음수는 0 으로). 줄이 하나도 없으면 hit_rate 는 0 입니다. 그리고/root/cache-lab/cache-trust.sh <작업디렉터리>를 만드세요.deps.lock·vendor·env/runtime.txt중 하나라도 없으면ERROR로 시작하는 줄과 종료 코드 1,vendor/_built-with.txt가 없거나env/runtime.txt와 다르거나 잠금 파일의 어느 패키지 판이vendor/<이름>/f01.txt의 판과 다르면REBUILD로 시작하는 줄과 2, 전부 맞으면TRUST와 0 입니다. 마지막으로/root/cache-lab에서 두 스크립트를 돌려/root/cache-lab/reports/summary.json을 남기세요.
참고
- 이 실습 파드에서는 컨테이너를 띄울 수 없습니다(seccomp 가 사용자 네임스페이스 생성을 막습니다).
podman run·podman build·buildah·unshare -U를 쓰지 마세요. 쓸 수 있는 것은 GNU tar · gzip · sha256sum · git · python3 · jq · openssl · coreutils 입니다.yq·make·go·bc는 없습니다. - 인터넷이 없어 진짜 패키지 설치는 못 합니다. 그래서 의존성 설치는 파드 안에 만든 로컬 "패키지 저장소" 디렉터리에서 복사하고
sleep으로 내려받기 비용을 흉내 냅니다. 흉내지만 재는 값(파일 수·바이트·밀리초)과 배우는 규칙은 실제와 같습니다. - 모든 스크립트는 첫 인자로 작업 디렉터리를 받게 만드세요. 4·5·6·7단계가
cp -a로 사본을 떠서 같은 스크립트를 다른 상태로 돌립니다. - 흔한 실수: 복원 스크립트에
set -e를 걸어 "빗나감" 에서 스크립트가 먼저 죽는 것. 빗나감은 오류가 아니라 정상적인 결과입니다. - 흔한 실수: 접두 대체로 부분 복원해 놓고 설치를 건너뛰는 것. 그 순간 vendor 에는 잠금 파일이 요구하지 않는 판이 들어 있습니다.
- 흔한 실수: 캐시에 산출물(dist)을 함께 넣는 것. 캐시는 만료되고 비워지는 것이 정상이라, 그 안에 배포한 파일이 있으면 만료가 곧 사고가 됩니다.
- GitLab · Caching in CI/CD · GitHub Actions · Caching dependencies · Docker · Build cache
캐시 없이 한 번 갖추는 데 얼마가 드는가
/root/cache-lab 아래에 실습 바탕을 만드세요. (1) /root/cache-lab/env/runtime.txt 에 python3.12 한 줄. (2) /root/cache-lab/deps.lock 에 <이름> <판> 여섯 줄을 이 순서로 적습니다: left-pad 1.3.0, fast-json 2.1.4, tiny-http 0.9.2, color-util 4.0.1, date-fmt 3.2.2, crypto-lite 1.0.7. (3) /root/cache-lab/seed-repo.sh <작업디렉터리> 는 잠금 파일을 읽어 패키지마다 <작업디렉터리>/pkgrepo/<이름>/<판>/f01.txt 부터 f12.txt 까지 12개를 만들고, 각 파일에는 <이름> <판> <번호두자리> 한 줄만 씁니다. (4) /root/cache-lab/install.sh <작업디렉터리> 는 잠금 파일을 읽어 패키지마다 sleep 0.05 로 내려받기 비용을 흉내 낸 뒤 pkgrepo/<이름>/<판> 을 vendor/<이름> 으로 복사하고, 마지막에 vendor/_built-with.txt 에 env/runtime.txt 의 내용을 적고 installed=<개수> ms=<밀리초> 한 줄을 출력합니다(vendor 는 매번 새로 만듭니다). (5) 두 스크립트를 차례로 돌린 뒤 /root/cache-lab/reports/cold.json 에 files·bytes·ms 세 숫자를 적으세요. files 는 find /root/cache-lab/vendor -type f | wc -l, bytes 는 그 파일들의 크기 합, ms 는 install.sh 가 알려 준 값입니다.
인터넷이 없어서 진짜 내려받기는 못 합니다 — 저장소도 우리가 만든 디렉터리이고, 비용은 sleep 으로 흉내만 냅니다. 그래도 재는 값(파일 수·바이트·밀리초)은 진짜입니다. 두 스크립트 모두 첫 인자로 작업 디렉터리를 받게 만드세요. 뒤 단계에서 이 실습 디렉터리의 사본을 떠서 같은 스크립트를 돌립니다. seq -w 1 12 가 01 부터 12 까지 두 자리로 세어 줍니다. 파일 크기 합은 find ... -printf '%s\n' 과 awk 로 더할 수 있습니다(이 파드에는 bc 가 없습니다).
저장하고 지우고 되살려 본다
캐시 저장·복원의 뼈대를 만드세요. /root/cache-lab/cache-save.sh <작업디렉터리> <열쇠> 는 vendor 디렉터리를 <작업디렉터리>/cache/<열쇠>.tar.gz 로 묶고(tar czf ... -C <작업디렉터리> vendor), <작업디렉터리>/cache/index.txt 에 <시각><탭><열쇠> 한 줄을 덧붙인 뒤 saved <열쇠> 를 출력합니다. /root/cache-lab/cache-restore.sh <작업디렉터리> <열쇠> 는 그 tar 가 있으면 vendor 를 지우고 풀어 낸 뒤 hit <열쇠> 와 종료 코드 0, 없으면 vendor 를 건드리지 않고 miss <열쇠> 와 종료 코드 1 로 끝냅니다. 그다음 열쇠 manual-v1 로 저장하고, vendor 를 통째로 지우고, 같은 열쇠로 복원하세요. 복원 전후의 지문을 /root/cache-lab/reports/roundtrip.txt 에 before=<지문>·after=<지문>·same=yes 세 줄로 적습니다. 지문은 cd /root/cache-lab/vendor && find . -type f -name '*.txt' | LC_ALL=C sort | xargs sha256sum | sha256sum | cut -c1-16 로 계산합니다.
캐시는 tar 한 덩어리로 다루는 편이 낫습니다 — 파일 수천 개를 하나씩 옮기는 것보다 빠르고, 권한과 빈 디렉터리도 함께 보존됩니다. 복원이 빗나갔을 때 vendor 를 지워 버리면 안 됩니다. 빗나감은 오류가 아니라 정상적인 결과이고, 그때 할 일은 설치이지 파괴가 아닙니다. 종료 코드로 적중과 빗나감을 구분하게 만드세요. 뒤 단계에서 이 두 코드 위에 세 번째 경우를 얹습니다. set -e 를 걸면 빗나감에서 스크립트가 먼저 죽습니다.
열쇠를 잠금 파일 해시로 짓는다
/root/cache-lab/cache-key.sh <작업디렉터리> 를 만드세요. 출력은 한 줄이고 deps-<잠금해시12자> 꼴입니다. 잠금 해시는 sha256sum < <작업디렉터리>/deps.lock | cut -c1-12 입니다. 열쇠에는 / 가 들어가면 안 되고(파일 이름이 됩니다), 반드시 잠금 해시 12자로 끝나야 합니다 — 뒤 단계에서 앞쪽에 항목을 더 붙일 것이기 때문입니다. 이어서 /root/cache-lab/cache-run.sh <작업디렉터리> 를 만드세요. 열쇠를 짓고 복원을 시도해 적중하면 그대로 끝내고, 빗나가면 install.sh 를 돌린 뒤 그 열쇠로 저장합니다. 그리고 <작업디렉터리>/reports/runs.jsonl 에 {"key":"<열쇠>","result":"hit|miss","ms":<밀리초>} 한 줄을 덧붙이고, 화면에는 <결과> <열쇠> <밀리초>ms 를 출력합니다. vendor 와 캐시를 지운 상태에서 cache-run.sh 를 두 번 돌려 첫 번째가 빗나가고 두 번째가 적중하는 것을 확인한 뒤, /root/cache-lab/reports/keys.txt 에 네 줄을 적으세요: run1=<첫 결과>, run2=<두 번째 결과>, lock-sha12=<지금 잠금 해시 12자>, 그리고 fast-json 을 2.1.4 에서 2.2.0 으로 바꿨다고 가정했을 때의 잠금 해시 12자를 bumped-lock-sha12=<값> 으로 적습니다(잠금 파일 자체는 바꾸지 않고 계산만 합니다).
열쇠는 "이 캐시가 무엇을 담고 있는지" 를 한 문자열로 요약한 것입니다. 잠금 파일은 판까지 못박힌 목록이라 그 요약으로 알맞습니다 — 반대로 package.json 처럼 범위(^1.2)가 적힌 파일을 해시하면 내용이 같아도 실제로 설치되는 것이 달라집니다. 바뀌지 않은 파일을 해시에 넣으면 열쇠가 쓸데없이 자주 바뀌고, 바뀌는 파일을 빼면 낡은 캐시가 계속 적중합니다. 두 번째 값은 sed 's/fast-json 2.1.4/fast-json 2.2.0/' deps.lock | sha256sum 처럼 파일을 고치지 않고 계산할 수 있습니다.
정확한 열쇠가 없으면 가장 가까운 것을 가져온다
cache-restore.sh 에 셋째 인자 <접두> 를 더하세요. 정확한 열쇠가 없고 접두가 주어졌으면 cache/index.txt 에 기록된 열쇠 중 그 접두로 시작하고 tar 가 실제로 있는 가장 나중에 저장된 것을 풀어 내고 partial <그열쇠> 와 종료 코드 2 로 끝냅니다. 접두도 없거나 맞는 것이 없으면 전과 같이 miss 와 1 입니다. cache-run.sh 는 접두를 "${KEY%-*}-" 로 계산해 넘기고, 결과가 partial 이면 반드시 install.sh 를 다시 돌린 뒤 정확한 열쇠로 저장합니다(runs.jsonl 의 result 에 partial 이 추가됩니다). 이제 위험을 재현합니다. cp -a /root/cache-lab /root/cache-lab-bump 로 사본을 뜨고, 사본의 deps.lock 에서 fast-json 2.1.4 를 fast-json 2.2.0 으로 바꾼 뒤 seed-repo.sh 로 사본의 저장소를 채우세요. 사본에서 cache-run.sh 를 돌리기 직전에 접두 대체만 일으켜(정확한 열쇠는 없으므로) 그때 vendor/fast-json/f01.txt 에 적힌 판을 확인하고, 그다음 cache-run.sh 를 끝까지 돌려 판이 바로잡히는지 확인합니다. 결과를 /root/cache-lab/reports/stale.txt 에 네 줄로 적으세요: exact=miss, fallback=<접두 대체로 가져온 열쇠>, before-install=fast-json <그때 판>, after-install=fast-json <끝난 뒤 판>. 원본 /root/cache-lab 의 잠금 파일은 건드리지 않습니다.
GitHub Actions 의 restore-keys, GitLab 의 fallback_keys 가 하는 일이 이것입니다. 열쇠가 <접두>-<해시> 꼴이면 접두만 남겨 가장 가까운 캐시를 찾을 수 있습니다 — 그래서 열쇠가 해시로 끝나야 했습니다. 부분 복원은 공짜 이득처럼 보이지만 그 순간 vendor 에는 잠금 파일이 요구하지 않는 판이 들어 있습니다. 여기서 설치를 건너뛰면 그 판이 그대로 빌드에 섞이고, 캐시가 사라지는 날 결과가 달라집니다. 사본에서 작업하는 이유는 원본의 잠금 파일을 지키기 위해서입니다. 스크립트가 첫 인자로 작업 디렉터리를 받게 만들어 둔 것이 여기서 값을 합니다.
캐시를 통째로 지워도 결과는 같아야 한다
/root/cache-lab/build.sh <작업디렉터리> 를 만드세요. vendor 아래 모든 *.txt 의 sha256sum 을 경로 순으로 모아 <작업디렉터리>/dist/bundle.txt 에 쓰고, 그 파일의 sha256 앞 16자를 <작업디렉터리>/dist/bundle.id 에 씁니다(( cd vendor && find . -type f -name '*.txt' | LC_ALL=C sort | xargs sha256sum ) 를 그대로 쓰면 됩니다). 시각·난수를 섞지 마세요. /root/cache-lab 에서 cache-run.sh 와 build.sh 를 돌려 산출물을 만든 뒤, cp -a /root/cache-lab /root/cache-lab-nocache 로 사본을 뜨고 사본의 cache 디렉터리와 vendor·dist 를 통째로 지우세요. 사본에서 다시 cache-run.sh 와 build.sh 를 돌립니다. /root/cache-lab/reports/cache-drop.txt 에 네 줄을 적으세요: with-cache=<원본의 bundle.id>, without-cache=<사본의 bundle.id>, same=yes, artifact-in-cache=no. 마지막 줄은 /root/cache-lab/cache 의 모든 tar 를 tar tzf 로 열어 dist/ 로 시작하는 항목이 하나도 없음을 확인한 결과입니다.
캐시와 산출물은 지웠을 때 벌어지는 일로 갈립니다. 캐시는 없어도 다시 만들 수 있어야 하고, 결과가 달라지면 그건 캐시가 아니라 숨은 입력입니다. 산출물은 없어지면 다시 만들 수 없거나(빌드 기계가 사라졌다) 다시 만들면 안 되는 것(이미 배포한 그 파일)입니다. 그래서 캐시에 산출물을 넣으면 안 됩니다 — 캐시는 만료되고 비워지는 것이 정상인데, 그 안에 배포한 파일이 들어 있으면 만료가 곧 사고가 됩니다. 빌드가 결정적이어야 이 비교가 성립합니다. 타임스탬프 한 줄이 섞이면 두 결과는 언제나 다릅니다.
런타임 판을 올렸는데 옛 판으로 설치된 캐시가 딸려 왔다
먼저 사고를 재현합니다. cp -a /root/cache-lab /root/cache-lab-py313 로 사본을 뜨고 사본의 env/runtime.txt 를 python3.13 으로 바꾼 뒤, 지금의 (런타임을 모르는) 열쇠로 cache-run.sh 를 돌리세요. 결과가 적중이고 vendor/_built-with.txt 에는 python3.12 가 들어 있을 것입니다. 그다음 /root/cache-lab/cache-key.sh 를 고쳐 열쇠 앞에 운영체제·아키텍처·런타임 판을 넣으세요: <uname -s 소문자>-<uname -m>-<런타임을 영숫자 외에는 - 로 바꾼 값>-deps-<잠금해시12>. (python3.12 는 python3-12 가 됩니다. 끝이 잠금 해시 12자라는 규칙은 그대로입니다.) 고친 스크립트를 사본에도 복사하고 사본에서 cache-run.sh 를 다시 돌려 이번에는 빗나가고 3.13 으로 새로 설치되는지 확인하세요. /root/cache-lab/reports/toolver.txt 에 여섯 줄을 적으세요: blind-key=, blind-result=, blind-built-with=, versioned-key=, versioned-result=, versioned-built-with=. 앞 셋은 런타임을 모르는 열쇠로 돌렸을 때, 뒤 셋은 고친 열쇠로 돌렸을 때의 값입니다(열쇠·결과·vendor 의 판 기록).
캐시 열쇠는 "이 캐시를 만들어 낸 모든 입력" 을 요약해야 합니다. 의존성 목록만 넣으면 같은 목록이라도 다른 런타임·다른 배포판에서 만든 바이너리가 그대로 딸려 옵니다 — 그리고 그건 설치가 성공해 버리기 때문에 아무도 눈치채지 못합니다. 그래서 어느 CI 문서든 예시 열쇠가 ${{ runner.os }}-node-... 처럼 시작합니다. 사람이 열쇠를 읽고 "이 캐시가 어디서 만들어졌는지" 를 알 수 있어야 합니다. 영숫자가 아닌 글자를 바꾸는 데는 tr -c 'A-Za-z0-9' '-' 가 편합니다. 남는 꼬리표는 sed 's/-*$//' 로 다듬습니다.
짧게 살다 간 브랜치가 모두의 캐시를 물들인다
/root/cache-lab 를 git 저장소로 만드세요(git init -b main, 사용자 이름·메일 설정, vendor/·cache/·dist/·reports/ 를 .gitignore 에 넣고 커밋). /root/cache-lab/env/default-branch.txt 에 main 한 줄을 적습니다. /root/cache-lab/cache-policy.sh <작업디렉터리> <브랜치> 를 만드세요. 브랜치가 default-branch.txt 의 값과 같으면 save 와 종료 코드 0, 다르면 nosave 와 2, 기본 브랜치를 읽을 수 없으면 unknown 으로 시작하는 줄과 1 입니다(브랜치 이름을 스크립트에 박아 넣지 마세요). cache-key.sh 는 열쇠 맨 앞에 브랜치를 더합니다: <브랜치를 영숫자 외에는 - 로 바꾼 값>-<uname -s 소문자>-<uname -m>-<런타임>-deps-<잠금해시12>. 둘째 인자로 브랜치를 주면 그 브랜치의 열쇠를 계산합니다. cache-run.sh 는 읽기를 넓게(내 브랜치 열쇠 → 내 브랜치 접두 → 기본 브랜치 열쇠 → 기본 브랜치 접두), 쓰기를 좁게(cache-policy.sh 가 save 일 때만) 합니다. runs.jsonl 줄에 "saved":"yes|no" 를 더합니다. 확인은 사본에서 합니다. cp -a /root/cache-lab /root/cache-lab-feature, 그 안에서 git checkout -b feature/spike, deps.lock 끝에 md5-lite 0.4.0 을 더하고 seed-repo.sh 로 저장소를 채운 뒤 cache-run.sh 를 돌리세요. /root/cache-lab/reports/branch.txt 에 다섯 줄을 적으세요: branch=feature/spike, key=<사본의 열쇠>, result=<그 실행의 결과>, policy=nosave, feature-tarballs=<사본의 cache 에 생긴 feature- 로 시작하는 tar 개수>.
캐시를 아무 브랜치나 저장하게 두면, 실험하다 지운 브랜치가 남긴 의존성이 기본 브랜치 빌드에 스며듭니다. 되돌릴 방법도 없습니다 — 그 브랜치는 이미 없으니까요. 그래서 실무의 규칙은 "읽기는 누구나, 쓰기는 기본 브랜치만" 입니다. 브랜치 이름에는 / 가 흔히 들어가는데 열쇠는 파일 이름이 됩니다. 바꾸지 않으면 cache/feature/spike-...tar.gz 처럼 디렉터리가 하나 더 생기거나 저장 자체가 실패합니다. git -C <경로> rev-parse --abbrev-ref HEAD 가 지금 브랜치를 알려 줍니다. git 저장소가 아닐 수도 있으니 실패했을 때의 대비를 넣으세요.
이 캐시를 믿어도 되는가를 게이트로 만든다
/root/cache-lab/cache-report.sh <작업디렉터리> 를 만드세요. <작업디렉터리>/reports/runs.jsonl 과 reports/cold.json 을 읽어 <작업디렉터리>/reports/summary.json 을 쓰고 화면에도 출력합니다. 필드는 여섯 개입니다: runs(줄 수), hits·partial·misses(result 별 줄 수), hit_rate(hits × 100 ÷ runs 를 내림한 정수), saved_ms(result 가 hit 인 줄마다 cold.json 의 ms 에서 그 줄의 ms 를 뺀 값을 더한 것, 음수는 0 으로). 줄이 하나도 없으면 hit_rate 는 0 입니다. 그리고 /root/cache-lab/cache-trust.sh <작업디렉터리> 를 만드세요. deps.lock·vendor·env/runtime.txt 중 하나라도 없으면 ERROR 로 시작하는 줄과 종료 코드 1, vendor/_built-with.txt 가 없거나 env/runtime.txt 와 다르거나 잠금 파일의 어느 패키지 판이 vendor/<이름>/f01.txt 의 판과 다르면 REBUILD 로 시작하는 줄과 2, 전부 맞으면 TRUST 와 0 입니다. 마지막으로 /root/cache-lab 에서 두 스크립트를 돌려 /root/cache-lab/reports/summary.json 을 남기세요.
적중률만으로는 아무것도 못 정합니다. 100% 적중하는 캐시가 100% 틀린 내용을 되살릴 수도 있습니다 — 그게 앞 단계에서 본 것입니다. 그래서 보고서 옆에 "믿어도 되는가" 를 따로 둡니다. 게이트의 판정 근거는 전부 지금 디스크에 있는 상태여야 합니다. 지난번 실행이 뭐라고 적어 두었는지가 아니라, 지금 vendor 에 무엇이 들어 있는지를 봅니다. jq -s 는 여러 줄 JSON 을 배열 하나로 읽어 줍니다. 나눗셈 결과를 정수로 만들 때는 | floor 를 씁니다. 빈 배열에 add 를 쓰면 null 이 나오니 + [0] 으로 막습니다.