🔑 핵심 요약
✦ AI·GEN서른 몇 개의 컨테이너가 돌아가면서 동시에 VS Code Remote-SSH로 개발에도 쓰던 홈 서버가, 90°C에 박힌 채 사십 분을 버티고 load가 8에서 196까지 오른 끝에 응답을 잃었다. 태운 것은 그 서비스들이 아니라 에디터였다. 이 글은 추적의 전 과정을 기록한다. 설득력은 있었지만 전부 틀렸던 세 개의 단서(SMU가 멈춘 뒤 센서가 거짓말을 한 것, 재부팅하면 고쳐진다는 착각, CPU가 30에서 50%면 과열로 보이지 않는다는 점), sysstat의 sar와 netdata의 apps.plugin으로 이미 끝난 사건을 복원하는 방법, 그리고 최종적으로 찾아낸 세 범인 — GTK 의존성이 하나도 없어 영원히 컴파일되지 않는 Tauri 프로젝트에 대해 rust-analyzer가 cargo check를 무한히 재실행하던 것, Foam 확장이 ripgrep을 이백 개 넘게 띄우던 것, 그리고 노이즈에 불과했던 RustDesk의 폴링. 마지막에 방어선 설정과 사후에 더한 두 개의 보험을 실었다.
집에 둔 서버에서 서른 몇 개의 컨테이너가 돌아갑니다. 이 블로그, NAS 대시보드, 모니터링, 자체 호스팅하는 서비스 몇 개. 거기에 더해 평소 개발도 VS Code Remote-SSH로 거기에 붙어서 했습니다. 워크스페이스는 36 GB, 파일 수는 사십만 개입니다.
7월 중순 어느 오후, 그 머신이 타 죽어서 응답을 멈췄습니다.
사흘을 추적한 결과 범인은 에디터였습니다. 서른 몇 개의 컨테이너 중 어느 것도 아니었습니다.
증상: 90°C에 박힌 채 사십 분#
더 이전의 전원 차단 문제를 쫓던 때에 CPU 온도와 load를 매분 기록하는 스크립트를 넣어 두었습니다. 그게 잡아낸 것이 이겁니다.
15:12 cpu=90.1C load=8.65
15:22 cpu=90.0C load=32.53
15:31 cpu=90.0C load=84.42
15:43 cpu=90.0C load=156.54
15:50 cpu=90.0C load=196.01 ← cron이 더 이상 스케줄되지 않음온도는 90°C에 못 박힌 채, load는 8에서 196까지 치솟았고, 마지막에는 매분 실행되는 cron조차 차례가 돌아오지 않았습니다. 그 상태로 사십 분이 지나자 머신 전체가 응답을 반환하지 않게 됐습니다.
죽기 직전의 journal은 사 초마다 이걸 뱉고 있었습니다.
amdgpu 0000:79:00.0: amdgpu: SMU: I'm not done with your previous command는 칩 안에서 전원과 온도를 관리하는 작은 컨트롤러입니다. 그것조차 스케줄되지 않는다는 건 CPU가 한계까지 바쁘다는 뜻입니다.
전원은 먼저 배제할 수 있습니다. 이 머신은 UPS 아래에 있고, UPS는 전 구간 OL, 상용 전원은 안정, 배터리 만충이었습니다. 정전이 아닙니다.
결론부터#
범인은 셋. 전부 VS Code Remote-SSH 한 묶음에서 나왔습니다.
| 범인 | 점유 | 처리 |
|---|---|---|
rust-analyzer가 컴파일 불가능한 프로젝트에 대해 cargo check를 무한히 재실행 | 8.3 코어 | jobs 제한, allTargets 끄기 |
| Foam이 주기적으로 ripgrep을 띄워 사십만 파일을 훑음 | 피크 245개의 rg | 확장 자체를 비활성화 |
RustDesk가 매초 ps aux를 fork하며 폴링 | 머신 전체의 1.5% | 그냥 둠. 노이즈일 뿐 |
아래는 거기까지 어떻게 도달했는지입니다.
이 건이 까다로운 건 단서가 거짓말을 하기 때문#
온도는 갑자기 튄 게 아니라 서서히 올라갔습니다.
| 날짜 | 85°C 초과 분수 | 최고 온도 |
|---|---|---|
| 07-16 | 0 | 83.9°C |
| 07-17 | 167 | 96.2°C |
| 07-18 | 397 | 96.4°C |
| 07-19 | 81 | 94.0°C |
| 07-20 | 617 | 96.1°C |
07-16은 완전히 멀쩡했고 07-17부터 달라졌습니다. 뭔가가 07-17에 들어와서 그대로 눌러앉았다는 뜻입니다.
문제는 그다음에 나오는 세 개의 단서가 전부 설득력이 있었고, 전부 틀렸다는 점입니다.
함정 1: 센서가 거짓말을 하고 있었다#
당시 읽히던 건 이 숫자였습니다.
Tctl: 94.6°C PPT: 5-22W load: 10-138700G가 5에서 22W, 즉 아이들에 해당하는 상태라면 45에서 55°C여야 합니다. 그런데 94°C. 소비 전력이 그렇게 낮은데 이만큼 뜨겁다면 결론의 방향은 하나뿐입니다. 열이 빠져나가지 못하고 있다. 서멀 구리스가 말랐거나 쿨러가 제대로 안 얹혔거나. 시점도 맞아떨어졌습니다. 7월 초에 전원 문제를 쫓느라 케이스를 열었으니, 그때 CPU 팬 케이블을 건드렸을 가능성은 충분했습니다.
그래서 당시의 판단은 서멀 구리스 재도포였습니다. 저는 손을 대지 않았습니다. 순전히 귀찮아서였습니다. 얹혀 있는 건 인데, 듀얼 타워를 떼고 다시 다는 건 번거롭고, 애초에 서멀 구리스를 어디에 뒀는지부터 찾아야 했습니다.
손을 대지 않은 게 결과적으로 옳았습니다. 재부팅 후, 같은 머신 같은 쿨러로:
| 재부팅 전 | 재부팅 후 | |
|---|---|---|
| CPU | 94.9°C | 54.5°C |
| GPU | 61°C | 38°C |
| NVMe | 57°C | 42°C |
게다가 이건 부하가 걸린 상태입니다. load 12에 컨테이너가 전부 돌아가는 상태에서 55°C입니다.
WARNING
센서가 고장 났을 때 읽히는 숫자는 지어낸 이야기입니다
그 "PPT가 겨우 5에서 22W"라는 값은 애초에 성립하지 않습니다. 그 시점에 SMU는 이미 멈춰 있었고, amdgpu가 사 초마다 응답이 오지 않는다고 불평하던 것이 바로 그것이었습니다. SMU가 죽은 뒤에는 그것을 거치는 모든 값이 신뢰할 수 없습니다. 소비 전력도 포함해서요. 실제로 CPU는 진짜 풀로드로 발열하고 있었고, 열은 문제없이 빠져나가고 있었습니다.
고장 난 계측기의 숫자로 추론하는 건 숫자가 아예 없는 것보다 위험합니다. 출력이 증거처럼 보이기 때문입니다.
함정 2: 재부팅했더니 고쳐졌다#
재부팅 후 프로세스 목록을 다시 보니 며칠 동안 CPU를 먹고 있던 게 하나 나왔습니다.
msedge 395% CPU ← 4 코어분인과관계가 그 자리에서 조립됩니다. Edge의 어느 탭이 폭주 → CPU가 장시간 풀로드 → Tjmax에 박혀 심하게 클럭 다운 → 처리가 적체 → load가 8, 32, 84, 196 → SMU가 고갈 → 시스템이 멈춤. 게다가 재부팅으로 Edge가 사라진 뒤에는 정상이니 검증된 것처럼 보입니다.
다만 저는 직접 안을 봤습니다. RustDesk로 서버에 접속했을 때 Edge는 실행되어 있지 않았고, CPU 사용률도 30에서 50%밖에 되지 않았습니다.
Edge가 없어도 뜨겁다면 Edge는 주된 원인이 아닙니다. "재부팅했더니 고쳐졌다"가 증명할 수 있는 건 범인이 상주 서비스가 아니라는 것뿐이고, 범인이 누구인지는 아무것도 말해 주지 않습니다. 그걸 인과의 검증으로 다룬 것이 이 건에서 길을 잘못 들 뻔한 가장 큰 순간이었습니다.
함정 3: CPU가 30에서 50%면 과열처럼 보이지 않는다#
이게 가장 직관에 반하지만, 사실 이게 답입니다. 뒤에서 sar로 분해합니다.
이미 끝난 사건을 어떻게 추적하는가#
사건은 지나갔고 프로세스도 진작 죽었습니다. 그리고 재현될 때까지 기다려서 잡을 생각은 없었습니다. 다행히 이력을 남겨 준 것이 둘 있었습니다.
top은 현재만 보여 줍니다. 다만 이 머신에는 이 들어 있습니다. 평온했던 07-16과 터진 07-20을 나란히 놓으면:
07-16(무사) %user 7% %system 3% %idle 87%
07-20(94°C) %user 46% %system 18% %idle 32% runq-sz 20-24 blocked 0제가 본 30에서 50%는 %user의 46%였습니다. 거기에 %system의 18%를 더하면 CPU는 실제로 68%가 타고 있었고, 그것도 몇 시간씩 이어졌다는 뜻이 됩니다.
8700G에서 68%의 지속 부하는 대략 열한 개의 스레드가 쉬지 않고 계산하는 상태에 해당합니다. 숫자로는 높게 느껴지지 않지만, 지속 부하로서는 APU를 온도 상한까지 밀어 올리기에 충분합니다. 순간 100%가 십 초라면 아무 일도 없지만, 68%가 세 시간 이어지면 Tjmax에 닿습니다.
iowait 0.04%, blocked 0, runq-sz 20-24 셋을 함께 보면 I/O에서 막힌 게 아니라는 것도 확정됩니다. 프로세스가 에 쌓여 있는 경우에도 load는 높아지지만, 그런 종류의 높은 load에서는 CPU가 한가합니다. 여기서는 정말로 스무 개 남짓한 실행 가능 스레드가 줄 서 있었습니다.
재부팅 후 서른다섯 개의 컨테이너가 전부 돌아왔는데도 %user는 6.9%밖에 되지 않았습니다. 평온했던 날과 똑같습니다.
그 46%를 먹고 있던 건 어떤 컨테이너도 아니었습니다. 재부팅 후에 스스로 돌아오지 않는 것, 즉 데스크톱 세션 안의 프로그램입니다.
이 머신에서는 netdata가 돌고 있고, 그 이 per-process 이력을 보관하고 있었습니다. "뜨거웠던 구간"과 "조용한 구간"에서 각 app group을 빼 봅니다.
차분 뜨거움 조용함 app
+829.3 834.7 5.4 sshd ← 8.3 코어
+5.0 45.5 40.4 dockerd
+4.7 9.0 4.3 rustdesk답은 처음부터 디스크 위에 굴러다니고 있었습니다. 재현을 기다릴 필요가 없습니다.
sshd가 5.4%에서 834.7%까지 튀었지만, 이게 SSH 서비스 자체에 문제가 있다는 뜻은 아닙니다. apps.plugin은 이름을 인식하지 못한 프로세스를 부모의 그룹으로 집계하는데, VS Code Remote-SSH로 접속하는 제 경우에는 이렇게 됩니다.
sshd
└─ code-server
├─ bootstrap-fork --type=fileWatcher
└─ bootstrap-fork --type=extensionHost
├─ tsserver.js ×2
├─ typingsInstaller.js
├─ jsonServerMain
└─ claude ×2netdata가 보기에 VS Code Remote-SSH 한 묶음은 통째로 sshd입니다. 사용자별로 봐도 일치합니다. 제 계정이 74%에서 928.7%가 된 반면 root는 거의 움직이지 않았습니다. 시간축도 맞아떨어집니다.
| 시각 | sshd 그룹 | 대응하는 사건 |
|---|---|---|
| 07-16 18:54 | 12.7% | 평온한 날, 63°C |
| 07-17 13:18 | 227.8% | 온도가 처음 85°C를 넘은 게 13:05 |
| 07-18 16:25 | 380.1% | 이날은 397분이 85°C 초과 |
| 07-19 15:10 | 308.7% | 15:43의 load=196 |
| 07-19 16:38 | 11.5% | 재부팅 후 내려감 |
| 07-20 17:48 | 0.0% | 종료 후 0 |
이 둘은 둘 다 한참 전에 아무 생각 없이 깔아 둔 것이고 평소에 전혀 보지 않습니다. 재현을 기다리지 않고 사건을 복원할 수 있었던 건 전적으로 이 둘 덕분이었습니다. sysstat이 CPU 구성을, netdata의 apps.plugin이 per-process 이력을 남겨 두고 있었다는 얘기입니다.
범인 1: rust-analyzer가 cargo check를 무한히 재실행#
VS Code 한 묶음까지 좁혀졌으니 그 로그를 직접 읽었습니다. 가장 큰 파일은 rust-analyzer의 것이었고, 내용은 이걸로 가득 차 있었습니다.
ERROR Flycheck failed to run: cargo check --workspace
error: failed to run custom build command for `soup3-sys`
error: failed to run custom build command for `javascriptcore-rs-sys`
error: failed to run custom build command for `gobject-sys` / `glib-sys` / `gio-sys`전체 명령은 이렇습니다.
cargo check --workspace --keep-going --all-targets \
--manifest-path ~/Server/tauri-plugin-sidecar/Cargo.toml이건 플러그인 프로젝트이고, Tauri는 리눅스에서 시스템의 GTK와 WebKit으로 창을 그립니다. 빌드할 때 그 *-sys 크레이트들이 해당 라이브러리의 개발용 파일을 시스템에서 찾으러 갑니다.
그런데 이 머신은 헤드리스 서버라 데스크톱 환경을 한 번도 넣은 적이 없습니다. 즉 하나도 존재하지 않습니다. 로그에 나온 부족분이 이것들입니다.
| 없는 라이브러리 | 무엇을 담당하는가 |
|---|---|
gdk-3.0 | 창과 그리기의 기반 |
javascriptcoregtk-4.1, libsoup-3.0 | WebKit의 JS 엔진과 HTTP 계층 |
glib-2.0, gobject-2.0, gio-2.0 | GTK의 객체 시스템과 I/O |
cairo, pango | 벡터 그리기와 텍스트 레이아웃 |
gdk-pixbuf-2.0, atk | 이미지 디코딩과 접근성 |
그리고 같은 실패 묶음이 로그 안에서 서른다섯 번 반복됩니다. 그게 재시도 횟수입니다.
다시 말해 이 cargo check는 오래 걸리는 게 아니라 영원히 끝나지 않습니다. 매번 정확히 같은 자리에서 쓰러지고, 쓰러지면 다시 처음부터 시작합니다. 루프는 이렇게 닫힙니다.
CAUTION
--keep-going --all-targets는 치명적인 조합
--keep-going은 "에러가 나도 멈추지 말고 나머지를 전부 계속 빌드하라", --all-targets는 "lib, bin, test, bench, example을 전부 빌드하라"는 뜻입니다.
둘을 합치면, 컴파일이 불가능하다는 걸 알면서도 워크스페이스 전체의 모든 타깃을 처음부터 끝까지 돌린다는 얘기가 됩니다. 게다가 실패한 build script는 캐시를 남기지 않으므로 재시도마다 완전한 재빌드가 되고, rustc가 모든 코어를 채웁니다.
이걸로 그때까지 앞뒤가 맞지 않던 두 가지도 정리됩니다. 왜 07-17 이후에만 일어났는가(그날부터 그 Tauri 프로젝트를 만지기 시작했다), 그리고 왜 재부팅하면 반드시 고쳐졌는가(VS Code 세션이 사라지고 rust-analyzer도 같이 사라진다).
어떻게 고치는가#
근본적으로는 해당 시스템 의존성을 넣거나, 애초에 서버에서 그 프로젝트를 열지 않는 것입니다. 다만 그보다 중요한 건 다시 루프가 생겨도 머신을 태울 수 없게 만드는 상한을 두는 일이었습니다.
# ~/.cargo/config.toml
[build]
jobs = 6// rust-analyzer: test/bench/example은 빌드하지 않고, 스레드 수도 제한
{
"rust-analyzer.check.allTargets": false,
"rust-analyzer.numThreads": 4
}상한을 실제로 보장할 수 있는 건 jobs 줄뿐입니다. 나머지는 대증요법입니다.
덧붙이자면 files.watcherExclude는 원래부터 제대로 설정해 두어 큰 디렉터리는 제외되어 있었습니다. 즉 파일 감시는 이번 원인이 아니고 순전히 cargo입니다. 이건 굳이 말할 가치가 있습니다. "VS Code는 무겁다"는 논의에서 파일 감시가 유일한 용의자로 취급되기 쉽기 때문입니다.
범인 2: Foam 확장이 ripgrep을 이백 개 넘게 띄운다#
이걸로 끝난 줄 알았는데 며칠 뒤 다시 CPU가 오르내리기 시작했습니다. 의 프로세스 수를 연속으로 샘플링하면:
16 → 36 → 6 → 5 → 15 → ... → 245출처를 따라가 보니 전부 이 지문이었습니다.
rg (--files ... .foam ...)
└── PID 1220855 = VS Code가 방금 다시 띄운 새 ext host이 주기적으로 rg --files로 워크스페이스 전체를 훑고 있었습니다. 그리고 제 워크스페이스는 이 규모입니다.
WARNING
프로세스를 죽여도 해결되지 않습니다
Foam이 올라가 있는 ext host를 죽이자 VS Code는 즉시 새것을 띄웠고, Foam도 같이 부활했으며, rg가 다시 뿜어져 나왔습니다. 이 루프를 눈앞에서 한 번 재현했습니다.
유일하게 통하는 건 확장 자체를 비활성화하는 것입니다. 게다가 디스크에서 폴더 이름을 바꾼 뒤에도 Reload Window가 필요합니다. 이미 메모리에 올라간 것은 파일 이름을 바꿔도 사라지지 않습니다.
비활성화한 뒤 rg는 0이 되었고 온도는 90°C에서 69°C로 떨어졌습니다.
이 증상은 저만의 것이 아닙니다. VS Code에는 rg가 900% CPU에 달하는 issue와 rg가 장시간 높은 CPU를 쓰는 issue가 있고, Cursor 커뮤니티에도 Cursor spawns hundreds of rg processes라는 스레드가 있습니다. 증상은 제 245개와 거의 같습니다.
그 논의들의 공통점은 아무도 근본 원인을 짚지 못한다는 것이고, 답변 대부분은 ripgrep 자체가 느려진 게 아니냐고 추측합니다. 하지만 rg는 고장 나지 않았습니다. 몇 번이고 반복해서 호출되고 있을 뿐입니다. CPU를 태우는 건 rg이고, 버튼을 계속 누르는 손은 다른 곳에 있습니다. 이런 종류의 문제에서 찾아야 하는 건 호출하는 쪽이지 호출당하는 쪽이 아닙니다.
범인 3: RustDesk, 다만 노이즈#
삼십 초짜리 프로세스 생성 캡처를 떠서 새로 나타난 것을 전부 기록했습니다.
11× sh ← rustdesk가 띄움
7× ps ← rustdesk가 띄움RustDesk 1.3.1은 매초 한두 개의 sh를 fork해 ps aux를 실행하며, 자기 자식 프로세스 관리와 사용자 세션 감지에 씁니다. 나흘 동안 쌓인 CPU 시간은 열아홉 시간, 아무도 접속하지 않아도 계속 돕니다. 재시작해도 달라지지 않습니다.
열여섯 코어 머신 전체로 환산하면 1.5%. CPU를 끓이기에는 턱없이 부족하고, 원격 데스크톱으로 안을 보는 데 필요하니 그대로 뒀습니다. 여기에 쓴 건 netdata 차분 표에서 세 번째로 올라왔기 때문이고, 이걸 배제하는 데 쓴 시간이 더 아까웠기 때문입니다.
사후에 더한 두 개의 보험#
온도 상한을 85°C로 낮췄다#
BIOS에 들어가 CPU의 온도 상한을 기본값인 95°C 부근에서 85°C로 낮췄습니다.
이유는 냉각이 부족해서가 아니라, "뭔가가 무한 루프를 돌며 전 코어를 채운다"는 사태가 일어날 수 있다는 게 이번에 증명됐기 때문입니다. 온도 상한의 역할은 그런 상황에서 일찍 클럭을 떨어뜨리는 데 있습니다. 머신은 느려지지만, 전 코어 풀가동인 채로 90°C 넘게 사십 분을 버티는 일은 없어집니다.
설정 위치는 입니다. 평소 사용에서는 차이를 전혀 느낄 수 없습니다. 애초에 85°C에 닿지 않기 때문으로, 처리 후 측정한 기준값은 온도 72.6°C·피크 83°C, 소비 전력 평균 40W·피크 64W(상한은 약 88W), 전 코어 4.6GHz·싱글 4.9GHz. 전부 정상입니다.
더 나아가면 Curve Optimizer로 전압을 낮추는 방법도 있고, 더 시원하고 전력도 아끼면서 성능은 떨어지지 않습니다. 다만 이건 데이터를 두는 머신이고, 불안정한 언더볼팅은 무작위 멈춤과 segfault로 나타납니다. 극한보다 안정을 택하므로 이건 하지 않습니다.
원격 개발 방식을 바꿨다#
고친 뒤에도 작업 방식은 바꿨습니다. 이 두 범인은 둘 다 사고가 아니라 원격 개발이라는 아키텍처의 필연이기 때문입니다. 언어 서버, 파일 인덱싱, 확장, 검색. 그 전부가 데스크톱보다 훨씬 열 여유가 없는 머신 위에서 돕니다. 워크스페이스가 클수록 무거워지고, 제 것은 36 GB·사십만 파일로 거의 최악의 조합이었습니다.
지금의 구분은 이렇습니다. 집에서 LAN을 통할 때는 VS Code Remote-SSH. 머신 바로 옆에 있으니 타기 시작해도 보이고 멈출 수 있습니다. 밖에서 장시간 접속할 때는 Zed. 위의 세 범인은 전부 "접속해 있는 동안 알아서 타오르는" 유형이고, 현장에 없는 것이 바로 최악의 상황이기 때문입니다.
- rust-analyzer —— check (flycheck) 설정rust-analyzer.github.io
- Cargo —— config.toml의 build.jobs와 병렬도doc.rust-lang.org
- Cargo —— --keep-going과 --all-targets의 의미cargo check
- Tauri —— 리눅스 시스템 의존성 목록tauri.app
- ripgrep —— 프로젝트 페이지GitHub
- VS Code —— rg 고 CPU 관련 두 개의 issue#186279#248666
- Cursor 커뮤니티 —— 대량의 rg 프로세스forum.cursor.com
- netdata —— apps.plugin의 프로세스 그룹과 이력learn.netdata.cloud
- sysstat —— sar 필드 설명man sar
아직 댓글이 없어요
✨ 첫 댓글을 남겨보세요