
Airflow DAG Hash가 계속 바뀌는 문제: Variable.get()의 함정
- 1Airflow DAG Hash가 계속 바뀌는 문제: Variable.get()의 함정읽는 중
- 2Airflow Dynamic Task Mapping: 병렬 처리와 동시 실행 제한 전략
- 3Airflow "No host supplied" 해결 과정: hostname_callable부터 메모리까지
- 4Airflow DB 정리하는 법: db clean부터 VACUUM까지
- 5Airflow에서 15년 된 SQL Server 연결하기: pymssql 실패부터 Custom Hook까지
TTS 배치 DAG의 Task들이 간헐적으로 실패하기 시작했습니다. 30개짜리 Dynamic Task Mapping 중 앞쪽 13개는 성공하고, 뒤쪽 17개가 한꺼번에 실패하는 패턴이었습니다.
DAG 'tts_batch_dag' not found in serialized_dag table특이한 건 빠르게 끝나는 DAG에서는 재현이 안 되고, 처리량이 많아서 시간이 좀 걸리는 DAG에서만 터진다는 점이었습니다. 처음엔 Worker 리소스 문제인 줄 알았는데, 원인은 전혀 다른 곳에 있었습니다.
에러 상황: Task 실행 중 “DAG not found”
에러 로그를 좀 더 자세히 뜯어보면, Scheduler가 Task를 Worker에 넘기려는 순간 DAG를 찾지 못하고 있었습니다.
scheduler | ERROR - DAG 'tts_batch_dag' for task instance
<TaskInstance: ...generate_tts map_index=14 [failed]>
not found in serialized_dag table이상한 건 실패한 Task들의 상태였습니다. 성공한 Task에는 hostname=elastic이 찍혀 있는데, 실패한 Task는 전부 hostname=NULL, pid=NULL이었습니다. Worker에 도달조차 못 한 겁니다.
map_index 0-13: success ✓ (hostname=elastic)
map_index 14+: failed ✗ (hostname=NULL, pid=NULL)DAG 코드에 문법 에러가 있는 것도 아니고, Worker 리소스가 부족한 것도 아니었습니다. 혹시 DAG 자체에 뭔가 이상이 있나 싶어 serialized_dag 테이블을 직접 조회해봤습니다.
SELECT last_updated, dag_hash
FROM serialized_dag
WHERE dag_id = 'tts_batch_dag'
ORDER BY last_updated DESC LIMIT 10; last_updated | dag_hash
-------------------------------+----------------------------------
2025-12-09 00:48:24.773546+00 | 5005eb097fc4a58972cf320eac3dc6b7
2025-12-08 10:27:17.520069+00 | e5db60def7aaa5b89307378afb4ff06c
2025-12-08 10:14:12.194379+00 | fa0527965698f73c494e901d02ef2ecd
2025-12-08 10:13:40.324092+00 | b7b40eb57a6e41a0f1160a63551d337a
2025-12-08 10:13:08.285033+00 | 275b8935d0cb02eebbdbbe8c06bc2903
2025-12-08 10:12:29.201299+00 | 2718a0e66f733bc924c70a2ea4b95cfa
2025-12-08 10:11:57.912539+00 | 513c52b38417299fb49a3f48a5c4353e
2025-12-08 10:11:26.426727+00 | 176e3e5dafbbbbe537340a43f037f416
2025-12-08 10:10:54.996065+00 | 4f931b60bd4464284471031366dd84d6
2025-12-08 10:10:22.857835+00 | 0c2ede2f5b25e5a1d974fb7d7fe204e6DAG 파일을 전혀 수정하지 않았는데, dag_hash가 30초 간격으로 계속 바뀌고 있었습니다. 10개 행이 전부 다른 해시값입니다. 여기서 감이 왔습니다 — 이건 DAG 파싱 자체에 문제가 있는 겁니다.
원인 분석: DAG Serialization과 dag_hash의 역할
Airflow의 DAG 처리 흐름
왜 dag_hash가 바뀌면 Task가 실패하는 걸까요? 먼저 Airflow 3.x의 DAG 처리 구조를 짚고 넘어가겠습니다.
Airflow 3.x의 DAG 처리 파이프라인 — dag-processor가 30초마다 재파싱하며 dag_hash를 갱신한다
Airflow 3.x에서는 DAG 파일을 직접 읽는 게 아닙니다. dag-processor가 DAG 파일을 파싱하고 직렬화(serialize)해서 DB의 serialized_dag 테이블에 저장하면, scheduler가 이 DB에서 직렬화된 DAG를 읽어 Task를 스케줄링하고 worker가 실행하는 구조입니다.
여기서 핵심은 dag-processor가 기본 30초 간격으로 DAG 파일을 반복 파싱한다는 점입니다. 파싱할 때마다 DAG의 구조를 해시로 만들어 dag_hash에 저장하는데, DAG 파일이 변하지 않았다면 이 해시값은 항상 같아야 합니다.
dag_hash가 바뀌면 왜 Task가 실패할까?
문제는 DAG Run이 실행되는 도중에 dag_hash가 바뀔 때 발생합니다. 실제로 겪었던 시나리오를 시간순으로 재구성하면 이렇습니다.
09:45:24 DAG Run 시작 (dag_hash: c60abd28)
├── Task 0~13 실행 시작 → 정상 완료 ✓
09:45:55 dag-processor가 DAG 재파싱
└── 새로운 dag_hash 생성 (00f68c14) → DB 덮어쓰기
09:46:28 scheduler가 Task 14~29 실행 시도
├── Task들은 이전 dag_hash (c60abd28)를 참조
├── DB에는 새 dag_hash (00f68c14)만 존재
└── ❌ "DAG not found" → Task 전부 실패!DAG Run이 시작될 때의 dag_hash와, Task가 실행되려는 시점의 dag_hash가 달라져버린 겁니다. Scheduler 입장에서는 해당 DAG를 찾을 수 없으니 Task 실행을 포기하게 됩니다. 실행 시간이 짧은 DAG에서는 hash가 바뀌기 전에 모든 Task가 끝나버리니 문제가 안 되고, 오래 걸리는 DAG에서만 터졌던 이유가 바로 이겁니다.
근본 원인: DAG 파일 최상위의 Variable.get()
그러면 DAG 파일을 수정하지 않았는데 왜 dag_hash가 매번 달라졌을까요? 문제는 DAG 파일의 최상위 스코프에서 Variable.get()을 호출하는 코드에 있었습니다.
# ❌ DAG 파일의 최상위 스코프 — DAG parsing 시 매번 실행됨
from airflow.sdk import Variable
# DAG 함수 바깥에서 Variable.get() 호출!
gemini_manager = GeminiManager(
api_key=Variable.get("GOOGLE_API_KEY"), # ← 이게 문제
ftp_manager=ftp_manager,
)
@dag(...)
def tts_batch_pipeline():
result_batches = generate_tts.partial(
tts_manager=tts_manager, # 위에서 생성한 Manager 전달
).expand(...)Airflow의 dag-processor는 DAG 파일을 파싱할 때 파일의 모든 최상위 코드를 실행합니다. 즉, Variable.get("GOOGLE_API_KEY") 호출이 30초마다 반복되는 셈입니다.
3.x에서는 DAG를 직렬화할 때 Variable 접근 정보도 함께 포함시킵니다. 매번 Variable.get()이 호출되면서 직렬화 결과에 미세한 차이가 생기고, 결국 dag_hash가 매번 달라지는 것이었습니다.
Airflow 2.x에서는 DAG parsing 시 Variable/Connection 접근이 "권장하지 않음" 수준이었습니다. 하지만 Airflow 3.x에서는 사실상 금지입니다. Execution API 도입으로 DAG parsing과 Task execution의 분리가 더 엄격해졌고, parsing 시점에 외부 상태에 접근하면 dag_hash가 불안정해져 Task 실패로 이어집니다.
해결: Variable.get()을 Task 실행 시점으로 이동
해결 방법은 명확합니다. DAG 파일 최상위에서 Variable.get()을 호출하는 코드를 Task 함수 내부로 옮기면 됩니다.
# ✅ After: DAG 파일에서는 Manager를 생성하지 않음
@dag(...)
def tts_batch_pipeline():
result_batches = generate_tts.partial(
requests_url=..., # 정적 데이터만 전달
).expand(...)# ✅ Task 파일: 실행 시점에만 Variable 접근
@task
def generate_tts(requests_url, batch_idx, batch_size):
# Task 실행 시에만 Variable 접근!
from airflow.sdk import Variable
api_key = Variable.get("GOOGLE_API_KEY")
gemini_manager = GeminiManager(api_key=api_key, ...)
# 작업 수행
return run_generate_tts(...)핵심은 Variable.get()을 @task 데코레이터가 붙은 함수 안으로 옮긴 것입니다. 이렇게 하면 dag-processor가 DAG를 파싱할 때는 Variable에 접근하지 않으므로, 직렬화 결과가 항상 동일해집니다.
수정 배포 후 DB를 다시 열어보는 순간 속이 시원했습니다. 차이가 확연합니다.
-- Before: dag_hash가 매번 다름
10:10:22 - 0c2ede2f5b25e5a1d974fb7d7fe204e6
10:10:54 - 4f931b60bd4464284471031366dd84d6
10:11:26 - 176e3e5dafbbbbe537340a43f037f416
-- After: dag_hash가 고정!
10:27:17 - e5db60def7aaa5b89307378afb4ff06c
10:28:00 - e5db60def7aaa5b89307378afb4ff06c ✓
10:29:00 - e5db60def7aaa5b89307378afb4ff06c ✓Before (❌)
DAG Parsing (30초마다) → Variable.get() 호출 → dag_hash 매번 변경 → DB 저장 → Task 실패
After (✅)
DAG Parsing (30초마다) → Variable 접근 없음 → dag_hash 항상 동일 → DB 저장
Task 실행 (필요할 때만) → Variable.get() 호출 → Manager 생성 → 작업 수행
DAG Parsing vs Task Execution: 반드시 알아야 할 규칙
이 문제의 본질은 “DAG 파싱 시점에 실행되는 코드”와 “Task 실행 시점에 실행되는 코드”의 구분입니다. Airflow를 처음 쓸 때 가장 헷갈리는 부분이기도 합니다.
| 구분 | DAG Parsing | Task Execution |
|---|---|---|
| 발생 시점 | 30초마다 (계속) | Task 실행 시 (1회) |
| 실행 위치 | dag-processor | Worker |
| 목적 | DAG 구조 파악 | 실제 작업 수행 |
| Variable 접근 | ❌ 금지 | ✅ 허용 |
| Connection 접근 | ❌ 금지 | ✅ 허용 |
| 외부 API 호출 | ❌ 금지 | ✅ 허용 |
2.x에서는 DAG parsing 시 Variable 접근이 가능했습니다. 권장하지는 않았지만 동작은 했고, dag_hash도 비교적 안정적이었습니다. 하지만 3.x의 Execution API 도입 이후 DAG parsing과 Task execution이 완전히 분리되면서, parsing 시점의 외부 상태 접근이 곧바로 dag_hash 불안정으로 이어지게 됐습니다.
피해야 할 패턴
DAG 파일의 최상위 스코프(즉 @task 바깥)에서 절대 하면 안 되는 것들을 정리하면 이렇습니다.
# ❌ DAG parsing 시 실행되는 최상위 코드들 — 전부 문제!
api_key = Variable.get("API_KEY") # DB 조회 → 직렬화에 포함
conn = BaseHook.get_connection("my_conn") # DB 조회 → 직렬화에 포함
start_date = datetime.now() # 매번 다른 값 → 직렬화 결과 변경
data = requests.get("https://api.com/...") # 외부 API → 응답이 직렬화에 포함될 수 있음
config = json.load(open("/tmp/config.json")) # 파일 내용 변경 시 hash 변경이 코드들은 전부 @task 함수 안으로 옮겨야 합니다. DAG 파일의 최상위에는 정적 정의만 있어야 합니다.
# ✅ DAG 파일: 정적 정의만
@dag(
schedule="@daily",
start_date=datetime(2025, 1, 1), # 고정값은 OK
catchup=False,
)
def my_pipeline():
result = my_task.partial(
static_param="value", # 정적 값만 전달
).expand(...)
# ✅ Task 파일: 동적 리소스는 여기서
@task
def my_task(static_param):
from airflow.sdk import Variable
api_key = Variable.get("API_KEY")
conn = BaseHook.get_connection("my_conn")
manager = MyManager(api_key, conn)
return manager.process(static_param)from airflow.sdk import Variable 같은 가벼운 import는 파일 최상단에 둬도 괜찮습니다. 하지만 pandas, numpy, tensorflow 같은 무거운 라이브러리는 top-level에서 import하면 30초마다 파싱할 때마다 로딩 비용이 발생합니다. 공식 문서에서도 무거운 import는 Task 함수 내부로 옮기는 것을 권장하고 있습니다.
- DAG 파일의 최상위 스코프에서
Variable.get(),Connection.get(),datetime.now()등을 호출하면dag_hash가 매번 달라진다 dag_hash가 DAG Run 도중에 바뀌면 scheduler가 나머지 Task를 찾지 못해 "DAG not found"로 실패한다- 해결: 동적 리소스 접근을
@task함수 내부로 이동. DAG 파일에는 정적 정의만 둔다 - 진단:
SELECT dag_hash FROM serialized_dag로 hash가 고정되어 있는지 확인
마치며
디버깅 과정에서 가장 도움이 됐던 건 serialized_dag 테이블을 직접 조회해본 것이었습니다. dag_hash가 고정되어 있는지 확인하는 것만으로도 이 유형의 문제를 빠르게 진단할 수 있으니, Airflow에서 원인 모를 Task 실패가 발생하면 가장 먼저 확인해볼 만한 포인트입니다.
다음 글에서는 이번에 언급한 Dynamic Task Mapping의 병렬 처리 제한 전략을 다뤄보겠습니다.