# 재시도 Cron은 자기 죽음을 알릴 수 없다: Watch the Watcher 설계

> 기록된 job ID와 live scheduler 상태를 대조하고, 작업의 존재·실행·성공·업무 진척을 분리해 사라진 재시도 작업을 찾는 최소 운영 계약을 정리합니다.

- Canonical: https://blog.jaemyeong.com/posts/watch-the-watcher-retry-cron-reconciliation/
- Published: 2026.08.06
- Category: 기술
- Tags: #CI-CD, #Automation, #Observability, #Reliability, #Cron

실패한 재시도 작업은 로그를 남길 수 있다. 예외 횟수와 마지막 실패 시각도 기록할 수 있다. 그러나 재시도 작업 자체가 scheduler에서 사라지면 아무것도 실행되지 않는다. 실행되지 않은 작업은 자신의 죽음을 보고할 수도 없다.

DailySudoku 자동화 루프를 점검하다가 이 차이를 마주쳤다. 운영 원장에는 재시도 job이 등록됐다고 적혀 있었지만, live scheduler 목록에는 다른 gate poller만 남아 있었다. 살아남은 poller는 자기 책임을 계속 수행했기 때문에 전체 루프가 멈추지 않은 것처럼 보였다. 정확히 언제 왜 job이 사라졌는지는 당시 증거만으로 확정할 수 없었다.

이것은 현재 장애 보고가 아니라 과거 스냅샷에서 얻은 설계 사례다. 이 글에서는 특정 scheduler나 내부 job ID 대신, scheduled job이 사라지는 공통 실패 모드와 이를 찾는 최소한의 Watch the Watcher 계약을 정리한다. 제품 동작은 2026년 8월 6일 공식 문서 기준이다.

## 실패한 작업과 사라진 작업은 다르다

작업 실패와 작업 부재는 서로 다른 상태다.

| 상태 | 남는 신호 | 작업 내부의 재시도로 복구 가능한가 |
| --- | --- | --- |
| 실행 후 실패 | 시작 기록, 오류, 실패 횟수 | 가능 |
| 실행 중 정지 | 시작 기록, 오래된 heartbeat | 경우에 따라 가능 |
| 비활성화 또는 suspend | scheduler 객체 | 불가능 |
| scheduler 객체 삭제 | 내부 신호 없음 | 불가능 |
| scheduler 전체 중단 | 내부 신호 없음 | 불가능 |

성공률과 오류율은 작업이 실행됐다는 전제에서만 의미가 있다. “오류가 0건”은 건강하다는 증거가 아니라 실행이 0건이라는 결과일 수도 있다. 그래서 재시도 성공률보다 먼저 작업의 존재와 최근 실행을 확인해야 한다.

이 글에서 `Cron`은 Unix `crontab`에만 한정하지 않는다. GitHub Actions의 `schedule`, Kubernetes CronJob처럼 정해진 시각에 일을 시작하는 제어 평면을 통칭한다.

## 원장에 기록된 job ID는 live 상태가 아니다

job ID를 파일이나 데이터베이스에 저장해 두면 나중에 작업을 찾는 데 쓸 수 있다. 하지만 그 값은 “등록하려고 했던 작업”의 포인터이지, scheduler가 지금도 그 작업을 보유한다는 증명이 아니다.

두 상태를 분리해야 한다.

- desired state: 어떤 역할의 작업이 어떤 주기와 명령으로 존재해야 하는가
- actual state: scheduler가 현재 반환한 작업 목록과 활성 상태는 무엇인가

점검은 저장된 ID 하나를 조회하는 데서 끝내지 않고, 역할을 나타내는 안정적인 키로 두 집합을 비교한다.

```text
expected = desired_jobs_by_role
actual = scheduler.list()

missing    = expected.roles - actual.roles
duplicates = actual.group_by(role).where(count > 1)
mismatched = actual.where(spec_version != expected.spec_version)
```

일회성 ID는 재생성 때 바뀔 수 있다. 비교 키에는 `retry`, `gate`처럼 작업의 역할을 쓰고, schedule과 실행 템플릿에는 버전을 붙이는 편이 낫다. 그래야 “같은 이름의 오래된 작업”도 존재하는 척 통과하지 않는다.

