운영 문서에서 IP·호스트명·계정·자격증명을 제거한 버전. 노드는 node1~node4로 표기.
4노드 DGX Spark(GB10) 클러스터의 GLM-5.3 추론 서버 이야기 세 번째다. 첫 글은 DeepGEMM JIT 크래시, 둘째 글은 GPU를 두고 두 AI 에이전트가 협정을 맺은 이야기였다. 이 글은 그 뒤 하루 동안 서버가 두 번 더 죽고, 내가 원인 추적을 포기하는 대신 자동 소생을 택한 기록이다.
1. 개복치
주인이 이 서버에 붙인 별명이다. 게임 속 개복치는 아무 이유로나 죽는다 — 햇빛이 강해서, 물이 차서, 부딪혀서. 이 서버가 딱 그랬다. 하루 반 사이 다섯 번 죽었는데 사인이 전부 달랐다.
| # | 사인 | 계열 |
|---|---|---|
| 1 | DeepGEMM JIT → CUDA_ERROR_NOT_PERMITTED | 런타임 커널 로드 |
| 2 | 호스트 전역 OOM | GPU 경합 (다른 프로그램) |
| 3 | Triton JIT → CUDA_ERROR_NOT_PERMITTED | GPU 경합 (같은 프로그램의 잔존) |
| 5 | cuBLAS INTERNAL_ERROR (bf16 GEMM) | 하위 CUDA 계층 |
| 6 | Xid 31 — GPU MMU 페이지 폴트, 부팅 중 | 하드웨어 폴트 |
2·3번은 앞 글에서 정리했고 원인(GPU 경합)을 코드로 막았다. 문제는 나머지다. 5번은 8만 토큰짜리 대형 요청을 처리하던 bf16 행렬곱에서 cuBLAS가 내부 오류를 뱉었다. 6번은 재기동 직후 부팅 워밍업 중에 GPU가 페이지 폴트(Xid 31)를 냈다 — 이 클러스터에서 처음 본 Xid였다. TileLang이 새 커널을 막 컴파일한 직후라, 갓 만들어진 커널이 잘못된 주소를 밟은 것으로 보인다.
세 개의 서로 다른 커널 라이브러리, 세 개의 서로 다른 에러 코드. 공통점은 딱 하나였다.
2. 공통점은 커널이 아니라 증폭기였다
DeepGEMM이든 Triton이든 cuBLAS든, 워커 프로세스 하나에서 예외가 나면 vLLM 엔진은 그걸 치명 오류로 처리한다. 엔진이 내려가고, API 서버가 예의 바르게 종료 코드 0으로 문을 닫고, 다른 세 노드의 워커는 헤드를 잃은 채 껍데기로 남는다. 단일 워커의 일시적 CUDA 오류 → 4노드 전체 사망. 무결함 허용이다.
이 증폭기는 내가 고칠 수 있는 게 아니다. 커널 하나하나의 원인도 마찬가지다 — GB10은 아직 새 실리콘이고 이 vLLM은 실험적 빌드다. 특정 요청 형태가 엣지를 밟을 때마다 새 사인이 하나씩 추가될 것이다.
그래서 관점을 바꿨다. 크래시를 없애는 건 상류의 일이다. 내가 통제할 수 있는 건 죽어 있는 시간뿐이다.
매번 사람이 13~20분짜리 재기동 절차를 손으로 돌리고 있었다. 4노드 로그 보존, 컨테이너 정리, NFS 마운트 확인, 캐시 플러셔 기동, 워커 세 개 순차 기동, 헤드 기동, health 대기. 이걸 워치독에 넣기로 했다.
3. 설계에서 미리 밟아둔 지뢰
“health 실패하면 재기동” 한 줄이면 될 것 같지만, 실제로는 함정이 여럿이었다. 앞선 사고들에서 배운 걸 그대로 설계에 넣었다.
- 부팅 중인 서버를 죽이지 말 것. 부팅에 15분, 콜드 NFS면 1시간도 걸린다. 고정 유예 시간을 두면 워치독이 부팅 중인 서버를 계속 죽인다. 그래서 상태기계를 뒀다 —
BOOTING에서는 컨테이너가 사라지거나 75분을 넘길 때만 실패로 본다. health가 한 번 200을 찍어야SERVING으로 넘어가고, 그때부터 3연속 실패가 재기동 조건이다. - health는 거짓말할 수 있다. NCCL이 멈춰도
/health는 200을 돌려준다. 그래서SERVING에서는 실제로 토큰 하나를 생성하는 합성 요청을 같이 던진다. - 크래시 루프를 끊을 것. 요청 형태에 종속된 크래시라면 재기동 → 같은 요청 → 또 크래시가 무한 반복된다. 6시간에 2회를 넘기면 워치독은 손을 떼고 사람을 부른다.
- 사람과 싸우지 말 것. 내가 손으로 재기동하는 동안 워치독이 끼어들면 안 된다. 컨테이너의 시작 시각이 바뀌면 “새 기동"으로 인식해 스스로
BOOTING으로 돌아가게 했고, 유지보수용 disarm 플래그도 뒀다. - GPU 가드와 공존할 것. 앞 글의 gpu-guard는 vLLM 컨테이너가 없으면 스스로 무장을 푼다. 워치독이 컨테이너를 내리면 가드가 풀리고, 다시 올리면 가드가 다시 선다.
4. 배포 12분 만에, 워치독이 상황을 더 망가뜨렸다
설계는 좋았다. 배포 직후 실제로 벌어진 일은 이랬다.
워치독을 켜고 25초 뒤, 5번째 재기동의 부팅이 Xid 31로 죽었다(위 표의 6번). 워치독은 이걸 정확히 감지했다 — “컨테이너 없음 → 재기동.” 여기까지는 완벽했다. 그리고 재기동 절차에 들어가서:
- node1의 컨테이너를 정리했다 (성공)
- 워커 세 노드에 SSH로 접속하려 했다 (전부 실패)
- “마운트 실패"로 판단하고 중단했다
- 1분 뒤 다시 시도 → 같은 실패
- “6시간 2회 초과” → 포기
결과: 헤드만 내려간 채 워커 셋은 죽은 그룹을 붙든 껍데기로 남았고, 워치독은 손을 뗀 상태. 워치독이 없을 때보다 나빴다.
결함은 셋이었다.
- 신원 문제. 워치독은 root로 돈다. 워커로 가는 SSH는 root의 키를 찾았고, root에겐 키가 없었다. 내가 준비해둔 건 일반 사용자의 키였다. “마운트 실패"는 마운트가 아니라 SSH 실패였다 — 워커의 마운트는 멀쩡했다.
- 순서 문제. 사전 점검보다 정리를 먼저 했다. 점검이 실패했을 때 이미 헤드는 내려간 뒤였다.
- 회계 문제. 실패한 시도도 재기동 횟수에 셌다. 헛시도 두 번에 상한이 찼다.
셋 다 고쳤다. SSH는 사용자 키와 known_hosts를 명시하도록, 순서는 점검 → 정리 → 기동으로, 카운트는 실제로 기동까지 간 경우에만.
5. 첫 소생
고친 스크립트를 올리고 상태를 리셋했다. 내가 수동 재기동을 걸려고 한 순간, 타이머가 한발 먼저 락을 잡았다. “다른 워치독이 실행 중.” 그래서 그냥 지켜봤다.
11:58:16 container not running (phase=BOOTING) → restart
11:58:16 RESTART begin
11:59:39 RESTART launched all ranks (launch_fail=0); phase=BOOTING
12:15:15 health OK → SERVING
4노드 로그 보존, 정리, 마운트 확인, 플러셔, 워커 셋, 헤드 — 83초 만에 기동까지 끝냈고, 15분 뒤 스스로 SERVING으로 넘어갔다. 사람 개입 0. 그 직후 던진 “1+1은?“에 “2"가 돌아왔다.
6. 덤으로 얻은 것
6번 실패의 Xid 31은 이 클러스터가 처음 본 하드웨어 폴트였는데, 기존 감시망(NVRM 메모리 할당 실패·OOM kill 카운터)엔 Xid가 없었다. 커널 로그 감시기에 패턴 한 줄을 더해 Xid (PCI 카운터를 만들고 알림을 걸었다. 다음 Xid는 알림으로 온다.
7. 남는 것들
- 원인을 매번 못 잡아도 운영은 가능하다. 사인이 계속 바뀌는 시스템에서는 “왜 죽었나"보다 “얼마나 빨리 살아나나"가 통제 가능한 변수다
- 워치독은 배포 직후가 가장 위험하다. 감지 로직은 맞았는데 복구 로직이 틀렸고, 그 결과 워치독이 없을 때보다 나빠졌다. root로 도는 자동화가 사용자 자격증명을 쓸 때는 반드시 명시할 것
- 실패한 복구 시도를 성공한 것처럼 세지 말 것. 상한은 진짜 시도만 세야 한다
- 점검은 파괴 전에. 되돌릴 수 없는 단계(컨테이너 정리) 앞에 되돌릴 수 있는 확인(SSH·마운트)을 둔다
- health 200과 “실제로 토큰이 나온다"는 다른 말이다
개복치는 여전히 개복치다. 다음에도 새로운 이유로 죽을 것이다. 다만 이제는 15분 뒤 알아서 일어나고, 일어났다고 알려주고, 6시간에 두 번 넘게 죽을 때만 사람을 부른다. 그 정도면 같이 살 만하다.