본문으로 건너뛰기
4.6 Job과 CronJob

4.6 Job과 CronJob

이 Part의 controller들은 지금까지 전부 “계속 실행 중"을 지향했다. 배치 작업은 반대로 끝나는 것이 목표다. Deployment로 배치 작업을 실행하면 성공 종료조차 장애로 간주되어 재시작이 반복된다. “성공적으로 끝났다"를 셀 줄 아는 controller가 Job이고, 그것을 정해진 시각에 실행하는 것이 CronJob이다.

완료를 보장하는 실행

Job은 Pod를 만들고, 지정한 수의 Pod가 성공(exit 0)으로 끝날 때까지 실패한 Pod를 대체한다. 노드 장애로 Pod가 사라져도 다른 노드에서 다시 시도하므로 보장의 단위는 “한 번 실행"이 아니라 “완료까지 실행"이다. Pod template의 restartPolicyNever 또는 OnFailure만 허용된다. Always는 완료라는 개념과 모순이라 거부된다.

apiVersion: batch/v1
kind: Job
metadata:
  name: pi
spec:
  backoffLimit: 3
  ttlSecondsAfterFinished: 3600
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: pi
          image: perl:5.34
          command: ["perl", "-Mbignum=bpi", "-wle", "print bpi(2000)"]

실행의 모양은 completionsparallelism 조합으로 정한다.

패턴completionsparallelism동작
단일 실행1 (기본)1 (기본)Pod 하나가 성공하면 완료
고정 횟수 병렬NM성공 합계 N까지 최대 M개 동시 실행
작업 큐미지정M하나가 성공하면 신규 생성 중지, 전원 종료 시 완료

작업 큐 패턴에서는 Pod들이 외부 큐를 나눠 소비하다가 큐가 비면 성공으로 종료하는 식의 협조가 애플리케이션 쪽에 필요하다. 고정 횟수 패턴에서 Pod마다 서로 다른 몫을 주려면 completionMode: Indexed를 쓴다. 각 Pod가 0부터 N-1의 index를 JOB_COMPLETION_INDEX 환경 변수로 받아 “분할 파일 i번을 처리하라” 같은 정적 분배가 된다.

재시도와 뒷정리

실패 시 재시도 한도가 backoffLimit(기본 6)이고, 재시도 사이에는 10초에서 두 배씩 늘어나는 지연(최대 6분)이 붙는다. 한도를 넘으면 Job은 Failed로 끝난다. restartPolicy에 따라 실패의 현장이 달라진다. Never는 실패한 Pod를 그대로 두고 새 Pod를 만들므로 실패 Pod의 로그가 조사할 수 있는 상태로 남고, OnFailure는 같은 Pod 안에서 컨테이너만 재시작하므로 깔끔한 대신 이전 시도의 로그가 사라진다.

실패라고 다 같은 실패가 아니라는 문제도 있다. 노드 drain이나 preemption으로 축출된 Pod와 코드 버그로 종료된 Pod가 똑같이 backoffLimit를 소모하면, 인프라 사정 때문에 배치가 Failed로 끝나는 억울한 경우가 생긴다. 1.31에서 GA된 podFailurePolicy가 이를 구분한다. DisruptionTarget condition이 붙은 실패는 한도 계산에서 제외(Ignore)하고, 재시도가 무의미한 exit code는 즉시 Job 실패(FailJob)로 처리하는 규칙을 적는 자리다.

끝난 Job은 저절로 사라지지 않는다. 완료된 Job과 그 Pod가 계속 쌓이면 API 서버와 etcd의 부담이 되므로, 위 예시처럼 ttlSecondsAfterFinished를 지정해 완료 후 일정 시간이 지나면 TTL controller가 삭제하게 한다.

CronJob: 시간표와 놓친 스케줄

CronJob은 스케줄 시각마다 Job 객체를 만드는 controller다. 실행의 실체는 앞에서 본 Job 그대로이고, CronJob이 더하는 것은 시간 축이다.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-report
spec:
  schedule: "0 3 * * *"
  timeZone: "Asia/Seoul"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 600
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: report
              image: busybox:1.36
              command: ["sh", "-c", "date; echo report done"]

schedule은 분, 시, 일, 월, 요일의 5필드 cron 문법이다. timeZone(1.27 GA)을 지정하지 않으면 kube-controller-manager가 실행되는 머신의 시간대가 기준이 되는데, 그것이 무엇인지는 클러스터마다 다르므로 명시하는 편이 안전하다. “매일 03시"가 사실은 UTC 03시였다는 것을 리포트가 9시간 밀린 뒤에 아는 상황은 피할 만하다.

이전 회차가 아직 실행 중일 때 다음 스케줄 시각이 오면 concurrencyPolicy가 개입한다. Allow(기본)는 겹쳐서 실행하고, Forbid는 이번 회차를 건너뛰고, Replace는 실행 중이던 Job을 종료시키고 새로 시작한다. 같은 테이블을 다루는 배치가 겹치면 곤란한 데이터 작업에는 대개 Forbid가 맞다.

controller가 잠시 내려가 있었거나 Forbid로 건너뛰는 등의 이유로 스케줄을 놓쳤을 때의 허용 폭이 startingDeadlineSeconds다. 지정하면 예정 시각부터 그 시간 안쪽까지는 늦게라도 시작하고, 지나면 그 회차는 놓친 것으로 처리한다. 지정하지 않은 상태에서 놓친 스케줄이 100회를 넘으면 controller는 에러만 기록하고 Job을 더 만들지 않으므로, 매분 실행 같은 촘촘한 스케줄일수록 이 값을 지정해 두는 것이 장애 후 복구를 단순하게 만든다.

공식 문서는 CronJob이 스케줄당 Job을 “약 한 번(about once)” 만든다고 표현한다. 드물게 한 회차에 Job이 두 개 만들어지거나 하나도 만들어지지 않는 경우가 있다는 뜻이므로, 배치 작업 자체를 멱등하게, 즉 두 번 실행되어도 결과가 같도록 설계해 두어야 한다.

정리

  • Job의 보장 단위는 완료다. completions/parallelism으로 실행 모양을, backoffLimit와 podFailurePolicy로 실패 처리를, ttlSecondsAfterFinished로 뒷정리를 정한다.
  • CronJob은 스케줄마다 Job을 만든다. timeZone은 명시하고, 회차 겹침은 concurrencyPolicy로 통제한다.
  • 스케줄은 정확히 한 번을 보장하지 않는다. 멱등한 작업만 스케줄에 등록한다.