CI 파이프라인에서 가장 자주 손보게 되는 곳이 컨테이너 이미지 빌드입니다. 매번 몇 분씩 걸리는 docker build는 개발 피드백 루프를 갉아먹고, CI 청구서도 불립니다. 다행히 Docker의 기본 빌더가 BuildKit으로 바뀌면서, 제대로만 쓰면 빌드 시간을 극적으로 줄일 수 있는 도구들이 손에 들어왔습니다.
이 글에서는 BuildKit의 핵심인 병렬 빌드, 캐시 마운트(--mount=type=cache), 그리고 CI 환경에서의 레지스트리 캐시 내보내기/불러오기를 실전 예제로 다룹니다. 목표는 “매번 다시 받는 의존성 설치”를 근본적으로 없애는 것입니다.
BuildKit이 기존 빌더와 다른 점
전통적인 Docker 빌더는 Dockerfile을 위에서 아래로 순차 실행하고, 각 명령을 레이어로 캐시했습니다. 문제는 캐시 무효화가 지나치게 광범위하다는 점입니다. 한 줄이 바뀌면 그 아래 모든 레이어가 무효화됩니다. BuildKit은 빌드를 의존성 그래프(DAG)로 분석해 서로 무관한 단계를 병렬로 실행하고, 실제로 필요한 단계만 재실행합니다.
최신 Docker에서는 BuildKit이 기본이지만, 명시적으로 켜려면 다음과 같이 합니다.
export DOCKER_BUILDKIT=1
docker build -t myapp:latest .
# 또는 buildx 사용 (권장, 고급 기능 전부 지원)
docker buildx build -t myapp:latest .
캐시 마운트: 의존성 디렉터리를 빌드 간 유지
BuildKit의 진짜 무기는 RUN --mount=type=cache입니다. 이것은 이미지 레이어에 포함되지 않는 임시 캐시 디렉터리를 빌드 스텝에 마운트합니다. npm, pip, apt, go, maven 같은 패키지 매니저의 캐시 디렉터리를 여기에 걸어두면, 이미지 레이어 캐시가 무효화되어도 다운로드한 패키지는 그대로 재사용됩니다.
Node.js 예제를 보겠습니다. package.json이 바뀌면 npm ci 레이어는 무효화되지만, 캐시 마운트 덕에 npm은 대부분의 패키지를 로컬 캐시에서 가져옵니다.
# syntax=docker/dockerfile:1
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline
COPY . .
RUN npm run build
첫 줄의 # syntax=docker/dockerfile:1은 필수입니다. 이 지시어가 있어야 최신 Dockerfile 프런트엔드가 로드되어 --mount 문법을 이해합니다.
Python(pip)과 Debian(apt)도 같은 패턴을 씁니다.
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
# apt 캐시는 sharing=locked로 동시 접근 충돌 방지
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 build-essential
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
주의: apt를 쓸 때 Debian 이미지는 기본적으로 /etc/apt/apt.conf.d/docker-clean이 다운로드한 .deb를 즉시 삭제하도록 설정되어 있습니다. 캐시 마운트 효과를 보려면 이 정리 동작을 꺼야 합니다.
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
바인드 마운트와 시크릿 마운트
캐시 외에도 유용한 마운트 타입이 있습니다. --mount=type=bind는 COPY 없이 빌드 컨텍스트 파일을 잠깐 참조할 때 쓰고, --mount=type=secret은 프라이빗 레지스트리 토큰 같은 민감정보를 이미지 레이어에 남기지 않고 주입할 때 씁니다.
# 시크릿을 레이어에 남기지 않고 빌드 중에만 사용
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
# 빌드 시:
# docker buildx build --secret id=npmrc,src=$HOME/.npmrc -t app .
이전에는 ARG TOKEN으로 토큰을 넘기다가 이미지 히스토리에 그대로 박히는 사고가 흔했는데, 시크릿 마운트가 이를 근본적으로 막아줍니다.
CI에서의 캐시: 레지스트리 캐시 백엔드
로컬 개발에서는 캐시 마운트만으로 충분하지만, CI는 매 실행이 깨끗한 러너에서 시작하는 경우가 많아 로컬 캐시가 사라집니다. 이때는 캐시를 레지스트리에 저장해서 다음 빌드가 불러오게 합니다. buildx의 --cache-to와 --cache-from을 씁니다.
docker buildx build \
--cache-to type=registry,ref=myregistry/myapp:buildcache,mode=max \
--cache-from type=registry,ref=myregistry/myapp:buildcache \
--push -t myregistry/myapp:latest .
mode=max는 중간 스테이지 레이어까지 모두 캐시로 내보냅니다(멀티스테이지 빌드에 유리). mode=min은 최종 이미지 레이어만 저장해 캐시 크기가 작습니다. 대신 재사용 범위가 좁습니다.
GitHub Actions에서는 docker/build-push-action이 이를 감싸줍니다. GitHub Actions 캐시(type=gha)를 백엔드로 쓰면 별도 레지스트리 없이도 캐시가 유지됩니다.
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: myregistry/myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=max
레이어 순서: 캐시를 살리는 Dockerfile 설계
어떤 캐시 백엔드를 쓰든, Dockerfile 명령 순서가 잘못되면 효과가 반감됩니다. 원칙은 변경 빈도가 낮은 것을 위에, 자주 바뀌는 것을 아래에 두는 것입니다. 의존성 매니페스트를 먼저 복사해 설치하고, 소스 코드는 그 뒤에 복사하는 이유입니다.
- 나쁜 예:
COPY . .후RUN npm ci→ 소스 한 글자만 바뀌어도 전체 재설치. - 좋은 예:
COPY package*.json ./→RUN npm ci→COPY . .→ 의존성 캐시 유지.
또한 .dockerignore로 node_modules, .git, 빌드 산출물을 컨텍스트에서 빼면 전송 시간과 불필요한 캐시 무효화를 함께 줄일 수 있습니다.
# .dockerignore
node_modules
.git
dist
*.log
.env
마무리
BuildKit의 캐시 마운트는 “의존성은 한 번만 받으면 된다”는 당연한 명제를 CI에서 현실로 만들어 줍니다. 로컬에서는 --mount=type=cache로 패키지 다운로드를 없애고, CI에서는 레지스트리나 gha 캐시 백엔드로 러너 간에 캐시를 넘기며, Dockerfile 레이어 순서로 마지막 한 방울까지 짜내는 것이 실전 조합입니다. 무엇보다 도입 후에는 --progress=plain으로 빌드 로그를 보며 어떤 단계가 실제로 캐시 히트하는지 눈으로 확인하는 습관을 들이길 권합니다.