- Published on
GitLab CI 캐시 안 먹힐 때 key·policy·권한 점검
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
GitLab CI에서 캐시를 걸어도 매번 Cache not found가 뜨거나, 분명히 cache:paths를 지정했는데 다음 잡에서 전혀 재사용되지 않는 경우가 자주 있습니다. 이 문제는 단순히 paths를 잘못 쓴 게 아니라, 캐시의 키(key) 설계, policy 동작 방식, 러너의 실행기(executor)·권한·스토리지 설정이 얽혀 발생합니다.
이 글은 “왜 캐시가 안 먹는지”를 로그로 확정하는 방법부터, 실제로 많이 터지는 케이스(브랜치마다 키가 바뀜, pull-only라 업로드가 안 됨, Kubernetes 러너에서 권한 문제로 업로드 실패, S3/MinIO 권한 오류 등)를 순서대로 해결합니다.
1) 캐시와 아티팩트부터 구분하기
먼저 GitLab CI의 cache와 artifacts는 목적이 다릅니다.
cache: 속도 최적화(의존성, 빌드 캐시 등). 없어도 파이프라인은 돌아가야 정상입니다.artifacts: 잡 결과물 전달(빌드 산출물, 테스트 리포트 등). 다음 스테이지에 반드시 전달돼야 할 때 사용합니다.
캐시가 “다음 잡에서 꼭 있어야 한다”는 전제로 설계하면 자주 깨집니다. 반대로 캐시가 안 먹는 상황에서 아티팩트를 써야 하는데 캐시로 해결하려고 하면 끝이 없습니다.
2) 가장 먼저 볼 것: 잡 로그의 캐시 라인
원인 분석은 설정 파일보다 로그가 더 정확합니다. 잡 로그에서 다음 문구를 찾으세요.
Checking cache for ...No URL provided, cache will not be uploaded to shared cache server(공유 캐시 서버가 없거나 러너 설정 문제)Cache file does not exist(업로드 자체가 안 됨)WARNING: ... permission denied(권한 문제)Uploading cache.../Downloading cache...(정상 흐름)
특히 No URL provided...는 “캐시가 로컬 러너에만 남는다”는 의미일 수 있어, 러너가 매번 새로 뜨는 환경(예: Kubernetes executor, autoscaling runner)에서는 사실상 매번 미스가 납니다.
3) key가 매번 바뀌면 캐시는 영원히 미스난다
캐시가 안 먹는 가장 흔한 이유는 key가 파이프라인마다 달라지는 것입니다.
3.1 브랜치/커밋 기반 key의 함정
예를 들어 아래처럼 CI_COMMIT_SHA를 키에 넣으면 커밋마다 키가 바뀌어 재사용이 거의 불가능합니다.
cache:
key: "npm-${CI_COMMIT_SHA}"
paths:
- node_modules/
브랜치마다 분리하고 싶다면 CI_COMMIT_REF_SLUG 정도가 적당합니다.
cache:
key: "npm-${CI_COMMIT_REF_SLUG}"
paths:
- node_modules/
하지만 이것도 브랜치가 많으면 캐시가 쪼개져 히트율이 떨어집니다.
3.2 lockfile 기반 key로 “의존성 변경 시에만” 갱신
Node/Java/Python 모두 의존성 캐시는 lockfile이 진짜 기준입니다. GitLab은 cache:key:files를 지원합니다.
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
Yarn이면 yarn.lock, pnpm이면 pnpm-lock.yaml, Gradle이면 gradle.lockfile 또는 build.gradle 변경을 기준으로 잡는 식으로 설계합니다.
이 방식의 장점은 “커밋이 바뀌어도 lockfile이 같으면 캐시를 재사용”한다는 점입니다.
3.3 모노레포는 prefix 없으면 충돌한다
모노레포에서 여러 패키지가 각자 node_modules를 캐시하면, 키가 같을 때 서로 덮어쓰는 문제가 생깁니다. 이 경우 prefix를 패키지 경로로 분리하세요.
cache:
key:
files:
- apps/web/package-lock.json
prefix: "apps-web"
paths:
- apps/web/node_modules/
4) policy 때문에 업로드가 안 되는 케이스
GitLab 캐시는 policy로 다운로드/업로드 동작을 제어합니다.
pull: 다운로드만push: 업로드만pull-push: 둘 다(기본)
4.1 자주 하는 실수: pull로만 둬서 영원히 캐시가 없다
cache:
policy: pull
paths:
- .gradle/caches/
이렇게 하면 “기존 캐시가 있을 때만” 내려받고, 새로 생성된 캐시는 업로드하지 않습니다. 처음부터 캐시가 없는 프로젝트라면 영원히 Cache not found가 됩니다.
해결은 pull-push로 바꾸거나, 첫 번째로 캐시를 만드는 잡에서만 push를 허용하는 식으로 분리합니다.
build:
stage: build
cache:
policy: pull-push
key:
files:
- gradle.lockfile
paths:
- .gradle/caches/
4.2 protected 브랜치/태그에서만 업로드가 되는 경우
러너가 protected로 설정되어 있거나, 잡이 protected에서만 실행되도록 제한된 경우, feature 브랜치에서는 캐시 업로드가 막히는 것처럼 보일 수 있습니다.
- 러너 설정에서
Run untagged jobs/Protected옵션 확인 - 잡에
tags를 붙였다면, 해당 태그 러너가 실제로 feature 브랜치에서도 잡을 실행하는지 확인
이 문제는 “캐시”가 아니라 “잡 실행 권한/러너 매칭” 문제인 경우가 많습니다. 비슷한 결로 환경변수가 안 먹는 문제를 다룬 글도 참고할 만합니다: Jenkins Declarative Pipeline에서 env가 안 먹는 6가지 이유
5) paths가 잘못되어 실제로 캐시할 게 없는 경우
캐시는 지정한 경로가 실제로 존재하고, 잡 종료 시점에 파일이 있어야 업로드됩니다.
5.1 상대 경로 기준은 프로젝트 루트
GitLab 러너는 기본적으로 체크아웃된 프로젝트 디렉터리에서 실행합니다. cd로 다른 곳으로 이동한 뒤 생성한 파일을 캐시하려면 paths가 그 위치를 정확히 가리켜야 합니다.
script:
- cd apps/web
- npm ci
cache:
paths:
- node_modules/ # 실제 위치는 apps/web/node_modules 일 수 있음
올바르게는:
cache:
paths:
- apps/web/node_modules/
5.2 캐시 업로드 전에 삭제되는 패턴
일부 빌드 스크립트가 용량 절감을 위해 node_modules나 .gradle을 마지막에 지우는 경우가 있습니다. 그러면 업로드 단계에서 “없음”으로 처리됩니다.
- 잡 스크립트에서 삭제 로직이 있는지 확인
after_script에서 정리하는 경우 특히 주의
6) 러너 실행기별 “캐시가 안 먹는” 구조적 원인
6.1 Docker executor: 로컬 캐시는 러너 머신에만 남는다
Docker executor에서 공유 캐시 서버(S3/MinIO 등)를 설정하지 않으면, 캐시는 러너 머신 로컬에만 남습니다.
- 단일 러너 머신에서 계속 실행되면 히트할 수 있음
- autoscaling(매번 새 VM)이라면 매번 미스
로그에 No URL provided...가 보이면 이 케이스를 의심하세요.
6.2 Kubernetes executor: 파드가 매번 새로 떠서 로컬 캐시가 없다
Kubernetes executor는 잡 파드가 매번 새로 만들어지는 경우가 많아 로컬 캐시에 의존하면 히트율이 0에 수렴합니다. 해결책은:
- 러너에 S3/MinIO 기반 shared cache 설정
- 또는 PV를 붙여서 캐시 디렉터리를 유지(운영 복잡도 증가)
Kubernetes 환경에서 리소스/네트워크 이슈가 겹치면 원인 파악이 더 어려워지는데, 인프라성 병목을 진단하는 글도 함께 보면 도움이 됩니다: Kubernetes CNI IP 부족으로 Pod Pending 해결 가이드
7) 권한 문제: 캐시는 “쓰기”가 되어야 업로드된다
캐시가 다운로드는 되는데 업로드가 실패하거나, 특정 디렉터리만 캐시가 비어 있다면 권한을 의심해야 합니다.
7.1 컨테이너 유저와 파일 소유권 불일치
예를 들어 이미지가 non-root 유저로 실행되는데, 캐시 디렉터리가 root 소유로 생성되면 다음 실행에서 쓰기 실패가 납니다.
진단용으로 아래를 잡에 추가해 보세요.
script:
- whoami
- id
- ls -al
- ls -al .gradle || true
해결은 보통 아래 중 하나입니다.
- 빌드 캐시 디렉터리를 쓰기 가능한 경로로 변경
- 컨테이너에서
chown처리(보안 정책상 제한될 수 있음) - 러너/이미지에서 실행 유저를 통일
7.2 S3/MinIO 권한(IAM) 문제
shared cache를 S3로 쓰는 경우, 러너에 설정된 키가 다음 권한을 가져야 합니다.
s3:GetObjects3:PutObjects3:ListBucket(경우에 따라 필요)s3:DeleteObject(정리 정책 사용 시)
권한이 부족하면 다운로드는 되는데 업로드가 실패하거나(또는 반대), 특정 prefix만 실패할 수 있습니다.
8) 실전 추천 구성: lockfile key + 정책 분리 + 빠른 실패 진단
아래는 Node 프로젝트 기준으로 “캐시가 잘 먹고, 문제 시 로그로 바로 확인”하는 예시입니다.
stages:
- install
- test
default:
image: node:20
variables:
NPM_CONFIG_CACHE: ".npm"
cache:
key:
files:
- package-lock.json
prefix: "npm"
paths:
- .npm/
policy: pull-push
install:
stage: install
script:
- node -v
- npm -v
- npm ci --prefer-offline
- ls -al .npm || true
test:
stage: test
script:
- npm test
포인트는 다음과 같습니다.
node_modules대신.npm(npm 캐시)만 캐시하면 OS/아키텍처/바이너리 모듈 차이로 인한 깨짐이 줄어듭니다.key:files로 lockfile 변경 시에만 캐시가 갱신됩니다.policy: pull-push로 최초에도 캐시가 생성됩니다.
9) 그래도 안 되면: 체크리스트 10
- 잡 로그에
Downloading cache...가 실제로 찍히는가 No URL provided...가 있다면 shared cache 설정이 있는가key가 커밋마다 바뀌지 않는가(CI_COMMIT_SHA사용 여부)cache:key:files가 lockfile을 제대로 가리키는가- 모노레포면
prefix로 충돌을 막았는가 policy가pull로 고정되어 업로드가 막히지 않는가paths가 실제 생성 위치와 일치하는가- 잡 마지막에 캐시 디렉터리를 삭제하고 있지 않은가
- 컨테이너 유저 권한으로 해당 디렉터리에 쓰기가 가능한가
- S3/MinIO 사용 시 버킷 정책과 prefix 권한이 충분한가
권한과 경로 문제는 CI에서 특히 흔합니다. 운영 환경에서 스케줄러가 작업을 못 돌리는 문제와 결이 비슷하니, 원인 접근 방식은 아래 글의 체크리스트도 참고할 만합니다: 리눅스 cron 미실행? PATH·메일로그·권한 점검
10) 마무리: 캐시를 “설계”하면 히트율이 올라간다
GitLab CI 캐시는 단순히 paths만 적는 기능이 아니라, 키 설계로 재사용 범위를 정하고, policy로 업로드/다운로드 흐름을 만들고, 러너의 저장소/권한/실행기 특성에 맞춰야 안정적으로 동작합니다.
정리하면,
- 의존성 캐시는
key:files로 lockfile 기반으로 잡기 policy는 최초 생성까지 고려해pull-push를 기본으로- Kubernetes/autoscaling이면 shared cache(S3/MinIO) 없이는 히트율이 낮을 수 있음
- 로그에서 “다운로드/업로드가 실제로 수행됐는지”부터 확인
여기까지 점검하면 대부분의 “GitLab CI 캐시 안 먹힘” 문제는 재현 가능하게 원인을 특정하고 해결할 수 있습니다.