Published on

Jenkins 파이프라인 AbortException 원인·해결

Authors

서로 다른 실패가 모두 AbortException으로 뭉뚱그려 보이면, 원인 파악이 늦어지고 재시도/알림 정책도 꼬입니다. Jenkins Pipeline에서 AbortException은 흔히 “빌드가 중단됨”을 나타내는 예외로, 실제로는 사용자/시스템이 의도적으로 파이프라인을 끊었거나, Pipeline 코드가 명시적으로 중단을 선언했거나, 특정 스텝이 실패를 Abort로 래핑했을 때 발생합니다.

이 글에서는 AbortException이 터지는 대표 패턴을 분류하고, 로그에서 빠르게 갈라내는 방법과, Declarative/Scripted Pipeline에서의 해결책을 코드로 정리합니다.

AbortException을 먼저 “분류”해야 하는 이유

Jenkins에서 실패는 대개 hudson.AbortException, FlowInterruptedException, InterruptedException 계열로 나타납니다. 문제는 UI나 콘솔 로그의 상단 요약이 AbortException: script returned exit code 1처럼 뭉개져서 보일 때가 많다는 점입니다.

실무적으로는 아래 3가지를 먼저 결정해야 합니다.

  1. 이 중단은 의도된 것인가 (예: error() 호출, timeout 만료, input 취소)
  2. 재시도 대상인가 (예: 네트워크/레지스트리 일시 오류는 retry 가치가 큼)
  3. 빌드 결과를 실패로 둘 것인가/중단으로 둘 것인가 (예: 사용자가 취소한 배포는 ABORTED로 남기는 편이 자연스러움)

원인 1) sh/bat가 0이 아닌 종료 코드를 반환

가장 흔한 케이스입니다. sh 스텝은 기본적으로 종료 코드가 0이 아니면 빌드를 실패 처리하면서 AbortException을 던집니다.

전형적인 로그

  • AbortException: script returned exit code 1
  • 또는 script returned exit code 127(명령 없음), 2(사용법 오류) 등

해결 1: 실패를 “값”으로 받아서 분기

종료 코드를 직접 받아서 제어하면, 불필요한 AbortException을 피하고 메시지도 명확히 만들 수 있습니다.

pipeline {
  agent any
  stages {
    stage('Test') {
      steps {
        script {
          int code = sh(script: 'npm test', returnStatus: true)
          if (code != 0) {
            error("테스트 실패: 종료 코드=${code}")
          }
        }
      }
    }
  }
}

해결 2: 표준 출력도 함께 수집해 진단 강화

script {
  def out = sh(script: 'node -v && npm -v', returnStdout: true).trim()
  echo "env info: ${out}"
}

해결 3: Docker 빌드/푸시에서 자주 나는 Abort를 캐시/레이어로 줄이기

빌드 시간이 길어질수록 타임아웃/에이전트 끊김/레지스트리 오류로 AbortException이 연쇄적으로 증가합니다. Docker 빌드가 느려서 간헐적 실패가 잦다면 아래 글의 BuildKit 캐시/레이어 최적화도 같이 점검하는 편이 좋습니다.

원인 2) error() 스텝으로 명시적 중단

Pipeline에서 의도적으로 중단시키는 가장 단순한 방법이 error('message')입니다. 이때도 내부적으로 AbortException이 발생합니다.

stage('Guard') {
  steps {
    script {
      if (!env.BRANCH_NAME?.startsWith('release/')) {
        error('release 브랜치에서만 배포 가능합니다.')
      }
    }
  }
}

해결 포인트

  • “실패”가 맞다면 error()가 가장 명확합니다.
  • 다만 사용자 취소/정책상 중단을 실패로 남기고 싶지 않다면 currentBuild.resultABORTED로 바꾸는 패턴을 고려합니다.
script {
  currentBuild.result = 'ABORTED'
  error('정책상 배포를 중단합니다(ABORTED 처리).')
}

주의: error() 자체는 실패로 던지므로, 결과를 ABORTED로 남기려면 후속 처리(예: post 블록)와 함께 의도대로 기록되는지 확인하세요.

원인 3) input 단계에서 사용자가 취소하거나 타임아웃

승인 게이트로 input을 쓰는 경우, 사용자가 “Abort”를 누르거나 승인 대기 중 타임아웃이 걸리면 중단 예외가 발생합니다.

