3135 words
16 minutes
cron을 Dagu로 바꾼 이유: Airflow는 왜 안 됐나

집에 서버를 몇 대 두고 홈랩으로 씁니다. 그중 한 대에서 매일 브리핑이 하나 날아오는데, 밤새 쌓인 글을 추려 요약해서 메신저로 넘겨주는 Job입니다. 그 밖에도 정해진 시각에 도는 관리용 Job이 여럿 있고요.

얼마 전까지 이 Job들은 전부 crontab에 있었는데, 지금은 Dagu라는 스케줄러가 돌리고 있습니다. 스케줄러를 갈아치운 이유가 “cron이 낡아서”였다면 굳이 글로 쓰지는 않았을 겁니다. 실제 이유는 돌려야 할 Job이 달라진 것이었고, 그 변화는 스케줄러와 상관없어 보이는 데서 시작됐거든요.

에이전트를 컨테이너에서 꺼냈습니다#

원래 브리핑은 컨테이너로 띄운 에이전트가 돌렸습니다. 프롬프트도 수집 스크립트도 컨테이너 안에 있었고, cron은 그저 docker exec를 부르는 역할이었죠.

이걸 걷어내고 claude -p 한 줄로 바꿨습니다. 이유는 셋입니다.

  • 구독 로그인 세션을 그대로 쓰니 API 요금이 붙지 않습니다
  • 프롬프트와 수집 스크립트가 전부 저장소로 나오면서 “에이전트 안에만 있는 상태”가 사라졌습니다
  • 컨테이너가 없어지니 관리할 게 스크립트 몇 개와 cron 몇 줄로 줄었습니다

정리하고 나서는 가벼워졌다고 생각했습니다. 그런데 여기서 발목을 잡는 조건이 하나 생겼고, 알아채는 데 시간이 좀 걸렸습니다.

호스트에서만 돌 수 있습니다#

claude -p는 홈 디렉터리에 있는 구독 로그인 세션을 읽습니다. 컨테이너에 넣으면 그 세션이 없습니다. 마운트로 밀어 넣는 것도 방법이겠지만, 갱신될 때마다 깨질 자리를 하나 더 만드는 셈이라 내키지 않았습니다.

그래서 이 Job들은 호스트에서만 돌 수 있습니다. 요즘 도구를 고르다 보면 대개 “컨테이너로 띄우세요”가 기본값인데, 여기서는 그 길이 막혔습니다. 게다가 하필 제일 무겁고 제일 자주 도는 Job이 컨테이너에 못 들어가는 Job이었고요. 뒤에 나오는 선택은 전부 이 조건에서 나옵니다.

cron이 불편했던 세 가지#

crontab을 저장소에 넣고 설치 스크립트로 배포하는 데까지는 이미 해둔 상태였습니다. 그런데도 세 가지가 계속 걸렸습니다.

첫째, Job이 안 돌아도 아무도 모릅니다. 실패하면 알림이 오게 해뒀지만 아예 안 돈 경우는 조용합니다. 서버가 꺼져 있던 날은 그날 실행이 그냥 사라지고, 다음 날 아침에야 “어제 브리핑이 없었네” 합니다.

둘째, 시간을 벌려놓은 것이 동시성 제어를 대신하고 있었습니다. 브리핑 Job을 10분씩 떨어뜨려 둔 게 그겁니다.

0 8 * * * /usr/bin/python3 /opt/briefing/run.py digest-a
10 8 * * * /usr/bin/python3 /opt/briefing/run.py digest-b
20 8 * * * /usr/bin/python3 /opt/briefing/run.py digest-c

앞 Job이 10분 안에 끝난다는 보장이 어디에도 없습니다. lock이 아니라 기도에 가깝죠. LLM을 태우는 Job은 걸리는 시간이 들쭉날쭉해서 이런 전제가 특히 잘 깨집니다.

셋째, 로그가 흩어지고 기록이 안 남습니다. 언제 얼마나 걸렸는지, 무슨 출력을 뱉었는지 남지 않으니 문제가 생기면 매번 SSH로 들어가 파일을 뒤졌습니다.

여기서 짚어둘 게 하나 있습니다. 스무 개 남짓한 Job 중에 서로 의존하는 Job은 하나도 없었습니다. A가 끝나야 B가 도는 구조가 아니라, 그냥 각자 정해진 시각에 도는 Job들입니다.

후보를 훑고 대부분 접었습니다#

