Published on

Jenkins Declarative Pipeline 병렬 스테이지 데드락 해결

Authors

서로 다른 테스트/빌드를 병렬로 돌리려 parallel을 썼는데, 어느 순간부터 파이프라인이 끝나지 않고 멈춘 듯이 보이는 현상을 종종 만납니다. Jenkins UI에서는 특정 브랜치가 Running 상태로 남아 있고, 로그는 더 이상 진행되지 않으며, 빌드 큐에는 항목이 쌓여갑니다. 전형적인 데드락(deadlock)부터, executor 고갈, Lockable Resources 경쟁, 동일 workspace 충돌, input/milestone로 인한 교착, agent 라벨 매칭 실패로 인한 무한 대기까지 증상이 비슷하게 나타납니다.

이 글에서는 Declarative Pipeline 기준으로 병렬 스테이지가 멈추는 문제를 원인별로 분해하고, 운영 환경에서 바로 적용 가능한 진단 체크리스트와 구조적 해결책을 코드 예제와 함께 정리합니다.

1) “데드락처럼 보이는” 병렬 정지의 5가지 패턴

병렬이 멈췄다고 해서 항상 스레드 데드락은 아닙니다. Jenkins Pipeline은 CPS(continuation passing style)로 동작하고, 대부분의 정지는 리소스 대기에서 발생합니다.

패턴 A. Executor 고갈 (가장 흔함)

  • 병렬 브랜치가 각각 node('label')로 executor를 잡음
  • 같은 잡/같은 노드 풀에서 다른 스테이지도 executor를 잡고 있음
  • 결과적으로 서로가 서로의 executor 반환을 기다리는 상태가 됨

특히 다음 조합이 위험합니다.

  • parallel 안에서 node를 여러 번 중첩
  • agent any + parallel + docker/kubernetes 플러그인 혼합
  • options { disableConcurrentBuilds() }가 걸린 upstream/downstream 체인

패턴 B. Lockable Resources 플러그인 잠금 경쟁

  • lock('resource')를 여러 브랜치가 잡으려 함
  • 한 브랜치가 예외/중단으로 lock을 풀지 못하거나, inversePrecedence/quantity 설정이 꼬임

패턴 C. 동일 workspace 공유로 인한 교착/대기

  • 병렬 브랜치가 같은 workspace에서 npm ci, go test, mvn 등을 동시에 수행
  • 파일 락/캐시 디렉터리 충돌로 프로세스가 멈추거나, Jenkins가 ws/checkout 단계에서 경합

패턴 D. milestone / input 단계의 논리적 교착

  • milestone()을 잘못 배치해 특정 브랜치만 통과하고 나머지가 중단되거나
  • input이 병렬 브랜치 내부에 있어 승인 대기 상태가 되며 전체가 멈춘 것처럼 보임

패턴 E. agent 라벨/Pod 스케줄 실패로 무한 대기

  • agent { label 'gpu' }인데 실제로 해당 라벨의 노드가 없음
  • Kubernetes 플러그인에서 Pod가 Pending(리소스 부족, 이미지 pull 실패 등)인데 Jenkins는 executor를 기다림

Kubernetes 환경이라면, 파드가 Pending/CrashLoop/이미지 Pull 오류로 인해 “Jenkins는 대기”처럼 보이는 경우가 많습니다. 이때는 앱 레벨이 아니라 인프라 레벨 신호를 함께 봐야 합니다. 비슷한 성격의 장애 진단 흐름은 Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅도 참고할 만합니다.

2) 10분 안에 하는 1차 진단 체크리스트

2.1 Jenkins UI에서 Queue/Executor부터 확인

  1. Manage Jenkins → System Information에서 executor 수, 노드 상태 확인
  2. Build Queue에 “Waiting for next available executor”가 반복되는지 확인
  3. 각 병렬 브랜치 로그에 Waiting for executor / Still waiting to schedule task가 있는지 확인

2.2 Thread Dump(스레드 덤프)로 “진짜 데드락”인지 확인

  • Manage Jenkins → System Log / Thread Dump
  • 또는 /threadDump 엔드포인트

진짜 JVM-level deadlock은 Thread Dump에 Found one Java-level deadlock 형태로 표시됩니다. 하지만 대부분은 여기서 deadlock이 아니라 대기 상태로만 보일 겁니다.

2.3 Pipeline Steps 대기 원인 파악

