Airflow Self-Healing Agent 회고(aka. Stuck Dag)

realBro·2026년 6월 12일

회고

목록 보기
1/2

왜 시작했는가?

거창한 제목을 가지고 있지만 시작은 거창한 문제를 해결하기 위해 시작한 건 아니였다.
당시 우리 환경에서 Airflow 운영시 가장 귀찮은 문제 중 하나는 Stuck DAG 였다.
(당시는 그냥 Zombie Pod 정도로 인식했던 것 같다)

실패한 task는 그래도 알기 쉽다. Slack 알람이 오고, 중요한 DAG의 경우 On-call Engineer에게 알람이 한 번 더 간다. 실패 로그를 확인하면 웬만하면 바로 수정도 가능하다.
근데 문제는 RUNNING 상태인데 아무것도 안 하는 task 였다.

어느 날 Airflow 웹서버에서 RUNNING 중인 DAG들을 보다가, 유독 오래 실행 중인 DAG 하나가 눈에 들어왔다.
확인해보니 실제론 거의 멈춰 있었다. 로그도 안 찍히고 있었고, 결국 사람이 들어가 상태를 보고 다시 실행해야 했다.

당시 다른일도 많아서 처음엔 아주 단순하게 접근했다.

“일단 오래 돌고 있는 DAG를 빨리라도 알자”

그렇게 long_time_running_dag_alert라는 DAG를 만들었다.

지금 보면 상당히 러프한 방식이었다. 근데 여기서 또 애매한 문제가 생긴다.
오래 돈다는 기준이 뭐지? 1시간? 2시간? 아니면 6시간?

DAG마다 실행 시간도 다르고, 어떤 건 원래 오래 돌기도 한다.

당시엔 이런 기준을 정교하게 잡을 여유도 없었고, 일단은 빠르게 만들고 운영하면서 보자는 쪽으로 갔다.

확인해 봤을때 가장 오래 도는 dag가 4시간 정도 도는 상황었다. 그래서 3시간마다 도는 DAG 하나를 만들고, 6시간 이상 RUNNING 상태인 DAG가 있으면 Slack 알람을 보내는 방식 이었다.

지금 생각하면 상당히 crude한 방식이다.
그래도 당시엔 “문제되고 하루 안에는 알게 됐잖아?” 정도의 느낌으로 넘어갔던 것 같다.

근데 운영하다보니 또 다른 문제가 생겼다.
결국 알람만 빨리 온다고 해결되는 건 아니었다.
Slack 알람이 와도 결국 사람이 들어가서 확인해야 했다.

이거 진짜 stuck인가?
그냥 오래 도는 DAG인가?
retry 해도 되는 건가?
조금만 기다리면 끝나는 건가?

결국 사람이 하는 일은 크게 달라지지 않았다.

Airflow UI 들어가고, 로그 보고, 이전 실행 이력 확인하고, “대충 이런 케이스겠지” 판단하고 다시 실행하는 작업이 계속 반복됐다.

그 즈음이었던 것 같다.

마침 LLM도 꽤 잘 나오기 시작했고, pipeline에 agent를 붙여본다거나 운영 자동화 이야기도 조금씩 보이기 시작했다.

그래서 자연스럽게 이런 생각을 했던 것 같다. “이 정도 판단은 AI가 대신 해줄 수 있지 않을까?”
물론 처음부터 “AI가 장애를 자동 해결하게 만들자” 같은 생각은 아니었다.

오히려 사람이 반복적으로 하고 있는 운영 판단을 조금 덜 하게 만들자 에 가까웠다.
그리고 그렇게 시작한 프로젝트에 꽤 거창한 이름을 붙였다. Self-Healing Agent!

Self-Healing Agent라는 거창한 이름을 붙였지만

뭔가 이름만 보면 Airflow가 스스로 장애를 인식하고, 원인을 분석하고, 알아서 복구하는 미래 같은 느낌이다.