## 감시자를 피감시자 안에 두면 한 축이 빈다

문제가 된 구조에서는 재시도 job이 다른 poller를 점검하고 필요하면 복구했다. 반대편 poller는 자기 업무만 확인했다. 따라서 재시도 job이 먼저 사라지면 이를 되살릴 주체도 함께 사라졌다.

두 job이 서로를 감시하게 만들면 한쪽 삭제는 찾을 수 있다. 하지만 둘이 같은 scheduler에 있다면 scheduler 전체 중단처럼 두 실행 경로를 함께 막는 공통 장애에는 동시에 침묵할 수 있다. 상호 감시는 실패 도메인을 분리하지 않는다.

필요한 독립성은 요구 수준에 따라 두 단계로 나뉜다.

1. 운영자가 세션을 재개하거나 scheduler 설정을 바꿀 때 live 목록을 다시 대조한다. 가장 작은 보완책이지만 점검 사이의 공백은 남는다.
2. 24시간 감지가 필요하면 scheduler 밖의 dead-man monitor가 마지막 성공 시각을 확인한다. 내부 scheduler가 완전히 멈춰도 외부 신호는 남는다.

Google SRE는 내부 지표를 보는 white-box monitoring과 사용자 관점에서 외부 동작을 확인하는 black-box monitoring을 구분한다. scheduler 목록, 실행 기록, job이 내보낸 heartbeat는 white-box 신호다. 별도 시스템이 최종 결과를 사용자 관점에서 직접 확인하면 black-box 신호가 된다.

외부 monitor도 완전히 무결하지는 않다. 그 monitor와 알림 전달 경로의 상태까지 별도로 확인해야 한다. 핵심은 감시 계층을 무한히 늘리는 것이 아니라, 가장 치명적인 공통 실패 원인 하나를 끊는 것이다.

## 존재·실행·성공·업무 진척을 따로 본다

scheduled job의 건강 상태를 한 Boolean으로 표현하면 원인을 찾기 어렵다. 기대 상태 하나와 관측 단계 네 개로 나누면 된다.

| 단계 | 확인할 질문 | 대표 증거 |
| --- | --- | --- |
| desired | 이 역할의 작업이 존재해야 하는가 | 버전 관리된 작업 명세 |
| registered | scheduler에 활성 객체가 있는가 | live list, enabled 또는 suspend 상태 |
| executed | 예정된 구간에 시작했는가 | last started, run history |
| succeeded | 허용 시간 안에 성공했는가 | last success timestamp |
| progressed | 작업이 맡은 업무가 실제로 전진했는가 | checkpoint, queue depth, 처리 완료 시각 |

등록과 실행은 같지 않다. Kubernetes CronJob은 객체가 존재해도 `.spec.suspend: true`이면 새 Job을 시작하지 않는다. 실행과 성공도 같지 않다. 프로세스가 시작된 뒤 멈추거나 오류로 끝날 수 있다. 성공과 업무 진척 역시 다르다. 빈 입력을 정상 처리한 것인지, 잘못된 조건 때문에 계속 아무 일도 하지 않는지는 도메인 checkpoint를 봐야 구분된다.

모든 작업에 다섯 지표를 새로 만들 필요는 없다. 먼저 live list와 last success 두 신호를 확보하고, “성공했지만 아무 일도 안 하는” 실패가 실제로 문제가 될 때 업무 진척 지표를 추가하면 된다.

## 가장 작은 복구 루프는 live reconciliation이다

복구는 등록 API를 무조건 다시 호출하는 것이 아니다. 먼저 actual state를 읽고 차이를 분류한다.

```text
observe → classify → re-read → repair → verify
```

1. scheduler의 live 목록과 활성 상태를 읽는다.
2. 누락, 중복, 비활성화, spec drift를 구분한다.
3. 일시적 지연이나 경쟁 조건이 아닌지 한 번 더 읽는다.
4. 누락된 작업만 버전 관리된 literal template에서 재생성한다.
5. 다시 목록을 읽고 역할·schedule·spec version을 확인한 뒤 원장의 ID를 갱신한다.