Pipeline이 멈춰 있을 때는 보통 아래 중 하나에서 멈춥니다.

  • node (executor/agent 대기)
  • lock (리소스 잠금 대기)
  • checkout scm (workspace/credentials/SCM lock)
  • 외부 명령(테스트/빌드)이 파일락으로 멈춤

3) 재현 가능한 최소 예제: executor 고갈로 인한 병렬 정지

아래는 “병렬인데 더 느려지고, 어느 순간 멈춘다”를 만들기 쉬운 형태입니다.

pipeline {
  agent none
  stages {
    stage('Fan-out') {
      parallel {
        stage('A') {
          agent { label 'linux' }
          steps {
            sh 'echo A; sleep 60'
          }
        }
        stage('B') {
          agent { label 'linux' }
          steps {
            sh 'echo B; sleep 60'
          }
        }
        stage('C') {
          agent { label 'linux' }
          steps {
            sh 'echo C; sleep 60'
          }
        }
      }
    }
  }
}
  • linux 라벨 노드가 1대이고 executor=2라면, 3번째 브랜치가 큐에서 대기합니다.
  • 여기에 다른 잡이 같은 라벨을 점유하면, 병렬이 사실상 직렬이 되거나 대기 시간이 길어집니다.

해결 방향

  • 노드 풀을 늘리거나 executor 수를 조정
  • 병렬 fan-out 크기를 제한
  • 병렬 브랜치가 꼭 node를 잡아야 하는 구간만 최소화

4) 해결책 1: 병렬 fan-out 제한(throttle)과 fail-fast

Declarative 자체에는 동적 스로틀이 제한적이지만, 다음 2가지는 즉시 효과가 있습니다.

4.1 failFast로 “한 브랜치 실패 시 나머지 중단”

불필요하게 executor를 오래 점유하는 것을 줄입니다.

stage('Tests') {
  parallel failFast: true,
    'unit': {
      node('linux') { sh 'make test-unit' }
    },
    'integration': {
      node('linux') { sh 'make test-integration' }
    }
}

4.2 병렬 개수 자체를 줄이는 전략

  • 모듈을 20개 병렬 → 4개 배치로 묶기
  • 테스트를 “빠른 그룹/느린 그룹”으로 나눠 병렬 수를 제한

Declarative에서 동적 매트릭스가 필요하면 matrix를 고려하되, 라벨/리소스에 맞춰 축을 설계해야 합니다.

5) 해결책 2: workspace 충돌 제거 (ws, stash/unstash)

병렬에서 같은 workspace를 공유하면, 다음이 터집니다.

  • node_modules/.gradle/.m2/target 동시 접근
  • git 디렉터리 잠금
  • 테스트가 생성하는 임시 파일 충돌

5.1 브랜치별 workspace 분리

pipeline {
  agent none
  stages {
    stage('Parallel build') {
      parallel {
        stage('svc-a') {
          agent { label 'linux' }
          steps {
            ws("${env.WORKSPACE}@svc-a") {
              checkout scm
              sh 'make build-a'
            }
          }
        }
        stage('svc-b') {
          agent { label 'linux' }
          steps {
            ws("${env.WORKSPACE}@svc-b") {
              checkout scm
              sh 'make build-b'
            }
          }
        }
      }
    }
  }
}

5.2 checkout은 1번만, 병렬은 stash로 배포

SCM/credentials/네트워크 병목도 줄고, git lock 문제도 줄어듭니다.

pipeline {
  agent { label 'linux' }
  stages {
    stage('Checkout once') {
      steps {
        checkout scm
        stash name: 'src', includes: '**/*'
      }
    }

    stage('Parallel tests') {
      agent none
      parallel {
        stage('unit') {
          agent { label 'linux' }
          steps {
            ws("${env.WORKSPACE}@unit") {
              unstash 'src'
              sh 'make test-unit'
            }
          }
        }
        stage('lint') {
          agent { label 'linux' }
          steps {
            ws("${env.WORKSPACE}@lint") {
              unstash 'src'
              sh 'make lint'
            }
          }
        }
      }
    }
  }
}

6) 해결책 3: Lockable Resources를 “좁게” 잡고, 타임아웃을 건다

공유 DB, 테스트 계정, 단일 GPU 같은 리소스는 lock이 필요합니다. 하지만 lock 범위가 넓으면 병렬이 무력화되고, lock 해제가 안 되면 장시간 정지로 이어집니다.

6.1 lock 범위를 최소화

