
7개의 AI 에이전트를 Cron으로 예약했습니다. 그중 2개는 18일 동안 조용히 실패하고 있었습니다. 트레이싱(Tracing)으로도 잡아낼
요약
Cron으로 예약된 AI 에이전트 중 일부가 실행조차 되지 않아 18일 동안 감지되지 않았던 장애 사례를 다룹니다. 기존 모니터링 시스템이 '실행 중인 프로세스'에만 집중할 때 발생하는 사각지대와 이를 해결하기 위한 종료 코드(exit-code) 계약의 중요성을 설명합니다.
핵심 포인트
- 기존 트레이싱과 대시보드는 실행 중인 프로세스만 감시하는 한계가 있음
- 프로세스가 시작조차 되지 않는 'Silent Failure'는 기존 모니터링을 우회함
- 예약된 CLI 에이전트 운영 시 명확한 종료 코드(exit-code) 계약이 필수적임
- 의존성 있는 스크립트 실행 시 환경 변화에 따른 실패 가능성을 고려해야 함
저는 7개의 AI 에이전트를 cron으로 실행하고 있었습니다. 그중 2개는 첫날부터 작동을 멈췄습니다. 저는 18일째 되는 날에야 이를 알아차렸습니다.
이 문장이 이 글의 전부이며, 만약 누군가 팟캐스트에서 이런 말을 했다면 저 또한 반박했을 법한 문장이기도 합니다. "설마 모르고 지나쳤겠어요? 트레이싱(Tracing)도 있고, 대시보드(Dashboards)도 있잖아요. 무언가 움직일 때마다 알림이 울리는 Telegram 채널도 있고요." 맞습니다. 저는 그 모든 것을 갖추고 있었습니다. 하지만 죽어버린 두 개의 에이전트는 그 모든 것을 교묘히 빠져나갔습니다. 왜냐하면 제가 가진 모든 모니터링 계층은 '실행 중인' 프로세스를 감시하도록 설계되었기 때문입니다. 제 에이전트 중 두 개는 실행조차 되지 않고 있었습니다.
이 글은 18일간의 기록입니다. 7개의 에이전트가 무엇이었는지, 어떻게 2개가 첫날에 조용히 고장 났는지, 왜 저의 트레이싱(Tracing)이 해당 실패를 잡아내기에 적절한 형태가 아니었는지, 그리고 현재 제가 모든 예약된 CLI 에이전트에 추가하여 적용하고 있는 작은 종료 코드(exit-code) 계약에 대해 다룹니다.
7개의 에이전트와 멀쩡해 보였던 설정
저는 동일한 서버에서 두 개의 콘텐츠 도메인과 하나의 자기 진화형 하네스(self-evolving harness)를 운영하고 있습니다. 각 도메인에는 매일 09:00에 실행되는 3개의 에이전트(관찰자, 전략가, 마케터)가 있으며, 토요일마다 실행되는 공유 진화기(evolver)가 하나 있습니다. 총 7개의 프로세스입니다. cron 설정은 대략 다음과 같은 형태였습니다:
0 9 * * * /home/me/repos/harness-ops/scripts/marketer-A.sh >/dev/null 2>&1
0 9 * * * /home/me/repos/harness-ops/scripts/marketer-B.sh >/dev/null 2>&1
각 셸 스크립트(shell script)는 프롬프트와 함께 claude -p "..."를 감싸고, 출력을 캡처하며, 일일 로그 파일을 작성한 뒤 종료됩니다. 에이전트가 게시하기로 결정하면 마지막에 실제 기사가 푸시됩니다. 저는 스크립트 내부에서 성공 시와 set -e 실패 경로에서 작동하는 Telegram 웹훅(webhook)을 가지고 있습니다. 저는 이 설정이 조용히 실패하기 전까지 약 두 달 동안 이 시스템을 운영해 왔습니다.
설정 당시 제가 놓친 부분은 heredoc(heredoc) 아래의 세 줄이었습니다. 두 개의 마케터 스크립트(marketer scripts)는 형제 리포지토리(sibling repo)에 있는 Python 헬퍼(helper)를 참조하고 있었습니다. 저는 어느 시점에 그 형제 리포지토리로 cd하여 모든 것을 수동으로 테스트했습니다. 스크립트는 작동했습니다. 저는 그것을 체크인(check in)했습니다. 그러고 나서 형제 리포지토리를 정리하며 헬퍼 모듈의 이름을 변경했고, 제 마케터 스크립트의 임포트(import) 라인은 아무것도 가리키지 않게 되었습니다.
무슨 일이 일어났는지 이미 짐작하실 수 있을 것입니다. python3 helper.py ...는 ModuleNotFoundError와 함께 즉시 코드 1로 종료됩니다. 쉘 스크립트(shell script)의 첫 번째 줄은 set -euo pipefail이므로, 스크립트는 처음 10줄 이내에 종료됩니다. Telegram은 Python 호출 이후인 나중에 연결되어 있으므로, 스크립트는 Telegram 블록에 절대 도달하지 못합니다. >/dev/null 2>&1 리다이렉션(redirect)은 stderr(표준 에러)를 삼켜버립니다. Cron은 MAILTO= 설정 없이 구성되어 있습니다. 매일 아침 두 개의 에이전트가 조용히 죽습니다. 나머지 다섯 개는 정상적으로 게시합니다. 시스템은 건강해 보입니다.
트레이싱(Tracing)이 감시하던 것과 감시하지 못하던 것
여기서 정확히 말씀드리고 싶습니다. 왜냐하면 저는 18일째 되는 날, 더 나은 트레이싱(tracing)이 있었다면 이를 잡아냈을 것이라고 스스로를 설득하는 데 몇 시간을 보냈기 때문입니다. 하지만 그렇지 않았을 것입니다.
저는 모든 claude -p 호출에서 나오는 OTEL(OpenTelemetry) 스팬(span)을 가지고 있었습니다. 이 스팬들은 셀프 호스팅된 컬렉터(collector)로 전송되었고, 작업당 토큰 수, 도구 호출 지연 시간(tool-call latency), 재시도율(retry rate), 그리고 일일 총 에이전트 실행 횟수를 보여주는 작은 대시보드로 전달되었습니다. 18일째 되는 날 아침, 대시보드는 지난 18일 동안 매일 하루에 5개의 에이전트 실행이 있었음을 보여주었습니다. 그래프 선은 완벽하게 평평했습니다. 그 선은 7이어야 했습니다.
트레이싱은 실행되는 프로세스를 추적합니다. 느린 호출을 보여줄 수 있습니다. 실패한 호출을 보여줄 수 있습니다. 재시도 폭풍(retry storm)을 보여줄 수 있습니다. 하지만 아예 생성(spawn)조차 되지 않은 프로세스는 보여줄 수 없습니다. 죽어버린 두 개의 마케터는 스팬(span)이 없었습니다. 왜냐하면 유일한 스팬 에미터(span emitter)가 바로 임포트에 실패하고 있는 그 Python 헬퍼 내부에 있었기 때문입니다. 대시보드의 관점에서 볼 때, 그 두 에이전트는 그날 존재하지 않았습니다. 그리고 그다음 날도, 그다음 날도 말입니다.
이것은 healthchecks.io 문서에서 '데드 맨즈 스위치 (dead man's switch)'라고 설명하는 정확한 실패 모드입니다. 즉, 중요한 작업이 어떤 알람도 울리지 않은 채 멈춰버리고, 누군가 데이터 누락을 알아차리기 전까지 조용한 실패 (silent failure)가 며칠 동안 지속되는 상황을 말합니다. 2026년의 cron 모니터링 관련 글들은 이를 직설적으로 표현합니다. 조용한 실패는 가장 비용이 많이 드는 종류입니다. 왜냐하면 아무것도 충돌(crash)하지 않고 아무에게도 페이지(page)가 가지 않기 때문입니다. 저는 이 페이지를 전에 읽은 적이 있었습니다. 다만 Telegram 알림이 있었기에 충분히 대비하고 있다고 느껴 제 자신의 cron에는 적용하지 않았을 뿐입니다. Telegram은 스크립트가 실제로 도달하는 코드 경로(code paths)에서만 실행됩니다.
내가 덧붙인 종료 코드 (exit-code) 계약
해결책은 더 많은 관측성 (observability)을 도입하는 것이 아니었습니다. 에이전트가 스스로를 보고하도록 하는 것에 대한 신뢰를 줄이고, 대신 cron 래퍼 (wrapper)가 에이전트를 대신해 보고하도록 하는 신뢰를 높이는 것이었습니다. 저는 모든 예약된 에이전트에 작은 계약을 부여했습니다.
- 의미 있는 종료 코드 (exit codes)를 정의합니다. 단순히
0 = 정상, 그 외 = 비정상이 아닙니다. 저는sysexits.h를 느슨하게 참고했습니다:0은 "에이전트가 실행되었고 작업을 완료함",64는 "설정 또는 환경 오류" (ModuleNotFoundError케이스),65는 "작업은 실행되었으나 사용할 만한 출력을 생성하지 못함",78은 "에이전트가 의도적으로 건너뜀" (마케터가 오늘 게시할 내용이 없다고 결정한 경우)을 의미합니다. - 보고의 주체를 cron 래퍼로 만듭니다. 에이전트 스크립트의 역할은 올바른 코드로 종료하는 것입니다. 래퍼의 역할은 에이전트의 성공 여부와 관계없이 그 코드를 받아 영구적인 곳에 전송하는 것입니다.
- 성공 시에만 발생하는 하트비트 (heartbeat)를 추가합니다. 실패 시가 아닙니다. 침묵이 곧 알람이 되어야 합니다. 이것이 모니터링 도구들이 구축된 기반인 데드 맨즈 스위치입니다. 작업은 실행되었음을 증명하기 위해 능동적으로 체크인 (check in)해야 하며, 핑 (ping)이 누락되는 것이 당신에게 페이지를 보내는 신호가 됩니다.
이제 cron 래퍼는 대략 다음과 같은 모습입니다:
#!/usr/bin/env bash
# scripts/cron-wrap.sh <agent-name>
set -uo pipefail
...
그 안의 세 가지 요소가 제대로 작동하도록 만드는 데 며칠 밤을 보냈습니다.
첫째, set -euo pipefail 대신 set -uo pipefail을 사용했습니다. 에이전트 스크립트가 실패하더라도 래퍼(wrapper)가 종료되는 것을 원치 않았기 때문입니다. 만약 핑(ping)을 보내기 전에 래퍼가 종료되면, 하트비트(heartbeat) 서비스가 결국 저에게 페이지(page)를 보내겠지만, 그 알림은 24시간이나 늦게 도착할 것이고 로그 라인은 영영 기록되지 않을 것입니다. 래퍼는 계속 실행 중이어야 하며 종료 코드(exit code)를 직접 캡처해야 합니다.
둘째, 핑(ping) URL의 경로(path)에 종료 코드를 포함했습니다. healthchecks.io는 이를 수용하며 대시보드에 마지막으로 보고된 코드로 노출해 줍니다. 덕분에 로그 파일을 열어보지 않고도 목록을 훑어보며 "에이전트 실행됨, 종료 코드 64"와 같은 내용을 확인할 수 있습니다. Cronitor도 약간 다른 URL 형태를 사용하지만 동일한 기능을 수행합니다. 기존 도구에 맞는 것을 선택하면 됩니다.
셋째, 78은 실패가 아닌 의도적인 건너뛰기(skip)로 처리됩니다. 마케터의 "오늘 발행할 가치가 있는 내용이 없음" 경로가 78을 반환합니다. 이 처리가 없다면, 실제로 조용한 날에도 실패 에스컬레이션(failure escalation)이 발생할 것이고, 저는 결국 해당 채널을 무시하게 될 것입니다. 이것이 실무에서 모니터링이 무용지물이 되는 방식입니다.
배포 당일 잡아낸 것
저는 조용한 마케터들이 활동을 멈춘 지 18일째 되는 날에 이 시스템을 배포했습니다. 10분도 채 되지 않아 marketer-A와 marketer-B 모두 대시보드에 마지막 보고된 종료 코드 64와 함께 나타났습니다. 이는 더 이상 존재하지 않는 모듈에서 발생한 설정 오류(config error)였습니다. 저는 아직 에이전트 코드를 열어보지도 않았습니다. 그저 대시보드만 확인했을 뿐입니다.
한 시간 이내에 임포트(import) 이름을 변경하고, 두 스크립트를 수동으로 실행하여 종료 코드 0을 확인했습니다. 그리고 다음 날 아침, 크론(cron)은 해당 에이전트들이 2주 반 동안 조용히 건너뛰었던 두 개의 기사를 발행했습니다. 트레이싱(tracing) 대시보드는 마침내 하루 7회의 실행 횟수를 기록했습니다. 그래프는 여전히 평탄하지만, 이제는 올바른 수치에서 평탄합니다.
다음 날, 다른 에이전트(observer-B, 침묵의 실패 기간 동안 계속 정상 작동함)가 65, "사용 가능한 출력 없음(no usable output)"을 내뱉으며 종료되기 시작했습니다. 대시보드는 20분 이내에 이를 포착했습니다. 이것이 바로 계약(contract)이 존재하는 이유입니다. 에이전트가 실행은 되었지만 쓰레기(garbage)를 생성했을 때, 2주 뒤가 아니라 당일에 바로 알아낼 수 있어야 합니다.
과거의 나에게 해주고 싶은 말
두 달 전 이 cron을 설정했던 과거의 나는 부주의하지 않았습니다. 그는 Telegram 알림, 트레이싱(tracing) 대시보드, 그리고 일일 로그 파일(daily log file)을 갖추고 있었습니다. 그는 Twelve-Factor App의 가용성(disposability) 장도 읽었습니다. 심지어 "에이전트 실패(agent failed)"와 "에이전트 미실행(agent did not run)"의 차이점에 대해서도 고민했고, 후자는 무시해도 될 만큼 발생 가능성이 낮다고 결론지었습니다.
실수는 "미실행"이 드문 예외 케이스(edge case)라고 가정한 것이었습니다. 7개의 예약된 프로세스, 3개의 Python 헬퍼(helpers), 독립적으로 움직이는 2개의 리포지토리(repos), 그리고 Telegram을 양 끝단이 아닌 중간에 연결하는 롱 러닝 스크립트(long-running script)가 있는 환경에서, "미실행"은 가장 발생하기 쉬운 침묵의 실패 모드(silent failure mode)입니다. 비교조차 되지 않을 만큼 압도적입니다. (저는 주변 스크립트들에 대해 my AI pipeline의 9가지 버그라는 별도의 포스트에 작성했습니다. silent-cron은 그 목록의 7번이었습니다.)
따라서 설정 비용이 저렴한 순서대로 세 가지를 제안합니다.
MAILTO=는 무료입니다. 이를 설정하면, 알림(alerting) 코드가 실행되기도 전에 종료되어 버리는 작업이라 할지라도 cron 자체가 실패한 작업의 stderr(표준 에러)를 이메일로 보내줍니다. 이것만 있었어도 발생한 당일 아침에 제 실패를 잡아낼 수 있었을 것입니다.- 모든 예약된 에이전트를 직접 작성한 스크립트로 감싸세요 (Wrap). 에이전트 자체를 감싸는 것이 아니라, 종료 코드(exit code)를 캡처하여 어딘가로 핑(ping)을 보내는 단 하나의 임무를 가진 에이전트 래퍼(wrapper)를 만드는 것입니다. 래퍼는 에이전트보다 더 투박해도 괜찮습니다. 왜냐하면 래퍼는 절대 변경되어서는 안 되기 때문입니다.
- 성공적인 하트비트(heartbeat)가 침묵을 소란스럽게 만듭니다. 실패 알림(failure alerts)은 어디에나 있으며, 아예 실행조차 되지 않은 에이전트에 대해서는 아무런 정보도 주지 않습니다. 성공 시 발생하는 하트비트와, 하트비트가 사라졌을 때 페이지(page)를 보내는 데드맨 스위치(dead man's switch)를 결합하면, "두 개의 에이전트가 조용해졌다"는 사실을 18일 만에 발견하는 것이 아니라 하루 만에 발견할 수 있게 됩니다.
트레이싱(Tracing)과 관측성(observability)은 살아있는 프로세스를 모니터링하는 방법입니다. 종료 코드 계약(exit-code contract)은 그 프로세스들이 애초에 살아있어야 했다는 사실을 기억하는 방법입니다. 이 두 가지는 서로 보완적이며, 두 번째 요소가 없다면 cron 기반의 "설정 후 망각(set it and forget it)" 패턴은 무너집니다. 제 패턴은 매일 아침 확인하던 서버에서 18일 동안 조용히 무너졌습니다.
저는 대시보드를 확인했습니다. 대시보드는 그저 잘못된 질문을 던지고 있었을 뿐입니다.
단일 블로그 포스트보다 더 깊이 있게 파고들고 싶다면, 저는 이 이야기의 라이프사이클 및 훅(lifecycle-and-hooks) 레이어를 AI 에이전트 활용에 관한 제 저서의 한 장으로 확장했습니다: Harness Engineering Guide: From Tools to Compounding Productivity.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기