전형적인 증상

  • 승인 대기 중 파이프라인이 끊기고 AbortException 또는 FlowInterruptedException 형태로 종료

해결: timeout과 예외 처리로 “정상적인 취소”를 구분

아래는 승인 대기 10분 후 자동 중단, 또는 사용자가 취소한 경우를 ABORTED로 분류하는 예시입니다.

stage('Approve') {
  steps {
    script {
      try {
        timeout(time: 10, unit: 'MINUTES') {
          input message: '배포를 진행할까요?', ok: 'Deploy'
        }
      } catch (e) {
        currentBuild.result = 'ABORTED'
        echo "승인 단계에서 중단됨: ${e}"
        // 이후 스테이지를 실행하지 않도록 명시 중단
        error('사용자 취소 또는 승인 타임아웃')
      }
    }
  }
}

핵심은 “실패”와 “취소”를 구분해 알림/지표를 다르게 가져가는 것입니다.

원인 4) timeout 스텝 만료로 강제 중단

timeout은 내부적으로 실행 중인 스텝을 인터럽트하고 파이프라인을 중단시킵니다. 이때도 AbortException처럼 보일 수 있습니다.

options {
  timeout(time: 30, unit: 'MINUTES')
}

해결 체크리스트

  • 타임아웃이 너무 짧지 않은지(특히 Docker build, 대형 테스트)
  • 에이전트 성능 저하로 처리 시간이 늘어나지 않았는지
  • 병렬 스테이지에서 특정 가지가 느려 전체가 끌려가지 않는지

병렬 처리에서 특정 작업이 장시간 걸려 전체가 묶이는 경우, 스테이지별 timeout을 걸어 “어디서” 시간이 소모되는지 분리하는 게 좋습니다.

stage('Parallel') {
  parallel {
    stage('unit') {
      options { timeout(time: 10, unit: 'MINUTES') }
      steps { sh 'npm test' }
    }
    stage('e2e') {
      options { timeout(time: 20, unit: 'MINUTES') }
      steps { sh 'npm run e2e' }
    }
  }
}

원인 5) catchError, warnError 사용 방식으로 Abort가 “가려짐”

catchError는 실패를 잡아 빌드/스테이지 결과를 조정할 수 있지만, 잘못 쓰면 진짜 원인(예: 특정 커맨드 exit code)이 숨겨지고 마지막에 AbortException만 남을 수 있습니다.

권장 패턴: 실패는 잡되 로그를 남기고 결과를 명확히

stage('Lint') {
  steps {
    catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
      sh 'npm run lint'
    }
  }
}
  • UNSTABLEFAILURE를 구분해두면 “배포는 막되, 전체 파이프라인은 계속 진행” 같은 정책을 구현하기 쉽습니다.

원인 6) 에이전트(노드) 끊김, 컨테이너 종료, OOM 등 인프라 이슈

Jenkins 에이전트가 중간에 죽거나 연결이 끊기면, 실행 중이던 스텝이 중단되며 예외가 AbortException 계열로 귀결될 수 있습니다.

흔한 시나리오

  • Kubernetes 에이전트 Pod가 OOMKilled
  • Spot 인스턴스 종료
  • Docker-in-Docker 컨테이너가 종료
  • 네트워크 단절로 에이전트가 오프라인

해결 포인트

  • Jenkins 콘솔 로그만 보지 말고, 에이전트 런타임 로그(K8s 이벤트, 노드 로그)를 함께 확인
  • 빌드 머신 자원(CPU/메모리/디스크) 모니터링 및 제한값 조정

Kubernetes 환경에서 Pod가 재시작되거나 프로브 실패로 내려가는 경우는 CI에서도 동일하게 발생합니다. 파이프라인이 뜬금없이 끊긴다면 아래 가이드의 접근(이벤트/로그/Probe 순서)이 그대로 도움이 됩니다.

원인 7) 외부 API 레이트리밋/쿼터로 실패 후 Abort

배포/테스트 과정에서 외부 API(예: LLM API, 패키지 레지스트리, 사내 게이트웨이)를 호출하다가 429나 일시 장애가 나면, 커맨드가 비정상 종료하며 AbortException으로 이어집니다.

