감시자를 감시하는 감시자를 감시하는 감시자를..
집에서 Proxmox로 홈서버를 굴린다. 컨테이너 스무 개쯤 올려놓고 블로그도 여기서 돌린다.
이 서버가 죽었다. 그런데 죽은 걸 이틀 반 동안 몰랐고, 알고 나서도 집 밖이라 아무것도 못 했다. 결국 집에 가서 전원을 뽑았다 꽂고서야 살렸다. 그리고 원인을 찾아보니 소프트웨어 문제가 아니라 CPU 절전 기능 버그였다.
이 글은 그 하루의 기록이다. 원인을 찾은 과정과, 같은 일이 또 나지 않게 고친 것들을 정리했다.
거기 아무도 없어요?
퇴근길에 웹 UI가 안 열렸다. 하필 집 밖이었다. 노트북을 열고 상태부터 확인했다.
bash1ping -c 3 192.168.0.20 # 응답 정상 2nc -zv 192.168.0.20 8006 # 포트 열림 3curl -sk https://192.168.0.20:8006/ # 무응답 4ssh root@192.168.0.20 # Connection reset by peer
핑은 되고 TCP 포트도 열려 있는데 서비스는 전부 응답이 없었다. SSH는 배너조차 안 주고 끊겼다. 비밀번호가 틀린 게 아니라 sshd가 세션을 못 만드는 상태였다.
완전히 꺼진 것도 아니고 멀쩡한 것도 아닌 애매한 상태였다. 나중에 알았지만 이 애매함이 원인을 가리키는 단서였다.
원격으로 재부팅할 방법을 찾아봤지만 없었다. SSH도 웹 UI도 죽었으니 명령을 넣을 통로가 없고, IPMI 같은 관리 장치도 없었다. WOL은 설정해뒀지만 그건 꺼진 기계를 켜는 물건이라 켜져 있는데 얼어붙은 기계엔 아무 소용이 없다. 이걸 그 자리에서 깨달았다.
결국 집에 갈 때까지 기다렸다.
나의 죽음을 적에게 알리지 말라고 진짜 안알리면 어떡함
전원을 껐다 켜고 나서야 접속이 됐다. 부팅 이력부터 봤다.
bash1journalctl --list-boots
-2 Thu 2026-08-20 00:27 ~ Wed 2026-09-02 03:13
-1 Fri 2026-09-04 20:50 ~ Fri 2026-09-04 20:50
0 Fri 2026-09-04 20:53 ~ (현재)
9월 2일 새벽 3시 13분에 죽어서 9월 4일 저녁까지 그대로 있었다. 이틀 반이다. 그동안 나는 서버가 멀쩡히 돌아가는 줄 알았다.
그 위쪽 기록도 눈에 띄었다. 8월 4일부터 18일까지 2주, 8월 20일부터 9월 2일까지 13일. 이 서버는 원래 2주씩 연속으로 잘 돌던 놈이었다. 자주 죽는 게 아니라 가끔 한 번씩 통째로 얼어붙는 유형이었던 것이다.
디버그를 하려면 로그가 있어야죠
죽기 직전 로그를 열었다.
bash1journalctl -b -2 -n 50 --no-pager
Sep 02 03:13:37 kernel: br-xxx: port 1(veth3ca4350) entered forwarding state
Sep 02 03:13:37 kernel: veth3ca4350 (unregistering): left promiscuous mode
Sep 02 03:13:37 kernel: br-xxx: port 1(veth3ca4350) entered disabled state
(끝)
평범한 컨테이너 네트워크 메시지를 마지막으로 로그가 끊긴다. OOM도 커널 패닉도 디스크 에러도 온도 경고도 없다. 크래시 흔적을 죄다 grep해봐도 나오는 게 없었다.
예전에 이 서버가 몇 번 죽었을 때도 로그를 봤는데 아무것도 없어서 그냥 넘어갔었다. 그런데 이번엔 그 "아무것도 없음"이 이상하게 느껴졌다. 소프트웨어가 문제였다면 뭐라도 찍혔어야 한다.
로그가 없다는 건 커널이 메시지를 남길 틈도 없이 얼어붙었다는 뜻이다. 게다가 디스크 쓰기까지 멈췄으니 설령 뭔가 찍혔어도 저장이 안 됐다. 그동안 "로그를 봐도 아무것도 없더라"고 넘어갔던 게 사실은 가장 중요한 정보였던 셈이다.
전 한번 자면 누가 업어가도 몰라요
하드웨어 정보를 봤다.
bash1grep -m1 "model name" /proc/cpuinfo 2# AMD Ryzen 7 255 w/ Radeon 780M Graphics 3 4cat /proc/cmdline 5# BOOT_IMAGE=/boot/vmlinuz-... root=/dev/mapper/pve-root ro quiet
부팅 로그에는 이런 줄이 있었다.
kernel: Low-power S0 idle used by default for system suspend
정황이 맞아떨어졌다. AMD Ryzen APU는 리눅스에서 유휴 상태 락업으로 알려진 계열이다. 커널 파라미터는 quiet 하나뿐이라 C-state를 억제하는 옵션이 없었다. 죽은 시각은 새벽 3시 13분, 아무도 서버를 안 쓰는 시간이다. 메모리는 27GB 중 3.8GB만 쓰고 있었고 OOM 기록은 역대 0회, 온도도 SMART도 정상이었다.
CPU는 할 일이 없으면 전기를 아끼려고 점점 깊은 절전 단계로 내려간다. 문제는 일부 AMD 칩이 가장 깊은 단계에서 못 깨어나는 하드웨어 버그가 있다는 것이다.
낮에는 바쁘니까 그 단계까지 안 내려간다. 그래서 2주씩 멀쩡히 돌았다. 그러다 새벽에 할 일 없이 놀다가 깊은 절전으로 내려간 뒤 못 올라온 것이다.
NIC 펌웨어는 CPU와 별개로 도니까 핑에는 계속 응답했다. 하지만 CPU가 멈췄으니 유저스페이스는 아무것도 못 했다. 처음에 봤던 "핑은 되는데 서비스는 다 죽은" 상태가 이래서 나왔다.
고장 원인이 소프트웨어가 아니라 절전 기능이었다는 게 좀 허탈했다.
해결? 방법?
해법은 널리 알려져 있다. 깊은 C-state를 안 쓰게 하는 것이다.
bash1# /etc/default/grub 2GRUB_CMDLINE_LINUX_DEFAULT="quiet processor.max_cstate=1" 3 4update-grub && reboot
나도 처음에는 이러면 전기를 훨씬 더 먹는 거 아닌가 싶었는데, 그렇지 않다. C-state는 여러 층으로 되어 있다. C0는 작업 중이고, C1은 클럭만 멈춘 얕은 휴식, C2부터 C6까지는 코어 전원을 점점 차단한다. 그런데 절약의 큰 덩어리는 C0에서 C1로 갈 때 이미 챙겨지고 그 아래는 찔끔이다.
max_cstate=1은 C1까지는 그대로 쓰고 C2 이하만 막는 설정이다. 이득은 대부분 유지하면서 버그가 터지는 구간만 피한다. 실제 소비 전력 차이는 유휴 시 몇 W 정도인데, 새벽마다 서버가 얼어붙는 것과 비교하면 고민할 거리가 아니다.
BIOS의 Power Supply Idle Control을 Typical Current Idle로 바꾸는 게 AMD 공식 권장 해법이지만, 그건 모니터를 물려야 해서 나중으로 미뤘다. 서버에 모니터 연결하는 일이 왜 이렇게 귀찮은지 모르겠다.
자동 복구 시스템 온
커널 파라미터가 진짜 효과가 있는지는 몇 주 지나봐야 안다. 그 사이에 또 얼면 곤란하니 워치독을 걸었다.
여기서 선택이 중요했다. 리눅스에는 softdog이라는 소프트웨어 워치독이 있는데, 이번 고장처럼 커널이 통째로 얼면 softdog도 같이 얼어서 아무것도 못 한다. 그래서 칩셋에 물리적으로 박혀 있는 워치독을 써야 한다. AMD는 sp5100_tco, 인텔은 iTCO_wdt다.
bash1modprobe sp5100_tco 2# sp5100-tco: initialized. heartbeat=60 sec (nowayout=0)
원리는 단순하다. 하드웨어 타이머에 주기적으로 신호를 보내다가 그 신호가 끊기면 하드웨어가 알아서 리셋한다. 시스템이 얼면 신호를 보내던 프로세스도 멈추니까 자동으로 재부팅이 걸린다.
처음엔 watchdog 패키지를 설치하려다 Proxmox의 apt 훅에 막혔다. 핵심 패키지를 지우려고 해서 차단당했는데, 생각해보니 이 고장에는 무거운 데몬이 필요 없었다. 워치독을 주기적으로 쓰다듬는 프로세스 하나면 충분하다.
python1#!/usr/bin/env python3 2import glob, os, time, signal, sys 3 4dev = None 5for p in sorted(glob.glob('/sys/class/watchdog/watchdog*')): 6 if 'sp5100' in open(p + '/identity').read().lower(): 7 dev = '/dev/' + os.path.basename(p) 8 break 9 10f = open(dev, 'w') 11 12def disarm(*a): 13 f.write('V') # 매직 클로즈: 정상 종료 시 무장 해제 14 f.flush() 15 sys.exit(0) 16 17signal.signal(signal.SIGTERM, disarm) 18 19while True: 20 f.write('1') 21 f.flush() 22 time.sleep(20)
종료할 때 'V'를 쓰고 닫는 부분이 중요하다. 이걸 빼먹으면 정상적으로 재부팅하는 중에도 워치독이 물어버려서 부팅 루프에 빠진다. 자동 복구를 만들려다 자동 파괴를 만드는 셈이다.
커널 패닉 때 자동으로 재부팅하도록 한 줄 더 추가했다.
bash1echo 'kernel.panic = 10' > /etc/sysctl.d/90-panic-reboot.conf
이제 또 얼어도 60초 안에 스스로 재부팅한다. 이틀 반 방치는 구조적으로 안 일어난다.
죽어도 살리면 그만
밖에서 왜 아무것도 못 했는지를 곱씹다가 구조적인 문제를 발견했다.
Tailscale로 집 네트워크에 접속하고 있었는데, 그 Tailscale이 Proxmox 위에서 돌아가는 컨테이너 안에 있었다. 서버가 죽으면 컨테이너도 죽으니까 접속 수단이 감시 대상과 운명을 같이한다. 당연한 말인데 막상 구축할 때는 "서버 있으니까 거기 올리자"가 자연스러워서 이렇게 된다.
마침 안 쓰는 노트북이 하나 있어서 우분투 서버를 깔고 두 번째 서브넷 라우터로 만들었다.
bash1tailscale up --advertise-routes=192.168.0.0/24 --advertise-exit-node
Tailscale은 같은 서브넷을 여러 노드가 광고하면 알아서 장애 조치를 해준다. 이제 Proxmox가 죽어도 노트북 경로로 집 LAN에 들어갈 수 있다. 다만 서브넷 라우터는 자동 페일오버가 되는데 exit node는 안 된다. exit node는 클라이언트가 직접 고르는 구조라 수동으로 바꿔줘야 한다.
노트북을 상시 서버로 쓰려면 몇 가지 손볼 게 있다.
bash1# 덮개 닫아도 안 꺼지게 2sed -i 's/^#*HandleLidSwitch=.*/HandleLidSwitch=ignore/' /etc/systemd/logind.conf 3systemctl mask sleep.target suspend.target hibernate.target 4 5# 배터리 충전 상한 80% (상시 충전 열화 방지) 6echo 80 > /sys/class/power_supply/BAT0/charge_control_end_threshold 7 8# 와이파이 절전 끄기 9iw dev wlo1 set power_save off
노트북을 서버로 쓰면 내장 배터리가 그대로 UPS가 된다. 정전이 나도 감시자는 살아남는다는 뜻이라 생각보다 괜찮은 부수 효과다.
백업은 필수적인 수단입니다
서버 살리는 데 정신이 팔려 있다가 문득 더 큰 문제를 깨달았다. 백업이 없었다.
SSD 하나가 죽으면 Gitea도 PostgreSQL도 블로그도 오브젝트 스토리지도 전부 한 번에 사라지는 상태였다. 멈추는 건 재부팅하면 되지만 디스크 고장은 되돌릴 수 없다. 오늘 고친 문제보다 이쪽이 훨씬 무서운 이야기다.
노트북에 Proxmox Backup Server를 올렸다. 물리적으로 분리된 기계에 둔다는 게 핵심이다.
우분투 설치할 때 LVM이 루트에 100GB만 할당하고 373GB를 놀리고 있길래 백업 전용 볼륨을 따로 만들었다.
bash1lvcreate -l 100%FREE -n backup ubuntu-vg 2mkfs.ext4 -m 0 -L pbs-backup /dev/ubuntu-vg/backup
루트와 분리한 이유가 있다. 백업이 폭주해서 루트를 꽉 채우면 SSH도 안 되고 손쓸 방법이 없어진다. 분리해두면 백업만 실패하고 시스템은 멀쩡하다.
압축 효율이 좋네요
용량이 걱정됐다. 게스트 20개에 논리적으로 476GB였으니까. 첫 전체 백업은 33분 걸렸고 실제로 쓴 공간은 133GB였다. 압축으로 3분의 1 넘게 줄었다.
그런데 진짜는 두 번째 백업이었다. 48GB짜리 컨테이너 두 개를 다시 백업해봤다.
CT 107: had to backup 0 B of 27.1 GiB — reused 27.1 GiB (100.0%)
CT 112: had to backup 131 MiB of 21.2 GiB — reused 21.1 GiB (99.4%)
→ 저장소 증가량: 약 24 MB
48GB를 통째로 다시 백업했는데 디스크는 24MB 늘었다. PBS는 파일이 아니라 청크 단위로 저장하고 같은 내용의 청크는 한 번만 보관하기 때문이다. 그래서 매일 전체 백업을 돌려도 저장소가 거의 안 늘어난다. 일7 + 주4 + 월6으로 스냅샷 17개를 보관해도 150~200GB면 된다는 계산이 나왔다.
"매일 전체 백업하면 용량 터지는 거 아닌가"가 내 첫 반응이었는데 완전히 기우였다.
반쪽짜리 백업
PBS의 게스트 백업에는 Proxmox 호스트 자체의 설정이 빠져 있다. 네트워크 브리지, 스토리지 정의, 사용자, 방화벽 규칙, 방금 설치한 워치독까지 전부 없다.
재설치 시나리오를 그려보면 이게 뼈아프다. 게스트는 클릭 몇 번으로 복원되는데 호스트 재구성에 시간이 다 간다. 그래서 /etc도 매일 백업하도록 했다.
bash1proxmox-backup-client backup \ 2 etc.pxar:/etc \ 3 pveconf.pxar:/tmp/pve-copy \ 4 --backup-type host --backup-id "$(hostname)-config"
복원 테스트
백업은 복원해봐야 백업이다. 실제로 필요한 순간에 복원이 안 되는 경우가 흔하고, 하필 그걸 알게 되는 시점이 최악이다.
기존 컨테이너는 안 건드리고 새 ID로 복원해봤다.
bash1pct restore 999 pbs-laptop:backup/ct/110/2026-09-04T19:03:01Z --storage local-lvm 2pct set 999 --net0 name=eth0,bridge=vmbr0,ip=manual # 원본과 IP 충돌 방지 3pct start 999
부팅됐고 설정 파일도 데이터도 파일 소유권과 권한, 타임스탬프까지 그대로 살아났다. 확인하고 테스트 컨테이너는 지웠다. 5분짜리 테스트인데 주는 안심이 꽤 크다.
모니터링도 빵빵하게 넣어주세요
사실 이게 제일 큰 문제였다. 자동 복구가 아무리 잘 되어 있어도 상황을 모르면 대응할 수 없다.
노트북에 Uptime Kuma를 올렸다. 여기서도 원칙은 같다. 감시 도구를 감시 대상 위에 두면 안 된다.
Proxmox는 핑과 웹 UI 두 개로 나눠서 감시하게 했다. 이번 고장이 정확히 "핑은 되는데 서비스는 죽은" 상태였기 때문이다. 핑만 봤으면 영원히 정상으로 보였을 것이다.
모니터링은 설치만하면 끝인가요?
설정하자마자 알림이 왔다.
❌ Blog Backend went down.
Error: Request failed with status code 401
서버는 멀쩡했다. 인증이 필요한 API라 루트 경로가 401을 주는 것뿐이었고, 19초 뒤에 "복구됨" 알림이 또 왔다.
401을 허용 코드에 추가하면 조용해진다. 하지만 그러면 인증 설정이 망가져도 정상으로 보이게 된다. 더 나쁜 건 가짜 알림이 쌓이면 진짜 알림도 무시하게 된다는 점이다. 알림 피로가 모니터링을 죽이는 가장 흔한 경로다.
제대로 된 해법은 헬스체크 엔드포인트라서 서비스마다 하나씩 만들거나 찾았다. 블로그 백엔드는 /actuator/health, 내부 API는 /healthz, 오브젝트 스토리지는 /minio/health/ready를 쓴다.
특히 Spring Actuator가 물건이었다. DB와 Redis 연결까지 확인해서 집계한 상태를 주기 때문에, 모니터 하나로 앱과 PostgreSQL, Redis 세 계층을 동시에 보는 셈이 된다. 앱만 살아있고 뒷단이 끊긴 상태는 포트 체크로는 절대 못 잡는다.
Minio도 live보다 ready가 낫다. live는 프로세스만 확인하지만 ready는 스토리지 접근까지 본다.
이제 모든 모니터가 200을 기대하고 200이 아니면 진짜 문제다. 알림을 믿을 수 있게 만드는 게 핵심이다.
이런.. 너도 죽어있었구나
작업하다가 도메인 하나가 응답을 안 하는 걸 발견했다. 리버스 프록시 설정을 보니 백엔드가 192.168.0.4:8080인데 그 주소에 아무것도 없었다.
추적해보니 그 서비스는 LXC 컨테이너였고 네트워크가 DHCP로 잡혀 있었다.
net0: name=eth0,bridge=vmbr0,ip=dhcp
IP가 바뀌면서 프록시 설정과 어긋난 것이다. 언제부터 죽어 있었는지도 모른다. 모니터링을 붙이자마자 그동안 몰랐던 고장이 튀어나오는 게 좀 웃겼다.
고정 IP를 주려는데 함정을 두 개 만났다.
먼저 컨테이너 안에서 /etc/network/interfaces를 고치면 안 된다. Proxmox가 컨테이너를 시작할 때마다 덮어쓴다. 호스트에서 설정해야 한다.
bash1pct set 601 --net0 name=eth0,bridge=vmbr0,ip=192.168.0.201/24,gw=192.168.0.1,type=veth
그리고 IP가 비었는지 확인할 때 핑을 믿으면 안 된다. 원래 쓰려던 주소는 핑이 100% 무응답이었는데 arping으로 찔러보니 응답이 왔다.
bash1arping -c 4 -I vmbr0 192.168.0.4 2# Unicast reply from 192.168.0.4 [xx:xx:xx:xx:xx:xx]
핑을 막아둔 기기가 그 주소를 쓰고 있었던 것이다. 그냥 썼으면 IP 충돌로 양쪽 다 이상해졌을 것이다. 핑과 ARP 캐시는 둘 다 거짓말을 하니 IP 점유 확인은 arping으로 해야 한다.
오버 엔지니어링은 제 취미입니다
내친김에 정리를 좀 했다.
내부 전용 서비스에 도메인을 붙이면서 DNS A 레코드에 사설 IP를 넣었다. 공인 인터넷에서는 사설 IP로 라우팅이 안 되니까 조회는 누구나 되지만 접속은 VPN 안에서만 가능하다. 방화벽 규칙 하나 없이 외부 차단이 완성된다.
문제는 인증서였다. 외부에서 접속이 안 되는 서버는 Let's Encrypt를 못 받는 줄 알았는데 아니었다. HTTP-01 방식은 Let's Encrypt가 서버 80포트로 직접 접속해서 확인하니까 내부 전용 서버에는 못 쓴다. 하지만 DNS-01 방식은 DNS에 TXT 레코드를 만들어서 확인하기 때문에 서버로 들어오는 연결이 아예 없어도 된다. DNS API로 레코드를 만들면 Let's Encrypt는 조회만 한다.
이왕 DNS-01을 쓰게 된 김에 와일드카드 인증서로 합쳤다. 14개 도메인이 각자 인증서를 갖고 있던 걸 하나로 정리했더니 갱신 대상이 1개가 됐고, 앞으로 서브도메인을 추가할 때 인증서 발급 단계 자체가 사라졌다.
다만 와일드카드는 단일 장애점이 된다. 갱신이 실패하면 전부 동시에 만료된다. 그래서 인증서 만료 감시를 같이 걸어뒀다.
마무리
| 이전 | 이후 | |
|---|---|---|
| 원인 | 모름 | AMD 유휴 락업 |
| 예방 | 없음 | C-state 억제 |
| 복구 | 수동, 이틀 반 | 하드웨어 워치독, 60초 |
| 백업 | 없음 | 매일 자동, 물리 분리, 복원 검증 |
| 감지 | 우연 | 헬스체크 + 푸시 알림 |
| 원격 접속 | 서버에 종속 | 이중화 |
제일 값진 건 원인을 알아낸 거라고 생각한다. "가끔 죽는 서버"로 두고 재부팅만 반복했으면 같은 일이 계속 났을 것이다. 로그가 비어 있는 이유까지 파고들지 않았으면 하드웨어 문제라는 걸 영영 몰랐을 테고.
그리고 이번에 반복해서 나온 원칙이 하나 있다. 감시하고 복구하는 수단은 감시 대상 밖에 두어야 한다는 것이다. Tailscale이 서버 안에 있어서 서버가 죽자 접속이 끊겼고, 모니터링이 없어서 이틀 반을 몰랐고, 백업이 같은 기계에 있었으면 디스크 고장 때 함께 사라졌을 것이다. 전부 같은 문제의 다른 얼굴이다.
물론 이 원칙을 끝까지 밀면 감시자를 감시하는 감시자가 무한히 필요해진다. 실제로 "Kuma가 죽으면 어떡하지"까지 갔다가 멈췄다. 어딘가에서는 끊어야 한다. 홈서버니까.
마지막으로 정직하게 덧붙이면, C-state 억제가 진짜 효과가 있는지는 아직 모른다. 원래 2주씩 잘 돌던 서버라 지금 멀쩡한 게 증거가 되지 않는다. 몇 주는 지나봐야 안다. 다만 그 기간에도 워치독이 지키고 있고 뭔가 잘못되면 알림이 오니까, 최악의 경우도 60초짜리 재부팅이지 이틀 반짜리 정지는 아니다.
홈서버 하겠다고 나선 이상 이런 일은 계속 생길 것이다. 뭐 어쩌겠는가.