CI/CD 파이프라인 · 캐시 열쇠 설계 · 실습
잠금 파일 한 줄을 고쳤더니 캐시가 옛 의존성을 되살렸다
목표
파이프라인 캐시의 열쇠를 손으로 지어 보며 적중·빗나감·접두 대체를 만들고, 캐시와 산출물의 차이를 삭제 실험으로 확인하고, 열쇠에 런타임 판과 브랜치를 넣지 않았을 때 무엇이 오염되는지 재현한 뒤, 적중률 보고서와 신뢰 게이트까지 만듭니다.
왜 중요한가
캐시는 파이프라인에서 가장 쉽게 넣고 가장 오래 틀린 채로 남는 장치입니다. 열쇠가 너무 좁으면 매번 빗나가 캐시가 없는 것과 같고, 너무 넓으면 바뀐 의존성·다른 런타임·남의 브랜치가 만든 내용을 그대로 되살립니다. 뒤쪽 실패가 훨씬 나쁜데, 빌드가 성공해 버리기 때문에 아무도 신고하지 않습니다. "캐시를 지우면 고쳐진다" 는 말이 농담이 아닌 이유가 여기 있습니다 — 그 말은 캐시가 숨은 입력이 되었다는 뜻입니다. 그래서 캐시 설계는 세 가지를 정하는 일입니다. 무엇이 바뀌면 열쇠가 바뀌어야 하는가, 정확히 맞는 것이 없을 때 무엇까지 빌려와도 되는가, 그리고 누가 캐시에 쓸 수 있는가. 이 셋을 정해 두면 도구가 GitHub Actions 든 GitLab CI 든 BuildKit 이든 같은 설정을 옮겨 적기만 하면 됩니다.
단계
1. /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 가 알려 준 값입니다.
2. 캐시 저장·복원의 뼈대를 만드세요. /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 로 계산합니다.
3. /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=<값> 으로 적습니다(잠금 파일 자체는 바꾸지 않고 계산만 합니다).
4. 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 의 잠금 파일은 건드리지 않습니다.
5. /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/ 로 시작하는 항목이 하나도 없음을 확인한 결과입니다.
6. 먼저 사고를 재현합니다. 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 의 판 기록).
7. /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 개수>.
8. /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](https://docs.gitlab.com/ci/caching/) · [GitHub Actions · Caching dependencies](https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows) · [Docker · Build cache](https://docs.docker.com/build/cache/)
단계 8개
- 캐시 없이 한 번 갖추는 데 얼마가 드는가
- 저장하고 지우고 되살려 본다
- 열쇠를 잠금 파일 해시로 짓는다
- 정확한 열쇠가 없으면 가장 가까운 것을 가져온다
- 캐시를 통째로 지워도 결과는 같아야 한다
- 런타임 판을 올렸는데 옛 판으로 설치된 캐시가 딸려 왔다
- 짧게 살다 간 브랜치가 모두의 캐시를 물들인다
- 이 캐시를 믿어도 되는가를 게이트로 만든다