해결: 재시도(backoff)와 “재시도 가능한 실패” 분리

  • retry(n)는 단순하지만, 원인별 backoff가 필요하면 스크립트로 제어합니다.
stage('Call API') {
  steps {
    script {
      int max = 5
      for (int i = 1; i <= max; i++) {
        int code = sh(script: 'curl -sS -o /tmp/resp.json -w "%{http_code}" https://api.example.com', returnStatus: true)
        if (code == 0) {
          echo 'API call ok'
          break
        }
        if (i == max) {
          error("API 호출 실패(재시도 초과). exit=${code}")
        }
        sleep time: (i * 3), unit: 'SECONDS'
      }
    }
  }
}

외부 API의 429 대응은 CI에서 특히 중요합니다. 레이트리밋을 만나면 “코드 문제”가 아닌데도 빌드가 빨갛게 쌓이기 때문입니다. 아래 글의 쿼터/레이트리밋 대응 전략을 참고해, Jenkins 단계에도 동일한 재시도/지수 백오프/서킷 브레이커 개념을 적용하세요.

로그에서 원인을 빨리 찾는 실전 팁

1) “첫 번째 실패 지점”을 찾고, 마지막 Abort 메시지는 버린다

AbortException은 종종 최종 요약에 가깝습니다. 콘솔에서 다음을 우선 찾으세요.

  • +로 시작하는 실제 실행 커맨드(셸 trace)
  • 실패 직전의 stderr 출력
  • ERROR: 라인
  • Caused by: 체인

2) 병렬 실행이면 가장 먼저 실패한 브랜치부터 본다

병렬 스테이지에서는 한 브랜치의 실패가 전체 중단으로 보입니다. 각 브랜치 로그를 펼쳐서 “첫 실패”를 찾는 게 중요합니다.

3) post { always { ... } }에 진단 정보 수집을 넣는다

실패 시점에만 필요한 정보를 자동으로 모으도록 해두면, 다음 번부터 AbortException이 떠도 조사 시간이 크게 줄어듭니다.

post {
  always {
    echo "result=${currentBuild.currentResult}"
    sh(script: 'df -h', returnStatus: true)
    sh(script: 'free -m', returnStatus: true)
  }
}

재발 방지용 파이프라인 템플릿(요약)

아래는 AbortException을 “원인별로 구분”하고, 재시도/타임아웃/취소 처리를 기본 탑재한 뼈대입니다.

pipeline {
  agent any

  options {
    timestamps()
    ansiColor('xterm')
    timeout(time: 40, unit: 'MINUTES')
  }

  stages {
    stage('Checkout') {
      steps {
        checkout scm
      }
    }

    stage('Build') {
      steps {
        script {
          retry(2) {
            int code = sh(script: 'npm ci && npm run build', returnStatus: true)
            if (code != 0) {
              error("빌드 실패: exit=${code}")
            }
          }
        }
      }
    }

    stage('Approve') {
      when { branch 'main' }
      steps {
        script {
          try {
            timeout(time: 5, unit: 'MINUTES') {
              input message: '프로덕션 배포 승인', ok: 'Deploy'
            }
          } catch (e) {
            currentBuild.result = 'ABORTED'
            error('승인 취소 또는 타임아웃')
          }
        }
      }
    }
  }

  post {
    always {
      echo "final=${currentBuild.currentResult}"
    }
    failure {
      echo '실패: 원인 스텝의 stderr/exit code를 확인하세요.'
    }
    aborted {
      echo '중단: 사용자 취소/타임아웃/정책 중단 가능성이 큽니다.'
    }
  }
}

마무리: AbortException은 “에러 메시지”가 아니라 “결과”다

AbortException 자체는 대개 결론에 가깝고, 진짜 원인은 그 이전의 스텝 실패(종료 코드), 승인 취소, 타임아웃, 에이전트 종료 같은 이벤트입니다.

  • sh의 종료 코드를 returnStatus로 받아 원인을 명확히 만들고
  • input/timeoutABORTED로 분류해 알림을 분리하고
  • 인프라 끊김은 K8s/노드 이벤트까지 확장해 조사하며
  • 외부 API는 재시도와 backoff로 “일시 장애”를 흡수

이 네 가지를 기본값으로 깔아두면, AbortException 때문에 CI가 불안정해 보이는 문제를 상당 부분 줄일 수 있습니다.