Published on

Docker BuildKit 캐시가 안 먹는 9가지 원인

Authors

서버에서 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 updateapt-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 --from=build /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 --mount=type=cache,target=/root/.npm \
    npm ci

예: apt 캐시 마운트

# syntax=docker/dockerfile:1
FROM ubuntu:24.04

RUN --mount=type=cache,target=/var/cache/apt \
    --mount=type=cache,target=/var/lib/apt \
    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=secretRUN --mount=type=ssh 를 지원합니다. 다만 여기서 자주 하는 실수는 다음과 같습니다.

  • 시크릿을 ARGENV 로 넘겨서 레이어가 오염됨
  • private repo 접근을 위해 토큰을 파일로 COPY 해버림
  • SSH를 쓰는 단계가 너무 앞에 있어 작은 변경에도 매번 재실행

올바른 패턴: secret mount 사용

# syntax=docker/dockerfile:1
FROM alpine:3.20
RUN apk add --no-cache git

# 시크릿은 이미지 레이어에 남지 않게
RUN --mount=type=secret,id=git_token \
    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분 진단 같은 체크리스트 접근이 유효합니다.

캐시 디버깅을 빠르게 하는 체크 포인트

  1. --progress=plain 으로 처음 캐시 미스가 발생한 스텝을 찾는다.
  2. 그 스텝의 입력을 분해한다: Dockerfile 라인, COPY 대상, 빌드 컨텍스트, ARG, 베이스 이미지 digest, 네트워크 의존성.
  3. CI라면 --cache-to / --cache-from 이 실제로 동작하는지(권한, 레지스트리, 태그/레퍼런스 고정)부터 확인한다.

결론: BuildKit 캐시는 “입력의 안정성” 싸움이다

BuildKit 캐시가 안 먹는 이유는 대체로 두 부류입니다.

  • 캐시 키가 계속 바뀜: 이른 COPY . ., 부실한 .dockerignore, 떠다니는 베이스 이미지, 매 빌드 변하는 ARG, 시간/원격 상태 의존 명령
  • 캐시를 공유/보존하지 못함: CI의 휘발성 빌더, --cache-to 누락, 캐시 저장소 접근 실패

위 9가지를 순서대로 점검하면, “왜 여기서부터 다시 빌드하지?”가 대부분 설명됩니다. 특히 COPY 순서 재배치와 원격 캐시(--cache-to / --cache-from)만 제대로 잡아도 체감 빌드 시간은 크게 줄어듭니다.