Published on

GitLab CI Docker 빌드가 느릴 때 BuildKit 캐시 최적화

Authors

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 --mount=type=cache,target=/root/.npm \
    npm ci

COPY . .
RUN npm run build

apt 캐시 마운트 예시

# syntax=docker/dockerfile:1.7
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 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 --mount=type=cache,target=/root/.npm npm ci

FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html

이 구조의 장점은 다음과 같습니다.

  • deps 스테이지는 의존성 파일이 바뀌지 않으면 거의 고정
  • build 스테이지는 소스 변경에만 반응
  • 최종 runtime 은 매우 작고 배포/푸시도 빠름

6단계: 캐시가 “안 먹는” 대표 원인 체크리스트

ARGENV 위치가 위쪽에 있다

예를 들어 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 빌드가 느릴 때의 우선순위는 다음이 현실적입니다.

  1. DOCKER_BUILDKIT=1 로 BuildKit 활성화
  2. Dockerfile을 캐시 친화적으로 재구성(COPY 순서, lockfile 우선)
  3. --mount=type=cache 로 패키지 다운로드 캐시 분리
  4. ephemeral runner라면 buildx + type=registry 원격 캐시로 캐시를 “공유 자산”으로 만들기

이 조합을 적용하면, 커밋이 잦은 서비스에서도 빌드 시간을 안정적으로 줄이고 파이프라인 변동폭을 크게 낮출 수 있습니다.