자가 호스팅을 오래 하다 보면 난처한 문제와 마주친다. 감시 대상 머신 위에서 감시 도구가 돌고 있다면, 그 머신이 죽었을 때 누가 알려주는가?

내 모든 서비스는 Uptime Kuma로 지켜보고 있는데, 정작 그 Kuma가 감시 대상과 같은 머신에 올라가 있다. 정전, 회선 단절, 통째로 먹통. 그러면 모니터링도 함께 죽고 알림은 한 통도 나가지 않는다.

이걸 해결하려면 모니터링을 다른 곳에 두는 수밖에 없다. 방법은 하나가 아니다.

방식비용어울리는 사람
저렴한 VPS에서 자기 Kuma를 돌린다월 몇 달러전부 직접 쥐고 싶고, 누구에게도 신세 지기 싫다
기성 서드파티 모니터링 서비스를 쓴다무료 플랜으로 대개 충분"죽으면 알려줘"만 필요하고 서비스를 더 늘리기 싫다
다른 자가 호스팅 운영자와 교차 모니터링0원마침 같은 걸 하는 지인이 있다

세 번째를 고른 건 순전히 그 상대가 있었기 때문이다. 또 다른 자가 호스팅 운영자와 서로를 지켜본다. 저쪽 Kuma가 이쪽을, 이쪽 Kuma가 저쪽을 본다. 덤으로 두 설치 장소는 장비와 회선과 전원이 완전히 독립적이고, 게다가 상대는 살아 있는 사람이라 진짜로 죽었을 때 직접 연락해 준다.

이 글은 그 전말의 기록이다. 처음에 둘 다 발목 잡혔던 인증서에 대한 오해, v1에서 v2로의 데이터베이스 마이그레이션, 그리고 마지막에 드러난 함정. Trust Proxy라는 설정은 내 구성에서 켰을 때가 껐을 때보다 더 위험했다.

(상태 페이지의 외관은 커스텀 CSS로 갈아 끼울 수 있다. 위는 나중에 맞춘 픽셀풍이다. Kuma는 상태 페이지와 관리 화면이 분리되어 있어서, 관리 화면은 로그인이 필요하지만 상태 페이지는 단독으로 공개할 수 있고 보여주고 싶은 서비스만 노출하면 된다.)

인증서를 교환할 필요가 없다#

처음 구성은 이랬다. 상대가 status.<상대 도메인>이라는 서브도메인을 만들고 그 A 레코드를 내 고정 IP로 향하게 했으며, Kuma는 내 머신에서 돌린다.

거기서 막혔다. 내 nginx는 Let's Encrypt를 쓰는데 도메인은 상대의 것이다. 둘 다 같은 생각을 하고 있었다. HTTPS로 가려면 인증서를 넘겨받아야 한다고. 상대는 *.<상대 도메인>를 가지고 있었고, 나는 그게 유일한 길이라고 믿었다.

이 전제가 통째로 틀렸다.

IMPORTANT

교차 모니터링에는 인증서 교환이 전혀 필요 없다 핵심은 여기 있다. status.<상대 도메인>은 분명 상대의 도메인이지만 A 레코드는 이미 내 IP를 가리키고 있으므로 TLS는 내 서버에서 종료된다. Let's Encrypt의 http://status.<상대 도메인>/.well-known/acme-challenge/...를 가지러 오는데, 그 요청은 곧장 내 nginx로 들어온다. 즉 인증서는 내가 직접 발급할 수 있고, 상대에게 받을 것은 아무것도 없다.

certbot --nginx -d status.<상대 도메인> 한 줄로 끝났다. 갱신도 같이 해결된다. certbot.timer가 자동으로 돌아서 내 도메인과 똑같이 다뤄진다.

와일드카드가 묶는 것은 "발급하는 그 순간"뿐#

내 오해는 "와일드카드는 DNS에 묶이니까 상대 것을 쓸 수밖에 없다"였다. 이건 절반만 맞다. 와일드카드가 "DNS에 묶인다"는 건 발급하는 그 순간의 검증 방식 이야기이고, 거기는 확실히 DNS-01이어야 한다. 하지만 그것과 "발급 후 어디에 묶이는가"는 별개다.

