[Spark] Spill 및 디스크 사용량 증가 원인 분석
1. 문제 상황
운영 중인 Spark on YARN 클러스터에서 일부 워커 노드의 로컬 디스크가 Read-only 상태로 전환되는 문제가 발생했다. 클러스터는 워커 20대, 노드당 메모리 30GB와 16 vCore로 구성되어 있고, 야간에 전체 적재와 증분 적재가 이어지는 구조다. 적재 결과는 별도 오브젝트 스토리지의 Iceberg 테이블에 저장한다.
문제 발생 기간 동안 여러 워커에서 동일한 현상이 순차적으로 확인됐고, Read-only 상태가 된 노드는 NodeManager 헬스 체크에서 비정상으로 판단되면서 YARN의 가용 노드에서 제외됐다. 이후 가용 워커가 줄어든 상태가 이어지다가 야간 배치 종료 후 다음 배치가 정상적으로 제출되지 않는 상황까지 발생했다. 당시 워커의 헬스 리포트에는 다음 오류가 남아 있었다.
1/1 local-dirs have errors:
[ /data/yarn/nm-local-dir : Directory is not writable ]
단순한 용량 부족 메시지가 아니라 파일시스템이 읽기 전용으로 전환된 상태였다. 같은 시각 다른 호스트의 PostgreSQL에서도 Read-only file system 오류가 확인됐다.
장애 전까지 Spark 애플리케이션 자체는 정상 종료하고 있었다. 해당 기간 YARN 기록 64건은 모두 FINISHED / SUCCEEDED였고, 종료된 애플리케이션의 NodeManager 작업 디렉터리도 정리됐다. 게스트 OS에서 확인한 파일시스템 사용량과 인프라에서 본 물리 스토리지 사용량은 서로 다르게 움직였다.
확인할 대상을 두 가지로 나눴다.
- Spark가 실제로 로컬 디스크를 얼마나 사용하고 있었는지
- 애플리케이션 종료 후 삭제된 공간이 물리 스토리지까지 정상적으로 반환되고 있었는지
본문의 테이블명, 호스트명, 잡 이름은 예시 값으로 바꿨다. 가장 큰 적재 대상은 이하 dw.exam_result로 표기한다.
2. Spill 발생 구간과 규모
먼저 Spark History Server REST API에서 8일치 스테이지 메트릭을 수집했다. 디스크 Spill 합계는 약 10,009GB였고, 전체의 66%가 하나의 적재 잡에서 발생했다.
| 적재 잡 | 테이블 수 | 8일 디스크 Spill | 비중 |
|---|---|---|---|
A (dw.exam_result 계열) |
3 | 6,638GB | 66.3% |
| B | 18 | 2,674GB | 26.7% |
| C | 16 | 530GB | 5.3% |
| D | 7 | 165GB | 1.6% |
| 마스터 전체 적재 | 39 | 0GB | 0% |
잡 A에서는 매일 약 654GB의 디스크 Spill이 발생했다. 6일 동안 편차도 3GB 안쪽으로 크지 않았다. 주 테이블인 dw.exam_result는 약 11억 건이다.
2.1 셔플 파티션 수 확인
Spill이 집중된 스테이지는 셔플 읽기 334GB를 240개 태스크가 처리하고 있었다. 단순 평균으로 보면 태스크당 약 1.39GB다.
당시 세션은 익스큐터 메모리 8g, 코어 4개, spark.memory.fraction=0.80으로 설정되어 있었다. Spark의 통합 메모리 영역은 다음과 같이 계산할 수 있다.
통합 메모리 = (8,192MiB − 300MiB) × 0.80 ≈ 6.17GiB
코어 4개가 모두 사용되는 구간에서는 익스큐터 하나에서 최대 4개 태스크가 동시에 실행된다. 이때 실행 메모리는 활성 태스크 사이에서 공유되므로 태스크당 사용할 수 있는 몫은 대략 1.5GB 수준까지 내려간다. 다만 이 값은 고정된 태스크 메모리 한도가 아니다. 동시에 실행 중인 태스크 수와 스토리지 메모리 사용량에 따라 실제 할당량은 달라진다.
셔플 입력 크기와 필요한 실행 메모리는 1:1 관계가 아니다. 정렬이나 집계 과정에서는 셔플로 읽은 데이터 외에도 작업용 메모리가 필요하다. 해당 스테이지는 대부분의 태스크에서 Spill이 반복됐고, 익스큐터의 실행 메모리 사용량도 통합 풀에 근접했다. 실측 지표에서는 파티션당 처리량이 당시 메모리 구성에 비해 컸다.
240이라는 파티션 수는 세션 설정이 아니라 클러스터의 spark-defaults.conf에서 들어오고 있었다. 세션에서는 spark.sql.adaptive.enabled만 별도로 활성화한 상태였다.
AQE의 coalescePartitions는 작은 셔플 파티션을 합치는 기능이다. 초기 파티션 수가 부족한 상황을 일반적으로 다시 잘게 쪼개 주는 기능은 아니다. skew join처럼 특정 조건에서 큰 파티션을 분리하는 최적화도 있지만, 이 스테이지처럼 파티션이 전반적으로 큰 경우에는 초기 셔플 파티션 수를 충분히 주는 쪽이 더 직접적이었다.
2.2 copy-on-write에 따른 쓰기 증폭
파티션 수만으로는 매일 비슷한 크기의 Spill이 반복되는 이유를 모두 설명하기 어려웠다. 실제 변경 행 수와 Spark의 입출력량을 같이 비교했다.
| 실행 | 실제 변경 행수 | 입력 | 출력 | 디스크 Spill |
|---|---|---|---|---|
| 1 | 1,872,756 | 114.2GB | 92.2GB | 655.5GB |
| 2 | 1,587,318 | 114.4GB | 92.6GB | 657.3GB |
| 3 | 1,006,813 | 114.1GB | 92.7GB | 653.8GB |
| 4 | 23,465 | 166.5GB | 92.3GB | 1,052.6GB |
변경 행 수는 크게 달랐지만 출력량은 약 92GB로 거의 고정됐다. 처리량은 변경 행 수보다 영향을 받은 데이터 파일의 범위에 더 크게 좌우됐다.
Iceberg의 row-level DELETE, UPDATE, MERGE는 기본적으로 copy-on-write 방식으로 동작한다. 이 방식에서는 변경 대상 행이 포함된 데이터 파일을 다시 작성한다. 변경 행이 과거 데이터 전반에 흩어져 있다면 실제 변경 건수가 적어도 많은 데이터 파일이 재작성될 수 있다.
이 잡도 같은 형태였다. 대표 실행 기준 입력은 약 114GB였지만 중간 셔플은 수백 GB까지 커졌고, 최종 출력은 다시 약 92GB가 만들어졌다. Spill을 줄이려면 파티션 크기뿐 아니라 이 재작성 구조도 같이 봐야 했다.
3. 파일은 삭제됐는데 물리 사용량이 줄지 않은 이유
Spark가 로컬 디스크를 많이 쓰는 이유는 여기까지 확인됐다. 다만 애플리케이션 종료 후 로컬 파일은 정리되고 있었으므로, 이것만으로 물리 디스크 사용량이 며칠 동안 계속 증가한 현상은 설명되지 않았다.
인프라 구성을 확인해 보니 워커는 씬 프로비저닝된 가상 SSD 볼륨을 사용하고 있었다.
씬 프로비저닝 환경에서는 게스트 OS가 파일을 삭제해도 그 사실이 스토리지 계층까지 자동으로 전달되는 것은 아니다. 파일시스템에서는 해당 블록을 다시 사용할 수 있는 공간으로 표시하지만, discard/TRIM 신호가 아래 계층까지 전달되지 않으면 백엔드에서는 기존 블록을 계속 할당된 공간으로 볼 수 있다.
Spark 임시 파일 생성
→ 게스트 파일시스템의 블록 사용
→ 백엔드 스토리지 할당 증가
애플리케이션 종료 후 파일 삭제
→ 게스트에서는 free block으로 전환
→ discard가 전달되지 않으면 백엔드 할당은 유지
이후 다른 논리 블록까지 새로 사용
→ 백엔드 할당량이 추가로 증가할 수 있음
게스트에서 df로 여유 공간이 보이더라도 하이퍼바이저나 스토리지의 물리 사용량은 계속 증가할 수 있다. 셔플과 Spill처럼 큰 임시 파일을 반복해서 만들고 지우는 워크로드에서는 이 차이가 빠르게 커질 수 있다.
사용하지 않는 블록을 아래 계층에 알리는 방법으로는 온라인 discard와 주기적인 fstrim이 있다. 이 환경에서는 별도의 정기 TRIM 작업이 없었다.
확인할 문제는 두 갈래로 나뉘었다.
- Spill이 많아 임시 디스크 쓰기 자체가 컸다.
- 삭제된 블록이 물리 스토리지까지 반환되지 않았다.
Spill이 물리 사용량 누적의 직접 원인은 아니었지만, 반환되지 않는 환경에서는 물리 할당이 늘어나는 속도를 키우는 요인이 됐다.
4. 조치 1. 정기 TRIM 스케줄 적용
먼저 인프라 팀과 각 시스템의 배치 시간을 확인하고, 주요 작업이 끝난 뒤 fstrim이 실행되도록 스케줄을 추가했다.
| 대상 | 주요 작업 시간대 | TRIM |
|---|---|---|
| 적재용 Spark 클러스터 | 매일 00:00 ~ 03:30 적재, 월 1회 저녁 대량 적재 | 매일 04:30 |
| 가공용 Spark 클러스터 | 매일 06:00 ~ 07:00 정기 가공 | 매일 07:30 |
| PostgreSQL | 00:00 ~ 03:30 배치, 03:30 ~ 05:00 매트뷰 리프레시, 06:00 ~ 23:00 실시간 리프레시 | 매일 05:20 |
| Citus | 월 1회 저녁 정기 적재, 수시 조회 | 매일 01:00 |
TRIM/discard 작업도 스토리지 I/O와 겹칠 수 있기 때문에 주요 배치 시간과 겹치지 않도록 잡았다. 일반적인 서버에서는 더 긴 주기로도 충분할 수 있지만, 이 환경은 야간마다 대량의 임시 파일이 생성되는 구조라 우선 매일 수행하도록 운영했다.
이 조치는 삭제된 공간이 실제 스토리지까지 반환되도록 하는 조치다. 이후의 변경은 그와 별개로 Spark가 매일 쓰고 지우는 데이터 자체를 줄이는 데 목적이 있다.
5. 조치 2. 셔플 파티션 상향
다음으로 세션 페이로드에 셔플 파티션 수와 AQE 병합 기준을 명시했다.
# 셔플 이후 기본 파티션 수
"spark.sql.shuffle.partitions": "1000",
# Adaptive Query Execution
"spark.sql.adaptive.enabled": "true",
"spark.sql.adaptive.coalescePartitions.enabled": "true",
# coalesce 이전 초기 셔플 파티션 수
"spark.sql.adaptive.coalescePartitions.initialPartitionNum": "1000",
# 병렬도보다 advisory size를 기준으로 병합하도록 설정
"spark.sql.adaptive.coalescePartitions.parallelismFirst": "false",
# coalesce 시 목표로 사용할 파티션 크기
"spark.sql.adaptive.advisoryPartitionSizeInBytes": "128MB",
# skew join 최적화
"spark.sql.adaptive.skewJoin.enabled": "true",
parallelismFirst=false로 두면 AQE가 병렬도를 우선해 작은 파티션을 많이 유지하지 않고 128MB 기준으로 병합한다. 초기 파티션 수를 1000으로 올려도 작은 셔플은 AQE가 다시 합칠 수 있다.
파티션 수의 영향만 확인하려고 익스큐터 수나 메모리 같은 다른 리소스 설정은 바꾸지 않았다.
5.1 결과
같은 잡 A를 배포 전후로 비교했다.
| 항목 | 변경 전 | 변경 후 1일 | 2일 | 3일 |
|---|---|---|---|---|
| 태스크 수 | 240 | 1,000 | 1,000 | 1,000 |
| 태스크당 셔플 | 1.39GB | 346MB | 344MB | 344MB |
| 디스크 Spill | 653.8GB | 286.3GB | 261.1GB | 284.6GB |
| 셔플 쓰기 | 690GB | 682GB | 679GB | 680GB |
| 앱 소요 | 43분 | 44분 | 37분 | 41분 |
디스크 Spill은 약 56% 감소했다. 실행 시간도 기존 범위에서 크게 벗어나지 않았다.
다만 셔플 총량은 거의 변하지 않았다. 파티션을 더 잘게 나눈 덕분에 한 태스크가 한 번에 처리하는 데이터는 줄었지만, 잡 자체가 만들어 내는 셔플 양은 그대로였기 때문이다.
5.2 남은 Spill 확인
변경 후 태스크 분위수를 보면 1,000개 태스크의 Memory Bytes Spilled가 거의 같은 수준으로 나타났다. 특정 태스크만 유난히 큰 형태가 아니어서 데이터 skew보다는 전반적인 메모리 압박에 가까웠다.
여기서 Memory Bytes Spilled를 그대로 “부족한 메모리 크기”로 해석하면 안 된다. 이 값은 Spill된 레코드가 메모리에서 차지하던 크기를 누적한 지표라, 익스큐터에 해당 크기만큼 메모리를 더 주면 된다는 의미는 아니다.
실행 당시 익스큐터의 실행 메모리 사용량도 통합 메모리 풀에 근접해 있었다. 같은 애플리케이션 안에서도 단순히 셔플 데이터를 읽어 쓰는 스테이지는 Spill이 없었고, 정렬이 포함된 스테이지에서만 Spill이 발생했다. 데이터 크기만큼이나 연산자의 메모리 사용 특성이 영향을 주고 있었다.
익스큐터 메모리를 8g에서 12g로 높이는 방안도 검토했지만, 다음 단계에서 쓰기 방식을 바꾼 뒤 해당 잡의 Spill이 거의 사라져 별도로 적용하지 않았다.
6. 조치 3. copy-on-write에서 merge-on-read로 전환
파티션 조정으로 Spill은 줄었지만 셔플 총량은 거의 그대로였다. 다음으로 변경 행이 적어도 데이터 파일을 대량으로 다시 쓰던 Iceberg 쓰기 방식을 조정했다.
Spill이 큰 테이블에 대해 row-level write mode를 merge-on-read로 변경했다.
ALTER TABLE lakehouse.dw.exam_result SET TBLPROPERTIES (
'write.delete.mode' = 'merge-on-read',
'write.update.mode' = 'merge-on-read',
'write.merge.mode' = 'merge-on-read',
'history.expire.min-snapshots-to-keep' = '10'
);
실제 증분 적재는 MERGE INTO를 사용하므로 핵심 설정은 write.merge.mode다. DELETE와 UPDATE도 같은 정책으로 맞춰 두기 위해 세 속성을 함께 변경했다.
copy-on-write가 변경 대상이 포함된 데이터 파일을 다시 작성하는 방식이라면, merge-on-read는 변경 내용을 delete 파일과 신규 데이터 파일로 분리해 기록하고 읽을 때 이를 병합한다. MERGE 과정에서 대상 행을 찾기 위한 스캔 자체가 없어지는 것은 아니지만, 영향받은 기존 데이터 파일을 통째로 다시 쓰는 비용은 줄일 수 있다.
6.1 전환 대상 선정
모든 테이블을 merge-on-read로 바꾸지는 않았다. 전체 94개 테이블 중 상당수는 8일 동안 발생한 Spill 자체가 매우 작았다. 이런 테이블까지 전환하면 쓰기 쪽 이득보다 delete 파일 관리와 읽기 비용이 더 커질 수 있다.
전체 재적재 성격이 강한 테이블도 제외했다. 대부분의 데이터를 매번 다시 작성하는 잡은 row-level update 최적화로 얻는 이득이 크지 않고, merge-on-read의 delete 파일 관리 비용만 추가될 가능성이 있기 때문이다.
최종 전환 대상은 Spill 상위 잡에 포함된 큰 테이블 6개로 제한했다.
6.2 스냅샷 보존 설정
전환하면서 history.expire.min-snapshots-to-keep=10도 함께 설정했다.
이 값은 스냅샷 개수의 상한이 아니라, 스냅샷 만료 작업이 실행될 때 최소 몇 개를 남길지 정하는 값이다. 오래된 스냅샷의 만료 여부는 history.expire.max-snapshot-age-ms와 함께 결정되고, 실제 스냅샷 수를 줄이려면 별도의 expiration 작업이 실행되어야 한다.
전환 직후 유지관리 작업을 수행한 결과 dw.exam_result 계열 테이블의 스냅샷 수는 98개에서 10개로 줄었고, 더 이상 어떤 유효한 스냅샷에서도 참조하지 않는 데이터 파일 약 2.6TiB가 오브젝트 스토리지에서 정리됐다.
6.3 결과
전환 후 닷새 동안 잡 A를 비교한 결과다.
| 구간 | 디스크 Spill | 셔플 쓰기 | 입력 | 앱 소요 |
|---|---|---|---|---|
| 파티션 240 | 654~657GB | 689~739GB | 114GB | 43~45분 |
| 파티션 1000 | 261~286GB | 679~682GB | 110GB | 37~44분 |
| merge-on-read | 0~21GB | 53~74GB | 21~25GB | 4~11분 |
파티션 상향은 한 태스크가 처리하는 양을 줄여 Spill을 낮췄지만, 셔플 총량 자체는 거의 그대로였다. 반면 merge-on-read 전환 후에는 이 워크로드에서 입력이 기존의 약 1/5, 셔플 쓰기가 약 1/10 수준으로 줄었다. 첫날 21GB가 발생한 뒤에는 디스크 Spill도 0GB로 유지됐다.
익스큐터 점유 시간은 1,325분에서 258분으로 줄었고, 피크 메모리도 8.0GB에서 4.6GB로 내려갔다.
6개 테이블 전체의 전후 수치는 다음과 같았다.
| 테이블 | 디스크 Spill | 셔플 쓰기 | 소요 |
|---|---|---|---|
dw.exam_result |
610GB → 4GB | 744GB → 66GB | 48분 → 7분 |
dw.report_order, dw.work_log |
318GB → 6GB | 594GB → 80GB | 44분 → 20분 |
dw.visit_index, dw.visit_detail |
56GB → 0GB | 234GB → 24GB | 28분 → 16분 |
dw.exam_order |
21GB → 18GB | 78GB → 104GB | 20분 → 16분 |
dw.exam_order는 개선 폭이 거의 없었다. 원래 Spill이 상대적으로 작았고, 데이터 파일은 약 7,400개에 평균 크기가 3MB 수준이었다. 이 테이블은 merge-on-read보다 작은 파일 문제를 먼저 확인하기로 했다. 파티션 단위, 파일 크기, compaction 조건은 별도로 점검할 예정이다.
6.4 delete 파일 증가와 compaction
merge-on-read는 쓰기 비용을 줄이는 대신 delete 파일을 남긴다. 전환 닷새째 현재 스냅샷을 기준으로 확인한 파일 수는 다음과 같았다.
| 테이블 | 데이터 파일 | delete 파일 |
|---|---|---|
dw.exam_result |
882 → 1,180 | 0 → 298 |
dw.report_order |
1,170 | 0 → 650 |
dw.work_log |
911 → 1,277 | 0 → 366 |
dw.visit_detail |
339 → 1,140 | 0 → 663 |
이 상태를 계속 두면 읽기 시 적용해야 할 delete 파일이 늘어나므로 주기적인 compaction이 필요하다. 전환 후 닷새 동안 compaction이 한 번도 실행되지 않았고, dw.visit_detail은 데이터 파일 수도 크게 늘어 있었다.
기존 유지관리 DAG는 delete 파일이 1개만 있어도 compaction 대상으로 잡도록 되어 있었다. merge-on-read 전환 뒤에는 거의 매 적재마다 delete 파일이 생성되기 때문에, 이 조건은 너무 민감하다. 파일 개수와 delete 비율을 함께 보는 방향으로 기준을 조정할 필요가 있다.
rewrite_data_files의 max-concurrent-file-group-rewrites도 확인 대상이다. 이 값은 계획된 file group을 동시에 몇 개까지 재작성할지 정하고 기본값은 5다. 현재 환경에서는 2로 동작하고 있어 병렬도를 높일 여지는 있다.
다만 출력 파일 수를 file group 수와 동일하게 볼 수는 없고, 동시성을 2에서 5로 올렸다고 실행 시간이 비례해서 줄어드는 것도 아니다. 이 부분은 compaction 기준을 정리한 뒤 실제 실행 시간과 클러스터 부하를 같이 보면서 조정할 계획이다.
7. 정리
- 물리 디스크 사용량이 계속 증가한 직접 원인은 씬 프로비저닝 스토리지에서 삭제 블록의 discard가 정상적으로 전달되지 않은 것이었다. 정기 TRIM을 추가해 반환 경로를 만들었다.
- Spark의 대량 Spill은 별개의 문제였지만, 큰 임시 파일을 반복해서 쓰고 지우는 만큼 물리 할당 증가를 빠르게 만드는 요인이 됐다.
- 셔플 파티션을 240개에서 1,000개로 늘리자 디스크 Spill은 약 56% 줄었다. 대신 셔플 총량은 거의 그대로여서 잡의 쓰기 구조 자체는 바뀌지 않았다.
- copy-on-write의 재작성 비용이 큰 테이블 6개만 merge-on-read로 전환한 뒤 입력과 셔플이 크게 줄었다. 반면 delete 파일이 빠르게 늘기 시작해, 이후에는 compaction 주기와 실행 기준을 다시 잡는 작업이 필요해졌다.
같은 기간 standalone 클러스터에서 확인한 익스큐터 메모리 부족 사례는 Spark Standalone Executor 메모리 부족 및 Spill 분석에 따로 정리했다.
Leave a comment