집에 서버를 몇 대 두고 홈랩으로 씁니다. 그중 한 대에서 매일 브리핑이 하나 날아오는데, 밤새 쌓인 글을 추려 요약해서 메신저로 넘겨주는 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-a10 8 * * * /usr/bin/python3 /opt/briefing/run.py digest-b20 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: lateststeps: - run: /usr/bin/python3 /opt/briefing/run.py digest-bQueue는 서버 설정에 한 번 선언해두면 됩니다.
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 상세 화면 스크린샷입니다.