감시자를 누가 감시하나: 사라진 예약 작업 찾기
앱의 자동화를 점검하다가 기록과 실제가 어긋난 것을 본 적이 있어요. 작업 원장에는 retry 작업을 등록했다는 기록이 있었는데, scheduler를 조회하니 gate poller 하나만 나왔어요. gate가 맡은 일은 계속 처리되고 있었기 때문에 retry가 빠진 것이 드러나지 않았어요. 언제 왜 지워졌는지를 특정할 근거는 부족했어요.
지워진 예약 작업은 스스로 실패를 알리지 못해요. 그런 누락을 찾기 위한 상태 대조와, 감시가 감시 대상과 함께 멈추지 않게 하는 구성을 정리해요. 여기서 cron은 Unix crontab만이 아니고 GitHub Actions의 schedule과 Kubernetes의 CronJob도 포함해요. 조회 출력이나 로그의 원본, 복구한 뒤의 실측은 싣지 않았어요. 그때 쓴 scheduler와 도구의 버전, 실행 환경도 기록이 남아 있지 않아요.
오류가 0건인 것과 실행이 0건인 것
실패의 모양에 따라 남는 흔적이 달라요.
- 실행 중에 오류가 나면 시작 기록, 오류, 실패 횟수가 남고 내부에서 다시 시도할 수 있어요.
- 실행 도중에 멈추면 시작 기록과 낡은 heartbeat로 알 수 있어요. 내부에서 복구할 수 있는지는 조건에 따라 달라요.
- 비활성으로 바뀐 객체는 scheduler에 남아 있지만 내부에서 복구할 수 없어요.
- 객체가 지워졌거나 scheduler가 멈추면 내부에서 올라오는 보고 자체가 없고, 내부 복구도 안 돼요.
그래서 오류가 0건이라는 것만으로는 정상이라고 판단할 수 없어요. 실행이 0건일 가능성부터 배제해야 해요.
원장에는 역할을, 조회 결과에는 실제 객체를
원장에 적힌 ID는 조회할 때 쓰는 참조일 뿐이고, 지금 등록돼 있다는 증거는 아니에요. 기대 상태(desired)에는 역할, 주기, 명령을 두고, 실제 상태(actual)에는 조회된 객체와 활성 여부를 둬요. 객체를 다시 만들면 ID가 바뀔 수 있으므로 retry, gate 같은 역할 키로 대조해요. schedule과 실행 템플릿에 버전을 붙여 두면 오래된 설정도 찾을 수 있어요. 역할 집합을 비교하는 의사코드는 이래요.
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)
비교의 뜻을 적은 것이고, 실행한 결과는 이 글에 없어요. 이 의사코드만으로는 놓치는 경우가 둘 있어요. 첫째, 버전만 비교하면 누군가 버전을 올리지 않고 schedule이나 명령을 고쳤을 때 잡지 못해요. 실제 schedule과 명령, 또는 기대 명세 전체의 해시를 역할마다 비교해야 해요. 둘째, 기대 목록에 없는 역할의 객체는 missing도 duplicate도 아니어서 그대로 남아요. actual.roles - expected.roles도 따로 분류하고, 자동으로 지우지 않고 소유권을 조사하도록 경보를 내요.
기대 상태 하나에 관측 단계 넷을 맞춰 봐요.
- desired: 버전이 붙은 명세에 적힌 기대 역할
- registered: 실제 객체가 있는지와 enabled 또는 suspend 상태
- executed: 마지막 시작 시각과 실행 이력
- succeeded: 제한 시간 안에 있는 마지막 성공
- progressed: checkpoint, queue의 깊이, 완료 시각
단계마다 서로 다른 것을 알려 줘요. Kubernetes의 CronJob 문서에 따르면 .spec.suspend가 true이면 객체가 있어도 그 뒤의 실행이 멈춰요. 시작한 뒤에 멈추거나 오류가 날 수도 있어요. 성공 시각이 최신이어도 입력이 비어 있었는지, 조건이 잘못됐는지는 checkpoint를 봐야 가려져요. 처음에는 실제 객체 목록과 마지막 성공 시각부터 확보하고, 필요해지면 업무 지표로 넓히기를 제안해요.
같은 scheduler 안의 감시가 보지 못하는 것
처음에 본 사례의 구조에는 결함이 있었어요. retry가 다른 poller를 복구하는 한 방향의 관계였고, 상대 poller는 자기 업무만 처리했어요. retry가 지워지면 복구를 맡을 쪽이 없어요. 서로 점검하게 해도, 같은 scheduler에서 함께 생기는 장애에는 소용이 없어요.
세션을 다시 시작할 때나 설정을 바꿀 때 대조하면 기본적인 보완책은 돼요. 다만 점검과 점검 사이에는 공백이 생겨요. 늘 감지해야 한다면 바깥의 dead-man monitor가 마지막 성공 시각을 확인하게 해요. 바깥의 monitor와 알림 전달도 점검 대상이에요. 목적은 감시 작업과 감시 대상이 같은 scheduler의 중단으로 함께 멈추지 않도록, 둘이 실행되는 기반을 나누는 것이에요.
Google SRE 책의 감시 장은 시스템 내부가 내놓는 지표에 기대는 감시를 white-box, 사용자가 보는 것처럼 바깥에서 동작을 시험하는 감시를 black-box라고 불러요. 이 구분으로 보면 내부 목록, 실행 이력, heartbeat는 white-box예요. 별도의 시스템이 사용자 관점의 최종 결과를 직접 검사해야 black-box이고, 감시를 바깥에 설치했다는 것만으로 black-box가 되지는 않는다는 것이 제 해석이에요.
대조하고 고치는 순서
복구는 다섯 단계로 나눠요.
observe → classify → re-read → repair → verify
목록과 활성 여부를 조회하고, 결과를 missing, duplicate, inactive, drift, 그리고 기대 목록에 없는 역할인 unexpected로 분류해요. 경쟁 상태나 일시적인 지연을 걸러 내기 위해 한 번 더 조회해요. 그런 다음 missing만 버전 관리되는 literal template으로 생성하고, 역할, schedule, 명령, spec version을 다시 확인한 뒤 원장의 ID를 고쳐요. 기대 역할마다 객체가 하나씩 있게 하는 것이 목표예요. 같은 역할의 객체가 여럿이면 duplicate 경보를, 기대 목록에 없는 역할이면 unexpected 경보를 내요. 어느 쪽이든 소유권을 알 수 없는 객체는 지우지 않아요.
자동으로 고치는 대상은 소유권과 명세가 확실한 missing으로 제한해요. 나머지 상태는 이렇게 다뤄요.
- inactive: 변경 내역을 확인한 뒤 명시적으로 활성화해요.
- duplicate: 동시에 실행될 위험을 경보로 알리고, 소유권은 사람이 조사해요.
- drift: 기대 버전과 변경한 사람을 확인해요.
- unexpected: 누가 왜 만들었는지 사람이 조사해요. 자동으로 지우지 않아요.
누군가 의도해서 만든 inactive나 drift를 덮어쓰면 안 돼요. 이 대조는 프로세스를 시작하거나 재개할 때, 그리고 설정을 바꾼 직후에 해요. 감지가 더 빨라야 하면 바깥에서 하는 정기 점검을 더해요.
마지막 성공 시각으로 경보하기
Prometheus의 계측 안내는 batch job의 핵심 지표를 마지막으로 성공한 시각이라고 적어요. 같은 문서는 경과 시간을 추적하려면 경과 시간이 아니라 일이 일어난 Unix timestamp를 내보내라고 해요. 경과 시간은 조회할 때 계산해요. retry의 마지막 성공 뒤로 지난 시간이 임계값을 넘는지 보는 조건식은 이래요.
time() - job_last_success_timestamp_seconds{job="retry"} > stale_after_seconds
임계값의 출발점으로는 실행 주기의 2배에 예상되는 최대 소요 시간을 더한 값을 제안해요. Prometheus의 경보 안내도 batch job이 충분히 최근에 성공하지 못했을 때 알리되, 그 시간은 보통 전체 실행 2회분 이상이어야 한다고 적어요. 이 값은 실측으로 정한 수치가 아니에요. 실행 시간이 얼마나 흩어지는지와 감지 지연을 얼마나 허용할지에 따라 조정해요. 한 주기 늦게 정상으로 성공한 경우는 grace window 안에서 경보를 미루고, 시작한 뒤 timeout을 넘긴 경우는 stale execution이나 hang으로 분류해요.
시계열이 아예 없는 경우는 따로 봐요. PromQL의 함수 문서에 absent()와 absent_over_time()이 있어요. Pushgateway 안내에 따르면 Pushgateway는 받은 시계열을 스스로 지우지 않아요. 직접 지우기 전까지 계속 내보내므로 낡은 성공 값이 남을 수 있어요. 그래서 시계열이 없는 것과 성공 시각이 낡은 것을 구분해요.
- 객체가 없으면 등록과 대조 과정, 실제 목록을 봐요.
- 성공 metric이 없으면 계측, 수집, 첫 실행, scrape나 push를 봐요.
- 성공 시각이 낡았으면 지연, 실패, 정지를 이력과 로그에서 찾아요.
관측 단계에 따라 조사할 곳도 달라요.
- 최근에 시작한 기록이 없으면 trigger와 scheduler를 봐요.
- 시작은 했는데 성공이 없으면 로그와 timeout을 봐요.
- 성공은 최신인데 checkpoint가 멈춰 있으면 입력과 업무 조건을 봐요.
- watcher의 heartbeat가 없으면 바깥의 probe와 알림을 봐요.
scheduler가 무엇을 보장하는지도 제품마다 달라요. Kubernetes 문서에 따르면 CronJob은 실행 시각마다 Job을 대략 한 번 만들어요. 상황에 따라 Job이 둘 생기거나 아예 생기지 않을 수 있어서, Job은 멱등이어야 해요. startingDeadlineSeconds와 concurrencyPolicy는 지연과 동시 실행을 조절하는 설정이고, 정확히 한 번 실행된다는 보장은 아니에요. GitHub Actions의 schedule 안내에 따르면 부하가 높은 시간에는 schedule 이벤트가 늦어지거나 대기 중인 작업이 버려질 수 있고, workflow 파일이 기본 브랜치에 있어야 하며 기본 브랜치에서만 실행돼요. 제품이 달라도 공통으로 볼 것은 객체, 실행 이력, 성공 시각이에요.
집합을 비교하고 분류하는 부분은 단위 테스트로 확인할 수 있어요. 실제의 지연, 중복, 누락은 staging이나 격리된 운영 점검에서 확인할 대상이에요. 실행한 결과는 이 글에 없어요. 처음의 누락이 왜 생겼는지도 확정하지 못했어요. 간헐적으로 대조하면 감지에 공백이 남고, 바깥의 monitor와 알림도 고장 날 수 있어요. 생성한 결과가 조회에 반영되기까지 지연이 있을 수 있어요. 생성 직후에 바로 다시 조회하면 아직 없는 것으로 보여서 한 번 더 만들 수 있어요. 객체가 보일 때까지 한도를 두고 기다렸다가 확인하거나, 역할 키를 고유한 이름으로 써서 생성이 멱등이 되게 해야 중복을 피해요.