알 수 없는 작업을 자동 삭제하는 기능은 이 복구에 필요하지 않다. 소유권을 증명하지 못한 객체를 지우는 것보다, 기대한 작업을 정확히 한 개 확보하고 중복을 경보로 남기는 편이 안전하다.

reconciliation을 실행할 시점도 작게 시작할 수 있다. 작업을 소유한 프로세스의 시작·재개 직후와 scheduler 설정 변경 직후면 된다. 이것으로 감지 지연을 허용할 수 없다면 그때 외부 주기 점검을 추가한다.

## heartbeat는 존재가 아니라 최신성을 증명해야 한다

batch job에는 `healthy = true`보다 마지막 성공 시각이 유용하다. Prometheus도 batch job의 핵심 지표로 마지막 성공 시각을 권장하며, 경과 시간이 아니라 Unix timestamp를 내보내도록 안내한다.

```text
time() - job_last_success_timestamp_seconds{job="retry"} > stale_after_seconds
```

`stale_after_seconds`는 모든 시스템에 같은 상수가 아니다. 이 글에서는 `2 × 실행 주기 + 최대 예상 실행 시간`을 초기값으로 삼고, 허용 가능한 감지 지연과 실제 실행 분산에 맞춰 정한다. 한 번의 지연을 곧바로 장애로 만들기보다 최소 두 번의 기대 실행 구간을 관찰하는 이유다.

시계열 자체가 만들어지지 않는 경우에는 `absent()`나 `absent_over_time()`을 별도 경보에 사용할 수 있다. 다만 “시계열이 존재한다”와 “최근 값이다”도 구분해야 한다. Prometheus Pushgateway는 push된 시계열을 자동 만료하지 않으므로, 오래전에 성공한 값이 계속 존재할 수 있다. 부재 검사는 missing series를, timestamp 비교는 stale success를 찾는다.

다음 세 신호를 한 경보로 섞지 않으면 대응도 명확해진다.

| 신호 | 의미 | 우선 확인할 곳 |
| --- | --- | --- |
| job object 없음 | 등록 또는 reconciliation 실패 | scheduler live list |
| success metric 없음 | 계측·수집·첫 실행 중 하나가 없음 | scrape 또는 push 경로 |
| success timestamp 오래됨 | 실행 지연·실패·정지 가능성 | run history와 작업 로그 |

## scheduler는 정확히 한 번을 보장하지 않을 수 있다

watcher가 작업의 존재를 확인해도 모든 예정 실행을 보장하는 것은 아니다. scheduler별 계약을 따로 읽어야 한다.

Kubernetes는 CronJob이 예정 시각마다 대략 한 번 Job을 만들지만, 두 개가 만들어지거나 하나도 만들어지지 않는 경우를 완전히 막을 수 없다고 문서화한다. 그래서 Job은 멱등적으로 만들어야 한다. `startingDeadlineSeconds`와 `concurrencyPolicy`도 지연 실행과 겹침을 제어하지만 exactly-once 보장은 아니다.

GitHub Actions도 `schedule` 이벤트가 부하에 따라 지연될 수 있고, 충분히 높은 부하에서는 대기 중인 작업이 드롭될 수 있다고 밝힌다. 예약 workflow는 기본 브랜치에 파일이 있어야 하며 그 브랜치에서만 실행된다. 따라서 workflow 파일 존재만 확인하거나 정각 실행만 가정해서는 안 된다.

이 예시는 모든 scheduler가 같은 방식으로 실패한다는 뜻이 아니다. 공통 원칙은 live 객체, run history, 성공 freshness를 나눠 확인하는 것이다. Kubernetes처럼 중복 가능성이 문서화된 scheduler에서는 작업도 중복 실행을 견디게 설계한다.

## 상태표가 경보보다 먼저다

경보 문구를 만들기 전에 관측 결과와 복구 행동을 표로 고정하면 자동 복구의 경계가 선명해진다.

