소스 한 줄 고쳤는데 도커 빌드가 처음부터 다시 돌 때, 빌드 캐시 질문 일곱 가지
환경 Dockerfile · BuildKit(docker buildx build) · 2026-09-27 확인
로컬에선 30초면 끝나던 이미지 빌드가 어느 날부터 매번 5분씩 걸립니다. 바꾼 건 소스 한 줄인데 의존성 설치부터 다시 돕니다. 반대로 꼭 새로 받아야 할 패키지는 캐시 때문에 옛날 것이 그대로 들어가기도 합니다. 둘 다 빌더가 캐시를 쓸지 말지 정하는 규칙에서 나옵니다. 그 규칙을 질문 순서대로 풀어 봅니다.
소스 파일 하나 고쳤는데 npm install 부터 다시 돌아요
가장 많이 받는 질문입니다. Dockerfile 을 열어 보면 대개 COPY . . 이 먼저 있고 그 아래에 RUN npm ci 가 있습니다. 소스가 한 글자만 바뀌어도 COPY . . 줄의 캐시가 깨집니다.
빌드 캐시는 위에서 아래로 한 줄씩 맞춰 봅니다. 그리고 한 줄에서 캐시가 깨지면 그 아래 줄은 전부 새로 만듭니다. 설치 명령 자체는 그대로여도, 바로 위 레이어가 새것이니 재사용할 수 없습니다.
순서를 바꾸면 됩니다. 의존성 목록 파일만 먼저 복사해 설치하고, 소스는 맨 나중에 복사합니다. 이러면 package.json 과 락파일이 그대로인 한 설치 단계는 캐시에서 바로 나옵니다. pip 의 requirements.txt, Go 의 go.mod·go.sum 도 같은 방식으로 앞에 둡니다.
# 느린 순서 — 소스가 바뀔 때마다 설치도 다시 돈다
COPY . .
RUN npm ci
# 빠른 순서 — 락파일이 그대로면 설치는 캐시에서 나온다
COPY package.json package-lock.json ./
RUN npm ci
COPY . .자주 바뀌는 것을 아래로, 드물게 바뀌는 것을 위로
캐시는 한 번 깨지면 그 아래 줄이 전부 다시 돈다. 그래서 순서가 곧 속도다.
아무것도 안 고쳤는데 COPY 에서 캐시가 깨져요
순서를 바로잡았는데도 마지막 COPY . . 이 매번 새로 돈다면, 빌드 컨텍스트에 원하지 않는 파일이 섞여 있는 경우가 많습니다. .git 디렉터리가 대표적입니다. 커밋 하나만 해도 .git 안이 바뀌고, 그게 COPY . . 에 같이 딸려 들어갑니다. 로그 파일, 로컬 node_modules, 빌드 결과물, 에디터 임시 파일도 같은 역할을 합니다.
빌더는 COPY·ADD 줄의 캐시를 파일 메타데이터로 만든 체크섬으로 판단합니다. 이때 수정 시각(mtime)은 넣지 않습니다. 그러니 touch 로 시각만 바꾼 파일은 캐시를 깨지 않습니다. 반면 내용이 바뀐 파일이 하나라도 들어오면 깨집니다.
.dockerignore 로 컨텍스트에서 빼 두면 해결됩니다. 이미지에 필요 없는 것을 빼면 컨텍스트 전송도 빨라지고, .env 같은 비밀 파일이 이미지에 들어가는 사고도 같이 막힙니다.
# .dockerignore
cat > .dockerignore <<'IGN'
.git
node_modules
dist
*.log
.env
IGN
# 어느 줄에서 캐시가 깨지는지 확인 (CACHED 가 안 붙은 첫 줄)
docker buildx build --progress=plain -t app . 2>&1 | grep -E '^#[0-9]+ (\[|CACHED)'--progress=plain 으로 보면 CACHED 가 끊기는 첫 단계가 원인이다
패키지를 하나 추가했더니 옛날 버전이 깔려요
RUN apt-get update 를 한 줄로 따로 두고, 그 아래 RUN apt-get install 에 패키지를 추가한 경우입니다. 설치 줄은 바뀌었으니 다시 돌지만, update 줄은 캐시에서 나옵니다. 몇 달 전에 받아 둔 패키지 목록으로 설치를 하니 옛 버전이 들어오거나, 저장소에서 이미 사라진 파일을 받으려다 404 가 납니다.
RUN 줄의 캐시는 명령 문자열만 보고 맞춥니다. 컨테이너 안에서 무엇이 바뀌었는지, 저장소가 갱신됐는지는 확인하지 않습니다. apt-get update 라는 글자가 같으면 같은 줄로 봅니다.
그래서 update 와 install 은 한 RUN 에 묶습니다. 설치 목록을 고치면 문자열이 바뀌니 update 도 같이 다시 돕니다. 버전을 못 박고 싶다면 install 에 버전까지 적어 두면 됩니다.
# 이렇게 나누지 않는다
RUN apt-get update
RUN apt-get install -y curl git
# update 와 install 을 한 줄에
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
git \
&& rm -rf /var/lib/apt/lists/*RUN 캐시는 문자열 비교다. 저장소가 바뀐 건 모른다
빌드 번호를 ARG 로 넘겼더니 그 아래가 전부 다시 돌아요
CI 에서 ARG GIT_SHA 나 ARG BUILD_DATE 를 넘기는 경우가 많습니다. 이 값은 빌드마다 바뀝니다. 이걸 Dockerfile 맨 위에 선언해 두면 의존성 설치까지 매번 다시 돕니다.
ARG 는 선언한 곳이 아니라 처음 쓰이는 곳에서 캐시를 깹니다. 그런데 ARG 선언 아래에 있는 RUN 은 그 값을 환경변수로 암묵적으로 받습니다. 그래서 RUN 안에서 그 변수를 한 번도 안 써도, 값이 달라지면 그 RUN 부터 캐시가 깨집니다. HTTP_PROXY 같은 미리 정의된 ARG 는 Dockerfile 에 같은 이름의 ARG 줄이 없으면 캐시에 영향을 주지 않습니다.
매번 바뀌는 ARG 는 그 값이 필요한 곳 바로 위, 가능하면 Dockerfile 끝 쪽으로 내립니다. 라벨에만 쓰는 값이라면 마지막 단계에서 선언하고 LABEL 에만 씁니다.
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# 빌드마다 바뀌는 값은 맨 아래에서 선언한다
ARG GIT_SHA
LABEL org.opencontainers.image.revision=$GIT_SHAARG 아래의 RUN 은 전부 그 값의 영향을 받는다
캐시를 무시하고 깨끗하게 다시 빌드하려면 뭘 쓰나요
--no-cache 와 --pull 을 헷갈리는 경우가 많습니다. --no-cache 는 빌드 캐시를 쓰지 않고 모든 단계를 처음부터 다시 실행합니다. 그런데 베이스 이미지는 로컬에 있는 것을 그대로 씁니다. --pull 은 반대로 캐시는 쓰되, FROM 에 적힌 이미지의 새 버전이 있는지 레지스트리에 확인하고 받아 옵니다.
베이스 이미지의 보안 패치까지 반영한 완전히 새 빌드가 필요하면 둘을 같이 씁니다. 멀티 스테이지 빌드에서 특정 스테이지만 새로 돌리고 싶다면 --no-cache-filter 에 스테이지 이름을 줍니다.
# 베이스 이미지 새로 받기 + 모든 단계 다시 실행
docker buildx build --pull --no-cache -t app .
# test 스테이지만 캐시 없이
docker buildx build --no-cache-filter test -t app .--no-cache 는 베이스 이미지를 갱신하지 않는다. 그건 --pull 의 일이다
로컬에선 빠른데 CI 에서는 매번 처음부터 빌드해요
CI 러너는 대개 작업이 끝나면 버려집니다. 빌드 캐시는 빌더에 쌓이는데, 빌더가 매번 새로 뜨니 캐시가 늘 비어 있습니다. Dockerfile 이 아무리 잘 짜여 있어도 재사용할 캐시가 없는 셈입니다.
캐시를 밖에 두면 됩니다. --cache-to 로 빌드가 끝날 때 캐시를 레지스트리에 내보내고, 다음 빌드에서 --cache-from 으로 불러옵니다. mode=max 를 주면 최종 이미지에 안 남는 중간 스테이지 레이어까지 내보내서 멀티 스테이지 빌드에서도 캐시가 잘 맞습니다. 이미지 태그와 캐시 태그는 따로 두는 편이 헷갈리지 않습니다.
docker buildx build \
--cache-from type=registry,ref=registry.example.com/app:buildcache \
--cache-to type=registry,ref=registry.example.com/app:buildcache,mode=max \
-t registry.example.com/app:latest --push .버려지는 러너에서는 캐시를 레지스트리에 보관한다
락파일이 바뀌면 결국 전부 다시 받는데, 줄일 방법이 있나요
순서를 아무리 잘 짜도 의존성 하나를 올리면 설치 레이어는 새로 만들어야 합니다. 이때 수백 개 패키지를 전부 다시 내려받는 게 문제라면 캐시 마운트를 씁니다.
RUN --mount=type=cache 는 그 RUN 이 도는 동안에만 빌더 쪽 캐시 디렉터리를 붙여 줍니다. 레이어 캐시와는 별개입니다. 레이어는 다시 만들어도 npm·pip·apt 의 다운로드 캐시는 남아 있어서, 새로 바뀐 패키지만 받습니다. 붙이는 디렉터리는 레이어에 들어가지 않으니 이미지 크기도 늘지 않습니다.
apt 는 캐시 디렉터리를 동시에 둘이 쓰면 안 되니 sharing=locked 를 줍니다. 여러 빌드가 같은 캐시를 쓰면 차례를 기다립니다. 공식 이미지의 데비안·우분투는 설치 후 받은 .deb 를 지우는 설정이 들어 있어서, 캐시를 남기려면 그 설정 파일부터 지워야 합니다.
# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
# apt 는 잠금을 걸어 공유한다
RUN rm -f /etc/apt/apt.conf.d/docker-clean \
&& echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends curl레이어는 다시 만들어도 다운로드는 캐시에서 나온다
조치 후 확인할 것
- 의존성 목록 파일만 먼저 COPY 하고, 설치한 뒤에 소스를 COPY 하는 순서인가
- .dockerignore 에 .git·node_modules·로그·.env 가 들어 있는가
- apt-get update 가 install 과 같은 RUN 에 묶여 있는가
- 빌드마다 바뀌는 ARG 가 Dockerfile 아래쪽에 있는가
- CI 빌드에 --cache-from·--cache-to 가 설정돼 있는가
빌드가 느려졌을 때 제일 먼저 할 일은 --progress=plain 으로 CACHED 가 끊기는 첫 줄을 찾는 것입니다. 그 줄이 COPY 면 컨텍스트를, RUN 이면 바로 위 줄과 ARG 를 보면 됩니다. 반대로 새로 받아야 할 게 안 바뀐다면, RUN 캐시가 명령 문자열만 본다는 걸 떠올리면 됩니다.