
Airflow "No host supplied" 해결 과정: hostname_callable부터 메모리까지
- 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들이 무더기로 실패했고, 실패한 Task의 task_instance 행에서는 hostname과 pid가 둘 다 NULL이었습니다. DAG Hash가 파싱 때마다 바뀌어 Task가 깨지던 문제를 잡은 뒤에도 대량 Task를 돌리면 같은 증상이 간헐적으로 다시 나타났는데, 이번에는 에러 메시지가 달랐습니다.
Could not read served logs: Invalid URL
'http://:8793/log/dag_id=tts_batch_dag/run_id=.../task_id=.../map_index=51/attempt=1.log':
No host supplied로그 URL에서 hostname 자리가 비어 있습니다. http://:8793/...은 그 Task가 어느 호스트에서 돌았는지 Airflow가 모른다는 뜻입니다. 자연스럽게 hostname 설정을 의심했고, 거기서부터 꽤 긴 삽질이 시작됐습니다.
에러 상황: hostname=NULL인 Task들
실패한 Task들의 공통점은 task_instance 테이블에 hostname이 저장되지 않았다는 점입니다.
| map_index | 상태 | hostname | pid |
|---|---|---|---|
| 0 ~ 33 | success | elastic |
12345 |
| 34 이상 | failed | NULL |
NULL |
성공한 Task에는 hostname과 pid가 정상적으로 찍혀 있는데, 실패한 Task는 둘 다 NULL입니다. 그리고 매번 다른 번호에서 끊겼습니다. 어떤 때는 34번째, 어떤 때는 28번째였습니다.
여기서 두 컬럼이 언제 채워지는지를 짚고 넘어갈 필요가 있습니다. Airflow 3.x에서 Task 실행 프로세스는 메타데이터 DB에 직접 쓰지 않습니다. Task SDK가 Task Execution API로 상태를 보고하고, API 서버가 대신 DB에 씁니다.
두 컬럼이 NULL이라는 건 이 사슬이 네 번째 칸에 닿기 전에 끊겼다는 뜻입니다. 값을 잘못 구한 게 아니라 값을 실어 나를 API 호출 자체가 없었다는 신호입니다. 이 사실을 알아채기까지 세 번을 헤맸습니다.
삽질 1: hostname_callable 변경 → 효과 없음
hostname이 비어있으니 가장 먼저 의심한 건 hostname_callable 설정이었습니다. 이 설정은 [core] 섹션에 있고 기본값은 airflow.utils.net.getfqdn입니다.
# airflow.cfg 기본값
[core]
hostname_callable = airflow.utils.net.getfqdnairflow.utils.net.getfqdn은 표준 라이브러리의 socket.getfqdn()을 Airflow가 손본 버전입니다. 이름이 비어 있으면 socket.gethostname()으로 채운 다음 getaddrinfo(..., AI_CANONNAME)로 정식 이름을 찾습니다. 컨테이너 환경에서 이 이름이 실제로 도달 가능한 주소와 다르게 나오는 사례는 여러 번 보고됐고, 같은 “No host supplied” 문구가 찍힌 GitHub 이슈도 있었습니다(다만 그쪽은 Airflow 2.10.1에 CeleryExecutor 조합이라 환경이 다릅니다).
공식 문서가 IP를 쓰고 싶을 때의 대안으로 안내하는 함수로 바꿔봤습니다.
# 문서가 안내하는 IP 기반 대안
hostname_callable = airflow.utils.net.get_host_ip_address결과: 효과 없음. 여전히 대량 Task에서 같은 패턴으로 실패했습니다. hostname_callable을 되돌렸습니다.
사실 이 교체는 기대만큼의 변화를 줄 수 없는 것이었습니다. get_host_ip_address의 구현은 socket.gethostbyname(getfqdn())이라, 이름 해석을 건너뛰는 게 아니라 getfqdn()이 내놓은 이름을 한 번 더 IP로 바꿀 뿐입니다.
삽질 2: jwt_secret 고정 → 효과 없음
hostname_callable을 바꿨을 때 로그 조회에서 별도 에러가 나길래, Airflow 3.x의 내부 API 인증(JWT)과 hostname이 연관되어 있다는 글을 보고 [api_auth] jwt_secret도 고정해봤습니다. 컨테이너마다 시크릿이 다르면 Task Execution API 호출이 인증에서 막힐 수 있으니 아주 엉뚱한 가설은 아니었습니다.
# docker-compose.yaml
AIRFLOW__API_AUTH__JWT_SECRET: "fixed-secret-key"결과: 역시 효과 없음. 시크릿을 넣든 빼든 “No host supplied” 에러는 동일하게 발생했습니다. 이것도 되돌렸습니다.
이 시점에서 “hostname 문제가 아닐 수도 있다”는 생각이 들기 시작했습니다.
삽질 3: DB Connection Pool 증가 → 효과 없음
다음 의심은 DB connection pool이었습니다. [core] parallelism 기본값은 32인데 [database]의 커넥션 풀 기본값은 sql_alchemy_pool_size = 5에 sql_alchemy_max_overflow = 10, 합쳐서 동시 15개입니다. Task가 한꺼번에 몰리면 hostname 저장 자체가 밀릴 수 있겠다고 판단했습니다.
# 커넥션 풀을 넉넉하게
[database]
sql_alchemy_pool_size = 20
sql_alchemy_max_overflow = 20결과: 효과 없음. pool을 넉넉하게 잡아도 같은 위치에서 실패했습니다. 되돌렸습니다.
진짜 원인: Scheduler 메모리 부족
세 번의 삽질을 거치고 나서야 docker stats를 실시간으로 띄워놓고 DAG를 돌려봤습니다.
$ docker stats
CONTAINER CPU % MEM USAGE / LIMIT MEM %
airflow-scheduler-1 92.1% 3.8GiB / 4GiB 95.0%
# ... 잠시 후
airflow-scheduler-1 0.00% 0B / 0B -- ← 죽음
# ... 몇 초 후 자동 재시작
airflow-scheduler-1 45.2% 1.2GiB / 4GiB 30.0%Scheduler가 메모리 100%를 찍고 죽었다가 다시 뜨는 걸 반복하고 있었습니다. 컨테이너가 cgroup 메모리 한도에 닿으면 커널 OOM killer가 그 cgroup 안에서 프로세스 하나를 골라 죽입니다. 고르는 기준은 대체로 메모리를 많이 쓰는 쪽이고, 그게 컨테이너의 PID 1이면 컨테이너 자체가 종료 코드 137로 내려갑니다. Airflow 공식 docker-compose.yaml은 서비스마다 restart: always를 걸어두므로 곧바로 다시 뜨고, 다음 배치에서 같은 일이 반복됩니다.
왜 Scheduler OOM이 hostname=NULL을 만드는가
LocalExecutor는 스케줄러 노드에서 Task를 실행합니다. 워커가 스케줄러 프로세스의 자식 프로세스로 뜨고, 각 워커가 다시 Task 실행 프로세스를 fork합니다. Linux 기본값인 fork 모드에서는 copy-on-write로 인한 메모리 스파이크를 피하려고 워커를 parallelism 수만큼 한 번에 띄웁니다. 결과적으로 Task 실행에 드는 메모리가 전부 스케줄러 컨테이너 몫으로 잡힙니다. 공식 문서도 이 점을 명시적으로 경고합니다. 컨테이너 환경에서는 스케줄러 프로세스가 메모리를 과하게 쓰는 것처럼 보이고, 이 때문에 OOM으로 컨테이너가 재시작될 수 있으니 parallelism을 컨테이너 리소스 한도에 맞춰 조정하라는 내용입니다.
여기에 앞서 본 기록 경로를 겹치면 그림이 완성됩니다. 스케줄러 컨테이너가 내려가면 그 안의 워커와 Task 프로세스가 전부 함께 사라집니다. 이미 뜬 프로세스는 state=running을 보고했으니 hostname과 pid가 남아 있지만, 아직 뜨지 못한 Task는 보고할 프로세스 자체가 없었으므로 두 컬럼이 NULL 그대로입니다.
갈림의 기준은 Task 번호가 아니라 OOM이 터진 시각입니다. 매번 다른 번호에서 끊기는 것도 이걸로 설명됩니다. 스케줄러가 한도에 닿는 시점이 그때그때의 Task 실행 상황에 따라 달라지기 때문입니다.
에러 메시지의 함정
“No host supplied”라는 문구만 보면 hostname 설정 문제처럼 보입니다. 실제로 hostname_callable을 바꾸라는 조언도 흔합니다. 하지만 이 에러에서 물어야 할 것은 “hostname을 어떤 함수로 구하는가”가 아니라 “hostname을 기록하는 절차가 왜 시작되지 않았는가”입니다. hostname을 만드는 함수는 잘못된 값을 줄 수는 있어도 NULL을 주지는 않습니다. 컬럼이 비어 있다는 건 그 값을 실어 나를 프로세스가 뜨지 못했다는 신호입니다.
해결: Scheduler 메모리 제한 올리기
# docker-compose.yaml
services:
airflow-scheduler:
deploy:
resources:
limits:
memory: 12G # 4G → 8G → 최종 12G한도를 한 번에 정하지 못하고 두 단계로 올렸습니다.
4G에서 8G로 올렸을 때 상황이 많이 나아졌지만, TTS 배치처럼 수십 개 Task를 동시에 돌리는 DAG에서는 여전히 간헐적으로 터졌습니다. 12G로 올린 뒤에는 대량 Task를 돌려도 Scheduler가 죽지 않았고, 모든 Task의 hostname이 정상적으로 채워졌습니다. “No host supplied” 에러도 사라졌습니다.
한도를 얼마로 잡을 것인가
Task 프로세스 하나가 쓰는 메모리는 Task가 무슨 일을 하느냐에 따라 크게 달라져서 일반화된 수치가 없습니다. 공식 문서도 절대량을 제시하는 대신 컨테이너의 리소스 한도에 맞춰 parallelism을 조정하라고만 안내합니다. 그러니 실측이 먼저입니다. 대량 Task를 돌리는 동안 docker stats로 최대 사용량을 재고, 거기에 여유를 얹어 한도를 잡으세요.
동시에 도는 매핑 Task 수를 max_active_tis_per_dag로 묶어두면 한도를 올리지 않고도 같은 압박을 줄일 수 있습니다. 한도를 키우는 쪽과 동시 실행을 줄이는 쪽 중 어느 것이 나은지는 배치 시간 여유가 얼마나 있느냐에 달려 있습니다.
이 경험에서 배운 것
에러 메시지와 원인이 다른 레이어에 있을 수 있다
“No host supplied”라는 에러 메시지 자체는 정확합니다. 실제로 hostname이 비어 있으니까요. 문제는 그 메시지가 가리키는 방향(hostname 설정)과 실제 원인(메모리)이 전혀 다른 레이어에 있었다는 겁니다. docker stats로 메모리 사용량은 계속 보고 있었지만, 그게 hostname=NULL과 연결된다는 걸 떠올리지 못했습니다.
hostname=NULL + pid=NULL의 의미
돌이켜보면 단서는 처음부터 있었습니다. hostname만 NULL이면 설정 문제일 수 있지만, pid까지 NULL이라는 건 프로세스가 아예 안 떴다는 뜻입니다. 설정이 아니라 리소스 쪽 신호였는데, 에러 메시지에 이끌려 hostname 설정만 팠던 거죠.
비슷한 상황을 만나면
# 1. 리소스부터 확인한다
$ docker stats --no-stream
# 2. Scheduler 컨테이너가 재시작한 흔적이 있는지 본다
$ docker inspect --format '{{.RestartCount}} {{.State.OOMKilled}} {{.State.ExitCode}}' \
$(docker compose ps -q airflow-scheduler)
# 3. Scheduler 로그에서 fork 실패 흔적 확인
$ docker compose logs airflow-scheduler --tail=500 | grep -E "fork|OSError|MemoryError|killed"
# 4. 그래도 안 보이면 그때 hostname 설정 확인
$ docker compose exec airflow-scheduler python -c \
"from airflow.utils.net import get_hostname; print(repr(get_hostname()))"핵심 요약
- “No host supplied” 에러는 hostname 설정 문제처럼 보이지만, 실제 원인은 Scheduler 메모리 부족일 수 있습니다
- Airflow 3.x에서 hostname과 pid는 Task 프로세스가 뜬 뒤 Task Execution API로 보고돼야 채워집니다. 둘 다 NULL이면 그 프로세스가 없었다는 뜻입니다
- LocalExecutor 워커는 스케줄러의 자식 프로세스라, Task 실행 메모리가 스케줄러 컨테이너 한도에 잡힙니다
hostname_callable변경,jwt_secret고정, DB 커넥션 풀 증가는 이 경우 효과가 없었습니다- 해결은 스케줄러 컨테이너 메모리 한도를 올리거나
parallelism을 줄여 동시에 뜨는 프로세스 수를 제한하는 것입니다 - 디버깅할 때
docker stats부터 확인하면 세 번의 우회를 건너뛸 수 있었습니다
마치며
hostname_callable → jwt_secret → DB 커넥션 풀 → 메모리. 돌아돌아 결국 가장 기본적인 리소스 문제였습니다. docker stats는 계속 보고 있었는데, “Scheduler 메모리가 차는 것”과 “hostname이 NULL인 것”을 연결 짓지 못한 게 삽질의 원인이었습니다.
pid=NULL이라는 단서를 좀 더 일찍 주목했으면 방향을 빨리 잡았을 텐데, “No host supplied”에 꽂혀서 hostname 설정만 계속 건드린 셈입니다.
참고자료
- Airflow GitHub Issue #42136: Invalid URL, No host supplied
- Airflow 공식 문서: LocalExecutor (워커가 스케줄러의 서브프로세스라 컨테이너 OOM으로 이어질 수 있다는 경고)
- Airflow 공식 문서: Configuration Reference의 hostname_callable
- Airflow Task SDK 문서 (Task가 Execution API로 상태를 보고하는 구조)
- execution_api/datamodels/taskinstance.py (
TIEnterRunningPayload가 hostname, pid, unixname을 싣는 지점) - airflow/utils/net.py (
getfqdn과get_host_ip_address구현) - Airflow 공식 문서: Running Airflow in Docker