Published on

Jenkins 빌드가 갑자기 느릴 때 Docker 레이어 캐시 복구

Authors

서론

Jenkins 파이프라인이 평소엔 35분이면 끝나던 Docker 빌드가 어느 날 갑자기 2030분씩 걸리기 시작하면, 가장 먼저 의심해야 할 것은 Docker 레이어 캐시(layer cache) 입니다. 로그를 보면 늘 보이던 CACHED가 사라지고 매번 RUN apt-get ..., npm ci, pip install 같은 무거운 단계가 처음부터 다시 실행됩니다.

문제는 “왜 느려졌는지”보다 “왜 캐시가 더 이상 적용되지 않는지”를 정확히 찔러야 재발을 막을 수 있다는 점입니다. 이 글에서는 Jenkins 환경에서 캐시가 깨지는 전형적인 패턴을 빠르게 진단하고, BuildKit + registry cache 또는 로컬 캐시 유지 전략으로 레이어 캐시를 복구하는 방법을 다룹니다.

> 참고: 빌드가 느려진 원인이 네트워크/권한 문제(예: S3, STS)로 인한 타임아웃일 수도 있습니다. 그런 경우는 로그에 403/timeout이 같이 보이므로 별도 점검이 필요합니다. 예: AWS S3 AccessDenied 403 - 정책·KMS·VPCE 점검

1) “캐시가 안 먹는다”의 증상 체크리스트

아래 중 2개 이상이면 레이어 캐시 문제일 확률이 높습니다.

  • Jenkins 에이전트가 매 빌드마다 새로 뜨는 ephemeral 컨테이너/노드(Kubernetes plugin, EC2 spot 등)로 바뀜
  • docker build 로그에서 Using cache(legacy) 또는 CACHED(BuildKit)가 거의 보이지 않음
  • Dockerfile 상단의 COPY . . 이후 단계가 매번 전부 재실행
  • --no-cache 옵션이 파이프라인 어딘가에 들어가 있음(의외로 흔함)
  • 베이스 이미지 태그가 latest 같은 가변 태그이고, pull 정책 때문에 매번 바뀜
  • 빌드 컨텍스트가 커졌거나(불필요 파일 포함), .dockerignore가 비어 있음

2) 캐시가 깨지는 대표 원인 7가지

2.1 Jenkins 에이전트가 매번 새 머신(새 Docker 데몬)

Docker 레이어 캐시는 기본적으로 같은 Docker 데몬의 로컬 스토리지에 쌓입니다. Jenkins가 Kubernetes에서 pod로 에이전트를 띄우고, 빌드가 끝나면 pod가 삭제되는 구조라면 캐시는 매번 초기화됩니다.

  • 해결 방향: 원격 캐시(Registry cache)로 이동하거나, 빌드 노드를 고정/볼륨 마운트해서 로컬 캐시를 유지합니다.

2.2 Dockerfile의 레이어 순서가 캐시에 불리함

예를 들어 Node 프로젝트에서 다음처럼 작성하면 소스 변경 때마다 의존성 설치가 다시 돕니다.

FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build

COPY . .가 먼저라서 파일 하나만 바뀌어도 npm ci 레이어가 무효화됩니다.

  • 해결: lock 파일만 먼저 복사 → 의존성 설치 → 소스 복사 순으로 재배치합니다.
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

2.3 빌드 컨텍스트가 커짐 + .dockerignore 부재

테스트 리포트, .git, 로컬 캐시(node_modules, .venv, dist) 등이 컨텍스트에 포함되면, 작은 변경에도 컨텍스트 해시가 바뀌어 캐시 적중률이 떨어집니다.

.dockerignore 최소 예시:

.git
node_modules
.dist
build
coverage
*.log
.DS_Store

2.4 latest/가변 태그로 베이스 이미지가 흔들림