Airflow를 먼저 봤습니다. 이전 회사에서 써본 적이 있어 익숙하기도 했고요. 그런데 Airflow가 잘하는 건 Job 사이의 의존 관계, backfill, task 재시도, task 간 값 전달입니다. 앞에서 말했듯 의존 관계가 없으니 이 중 쓸 게 없습니다. 스케줄러에 웹서버에 워커에 DB까지 띄워서 가만히 있어도 2GB를 먹고, 얻는 건 사실상 UI 하나였습니다.

거기다 결정적인 문제가 있었습니다. Airflow는 컨테이너로 띄우는데, 컨테이너 안에서는 claude -p가 안 돕니다. Kestra, Windmill, Prefect, Dagster도 같은 이유로 접었습니다.

몇 개 더 봤습니다. 분산 환경을 겨냥한 스케줄러들은 서버 몇 대짜리 홈랩엔 과합니다. 오래된 후보 하나는 저자가 후속작으로 넘어가면서 “앞으로는 버그와 보안 패치만”이라고 공지를 걸어둔 상태였고요.

남은 게 Dagu였습니다. 가볍고 호스트에서 도는, 조건에 맞는 게 사실상 이것뿐이었습니다.

Dagu로 좋아진 것#

Dagu는 Go로 짠 단일 바이너리입니다. 16MB짜리 파일 하나를 놓고 실행하면 끝이고, DB도 메시지 브로커도 없습니다. 상태는 로컬 파일에 쌓이고 가만히 있을 때 메모리는 수십 MB입니다.

좋아진 것뭐가 달라졌나
Queue같은 Queue에 묶인 Job은 한 번에 하나만 돕니다. 겹치면 실패도 skip도 아니고 기다립니다. 10분씩 벌려놓던 시간차와 flock -n을 한꺼번에 대신합니다
catchup서버가 꺼져 있어 놓친 실행을 부팅 후에 뒤늦게 돌려줍니다. 정해둔 시간 안에서 최신 것 한 번만
실행 기록언제 돌았고 얼마나 걸렸고 무슨 출력을 뱉었는지 남습니다. 로그 뒤지러 서버에 들어갈 일이 줄었습니다
호스트 바이너리컨테이너가 아니니 구독 로그인 세션이 그대로 살아 있습니다. 애초에 이것 때문에 골랐습니다
Job 하나 = 파일 하나스케줄 변경이 git diff로 남습니다. 작업 디렉터리나 실패 알림처럼 공통으로 쓰는 설정은 base 파일에 한 번만 적습니다
기존 명령 그대로옮길 때 명령을 run: 아래 그대로 넣으면 됩니다

마지막이 생각보다 컸습니다. 스크립트는 한 줄도 안 고쳤고, Job 하나만 먼저 옮겨 며칠 돌려보고 나머지를 옮겼습니다.

# 아침 브리핑
schedule: "1 8 * * *"
queue: claude # claude를 쓰는 Job끼리 한 줄로 세운다
catchup_window: 6h # 꺼져 있었으면 6시간 안에서 뒤늦게라도
overlap_policy: latest
steps:
- run: /usr/bin/python3 /opt/briefing/run.py digest-b

Queue는 서버 설정에 한 번 선언해두면 됩니다.

queues:
enabled: true
config:
- name: "claude"
max_concurrency: 1

시각이 겹쳐도 알아서 줄을 서니, 브리핑 Job을 10분씩 벌려놓을 이유가 사라졌습니다. 지금은 전부 8시 언저리에 몰아두고 순서만 신경 씁니다.

불편한 점과 삽질#

좋은 얘기만 하면 반쪽입니다. 옮기고 나서 애먹은 것들이 있습니다.

손으로 한 번 돌린 게 다음 날 정기 실행을 통째로 먹었습니다. Dagu에는 skip_if_successful이라는 옵션이 있습니다. 직전 스케줄 이후에 성공한 실행이 있으면 이번엔 건너뛴다는 뜻인데, 여기서 말하는 성공에 손으로 돌린 실행도 들어갑니다. 저녁에 Job이 제대로 도는지 확인하려고 UI에서 한 번 실행했더니, 그 기록이 다음 날 아침 브리핑을 삼켰습니다.

문제는 알아챌 방법이 없다는 점입니다. 실패한 게 아니라 아예 안 돈 것이라 실패 알림에 안 걸리고, 스케줄러 로그에 Skipping job due to successful prior run 한 줄로만 남습니다. 브리핑이 안 온 걸 눈으로 보고서야 알았습니다.