근데 실제로 AI가 하는 일은 그렇게 많지 않았다. 당시 내가 기대했던 건 생각보다 단순했다.

로그를 읽고, 이정도만 판단해줘도 충분하다고 생각했다.

“이건 retry 해도 되는 실패인가?”
“조금 기다리면 끝나는 건가?”
“아니면 사람을 불러야 하는가?”

왜냐면 실제 운영에서도 사람이 하는 일이 딱 그 정도였기 때문이다.

그래서 구조도 생각보다 보수적으로 잡았다. LLM이 retry 하라고 판단했다고 바로 실행하지 않게 했다. 지금 생각해보면 오히려 AI를 믿지 않는 방향으로 설계가 계속 추가됐던 것 같다.

예를 들면:

  • doc_md에 재수행 가능 여부가 명시되어 있어야 함
  • confidence가 낮으면 그냥 사람에게 넘김
  • dry-run을 기본값으로 둠
  • retry 횟수 제한
  • 애매하면 그냥 NOTICE 처리

결국 LLM의 판단은 “결정”이 아니라 “제안” 에 가까웠다.

이 프로젝트를 만들면서 제일 오래 쓴 코드도 사실 AI 호출부가 아니라, AI가 이상한 짓 못하게 막는 코드 였다.

구현도 최대한 단순하게 가져가려고 했다. LangChain, LangGraph, MCP 같은 얘기들이 꽤 많았는데, 이런 프레임워크들은 일부러 쓰지 않았다. 그냥 FastAPI 서버 하나 였다.

Airflow에서 이벤트를 보내면, 로그 읽고, LLM 한번 태우고, rule 기반 guardrail 거친 다음 Slack에 결과 남기고 필요하면 retry 하는 구조. 지금 생각해도 이 선택은 괜찮았던 것 같다.

괜히 추상화가 많은 프레임워크를 올리기보다, 실제로 어떤 판단이 이뤄지는지 코드 레벨에서 바로 보이는 구조가 운영 자동화에는 더 맞았던 것 같다.

물론 나중에 Human-In-the-loop구조가 꼭 필요하다고 판단되면 붙일 수도 있겠지만, 적어도 이 지금까진 “단순하게 만들고, 명확하게 동작하게 하자” 가 더 중요했다.

결국 입력이 중요하다

처음엔 꽤 순진하게 생각했던 것 같다.
로그 읽고 retry 여부 정도 판단하는 건 생각보다 금방 될 줄 알았다.
근데 당연한거지만, 모델 성능보다 입력이 더 문제였다.

로그가 이상하면 판단도 이상해진다

분명 dbt 쪽 로직 문제였는데, 원인을 네트워크 이슈처럼 판단한다거나 엉뚱한 retry recommendation이 나오기도 했다. 처음엔 “모델이 멍청한가?” 싶었다.
근데 원인을 까보니 문제는 LLM이 아니었다.

로그였다.

당시 Kubernetes 환경에서 실행되던 task 로그에는 V1Pod metadata가 꽤 길게 붙는 경우가 있었는데, 이게 로그 끝부분을 오염시키고 있었다.

문제는 우리가 로그 전체를 LLM에 보내고 있지 않았다는 점 이었다.

당연한 이야기지만 Airflow task 로그는 길다.

몇 MB 넘어가는 경우도 있고, dbt처럼 verbose한 로그는 그냥 다 보내면 token 비용도 감당 안 되고 latency도 길어진다.
그래서 처음엔 단순하게 “마지막 N자만 보내자” 로 구현했다.

지금 기준으론 꽤 naive한 방식이었다.

근데 문제는 에러 traceback은 잘려 나가고, 대신 V1Pod(...) 같은 metadata가 tail 영역을 먹어버린다는 점이었다.
LLM 입장에선 정작 중요한 에러는 못 보고 있었다.

그러니까 모델이 이상하게 판단한 게 아니라, 쓰레기 입력을 열심히 분석하고 있었던 것 이다.

