컨테이너 메모리 제한을 걸어놨는데 장비가 통째로 멈췄다
DGX Spark에서 도커 메모리 제한이 통하지 않아 26B LoRA 학습이 장비를 통째로 멈춘 15시간짜리 사고와 두 겹 방어 기록
하려던 것
어제 저녁, DGX Spark(GB10)에서 google/gemma-4-26B-A4B-it LoRA 학습을 걸어 놓고 잤다. BF16, rank 16이다. 컨테이너에 --memory=100g 제한을 걸어 뒀으니 최악의 경우 컨테이너만 죽을 거라고 생각했다.
오늘 아침에 보니 장비가 통째로 죽어 있었다. SSH도 안 되고 콘솔도 안 먹어서 물리적으로 가서 전원을 껐다 켰다. 복구까지 15시간이 걸렸다.
어떻게 시켰나
원인을 찾을 근거는 있었다. 서버 세운 첫날 붙여 둔 Prometheus가 죽는 과정을 분 단위로 남겨 놨다.
| UTC | 가용 메모리 |
|---|---|
| 09:48 학습 시작 | 114 GB |
| 10:03 모델 로드 완료 | 54 GB |
| 10:23 | 3.5 GB |
| 10:33 | 0.3 GB (swap 진입) |
| 10:38 | swap 9.7 GB |
| 11:07 | 수집 중단 = hang |
시작 30~45분 만이다. 메모리가 마르고, swap으로 밀려나고, swap마저 차니까 커널이 멈췄다. 전형적인 swap death다.
어디서 막혔나
컨테이너 제한이 왜 안 먹었는지가 핵심이었다. 긴 샘플 30건으로 재현 테스트를 돌리면서 컨테이너와 호스트를 동시에 봤다.
| 보는 곳 | 값 |
|---|---|
컨테이너 working_set | 7.7 GB |
| 실제 호스트 사용량 | 110 GB |
GPU가 시스템 RAM을 99GB 먹는 동안 컨테이너는 7.7GB로 보인다. 이유는 둘이다.
- DGX Spark는 CPU와 GPU가 128GB를 공유하는 구조인데, CUDA 통합 메모리 할당이 cgroup 카운트를 우회한다. GPU가 잡은 게 결국 호스트 RAM인데
memory.current에 안 잡힌다. container_memory_working_set_bytes는 mmap/shared를 빼고 센다.docker stats의 MEM 컬럼이 이 값이다.
그래서 Docker OOM-kill이 발동조차 안 했다. 컨테이너 입장에서는 100GB 한도 중 7.7GB 쓰는 얌전한 프로세스였다.
“다운 시점에도 컨테이너 메모리는 얼마 사용 안 했었네.”
대시보드는 끝까지 초록색이었다. 게다가 사고 전후를 훑다가 모니터링 컨테이너가 초당 60MB를 받고 있는 걸 발견했는데, 파 보니 cAdvisor가 호스트 NIC 전체 값을 컨테이너 값으로 복제해 내보내고 있었다. 지표가 이상하면 시스템을 의심하기 전에 지표를 의심해야 한다.
어떻게 풀었나
먼저 시작 시점을 다시 봤다. 학습 시작 때 가용 메모리가 114GB다. 128GB 장비인데 14GB가 이미 없다. 서빙이 같이 돌고 있었기 때문이다.
--gpu-memory-utilization 0.85
이 설정으로 서빙만으로 통합 메모리의 93%를 점유하고 있었다. 남는 게 6.4GB. 학습을 시작할 때 이미 여유가 없었다.
gpu-memory-utilization을 “GPU 메모리의 몇 %“로 읽으면 0.85는 보수적으로 보인다. 그런데 **통합 메모리 장비에서 그건 시스템 전체의 85%**다. 첫날 “보수적인 값”이라고 적었던 걸 정정한다.
그래서 두 겹으로 막았다.
1차 — 학습 설정. max_seq_length를 16384에서 8192로 캡하고, gradient_checkpointing을 켜고(activation 30~50% 절감), 만 자 넘는 샘플은 학습 셋에서 뺐다. 길이별로 재보니 seq 16,000에서 peak가 110GB를 넘고 8,000이면 80GB다. 26B-A4B는 MoE라 활성 expert 수만큼 activation이 더 붙어서 더 나쁘다. 거칠게는 가중치 × 4~8배가 필요하다 — 26B BF16 가중치가 50GB니 기본 설정으로는 200GB다. 애초에 안 들어가는 계산이었다.
최후 방어선 — earlyoom. 가용 8% 미만 SIGTERM, 4% 미만 SIGKILL. 학습 프로세스를 우선 죽이고 sshd는 보호한다. 커널이 멈추기 전에 뭐라도 죽여야 SSH로 들어갈 수 있다. 15시간을 날린 이유가 그거였다.
그리고 학습 전에 swap을 끈다. 역설적인데, swap이 있으면 죽는 대신 질질 끌다가 커널이 hang된다. swap을 꺼야 OOM 킬러가 빨리 문다.
배운 점
- DGX Spark 같은 통합 메모리 장비에서 컨테이너 격리는 메모리 사고를 못 막는다. CUDA 할당은 cgroup 밖이다.
docker stats와container_memory_working_set_bytes를 믿지 말고node_memory_MemAvailable_bytes나free -h를 봐라. gpu-memory-utilization은 GPU 비율이 아니라 시스템 전체 비율이다. 서빙과 학습을 같은 장비에서 돌린다면 서빙 쪽부터 줄여야 한다.- 추론 가능 ≠ 학습 가능. 추론은 가중치만 올리면 되지만 학습은 optimizer state + gradient + activation이 붙는다.
- earlyoom은 대책이 아니라 최후 방어선이다. 대책은 설정이다. 다만 최후 방어선이 없으면 복구가 원격에서 안 된다.
- 혼자 쓰는 장비인데 물리적으로 가야만 복구되는 상태에 빠질 수 있다는 게 제일 걸린다.