Cronhq — Cron jobs that actually run
Cronhq — 실제로 실행되는 Cron 작업
Cronhq는 여러 서버에서 Cron 작업이 중복 실행되거나 조용히 멈추는 문제를 Postgres 잠금과 상태 기반 알림으로 다루는 스케줄러입니다. 재시도, 서명된 Webhook, 외부 작업용 Heartbeat, CLI와 Self-hosting을 제공하지만, 외부 작업의 중복 처리를 위한 안정적인 실행 ID는 아직 추가 예정입니다.
- 주제
AI 요약
Cron 작업은 설정이 단순한 만큼 실패를 놓치기 쉽습니다. 서버를 한 대에서 두 대로 늘리면서 같은 crontab을 배포하면 결제 작업이 두 번 실행될 수 있고, 작업이 멈춰도 오류나 알림 없이 몇 주가 지나갈 수 있습니다. Cronhq는 이 두 문제를 실행 제어와 운영 알림의 문제로 보고, Postgres를 조정 수단으로 삼는 스케줄러를 만들었습니다.
중복 실행을 막는 실행 모델
Cronhq는 모든 예정된 실행을 Postgres 잠금 행으로 먼저 점유합니다. 잠금에는 만료 기한이 있으며, next_run_at을 작업 전달 전에 다음 시각으로 옮깁니다. 따라서 두 Worker가 같은 실행 슬롯을 동시에 가져가 작업을 시작하지 않습니다. Worker가 잠금을 쥔 상태에서 죽으면 만료 시점 이후 다른 Worker가 인계할 수 있습니다. 개발자 설명에 따르면 잠금은 DB session 또는 transaction에 연결되어 있어 연결이 끊기면 Postgres가 잠금을 자동으로 해제합니다. Heartbeat가 잠금을 정리할 때까지 기다리는 구조는 아닙니다.
다만 이 설계가 외부 시스템의 부작용까지 한 번만 보장하는 것은 아닙니다. Webhook 대상이 실제 작업을 끝냈지만 Cronhq가 응답을 받지 못하거나 성공 기록을 남기기 전에 timeout이 발생하면 Cronhq는 재시도합니다. 이때 수신 측이 중복 요청을 구분할 안정적인 실행 ID가 없으면 결제가 두 번 처리될 수 있습니다. 현재는 timestamp와 signature가 시도마다 달라질 수 있고, 재시도 전체를 묶는 실행 ID를 Webhook에 전달하지 않습니다. 개발자는 각 예약 실행에 고정된 execution ID를 부여하고 모든 재시도에 같은 ID를 전달하는 기능을 추가하겠다고 밝혔습니다. 수신 endpoint는 이 ID를 저장해 실제 작업을 중복 처리하지 않도록 만들 수 있습니다. 각 재시도 시도는 별도 메타데이터로 추적할 계획입니다.
재시도와 장애 알림
재시도 설정은 작업별로 관리합니다. 최대 시도 횟수, backoff 지연 시간, timeout을 구성 파일에서 정하며, 작업마다 예외 처리 코드를 복사해 넣지 않아도 됩니다. 알림은 현재 상태가 아니라 상태 전환을 기준으로 중복을 제거합니다. 작업이 실패하기 시작할 때 한 번 알리고, 복구할 때 한 번 알립니다. 같은 장애로 새벽에 수십 건의 페이지가 울리는 상황을 피하려는 방식입니다.
Webhook에는 timestamp.body에 HMAC-SHA256을 적용한 서명을 붙입니다. 작업별 secret을 사용하고 중단 없이 교체할 수 있어, 수신 endpoint가 요청이 Cronhq에서 왔는지 검증할 수 있습니다. 다만 서명은 요청의 출처를 검증하는 수단이며, 재시도된 같은 실행을 구분하는 멱등성 키 역할은 하지 않습니다.
Cronhq가 직접 실행하지 않는 기존 작업도 Heartbeat monitor로 감시합니다. 기존 Cron에서 지정된 URL을 ping하고, 정해진 시간 창 안에 ping이 도착하지 않으면 알림을 보냅니다. 실행 자체보다 실행되지 않은 사실을 감지하기 어려운 작업에 쓰는 dead-man's switch입니다.
구성과 배포
스케줄은 cronhq.yaml에 선언하고 npx cronhq sync로 반영합니다. npx cronhq tail을 실행하면 작업 실행 흐름을 실시간으로 볼 수 있습니다. HTTP endpoint를 만들기 어려운 스크립트와 명령은 CLI 옵션으로 처리할 수 있으며, 개발자는 향후 SSH 명령 실행 방식도 더 매끄럽게 지원할지 살펴보겠다고 답했습니다.
Scheduler, Worker, API는 Rust의 axum, sqlx, tokio로 만들었고 조정 수단은 Postgres 하나만 사용합니다. Redis나 Kafka, 별도의 scheduler 계층은 넣지 않았습니다. 화면은 Next.js로 구성합니다. MIT License로 공개하며 Docker Compose 하나로 Self-hosting할 수 있습니다. Cloud에서 사용하는 이미지와 Self-hosted 이미지가 같고 기능 플래그로 숨긴 기능은 없다고 설명합니다. 무료 요금제는 작업 5개까지이며 유료 요금제는 월 5달러부터 시작합니다.
Product Hunt 반응
- @michael_sunmisola — 안녕하세요 Product Hunt 👋 저는 Michael이고, 지난 몇 달 동안 혼자 Cronhq를 만들었습니다. 클라이언트 작업에서 사람들이 반복해서 겪는 버그를 보면서 시작했습니다. 팀이 애플리케이션을 서버 한 대에서 두 대로 확장하고 두 서버에 같은 crontab을 두면, 매일 밤 실행하는 결제 작업이 두 번 실행되고 고객에게 두 번 청구됩니다. 대시보드가 문제를 알려주는 것이 아니라 화난 고객의 지원 요청으로 처음 알게 됩니다. 반대편에는 더 조용하고 더 나쁜 문제가 있습니다. 작업이 그냥 멈춥니다. 충돌도 없고 페이지 알림도 없으며 경고도 없습니다. 6주가 지나서야 누군가 보고서가 3월부터 비어 있었다는 사실을 알아챕니다. Cronhq는 이 두 실패를 단순히 가능성이 낮은 일이 아니라 구조적으로 어렵게 만들려는 시도입니다.
→ 정확히 한 번 실행합니다. 예정된 각 실행은 만료 기한이 있는 Postgres 잠금 행으로 점유하고, 전달 전에 next_run_at을 앞으로 이동합니다. 두 Worker가 같은 실행을 시작하지 않습니다. Worker가 잠금을 쥔 채 죽어도 만료 시점 이후 다른 Worker가 넘겨받으므로 실행 자체가 사라지지 않습니다.
→ 작업별 backoff 재시도를 제공합니다. 최대 시도 횟수, 지연 시간, timeout을 설정하며 Stack Overflow에서 복사한 try/except 코드에 의존하지 않습니다.
→ 상태가 아니라 상태 전환에 따라 알림을 중복 제거합니다. 작업이 실패하기 시작할 때 한 번, 복구될 때 한 번 알립니다. 새벽 3시에 같은 문제로 47개의 페이지를 보내지 않습니다.
→ 서명된 Webhook을 사용합니다. timestamp.body에 HMAC-SHA256을 적용하고 작업별 secret을 사용합니다. 중단 없이 secret을 교체할 수 있으므로 endpoint가 요청이 Cronhq에서 왔는지 확인할 수 있습니다.
→ 우리가 실행하지 않는 작업을 위한 Heartbeat monitor를 제공합니다. 기존 Cron에서 URL을 ping하면 됩니다. 정해진 시간 안에 ping이 도착하지 않을 때 알림을 보냅니다. 감지하기 어려운 것은 실행이 아니라 부재입니다.
→ Cron-as-code를 지원합니다. cronhq.yaml에 스케줄을 적고 npx cronhq sync를 실행합니다. npx cronhq tail에서는 실행 흐름을 실시간으로 확인합니다.
Scheduler, Worker, API는 Rust의 axum, sqlx, tokio로 만들었고 Postgres를 유일한 조정 수단으로 사용합니다. Redis도 Kafka도 scheduler를 조정하는 또 다른 계층도 없습니다. 대시보드는 Next.js입니다. MIT License이며 Docker Compose 하나로 Self-hosting할 수 있습니다. Self-hosted 이미지와 Cloud 이미지가 같고 플래그 뒤에 숨겨진 기능도 없습니다. 무료 요금제는 작업 5개까지이며 유료 요금제는 5달러부터 시작합니다. 두 가지 의견을 듣고 싶습니다. 정확히 한 번 실행한다는 점을 앞에 내세우는 것이 맞을까요, 아니면 Backend 개발자 외에는 너무 세부적인 내용처럼 보일까요? 실제로 돈이 움직이는 작업을 맡기려면 무엇을 확인해야 할까요? 하루 종일 질문에 답하겠습니다. 읽어주셔서 감사합니다 🙏
↳ @michael_sunmisola — 맞는 지적입니다. 현재는 재시도 전체에 걸쳐 유지되는 안정적인 execution ID를 노출하지 않습니다. timestamp와 signature header는 수신 측의 멱등성 처리를 위한 것이 아닙니다. endpoint가 작업을 성공적으로 끝냈지만 응답이 유실되면 Cronhq가 신뢰할 키 없이 다시 호출하고, 수신 측에서 중복 요청을 제거할 방법이 없습니다. Celery 예시가 이 빈틈을 분명하게 보여줬습니다. 같은 실행에 속한 모든 시도에 유지되는 안정적인 run 또는 execution ID를 추가하겠습니다. 각 시도에는 별도 메타데이터를 붙일 예정입니다. 특히 Celery 사례를 들어 지적해주셔서 감사합니다. Cronhq가 제대로 다뤄야 할 실패 유형입니다.
↳ @davide_terni — 두 번째 질문에 답하자면, 돈을 움직이는 작업에 맡기기 전에는 Webhook header에 run ID가 있는지 확인하겠습니다. 정확히 한 번 실행한다는 보장이 Cronhq 쪽에 적용되고 두 Worker가 같은 슬롯을 실행하지 않는다면 괜찮습니다. 하지만 재시도는 다른 문제입니다. endpoint가 작업을 처리했는데 응답이 유실되거나 timeout되면 Cronhq가 다시 호출하고, 제 쪽에서 결제가 두 번 발생할 수 있습니다. 문서에는 X-Cronhq-Timestamp와 X-Cronhq-Signature만 보입니다. timestamp는 시도마다 바뀌는 것 같아서 수신 측에서 중복을 제거할 기준이 없습니다. Celery와 Redis에서도 이런 일을 겪었습니다. 오래 걸리는 작업이 다시 전달되어 두 번 실행됐고, 방어 로직을 직접 만들어야 했습니다. 한 실행의 재시도 전체에 같은 execution ID가 있나요? 아니면 추가 예정인가요?
- @jay_janarthanan1 — 제 코드는 전부 Webhook을 노출해야 하나요?
ssh -T user@remote-ip 'python3 /path/to/script.py'같은 방식도 지원하면 좋겠습니다.- @michael_sunmisola — 모든 코드를 Webhook으로 노출할 필요는 없습니다 😄 Cronhq에는 CLI 옵션도 있어서 모든 것을 HTTP endpoint로 감싸지 않고 스크립트와 명령을 사용할 수 있습니다. 말씀하신 직접 SSH 실행 방식도 더 매끄럽게 만들 수 있을지 살펴보겠습니다.
- @anton_w — 정말 유용합니다. 오랫동안 저를 괴롭힌 문제입니다. 잘 만들었습니다.
- @michael_sunmisola — 감사합니다! 🙌 바로 그 불편함이 Cronhq를 만든 이유입니다. 실제 문제를 해결하는 사람의 이야기를 들어서 좋습니다. 사용해보신다면 무엇이 깨지거나 부족하게 느껴지는지 듣고 싶습니다.
- @galdayan — 작업이 두 번 실행된다는 설명은 결제 작업에서 실제로 문제가 되기 전까지 쉽게 무시하는 실패 유형을 정확히 짚습니다. 작업 중간에 Worker가 죽어도 Postgres 잠금이 제대로 처리되는지 궁금합니다. 잠금이 자동으로 풀려 다음 예약 실행이 이어받나요? 아니면 죽은 Worker가 Heartbeat 알림으로 누군가 알아차릴 때까지 작업을 멈춰두나요?
- @michael_sunmisola — 좋은 질문입니다. 잠금은 DB session 또는 transaction에 연결되어 있어서 Worker가 죽고 연결이 끊기면 Postgres가 자동으로 잠금을 해제합니다. 잠금을 해제하는 데 Heartbeat를 사용하지 않습니다. 더 신중하게 다루고 있는 부분은 대상 endpoint가 작업을 완료했지만 Cronhq가 성공 응답을 받거나 기록하지 못한 채 죽는 구간입니다. 이때 run ID와 멱등성이 중요합니다. 특히 결제 작업에서는 더 그렇습니다. 이 동작을 문서에 명확히 적으려고 합니다.
- @galdayan — 그 구분이 이해됩니다. 잠금에 대한 질문은 실제로 두 번 실행되는지에 관한 것이었고, 솔직히 말하면 더 작은 위험입니다. 더 무서운 문제는 완료 여부가 모호한 경우입니다. 겉으로는 아무것도 고장 나지 않은 것처럼 보이기 때문입니다. run ID가 Cronhq가 생성해 endpoint에 전달하는 값인가요? 아니면 현재는 Cronhq 내부 기록에만 쓰이나요? 전자라면 수신 작업에서 멱등성을 구현할 수 있으므로 Cronhq의 재시도 로직만 믿지 않아도 됩니다.
- @michael_sunmisola — 현재는 내부 기록에만 쓰고 있으며, 재시도 전체에 걸쳐 유지되는 안정적인 execution ID를 endpoint에 전달하지 않습니다. 이 부분을 바꿔서 각 예약 실행에 execution ID를 부여하고 Webhook header에 노출하겠습니다. 같은 실행의 모든 재시도에는 같은 ID가 들어가고, Cronhq는 각 재시도 시도를 별도로 추적합니다. 수신 endpoint는 ID를 저장해 실제 작업을 안전하게 중복 제거할 수 있습니다. 수신 측 멱등성이 없으면 정확히 한 번 전달하는 것만으로는 완료 여부가 모호한 상황을 완전히 해결하지 못한다는 지적이 맞습니다.
원문: Product Hunt / 번역·요약: Trawling