FROM ubuntu:latest는 pull 타이밍에 따라 이미지 digest가 바뀌고, 그 순간부터 아래 레이어가 전부 재빌드될 수 있습니다.

  • 해결: 가능하면 digest pinning
FROM ubuntu@sha256:... 

2.5 멀티스테이지에서 불필요한 파일 복사

멀티스테이지 빌드에서 COPY --from=builder /app /app처럼 크게 복사하면, 빌더 단계 산출물 변경이 자주 발생해 캐시 무효화가 잦습니다. 산출물만 좁혀 복사하세요.

COPY --from=builder /app/dist ./dist

2.6 BuildKit 비활성화 / builder 인스턴스 변경

Jenkins 노드마다 BuildKit 설정이 제각각이거나, docker buildx builder가 매번 새로 생성되면 캐시 전략이 흔들립니다.

2.7 캐시 purge/GC가 과격함

운영 중 디스크 부족으로 docker system prune -af 같은 작업이 주기적으로 돌면 캐시가 통째로 날아갑니다.

3) 가장 확실한 해법: BuildKit + Registry Cache

에이전트가 ephemeral이라도 캐시를 유지하려면 레지스트리에 캐시를 내보내고(import/export) 다시 가져오는 방식이 가장 재현성이 좋습니다.

핵심은 아래 3가지입니다.

  • docker buildx build 사용
  • --cache-to type=registry로 캐시 푸시
  • 다음 빌드에서 --cache-from type=registry로 캐시 풀

3.1 Jenkinsfile 예시 (Buildx + registry cache)

아래 예시는 ECR/사설 registry 어디든 적용 가능합니다(인증은 별도).

pipeline {
  agent any
  environment {
    IMAGE = "registry.example.com/myteam/myapp"
    CACHE = "registry.example.com/myteam/myapp:buildcache"
  }
  stages {
    stage('Prepare buildx') {
      steps {
        sh '''
          set -e
          docker version
          docker buildx version

          # 고정된 builder를 사용(없으면 생성)
          if ! docker buildx inspect jenkins-builder >/dev/null 2>&1; then
            docker buildx create --name jenkins-builder --use
          else
            docker buildx use jenkins-builder
          fi

          docker buildx inspect --bootstrap
        '''
      }
    }

    stage('Build & Push (with cache)') {
      steps {
        sh '''
          set -e
          GIT_SHA=$(git rev-parse --short HEAD)

          docker buildx build \
            --progress=plain \
            --tag ${IMAGE}:${GIT_SHA} \
            --tag ${IMAGE}:latest \
            --cache-from type=registry,ref=${CACHE} \
            --cache-to type=registry,ref=${CACHE},mode=max \
            --push \
            .
        '''
      }
    }
  }
}

포인트

  • mode=max는 가능한 많은 중간 레이어를 캐시에 저장해 적중률을 올립니다.
  • --progress=plain은 Jenkins 로그에서 캐시 적중 여부를 확인하기 좋습니다.
  • --push를 켜야 registry cache 전략이 매끄럽습니다(로컬에만 남기지 않음).

4) Kubernetes Jenkins 에이전트에서 캐시를 유지하는 2가지 대안

레지스트리 캐시가 가장 범용적이지만, 환경에 따라 로컬 캐시를 살리는 편이 더 빠를 때도 있습니다.

4.1 Docker-in-Docker(dind) + PVC로 /var/lib/docker 유지

Kubernetes Jenkins agent pod에 dind를 띄우고 /var/lib/docker를 PVC에 붙이면 레이어 캐시가 유지됩니다.

주의점:

  • 보안/권한(Privileged) 요구
  • 노드/스토리지 성능에 따라 오히려 느려질 수 있음

4.2 Kaniko/BuildKit daemonless + 캐시 볼륨

클러스터 정책상 privileged가 불가능하면 Kaniko의 --cache=true + cache repo를 쓰거나, BuildKit(rootless) 구성을 고려합니다.

다만 Jenkins에서 운영 난이도는 registry cache 방식이 가장 단순합니다.

