롯데온이 Airflow 2.7.3에서 3.2.2로 전환한 뒤 약 두 달 뒤 새벽 배치가 JWT 403 오류로 멈췄다. 로그만 보면 JWT TTL 문제처럼 읽히지만 실제 원인은 호스트 OOM이었고, Airflow 3의 API Server 기반 아키텍처가 메모리 부족을 JWT 에러로 표현하는 구조가 함정이었다. Prefork 메모리 튜닝과 DAG Version 폭증 방지까지 적용한 뒤 호스트 usable이 10GB대로 안정됐다.
핵심 포인트- Airflow 3에서 긴 Task는 API Server로 Heartbeat를 보내 JWT를 갱신한다 — dag-processor SIGKILL이 API Server를 막으면 JWT 갱신이 실패하고 Worker는 JWT 만료 에러를 낸다
- 장애 흐름: 메모리 부족 → dag-processor child SIGKILL → API Server Heartbeat 공백 → JWT 갱신 불가 → Worker 403; JWT TTL은 원인이 아니라 결과였다
- Site A(안정)와 Site B(장애)의 차이: DAG 수 5배 + Site B의 datetime.now() 패턴이 파싱마다 dag_hash를 바꿔 DAG Version을 1만7천까지 폭증시켰다
- 메모리 튜닝 4가지: worker_max_tasks_per_child, MIN_FILE_PROCESS_INTERVAL=3600, gunicorn+uvicorn apiserver, Worker 컨테이너 주간 재시작
- datetime.now()를 Operator 인자에 넣으면 파싱할 때마다 Serialize hash가 바뀌어 Version+1 — Jinja 템플릿(logical_date, data_interval_end)으로 옮겨야 한다
- Airflow 3 업그레이드는 버전 교체만이 아니다 — 컨테이너 설정 최적화·DAG 작성 패턴·모니터링이 한 세트여야 정상 운영이 가능하다
상세 정리- 전환 배경: Amazon Linux 2 EOS로 EC2를 전면 교체하면서 Airflow도 함께 올렸다; Graviton(ARM64)으로 아키텍처까지 바꾸어 ARM64 전용 이미지와 바이너리를 미리 맞춰야 했다
- Airflow 3 아키텍처: Task가 MetaDB를 직접 접근하지 않고 API Server를 통해 정보를 얻고 Heartbeat+JWT로 갱신한다 — 이 구조가 메모리 부족을 JWT 에러로 표현하는 함정을 만든다
- 장애 당일 흐름: Site B 새벽 배치 구간, 호스트 usable memory 급감 → dag-processor child SIGKILL → API Server Heartbeat 공백 → Worker JWT 403 → 배치 중단; Datadog 메모리 그래프 확인 후 비로소 OOM으로 진단했다
- 컨테이너 RSS 분석: Worker(절대값 최대·배치 후 우상향), dag-processor(파싱 많을수록 증가·대량 로그), apiserver(Site B가 Site A보다 유의미하게 큼 — DAG Version 폭증이 원인); Prefork+cgroup 특성상 프로세스 RSS와 컨테이너 RSS가 다르게 보인다
- 튜닝 1 — Celery child 재활용: `worker_max_tasks_per_child=25`로 N개 task 처리 후 child 교체 → Private memory 회수; Prefork 환경 기본값으로 설정을 권장
- 튜닝 2 — dag-processor 파싱 간격: `MIN_FILE_PROCESS_INTERVAL=3600`(기본값 30초)으로 파싱 빈도 대폭 감소 → CPU·메모리 부담과 대량 로그 해소; 배포 후에는 `airflow dags reserialize` 수동 실행으로 즉시 반영
- 튜닝 3 — API Server gunicorn: `SERVER_TYPE=gunicorn`, `WORKERS=2`, `DAG_CACHE_SIZE=200`, `DAG_CACHE_TTL=7200`으로 apiserver 메모리 상한을 제어; Version 폭증이 근본 원인이므로 이 옵션은 안전장치 역할
- 튜닝 4 — Worker 컨테이너 재시작: Site A에 매주 화요일 오후 Cron(`stop_grace_period: 3600s`) 적용; 재시작 직후 0.5GB → 일배치 후 1.7GB 패턴 반복, 주간 재시작으로 장기 누적을 끊는 것이 목적
- DAG Version 폭증 원인: `ed_day = datetime.now().strftime(...)` 같이 파싱 시점의 날짜를 Operator 인자에 넣으면 파싱마다 dag_hash가 달라져 Version+1; Site B DAG 중 v17,000대까지 올라간 것이 있었다
- 해결책: Jinja 템플릿 교체 — `{{ logical_date.in_timezone("Asia/Seoul").subtract(days=1).format("YYYYMMDD") }}`는 파싱 시 렌더되지 않아 hash 고정; 실행 시각이 꼭 필요하면 @task 본문에서 호출하고 XCom으로 전달
- 결과: 양쪽 사이트 usable 10GB대 여유 확보; dag-processor·scheduler 메모리 대폭 감소; Site A는 Worker 컨테이너 주간 재시작 패턴이 그래프에서 확연히 보인다
왜 읽나Airflow 3으로 업그레이드했거나 계획 중인 데이터·인프라 엔지니어에게 JWT 에러 뒤에 숨은 OOM 진단법과 컨테이너/DAG 레벨 튜닝 실전 레퍼런스.