gpu-zombie-test/plan.md

47 KiB

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

사전 준비

체크리스트

  • 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 (컨테이너 내부에서 실행)

#!/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로 실행)

#!/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 종료)
#!/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 형식에 맞춰 작성.

#!/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 준비

# 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개 이미지 빌드.

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

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

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

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

{{- 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

{{- 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

{{- 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 분리

{{- 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

{{- 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

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에 한 번만 등록:

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에서 본문 대신 이걸로 대체):

# 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

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

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

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

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

실행 절차

# 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

재현 확인:

  • PID 1이 bash인 것 확인
  • parent.py 실행 후 5초 뒤 부모 종료 확인
  • gpu_worker.py가 고아가 됨 확인 (PPID=1)
  • 좀비 프로세스 존재 확인 (ps aux | awk '$8~/Z/')
  • GPU 메모리 점유 확인 (nvidia-smi)
  • gpu-pod-trace.sh로 해당 PID → Pod 매핑 확인

kill -9 후 orphaned context 재현:

  • kill -9 <worker_pid> 실행
  • ls /proc/<worker_pid> → "No such file or directory" 확인
  • nvidia-smi → GPU 메모리 여전히 점유 확인 → orphaned context 재현
  • fuser -v /dev/nvidia0 → 해당 PID 상태 확인
  • gpu-pod-trace.sh 재실행 → PID DEAD 또는 GHOST 상태 확인

Pod 삭제 테스트:

  • 터미널 1 (gpu-4): watch -n 1 'nvidia-smi'
  • 터미널 2: kubectl delete pod gpu-zombie-nofix -n gpu-zombie-test
  • Pod 삭제 후 GPU 메모리 해제 여부 기록
  • 잔존 시 gpu-pod-trace.sh로 ghost context 확인
  • 결과 저장 (results/scenario-1/)

다음 시나리오 전 초기화:

  • nvidia-smi로 GPU 메모리 0 확인
  • 잔존 시 nvidia-smi --gpu-reset 시도
  • 안 되면 gpu-4 노드 재부팅 → GPU 정상 확인

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는 호출하지 않으니 동작에 영향 없음.

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

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

실행 절차

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

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

실행 절차

# 이미지는 시나리오 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

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

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

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

실행 절차

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 상태 확인