gpu-zombie-test/README.md

453 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# GPU Zombie / Orphan Process & Orphaned CUDA Context 재현·해결 실험
Kubernetes 위에서 GPU 워크로드가 예기치 않게 종료되거나 signal handler가 잘못 짜였을 때 발생하는
**좀비 프로세스 / 고아 프로세스 / orphaned CUDA context / ghost GPU memory** 문제를
단계적으로 재현하고, 각 수정안(tini, preStop hook, code-level signal handler)이
어디까지 문제를 해결하는지 정량적으로 검증하는 실험 프로젝트.
> 실험 설계 전문은 [`plan.md`](./plan.md) 참조. 5개 변형 실험 결과 비교는 [`results/comparison.md`](./results/comparison.md).
---
## ⭐ 실험 결과 요약 (2026-04-13~14)
시나리오 1을 5가지 변형으로 실행한 결과 **plan.md의 핵심 가설("orphan CUDA context 발생")이 현대 driver(580/590)에서 검증되지 않음**.
대신 **두 가지 다른 진짜 문제**가 더 중요한 것으로 드러남.
### 핵심 발견
| 가설 (plan.md) | 실제 결과 | 비고 |
|---|---|---|
| `kill -9` 후 orphaned CUDA context 잔존 | **❌ 미발생** | driver가 즉시 회수 |
| Pod 삭제 후 GPU 메모리 잔존 | **❌ 미발생** | cgroup SIGKILL → driver 회수 |
| 노드 재부팅 필요 가능 | **❌ 불필요** | 모든 케이스 자동 정리 |
| 좀비/고아 발생 | ✅ **재현 성공** | tini로 해결됨 (시나리오 2 효과 확인) |
| 30초 grace 동안 GPU 낭비 | **⭐ 발견 (진짜 문제)** | SIGTERM 무시 시 항상 발생 |
| NCCL hang으로 GPU 점유 | **⭐ 발견 (예상 못한 문제)** | 멀티 GPU 환경에서 발생 |
### 진짜 문제 vs plan.md 원래 가설
| 진짜 문제 | 발생 조건 | 영향 | 시나리오 X에서 해결 |
|---|---|---|---|
| **30초 grace 낭비** | SIGTERM 무시 코드 | GB·sec 낭비 (10w일 때 740 GB·sec) | **시나리오 4 (SIGTERM handler)** |
| **좀비 누적** | PID 1 ≠ init | 프로세스 슬롯 점유 | **시나리오 2 (tini)** |
| **NCCL hang** | rank 비정상 종료 | 무한 GPU 점유 | NCCL_ASYNC_ERROR_HANDLING + timeout |
| **Peer ctx 잔존** | NCCL 멀티 GPU | 작은 누수 (148 MiB) | NCCL hang 해결 시 자동 |
### 5개 변형 실험
| # | 변형 | 노드/Driver | 핵심 발견 | 결과 디렉토리 |
|---|---|---|---|---|
| 1 | 기본 (root, 1 worker) | gpu-4 / 590.48.01 | orphan ctx 미발생 | `results/scenario-1/` |
| 2 | 구버전 driver | v1003 / 580.126.09 | driver 버전 무관 동일 | `results/scenario-1-driver-580.126.09/` |
| 3 | non-root (UID 1000) | gpu-4 / 590.48.01 | sudo 없이 kill 가능 | `results/scenario-1-user-soo/` |
| 4 | 10 workers 동시 | gpu-4 / 590.48.01 | 선형 스케일, 740 GB·sec 낭비 | `results/scenario-1-user-soo-10workers/` |
| 5 | NCCL 3 GPU | gpu-4 / 590.48.01 | ⭐ **부분 누수 + hang** (유일) | `results/scenario-1-nccl-3gpu/` |
자세한 비교 분석: [`results/comparison.md`](./results/comparison.md)
### 시나리오 2~4의 재정의된 가치
| 시나리오 | 원래 목적 (plan.md) | 실제 가치 |
|---|---|---|
| 2 (tini) | orphan ctx 방지 | **좀비 reaping** (init protection) |
| 3 (preStop) | orphan ctx 방지 | GPU cleanup 명령 grace 활용 |
| 4 (Full Fix) | orphan ctx 방지 | ⭐ **30초 grace 낭비 제거 + NCCL graceful shutdown** |
---
## 배경 — 왜 이 실험이 필요한가
GPU Pod 하나가 죽어도 GPU 메모리가 해제되지 않거나, `nvidia-smi`에는 프로세스가 안 보이는데
GPU 메모리는 잡혀 있는 현상(**ghost context**)은 현업에서 자주 관찰된다. 원인은 대체로:
1. **PID 1 문제** — 컨테이너의 PID 1이 `bash` 같은 non-init 프로세스면 `waitpid(-1)`을 호출하지 않아 좀비가 쌓임
2. **고아 프로세스** — 부모가 `wait()` 없이 종료하면 자식은 PID 1로 reparent → PID 1이 bash면 계속 살아남음
3. **Signal 전달 실패**`kubectl delete pod` → SIGTERM → PID 1이 자식에게 전달 안 함 → gracePeriod 경과 후 SIGKILL
4. **Orphaned CUDA context** — 프로세스가 SIGKILL로 급사하면 CUDA driver가 미처 context를 정리하지 못해 GPU 메모리 잔존
5. **Ghost context**`nvidia-smi --query-compute-apps`에는 안 잡히는데 `/dev/nvidia*`에 fd가 걸려 있거나 메모리만 점유된 상태
이 실험은 **점진적 수정안의 효과 범위**를 시나리오별로 분리해 측정하는 게 목표다.
---
## 실험 환경
| 항목 | 값 |
|---|---|
| 노드 | `gpu-4` (A100 SXM4 80GB × 4) |
| 컨테이너 런타임 | containerd |
| cgroup | **v1** (systemd slice 형식) |
| K8s namespace | `gpu-zombie-test` |
| 컨테이너 레지스트리 | `harbor.cone-chain.net/aipf` |
| CI/CD | Gitea Actions + Harbor + ArgoCD |
| 관리 권한 | `gpu-4` 노드 SSH + sudo 가능 |
---
## 시나리오 개요
**4개 시나리오**, **3개 이미지**, **4개 Pod**.
(시나리오 2·3은 동일 이미지 `:tini`를 공유하며 Pod manifest의 preStop hook 유무로만 차이)
| # | 이름 | PID 1 | preStop | Worker 코드 | 이미지 태그 | Pod 이름 |
|---|---|---|---|---|---|---|
| 1 | No Fix | `bash` | ❌ | SIGTERM 무시 | `:nofix` | `gpu-zombie-nofix` |
| 2 | tini only | `tini` | ❌ | SIGTERM 무시 | `:tini` | `gpu-zombie-tini` |
| 3 | tini + preStop | `tini` | ✅ | SIGTERM 무시 | `:tini` (재사용) | `gpu-zombie-tini-prestop` |
| 4 | Full Fix | `tini` | ✅ | **SIGTERM handler + CUDA cleanup** | `:fullfix` | `gpu-zombie-fullfix` |
### 각 시나리오가 검증하는 가설
**시나리오 1 — No Fix** (실험 완료, 5가지 변형으로 검증)
> 가설: PID 1이 bash면 좀비/고아/orphaned CUDA context가 모두 발생한다.
검증 결과:
- ✅ 고아 발생 — `parent.py` 종료 후 `gpu_worker.py` PPID=1 reparent
- ✅ 좀비 발생 — kill -9 후 PID 1(sleep)이 reaping 안 함
-**orphaned CUDA context 미발생** — driver 590/580 모두 즉시 회수
-**Pod 삭제 후 GPU 잔존 미발생** — cgroup SIGKILL이 항상 정리
-**새 발견**: SIGTERM 무시 시 30초 grace 동안 GPU 점유 지속 (배포/스케일 시 리소스 낭비)
-**새 발견 (NCCL)**: rank 0 죽으면 rank 1, 2가 hang → GPU 5.7GB 영구 점유 (Pod 삭제 전까지)
> Tip: PID 1 = `sleep infinity` (bash exec 최적화로 sleep이 PID 1을 대체하지만, init 아닌 동작은 동일)
**시나리오 2 — tini only** (미실험, 가설 갱신)
> 가설: tini가 PID 1이면 좀비는 수거되지만, 코드에 signal handler가 없으면 CUDA cleanup은 여전히 실패한다.
원래 가설:
- tini의 `waitpid(-1)` 덕분에 좀비는 사라짐 → ✅ 예상
갱신된 가치 (시나리오 1 결과 반영):
- **좀비 reaping** — 시나리오 1의 좀비 발생을 막는 init 보호
-**orphan CUDA ctx 방지 가치는 사실상 없음** (시나리오 1에서 driver가 자동 회수)
- ⚠️ **30초 grace 낭비는 그대로** (signal handler 없으므로 동일)
**시나리오 3 — tini + preStop** (미실험, 가설 갱신)
> 가설: preStop hook으로 SIGTERM 미리 보내도 worker가 무시하면 SIGKILL fallback 발생.
갱신된 가치:
- preStop이 GPU cleanup 명령(`/usr/local/bin/prestop.sh`)을 grace period 안에 미리 실행
- 다만 worker가 SIGTERM 무시하면 시나리오 2와 동일하게 30초 후 SIGKILL
- ⚠️ **본질적으로 application 코드가 SIGTERM을 처리하지 않으면 효과 제한적**
**시나리오 4 — Full Fix** (미실험, 가장 큰 가치 기대)
> 가설: tini + preStop + 코드 signal handler 모두 적용 시 깨끗한 정리.
갱신된 가치 (가장 중요):
-**30초 grace 낭비 → 0초로 단축** (시나리오 1에서 발견된 진짜 문제 해결)
-**NCCL `dist.destroy_process_group()` 호출** — multi-GPU에서 5.7GB 누수 방지
- worker가 SIGTERM 받아서 즉시 `tensors.clear()``torch.cuda.empty_cache()` → exit
- 결과적으로 GPU 메모리 빠른 반환 + application graceful shutdown
- Pod 삭제 후 GPU 메모리 완전 해제, ghost context 없음
### 최종 비교표 (S1 실측 + S2~4 갱신 예상)
| 항목 | S1 (실측) | S2 (예상) | S3 (예상) | S4 (예상) |
|---|---|---|---|---|
| 좀비 수거 | ❌ 발생 (1~10개) | ✅ tini가 reap | ✅ | ✅ |
| 고아 발생 | ✅ (PPID=1) | ✅ but tini가 reap | ✅ but reap | 미발생 (handler가 정리) |
| SIGTERM 처리 | ❌ SIG_IGN | ❌ SIG_IGN | ❌ but preStop이 cleanup 시도 | ✅ graceful |
| kill -9 후 orphaned CUDA ctx | **❌ 미발생** (driver 회수) | 동일 예상 | 동일 예상 | N/A |
| Pod 삭제 시 GPU 점유 시간 | **30초** (grace 전체) | 30초 (handler 없음) | 30초 (worker 무시) | **0초** ⭐ |
| Pod 삭제 후 GPU 메모리 | ✅ 해제 (SIGKILL 후) | ✅ 해제 | ✅ 해제 | ✅ 즉시 해제 |
| 노드 재부팅 필요 | ❌ 불필요 | ❌ | ❌ | ❌ |
| **NCCL hang 시 GPU 점유** | **5.7GB 영구 점유** | 동일 (handler 없음) | preStop도 hang 못 풂 | NCCL_ASYNC + destroy_process_group |
> **시나리오 2~4는 미실험.** 시나리오 1 결과를 바탕으로 갱신된 예상치. 실측 시 [`results/`](./results/) 업데이트.
---
## 디렉토리 구조
```
process_error/
├── README.md # 이 문서
├── plan.md # 실험 설계 전문 (1500+ 줄)
├── .claude/
│ └── settings.json # Claude Code 권한 설정 (이 프로젝트 한정)
├── .gitea/
│ └── workflows/
│ ├── ci.yaml # 시나리오 이미지 빌드/푸시
│ └── cd.yaml # Helm chart 패키징/푸시
├── scripts/ # 공통 관찰·운영 스크립트
│ ├── observe.sh # 컨테이너 내부 관찰 (좀비/고아/GPU 상태)
│ ├── node-observe.sh # 노드 레벨 GPU 관찰
│ ├── gpu-pod-trace.sh # GPU PID → Container → Pod 역추적 (노드 root 실행)
│ └── prestop.sh # 시나리오 3·4 preStop hook 공용 스크립트
├── scenario-1-nofix/ # 시나리오 1 — bash PID 1
│ ├── Dockerfile
│ ├── parent.py # 자식 spawn 후 wait() 없이 종료
│ ├── gpu_worker.py # SIGTERM 무시, GPU ~2GB 점유
│ └── pod.yaml # manual apply용 (CI/CD 미사용 시)
├── scenario-2-tini-only/ # 시나리오 2 — tini PID 1
│ ├── Dockerfile
│ ├── parent.py
│ ├── gpu_worker.py # 시나리오 1과 동일
│ └── pod.yaml
├── scenario-3-tini-prestop/ # 시나리오 3 — tini + preStop
│ └── pod.yaml # 이미지는 scenario-2 재사용
├── scenario-4-fullfix/ # 시나리오 4 — 전부 적용
│ ├── Dockerfile
│ ├── parent.py
│ ├── gpu_worker_fixed.py # SIGTERM handler + CUDA cleanup
│ └── pod.yaml
├── charts/
│ └── gpu-zombie-test/ # Helm chart (4개 pod template)
│ ├── Chart.yaml
│ ├── values.yaml # scenario enable 토글
│ └── templates/
│ ├── _helpers.tpl
│ ├── pod-nofix.yaml
│ ├── pod-tini.yaml
│ ├── pod-tini-prestop.yaml
│ └── pod-fullfix.yaml
├── argocd-app.yaml # ArgoCD Application 정의
└── results/ # 시나리오별 실험 결과
├── scenario-1/
│ ├── observe-after-spawn.log
│ ├── observe-after-kill.log
│ ├── gpu-pod-trace.log
│ ├── nvidia-smi-before-delete.log
│ ├── nvidia-smi-after-delete.log
│ └── notes.md
├── scenario-2/
├── scenario-3/
├── scenario-4/
└── comparison.md # 최종 비교 분석
```
> **현재 상태:** `plan.md`, `README.md`, `.claude/settings.json`만 존재.
> 나머지는 `plan.md`의 설계대로 Phase 0부터 순차 생성 예정.
---
## 실험 흐름 — 어떻게 동작하는가
### Phase 0 — CI/CD 환경 구성
1. **Gitea repo 생성** (`soo-rnd/gpu-zombie-test`) + Secrets 등록 (REGISTRY_URL/USER/PASSWORD)
2. **K8s namespace + pull secret** 사전 생성
3. **`.gitea/workflows/ci.yaml`** 작성 — `scenario-*/` 변경 시 matrix로 3개 이미지를 Harbor에 빌드·푸시
4. **`.gitea/workflows/cd.yaml`** 작성 — `v*` tag push 시 Helm chart를 Harbor OCI에 푸시
5. **`charts/gpu-zombie-test/`** Helm chart 작성 — 4개 Pod template
6. **`argocd-app.yaml`** apply — 이후 chart 버전 업 시 ArgoCD가 자동 sync
```
[코드 변경]
├─ scenario-*/ 변경 → CI 트리거
│ └─ 3개 이미지 build → Harbor push (:nofix, :tini, :fullfix + sha 태그)
└─ git tag v0.1.x → CD 트리거
└─ Helm chart package → Harbor OCI push
└─ ArgoCD 자동 sync
└─ gpu-zombie-test ns에 4개 Pod 배포
```
### Phase 1~2-C — 시나리오 실행 사이클
각 시나리오는 다음 공통 흐름을 따름:
```bash
# 1. 코드 수정 → push → CI 자동 빌드 확인 (Gitea Actions UI)
# 2. Pod 재생성으로 새 이미지 pull (imagePullPolicy: Always)
kubectl delete pod gpu-zombie-<scenario> -n gpu-zombie-test
# → ArgoCD selfHeal로 즉시 재배포
# 3. Pod 진입 & 실험
kubectl exec -it gpu-zombie-<scenario> -n gpu-zombie-test -- bash
# 4. 컨테이너 내부 관찰
observe.sh # 좀비/고아/GPU 상태
# 5. 노드에서 역추적 (별도 터미널)
ssh gpu-4
bash /path/to/gpu-pod-trace.sh
# 6. 특정 액션 수행 후 재관찰
# - parent.py 실행 후 고아 생성
# - kill -9 <worker_pid>로 orphaned context 유발
# - kubectl delete pod로 preStop 동작 확인
# 7. 결과 로그 저장 → results/scenario-<N>/
```
---
## 관찰 도구 — 무엇을 어떻게 확인하는가
### `scripts/observe.sh` (컨테이너 내부)
컨테이너 안에서 실행. 다음 8가지를 한 번에 점검:
1. PID 1 cmdline (bash vs tini)
2. 전체 프로세스 트리 (`ps auxf`)
3. 좀비 프로세스 (`STAT=Z`)
4. 고아 프로세스 (PPID=1)
5. GPU 메모리 상태 (`nvidia-smi --query-gpu`)
6. GPU 사용 프로세스 (`nvidia-smi --query-compute-apps`)
7. `/dev/nvidia*` fd 보유 프로세스 (`fuser`)
8. Orphaned GPU context 종합 체크 (nvidia-smi ∩ fuser ∩ /proc 생존 확인)
> **⚠️ 알려진 한계 (시나리오 1 실험에서 발견)**: torch 사용 시 nvidia-smi가 컨테이너 내부 PID가 아닌
> **노드 PID**를 반환 → observe.sh가 컨테이너 내부 `/proc/<노드 PID>` 조회 실패 → "ORPHANED" 오탐지 발생.
> 정확한 orphaned context 판정은 노드 레벨 `gpu-pod-trace.sh`에서만 가능.
> raw CUDA driver API(ctypes) 사용 시는 컨테이너 PID가 일치해서 오탐지 없음.
### `scripts/node-observe.sh` (gpu-4 노드 root)
노드 레벨에서 GPU 전체 상태 + ghost context 감지.
### `scripts/gpu-pod-trace.sh` (gpu-4 노드 root) ⭐ 핵심
cgroup v1 + containerd + systemd slice 포맷에 맞춰 작성된 **GPU PID → Pod 역추적** 스크립트.
- `/proc/<pid>/cgroup` 파싱 → container ID 추출 → `crictl inspect` → Pod name/namespace/container name
- nvidia-smi에 안 잡히는 숨은 프로세스(`fuser`로만 감지)도 추적
- Ghost context (프로세스 0개인데 GPU 메모리 점유) 별도 경고
출력 예시:
```
PID STATE GPU_MEM NSMI QOS POD NAMESPACE CONTAINER
12345 R 2048 MiB Y besteffort gpu-zombie-nofix gpu-zombie-test worker
```
### `scripts/prestop.sh` (Pod preStop hook)
시나리오 3·4의 이미지에 `/usr/local/bin/prestop.sh`로 포함.
- GPU 점유 PID 수집 (nvidia-smi + fuser)
- SIGTERM 전송 → 최대 20초 대기
- 미종료 시 SIGKILL fallback (시나리오 3에서 트리거)
---
## 결과는 어디서 확인하는가
### 1. CI/CD 동작 확인
| 항목 | 위치 |
|---|---|
| CI 빌드 로그 | `https://gitea.inje-private.com/soo-rnd/gpu-zombie-test/actions` |
| 푸시된 이미지 | `https://harbor.cone-chain.net/harbor/projects/aipf/repositories/gpu-zombie-test` |
| 푸시된 Helm chart | Harbor 동 project OCI artifacts |
| ArgoCD sync 상태 | `https://argocd.gpulive.cloud` → Application `gpu-zombie-test` |
### 2. Pod & 클러스터 상태
```bash
# 배포된 Pod 확인
kubectl get pods -n gpu-zombie-test
# 특정 Pod 상세 (events, restarts, container status)
kubectl describe pod gpu-zombie-<scenario> -n gpu-zombie-test
# Pod 로그 (parent.py, gpu_worker.py 출력 + preStop 로그)
kubectl logs gpu-zombie-<scenario> -n gpu-zombie-test
# Pod 삭제 시 preStop 로그를 실시간으로 보려면
kubectl logs -f gpu-zombie-<scenario> -n gpu-zombie-test &
kubectl delete pod gpu-zombie-<scenario> -n gpu-zombie-test
```
### 3. GPU 상태 (노드에서)
```bash
ssh ubuntu@gpu-4
sudo -i
# 한 눈에 보기
watch -n 1 'nvidia-smi'
# 전체 진단
bash /path/to/scripts/node-observe.sh
# PID → Pod 역추적
bash /path/to/scripts/gpu-pod-trace.sh
```
### 4. 실험 결과 파일 (`results/`)
각 시나리오별로 다음 파일들을 저장:
| 파일 | 내용 |
|---|---|
| `observe-after-spawn.log` | parent.py 실행 직후 `observe.sh` 출력 |
| `observe-after-kill.log` | `kill -9 <worker>` 직후 `observe.sh` 출력 — orphaned context 확인 |
| `gpu-pod-trace.log` | 노드에서 `gpu-pod-trace.sh` 출력 — PID→Pod 매핑 |
| `nvidia-smi-before-delete.log` | Pod 삭제 직전 GPU 상태 |
| `nvidia-smi-after-delete.log` | Pod 삭제 직후 GPU 상태 (메모리 잔존 여부) |
| `notes.md` | 해당 시나리오 특이사항, 예상과 다른 결과, 타이밍 이슈 등 |
### 5. 최종 비교 — `results/comparison.md`
모든 시나리오 완료 후, 4개 결과를 한 표로 정리하고 각 수정안의 효과를 정량화.
plan.md의 "Phase 3: 결과 비교" 템플릿 사용.
---
## Quick Start
**전제:** `plan.md` 기준 Phase 0 (CI/CD 환경) 완료 상태.
```bash
# 1. 클러스터 접근 확인
kubectl config current-context
kubectl get ns gpu-zombie-test
# 2. 배포된 Pod 확인 (ArgoCD가 sync 완료한 상태)
kubectl get pods -n gpu-zombie-test
# NAME READY STATUS ...
# gpu-zombie-nofix 1/1 Running
# gpu-zombie-tini 1/1 Running
# gpu-zombie-tini-prestop 1/1 Running
# gpu-zombie-fullfix 1/1 Running
# 3. 시나리오 1부터 순차 실험 — plan.md Phase 1 참조
kubectl exec -it gpu-zombie-nofix -n gpu-zombie-test -- bash
# (컨테이너 내부)
python3 parent.py &
sleep 10
observe.sh
# 4. 노드에서 역추적
ssh ubuntu@gpu-4
sudo bash gpu-pod-trace.sh
# 5. 결과 저장 (로컬)
mkdir -p results/scenario-1
kubectl exec gpu-zombie-nofix -n gpu-zombie-test -- observe.sh > results/scenario-1/observe-after-spawn.log
# 6. 다음 시나리오 전 GPU 초기화 확인
nvidia-smi
# (필요 시 nvidia-smi --gpu-reset 또는 노드 재부팅)
```
---
## 주의사항
1. **실험은 `gpu-4` 노드를 독점한다** — 다른 GPU 워크로드가 있으면 간섭 가능.
실험 전 `nvidia-smi`로 깨끗한 상태 확인.
2. **시나리오 간 GPU 초기화 필수** — orphaned/ghost context가 누적되면 후속 시나리오 결과가 오염된다.
정리 안 되면 `nvidia-smi --gpu-reset` 시도, 안 되면 **gpu-4 노드 재부팅**.
3. **`nvidia-smi --gpu-reset`은 해당 GPU를 쓰는 다른 컨텍스트가 있으면 실패** — 모든 GPU pod 정리 후 시도.
4. **`prestop.sh`는 이미지 2개(`:tini`, `:fullfix`)에만 포함** — 시나리오 1의 `:nofix` 이미지에는 없음.
5. **시나리오 2와 3은 같은 이미지를 쓴다** — scenario-3는 별도 이미지를 빌드하지 않고 `:tini` 재사용.
CI matrix에 scenario-3가 없는 것은 의도된 것.
---
## 참고 문서
- **설계 전문:** [`plan.md`](./plan.md) — 모든 스크립트·매니페스트·체크리스트 포함 (1500+ 줄)
- **Claude Code 권한:** [`.claude/settings.json`](./.claude/settings.json) — 이 프로젝트 한정 권한
- **CI/CD 참고 템플릿:** `/home/ubuntu/soo/vLLM/A100_npu/` — 동일 Gitea/Harbor/ArgoCD 스택 사용 중