- Published on
Docker BuildKit 캐시가 안 먹는 9가지 원인
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 DOCKER_BUILDKIT=1 를 켰는데도 docker build 가 매번 처음부터 도는 경험, CI에서만 유독 캐시가 안 맞는 경험은 꽤 흔합니다. BuildKit 캐시는 마법이 아니라 각 레이어의 입력(명령어, 파일, 메타데이터, 외부 의존성)이 동일해야 재사용됩니다. 즉, “캐시가 안 먹는다”는 말은 대부분 “캐시 키가 달라졌다” 혹은 “캐시를 저장/가져오는 통로가 없다”로 해석할 수 있습니다.
이 글에서는 BuildKit 캐시가 무력화되는 대표적인 9가지 원인을, 증상과 재현 포인트, 해결책 중심으로 정리합니다. CI 환경이라면 GitHub Actions 캐시 점검과도 결이 비슷하니, 필요하면 GitHub Actions 캐시가 안 먹을 때 디버깅 체크리스트도 함께 참고하면 좋습니다.
먼저 확인: BuildKit이 실제로 동작 중인가
BuildKit을 “사용한다고 생각”하지만 실제로는 classic builder로 빌드하는 경우가 있습니다.
# 1) 환경변수로 강제
DOCKER_BUILDKIT=1 docker build -t app:dev .
# 2) buildx 사용 (권장)
docker buildx build -t app:dev .
# 3) 빌드 로그에 CACHED 표시가 나오는지 확인
DOCKER_BUILDKIT=1 docker build --progress=plain .
--progress=plain 으로 보면 어떤 스텝이 CACHED 인지, 어디서부터 다시 실행되는지 드러납니다. 이제부터는 “왜 그 스텝부터 캐시가 깨졌는지”를 추적하면 됩니다.
1) COPY . . 가 너무 이른 위치에 있다
가장 흔한 원인입니다. 소스 트리 전체를 초반에 COPY 하면, 사소한 변경(README, 테스트, 로컬 설정)도 이후 모든 레이어 캐시를 깨뜨립니다.
나쁜 예
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
개선 예: 의존성 파일만 먼저 복사
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
# 의존성 관련 파일만 먼저
COPY package.json package-lock.json ./
RUN npm ci
# 그 다음 소스
COPY . .
RUN npm run build
핵심은 변경 빈도가 낮은 입력을 앞에, 변경 빈도가 높은 입력을 뒤에 두는 것입니다.
2) .dockerignore 누락/불량으로 빌드 컨텍스트가 매번 달라진다
BuildKit 캐시의 중요한 입력은 “빌드 컨텍스트(전송된 파일 집합)”입니다. 예를 들어 node_modules, .git, dist, 로컬 로그 파일 등이 컨텍스트에 포함되면, 파일 타임스탬프나 내용 변화로 캐시가 쉽게 깨집니다.
권장 .dockerignore 예시
node_modules
.git
.gitignore
Dockerfile
README.md
*.log
dist
.next
coverage
.env
특히 CI에서는 체크아웃 방식(서브모듈, LFS, shallow clone 등)에 따라 .git 내용이 달라질 수 있어, .git 포함은 거의 항상 캐시 관점에서 손해입니다.
3) RUN apt-get update 같은 “시간 의존” 명령이 캐시를 흔든다
RUN apt-get update 자체는 파일 입력이 같아도, 원격 저장소 상태가 바뀌면 결과가 달라집니다. BuildKit은 기본적으로 “명령과 입력이 같으면 캐시”지만, 실제로는 네트워크로 내려받는 결과가 달라져 다음 레이어가 변동될 수 있습니다.
개선 방향
- 가능한 경우 베이스 이미지에 이미 포함된 패키지를 사용
- 패키지 버전 pinning
apt-get update와apt-get install을 한 레이어에서 처리
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
이렇게 해도 “원격이 바뀌면 빌드 결과가 바뀌는 문제”는 남습니다. 완전 고정이 필요하면 내부 미러나 스냅샷 저장소를 고려하세요.
4) 베이스 이미지 태그가 떠다닌다 (latest, alpine, node:20 등)
FROM node:20-alpine 는 시간이 지나면 다른 digest를 가리킬 수 있습니다. 베이스 이미지가 바뀌면 이후 레이어 캐시는 사실상 전부 무효화됩니다.
해결: digest로 고정
FROM node:20-alpine@sha256:0123456789abcdef...
운영/CI에서 재현 가능한 빌드가 필요하면 digest 고정은 거의 필수입니다.
5) 멀티스테이지에서 “캐시가 이어지지 않는” 구조
멀티스테이지 빌드에서 스테이지 이름/구성이 조금만 바뀌어도 캐시가 깨질 수 있습니다. 또한 빌드 단계와 런타임 단계 사이에 불필요하게 많은 파일을 옮기면, 작은 변경이 런타임 이미지까지 전파됩니다.
패턴: 빌드 결과만 최소 복사
# syntax=docker/dockerfile:1
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY /app/dist /usr/share/nginx/html
COPY --from=build 는 산출물만 가져오도록 좁히는 게 캐시/이미지 크기 모두에 유리합니다.
6) ARG / ENV 값이 매 빌드마다 바뀐다 (특히 커밋 SHA, 빌드 시간)
BuildKit에서 ARG 는 레이어 캐시에 영향을 줍니다. 아래처럼 빌드마다 다른 값을 주입하면, 그 ARG 를 참조하는 순간부터 캐시가 깨집니다.
흔한 예
ARG GIT_SHA
RUN echo "$GIT_SHA" > /app/version.txt
CI에서 --build-arg GIT_SHA=$(git rev-parse HEAD) 를 넣으면 매 커밋마다 캐시가 깨지는 건 정상입니다.
해결책
- 정말 필요한 단계(최후반)에만
ARG사용 - 바이너리/정적 파일에 박아야 한다면, 캐시 손실을 감수하되 영향 범위를 최소화
# 앞단은 캐시를 최대한 살리고
RUN npm ci
RUN npm run build
# 마지막에만 변동값 주입
ARG GIT_SHA
RUN printf "%s" "$GIT_SHA" > /usr/share/nginx/html/version.txt
7) RUN 단계에서 캐시 마운트를 안 써서 매번 다운로드한다
BuildKit은 RUN --mount=type=cache 로 패키지 매니저 캐시를 유지할 수 있습니다. 이걸 안 쓰면, 레이어 캐시가 깨졌을 때마다 의존성 다운로드가 풀로 재실행됩니다.
예: npm 캐시 마운트
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN \
npm ci
예: apt 캐시 마운트
# syntax=docker/dockerfile:1
FROM ubuntu:24.04
RUN \
apt-get update && apt-get install -y curl
이 방식은 “레이어 캐시”와 별개로 “다운로드 캐시”를 제공하므로, 캐시 미스가 나도 체감 속도를 크게 줄여줍니다.
8) CI에서 빌더가 매번 새로 떠서 캐시 저장소가 없다
로컬에서는 캐시가 잘 먹는데 CI에서는 항상 처음부터라면, 대부분 빌더 인스턴스가 매번 새 환경이라 로컬 캐시가 남지 않기 때문입니다.
해결: 원격 캐시 내보내기/가져오기
Buildx는 레지스트리나 로컬 디렉터리로 캐시를 내보낼 수 있습니다.
# 레지스트리 캐시 사용 예
docker buildx build \
--cache-from type=registry,ref=ghcr.io/acme/app:buildcache \
--cache-to type=registry,ref=ghcr.io/acme/app:buildcache,mode=max \
-t ghcr.io/acme/app:latest \
--push \
.
--cache-to를 설정하지 않으면 “가져오기만 하고 저장은 안 하는” 상태가 됩니다.mode=max는 더 많은 중간 레이어를 보존해 히트율을 올립니다(대신 캐시 크기 증가).
CI 캐시 전략은 Docker만의 문제가 아니라 워크플로 전체 문제로 번지는 경우가 많습니다. GitHub Actions 환경이라면 위 레지스트리 캐시와 함께 GitHub Actions 캐시가 안 먹을 때 속도 3배 올린 실전도 같이 보면 설계에 도움이 됩니다.
9) 시크릿/SSH 사용 방식 때문에 레이어가 재실행된다
BuildKit은 RUN --mount=type=secret 과 RUN --mount=type=ssh 를 지원합니다. 다만 여기서 자주 하는 실수는 다음과 같습니다.
- 시크릿을
ARG나ENV로 넘겨서 레이어가 오염됨 - private repo 접근을 위해 토큰을 파일로
COPY해버림 - SSH를 쓰는 단계가 너무 앞에 있어 작은 변경에도 매번 재실행
올바른 패턴: secret mount 사용
# syntax=docker/dockerfile:1
FROM alpine:3.20
RUN apk add --no-cache git
# 시크릿은 이미지 레이어에 남지 않게
RUN \
sh -lc 'TOKEN=$(cat /run/secrets/git_token) && git clone https://oauth2:$TOKEN@github.com/acme/private.git'
빌드 실행 시:
DOCKER_BUILDKIT=1 docker build \
--secret id=git_token,src=./token.txt \
.
시크릿을 안전하게 쓰는 건 캐시뿐 아니라 보안에도 직결됩니다. 쿠버네티스/클러스터에서 권한과 시크릿이 얽혀 문제를 만들 때는 EKS ExternalSecret 미동작 - IRSA·KMS·권한 10분 진단 같은 체크리스트 접근이 유효합니다.
캐시 디버깅을 빠르게 하는 체크 포인트
--progress=plain으로 처음 캐시 미스가 발생한 스텝을 찾는다.- 그 스텝의 입력을 분해한다: Dockerfile 라인,
COPY대상, 빌드 컨텍스트,ARG, 베이스 이미지 digest, 네트워크 의존성. - CI라면
--cache-to/--cache-from이 실제로 동작하는지(권한, 레지스트리, 태그/레퍼런스 고정)부터 확인한다.
결론: BuildKit 캐시는 “입력의 안정성” 싸움이다
BuildKit 캐시가 안 먹는 이유는 대체로 두 부류입니다.
- 캐시 키가 계속 바뀜: 이른
COPY . ., 부실한.dockerignore, 떠다니는 베이스 이미지, 매 빌드 변하는ARG, 시간/원격 상태 의존 명령 - 캐시를 공유/보존하지 못함: CI의 휘발성 빌더,
--cache-to누락, 캐시 저장소 접근 실패
위 9가지를 순서대로 점검하면, “왜 여기서부터 다시 빌드하지?”가 대부분 설명됩니다. 특히 COPY 순서 재배치와 원격 캐시(--cache-to / --cache-from)만 제대로 잡아도 체감 빌드 시간은 크게 줄어듭니다.