stage('E2E') {
  agent { label 'linux' }
  steps {
    // 빌드/준비는 lock 밖에서
    sh 'make build-e2e'

    timeout(time: 20, unit: 'MINUTES') {
      lock(resource: 'shared-test-account') {
        sh 'make run-e2e'
      }
    }
  }
}

6.2 lock 대기 자체를 타임아웃

  • “대기하다가 영원히 멈춤”을 막는 가장 현실적인 안전장치입니다.

7) 해결책 4: input/milestone은 병렬 바깥으로 이동

7.1 input을 병렬 내부에 두지 말 것

병렬 브랜치 중 하나가 승인 대기면, 사용자는 “파이프라인이 멈췄다”고 느낍니다.

권장 구조:

  • 병렬 테스트/빌드 완료
  • 단일 stage에서 input
  • 이후 배포
stages {
  stage('Test') {
    parallel {
      stage('unit') { steps { sh 'make test-unit' } }
      stage('integration') { steps { sh 'make test-integration' } }
    }
  }

  stage('Approve') {
    steps {
      input message: 'Deploy to prod?'
    }
  }

  stage('Deploy') {
    steps { sh 'make deploy-prod' }
  }
}

7.2 milestone은 “경쟁하는 빌드”를 정리할 때만

milestone()은 오래된 빌드를 중단시키는 데 유용하지만, 병렬 브랜치에 섞어 넣으면 의도치 않은 중단/대기가 생길 수 있습니다.

8) 해결책 5: Kubernetes/EKS 환경에서의 병렬 정지 포인트

Kubernetes 기반 Jenkins agent(Pod Template)를 쓰면 병렬 정지의 원인이 Jenkins가 아니라 클러스터일 때가 많습니다.

  • Pod Pending: 리소스 부족, 노드 셀렉터/taint, 이미지 pull
  • PVC mount 실패: 볼륨/권한/AZ mismatch

특히 병렬에서 각 브랜치가 PVC를 붙이거나(캐시, 워크스페이스), 같은 StorageClass를 공유하면 Mount 대기가 전체 시간을 잡아먹습니다. PVC는 Bound인데도 mount가 실패하는 케이스는 EKS PVC Bound인데 Mount 실패 - EBS CSI 권한·AZ·fsType에서 다룬 내용처럼 권한/AZ/fsType 이슈가 대표적입니다.

운영 팁:

  • 병렬 agent는 가능하면 ephemeral workspace(emptyDir) 사용
  • 캐시는 읽기/쓰기 경합을 고려해 브랜치별로 분리하거나, 읽기 전용 캐시만 공유
  • Pod가 안 뜨면 Jenkins 로그보다 kubectl describe pod 이벤트를 먼저 확인

9) “안 멈추게” 만드는 운영 안전장치 3종 세트

9.1 전역 timeout + stage timeout

pipeline {
  options {
    timeout(time: 60, unit: 'MINUTES')
  }
  stages {
    stage('Parallel') {
      options { timeout(time: 20, unit: 'MINUTES') }
      parallel {
        stage('A') { steps { sh 'make a' } }
        stage('B') { steps { sh 'make b' } }
      }
    }
  }
}

9.2 post { always { ... } }로 리소스 정리

  • 테스트 컨테이너/임시 파일/세션/외부 락을 정리
  • 외부 시스템(예: DB 커넥션, 테스트 계정 점유)을 반환
post {
  always {
    sh 'make cleanup || true'
  }
}

9.3 로그에 “어디서 대기 중인지” 남기기

대기/락/에이전트 스케줄 구간 전후로 echo를 넣으면, 다음 장애 때 시간을 크게 줄입니다.

10) 결론: 병렬 데드락은 “리소스 모델링” 문제다

Jenkins Declarative Pipeline에서 병렬 스테이지가 멈추는 문제는 대부분 다음 중 하나로 귀결됩니다.

  • executor/agent가 부족하거나 라벨이 잘못되어 스케줄 자체가 안 됨
  • lock/workspace/PVC 같은 공유 자원을 병렬이 경합
  • input/milestone 같은 제어 흐름이 병렬에 섞여 논리적 교착

해결의 핵심은 “병렬화”가 아니라 리소스 모델링입니다.

  • 병렬 브랜치가 점유하는 executor 시간을 줄이고
  • 공유 자원을 분리하거나 lock 범위를 최소화하고
  • 대기 구간에는 timeout을 걸어 영구 정지를 방지하세요.

이 3가지만 적용해도, 병렬 스테이지 데드락(처럼 보이는 멈춤)의 80%는 재발하지 않게 만들 수 있습니다.