서명된 인증서는 그저 파일이고, TLS를 종료하는 머신 위에 놓인다. DNS 사업자에도, 특정 서버에도 묶이지 않는다. 게다가 같은 호스트명에 대해 서로 다른 인증서가 동시에 여러 장 존재해도 되고 충돌하지 않는다. 상대가 와일드카드를 가졌다고 해서 CA가 내 단일 도메인 인증서 발급을 거부하지는 않는다. 인증서 발급은 독점이 아니며, 그 호스트명을 통제한다고 증명할 수 있으면 자기 몫의 한 장을 받는다.

거꾸로 보면 "상대의 와일드카드를 쓴다"는 지뢰를 정확히 두 개 밟는다.

와일드카드 공유직접 단일 도메인 발급
개인키 노출면상대의 모든 서브도메인을 덮는다. 사이트 전체의 열쇠를 맡는 셈이 호스트명 하나뿐
갱신약 90일마다 상대가 재발급하고 내가 다시 붙여 재시작. 완전히 수작업이라 끊기기 쉽다certbot.timer가 자동. 관여 0

세 역할을 나눠서 보면 헷갈리지 않는다. 발급하는 쪽은 언제나 CA(Let's Encrypt). 신청자는 나(certbot을 돌리고 통제권을 증명하는 건 나다). 도메인 소유자는 상대이고, 상대가 하는 일은 A 레코드를 향하게 하는 것뿐이며 그건 이미 오래전에 끝났다. 누구도 "상대를 대신해 서명해 줄" 필요가 없다.

WARNING

이 방법의 유일한 전제: 상대의 서브도메인이 순수 A 레코드 직결이어야 하고, Cloudflare 오렌지 클라우드를 거치면 안 된다 오렌지 클라우드가 켜져 있으면 TLS가 Cloudflare에서 종료되고 HTTP-01 검증도 당신이 아니라 Cloudflare로 가기 때문에 이 길이 막힌다(그 경우엔 Cloudflare Origin Cert로 바꿔야 한다). 이 전제는 글 후반부에서 다시 한 번 나를 물어뜯는다.

리버스 프록시: WebSocket을 빼먹지 말 것#

Kuma의 관리 화면은 실시간 하트비트와 그래프를 밀어 보내는 데 를 많이 쓴다. 리버스 프록시에 맨 proxy_pass 한 줄만 써두면 WebSocket으로 업그레이드되지 않고, 증상은 실시간 그래프가 전혀 움직이지 않는 것, 또는 브라우저가 "안전하지 않음"으로 판정하는 것이다(HTTPS 페이지 안에 ws://가 섞이기 때문).

프록시 블록에 필요한 줄은 이 정도다.

nginx
location / {
    proxy_pass http://127.0.0.1:3011;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 86400s;          # 24시간. 장시간 연결이 nginx에 끊기지 않도록
}

오독하기 쉬운 지점이 하나 있다. /socket.io/에 raw upgrade 요청을 직접 던지면 400이 돌아오는데, 이건 sid 파라미터가 없다는 이유로 Socket.IO 자신이 거절한 것이지 nginx가 막은 게 아니다. 이 400을 보고 서둘러 nginx를 고치지 말 것.

무엇을 감시할 것인가#

서비스를 붙이고 나서, 적어둘 만한 방식이 두 가지 있었다.

덧붙이자면, 모니터를 추가할 때 인증서 만료 알림에 체크해 두면 좋다. 교차 모니터링은 어차피 주기적으로 상대의 HTTPS를 두드리니, 잔여 일수가 얼마 안 남았을 때 소리를 내게 해두는 것이다. 공짜로 보험이 한 겹 늘어난다.

백엔드에 공개 /health를 하나 열어둔다

처음 NAS 백엔드를 감시할 때는 인증이 필요한 엔드포인트를 두드리고 "허용 상태 코드"를 401로 설정했다. 401이 오면 살아 있다는 논리다. 동작은 하지만 의미가 비뚤어져 있다. 백엔드가 망가져도 리버스 프록시가 살아 있으면 그대로 401을 계속 돌려주고 모니터는 초록 상태 그대로일 수 있다.

정답은 백엔드에 공개·인증 불필요한 /health를 하나 붙여 200을 돌려주게 하는 것이다. 살아 있으면 200, 죽었으면 502, 허용 상태 코드는 기본값 200-299로 되돌린다. 그러면 의미가 맞아떨어진다.

cron 스크립트에는 push 형 모니터를 쓴다

HTTP 모니터로 확인할 수 있는 건 "서비스가 지금 이 순간 살아 있다"는 것뿐이다. 일일 백업이나 업데이트 스크립트 같은 예약 작업에서 정말 봐야 할 것은 제시간에 완주했는지 여부다.

Kuma의 가 바로 이걸 위한 것이다. URL을 하나 주므로, 스크립트 마지막에 한 번 두드린다.

bash
curl -fsS "https://status.<내 도메인>/api/push/<TOKEN>?status=up&msg=OK"

설정한 간격을 넘겨도 오지 않으면 빨간불이 켜진다. 아래 사진은 실제로 하트비트가 오지 않았을 때의 모습으로, 메시지가 아주 직설적이다. No heartbeat in the time window.

이러면 "스크립트가 조용히 죽는", 가장 알아차리기 어려운 종류의 고장에도 지켜보는 눈이 생긴다.

전부 붙이고 나면 관리 화면은 이렇게 된다. 왼쪽에 하트비트 막대가 죽 늘어선다.

v1에서 v2로: 되돌릴 수 없다고 여겨야 할 마이그레이션#

이후 Kuma를 1.23.17에서 2.4.0으로 올렸다. 동기는 2.x의 고급 기능이지만, 그 전에도 호되게 겪은 게 있다. Kuma의 로그인 페이지는 인터넷에 공개되어 있으므로, 계정을 만든 시점에 2FA를 걸어두어야 한다.

이 업그레이드는 태그를 갈아 끼우는 정도의 일이 아니다. 먼저 알아둘 점이 몇 가지 있다.

CAUTION

1.x → 2.x에는 데이터베이스 마이그레이션이 있고, 되돌릴 수 없다고 여겨야 한다

  • 마이그레이션은 2.x 최초 기동 시 자동으로 실행된다(개별 하트비트를 새 형식으로 집계한다). 기본값은 SQLite 그대로이며 MariaDB로의 이전을 강요하지 않는다.
  • 도중에 중단해서는 안 된다. 중단하면 백업에서 복원해 다시 하는 수밖에 없다. 공식 추정치는 20 monitors / 90일 기준 약 7분이고, 느린 하드웨어에서는 더 걸린다.
  • 1.x로 되돌릴 수 있는지는 공식 문서에 없으므로 되돌릴 수 없다고 여기고, 백업만이 유일한 퇴로라고 생각한다.
  • 마이그레이션 중에는 서비스가 잠깐 내려간다(교차 모니터링 맥락에서는 상대 쪽에서 이쪽이 잠시 빨개지는 게 보인다).

순서는 이렇다. 백업 → image를 2.4.0으로 변경 → up → 마이그레이션 로그를 지켜보기 → 검증.

실제로 돌려보니 공식 추정보다 훨씬 빨랐다. 이쪽은 monitor 8개, 하트비트 12278건이었고 마이그레이션은 4초에 끝났다.

console
Uptime Kuma Version: 2.4.0
Aggregate Table Migration Completed
Listening on: ... ✓

첫눈에 놀랄 숫자가 하나 있다. 하트비트가 12278에서 10190으로 이천 건쯤 줄었다. 이건 정상이다. 2.x의 마이그레이션은 옛 개별 하트비트를 집계해 압축하는 것이고, monitor는 하나도 사라지지 않았다.

Trust Proxy: 켜면 더 위험해진다#

v2로 올린 뒤 설정 화면에서 Trust Proxy가 꺼져 있는 걸 발견했다. 내 nginx는 X-Forwarded-For를 보내고 있다. 꺼진 채로는 Kuma가 모든 방문자를 127.0.0.1로 취급하므로 로그인 속도 제한이 전역으로 공유된다. 누군가 로그인 페이지를 무차별 대입하면 나까지 함께 문밖에 잠긴다.

보기에는 켜야 마땅하다. 게다가 켜도 "안전할 터"였다. Kuma는 loopback에만 바인딩되어 있어 밖에서는 닿지 않고 반드시 nginx를 거치기 때문이다. 그래서 켰다.

그리고 실측에 뒤집혔다.

console
$ # 테스트 1: X-Forwarded-For 없이
  Kuma 기록 → 내 실제 IP               ✅

$ # 테스트 2: X-Forwarded-For를 직접 위조
$ curl -H 'X-Forwarded-For: 203.0.113.99' https://status.example.com/...
  Kuma 기록 → 203.0.113.99             ❌ 그대로 삼킴

(203.0.113.99는 RFC 5737에서 문서용으로 예약된 대역이라 실제 누군가에게 닿지 않는다.)

Kuma는 내가 위조한 헤더를 완전히 믿었다. 병소는 Kuma가 아니라 내 nginx의 이 한 줄에 있다.

nginx
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#                                 ↑ 이건 "덧붙이기"지 "덮어쓰기"가 아니다

는 "클라이언트가 보내온 XFF를 남기고 그 뒤에 $remote_addr를 붙인다"는 뜻이다. 그리고 Kuma(Express의 trust proxy)가 가져가는 것은 가장 왼쪽 값, 즉 클라이언트가 자칭한 값이다. 그래서 누구든 아무 IP나 자칭할 수 있다.

진짜 위험: CrowdSec가 Cloudflare를 차단한다#

처음에는 "로그인 속도 제한이 무의미해져서 비밀번호를 무한정 시도할 수 있다"고 판단했다. 하지만 2FA는 이미 켜져 있어서 맞혀도 들어오지는 못한다. 그건 주된 문제가 아니었다.

진짜 문제는 다른 서비스 쪽에 있었다. 내 머신에서는 가 돌고 있다. 짧게 말하면 현대판 fail2ban으로, nginx의 access log를 읽고 스캔이나 무차별 대입을 발견하면 그 IP를 자동으로 차단한다.

NOTE

CrowdSec 자체의 구축은 따로 정리해 두었다 설치, 시나리오 선택, bouncer를 어떻게 붙이는지, 그리고 내가 밟은 함정(가장 전형적인 것은 내 사이트를 테스트하다가 내 IP를 차단해버려서 밖에서 집으로 접속하지 못하게 된 일)은 모두 그쪽에 있다. 여기서 필요한 이해는 하나뿐이다. 그 판단은 access log에 기록된 출발지 IP에 전적으로 의존한다.

그리고 바로 거기가 문제였다. 따라가 보니 토폴로지가 내 생각과 달랐다. 내 도메인은 Cloudflare 오렌지 클라우드를 쓰지 않지만 상대의 도메인은 쓰고 있고, 그게 내 집을 가리킨다.

즉 내 nginx에 도착하는 요청 중 일부는 먼저 Cloudflare를 거친다(최근 로그를 실측하니 약 10%가 CF 대역에서 왔다). 그것들이 access log에 기록될 때 $remote_addrCloudflare 노드의 IP이지 실제 방문자가 아니고, CrowdSec는 바로 그 필드를 보고 누구를 차단할지 정한다.

결과는 두 방향으로 굴러가는데 둘 다 좋지 않다.

  • 누군가 그 경로로 나를 스캔하거나 무차별 대입한다 → CrowdSec가 공격 출처를 CF 노드로 판정한다 → Cloudflare 대역을 차단한다. 그 결과 상대의 상태 페이지가 전 세계에서 닿지 않게 되고, 차단된 것이 공격자가 아니라 CF이므로 진단이 극도로 어렵다.
  • 반대로 CF 뒤에 숨은 진짜 공격자는 CrowdSec에 전혀 잡히지 않는다. 잡히는 건 CF뿐이다.

고치는 법: XFF를 덮어쓰기로 바꾼다#

문제는 Kuma가 아니라 nginx에 있으므로 두 가지를 함께 한다.

nginx
# 1. Cloudflare 뒤의 진짜 클라이언트 IP를 nginx에 알려준다
set_real_ip_from 173.245.48.0/20;   # CF 공식 목록에서 전 대역을 생성하고 주기적으로 갱신
real_ip_header CF-Connecting-IP;

# 2. XFF를 덧붙이기가 아니라 덮어쓰기로
proxy_set_header X-Forwarded-For $remote_addr;

첫째로 $remote_addr가 진짜 방문자 IP가 된다. 둘째로 클라이언트가 무엇을 보내오든 통째로 버려지고 nginx가 스스로 인정한 출처로 바뀌므로 위조가 먹히지 않는다. 바꾼 뒤에는 같은 위조 요청을 한 번 더 던져, Kuma가 가짜 IP를 삼키지 않는지 확인한다.

부작용은 좋은 쪽이다. CrowdSec가 진짜 공격자 IP를 보게 되어 방어가 실제로 강해지고, Kuma 로그에도 상대 쪽 어느 기기가 주입한 192.168.0.1이 아니라 진짜 IP가 뜬다.

NOTE

$proxy_add_x_forwarded_for는 많은 사람의 기본 표기법이다 그 자체가 틀린 건 아니다. 상류를 신뢰하고 전달 체인을 통째로 남기고 싶은 상황에서는 옳다. 틀린 것은 그걸 "하류 애플리케이션이 trust proxy를 켜고 가장 왼쪽 값을 가져간다"는 구성과 조합하면서, set_real_ip_from으로 누구를 신뢰하는지 정의하지 않은 점이다. 내 메인 사이트의 server block 네 개도 같은 표기법이지만, 메인 사이트는 그레이 클라우드라 CrowdSec가 읽는 게 $remote_addr여서 영향을 받지 않았다. 같은 지뢰가 그저 묻힌 채 있을 뿐이다.

한 장짜리 체크리스트#

교차 모니터링 + Kuma 자가 호스팅 체크리스트
  1. 교차 모니터링에 인증서 교환은 불필요. 상대가 서브도메인의 A 레코드를 이쪽으로 향하게 했다면, 직접 certbot --nginx -d <그 서브도메인>을 실행한다. HTTP-01은 이쪽으로 오고 CA는 이쪽에 발급하며 갱신은 자동이다.
  2. 와일드카드를 공유하지 말 것. 개인키가 상대의 사이트 전체를 덮고, 게다가 90일마다 수작업 재발급이 필요하다. 지뢰가 둘.
  3. 전제는 순수 A 레코드 직결. 상대가 오렌지 클라우드를 쓰면 HTTP-01이 닿지 않으므로 Origin Cert로 바꿔야 한다.
  4. Kuma 리버스 프록시에는 반드시 proxy_http_version 1.1 + Upgrade + Connection "upgrade"를. 없으면 실시간 그래프가 움직이지 않는다. /socket.io/의 400은 정상이니 오독하지 말 것.
  5. 백엔드에 공개 /health를 붙인다. "401을 허용한다"는 잔재주에 기대지 않는다.
  6. cron 스크립트에는 push 형 모니터. "제시간에 완주하지 않았다"는 침묵하는 고장을 지켜본다.
  7. v1→v2는 되돌릴 수 없다고 여긴다. 먼저 데이터 볼륨을 백업하고, 마이그레이션 중에는 절대 중단하지 않는다. 하트비트 건수가 줄어드는 건 집계이지 데이터 유실이 아니다.
  8. 로그인 페이지가 공개라면 2FA를 건다.
  9. Trust Proxy를 켜기 전에 XFF가 덮어쓰기인지 덧붙이기인지 확인하고, set_real_ip_from으로 신뢰할 상류를 정의한다. 그러지 않으면 켠 쪽이 끈 쪽보다 위험해진다.
參考連結
  • Uptime Kuma —— 프로젝트 본체와 2.x 마이그레이션 설명GitHub
  • Let's Encrypt —— HTTP-01 / DNS-01 검증 방식 설명letsencrypt.org
  • CrowdSec —— 공식 문서docs.crowdsec.net
  • Cloudflare —— 방문자 실제 IP 복원(CF-Connecting-IP와 대역 목록)Cloudflare Docs
  • nginx —— realip 모듈nginx.org