이후부터 로그 extraction 로직을 꽤 많이 손봤다.

단순히 tail만 보내는 게 아니라 다음과 같은 방식으로 바뀌었다.

  • Kubernetes metadata 제거
  • traceback이나 ERROR, Exception 라인 우선 추출
  • 마지막 로그 일부 + 중요 error line 조합
  • token 제한 안에서 최대한 root cause 근처 로그 우선 전달

지금도 완벽하다고 생각하진 않는다.

다만 운영에서 LLM 붙일 때 중요한 건, 모델 자체보다 결국 어떤 로그를 넣느냐에 더 가까웠던 것 같다.

오작동 막기

retry recommendation 정도 잘 나오면 금방 자동 retry도 붙이고, 꽤 빠르게 운영 자동화가 될 줄 알았다.
근데 만들수록 생각이 조금 바뀌었다.
운영 자동화에서 제일 무서운 건 “안 되는 것” 보다 “이상하게 되는 것” 이었다.

안 되는 건 티라도 난다.
로그를 보면 에러가 있고, Slack 알람도 오고, 결국 사람이 보면 알 수 있다.
근데 잘못 동작하는 건 생각보다 늦게 발견된다.

retry 하면 안 되는 task를 retry 한다거나, 같은 알람이 계속 울린다거나, 애매한 상황인데 너무 공격적으로 판단하는 경우들. 이런 건 오히려 운영을 더 피곤하게 만들 수도 있다.

그래서 프로젝트 방향도 조금 바뀌기 시작했다.

처음엔 “자동 retry” 쪽에 더 가까웠다면, 점점 “안전하게 자동화하기” 쪽으로 무게가 이동했다.
지금 생각해보면 prod에서 실제 retry를 바로 켜지 않은 것도 꽤 괜찮은 선택이었다.

판단은 하되, 실제 액션은 하지 않는 상태로 꽤 오래 운영했다.(dry_run)

덕분에 “이 상황에서 실제 retry를 했으면 어땠을까?” 를 꽤 많이 관찰할 수 있었다.

예를 들어 dbt unique test 실패 같은 건 retry를 해도 해결되지 않는다.
로그에서 실패 내역 및 재수행시 성공 내역만 보면 재수행하면 된다고 판단 할 수도 있다.
실제로는 데이터 클렌징을 진행하고 재수행 했을테니까 말이다.

그러나 데이터 자체가 잘못 들어온 상황이라 데이터 클렌징을 하기 전엔 다시 돌려도 똑같이 실패한다.
만약 이런 걸 바로 자동 retry 했다면 Airflow worker 자원만 더 쓰고, 결국 사람은 더 큰 로그를 나중에 다시 보게 됐을 수도 있다.

그래서 이후부턴 이런 케이스를 명시적으로 retry 불가 범주로 넣기 시작했다.

예를 들어 dbt 데이터 품질 테스트 실패(unique_test, not_null_test 등), PK/FK/UNIQUE constraint 위반, MERGE/UPSERT 중복 오류 같은 건 retry 불가 케이스 로 분류했다.

결국 중요한 건 “retry가 가능한 실패인가” 였지, 단순히 “예전에 retry 후 성공한 적 있는가” 가 아니었다.

적어도 이제 왜 멈췄는진 보고 싶었다

여기까지 진행 되었을땐 나름 방어선이 꽤 생긴 상태였다.

장시간 실행 DAG 감지, Slack 알람, retry recommendation, 자동 재실행 정책까지.

이후 장기실행 감지 방식도 조금씩 고도화했다.

배치 주기를 줄이고, 단순 threshold 대신 평소 실행 시간 기준 이상치 탐지 를 섞었다.
그리고 agent 쪽에서는 단순히 “오래 돌고 있다” 만 보는 게 아니라, 로그가 멈췄는지, 종료 흔적이 있는데도 RUNNING 상태로 남아있는지 같은 신호도 같이 보기 시작했다.

