지난주 화요일 오전 10시쯤, 데이터팀 슬랙에 핑이 왔다. "혹시 정산 배치 어제도 안 돈 것 같은데, 뭐 바뀐 거 있어요?" 처음엔 별 생각 없이 로그부터 봤다. 어제, 그저께, 그그저께. 세 번 다 스케줄 시각에 Job이 아예 생성되지 않은 상태였다. Pod가 실패한 게 아니라 Job 자체가 없었다. 이거 뭐지, 하는 마음으로 CronJob describe를 걸었다.
문제 상황
문제의 CronJob은 매일 새벽 3시에 도는 정산 집계 배치였다. schedule은 0 3 * * *, concurrencyPolicy는 Forbid. 원래 잘 돌던 놈이었는데, 일주일쯤 전에 내가 매니페스트를 손댔다. 이유는 이랬다.
기존 설정은 startingDeadlineSeconds가 없었다. 배치가 오래 걸리는 날에 다음 스케줄과 겹쳐서 Forbid 때문에 스킵되는 사고가 한 번 있었고, 그때 "Forbid랑 startingDeadlineSeconds는 세트로 써야 안전하다"는 조언을 들었다. 그래서 이렇게 바꿨다.
spec:
schedule: "0 3 * * *"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 60
jobTemplate:
...
60초로 잡은 이유는 "1분 안에 못 뜨면 뭔가 문제니까 그냥 스킵하자" 정도의 생각이었다. 그런데 이게 화근이었다.
뭐가 문제였나
Kubernetes CronJob 컨트롤러는 10초마다 tick을 돌면서 스케줄된 Job을 만들어야 하는지 판단한다. 이때 startingDeadlineSeconds가 짧으면, 컨트롤러가 뭔가로 바빠서 그 시간 안에 tick을 못 돌리면 아예 그 스케줄은 miss된 걸로 처리한다. Kubernetes 공식 문서에도 명시돼 있다. 10초 이하로 잡으면 스케줄이 아예 트리거 안 될 수도 있다고. 60초도 안전한 값은 아니다.
여기까지는 아는 사람도 꽤 있을 거다. 근데 진짜 함정은 따로 있었다. controller가 새벽에 어떤 이유로든 지연되면 — 예를 들어 API 서버 부하가 잠깐 튀거나, controller-manager pod가 evict되고 재기동되는 순간이 스케줄 시각이랑 겹치면 — 60초 창은 그냥 사라진다. 그리고 컨트롤러는 아무 이벤트도 안 남기고 조용히 다음 tick으로 넘어간다.
여기서 두 번째 문제가 겹쳤다. 하필 그 주에 cluster upgrade를 하나 했다. kube-controller-manager leader election이 잠깐 흔들렸고, 새벽 3시 근처 몇 분간 tick이 밀렸다. 첫날은 이유가 이거였다. 그런데 그 다음날, 그 다다음날은 왜 계속 안 돌았을까? 이게 좀 애매한데, 컨트롤러는 마지막 성공 스케줄 시각을 기억하고 그 다음 스케줄부터 다시 확인한다. 근데 miss된 스케줄이 startingDeadlineSeconds 창을 넘긴 상태로 여러 개 쌓이면, 컨트롤러가 "너무 많이 밀렸다"고 판단해서 아예 스킵을 지속한다. 100개가 넘으면 아예 스케줄링을 멈춘다는 로직도 있고.
확인 과정
문제 파악은 이런 순서로 했다.
kubectl describe cronjob settlement-daily -n batch
이벤트가 텅 비어 있었다. 아니, 정확히는 이런 라인이 있었다.
Warning MissSchedule ... Cannot determine if job needs to be started:
too many missed start time (> 100). Set or decrease .spec.startingDeadlineSeconds
or check clock skew.
이거였다. lastScheduleTime을 보니 정확히 내가 매니페스트를 바꾼 그 시점 근처였고, 그 이후로 값이 갱신되지 않은 상태로 며칠이 지나 있었다. 밀린 스케줄 수를 컨트롤러가 계산해보니 100을 넘어서 "clock skew인가?" 하고 판단을 포기해버린 것.
그러니까 실제 원인 체인은 이랬다. 1. cluster upgrade 중 controller가 잠깐 밀림 → 첫날 스케줄 miss 2. startingDeadlineSeconds가 60초로 너무 짧아서 miss 처리 3. lastScheduleTime이 갱신 안 됨 4. 다음 tick부터 controller가 "이만큼 밀린 스케줄 다 채워야 하나?" 계산하다가 100개 넘어서 포기 5. 그 이후로는 그냥 계속 스킵
해결
일단 급한 불부터 껐다. lastScheduleTime을 강제로 최근 시각으로 밀어서 controller가 정상 판단하도록 만들었다.
kubectl patch cronjob settlement-daily -n batch \
--subresource status \
--type merge \
-p '{"status":{"lastScheduleTime":"2026-08-19T03:00:00Z"}}'
이렇게 하면 controller가 그 시점 이후 miss 카운트를 다시 계산한다. 물론 밀린 3일치 배치는 수동으로 돌려야 한다. 이건 그냥 kubectl create job --from=cronjob/settlement-daily settlement-manual-YYYYMMDD 로 날짜별로 override 해서 돌렸다. 데이터팀에 상황 공유하고, 이번 건 사이드 이펙트 없다는 것 확인받고 마무리.
그리고 진짜 원인이었던 startingDeadlineSeconds는 이렇게 바꿨다.
spec:
schedule: "0 3 * * *"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 900
successfulJobsHistoryLimit: 7
failedJobsHistoryLimit: 7
900초, 즉 15분. 이 정도면 controller 지연이나 API 서버 순간 부하 정도는 충분히 흡수한다. 처음에 60초로 잡았던 게 완전히 잘못된 발상이었다. startingDeadlineSeconds는 "이 시간 안에 못 뜨면 스킵"이 아니라 "이 시간 안에는 뜰 기회를 계속 주겠다"에 가깝다. Forbid랑 세트로 쓸 때 짧게 잡으면, 겹침 방지가 아니라 아예 실행 자체가 사라지는 부작용이 훨씬 크다.
이 삽질에서 배운 것
첫째, CronJob은 조용히 실패한다. Pod가 실패하면 알림이 오지만, Job 자체가 안 만들어지면 어디서도 알림이 안 온다. 그래서 배치성 CronJob에는 별도의 dead-man-switch 알림을 붙여야 한다. 우리는 이번에 Prometheus의 kube_cronjob_status_last_schedule_time 메트릭을 걸어서, 예상 주기 대비 lastScheduleTime이 너무 오래됐으면 알림을 쏘게 했다. 이거 진작 넣었어야 했다.
둘째, startingDeadlineSeconds를 짧게 잡으면 좋다는 조언은 반쯤만 맞다. concurrencyPolicy가 Forbid일 때 이전 Job이 오래 도는 상황을 handle하려는 목적이라면, 짧게 잡는 게 아니라 아예 다른 접근이 필요하다. activeDeadlineSeconds로 이전 Job에 하드 타임아웃을 걸든지, Argo Workflows 같이 좀 더 정교한 스케줄러로 옮기든지. 요즘은 Argo Events + Sensor로 CronJob을 대체하는 팀도 꽤 봤다.
셋째, 매니페스트 바꿀 때 이유를 GitOps commit message에 남기더라도, 그게 실제로 어떻게 동작하는지 문서로 다시 한 번 확인하자. "이렇게 하면 안전하다"는 조언은 대개 특정 맥락을 전제로 하는데, 그 맥락을 놓치면 정반대 결과가 나온다. 이번이 딱 그 케이스였다.
혹시 CronJob 잘못 만졌다가 비슷한 상황 겪은 분 있으면 얘기 좀 들려주세요. 특히 startingDeadlineSeconds 얼마로 잡고 쓰는지 궁금하다. 우리 팀은 이번 사고 이후로 15분을 기본값으로 잡고 있는데, 이게 정답인지는 여전히 모르겠다.
'IT > Kubernets' 카테고리의 다른 글
| HPA 진동 잡느라 이틀 태운 이야기 (0) | 2026.08.26 |
|---|---|
| kubectl events --for, 이거 모르는 분 꽤 많더라 (0) | 2026.08.23 |
| KEDA 도입 가이드 - HPA만으로는 부족했던 순간들 (0) | 2026.08.21 |
| Native sidecar 마이그레이션, 이거 하나만 조심하면 된다 (0) | 2026.08.20 |
| Karpenter vs Cluster Autoscaler, 우리 팀이 옮긴 이유 (0) | 2026.08.20 |