5) Dockerfile 캐시 적중률을 올리는 실전 패턴

5.1 Node/React/Next.js

  • package-lock.json/pnpm-lock.yaml/yarn.lock를 먼저 복사
  • npm ci는 lock 기반이라 캐시 친화적
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 --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

> Next.js 계열은 빌드 캐시/서버액션 캐시가 별도로 꼬일 때도 있습니다. 애플리케이션 레벨 캐시 문제라면 Next.js 14 서버액션 500·CSRF·캐시 꼬임 해결도 함께 점검하세요.

5.2 Python

  • requirements.txt/poetry.lock 먼저 복사
  • wheel 캐시를 BuildKit cache mount로 가속
# syntax=docker/dockerfile:1.6
FROM python:3.12-slim
WORKDIR /app

COPY requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

COPY . .
CMD ["python", "main.py"]

6) “캐시가 적용되는지” 로그로 검증하는 방법

6.1 BuildKit 로그에서 CACHED 확인

--progress=plain을 켠 상태에서 다음을 확인합니다.

  • #x [stage ...] CACHED
  • importing cache manifest from ...buildcache

캐시가 전혀 import되지 않는다면:

  • registry 인증 실패
  • cache ref 잘못됨
  • buildx builder가 다른 드라이버를 사용

6.2 레이어가 계속 깨지는 지점 찾기

캐시가 깨지는 “첫 번째 레이어”를 찾으면 원인이 거의 드러납니다.

  • COPY . .가 첫 번째라면 .dockerignore/컨텍스트 문제
  • RUN apt-get update부터 다시라면 베이스 이미지 변경 또는 ARG 변경
  • RUN npm ci부터 다시라면 lock 파일 변경 또는 이전 레이어 무효화

7) 자주 하는 실수와 예방책

7.1 ARG를 너무 앞에 두기

ARG BUILD_NUMBER 같은 값은 매 빌드마다 바뀌므로, Dockerfile 상단에 있으면 이후 레이어가 전부 무효화됩니다.

  • 해결: 정말 필요한 지점 직전에만 ARG 사용

7.2 빌드 시간 최적화와 런타임 최적화를 혼동

캐시를 살리는 목적은 “빌드 시간”을 줄이는 것입니다. 런타임 이미지 크기 최적화(멀티스테이지, slim)는 별개로 하되, 빌드 단계에서 의존성 설치 레이어가 잘 캐시되도록 설계해야 합니다.

7.3 디스크 부족으로 인한 캐시 손실

Jenkins 노드에서 디스크가 부족하면 Docker가 GC를 하거나 운영자가 prune을 돌립니다. 빌드가 갑자기 느려진 날에 노드 이벤트/알람(디스크 80%↑)이 있었는지 확인하세요.

클러스터에서 노드 리소스 압박이 원인이라면, 파드 스케줄링/리소스 이슈도 함께 점검해야 합니다. 예: EKS Pod Pending(Insufficient cpu) 원인과 해결

8) 결론: “에이전트가 바뀌어도 캐시가 남는 구조”로 고정하라

Jenkins 빌드가 갑자기 느려졌을 때 Docker 레이어 캐시를 복구하는 가장 확실한 접근은 다음 순서입니다.

  1. 로그에서 캐시가 사라진 지점(첫 무효 레이어)을 찾는다.
  2. Dockerfile을 캐시 친화적으로 재구성한다(COPY 순서, .dockerignore, 가변 태그 제거).
  3. Jenkins 에이전트가 ephemeral이면 BuildKit + registry cache로 캐시를 외부화한다.
  4. 디스크/GC/prune 같은 운영 요인으로 캐시가 날아가지 않도록 관측/정책을 정한다.

이렇게 구성하면 “갑자기 느려지는” 상황 자체가 줄어들고, 느려지더라도 원인 파악이 빨라져 CI의 예측 가능성이 크게 올라갑니다.