- Published on
GitLab CI Docker 빌드가 느릴 때 BuildKit 캐시 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
GitLab CI에서 Docker 이미지를 빌드하다 보면 어느 순간부터 파이프라인 시간이 눈에 띄게 늘어납니다. 특히 docker build 단계가 매번 “처음부터” 도는 느낌이 들면, 대부분의 원인은 캐시가 깨지거나(무효화) 캐시를 재사용할 수 없는 실행 환경에 있습니다.
이 글에서는 GitLab CI에서 빌드가 느려지는 전형적인 패턴을 짚고, BuildKit 활성화와 **캐시(레이어 캐시, 레지스트리 캐시, 로컬 캐시)**를 조합해 빌드 시간을 줄이는 실전 구성을 소개합니다. DinD 환경에서 자주 겪는 이슈는 별도 글로 정리해두었으니 필요하면 함께 참고하세요: GitLab CI DinD TLS 실패, 원인별 해결법
왜 GitLab CI에서 Docker 빌드가 느려질까
1) 러너가 매번 깨끗한 환경이라 캐시가 없다
GitLab Shared Runner나 ephemeral runner(매 잡마다 새 VM/컨테이너)에서는 로컬 Docker 레이어 캐시가 남지 않습니다. 로컬 캐시가 없으면 apt-get, npm ci, pip install 같은 단계가 매번 네트워크를 타고 반복됩니다.
2) Dockerfile 작성이 캐시 친화적이지 않다
아래와 같은 패턴은 캐시를 쉽게 무효화합니다.
COPY . .를 너무 이른 단계에 둠- 의존성 파일(
package-lock.json,poetry.lock,go.sum)보다 소스 전체를 먼저 복사 - 자주 바뀌는 빌드 인자(
ARG)를 상단에 둠
캐시는 “이전 레이어까지의 입력이 동일하면 재사용”되는데, 입력이 자주 바뀌면 이후 레이어가 줄줄이 무효화됩니다.
3) DinD 오버헤드와 스토리지 병목
DinD(docker:dind)는 편하지만, 스토리지 드라이버/네트워크/보안 설정에 따라 오버헤드가 커질 수 있습니다. 특히 디스크 사용량은 빌드 성능과 직결됩니다. 용량이 남아도 inode가 고갈되면 빌드가 급격히 느려지거나 실패할 수 있습니다: 용량 남는데 No space left? inode 고갈 해결법
BuildKit이 왜 중요한가
BuildKit은 기존 빌더보다 다음이 강합니다.
- 더 공격적인 병렬화와 효율적인 캐시 처리
--mount=type=cache로 패키지 매니저 캐시를 레이어와 분리해 재사용 가능- 레지스트리로 캐시를 내보내고(import/export) 다른 러너에서도 재사용 가능
즉, ephemeral runner에서도 “원격 캐시”로 속도를 유지할 수 있습니다.
1단계: GitLab CI에서 BuildKit 켜기(가장 쉬운 개선)
DinD를 쓰는 가장 흔한 구성은 아래처럼 DOCKER_BUILDKIT=1 을 켜는 것입니다.
build-image:
image: docker:27
services:
- name: docker:27-dind
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
DOCKER_BUILDKIT: "1"
script:
- docker version
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
이 단계만으로도 일부 프로젝트는 체감이 있습니다. 다만 “러너가 매번 새로 뜨는 구조”라면 여전히 캐시가 부족합니다.
2단계: Dockerfile을 캐시 친화적으로 바꾸기
Node.js 예시로, 캐시가 잘 타도록 순서를 재배치합니다.
나쁜 예(캐시 무효화가 잦음)
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
COPY . . 는 소스 변경이 있을 때마다 의존성 설치 레이어까지 깨뜨립니다.
좋은 예(의존성 레이어를 최대한 고정)
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
이렇게 하면 소스만 바뀌는 커밋에서는 npm ci 레이어가 재사용될 가능성이 커집니다.
3단계: BuildKit 캐시 마운트로 패키지 설치 가속
BuildKit의 핵심 기능 중 하나가 RUN --mount=type=cache 입니다. 레이어 캐시와 별개로 “패키지 다운로드 캐시”를 잡아두면, 레이어가 일부 무효화되더라도 다운로드/압축 해제 비용을 줄일 수 있습니다.
npm 캐시 마운트 예시
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN \
npm ci
COPY . .
RUN npm run build
apt 캐시 마운트 예시
# syntax=docker/dockerfile:1.7
FROM ubuntu:24.04
RUN \
apt-get update && apt-get install -y curl ca-certificates
주의할 점은, 이 캐시 마운트는 “빌더가 캐시를 유지할 수 있을 때” 효과가 큽니다. ephemeral runner라면 다음 단계인 **원격 캐시(export/import)**가 필요합니다.
4단계: 레지스트리 원격 캐시로 러너가 바뀌어도 빠르게
GitLab CI에서 가장 실용적인 방법은 레지스트리 캐시입니다. 즉, 빌드 결과뿐 아니라 캐시 메타데이터도 레지스트리에 함께 저장해 다음 빌드에서 가져옵니다.
여기서는 docker buildx 와 BuildKit 컨테이너 드라이버를 사용합니다. DinD 위에서 동작시키되, 캐시를 type=registry 로 내보냅니다.
.gitlab-ci.yml 예시: buildx + registry cache
stages:
- build
build-image:
stage: build
image: docker:27
services:
- name: docker:27-dind
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
DOCKER_BUILDKIT: "1"
IMAGE: "$CI_REGISTRY_IMAGE"
TAG: "$CI_COMMIT_SHA"
CACHE_REF: "$CI_REGISTRY_IMAGE:buildcache"
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker buildx version
- docker buildx create --use --name builder
script:
- |
docker buildx build \
--push \
--tag "$IMAGE:$TAG" \
--cache-from "type=registry,ref=$CACHE_REF" \
--cache-to "type=registry,ref=$CACHE_REF,mode=max" \
.
포인트
CACHE_REF를 고정 태그(예:buildcache)로 둬야 다음 파이프라인이 같은 캐시를 가져옵니다.mode=max는 가능한 많은 캐시를 내보내 빌드 속도에 유리하지만, 레지스트리 저장소 사용량은 늘 수 있습니다.--push를 사용하면 빌드 결과가 러너 로컬에 남지 않아도 됩니다.
5단계: 멀티스테이지와 타깃 분리로 캐시 효율 극대화
멀티스테이지는 이미지 크기뿐 아니라 캐시에도 도움이 됩니다. 예를 들어 빌드 툴체인이 바뀌지 않는다면 “빌더 스테이지”의 캐시를 오래 재사용할 수 있습니다.
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS build
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY /app/dist /usr/share/nginx/html
이 구조의 장점은 다음과 같습니다.
deps스테이지는 의존성 파일이 바뀌지 않으면 거의 고정build스테이지는 소스 변경에만 반응- 최종
runtime은 매우 작고 배포/푸시도 빠름
6단계: 캐시가 “안 먹는” 대표 원인 체크리스트
ARG 와 ENV 위치가 위쪽에 있다
예를 들어 ARG BUILD_TIME 같은 값이 매 빌드마다 바뀌면 그 아래 레이어가 전부 무효화됩니다. 자주 변하는 ARG 는 최대한 아래로 내리거나, 정말 필요할 때만 사용하세요.
.dockerignore 가 없다
컨텍스트가 커지면 업로드/해시 계산이 느려지고, 불필요한 파일 변경으로 캐시가 깨집니다.
.git
node_modules
dist
coverage
Dockerfile
README.md
프로젝트 성격에 맞게 조정하되, “빌드에 필요 없는 것”은 과감히 제외하는 게 좋습니다.
레지스트리 캐시 권한/네트워크 문제
type=registry 캐시는 결국 레지스트리 pull/push가 필요합니다. 간헐적 403/권한 문제는 빌드 자체를 느리게 만들거나 캐시를 포기하게 만듭니다. (GitHub Actions 사례지만 권한 설계 관점은 유사합니다: GitHub Actions 403 권한오류 해결 - GITHUB_TOKEN·OIDC)
7단계: 성능 측정과 로그에서 확인할 것
- BuildKit 로그에서
CACHED표기가 늘어나는지 - 의존성 설치 단계가 네트워크를 매번 타는지
- 컨텍스트 전송 시간이 긴지(특히 monorepo)
GitLab Job 로그만으로 부족하면, 빌드 단계에서 --progress=plain 을 켜서 캐시 히트 여부를 더 명확히 볼 수 있습니다.
docker buildx build --progress=plain .
결론: “BuildKit 켜기”만으로 부족하면 원격 캐시까지
정리하면, GitLab CI에서 Docker 빌드가 느릴 때의 우선순위는 다음이 현실적입니다.
DOCKER_BUILDKIT=1로 BuildKit 활성화- Dockerfile을 캐시 친화적으로 재구성(
COPY순서, lockfile 우선) --mount=type=cache로 패키지 다운로드 캐시 분리- ephemeral runner라면
buildx+type=registry원격 캐시로 캐시를 “공유 자산”으로 만들기
이 조합을 적용하면, 커밋이 잦은 서비스에서도 빌드 시간을 안정적으로 줄이고 파이프라인 변동폭을 크게 낮출 수 있습니다.