TL;DR
- 증상: 새 코드를 배포했는데 실행 시 새 코드와 옛 코드가 랜덤하게 실행
- 처음 의심: pycache의 stale 바이트코드
- 진짜 원인: 배포 스크립트가 파일 복사(replace) 방식이라 rename된 옛 파일이 그대로 잔존
→ Airflow가 동일 dag_id 파일 2개 중 랜덤 선택
- 해결: rsync --delete로 동기화 + (예방) PYTHONDONTWRITEBYTECODE=1
이런 분께: Airflow를 docker-compose로 운영하면서 DAG 배포 후 이상한 동작을 본 적 있는 분
상황 (Context)
- 환경: EC2 + docker-compose Airflow, dags 디렉토리는 호스트 볼륨 마운트
- 배포 방식: Jenkins → docker stop → rm → run. dags 디렉토리는 파일 단위 복사(replace) 방식으로 갱신
- 시점: 특정 DAG의 호출 방식을 SSH → curl 도메인 으로 리팩토링한 직후
마주한 문제
배포 직후 Airflow UI에서는 새 코드(curl)가 정상적으로 표시됐다. 그런데 DAG를 실제로 돌려보면:
- 어떤 실행에서는 curl 방식으로 정상 동작
- 다른 실행에서는 옛날 SSH 방식으로 동작 (대부분 실패)
- 같은 DAG가 매번 다른 코드로 도는 것처럼 보임
UI는 새 코드를 보여주는데 실행은 옛 코드 — 이게 가장 헷갈리는 지점이었다.
가설과 시도 — what didn't work
가설 1. Python 바이트코드 캐시(pycache)
docker stop → rm → run 으로 컨테이너는 새로 뜨지만, 호스트 볼륨에 마운트된 pycache의 .pyc 파일은 유지된다는 점이 의심됐다. 컨테이너 재시작이 파이썬 import 캐시는 비워줘도, 디스크의 .pyc 자체는 그대로 남으니까.
확인 명령:
# .pyc 존재 확인
find /opt/airflow/dags -name "*.pyc" | wc -l
# .pyc 안에 옛날 코드(SSH) 흔적이 남아있는지
strings /opt/airflow/dags/__pycache__/<dag>.cpython-*.pyc | grep -i "ssh"
→ 결과: .pyc 안에 SSH 문자열이 없거나, .pyc 자체가 없는 경우도 있었음. 이 가설로는 "랜덤하게 두 코드가 번갈아 실행되는" 현상이 설명되지 않았다. .pyc가 stale이면 항상 옛 코드가 나와야지, 둘 사이를 오갈 이유가 없다.
가설 2. scheduler/worker 메모리 캐시
scheduler·worker 컨테이너가 옛 모듈을 들고 있는 것 아닐까? 했지만 컨테이너를 완전히 재시작하면 파이썬 인터프리터 자체가 새로 뜬다. 그런데도 옛 코드가 나온다는 건 디스크 어딘가에 옛 코드가 살아있다는 뜻.
이 시점에 dags 디렉토리를 직접 들여다봤다.
원인 분석
dags 디렉토리에 동일한 dag_id를 가진 파일이 2개 존재했다.
리팩토링하면서 파일을 rename한 게 있었다:
a.py → a-1.py
문제는 배포 스크립트가 파일 단위 복사(replace) 방식이었다는 점:
- 신규 파일 → 생성 ✓
- 동일 경로의 기존 파일 → 덮어쓰기 ✓
- 삭제/rename으로 사라진 파일 → 처리 안 함 → 그대로 잔존 ✗
배포 후 dags 디렉토리 실제 상태:
dags/
a.py ← 삭제됐어야 하지만 그대로 남음 (옛 SSH 코드)
a-1.py ← 새로 생성됨 (새 curl 코드)
두 파일이 같은 dag_id를 선언하고 있었기 때문에:
- Airflow는 dag_id 중복을 silent하게 처리한다. 에러를 띄우지 않고
- 실행 시 둘 중 하나를 랜덤하게 선택해서 돌린다.
이게 "매번 다른 코드로 실행"의 정체였다. pycache는 무관했다.
해결
배포 스크립트가 dags 디렉토리를 동기화(sync) 방식으로 갱신하도록 변경.
rsync로 동기화
rsync -av --delete ./dags/ /home/ubuntu/airflow/dags/
--delete: source에 없는 파일은 destination에서도 삭제. 이게 핵심.
또는 디렉토리 완전 교체
rsync를 못 쓰는 환경이면:
rm -rf /home/ubuntu/airflow/dags/*
cp -r ./dags/* /home/ubuntu/airflow/dags/
배포 후 ls dags/로 의도한 파일만 있는지 한번 확인하면 더 안전.
DAG 작성 방법 변경 — rename 자체를 없애기
운영 측 해결(rsync --delete)에 더해, 코드 작성법 자체를 바꿔서 rename이 발생하지 않게 만드는 것이 근본적 예방이다.
기존엔 test_beta.py, test_live.py 처럼 환경·실행시각별로 파일을 분리하다 보니, 시각이 추가되거나 환경이 바뀔 때마다 파일이 새로 생기거나 rename되곤 했다. 이번 사고도 거기서 비롯됐다. 이걸 test.py 한 파일 안에서 리스트로 환경·시각·dag_id를 관리하도록 바꾸면 파일명 자체가 바뀔 일이 사라진다.
# (dag_id, schedule_interval(UTC), env)
dag_configs = [
# KST: 07:00 월~토 → UTC: 0 22 * * 1-6
("daily_report_0700_beta", "0 22 * * 1-6", "BETA"),
("daily_report_0700", "0 22 * * 1-6", "LIVE"),
# KST: 08:00 월~토 → UTC: 0 23 * * 1-6
("daily_report_0800_beta", "0 23 * * 1-6", "BETA"),
("daily_report_0800", "0 23 * * 1-6", "LIVE"),
# KST: 09:30 월~토 → UTC: 30 0 * * 1-6
("daily_report_0930_beta", "30 0 * * 1-6", "BETA"),
("daily_report_0930", "30 0 * * 1-6", "LIVE"),
]
이 리스트를 순회하면서 환경별·시각별로 DAG 객체를 동적으로 생성한다. 파일은 항상 test.py 하나로 유지되고, 새 스케줄이나 환경 추가는 리스트에 항목 한 줄 추가로 끝난다 — 파일 단위 복사 방식 배포에서도 정상적으로 replace 처리됨.
두 해결책은 상호 보완적: rsync --delete는 pycache 같은 다른 종류의 stale file도 막아주고, 코드 작성법 변경은 rename 빈도 자체를 낮춰 사고 발생률을 줄인다.
배운 점
1. Airflow는 dag_id 중복을 silent하게 처리한다.
다른 프레임워크와 달리 에러를 띄우지 않고 둘 중 하나를 랜덤 선택한다. UI에는 둘 중 한 버전만 노출되니까 더 헷갈림. 배포 후에는 dags 디렉토리에 의도한 파일만 남았는지 직접 확인하는 습관이 필요하다.
2. 배포 스크립트는 "동기화(sync)"여야 한다 — "복사(copy)"가 아니라.
파일이 추가/수정만 되는 워크로드라면 copy도 무방하지만, rename·삭제가 한 번이라도 발생하는 순간 함정이 된다. rsync --delete나 디렉토리 완전 교체가 정공법.
3. (예방) PYTHONDONTWRITEBYTECODE=1
이번 사건의 직접 원인은 아니었지만, pycache stale 바이트코드는 별도의 함정으로 분명히 존재한다. docker-compose에 환경변수 추가해서 .pyc 생성 자체를 막아두는 걸 권장.
environment:
&airflow-common-env
PYTHONDONTWRITEBYTECODE: "1"
기존 .pyc까지 정리하려면 배포 스크립트에:
find /home/ubuntu/airflow/dags -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null
댓글