| 관측 결과 | 분류 | 안전한 첫 행동 |
| --- | --- | --- |
| expected에는 있고 actual에는 없음 | missing | 한 번 재조회 후 template에서 생성 |
| 객체는 있으나 disabled 또는 suspended | inactive | 변경 이력 확인 후 명시적으로 활성화 |
| 같은 역할이 둘 이상 | duplicate | 겹침 위험을 경보하고 소유권을 수동 확인 |
| schedule 또는 spec version 불일치 | drift | 기대 버전과 변경 주체 확인 |
| 최근 시작 없음 | scheduler 또는 trigger 문제 | run history와 scheduler 상태 확인 |
| 최근 시작은 있으나 성공 없음 | job failure 또는 hang | 실행 로그와 timeout 확인 |
| 성공은 최신이나 checkpoint 정지 | 업무 판정 오류 | 입력과 도메인 조건 확인 |
| watcher heartbeat도 없음 | monitoring failure | 외부 probe와 알림 경로 확인 |

이 표에서 자동 수리는 `missing`처럼 소유권과 기대 명세가 분명한 경우에만 좁게 적용한다. 비활성화와 drift는 의도적인 운영 변경일 수 있으므로 이력을 확인하지 않고 덮어쓰지 않는다.

## 경계 사례로 watcher 계약을 검증한다

실제 장애를 기다리지 않고 제어 평면의 경계만 확인해도 맹점을 찾을 수 있다.

| 상황 | 기대 결과 |
| --- | --- |
| 원장에는 ID가 있지만 live 객체 없음 | missing 경보, 저장된 ID만으로 정상 판정하지 않음 |
| live 객체가 suspended | registered와 executed를 분리해 inactive 판정 |
| 같은 역할의 객체 두 개 | duplicate 경보, 둘 다 실행하지 않도록 보호 |
| schedule은 같지만 spec version이 오래됨 | drift 판정 |
| 한 주기 지연 뒤 정상 성공 | 설정한 grace window 안에서는 경보 보류 |
| 실행 시작 뒤 timeout 초과 | stale execution 또는 hang 판정 |
| Pushgateway에 오래된 성공 값만 남음 | metric 존재가 아니라 timestamp로 stale 판정 |
| 재시도 job만 삭제되고 gate poller는 생존 | 전체 루프 정상으로 오인하지 않음 |
| 같은 scheduler의 두 watcher가 함께 중단 | 외부 heartbeat에서 감지 |
| 누락 job 재생성 직후 목록 반영 지연 | 재조회 후 검증하고 중복 생성을 피함 |

여기서 단위 테스트로 검증할 대상은 expected/actual 비교와 상태 분류다. scheduler가 실제로 지연·중복·누락되는지는 staging 또는 의도적으로 격리한 운영 점검에서 확인해야 한다.

## 정리

재시도 작업은 자신의 실패는 보고할 수 있어도 자신의 부재는 보고할 수 없다. 저장된 job ID를 live 상태로 믿지 말고, 버전 관리된 desired state와 scheduler의 actual state를 역할 키로 대조해야 한다.

가장 작은 보완책은 소유 프로세스가 시작하거나 설정이 바뀔 때 live reconciliation을 실행하는 것이다. 24시간 감지가 필요할 때만 scheduler 밖의 heartbeat를 추가한다. 그리고 작업의 건강을 등록·실행·성공·업무 진척으로 나누면, “오류가 없어서 정상”인 침묵과 “아무것도 실행되지 않아 조용한” 침묵을 구별할 수 있다.

## 출처

- [Kubernetes — CronJob](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/)
- [Prometheus — Instrumentation](https://prometheus.io/docs/practices/instrumentation/)
- [Prometheus — Query functions](https://prometheus.io/docs/prometheus/latest/querying/functions/)
- [Prometheus — Alerting](https://prometheus.io/docs/practices/alerting/)
- [Prometheus — When to use the Pushgateway](https://prometheus.io/docs/practices/pushing/)
- [GitHub Docs — Events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule)
- [Google SRE — Monitoring Distributed Systems](https://sre.google/sre-book/monitoring-distributed-systems/)
