PySpark Executor OOM 정복 — Container killed 에러 끝내기
"Container killed by YARN for exceeding memory limits" 에러의 진짜 원인을 파헤칩니다. Executor 메모리 구조(heap/overhead/off-heap), spill, GC, 파티션 크기, PySpark 특유의 Python 메모리까지 이해하고 OOM 을 구조적으로 해결하는 법을 정리합니다.
Spark 운영에서 가장 악명 높은 에러 두 가지가 있습니다. java.lang.OutOfMemoryError, 그리고 Container killed by YARN for exceeding memory limits. X GB of Y GB physical memory used. 둘 다 "메모리 부족"이지만 원인과 해법이 다릅니다. 무작정 executor.memory 만 올리면 자원만 낭비하고 문제는 반복됩니다.
이 글은 Spark Executor 의 메모리가 어떻게 나뉘는지부터 시작해, 두 OOM 의 차이, PySpark 특유의 Python 메모리 문제, 그리고 OOM 을 구조적으로 없애는 접근을 정리합니다.
1. Executor 메모리 구조 — 먼저 그림부터
executor.memory 하나만 보면 OOM 을 이해할 수 없습니다. 컨테이너 하나의 메모리는 여러 영역으로 나뉩니다.
┌─────────────────── 컨테이너 (YARN/K8s 가 죽이는 단위) ───────────────────┐
│ │
│ ┌──────────────── JVM 힙 (spark.executor.memory) ───────────────┐ │
│ │ Reserved (300MB) │ │
│ │ ┌─ Unified Memory (spark.memory.fraction, 기본 0.6) ──────┐ │ │
│ │ │ Execution (셔플·정렬·조인 버퍼) ⇄ Storage (캐시) │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ │ User Memory (UDF·사용자 자료구조) │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ Overhead (spark.executor.memoryOverhead) ← 네이티브·셔플·Python 일부 │
│ Off-heap (spark.memory.offHeap.size, 선택) │
│ ⟵ PySpark: Python 워커 프로세스 메모리는 힙 밖! (overhead 압박) │
└──────────────────────────────────────────────────────────────────────────┘핵심: YARN/K8s 는 "컨테이너 전체 물리 메모리"를 보고 죽입니다. 힙(executor.memory)이 멀쩡해도 overhead + Python 프로세스가 컨테이너 한도를 넘으면 "Container killed" 가 납니다.
2. 두 OOM 은 다르다
| 에러 | 발생 위치 | 의미 | 1차 대응 |
|---|---|---|---|
java.lang.OutOfMemoryError: Java heap space | JVM 힙 내부 | 힙 부족 | 파티션 작게, 메모리↑, 캐시 줄이기 |
Container killed ... exceeding memory limits | 컨테이너 전체 | overhead/off-heap/Python 초과 | memoryOverhead↑, Python 메모리 관리 |
가장 흔한 오해: "Container killed" 를 보고
executor.memory(힙)만 올리는 것. 이 에러는 보통 overhead 부족이라, 힙을 올리면 오히려 컨테이너가 더 커져 악화될 수 있습니다.memoryOverhead를 올리는 게 정답인 경우가 많습니다.
3. 핵심 설정 한눈에
spark = (SparkSession.builder
.config("spark.executor.memory", "8g") # JVM 힙
.config("spark.executor.memoryOverhead", "2g") # 네이티브+셔플+Python
.config("spark.executor.cores", "4") # 코어당 동시 태스크
.config("spark.memory.fraction", "0.6") # execution+storage 비율
.config("spark.sql.shuffle.partitions", "400") # 셔플 파티션 수
.getOrCreate())| 설정 | 역할 | 튜닝 방향 |
|---|---|---|
executor.memory | JVM 힙 | 너무 크면 GC 악화 |
executor.memoryOverhead | 힙 밖 영역 | Container killed 시 1순위 ↑ |
executor.cores | 동시 태스크 수 | 많을수록 메모리 경합↑ |
memory.fraction | 작업/캐시 풀 비율 | 캐시 안 쓰면 execution 여유 |
sql.shuffle.partitions | 셔플 후 파티션 수 | 늘리면 파티션당 메모리↓ |
4. OOM 의 진짜 원인 — 파티션이 너무 크다
대부분의 힙 OOM 은 메모리가 작아서가 아니라 파티션 하나가 익스큐터 메모리에 안 들어가기 때문입니다. 한 태스크는 파티션 하나를 통째로 다룹니다.
파티션당 데이터 ≈ 입력 크기 / 파티션 수
파티션이 크면 → 태스크 하나가 거대 데이터를 메모리에 올림 → OOM해법은 보통 "메모리 키우기"가 아니라 "파티션 잘게 쪼개기" 입니다.
# 셔플 후 파티션 수 늘리기 → 파티션당 데이터 감소
spark.conf.set("spark.sql.shuffle.partitions", "800")
# 입력 단계에서 재분배
df = df.repartition(800, "key")
# AQE 가 런타임에 적정 파티션으로 자동 조정 (권장)
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")경험칙: 파티션당 처리 데이터를 100~200MB 수준으로 맞추는 것을 목표로 합니다. OOM 이 나면 파티션 수부터 늘려보세요. 단, 스큐로 특정 파티션만 큰 경우라면 파티션 수를 늘려도 안 되니, 스큐 해결(별도 글 "PySpark 데이터 스큐 완전 정복")이 먼저입니다.
5. Spill — OOM 직전의 신호
Execution 메모리가 부족하면 Spark 는 데이터를 디스크로 흘립니다(spill). spill 자체는 OOM 을 막아주지만, 과도한 spill 은 "메모리가 빠듯하다"는 경고등입니다.
Spark UI 의 Task 메트릭에서 Spill (Memory), Spill (Disk) 가 크면:
- 파티션을 더 잘게 (
shuffle.partitions↑) - 불필요한 컬럼 제거(
select로 일찍 좁히기) - 캐시를 줄여 execution 풀 확보
spill 이 디스크 I/O 로 잡을 느리게 하므로, 약간의 메모리 여유가 큰 성능 차이를 냅니다.
6. PySpark 특유의 함정 — Python 메모리
PySpark 에서 Python UDF·pandas_udf·applyInPandas 를 쓰면, JVM 힙 밖에서 별도 Python 워커 프로세스가 뜹니다. 이 메모리는 executor.memory(힙)가 아니라 컨테이너 overhead 를 압박합니다.
Python UDF 실행 → JVM ↔ Python 직렬화 → Python 프로세스가 데이터 메모리 점유
→ 힙은 여유인데 컨테이너가 죽음 (Container killed)대응:
# Python 워커 메모리 상한 (이 값을 넘으면 spill)
spark.conf.set("spark.executor.pyspark.memory", "2g")
# 그만큼 overhead 도 넉넉히
spark.conf.set("spark.executor.memoryOverhead", "3g")근본 해법은 Python UDF 를 줄이는 것입니다. 가능하면 내장 함수로 대체하고, 불가피하면 행 단위 UDF 대신 벡터화된 pandas_udf 를 쓰세요. (자세한 내용은 별도 글 "PySpark Python UDF vs Pandas UDF"에서 다룹니다.)
7. collect / toPandas — 드라이버 OOM
익스큐터가 아니라 드라이버가 죽는 경우도 흔합니다. 거대한 결과를 드라이버 메모리로 끌어오는 액션이 범인입니다.
# 위험: 전체 결과를 드라이버 메모리로
data = df.collect()
pdf = df.toPandas()
# 안전: 집계 후 작은 결과만, 또는 직접 저장
df.write.parquet("...") # 익스큐터가 분산 저장
small = df.limit(1000).toPandas() # 필요한 만큼만collect(), toPandas(), 큰 broadcast 변수는 드라이버 메모리를 먹습니다. 결과가 크면 드라이버로 모으지 말고 분산 저장하세요.
8. 캐시 관리 — Storage 가 Execution 을 굶긴다
cache()/persist() 한 데이터가 Storage 풀을 점유하면 Execution 풀이 줄어 OOM 위험이 커집니다.
# 정말 여러 번 재사용하는 것만 캐시
df_reused.persist(StorageLevel.MEMORY_AND_DISK) # 메모리 부족 시 디스크로
...
df_reused.unpersist() # 다 쓰면 즉시 해제
# 한 번만 쓰는데 캐시하는 건 낭비 + 위험MEMORY_ONLY 는 캐시가 메모리를 못 넣으면 재계산하거나 압박을 키웁니다. 큰 데이터는 MEMORY_AND_DISK 가 안전합니다.
9. 진단 → 처방 빠른 표
| 증상 | 원인 후보 | 처방 |
|---|---|---|
Java heap space | 파티션 과대 | shuffle.partitions↑, repartition, 컬럼 축소 |
Container killed | overhead/Python 초과 | memoryOverhead↑, Python UDF 축소 |
| 드라이버 OOM | collect/toPandas | 분산 저장, limit |
| spill 폭증 | execution 부족 | 파티션↑, 캐시↓ |
| 한 태스크만 OOM | 스큐 | 스큐 해결(salt/broadcast) |
| GC 시간 과다 | 힙 과대/객체 과다 | 힙 적정화, 객체 줄이기 |
10. OOM 튜닝 순서 (권장)
1. AQE 켜기 (자동 파티션 조정)
2. 에러 종류 구분 (heap vs container killed)
3. heap → 파티션 잘게 + 불필요 컬럼/캐시 제거
4. container killed → memoryOverhead ↑ + Python 메모리 점검
5. 한 태스크만 죽으면 → 스큐 의심
6. 그래도 부족하면 그때 executor.memory ↑핵심은 메모리를 키우는 건 마지막 수단이라는 것입니다. 파티션·스큐·캐시·Python 을 먼저 잡으면 같은 자원으로 더 큰 데이터를 처리할 수 있습니다.
11. 정리
| 영역 | 핵심 |
|---|---|
| 메모리 구조 | 힙 + overhead + off-heap + Python, 컨테이너 단위로 죽음 |
| 두 OOM 구분 | heap=파티션, container killed=overhead/Python |
| 1차 해법 | 메모리↑ 아니라 파티션 잘게 |
| PySpark 함정 | Python 워커가 overhead 압박 → UDF 축소 |
| 드라이버 | collect/toPandas 금지, 분산 저장 |
Executor OOM 의 핵심 통찰은 "OOM = 메모리 부족"이 아니라 "OOM = 데이터 단위가 메모리에 안 맞음"이라는 것입니다. 파티션을 적정 크기로 쪼개고, 컨테이너 메모리의 구조를 이해해 heap 과 overhead 를 구분하며, PySpark 라면 Python 메모리까지 시야에 넣으면 — Container killed 에러는 더 이상 미스터리가 아닙니다.
이 글은 Spark 3.5 기준으로 작성되었습니다. 대규모 Spark 잡의 메모리·안정성 튜닝이 필요하시면 언제든 문의해 주세요.
— Data Dynamics 엔지니어링 팀