적어도 “왜 아무도 모르고 몇 시간씩 지나가는 상황” 은 없어졌다고 생각했다.

그러다 자연스럽게 다음 고민이 생겼다. 근데 도대체 왜 stuck 되는 걸까?

처음엔 단순히 Zombie Pod 문제라고 생각했다.

KubernetesPodOperator를 쓰고 있었고, 실제로 Airflow UI에서는 task가 계속 RUNNING 상태였다.

마침 Airflow 2.8 쪽에 비슷한 이슈가 있다는 얘기도 있었고, 실제로 다른 분이 Airflow를 2.10으로 올리기도 했다.

근데 이상하게도 계속 발생했다. 그래서 조금 더 파보기 시작했다.

확인해보니 우리가 처음 생각한 것처럼 pod가 zombie처럼 남아있는 문제가 아니었다.

inner pod는 이미 정상 종료되어 있었다. 문제는 Airflow 쪽 task process였다.

Airflow 입장에서는 task가 계속 RUNNING 상태였지만, 실제 task process는 더 이상 진행되지 않고 있었다. 더 헷갈렸던 건 heartbeat는 살아있었다는 점이다.

Airflow의 zombie reaper는 보통 heartbeat가 끊긴 task를 보고 zombie로 판단한다. 근데 이 케이스는 heartbeat를 보내는 쪽은 살아있었고, 실제 일을 하던 쪽만 멈춰 있었다.

그러니까 Airflow 입장에선 죽은 task가 아니었다.

그냥 계속 RUNNING인 task였다.

우리가 처음에 Zombie Pod라고 부르던 건 정확히는 pod 문제가 아니라, Airflow task process가 멈춰 있는 stuck task 문제 였다.

이때부터 원인을 조금 더 아래 레이어까지 보기 시작했다.

결국 문제는 StandardTaskRunner 의 fork 기반 실행 방식과 gRPC 조합에 있었다.

당시 task 내부에서 Secret Manager, GCS XCom backend 같은 경로를 통해 gRPC client가 사용되고 있었는데, fork 이후 특정 상황에서 task process가 멈추는(deadlock) 문제가 발생하고 있었다.

그래서 먼저 StandardTaskRunner 에서 fork 방식의 분기를 안타도록 변경했다.

fork 대신 새로운 process를 exec 방식으로 띄우는 구조라, 적어도 fork 이후 상태 꼬임 자체를 원천적으로 없애는 방향 이었다.

그리고 여기서 끝난 줄 알았는데, 한 번 더 비슷한 문제가 나왔다.

ExecTaskRunner로 바꾸면서 fork 문제는 제거했다.
fork된 자식 프로세스가 gRPC 내부 lock을 이상하게 물고 멈추는 문제는 구조적으로 막은 셈이었다.

근데 이후에도 비슷하게 task가 멈춰 있는 케이스가 다시 보였다.

처음엔 당연히 “아직 fork 문제가 남아있나?” 싶었다.

근데 이번엔 달랐다.

프로세스를 확인해보니 fork된 자식이 없었다.
task는 exec 방식으로 새 interpreter에서 뜨고 있었다.

즉, 이전에 해결했던 fork deadlock과는 다른 문제였다.

조금 더 보니 이번엔 lifeguard라는 gRPC 내부 thread가 CPU를 계속 사용중이었다.

이름만 보면 뭔가 지켜주는 thread 같지만, 실제로는 gRPC EventEngine thread pool을 관리하는 thread다. 채널을 닫을 때 thread pool이 정상적으로 종료됐는지 확인하는 역할을 한다.

문제는 당시 사용하던 grpcio 1.67.1 버전에 이 종료 확인 루프 쪽 버그가 있었다는 점이다.

채널을 닫는 과정에서 thread pool이 완전히 종료되기를 기다리는데, lifeguard가 “끝났나?”를 계속 확인하면서 쉬지 않고 도는 상태가 됐다.

잠든 deadlock이 아니라, 깨어서 계속 도는 busy-spin에 가까웠다.

