libnginx-mod-rtmp + ffmpeg 기반 HLS 미디어 서버에 Prometheus와 Grafana를 붙여 생사 여부를 실시간으로 파악하는 대시보드를 구축한 과정을 기록한다.
nginx-rtmp (/stat) ──→ Python Exporter (9101) ─┐
node_exporter (9100) ├──→ Prometheus (9090) ──→ Grafana (3000)
ffmpeg 프로세스 상태 ─┘
세 가지 수집 레이어로 구성된다.
/stat XML을 파싱해 스트림 상태를 Prometheus 포맷으로 변환sudo useradd --no-create-home --shell /bin/false prometheus
sudo mkdir -p /etc/prometheus /var/lib/prometheus
sudo chown prometheus:prometheus /etc/prometheus /var/lib/prometheus
cd /tmp
wget https://github.com/prometheus/prometheus/releases/download/v3.4.1/prometheus-3.4.1.linux-amd64.tar.gz
tar xvf prometheus-3.4.1.linux-amd64.tar.gz
cd prometheus-3.4.1.linux-amd64
sudo cp prometheus promtool /usr/local/bin/
sudo chown prometheus:prometheus /usr/local/bin/prometheus /usr/local/bin/promtool
console 패키지는 3 버전에서 더이상 제공되지 않습니다.
왜냐하면 관리, 유지보수가 어렵고 그냥 모든 사람들도 grafana 와 같은 third-party library 와 함께 사용하는 게 국룰이 되어버렸기 때문에 tar 로 압축을 풀어봐도 console 패키지가 없으니 스킵!
sudo nano /etc/prometheus/prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_timeout: 15s
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'node'
static_configs:
- targets: ['localhost:9100']
- job_name: 'nginx_rtmp'
static_configs:
- targets: ['localhost:9101']
scrape_interval: 10s
sudo chown prometheus:prometheus /etc/prometheus/prometheus.yml
sudo nano /etc/systemd/system/prometheus.service
[Unit]
Description=Prometheus Monitoring
Wants=network-online.target
After=network-online.target
[Service]
User=prometheus
Group=prometheus
Type=simple
ExecStart=/usr/local/bin/prometheus \
--config.file=/etc/prometheus/prometheus.yml \
--storage.tsdb.path=/var/lib/prometheus/ \
--storage.tsdb.retention.time=30d \
--web.console.templates=/etc/prometheus/consoles \
--web.console.libraries=/etc/prometheus/console_libraries \
--web.listen-address=0.0.0.0:9090 \
--web.enable-lifecycle
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now prometheus
--web.enable-lifecycle 플래그를 추가해두면 재시작 없이 설정 리로드가 가능하다.
# 설정 변경 후 무중단 리로드
curl -X POST http://localhost:9090/-/reload
cd /tmp
wget https://github.com/prometheus/node_exporter/releases/download/v1.9.0/node_exporter-1.9.0.linux-amd64.tar.gz
tar xvf node_exporter-1.9.0.linux-amd64.tar.gz
sudo useradd --no-create-home --shell /bin/false node_exporter
sudo install -m 755 node_exporter-1.9.0.linux-amd64/node_exporter /usr/local/bin/node_exporter
sudo chown node_exporter:node_exporter /usr/local/bin/node_exporter
Tip. 기존에 node_exporter가 실행 중인 상태에서
cp로 덮어쓰려 하면Text file busy오류가 발생한다. 이때는install명령을 쓰거나 서비스를 먼저 중단한 뒤 복사한다.# 방법 1: install 명령 (서비스 중단 불필요) sudo install -m 755 node_exporter /usr/local/bin/node_exporter # 방법 2: 서비스 중단 후 복사 sudo systemctl stop node_exporter sudo cp node_exporter /usr/local/bin/ sudo systemctl start node_exporter
sudo nano /etc/systemd/system/node_exporter.service
[Unit]
Description=Node Exporter
Wants=network-online.target
After=network-online.target
[Service]
User=node_exporter
Group=node_exporter
Type=simple
ExecStart=/usr/local/bin/node_exporter \
--collector.systemd \
--collector.processes
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now node_exporter
nginx-rtmp의 /stat XML 엔드포인트를 파싱해 Prometheus 포맷으로 9101번 포트에 노출하는 Python 스크립트다.
# apt로 시스템 전역 설치 (pip install은 systemd 서비스에서 인식 못함)
sudo apt install -y python3-lxml python3-requests
sudo apt install -y python3-prometheus-client
# apt에 없다면:
# sudo pip3 install prometheus_client --break-system-packages
pip3 install을 일반 사용자 권한으로 실행하면 시스템 Python에 반영되지 않는다. systemd 서비스는 시스템 Python을 바라보므로 반드시apt또는sudo pip3로 설치해야 한다.
설치 후 한 번에 검증:
python3 -c "import prometheus_client, requests, lxml; print('모두 OK')"
sudo mkdir -p /opt/nginx_rtmp_exporter
sudo nano /opt/nginx_rtmp_exporter/exporter.py
#!/usr/bin/env python3
"""
nginx-rtmp → Prometheus Exporter
포트 9101에서 메트릭 노출
"""
import time
import requests
import logging
from lxml import etree
from prometheus_client import start_http_server, Gauge
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
RTMP_STAT_URL = "http://localhost:8888/stat"
SCRAPE_INTERVAL = 10
# ── 메트릭 정의 ────────────────────────────────────────────────────────────
rtmp_up = Gauge('nginx_rtmp_up', 'nginx-rtmp 서버 접근 가능 여부 (1=정상, 0=장애)')
rtmp_stream_count = Gauge('nginx_rtmp_stream_count', '현재 활성 스트림 수', ['app'])
rtmp_clients = Gauge('nginx_rtmp_clients', '스트림별 클라이언트 수', ['app', 'stream'])
rtmp_bw_in = Gauge('nginx_rtmp_bw_in_bps', '입력 대역폭 (bps)', ['app', 'stream'])
rtmp_bw_out = Gauge('nginx_rtmp_bw_out_bps', '출력 대역폭 (bps)', ['app', 'stream'])
rtmp_bytes_in = Gauge('nginx_rtmp_bytes_in_total','누적 수신 바이트', ['app', 'stream'])
rtmp_ffmpeg_count = Gauge('nginx_rtmp_ffmpeg_processes', '실행 중인 ffmpeg 프로세스 수')
hls_segment_count = Gauge('nginx_rtmp_hls_segment_count', 'HLS .ts 세그먼트 파일 수', ['stream'])
# ── 수집 함수 ──────────────────────────────────────────────────────────────
def count_ffmpeg_processes() -> int:
import subprocess
try:
r = subprocess.run(['pgrep', '-c', 'ffmpeg'], capture_output=True, text=True)
return int(r.stdout.strip()) if r.returncode == 0 else 0
except Exception:
return 0
def count_hls_segments(hls_root: str = '/var/hls') -> dict:
import os
counts = {}
try:
for entry in os.scandir(hls_root):
if entry.is_dir():
counts[entry.name] = len([f for f in os.listdir(entry.path) if f.endswith('.ts')])
except FileNotFoundError:
pass
return counts
def collect():
try:
resp = requests.get(RTMP_STAT_URL, timeout=5)
resp.raise_for_status()
rtmp_up.set(1)
except Exception as e:
logging.warning(f"stat 엔드포인트 접근 실패: {e}")
rtmp_up.set(0)
return
try:
root = etree.fromstring(resp.content)
for server in root.findall('.//server'):
for app in server.findall('application'):
app_name = app.findtext('name', default='unknown')
live = app.find('live')
if live is None:
continue
streams = live.findall('stream')
rtmp_stream_count.labels(app=app_name).set(len(streams))
for stream in streams:
name = stream.findtext('name', default='unknown')
rtmp_clients.labels(app=app_name, stream=name).set(len(stream.findall('.//client')))
rtmp_bw_in.labels(app=app_name, stream=name).set(int(stream.findtext('bw_in', '0')))
rtmp_bw_out.labels(app=app_name, stream=name).set(int(stream.findtext('bw_out', '0')))
rtmp_bytes_in.labels(app=app_name,stream=name).set(int(stream.findtext('bytes_in', '0')))
except Exception as e:
logging.error(f"XML 파싱 오류: {e}")
rtmp_ffmpeg_count.set(count_ffmpeg_processes())
for sname, cnt in count_hls_segments().items():
hls_segment_count.labels(stream=sname).set(cnt)
if __name__ == '__main__':
start_http_server(9101)
logging.info("nginx-rtmp exporter 가동 — :9101")
while True:
collect()
time.sleep(SCRAPE_INTERVAL)
sudo nano /etc/systemd/system/nginx_rtmp_exporter.service
[Unit]
Description=nginx-rtmp Prometheus Exporter
After=network.target
[Service]
User=prometheus
ExecStart=/usr/bin/python3 /opt/nginx_rtmp_exporter/exporter.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now nginx_rtmp_exporter
# 정상 동작 확인
curl http://localhost:9101/metrics | grep nginx_rtmp
설정 변경 후에는 반드시 리로드하고 타겟 상태를 점검한다.
curl -X POST http://localhost:9090/-/reload
sleep 30 && curl -s http://localhost:9090/api/v1/targets \
| python3 -m json.tool \
| grep -E '"job"|"health"|"lastError"'
세 job 모두 "health": "up" 이어야 정상이다.
"job": "nginx_rtmp" → "health": "up"
"job": "node" → "health": "up"
"job": "prometheus" → "health": "up"
주의. Prometheus를 재시작하지 않고
prometheus.yml만 수정하면 변경 사항이 반영되지 않는다.--web.enable-lifecycle플래그가 있다면 위처럼 reload API를 활용하면 되고, 없다면sudo systemctl restart prometheus로 재시작해야 한다.
sudo apt install -y apt-transport-https software-properties-common
wget -q -O - https://apt.grafana.com/gpg.key \
| sudo gpg --dearmor -o /usr/share/keyrings/grafana.gpg
echo "deb [signed-by=/usr/share/keyrings/grafana.gpg] https://apt.grafana.com stable main" \
| sudo tee /etc/apt/sources.list.d/grafana.list
sudo apt update && sudo apt install grafana -y
sudo systemctl enable --now grafana-server
서브경로(/grafana/)로 Grafana를 서빙하려면 nginx 설정과 grafana.ini 두 곳을 모두 올바르게 맞춰야 한다. 한쪽이라도 어긋나면 301 무한 루프가 발생한다.
| 실수 | 증상 |
|---|---|
server_name을 your-domain.com 플레이스홀더로 방치 | 요청이 엉뚱한 서버 블록으로 라우팅됨 |
| 기존 도메인 블록과 별도 server 블록 중복 생성 | 블록 충돌로 예측 불가 동작 |
proxy_pass http://localhost:3000/ (끝에 /) | prefix /grafana/가 제거되어 Grafana가 /grafana/로 redirect → 루프 |
grafana.ini의 domain, root_url, serve_from_sub_path 주석 상태 | Grafana가 localhost로 redirect |
별도 conf 파일을 만들지 말고, 해당 도메인의 기존 블록에 아래 location을 추가한다.
http {
##
# Basic Settings
##
...
server {
listen 8888;
allow 127.0.0.1;
deny all;
location /stat {
rtmp_stat all;
rtmp_stat_stylesheet stat.xsl;
}
}
}
location /grafana/ {
proxy_pass http://localhost:3000; # 끝 슬래시 없음!
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https; # $scheme 대신 하드코딩
proxy_redirect off;
}
proxy_pass http://localhost:3000/(슬래시 있음)으로 설정하면/grafana/login요청이http://localhost:3000/login으로 전달된다. Grafana는/login을 받으면/grafana/login으로 301을 내리고, nginx가 다시/login으로 벗겨서 보내는 무한 루프가 생긴다. 슬래시를 빼면/grafana/login그대로http://localhost:3000/grafana/login으로 전달된다.
sudo nano /etc/grafana/grafana.ini
[server]
protocol = http
domain = your-domain.com # 실제 도메인
root_url = https://your-domain.com/grafana/
serve_from_sub_path = true
주석 기호(;)를 반드시 제거해야 적용된다.
sudo nginx -t && sudo systemctl reload nginx
sudo systemctl restart grafana-server
브라우저에서 https://your-domain.com/grafana/ 접속 후 초기 계정 admin / admin으로 로그인.
Grafana UI에서 Connections → Data Sources → Add → Prometheus 선택 후:
| 항목 | 값 |
|---|---|
| URL | http://localhost:9090 |
| Access | Server (default) |
# 서버 생존 여부
nginx_rtmp_up
# 활성 스트림 수
nginx_rtmp_stream_count{app="live"}
# 전체 시청자 합계
sum(nginx_rtmp_clients)
# 입력 비트레이트 (Mbps)
sum(nginx_rtmp_bw_in_bps) / 1000000
# ffmpeg 프로세스 수
nginx_rtmp_ffmpeg_processes
# CPU 사용률 (%)
100 - (avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)
# 메모리 사용률 (%)
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100
# 네트워크 수신 (Mbps)
rate(node_network_receive_bytes_total{device!="lo"}[1m]) * 8 / 1000000
| 포트 | 용도 | 외부 오픈 |
|---|---|---|
| 9090 | Prometheus | ❌ 내부 전용 |
| 9100 | node_exporter | ❌ 내부 전용 |
| 9101 | rtmp exporter | ❌ 내부 전용 |
| 3000 | Grafana | ✅ nginx 프록시 경유 |
| 80 / 443 | nginx | ✅ |
Prometheus와 각 exporter는 외부에 노출할 이유가 없다. NHN Cloud 등 클라우드 보안 그룹에서 9090/9100/9101은 차단하고, Grafana는 nginx 리버스 프록시를 통해서만 접근하도록 구성하는 것을 권장한다.
sudo journalctl -u nginx_rtmp_exporter -n 30
ModuleNotFoundError: No module named 'lxml' → sudo apt install -y python3-lxml python3-requests python3-prometheus-client
설정 파일 수정 후 reload를 빠뜨린 경우.
curl -X POST http://localhost:9090/-/reload
nginx 에러 로그와 curl -sIL 리다이렉트 체인을 먼저 확인한다.
sudo tail -50 /var/log/nginx/error.log
curl -sIL --max-redirs 5 https://your-domain.com/grafana/
proxy_pass 끝 슬래시 제거 (http://localhost:3000 ← 슬래시 없음)grafana.ini의 domain, root_url, serve_from_sub_path 주석 해제 및 올바른 값 입력proxy_set_header X-Forwarded-Proto https 하드코딩 (변수 대신)