gpu-zombie-test/plan.md

1635 lines
53 KiB
Markdown

# GPU Zombie/Orphan Process & Orphaned CUDA Context 재현 및 해결 실험 계획서
## 실험 환경
| 항목 | 값 |
|---|---|
| 노드 | `gpu-4` (A100) |
| 재부팅 가능 여부 | O |
| 컨테이너 런타임 | containerd |
| cgroup | **v1 (systemd slice 형식)** |
| 실험 도구 | Claude CLI (claude code) |
### cgroup v1 경로 형식 (확인 완료)
```
# /proc/<PID>/cgroup 출력 형태:
9:memory:/kubepods.slice/kubepods-<QOS>.slice/kubepods-<QOS>-pod<POD_UID>.slice/cri-containerd-<CONTAINER_ID>.scope
# 실제 확인된 예시:
9:memory:/kubepods.slice/kubepods-besteffort.slice/kubepods-besteffort-pod4432d9c6_f213_41de_94eb_61ce8fa70f9b.slice/cri-containerd-327289ee8e99a9c53b46e737f01eb0e54373f3cdca986a60e88c21908e0c8a92.scope
# 파싱 포인트:
# Container ID → cri-containerd- 뒤의 64자 hex (.scope 제거)
# Pod UID → pod 뒤의 언더스코어 구분 UUID (하이픈으로 변환 필요)
# QoS → kubepods-besteffort / kubepods-burstable / kubepods.slice 바로 아래면 guaranteed
```
---
## ⭐ 실험 결과 요약 (2026-04-13~14)
> **이 plan.md는 실험 전 작성됐고, 실제 진행 결과 핵심 가설이 검증되지 않음.**
> 시나리오 2~4 진행 전에 이 섹션을 먼저 읽고 가설을 갱신할 것.
> 자세한 비교 분석: [`results/comparison.md`](./results/comparison.md)
### 시나리오 1을 5가지 변형으로 실행한 결과
| # | 변형 | 결과 디렉토리 |
|---|---|---|
| 1 | 기본 (root, 1 worker, A100) | `results/scenario-1/` |
| 2 | 구버전 driver (V100, Driver 580) | `results/scenario-1-driver-580.126.09/` |
| 3 | non-root (UID 1000) | `results/scenario-1-user-soo/` |
| 4 | 10 workers 동시 | `results/scenario-1-user-soo-10workers/` |
| 5 | NCCL 3 GPU 분산 | `results/scenario-1-nccl-3gpu/` |
### plan.md 가설 vs 실측 결과
| plan.md 가설 | 실측 결과 | 비고 |
|---|---|---|
| `kill -9` 후 orphaned CUDA context 잔존 | **❌ 미발생 (5개 변형 모두)** | Driver 580/590이 즉시 회수 |
| Pod 삭제 후 GPU 메모리 잔존 | **❌ 미발생** | cgroup SIGKILL → driver 회수 |
| 노드 재부팅 필요 가능 | **❌ 불필요** | 모든 케이스 자동 정리 |
| `nvidia-smi --gpu-reset` 필요 | **❌ 불필요** | 동일 |
| 좀비 발생 (PID 1 = init 아님) | ✅ **재현 성공** | sleep도 reaping 안 함 |
| 고아 발생 | ✅ **재현 성공** | parent 종료 후 PPID=1 |
### 새로 발견된 진짜 문제 (plan.md에 없던 것)
| 문제 | 발견 시나리오 | 영향 | 해결 (시나리오 X에서 검증 예정) |
|---|---|---|---|
| **30초 grace period 동안 GPU 점유** | #1 (단일 worker), #4 (10w 740 GB·sec) | 배포 시마다 발생, 누적 영향 큼 | **시나리오 4 (SIGTERM handler)** |
| **NCCL hang으로 영구 GPU 점유** | #5 (멀티 GPU) | 5.7 GB 무한 점유 | NCCL_ASYNC_ERROR_HANDLING + timeout |
| **Peer context 부분 잔존** | #5 NCCL only | 148 MiB 작은 누수 | NCCL hang 해결 시 자동 |
### Step 3-B 추가 (전 시나리오 공통)
> plan.md 원래 절차에는 "kill -9 후 관찰" 만 있었는데, **실제 운영 시나리오는 "worker 살아있는 상태에서 Pod 삭제"**.
> 이 케이스를 모든 시나리오에 추가해야 함.
```bash
# Step 3-B: worker 살아있는 상태에서 Pod 삭제
# 1. parent.py 실행 → worker가 GPU 점유 중 확인
kubectl exec <pod> -- bash -c "cd /app && python3 parent.py & sleep 10 && observe.sh"
# 2. 노드에서 GPU 모니터링 (백그라운드)
ssh ubuntu@gpu-4 'bash /tmp/gpu-monitor.sh' > step3b-monitor.log &
# 3. Pod 삭제 (worker는 살아있고 SIGTERM 무시 상태)
kubectl delete pod <pod> -n gpu-zombie-test
# 4. 모니터 로그 분석 (1초 간격으로 GPU 메모리 변화)
# 예상 패턴: 30초 점유 지속 → SIGKILL → 1초 이내 해제
```
이 절차는 **GPU·sec 낭비량을 정량 측정**하는 핵심 데이터를 제공.
### 시나리오 2~4의 가치 재정의
| 시나리오 | 원래 목적 (plan.md) | 실제 가치 (시나리오 1 결과 후 갱신) |
|---|---|---|
| 2 (tini) | orphan ctx 방지 | **좀비 reaping** (init protection) — orphan ctx와 무관 |
| 3 (preStop) | orphan ctx 방지 | preStop hook으로 grace 안에 cleanup 시도 — worker가 SIGTERM 무시면 효과 제한 |
| 4 (Full Fix) | orphan ctx 방지 | ⭐ **30초 grace 낭비 제거 + NCCL graceful shutdown** ← 가장 큰 가치 |
### NCCL 멀티 GPU의 특수성 (시나리오 4 변형으로 검증 필요)
```python
# 시나리오 4 NCCL 변형에 추가할 코드
import os, signal
from datetime import timedelta
import torch.distributed as dist
os.environ["NCCL_ASYNC_ERROR_HANDLING"] = "1"
dist.init_process_group(
backend="nccl",
timeout=timedelta(seconds=60) # rank 죽으면 60초 후 abort
)
def graceful_shutdown(signum, frame):
print(f"[Worker] Signal {signum} received, NCCL cleanup...")
dist.destroy_process_group() # NCCL 정리 (collective op abort)
torch.cuda.empty_cache()
sys.exit(0)
signal.signal(signal.SIGTERM, graceful_shutdown)
```
### observe.sh의 알려진 한계
torch 사용 시 nvidia-smi가 노드 PID를 반환 → 컨테이너 내부에서 `/proc/<노드 PID>` 조회 실패 → **observe.sh가 "ORPHANED GPU CONTEXT"로 오탐지**.
정확한 판정은 노드 레벨 `gpu-pod-trace.sh`에서만 가능. raw CUDA(ctypes) 사용 시는 PID 일치해서 오탐지 없음.
---
## 사전 준비
### 체크리스트
- [ ] `gpu-4` 노드에 SSH 접속 가능 확인
- [ ] `gpu-4` 노드에 GPU가 정상 동작하는지 확인 (`nvidia-smi`)
- [ ] `gpu-4` 노드에 다른 GPU 워크로드가 없는지 확인 (실험 간 간섭 방지)
- [ ] `kubectl` 클러스터 접근 권한 확인
- [ ] 실험용 namespace 생성 (`kubectl create ns gpu-zombie-test`)
- [ ] 컨테이너 이미지 빌드 환경 준비 (docker 또는 podman)
- [ ] 컨테이너 레지스트리 push 가능 확인
- [ ] `gpu-4` 노드에 `crictl` 설치 확인 (`crictl version`)
- [ ] `gpu-4` 노드에 `fuser` 설치 확인 (`which fuser`, 없으면 `apt install psmisc`)
- [ ] `gpu-4` 노드에 `jq` 설치 확인 (`jq --version`, 없으면 `apt install jq`)
### 디렉토리 구조
```
gpu-zombie-test/
├── plan.md # 이 문서
├── scripts/
│ ├── observe.sh # 컨테이너 내부 관찰 스크립트
│ ├── node-observe.sh # 노드 레벨 GPU 관찰 스크립트
│ ├── gpu-pod-trace.sh # GPU PID → Pod 역추적 스크립트 (노드에서 실행)
│ └── prestop.sh # 시나리오 3·4 preStop hook 공용 스크립트
├── scenario-1-nofix/
│ ├── Dockerfile
│ ├── parent.py
│ ├── gpu_worker.py
│ └── pod.yaml
├── scenario-2-tini-only/
│ ├── Dockerfile
│ ├── parent.py
│ ├── gpu_worker.py # SIGTERM 무시 (signal handler 없음)
│ └── pod.yaml
├── scenario-3-tini-prestop/
│ ├── Dockerfile
│ ├── parent.py
│ ├── gpu_worker.py # SIGTERM 무시 (signal handler 없음)
│ └── pod.yaml
├── scenario-4-fullfix/
│ ├── Dockerfile
│ ├── parent.py
│ ├── gpu_worker_fixed.py # SIGTERM handler + CUDA cleanup 포함
│ └── pod.yaml
└── results/
└── comparison.md # 최종 결과 비교표
```
---
## 공통 스크립트
### scripts/observe.sh (컨테이너 내부에서 실행)
```bash
#!/bin/bash
echo "=========================================="
echo "컨테이너 내부 관찰: $(date)"
echo "=========================================="
echo ""
echo "[1] PID 1 확인"
cat /proc/1/cmdline | tr '\0' ' '
echo ""
echo ""
echo "[2] 전체 프로세스 트리"
ps auxf
echo ""
echo ""
echo "[3] 좀비 프로세스 (STAT=Z)"
ZOMBIES=$(ps aux | awk '$8 ~ /Z/')
if [ -z "$ZOMBIES" ]; then
echo " → 좀비 없음 ✅"
else
echo " → 좀비 발견 ❌"
echo "$ZOMBIES"
fi
echo ""
echo ""
echo "[4] 고아 프로세스 (PPID=1, PID 1 자체 제외)"
ORPHANS=$(ps -eo pid,ppid,stat,cmd | awk '$2==1 && $1!=1')
if [ -z "$ORPHANS" ]; then
echo " → 고아 없음 ✅"
else
echo " → 고아 발견 ⚠️"
echo "$ORPHANS"
fi
echo ""
echo ""
echo "[5] GPU 메모리 상태"
nvidia-smi --query-gpu=index,memory.used,memory.total --format=csv
echo ""
echo ""
echo "[6] GPU 사용 프로세스 (nvidia-smi)"
GPU_PIDS=$(nvidia-smi --query-compute-apps=pid,used_gpu_memory,name --format=csv,noheader 2>/dev/null)
if [ -z "$GPU_PIDS" ]; then
echo " → nvidia-smi에 프로세스 없음"
else
echo "$GPU_PIDS"
fi
echo ""
echo ""
echo "[7] /dev/nvidia* fd 보유 프로세스 (nvidia-smi에 안 나올 수 있음)"
for dev in /dev/nvidia[0-9]* /dev/nvidia-uvm /dev/nvidiactl; do
[ -e "$dev" ] || continue
PIDS=$(fuser "$dev" 2>/dev/null)
if [ -n "$PIDS" ]; then
echo " $dev$PIDS"
fi
done
echo ""
echo ""
echo "[8] Orphaned GPU Context 종합 체크"
# 8-a. nvidia-smi에 있는 PID 중 죽은 것
echo " 8-a. nvidia-smi 등록 프로세스 생존 확인:"
NSMI_PIDS=$(nvidia-smi --query-compute-apps=pid --format=csv,noheader,nounits 2>/dev/null)
if [ -z "$NSMI_PIDS" ]; then
echo " nvidia-smi에 등록된 프로세스 없음"
else
for pid in $NSMI_PIDS; do
if [ -d "/proc/$pid" ]; then
STATE=$(cat /proc/$pid/status 2>/dev/null | grep "^State:" | awk '{print $2}')
echo " PID $pid: 살아있음 (State=$STATE)"
else
echo " PID $pid: 죽었음 → ❌ ORPHANED GPU CONTEXT"
fi
done
fi
# 8-b. nvidia-smi에 안 나오지만 fuser에 잡히는 프로세스
echo " 8-b. nvidia-smi 미등록 but GPU fd 보유:"
for dev in /dev/nvidia[0-9]* /dev/nvidia-uvm /dev/nvidiactl; do
[ -e "$dev" ] || continue
for pid in $(fuser "$dev" 2>/dev/null); do
if ! echo "$NSMI_PIDS" | grep -q "^${pid}$"; then
if [ -d "/proc/$pid" ]; then
CMD=$(cat /proc/$pid/cmdline 2>/dev/null | tr '\0' ' ' | head -c 50)
echo " ⚠️ PID $pid ($dev): $CMD"
fi
fi
done
done
# 8-c. Ghost context (프로세스 0인데 메모리 점유)
echo " 8-c. Ghost context 체크:"
PROC_COUNT=$(echo "$NSMI_PIDS" | grep -v '^$' | wc -l)
nvidia-smi --query-gpu=index,memory.used --format=csv,noheader,nounits 2>/dev/null | while IFS=', ' read -r idx mem; do
if [ "$PROC_COUNT" -eq 0 ] && [ "$mem" -gt 100 ]; then
echo " ⚠️ GPU $idx: 프로세스 없는데 ${mem}MiB 점유 → GHOST CONTEXT"
else
echo " GPU $idx: ${mem}MiB (프로세스 ${PROC_COUNT}개)"
fi
done
echo ""
echo "=========================================="
```
### scripts/node-observe.sh (gpu-4 노드에서 root로 실행)
```bash
#!/bin/bash
echo "=========================================="
echo "노드 레벨 GPU 관찰: $(date)"
echo "=========================================="
echo ""
echo "[1] nvidia-smi 전체"
nvidia-smi
echo ""
echo "[2] GPU 프로세스 목록"
nvidia-smi --query-compute-apps=pid,used_gpu_memory,name --format=csv
echo ""
echo "[3] /dev/nvidia* 사용 프로세스 (fuser)"
for dev in /dev/nvidia[0-9]* /dev/nvidia-uvm /dev/nvidiactl; do
[ -e "$dev" ] || continue
PIDS=$(fuser "$dev" 2>/dev/null)
if [ -n "$PIDS" ]; then
echo " $dev$PIDS"
fi
done
echo ""
echo "[4] nvidia-smi 미등록 but /dev/nvidia fd 보유 프로세스"
NVIDIA_PIDS=$(nvidia-smi --query-compute-apps=pid --format=csv,noheader,nounits 2>/dev/null | sort -u)
for dev in /dev/nvidia[0-9]* /dev/nvidia-uvm /dev/nvidiactl; do
[ -e "$dev" ] || continue
for pid in $(fuser "$dev" 2>/dev/null); do
if ! echo "$NVIDIA_PIDS" | grep -q "^${pid}$"; then
CMD=$(cat /proc/$pid/cmdline 2>/dev/null | tr '\0' ' ' | head -c 60)
echo " ⚠️ PID $pid ($dev): nvidia-smi 미등록, CMD: $CMD"
fi
done
done
echo ""
echo "[5] Ghost context 체크 (프로세스 0 but 메모리 점유)"
PROC_COUNT=$(nvidia-smi --query-compute-apps=pid --format=csv,noheader,nounits 2>/dev/null | grep -v '^$' | wc -l)
nvidia-smi --query-gpu=index,memory.used --format=csv,noheader,nounits 2>/dev/null | while IFS=', ' read -r idx mem; do
if [ "$PROC_COUNT" -eq 0 ] && [ "$mem" -gt 100 ]; then
echo " ⚠️ GPU $idx: 프로세스 0개인데 ${mem}MiB 점유 → GHOST CONTEXT"
fi
done
echo "=========================================="
```
### scripts/prestop.sh (시나리오 3·4 컨테이너 이미지에 포함)
시나리오 3·4의 preStop hook이 호출하는 단일 스크립트.
SIGTERM 전송 → 최대 20초 대기 → SIGKILL fallback의 슈퍼셋 동작.
- 시나리오 3 (`tini` 이미지, worker가 SIGTERM 무시): fallback 경로가 실제로 트리거됨
- 시나리오 4 (`fullfix` 이미지, worker가 SIGTERM 처리): fallback 경로 미트리거 (graceful 종료)
```bash
#!/bin/sh
# /usr/local/bin/prestop.sh
# Pod preStop hook용 GPU cleanup 스크립트
# 시나리오 3·4 공통 사용
echo "[preStop] GPU cleanup 시작: $(date)"
# 1. nvidia-smi + fuser로 GPU 점유 PID 수집
GPU_PIDS=$(nvidia-smi --query-compute-apps=pid --format=csv,noheader,nounits 2>/dev/null)
FUSER_PIDS=""
for dev in /dev/nvidia[0-9]* /dev/nvidia-uvm; do
[ -e "$dev" ] || continue
PIDS=$(fuser "$dev" 2>/dev/null)
FUSER_PIDS="$FUSER_PIDS $PIDS"
done
ALL_PIDS=$(echo "$GPU_PIDS $FUSER_PIDS" | tr ' ' '\n' | sort -u | grep -v '^$')
echo "[preStop] 전체 GPU 관련 PIDs: $ALL_PIDS"
# 2. PID 1(tini) 제외하고 SIGTERM 전송
for pid in $ALL_PIDS; do
if [ "$pid" != "1" ]; then
echo "[preStop] SIGTERM -> PID $pid"
kill -TERM $pid 2>/dev/null
fi
done
# 3. 최대 20초 대기 — graceful cleanup 기다림
for i in $(seq 1 20); do
REMAINING=$(nvidia-smi --query-compute-apps=pid --format=csv,noheader,nounits 2>/dev/null | wc -l)
if [ "$REMAINING" -eq 0 ]; then
echo "[preStop] GPU 프로세스 정리 완료 ✅"
break
fi
echo "[preStop] 대기 ($i/20) 남은 프로세스: $REMAINING"
sleep 1
done
# 4. SIGKILL fallback (시나리오 3에서 트리거됨)
STILL=$(nvidia-smi --query-compute-apps=pid --format=csv,noheader,nounits 2>/dev/null | wc -l)
if [ "$STILL" -gt 0 ]; then
echo "[preStop] ⚠️ SIGTERM 무시됨 → SIGKILL fallback"
for pid in $(nvidia-smi --query-compute-apps=pid --format=csv,noheader,nounits 2>/dev/null); do
kill -9 $pid 2>/dev/null
done
fi
echo "[preStop] 완료: $(date)"
```
### scripts/gpu-pod-trace.sh (gpu-4 노드에서 root로 실행)
cgroup v1 + containerd + systemd slice 형식에 맞춰 작성.
```bash
#!/bin/bash
# GPU Process → Container → Pod 역추적 스크립트
# 환경: cgroup v1, containerd, systemd slice 형식
# gpu-4 노드에서 root로 실행
echo "============================================"
echo "GPU Process → Pod 매핑 (cgroup v1)"
echo "시각: $(date)"
echo "노드: $(hostname)"
echo "============================================"
# --- 함수 정의 ---
extract_container_id() {
local pid=$1
# cgroup v1 systemd slice: cri-containerd-<64hex>.scope
grep ':memory:' /proc/$pid/cgroup 2>/dev/null | grep -oP 'cri-containerd-\K[a-f0-9]{64}'
}
extract_pod_uid() {
local pid=$1
# kubepods-<qos>-pod<UUID_underscores>.slice → 하이픈 변환
local raw=$(grep ':memory:' /proc/$pid/cgroup 2>/dev/null | grep -oP 'pod\K[a-f0-9_]+(?=\.slice)')
if [ -n "$raw" ]; then
echo "$raw" | tr '_' '-'
fi
}
extract_qos() {
local pid=$1
local qos=$(grep ':memory:' /proc/$pid/cgroup 2>/dev/null | grep -oP 'kubepods-\K(besteffort|burstable)(?=[\.-])' | head -1)
if [ -n "$qos" ]; then
echo "$qos"
else
grep ':memory:' /proc/$pid/cgroup 2>/dev/null | grep -q 'kubepods' && echo "guaranteed" || echo "host"
fi
}
get_pod_info() {
local container_id=$1
local info=$(crictl inspect "$container_id" 2>/dev/null)
[ -z "$info" ] && return
local pod=$(echo "$info" | grep -oP '"io.kubernetes.pod.name":\s*"\K[^"]+' | head -1)
local ns=$(echo "$info" | grep -oP '"io.kubernetes.pod.namespace":\s*"\K[^"]+' | head -1)
local cont=$(echo "$info" | grep -oP '"io.kubernetes.container.name":\s*"\K[^"]+' | head -1)
echo "${pod}|${ns}|${cont}"
}
# --- 1단계: GPU 점유 PID 수집 ---
echo ""
echo "[1] GPU 점유 프로세스 수집 (nvidia-smi + fuser)"
NVIDIA_PIDS=$(nvidia-smi --query-compute-apps=pid --format=csv,noheader,nounits 2>/dev/null | sort -u)
FUSER_PIDS=""
for dev in /dev/nvidia[0-9]* /dev/nvidia-uvm /dev/nvidiactl; do
[ -e "$dev" ] || continue
PIDS=$(fuser "$dev" 2>/dev/null)
FUSER_PIDS="$FUSER_PIDS $PIDS"
done
FUSER_PIDS=$(echo $FUSER_PIDS | tr ' ' '\n' | sort -u | grep -v '^$')
ALL_PIDS=$(echo -e "${NVIDIA_PIDS}\n${FUSER_PIDS}" | sort -u | grep -v '^$')
if [ -z "$ALL_PIDS" ]; then
echo " GPU 점유 프로세스 없음"
echo ""
echo "[!] Ghost Context 체크:"
nvidia-smi --query-gpu=index,memory.used --format=csv,noheader,nounits 2>/dev/null | while IFS=', ' read -r idx mem; do
if [ "$mem" -gt 100 ]; then
echo " ⚠️ GPU $idx: 프로세스 없는데 ${mem}MiB 점유 → GHOST CONTEXT"
echo " 추적 불가. nvidia-smi --gpu-reset -i $idx 또는 노드 재부팅 필요"
else
echo " ✅ GPU $idx: ${mem}MiB (정상)"
fi
done
exit 0
fi
echo " nvidia-smi PIDs: $(echo $NVIDIA_PIDS | tr '\n' ' ')"
echo " fuser PIDs: $(echo $FUSER_PIDS | tr '\n' ' ')"
# --- 2단계: PID → Container → Pod 매핑 ---
echo ""
echo "[2] PID → Container → Pod 매핑"
echo "--------------------------------------------------------------------------------------------------------------"
printf "%-8s %-6s %-12s %-8s %-12s %-30s %-20s %-15s\n" \
"PID" "STATE" "GPU_MEM" "NSMI" "QOS" "POD" "NAMESPACE" "CONTAINER"
echo "--------------------------------------------------------------------------------------------------------------"
for pid in $ALL_PIDS; do
IN_NSMI="N"
echo "$NVIDIA_PIDS" | grep -q "^${pid}$" && IN_NSMI="Y"
# 프로세스 생존
if [ ! -d "/proc/$pid" ]; then
printf "%-8s %-6s %-12s %-8s %-12s %-30s\n" \
"$pid" "DEAD" "orphaned" "$IN_NSMI" "-" "❌ /proc 없음 — Pod 추적 불가"
continue
fi
STATE=$(cat /proc/$pid/status 2>/dev/null | grep "^State:" | awk '{print $2}')
GPU_MEM=$(nvidia-smi --query-compute-apps=pid,used_gpu_memory \
--format=csv,noheader,nounits 2>/dev/null | grep "^${pid}," | awk -F', ' '{print $2}' | xargs)
[ -z "$GPU_MEM" ] && GPU_MEM="fd_only"
CONTAINER_ID=$(extract_container_id $pid)
QOS=$(extract_qos $pid)
if [ -z "$CONTAINER_ID" ]; then
CMD=$(cat /proc/$pid/cmdline 2>/dev/null | tr '\0' ' ' | head -c 40)
printf "%-8s %-6s %-12s %-8s %-12s %-30s\n" \
"$pid" "$STATE" "${GPU_MEM}" "$IN_NSMI" "$QOS" "호스트: $CMD"
continue
fi
POD_INFO=$(get_pod_info "$CONTAINER_ID")
if [ -n "$POD_INFO" ]; then
POD_NAME=$(echo "$POD_INFO" | cut -d'|' -f1)
NAMESPACE=$(echo "$POD_INFO" | cut -d'|' -f2)
CONT_NAME=$(echo "$POD_INFO" | cut -d'|' -f3)
printf "%-8s %-6s %-12s %-8s %-12s %-30s %-20s %-15s\n" \
"$pid" "$STATE" "${GPU_MEM}" "$IN_NSMI" "$QOS" "$POD_NAME" "$NAMESPACE" "$CONT_NAME"
else
SHORT_ID=$(echo "$CONTAINER_ID" | head -c 12)
POD_UID=$(extract_pod_uid $pid)
printf "%-8s %-6s %-12s %-8s %-12s %-30s\n" \
"$pid" "$STATE" "${GPU_MEM}" "$IN_NSMI" "$QOS" "⚠️ 삭제됨 (ctr:${SHORT_ID} pod:${POD_UID})"
fi
done
echo "--------------------------------------------------------------------------------------------------------------"
# --- 3단계: 숨겨진 프로세스 ---
HIDDEN_PIDS=$(comm -13 <(echo "$NVIDIA_PIDS" | sort) <(echo "$FUSER_PIDS" | sort) 2>/dev/null | grep -v '^$')
if [ -n "$HIDDEN_PIDS" ]; then
echo ""
echo "[3] ⚠️ nvidia-smi 미등록 but GPU fd 보유 프로세스:"
for pid in $HIDDEN_PIDS; do
if [ -d "/proc/$pid" ]; then
CMD=$(cat /proc/$pid/cmdline 2>/dev/null | tr '\0' ' ' | head -c 60)
FDS=$(ls -la /proc/$pid/fd 2>/dev/null | grep nvidia | wc -l)
CONTAINER_ID=$(extract_container_id $pid)
if [ -n "$CONTAINER_ID" ]; then
POD_INFO=$(get_pod_info "$CONTAINER_ID")
POD_NAME=$(echo "$POD_INFO" | cut -d'|' -f1)
echo " PID $pid: nvidia fd ${FDS}개, Pod: ${POD_NAME:-unknown}, CMD: $CMD"
else
echo " PID $pid: nvidia fd ${FDS}개, 호스트 프로세스, CMD: $CMD"
fi
fi
done
fi
# --- 4단계: 요약 ---
echo ""
echo "[4] 요약"
TOTAL=$(echo "$ALL_PIDS" | grep -v '^$' | wc -l)
DEAD=0; ZOMBIE=0; HIDDEN_COUNT=0
for pid in $ALL_PIDS; do
[ ! -d "/proc/$pid" ] && DEAD=$((DEAD+1))
[ -d "/proc/$pid" ] && grep -q "^State:.*Z" /proc/$pid/status 2>/dev/null && ZOMBIE=$((ZOMBIE+1))
done
[ -n "$HIDDEN_PIDS" ] && HIDDEN_COUNT=$(echo "$HIDDEN_PIDS" | grep -v '^$' | wc -l)
echo " 전체 GPU 관련 프로세스: $TOTAL"
echo " 정상: $((TOTAL - DEAD - ZOMBIE))"
echo " 죽은 프로세스 (orphaned ctx): $DEAD"
echo " 좀비 프로세스: $ZOMBIE"
echo " nvidia-smi 미표시: $HIDDEN_COUNT"
if [ "$DEAD" -gt 0 ] || [ "$ZOMBIE" -gt 0 ]; then
echo ""
echo " ⚠️ 조치 필요:"
[ "$ZOMBIE" -gt 0 ] && echo " 좀비 → 부모 프로세스 kill 또는 Pod 재시작"
[ "$DEAD" -gt 0 ] && echo " orphaned ctx → Pod 재시작, 안 되면 nvidia-smi --gpu-reset 또는 노드 재부팅"
fi
echo ""
echo "============================================"
```
---
## Phase 0: CI/CD 환경 구성
수동 `docker build/push` + `kubectl apply` 흐름을 **Gitea Actions + Harbor + ArgoCD**로 자동화한다.
이 Phase 완료 후, Phase 1~2-C의 "이미지 빌드 & 푸시" 단계는 CI가 자동 수행하며,
Pod 배포는 ArgoCD가 Harbor OCI Helm chart에서 sync한다.
(아래 본문에 남아 있는 수동 `docker build/push` 명령은 CI fallback 용도로 보존.)
### 환경 정보 (`vLLM/A100_npu/.env`에서 로드)
| 항목 | 값 | 비고 |
|---|---|---|
| Gitea URL | `https://gitea.inje-private.com` | |
| Gitea Org | `soo-rnd` | Runner 연동 완료 |
| Gitea Repo | `gpu-zombie-test` | **신규 생성 필요** |
| Harbor URL | `harbor.cone-chain.net` | OIDC CLI Secret 사용 |
| Harbor Project | `aipf` | 기존 재사용 |
| 이미지 prefix | `harbor.cone-chain.net/aipf/gpu-zombie-test` | tag로 시나리오 구분 |
| Helm Chart | `gpu-zombie-test` | OCI push |
| ArgoCD URL | `https://argocd.gpulive.cloud` | |
| ArgoCD App | `gpu-zombie-test` | dest namespace `gpu-zombie-test` |
| GPU 노드 | `gpu-4` (A100) | nodeName으로 강제 |
| K8s Pull Secret | `harbor-pull-secret` | namespace에 사전 생성 |
> **즉, 본문의 `harbor.cone-chain.net/aipf/gpu-zombie-test:<tag>` 는 모두 `harbor.cone-chain.net/aipf/gpu-zombie-test:<tag>` 로 매핑된다.**
> 이미지 태그: `nofix` / `tini` / `fullfix` (시나리오 2·3 동일 이미지)
### 0-1. Gitea Repo & Secrets 준비
```bash
# 1. soo-rnd/gpu-zombie-test repo 생성 (Gitea UI 또는 API)
# 2. Gitea repo Settings → Actions → Secrets 등록
# REGISTRY_URL = harbor.cone-chain.net
# REGISTRY_USER = selee
# REGISTRY_PASSWORD = <Harbor CLI Secret> # .env의 REGISTRY_PASSWORD
# ARGOCD_URL = https://argocd.gpulive.cloud
# ARGOCD_USER = admin
# ARGOCD_PASSWORD = <ArgoCD admin password> # .env의 ARGOCD_ADMIN_PASSWORD
# 3. K8s namespace + pull secret 사전 생성 (gpu-4 클러스터)
kubectl create ns gpu-zombie-test
kubectl create secret docker-registry harbor-pull-secret \
--docker-server=harbor.cone-chain.net \
--docker-username=selee \
--docker-password='<Harbor CLI Secret>' \
-n gpu-zombie-test
```
### 0-2. Repo 디렉토리 구조 (CI/CD 추가분)
기존 시나리오 디렉토리(plan.md 상단의 구조도)에 더해:
```
gpu-zombie-test/
├── .gitea/
│ └── workflows/
│ ├── ci.yaml # 시나리오 이미지 빌드/푸시
│ └── cd.yaml # Helm chart 패키징/푸시
├── charts/
│ └── gpu-zombie-test/
│ ├── Chart.yaml
│ ├── values.yaml # scenario 토글, image tag
│ └── templates/
│ ├── _helpers.tpl
│ ├── pod-nofix.yaml
│ ├── pod-tini.yaml
│ ├── pod-tini-prestop.yaml
│ └── pod-fullfix.yaml
├── argocd-app.yaml # ArgoCD Application 정의
├── scenario-1-nofix/ ... # 기존
├── scenario-2-tini-only/ ...
├── scenario-3-tini-prestop/ ...
├── scenario-4-fullfix/ ...
├── scripts/ ...
└── results/ ...
```
### 0-3. `.gitea/workflows/ci.yaml` — 이미지 빌드 & Harbor push
시나리오 2·3은 동일 이미지(`tini`)이므로 총 **3개 이미지** 빌드.
```yaml
name: CI — Build & Push Scenario Images
on:
push:
branches: [main]
paths:
- 'scenario-*/Dockerfile'
- 'scenario-*/parent.py'
- 'scenario-*/gpu_worker*.py'
- 'scripts/observe.sh'
- '.gitea/workflows/ci.yaml'
jobs:
build-and-push:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- scenario: scenario-1-nofix
tag: nofix
- scenario: scenario-2-tini-only
tag: tini
- scenario: scenario-4-fullfix
tag: fullfix
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set image tag
run: |
SHORT_SHA=$(echo ${GITHUB_SHA} | cut -c1-7)
echo "SHA_TAG=${{ matrix.tag }}-sha-${SHORT_SHA}" >> $GITHUB_ENV
- name: Login to Harbor
run: |
for i in 1 2 3; do
echo '${{ secrets.REGISTRY_PASSWORD }}' | \
docker login ${{ secrets.REGISTRY_URL }} \
-u ${{ secrets.REGISTRY_USER }} --password-stdin && break
echo "Retry $i..."; sleep 5
done
- name: Prepare build context (observe.sh + prestop.sh 포함)
run: |
cp scripts/observe.sh ${{ matrix.scenario }}/observe.sh
# prestop.sh는 시나리오 2(=3 공용)·4 이미지에만 필요하지만,
# 모든 컨텍스트에 복사해두고 Dockerfile COPY 여부로 제어 (nofix는 미사용)
cp scripts/prestop.sh ${{ matrix.scenario }}/prestop.sh
- name: Build & push image
run: |
IMAGE=${{ secrets.REGISTRY_URL }}/aipf/gpu-zombie-test
docker build \
-t ${IMAGE}:${{ env.SHA_TAG }} \
-t ${IMAGE}:${{ matrix.tag }} \
${{ matrix.scenario }}
docker push ${IMAGE}:${{ env.SHA_TAG }}
docker push ${IMAGE}:${{ matrix.tag }}
```
### 0-4. `.gitea/workflows/cd.yaml` — Helm chart 패키징 & Harbor OCI push
```yaml
name: CD — Package & Deploy Helm Chart
on:
push:
tags: ['v*']
env:
CHART_NAME: gpu-zombie-test
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set versions
run: |
TAG=${GITHUB_REF#refs/tags/}
echo "TAG=${TAG}" >> $GITHUB_ENV
echo "CHART_VERSION=${TAG#v}" >> $GITHUB_ENV
- name: Install Helm
run: |
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
- name: Package Helm chart
run: |
sed -i "s/appVersion: .*/appVersion: \"${{ env.TAG }}\"/" \
charts/${CHART_NAME}/Chart.yaml
sed -i "s/^version: .*/version: ${{ env.CHART_VERSION }}/" \
charts/${CHART_NAME}/Chart.yaml
helm package charts/${CHART_NAME}
- name: Push Helm chart to Harbor (OCI)
run: |
echo '${{ secrets.REGISTRY_PASSWORD }}' | \
helm registry login ${{ secrets.REGISTRY_URL }} \
-u ${{ secrets.REGISTRY_USER }} --password-stdin
helm push ${CHART_NAME}-${{ env.CHART_VERSION }}.tgz \
oci://${{ secrets.REGISTRY_URL }}/aipf
```
### 0-5. Helm Chart — `charts/gpu-zombie-test/`
**Chart.yaml**
```yaml
apiVersion: v2
name: gpu-zombie-test
description: GPU zombie/orphan/orphaned-CUDA-context reproduction experiment
type: application
version: 0.1.0
appVersion: "v0.1.0"
```
**values.yaml**
```yaml
image:
repository: harbor.cone-chain.net/aipf/gpu-zombie-test
pullPolicy: Always
pullSecret: harbor-pull-secret
node: gpu-4
# 시나리오별 토글 — 실험 격리가 필요하면 false 처리 후 chart 버전 bump
scenarios:
nofix:
enabled: true
tag: nofix
gracePeriod: 30
tini:
enabled: true
tag: tini
gracePeriod: 30
tiniPrestop:
enabled: true
tag: tini # scenario-2와 동일 이미지
gracePeriod: 60
fullfix:
enabled: true
tag: fullfix
gracePeriod: 90
```
**templates/_helpers.tpl**
```yaml
{{- define "gpu-zombie.podSpec" -}}
nodeName: {{ .root.Values.node }}
terminationGracePeriodSeconds: {{ .scenario.gracePeriod }}
restartPolicy: Never
imagePullSecrets:
- name: {{ .root.Values.image.pullSecret }}
containers:
- name: worker
image: "{{ .root.Values.image.repository }}:{{ .scenario.tag }}"
imagePullPolicy: {{ .root.Values.image.pullPolicy }}
resources:
limits:
nvidia.com/gpu: 1
{{- end }}
```
**templates/pod-nofix.yaml**
```yaml
{{- if .Values.scenarios.nofix.enabled }}
apiVersion: v1
kind: Pod
metadata:
name: gpu-zombie-nofix
labels:
app: gpu-zombie-test
scenario: nofix
spec:
{{- include "gpu-zombie.podSpec" (dict "root" . "scenario" .Values.scenarios.nofix) | nindent 2 }}
{{- end }}
```
**templates/pod-tini.yaml**
```yaml
{{- if .Values.scenarios.tini.enabled }}
apiVersion: v1
kind: Pod
metadata:
name: gpu-zombie-tini
labels:
app: gpu-zombie-test
scenario: tini
spec:
{{- include "gpu-zombie.podSpec" (dict "root" . "scenario" .Values.scenarios.tini) | nindent 2 }}
{{- end }}
```
**templates/pod-tini-prestop.yaml** — preStop은 inline이 길어 helper 분리
```yaml
{{- if .Values.scenarios.tiniPrestop.enabled }}
apiVersion: v1
kind: Pod
metadata:
name: gpu-zombie-tini-prestop
labels:
app: gpu-zombie-test
scenario: tini-prestop
spec:
nodeName: {{ .Values.node }}
terminationGracePeriodSeconds: {{ .Values.scenarios.tiniPrestop.gracePeriod }}
restartPolicy: Never
imagePullSecrets:
- name: {{ .Values.image.pullSecret }}
containers:
- name: worker
image: "{{ .Values.image.repository }}:{{ .Values.scenarios.tiniPrestop.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
resources:
limits:
nvidia.com/gpu: 1
lifecycle:
preStop:
exec:
# 공용 prestop.sh — 이미지에 포함됨 (scripts/prestop.sh 참조)
command: ["/bin/sh", "-c", "/usr/local/bin/prestop.sh"]
{{- end }}
```
**templates/pod-fullfix.yaml** — 동일 패턴 + Phase 2-C의 preStop
```yaml
{{- if .Values.scenarios.fullfix.enabled }}
apiVersion: v1
kind: Pod
metadata:
name: gpu-zombie-fullfix
labels:
app: gpu-zombie-test
scenario: fullfix
spec:
nodeName: {{ .Values.node }}
terminationGracePeriodSeconds: {{ .Values.scenarios.fullfix.gracePeriod }}
restartPolicy: Never
imagePullSecrets:
- name: {{ .Values.image.pullSecret }}
containers:
- name: worker
image: "{{ .Values.image.repository }}:{{ .Values.scenarios.fullfix.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
resources:
limits:
nvidia.com/gpu: 1
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "/usr/local/bin/prestop.sh"]
{{- end }}
```
> **메모:** 시나리오 3·4의 preStop은 공용 스크립트 `scripts/prestop.sh` (위 "공통 스크립트" 섹션 참조)를
> 사용한다. CI가 빌드 컨텍스트에 복사하고, 각 Dockerfile이 `/usr/local/bin/prestop.sh`로 COPY +chmod 한다.
> Pod template은 단지 `command: ["/bin/sh", "-c", "/usr/local/bin/prestop.sh"]`만 호출한다.
### 0-6. `argocd-app.yaml`
```yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: gpu-zombie-test
namespace: argocd
spec:
project: default
source:
chart: gpu-zombie-test
repoURL: harbor.cone-chain.net/aipf
targetRevision: ">0.0.0"
helm:
releaseName: gpu-zombie-test
destination:
server: https://kubernetes.default.svc
namespace: gpu-zombie-test
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
```
ArgoCD에 한 번만 등록:
```bash
kubectl apply -f argocd-app.yaml
```
이후 Harbor OCI에 새 chart 버전이 push될 때마다 자동 sync (`targetRevision: ">0.0.0"`).
### 0-7. CI/CD 트리거 전략 & 전체 흐름
```
[코드 변경]
├─ scenario-*/ 변경 → CI 트리거 (push to main)
│ └─ matrix로 3개 이미지 build → Harbor push
│ (harbor.cone-chain.net/aipf/gpu-zombie-test:{nofix|tini|fullfix})
│ + sha-<7> 태그 동시 push
└─ git tag v0.1.x push → CD 트리거
└─ Helm chart package → Harbor OCI push
└─ ArgoCD 자동 sync (targetRevision: ">0.0.0")
└─ gpu-zombie-test ns에 4개 Pod 배포
```
**시나리오 실험 사이클 (Phase 1~2-C에서 본문 대신 이걸로 대체):**
```bash
# 1. 코드 수정 후 push → CI 자동 빌드 확인 (Gitea Actions UI)
git add scenario-1-nofix && git commit -m "fix: nofix worker" && git push
# 2. ArgoCD가 이미지 변경 감지하지 않으므로, 첫 배포 또는 chart 변경 시에만 sync
# 이미 같은 tag(:nofix)에 새 이미지가 들어왔으면 Pod 재생성으로 pull
kubectl delete pod gpu-zombie-nofix -n gpu-zombie-test
# → ArgoCD selfHeal로 재배포 + imagePullPolicy: Always 로 새 이미지 pull
# 3. Pod 진입해서 실험
kubectl exec -it gpu-zombie-nofix -n gpu-zombie-test -- bash
# 4. 시나리오 격리 실행이 필요할 때 (옵션):
# values.yaml에서 다른 scenario.enabled: false 처리 → chart version bump → tag push
# → ArgoCD가 비활성 Pod prune
```
### 0-8. 체크리스트 — Phase 0
- [ ] Gitea `soo-rnd/gpu-zombie-test` repo 생성
- [ ] Gitea repo Secrets 등록 (`REGISTRY_URL`, `REGISTRY_USER`, `REGISTRY_PASSWORD`)
- [ ] K8s `gpu-zombie-test` namespace 생성
- [ ] `harbor-pull-secret` 생성 (위 namespace)
- [ ] `.gitea/workflows/ci.yaml`, `cd.yaml` 작성 & 첫 push로 CI 동작 확인
- [ ] Harbor에 3개 이미지 (`:nofix`, `:tini`, `:fullfix`) 존재 확인
- [ ] `scripts/prestop.sh` 작성 (시나리오 3·4 공용)
- [ ] `charts/gpu-zombie-test/` 작성
- [ ] 첫 tag `v0.1.0` push → CD 동작 확인
- [ ] Harbor OCI에 chart 존재 확인 (`helm pull oci://harbor.cone-chain.net/aipf/gpu-zombie-test --version 0.1.0`)
- [ ] `argocd-app.yaml` apply & ArgoCD UI에서 Sync OK 확인
- [ ] `kubectl get pods -n gpu-zombie-test` → 4개 Pod Running 확인
- [ ] 4개 Pod에 대해 `kubectl exec`로 진입 가능 확인
---
## Phase 1: 문제 재현 (시나리오 1 — No Fix)
### 목표
PID 1이 bash인 컨테이너에서 좀비 프로세스, 고아 프로세스, orphaned GPU context를 모두 재현한다.
### 파일
**scenario-1-nofix/parent.py**
```python
import subprocess
import time
import os
print(f"[Parent] PID: {os.getpid()}, PPID: {os.getppid()}")
# 자식 프로세스 spawn (GPU를 점유할 worker)
child = subprocess.Popen(["python3", "gpu_worker.py"])
print(f"[Parent] Child spawned, PID: {child.pid}")
# 부모가 wait() 없이 종료 → 자식은 고아가 됨
time.sleep(5)
print("[Parent] wait() 없이 종료합니다")
os._exit(0)
```
**scenario-1-nofix/gpu_worker.py**
```python
import torch
import time
import os
import signal
# SIGTERM 무시 — 잘못 작성된 코드 시뮬레이션
signal.signal(signal.SIGTERM, signal.SIG_IGN)
print(f"[Worker] PID: {os.getpid()}, PPID: {os.getppid()}")
# GPU 메모리 할당 (~2GB)
device = torch.device('cuda:0')
tensors = []
for i in range(8):
t = torch.randn(1024, 1024, 64, device=device)
tensors.append(t)
allocated = torch.cuda.memory_allocated() / 1024**3
print(f"[Worker] GPU 메모리 할당 완료: {allocated:.2f} GB")
# 무한 대기
while True:
time.sleep(5)
print(f"[Worker] alive, PID={os.getpid()}, PPID={os.getppid()}")
```
**scenario-1-nofix/Dockerfile**
```dockerfile
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04
RUN apt-get update && apt-get install -y python3 python3-pip procps psmisc && \
pip3 install torch --break-system-packages
WORKDIR /app
COPY parent.py gpu_worker.py ./
COPY observe.sh /usr/local/bin/observe.sh
RUN chmod +x /usr/local/bin/observe.sh
# PID 1 = bash (좀비 수거 안 함)
ENTRYPOINT ["/bin/bash", "-c"]
CMD ["sleep infinity"]
```
**scenario-1-nofix/pod.yaml**
```yaml
apiVersion: v1
kind: Pod
metadata:
name: gpu-zombie-nofix
namespace: gpu-zombie-test
spec:
nodeName: gpu-4
terminationGracePeriodSeconds: 30
containers:
- name: worker
image: harbor.cone-chain.net/aipf/gpu-zombie-test:nofix
resources:
limits:
nvidia.com/gpu: 1
restartPolicy: Never
```
### 실행 절차
```bash
# 1. 이미지 빌드 & 푸시
cd scenario-1-nofix
cp ../scripts/observe.sh .
docker build -t harbor.cone-chain.net/aipf/gpu-zombie-test:nofix .
docker push harbor.cone-chain.net/aipf/gpu-zombie-test:nofix
# 2. 파드 배포
kubectl apply -f pod.yaml
# 3. 파드 진입
kubectl exec -it gpu-zombie-nofix -n gpu-zombie-test -- bash
# 4. PID 1 확인
cat /proc/1/cmdline | tr '\0' ' '
# 예상: /bin/bash -c sleep infinity
# 5. parent.py 실행 (백그라운드)
python3 parent.py &
# 6. 10초 대기 (부모 종료 대기)
sleep 10
# 7. 컨테이너 내부 관찰
observe.sh
# 8. 노드에서 역추적 (별도 터미널)
ssh gpu-4
bash gpu-pod-trace.sh
```
### 체크리스트 — 시나리오 1 (실측 결과 포함)
> ⭐ 2026-04-13~14 실측 완료. ✅ 검증됨, ❌ plan.md 가설 틀림, ⭐ 새 발견. 자세히는 `results/scenario-1/notes.md`.
**재현 확인 (Step 1+2):**
- [x] PID 1 확인 — **`sleep infinity`** (bash exec 최적화로 sleep이 PID 1 대체. init 아닌 동작은 동일)
- [x] parent.py 실행 후 5초 뒤 부모 종료 확인 ✅
- [x] gpu_worker.py가 고아가 됨 확인 (PPID=1) ✅
- [x] 좀비는 kill -9 후 발생 (parent.py가 즉시 죽으면 좀비 단계 없이 사라짐)
- [x] GPU 메모리 점유 확인 (~2466 MiB) ✅
- [x] gpu-pod-trace.sh로 PID → Pod 매핑 확인 ✅
- [⚠️] observe.sh가 "ORPHANED" 오탐지 — torch가 노드 PID 반환 → 컨테이너에서 `/proc/<노드PID>` 미발견. **노드 트레이스로 정확 판정 가능.**
**kill -9 후 orphaned context 재현 (Step 3) — ❌ 가설 틀림:**
- [x] `kill -9 <worker_pid>` 실행
- [x] `ls /proc/<worker_pid>` → "No such file" ✅
- [❌] **`nvidia-smi` → GPU 메모리 0 MiB (즉시 해제)** ⭐ orphaned context **미발생**
- [x] `fuser -v /dev/nvidia0` → fd 잡는 프로세스 없음 ✅
- [x] gpu-pod-trace.sh → 죽은 프로세스 0개
**⭐ Step 3-B (worker 살아있는 상태 Pod 삭제) — 새로 추가된 핵심 테스트:**
- [x] parent.py 실행 후 worker GPU 점유 확인
- [x] 노드에서 GPU 1초 모니터링 시작
- [x] `kubectl delete pod ...`
- [x] **30초간 GPU 2466 MiB 점유 지속** (worker가 SIGTERM 무시)
- [x] **31초째 SIGKILL → GPU 1초 이내 해제 (0 MiB)**
- [⭐] **GPU·sec 낭비 = 73 GB·sec (단일 worker)** — 진짜 문제로 식별
**Pod 삭제 테스트 (kill 후) — ❌ 가설 틀림:**
- [x] `nvidia-smi` watch (이미 0 상태)
- [x] `kubectl delete pod ...`
- [x] **Pod 삭제 후 GPU 0 MiB 그대로** ⭐ 잔존 미발생
- [x] ghost context 없음
- [x] 결과 저장 (`results/scenario-1/`) ✅
**다음 시나리오 전 초기화:**
- [x] `nvidia-smi`로 GPU 메모리 0 확인 ✅
- [❌] **`nvidia-smi --gpu-reset` 불필요** (가설 틀림)
- [❌] **노드 재부팅 불필요** (가설 틀림)
### ⭐ 추가로 진행한 4가지 변형 (시나리오 1 확장)
```
results/scenario-1-driver-580.126.09/ # V100 + Driver 580 → 동일 결과
results/scenario-1-user-soo/ # UID 1000 → sudo 없이 kill 가능
results/scenario-1-user-soo-10workers/ # 10 workers → 740 GB·sec 낭비
results/scenario-1-nccl-3gpu/ # ⭐ NCCL hang + 148 MiB peer ctx 누수
```
자세한 비교: [`results/comparison.md`](./results/comparison.md)
---
## Phase 2-A: tini만 적용 (시나리오 2)
### 목표
tini를 PID 1로 사용하면 좀비 수거는 되지만, 코드에 signal handler가 없으면 CUDA cleanup이 안 되는 것을 확인한다.
### 변경 사항 (시나리오 1 대비)
- Dockerfile: `ENTRYPOINT ["tini", "--"]`로 변경
- gpu_worker.py: **동일** (SIGTERM 무시)
- pod.yaml: preStop 없음
**scenario-2-tini-only/Dockerfile**
> 이 이미지(`:tini`)는 시나리오 3에서도 재사용되므로 `prestop.sh`를 미리 포함시킨다.
> 시나리오 2는 호출하지 않으니 동작에 영향 없음.
```dockerfile
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04
RUN apt-get update && apt-get install -y python3 python3-pip procps psmisc tini && \
pip3 install torch --break-system-packages
WORKDIR /app
COPY parent.py gpu_worker.py ./
COPY observe.sh /usr/local/bin/observe.sh
COPY prestop.sh /usr/local/bin/prestop.sh
RUN chmod +x /usr/local/bin/observe.sh /usr/local/bin/prestop.sh
# PID 1 = tini (좀비 수거 O, signal 전달 O)
ENTRYPOINT ["tini", "--"]
CMD ["sleep", "infinity"]
```
**scenario-2-tini-only/pod.yaml**
```yaml
apiVersion: v1
kind: Pod
metadata:
name: gpu-zombie-tini
namespace: gpu-zombie-test
spec:
nodeName: gpu-4
terminationGracePeriodSeconds: 30
containers:
- name: worker
image: harbor.cone-chain.net/aipf/gpu-zombie-test:tini
resources:
limits:
nvidia.com/gpu: 1
restartPolicy: Never
```
### 실행 절차
```bash
cd scenario-2-tini-only
cp ../scripts/observe.sh .
docker build -t harbor.cone-chain.net/aipf/gpu-zombie-test:tini .
docker push harbor.cone-chain.net/aipf/gpu-zombie-test:tini
kubectl apply -f pod.yaml
kubectl exec -it gpu-zombie-tini -n gpu-zombie-test -- bash
python3 parent.py &
sleep 10
observe.sh
# 노드에서
ssh gpu-4
bash gpu-pod-trace.sh
```
### 체크리스트 — 시나리오 2
**tini 효과 확인:**
- [ ] PID 1이 `tini`인 것 확인
- [ ] parent.py 종료 후 좀비가 **수거되는지** 확인 (시나리오 1과 비교)
- [ ] gpu_worker.py 고아 상태 확인
**CUDA cleanup 안 되는 것 확인:**
- [ ] worker에 SIGTERM 전송: `kill -TERM <worker_pid>`
- [ ] worker가 SIGTERM 무시하는지 확인 (프로세스 살아있음)
- [ ] `kill -9 <worker_pid>` 후 orphaned GPU context 발생 여부 확인
- [ ] gpu-pod-trace.sh로 상태 확인
**Pod 삭제 테스트:**
- [ ] Pod 삭제 후 GPU 메모리 해제 여부 기록
- [ ] 결과 저장 (`results/scenario-2/`)
**예상 결과:**
- 좀비 수거: ✅ (tini가 `waitpid(-1)` 호출)
- CUDA cleanup: ❌ (worker 코드에 signal handler 없음)
- kill -9 시 orphaned context: 발생 가능
**다음 시나리오 전 초기화:**
- [ ] GPU 메모리 0 확인, 필요 시 gpu-4 재부팅
---
## Phase 2-B: tini + preStop 적용 (시나리오 3)
### 목표
preStop hook이 SIGTERM을 보내도, 코드가 SIGTERM을 무시하면 결국 SIGKILL fallback → CUDA cleanup 불가를 확인한다.
### 변경 사항 (시나리오 2 대비)
- Dockerfile: 동일 (tini 이미지 재사용)
- gpu_worker.py: **동일** (SIGTERM 무시)
- pod.yaml: **preStop hook 추가**, gracePeriod 60초
**scenario-3-tini-prestop/pod.yaml**
```yaml
apiVersion: v1
kind: Pod
metadata:
name: gpu-zombie-tini-prestop
namespace: gpu-zombie-test
spec:
nodeName: gpu-4
terminationGracePeriodSeconds: 60
containers:
- name: worker
image: harbor.cone-chain.net/aipf/gpu-zombie-test:tini # 시나리오 2와 동일 이미지
resources:
limits:
nvidia.com/gpu: 1
lifecycle:
preStop:
exec:
# 공용 prestop.sh 호출 (이미지에 /usr/local/bin/prestop.sh로 포함됨)
# 동작: SIGTERM → 20초 대기 → SIGKILL fallback
# 시나리오 3는 worker가 SIGTERM 무시하므로 fallback 경로 트리거됨
command: ["/bin/sh", "-c", "/usr/local/bin/prestop.sh"]
restartPolicy: Never
```
### 실행 절차
```bash
# 이미지는 시나리오 2와 동일 (prestop.sh 포함됨)
kubectl apply -f pod.yaml
kubectl exec -it gpu-zombie-tini-prestop -n gpu-zombie-test -- bash
python3 parent.py &
sleep 10
observe.sh
# Pod 삭제 (preStop 동작 확인)
# 터미널 1 (gpu-4): watch -n 1 'nvidia-smi'
# 터미널 2: 로그 스트림 + 삭제
kubectl logs -f gpu-zombie-tini-prestop -n gpu-zombie-test &
kubectl delete pod gpu-zombie-tini-prestop -n gpu-zombie-test
# 노드에서 확인
ssh gpu-4
bash gpu-pod-trace.sh
```
### 체크리스트 — 시나리오 3
**preStop 동작 확인:**
- [ ] preStop 실행 확인 (로그에서 `[preStop]` 메시지)
- [ ] preStop이 SIGTERM 전송 확인
- [ ] worker가 SIGTERM 무시 → 20초 대기 → SIGKILL fallback 확인
- [ ] SIGKILL 후 GPU 메모리 상태 확인
**Pod 삭제 후:**
- [ ] gpu-pod-trace.sh로 GPU 상태 확인
- [ ] orphaned GPU context 발생 여부 기록
- [ ] 결과 저장 (`results/scenario-3/`)
**예상 결과:**
- 좀비 수거: ✅ (tini)
- preStop SIGTERM 전달: ✅
- CUDA cleanup: ❌ (코드가 SIGTERM 무시 → SIGKILL)
- orphaned context: 발생 가능
**다음 시나리오 전 초기화:**
- [ ] GPU 메모리 0 확인, 필요 시 gpu-4 재부팅
---
## Phase 2-C: 전체 적용 (시나리오 4 — Full Fix)
### 목표
tini + preStop + 코드 signal handler + gracePeriod 모두 적용 시 좀비, 고아, orphaned GPU context가 깔끔하게 정리되는 것을 확인한다.
### 변경 사항
- Dockerfile: tini (동일)
- gpu_worker_fixed.py: **SIGTERM handler + CUDA cleanup** ← 핵심 변경
- pod.yaml: preStop + gracePeriod 90초
**scenario-4-fullfix/gpu_worker_fixed.py**
```python
import torch
import time
import os
import signal
import sys
print(f"[Worker] PID: {os.getpid()}, PPID: {os.getppid()}")
# GPU 메모리 할당 (~2GB)
device = torch.device('cuda:0')
tensors = []
for i in range(8):
t = torch.randn(1024, 1024, 64, device=device)
tensors.append(t)
allocated = torch.cuda.memory_allocated() / 1024**3
print(f"[Worker] GPU 메모리 할당 완료: {allocated:.2f} GB")
# === 핵심: SIGTERM handler 등록 ===
def graceful_shutdown(signum, frame):
print(f"[Worker] Signal {signum} 수신, CUDA cleanup 시작...")
# 1. tensor 참조 해제
tensors.clear()
# 2. GPU 캐시 비우기
torch.cuda.empty_cache()
# 3. 동기화 후 종료
torch.cuda.synchronize()
after = torch.cuda.memory_allocated() / 1024**3
print(f"[Worker] cleanup 완료, 잔여 GPU 메모리: {after:.2f} GB")
sys.exit(0)
signal.signal(signal.SIGTERM, graceful_shutdown)
signal.signal(signal.SIGINT, graceful_shutdown)
# 무한 대기
while True:
time.sleep(5)
print(f"[Worker] alive, PID={os.getpid()}, PPID={os.getppid()}, "
f"GPU={torch.cuda.memory_allocated()/1024**3:.2f}GB")
```
**scenario-4-fullfix/Dockerfile**
```dockerfile
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04
RUN apt-get update && apt-get install -y python3 python3-pip procps psmisc tini && \
pip3 install torch --break-system-packages
WORKDIR /app
COPY parent.py gpu_worker_fixed.py ./
COPY observe.sh /usr/local/bin/observe.sh
COPY prestop.sh /usr/local/bin/prestop.sh
RUN chmod +x /usr/local/bin/observe.sh /usr/local/bin/prestop.sh
ENTRYPOINT ["tini", "--"]
CMD ["sleep", "infinity"]
```
**scenario-4-fullfix/pod.yaml**
```yaml
apiVersion: v1
kind: Pod
metadata:
name: gpu-zombie-fullfix
namespace: gpu-zombie-test
spec:
nodeName: gpu-4
terminationGracePeriodSeconds: 90
containers:
- name: worker
image: harbor.cone-chain.net/aipf/gpu-zombie-test:fullfix
resources:
limits:
nvidia.com/gpu: 1
lifecycle:
preStop:
exec:
# 공용 prestop.sh 호출 — 시나리오 3과 동일 스크립트
# fullfix는 worker가 SIGTERM을 graceful하게 처리하므로
# SIGKILL fallback 경로는 트리거되지 않음
command: ["/bin/sh", "-c", "/usr/local/bin/prestop.sh"]
restartPolicy: Never
```
### 실행 절차
```bash
cd scenario-4-fullfix
cp ../scripts/observe.sh .
docker build -t harbor.cone-chain.net/aipf/gpu-zombie-test:fullfix .
docker push harbor.cone-chain.net/aipf/gpu-zombie-test:fullfix
kubectl apply -f pod.yaml
kubectl exec -it gpu-zombie-fullfix -n gpu-zombie-test -- bash
python3 parent.py &
sleep 10
observe.sh
# 수동 SIGTERM 테스트
kill -TERM <worker_pid>
sleep 3
observe.sh
# → GPU 메모리 해제 확인
# Pod 삭제 테스트
kubectl logs -f gpu-zombie-fullfix -n gpu-zombie-test &
kubectl delete pod gpu-zombie-fullfix -n gpu-zombie-test
# 노드에서 확인
ssh gpu-4
bash gpu-pod-trace.sh
```
### 체크리스트 — 시나리오 4
**Graceful shutdown 확인:**
- [ ] PID 1이 tini인 것 확인
- [ ] parent.py 종료 후 좀비 수거 확인
- [ ] worker에 SIGTERM → `[Worker] Signal 15 수신, CUDA cleanup 시작...` 로그
- [ ] `torch.cuda.empty_cache()` + `synchronize()` 실행 확인
- [ ] SIGTERM 후 `nvidia-smi` → GPU 메모리 **해제됨** 확인
- [ ] orphaned GPU context **없음** 확인
- [ ] fuser로도 GPU fd 잡는 프로세스 **없음** 확인
**Pod 삭제 시 전체 흐름:**
- [ ] preStop → SIGTERM → worker cleanup → 정상 종료 로그 확인
- [ ] Pod 삭제 후 GPU 메모리 **완전 해제** 확인
- [ ] gpu-pod-trace.sh → GPU 관련 프로세스 **0개** 확인
- [ ] 결과 저장 (`results/scenario-4/`)
**예상 결과:**
- 좀비 수거: ✅ (tini)
- SIGTERM 전달: ✅ (tini + preStop)
- CUDA cleanup: ✅ (코드에 signal handler)
- orphaned context: **없음**
- GPU 메모리 해제: ✅
---
## Phase 3: 결과 비교
### 최종 비교표
| 항목 | S1 (No Fix) | S2 (tini) | S3 (tini+preStop) | S4 (Full Fix) |
|---|---|---|---|---|
| PID 1 | bash | tini | tini | tini |
| 좀비 수거 | ❌ | ✅ | ✅ | ✅ |
| 고아 GPU 점유 | ❌ 잔존 | ❌ 잔존 | ⚠️ SIGKILL fallback | ✅ graceful |
| SIGTERM → CUDA cleanup | ❌ | ❌ handler 없음 | ❌ handler 없음 | ✅ |
| kill -9 후 orphaned ctx | ❌ 발생 | ❌ 발생 | ❌ 발생 가능 | N/A |
| nvidia-smi 미표시 ctx | 미확인 | 미확인 | 미확인 | 없음 |
| Pod 삭제 후 GPU 메모리 | ❌ 잔존 가능 | ⚠️ 불확실 | ⚠️ 불확실 | ✅ 해제 |
| PID → Pod 추적 (trace) | 기록 | 기록 | 기록 | 깨끗 |
| 노드 재부팅 필요 | 가능 | 가능 | 가능 | 불필요 |
### 각 시나리오별 결과 저장 구조
```
results/
├── scenario-1/
│ ├── observe-after-spawn.log # parent.py 실행 후 observe.sh
│ ├── observe-after-kill.log # kill -9 후 observe.sh
│ ├── gpu-pod-trace.log # 노드에서 gpu-pod-trace.sh
│ ├── nvidia-smi-before-delete.log
│ ├── nvidia-smi-after-delete.log
│ └── notes.md
├── scenario-2/
│ └── ...
├── scenario-3/
│ └── ...
├── scenario-4/
│ └── ...
└── comparison.md # 최종 비교 분석
```
---
## 전체 실행 순서 체크리스트
### 사전 준비
- [ ] gpu-4 노드 SSH 접근 확인
- [ ] gpu-4 GPU 정상 확인 (`nvidia-smi`)
- [ ] gpu-4에 `crictl`, `fuser`, `jq` 설치 확인
- [ ] gpu-4에 `gpu-pod-trace.sh`, `node-observe.sh` 배포
- [ ] cgroup v1 형식 재확인 (`cat /proc/<any_container_pid>/cgroup`)
- [ ] namespace `gpu-zombie-test` 생성
- [ ] 컨테이너 레지스트리 접근 확인
- [ ] `results/` 디렉토리 구조 생성
### Phase 0: CI/CD 환경 구성
- [ ] Gitea `soo-rnd/gpu-zombie-test` repo 생성 + Secrets 등록
- [ ] K8s `harbor-pull-secret` 생성
- [ ] `.gitea/workflows/{ci,cd}.yaml` 작성, 첫 push로 CI 동작 확인
- [ ] Harbor에 3개 이미지 (`:nofix`, `:tini`, `:fullfix`) 빌드 확인
- [ ] `scripts/prestop.sh` 작성 (시나리오 3·4 공용 — Dockerfile에서 `/usr/local/bin/`으로 COPY)
- [ ] `charts/gpu-zombie-test/` 작성 (4개 pod template + helper)
- [ ] tag `v0.1.0` push → CD 동작 확인 → Harbor OCI chart push 확인
- [ ] `argocd-app.yaml` apply → ArgoCD UI Sync OK 확인
- [ ] `kubectl get pods -n gpu-zombie-test` → 4개 Pod Running 확인
### 시나리오 1: No Fix
- [ ] 이미지 빌드 & 푸시
- [ ] Pod 배포 & 진입
- [ ] 좀비/고아/orphaned context 재현
- [ ] gpu-pod-trace.sh로 PID → Pod 매핑 확인
- [ ] kill -9 후 orphaned context 확인
- [ ] fuser로 nvidia-smi 미표시 context 확인
- [ ] Pod 삭제 후 GPU 상태 기록
- [ ] 결과 저장
- [ ] 초기화 (GPU 정리 또는 노드 재부팅)
### 시나리오 2: tini만
- [ ] 이미지 빌드 & 푸시
- [ ] Pod 배포 & 진입
- [ ] 좀비 수거 확인 (tini 효과)
- [ ] gpu-pod-trace.sh로 상태 확인
- [ ] kill -9 후 orphaned context 확인
- [ ] Pod 삭제 후 GPU 상태 기록
- [ ] 결과 저장
- [ ] 초기화
### 시나리오 3: tini + preStop
- [ ] Pod 배포 (이미지는 시나리오 2 재사용)
- [ ] preStop 동작 확인 (로그)
- [ ] SIGTERM 무시 → SIGKILL fallback 확인
- [ ] gpu-pod-trace.sh로 상태 확인
- [ ] Pod 삭제 후 GPU 상태 기록
- [ ] 결과 저장
- [ ] 초기화
### 시나리오 4: Full Fix
- [ ] 이미지 빌드 & 푸시
- [ ] Pod 배포 & 진입
- [ ] SIGTERM → graceful CUDA cleanup 확인
- [ ] 좀비 없음, orphaned context 없음 확인
- [ ] fuser로도 깨끗한지 확인
- [ ] gpu-pod-trace.sh → GPU 관련 프로세스 0개 확인
- [ ] Pod 삭제 후 GPU 메모리 완전 해제 확인
- [ ] 결과 저장
### 정리
- [ ] 최종 비교표 작성 (`results/comparison.md`)
- [ ] namespace 삭제 (`kubectl delete ns gpu-zombie-test`)
- [ ] 컨테이너 이미지 정리
- [ ] gpu-4 노드 최종 GPU 상태 확인