그래서 증상도 이전과 조금 달랐다.

이전 fork 문제는 lock을 기다리며 멈춰 있는 느낌이었다면, 이번엔 gRPC 내부 thread 하나가 CPU를 계속 쓰면서 종료 절차가 끝나지 않는 상태였다.

겉으로 보면 둘 다 Airflow task가 RUNNING 상태로 남는다.

supervisor는 살아있어서 heartbeat도 계속 찍힌다.
그래서 Airflow 입장에서는 여전히 zombie로 보지 않는다.

하지만 실제 task는 더 이상 앞으로 가지 않는다.

결국 같은 “stuck task”처럼 보이지만, 원인은 두 개였다.

첫 번째는 fork + gRPC deadlock.
두 번째는 grpcio EventEngine lifeguard spin.

그래서 단순히 task runner만 바꾸는 것으로는 끝나지 않았다.

grpcio 자체를 수정 버전 이상으로 올려야 했다.

문제는 우리가 쓰던 Python 3.8 라인에서는 수정된 grpcio wheel을 그대로 가져가기 어려웠다는 점이다. lifeguard spin fix가 들어간 공개 릴리스는 Python 3.9 이상 라인부터 쓸 수 있었다.

결국 grpcio만 올리는 작업이 아니라, Python runtime도 같이 올리는 작업이 됐다.

그래서 최종적으로 Python 3.11과 grpcio 1.80.0으로 올렸다.

처음엔 “stuck DAG를 빨리 알자”에서 시작했는데, 결국 task runner를 바꾸고 Python runtime과 grpcio 버전까지 올리게 됐다.

그래서 남은 것

지금도 이 시스템이 완벽하다고 생각하진 않는다.
그리고 Agent라고 부르기엔 Agentic하지도 않다.

LLM이 장애를 완벽하게 이해하는 것도 아니고, 여전히 애매한 상황은 많다. 그래서 판단은 꽤 보수적으로 한다. 애매하면 사람에게 넘기고, retry 가능한 조건도 제한하고, 로그 extraction도 계속 손보고 있다.

그렇다고 아무것도 안 남은 건 아니다.

생각보다 운영에서 반복되던 일들은 꽤 줄었다. 장시간 실행 DAG 감지, Slack 알람에서 원인 추정과 retry recommendation, 자동 재실행, JIRA 생성까지. 예전엔 사람이 로그 열어보고, 상태 확인하고, retry 하고, 티켓 남기던 일들을 어느 정도 시스템 안으로 넣었다.

완벽한 self-healing은 아니었다. 근데 적어도 “장애가 났을 때 어디서부터 봐야 하지?” 는 많이 줄었다고 생각한다.

그리고 이번 일을 하면서 가장 크게 느낀 건, 운영에서 중요한 건 자동화 자체보다 재발해도 버틸 수 있는 구조를 만드는 것 과 근본 원인을 없애는 것 이라는 점이었다.

장시간 실행 감지, 로그 기반 recommendation, 자동 재실행 같은 방어선을 만들면서 예전처럼 몇 시간 지나고 나서야 stuck task를 발견하는 일은 많이 줄었다. 동시에 stuck task 자체가 왜 생기는지를 계속 파고들면서 결국 Airflow의 Python 버전까지 변경하게 됐다.

돌아보면 이 프로젝트는 “AI가 알아서 운영해주는 시스템” 을 만든 이야기보다는, 운영자가 반복적으로 하던 판단을 조금 자동화하고, 재발해도 덜 고생하는 구조를 만들고, 가능하면 근본 원인을 제거해나간 과정 에 더 가까웠던 것 같다.

그리고 생각보다 그 과정에서 제일 오래 본 건 LLM output이 아니라 Airflow source code, thread dump, grpc release note 였다.

그래도 결과적으로는, 예전보다 조금 덜 피곤하게 장애를 볼 수 있게 된 것 같다.

0개의 댓글