옵션을 아예 빼면 되지 않나 싶겠지만, 그러면 catchup이 이미 성공한 걸 다시 돌려서 브리핑이 두 번 나갈 수 있습니다. 그래서 옵션은 두고, 손으로 돌릴 때만 다른 이름으로 기록되는 실행 스크립트를 따로 만들어 정기 실행 기록과 섞이지 않게 했습니다.

UI가 있는데 UI를 못 씁니다. Dagu 웹 UI에서는 Job을 편집할 수도, 알림 채널을 설정할 수도 있습니다. 그런데 그렇게 만든 설정은 서버의 데이터 디렉터리에 쌓입니다. 기준이 되는 설정은 저장소에 있는데 실제로 도는 건 서버에만 있게 되고, 설정이 어긋났는지 확인하는 스크립트에도 안 잡힙니다. 결국 편집 권한을 끄고 조회와 수동 실행 전용으로 씁니다. UI가 좋아 보여서 도입할 생각이라면 기대와 다를 수 있는 부분입니다.

로그아웃하면 스케줄러가 죽습니다. systemd user 서비스로 띄웠기 때문인데, loginctl enable-linger를 켜두지 않으면 세션이 끝나는 순간 같이 내려갑니다. cron에는 없던 종류의 실패라 처음엔 원인을 못 찾았습니다.

작업 디렉터리의 .env를 알아서 읽습니다. 의도하지 않은 환경변수가 Job에 흘러들어갑니다. dotenv: []로 명시적으로 꺼뒀습니다.

기록을 지우면 한꺼번에 다 돕니다. 처음 설치하거나 상태 디렉터리를 날리면 catchup이 “그동안 전부 놓쳤다”고 판단해서 시간 안에 있는 Job을 바로 실행합니다. 한 번 더 돌아도 무해한 Job이면 괜찮지만, 모르고 보면 당황합니다.

대신 포기한 것도 있습니다. Queue로 묶으면 겹치던 Job들이 줄을 서니 전체가 끝나는 시각은 뒤로 밀립니다. 그리고 crontab 한 줄이던 게 YAML 파일 하나가 됩니다. Job이 몇 개 안 되면 손해죠.

마지막으로 도구 자체의 리스크입니다. Dagu 본체는 GPL-3.0이고 홈랩 규모에서는 라이선스 키가 필요 없습니다. 다만 저장소가 개인에서 상용 조직으로 옮겨왔고, SSO나 RBAC 같은 기능은 이미 유료 self-host 라이선스 뒤에 있습니다. 실제로 인증 방식을 고를 때 이게 걸려서 단순한 basic auth 쪽으로 갔습니다. 지금 쓰는 데는 지장이 없지만 버전은 고정해두고 올릴 때 릴리스 노트를 봅니다. 무료로 쓸 수 있는 범위가 줄어드는 방향은 언제든 가능하니까요.

cron이 나쁘다는 얘기는 아닙니다#

옮기고 제일 크게 달라진 건 스케줄을 손볼 일이 없어졌다는 점입니다. Job을 하나 더 붙일 때 “몇 시 몇 분에 넣어야 안 겹치지”를 계산하지 않습니다. 그냥 Queue에 던집니다.

그렇다고 모두가 이 길을 갈 필요는 없습니다. 아래에서 걸리는 게 없다면 cron으로 충분합니다.

  • 에이전트나 CLI 도구를 호스트에서 돌리고 있나
  • Job이 겹치는 걸 시간차로 막고 있나
  • Job이 안 돌았을 때 알아챌 방법이 있나
  • 서버가 꺼져 있던 날의 실행을 뒤늦게라도 돌려야 하나
  • Job끼리 의존 관계가 있나 — 있다면 이 글 말고 Airflow를 보는 게 맞습니다

제 경우엔 앞의 넷이 걸렸고 마지막이 아니었습니다. 그래서 Airflow가 아니라 Dagu였습니다.

커버 이미지는 제 인스턴스가 아니라 Dagu 공식 저장소에 실린 Run 상세 화면 스크린샷입니다.

cron을 Dagu로 바꾼 이유: Airflow는 왜 안 됐나
https://monologg.kr/posts/2026/07/29/2026-07-29-cron-to-dagu-claude-p/
Author
Jangwon Park
Published at
2026-07-29
License
CC BY-NC-SA 4.0