집에 둔 서버에서 서른 몇 개의 컨테이너가 돌아갑니다. 이 블로그, NAS 대시보드, 모니터링, 자체 호스팅하는 서비스 몇 개. 거기에 더해 평소 개발도 VS Code Remote-SSH로 거기에 붙어서 했습니다. 워크스페이스는 36 GB, 파일 수는 사십만 개입니다.

7월 중순 어느 오후, 그 머신이 타 죽어서 응답을 멈췄습니다.

사흘을 추적한 결과 범인은 에디터였습니다. 서른 몇 개의 컨테이너 중 어느 것도 아니었습니다.

증상: 90°C에 박힌 채 사십 분#

더 이전의 전원 차단 문제를 쫓던 때에 CPU 온도와 load를 매분 기록하는 스크립트를 넣어 두었습니다. 그게 잡아낸 것이 이겁니다.

console
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은 사 초마다 이걸 뱉고 있었습니다.

console
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-16083.9°C
07-1716796.2°C
07-1839796.4°C
07-198194.0°C
07-2061796.1°C

07-16은 완전히 멀쩡했고 07-17부터 달라졌습니다. 뭔가가 07-17에 들어와서 그대로 눌러앉았다는 뜻입니다.

문제는 그다음에 나오는 세 개의 단서가 전부 설득력이 있었고, 전부 틀렸다는 점입니다.

함정 1: 센서가 거짓말을 하고 있었다#

당시 읽히던 건 이 숫자였습니다.

console
Tctl: 94.6°C   PPT: 5-22W   load: 10-13

8700G가 5에서 22W, 즉 아이들에 해당하는 상태라면 45에서 55°C여야 합니다. 그런데 94°C. 소비 전력이 그렇게 낮은데 이만큼 뜨겁다면 결론의 방향은 하나뿐입니다. 열이 빠져나가지 못하고 있다. 서멀 구리스가 말랐거나 쿨러가 제대로 안 얹혔거나. 시점도 맞아떨어졌습니다. 7월 초에 전원 문제를 쫓느라 케이스를 열었으니, 그때 CPU 팬 케이블을 건드렸을 가능성은 충분했습니다.

그래서 당시의 판단은 서멀 구리스 재도포였습니다. 저는 손을 대지 않았습니다. 순전히 귀찮아서였습니다. 얹혀 있는 건 인데, 듀얼 타워를 떼고 다시 다는 건 번거롭고, 애초에 서멀 구리스를 어디에 뒀는지부터 찾아야 했습니다.

손을 대지 않은 게 결과적으로 옳았습니다. 재부팅 후, 같은 머신 같은 쿨러로:

재부팅 전재부팅 후
CPU94.9°C54.5°C
GPU61°C38°C
NVMe57°C42°C

게다가 이건 부하가 걸린 상태입니다. load 12에 컨테이너가 전부 돌아가는 상태에서 55°C입니다.

WARNING

센서가 고장 났을 때 읽히는 숫자는 지어낸 이야기입니다 그 "PPT가 겨우 5에서 22W"라는 값은 애초에 성립하지 않습니다. 그 시점에 SMU는 이미 멈춰 있었고, amdgpu가 사 초마다 응답이 오지 않는다고 불평하던 것이 바로 그것이었습니다. SMU가 죽은 뒤에는 그것을 거치는 모든 값이 신뢰할 수 없습니다. 소비 전력도 포함해서요. 실제로 CPU는 진짜 풀로드로 발열하고 있었고, 열은 문제없이 빠져나가고 있었습니다.

고장 난 계측기의 숫자로 추론하는 건 숫자가 아예 없는 것보다 위험합니다. 출력이 증거처럼 보이기 때문입니다.

함정 2: 재부팅했더니 고쳐졌다#

재부팅 후 프로세스 목록을 다시 보니 며칠 동안 CPU를 먹고 있던 게 하나 나왔습니다.

console
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로 분해합니다.

이미 끝난 사건을 어떻게 추적하는가#

사건은 지나갔고 프로세스도 진작 죽었습니다. 그리고 재현될 때까지 기다려서 잡을 생각은 없었습니다. 다행히 이력을 남겨 준 것이 둘 있었습니다.

sar: 과거의 CPU 구성이 일 분 단위로 디스크에 남아 있다

top은 현재만 보여 줍니다. 다만 이 머신에는 이 들어 있습니다. 평온했던 07-16과 터진 07-20을 나란히 놓으면:

console
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의 apps.plugin은 거슬러 올라갈 수 있다

이 머신에서는 netdata가 돌고 있고, 그 이 per-process 이력을 보관하고 있었습니다. "뜨거웠던 구간"과 "조용한 구간"에서 각 app group을 빼 봅니다.

console
차분         뜨거움      조용함   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로 접속하는 제 경우에는 이렇게 됩니다.

text
sshd
└─ code-server
   ├─ bootstrap-fork --type=fileWatcher
   └─ bootstrap-fork --type=extensionHost
      ├─ tsserver.js ×2
      ├─ typingsInstaller.js
      ├─ jsonServerMain
      └─ claude ×2

netdata가 보기에 VS Code Remote-SSH 한 묶음은 통째로 sshd입니다. 사용자별로 봐도 일치합니다. 제 계정이 74%에서 928.7%가 된 반면 root는 거의 움직이지 않았습니다. 시간축도 맞아떨어집니다.

시각sshd 그룹대응하는 사건
07-16 18:5412.7%평온한 날, 63°C
07-17 13:18227.8%온도가 처음 85°C를 넘은 게 13:05
07-18 16:25380.1%이날은 397분이 85°C 초과
07-19 15:10308.7%15:43의 load=196
07-19 16:3811.5%재부팅 후 내려감
07-20 17:480.0%종료 후 0

이 둘은 둘 다 한참 전에 아무 생각 없이 깔아 둔 것이고 평소에 전혀 보지 않습니다. 재현을 기다리지 않고 사건을 복원할 수 있었던 건 전적으로 이 둘 덕분이었습니다. sysstat이 CPU 구성을, netdataapps.plugin이 per-process 이력을 남겨 두고 있었다는 얘기입니다.

범인 1: rust-analyzer가 cargo check를 무한히 재실행#

VS Code 한 묶음까지 좁혀졌으니 그 로그를 직접 읽었습니다. 가장 큰 파일은 rust-analyzer의 것이었고, 내용은 이걸로 가득 차 있었습니다.

console
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`

전체 명령은 이렇습니다.

bash
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.0WebKit의 JS 엔진과 HTTP 계층
glib-2.0, gobject-2.0, gio-2.0GTK의 객체 시스템과 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도 같이 사라진다).

어떻게 고치는가#

근본적으로는 해당 시스템 의존성을 넣거나, 애초에 서버에서 그 프로젝트를 열지 않는 것입니다. 다만 그보다 중요한 건 다시 루프가 생겨도 머신을 태울 수 없게 만드는 상한을 두는 일이었습니다.

toml
# ~/.cargo/config.toml
[build]
jobs = 6
json
// rust-analyzer: test/bench/example은 빌드하지 않고, 스레드 수도 제한
{
  "rust-analyzer.check.allTargets": false,
  "rust-analyzer.numThreads": 4
}

상한을 실제로 보장할 수 있는 건 jobs 줄뿐입니다. 나머지는 대증요법입니다.

덧붙이자면 files.watcherExclude는 원래부터 제대로 설정해 두어 큰 디렉터리는 제외되어 있었습니다. 즉 파일 감시는 이번 원인이 아니고 순전히 cargo입니다. 이건 굳이 말할 가치가 있습니다. "VS Code는 무겁다"는 논의에서 파일 감시가 유일한 용의자로 취급되기 쉽기 때문입니다.

범인 2: Foam 확장이 ripgrep을 이백 개 넘게 띄운다#

이걸로 끝난 줄 알았는데 며칠 뒤 다시 CPU가 오르내리기 시작했습니다. 의 프로세스 수를 연속으로 샘플링하면:

console
16 → 36 → 6 → 5 → 15 → ... → 245

출처를 따라가 보니 전부 이 지문이었습니다.

console
rg (--files ... .foam ...)
   └── PID 1220855  = VS Code가 방금 다시 띄운 새 ext host

이 주기적으로 rg --files로 워크스페이스 전체를 훑고 있었습니다. 그리고 제 워크스페이스는 이 규모입니다.

36GB
워크스페이스 용량
405k
파일 수
16
node_modules
이 밖에 Rust target/ 5개

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, 다만 노이즈#

삼십 초짜리 프로세스 생성 캡처를 떠서 새로 나타난 것을 전부 기록했습니다.

console
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. 위의 세 범인은 전부 "접속해 있는 동안 알아서 타오르는" 유형이고, 현장에 없는 것이 바로 최악의 상황이기 때문입니다.

參考連結