이채강

읽는 데 17분조회 3

홈페이지에 Grafana 대시보드 달기 (1) — Prometheus·Docker 로 세우고 사이트 안에 끼워 넣기

버전 1.0.0

작성 기준: 2026-09-11

서버 한 대에 서비스가 늘었다. 홈페이지, 개발 인스턴스, 방문자 통계, DB, 집에 있는 책 서재. 바깥으로 나가는 문을 Caddy 하나로 정리한 다음, 정작 그 서비스들이 어떻게 돌고 있는지는 내가 안 보고 있다는 것을 알았다.

그래서 Prometheus 로 지표를 모으고 Grafana 로 그렸다. 그리고 그 대시보드를 사이트 관리 화면 안에 끼워 넣었다. 사이트에 로그인하면 대시보드도 열린다 — 비밀번호를 따로 외울 것이 없다.

이 글은 그대로 따라 할 수 있게 쓴 것이다. 설정 파일은 줄이지 않고 전부 넣었다. 비밀번호와 접속 문자열만 자리표시자로 바꿨고, 그 값을 어떻게 만들고 어디에 두는지는 7절에 따로 적었다.

앱 안쪽 지표(힙, 이벤트 루프, 글·댓글 수)를 붙이는 이야기와 대시보드를 코드로 만드는 이야기는 다음 글에 있다.

그림1-1.png

인터넷 ──443──> Caddy ─┬─> 127.0.0.1:40000  앱 (Next.js)
                       ├─> 127.0.0.1:53000  Grafana   (/grafana/*)
                       └─> 127.0.0.1:40003  방문자 통계

        Prometheus ─┬─> host.docker.internal:59200  Caddy 지표
        (컨테이너)   ├─> node-exporter:9100           서버 자원
                    ├─> cadvisor:8080                컨테이너
                    ├─> postgres-exporter:9187       DB
                    └─> blackbox-exporter:9115       바깥에서 찔러 보기

준비물

  • 리눅스 서버 한 대 (이 글은 Ubuntu ARM64, Oracle Cloud 무료 인스턴스)
  • Docker 와 Docker Compose
  • 호스트에서 도는 Caddy (systemd). 컨테이너가 아니라 호스트인 것이 3절에서 중요해진다
  • 도메인 하나와 HTTPS. Caddy 가 인증서를 알아서 받는다
  • (선택) 관리자 로그인이 있는 앱. 4절의 문지기가 그 로그인을 쓴다

2절까지만 해도 대시보드는 뜬다. 4·5절이 사이트 안에 넣는 부분이다.


1. 감시 스택 올리기

사이트와 다른 디렉터리, 다른 compose 프로젝트로 만든다. 사이트가 죽을 때 감시도 같이 죽으면 아무 의미가 없다. 사이트 배포가 이 디렉터리를 건드리지 않게 하려는 것도 있다.

mkdir -p ~/monitoring/{prometheus,blackbox,grafana/provisioning/{datasources,dashboards},grafana/dashboards}
cd ~/monitoring

비밀값 먼저

cat > .env <<'EOF'
GF_SECURITY_ADMIN_PASSWORD=여기에-긴-무작위-문자열
POSTGRES_EXPORTER_DSN=postgresql://monitor:암호@host.docker.internal:55432/postgres?sslmode=disable
EOF
chmod 600 .env

.env 는 절대 저장소에 넣지 않는다. 무작위 문자열은 이렇게 만든다.

openssl rand -base64 24

DB 를 안 쓰면 POSTGRES_EXPORTER_DSN 줄과 postgres-exporter 서비스를 빼면 된다. 쓸 거면 읽기 전용 사용자를 따로 만든다. 감시는 읽기만 하면 되는데 쓰기 권한을 줄 이유가 없다.

create user monitor with password '암호';
grant pg_monitor to monitor;

pg_monitor 는 PostgreSQL 이 기본으로 주는 역할이다. 통계 뷰를 읽을 수 있고 데이터는 못 건드린다.

docker-compose.yml

services:
  grafana:
    image: grafana/grafana-oss:latest
    container_name: grafana
    ports:
      # 127.0.0.1 을 꼭 붙인다. 안 붙이면 도커가 0.0.0.0 에 열어
      # 인터넷 전체에 공개된다.
      - '127.0.0.1:53000:3000'
    volumes:
      - grafana_data:/var/lib/grafana
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
      - ./grafana/dashboards:/var/lib/grafana/dashboards:ro
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=${GF_SECURITY_ADMIN_PASSWORD:?.env  없습니다}
      - GF_SECURITY_ADMIN_USER=관리자-이름
      # 4절에서 설명한다. 사이트 도메인 밑 /grafana 로 들어오게 하는 값들
      - GF_SERVER_DOMAIN=example.duckdns.org
      - GF_SERVER_ROOT_URL=https://example.duckdns.org/grafana/
      - GF_SERVER_SERVE_FROM_SUB_PATH=true
      - GF_SECURITY_ALLOW_EMBEDDING=true
      # 문지기는 Caddy 다. Grafana 자체 로그인은 끈다 (4절)
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
      - GF_AUTH_DISABLE_LOGIN_FORM=true
      - GF_AUTH_DISABLE_SIGNOUT_MENU=true
      - GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/myhome.json
    restart: unless-stopped

  prometheus:
    image: prom/prometheus:v3.1.0
    container_name: prometheus
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus_data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
    ports:
      - '127.0.0.1:59090:9090'
    # 호스트에서 도는 Caddy 를 긁으려면 컨테이너에서 호스트를 부를 이름이
    # 필요하다. host-gateway 는 도커가 브리지 게이트웨이 주소로 바꿔 준다.
    # 172.18.0.1 처럼 직접 적으면 네트워크를 다시 만들 때 어긋난다.
    extra_hosts:
      - 'host.docker.internal:host-gateway'
    restart: unless-stopped

  node-exporter:
    image: prom/node-exporter:v1.12.1
    container_name: node-exporter
    # 지표에 붙는 이름이 된다. 서버가 여러 대면 구분에 쓴다
    hostname: ubuntu-arm
    volumes:
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
      - /:/rootfs:ro
    command:
      - '--path.procfs=/host/proc'
      - '--path.sysfs=/host/sys'
      - '--path.rootfs=/rootfs'
    ports:
      - '127.0.0.1:59100:9100'
    restart: unless-stopped

  cadvisor:
    image: ghcr.io/google/cadvisor:v0.60.1
    container_name: cadvisor
    privileged: true
    devices:
      - /dev/kmsg
    volumes:
      - /:/rootfs:ro
      - /var/run:/var/run:ro
      - /sys:/sys:ro
      - /var/lib/docker/:/var/lib/docker:ro
      - /dev/disk/:/dev/disk:ro
    ports:
      - '127.0.0.1:58080:8080'
    restart: unless-stopped

  postgres-exporter:
    image: quay.io/prometheuscommunity/postgres-exporter:v0.17.1
    container_name: postgres-exporter
    environment:
      DATA_SOURCE_NAME: ${POSTGRES_EXPORTER_DSN:?.env  없습니다}
    ports:
      - '127.0.0.1:59187:9187'
    extra_hosts:
      - 'host.docker.internal:host-gateway'
    restart: unless-stopped

  blackbox-exporter:
    image: prom/blackbox-exporter:v0.27.0
    container_name: blackbox-exporter
    volumes:
      - ./blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
    command:
      - '--config.file=/etc/blackbox_exporter/config.yml'
    ports:
      - '127.0.0.1:59115:9115'
    restart: unless-stopped

volumes:
  grafana_data:
  prometheus_data:

포트마다 127.0.0.1: 이 붙어 있는 것을 확인하고 넘어가자. 예전에 이걸 빼 두었다가 node-exporter·Prometheus·cAdvisor 가 인터넷에 열려 있었다. 커널 버전, 호스트 이름, 디스크 구성, 컨테이너 목록이 그대로 나가고 있었다.

이미지 태그는 Grafana 만 latest 다. 대시보드가 깨진 적이 없어 그대로 두고 있다. 나머지는 박아 뒀다 — 어느 날 갑자기 안 뜨는 것이 더 곤란하다.

blackbox.yml

바깥에서 주소를 실제로 열어 보는 도구다. 서버 안에서 보는 지표는 서버가 살아 있을 때만 나온다. DNS 가 안 돌거나 인증서가 만료된 상황은 못 잡는다.

modules:
  http_2xx:
    prober: http
    timeout: 10s
    http:
      # 리다이렉트를 따라가지 않는다. 302 도 "살아 있음" 으로 본다.
      # 따라가면 로그인 화면까지 가서 응답 시간이 부풀려진다.
      follow_redirects: false
      valid_status_codes: [200, 301, 302, 303, 307, 308]
      valid_http_versions: ["HTTP/1.1", "HTTP/2.0"]
      preferred_ip_protocol: ip4
      ip_protocol_fallback: false

  # 비밀번호로 막힌 주소용. 401 이 나오면 "살아 있고 제대로 막고 있음" 이다.
  http_401:
    prober: http
    timeout: 10s
    http:
      follow_redirects: false
      valid_status_codes: [401]
      valid_http_versions: ["HTTP/1.1", "HTTP/2.0"]
      preferred_ip_protocol: ip4
      ip_protocol_fallback: false

두 번째 모듈이 요점이다. 비밀번호를 걸어 둔 주소는 200 이 아니라 401 이 정상이다. 이걸 http_2xx 로 감시하면 늘 실패로 뜨고, 반대로 어느 날 인증이 풀려 200 이 되면 그게 정상으로 보인다.


2. 무엇을 긁을지 정하기

prometheus/prometheus.yml 전문이다. 도메인만 바꾸면 그대로 쓸 수 있다.

global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'prometheus'
    static_configs:
      - targets: ['localhost:9090']

  - job_name: 'node-exporter'
    static_configs:
      # 같은 compose 네트워크 안이라 컨테이너 이름으로 닿는다
      - targets: ['node-exporter:9100']
        labels:
          instance: 'ubuntu-arm'

  - job_name: 'cadvisor'
    static_configs:
      - targets: ['cadvisor:8080']

  - job_name: 'postgres'
    static_configs:
      - targets: ['postgres-exporter:9187']
        labels:
          instance: 'myhome-postgres'

  # 호스트에서 도는 Caddy. 3절에서 이 포트를 연다.
  - job_name: 'caddy'
    static_configs:
      - targets: ['host.docker.internal:59200']
        labels:
          instance: 'ubuntu-arm'

  # 앱 안쪽 지표. 6절에서 만든다.
  - job_name: 'myhome-app'
    static_configs:
      - targets: ['host.docker.internal:59201']
        labels:
          instance: 'myhome-app'

  # 바깥에서 주소를 찔러 본다
  - job_name: 'blackbox-http'
    metrics_path: /probe
    params:
      module: [http_2xx]
    static_configs:
      - targets:
          - https://example.duckdns.org
          - https://example.duckdns.org/about
          - https://example.duckdns.org/blog
          - https://umami.example.duckdns.org/login
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target
      - source_labels: [__param_target]
        target_label: instance
      - target_label: __address__
        replacement: blackbox-exporter:9115

  # 비밀번호로 막은 주소. 401 이 나와야 정상이다.
  - job_name: 'blackbox-auth'
    metrics_path: /probe
    params:
      module: [http_401]
    static_configs:
      - targets:
          - https://example.duckdns.org/grafana/
          - https://prometheus.example.duckdns.org
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target
      - source_labels: [__param_target]
        target_label: instance
      - target_label: __address__
        replacement: blackbox-exporter:9115

relabel_configs 가 왜 필요한지만 짚고 가자. blackbox-exporter 는 "대신 찔러 주는 심부름꾼" 이다. Prometheus 가 주소를 파라미터로 넘기고 결과를 받아 오는 구조라, 그대로 두면 모든 결과의 instance 라벨이 blackbox-exporter:9115 로 찍힌다. 어느 주소 결과인지 알 수 없다. 그래서 세 줄로 라벨을 바꿔 붙인다.

  1. 타깃 주소를 target 파라미터로 옮긴다
  2. 그 값을 instance 라벨로 복사한다
  3. 실제로 요청 보낼 곳은 blackbox-exporter 로 바꾼다

띄우고 확인

docker compose up -d
docker compose ps

타깃이 다 붙었는지 본다.

curl -s localhost:59090/api/v1/targets?state=active \
  | python3 -c "import sys,json;[print(t['labels']['job'], t['health'], t.get('lastError','')) for t in json.load(sys.stdin)['data']['activeTargets']]"

caddymyhome-app 은 아직 실패한다. 3절과 6절에서 그 문을 연다.

그림1-2.png

prometheus.yml 만 고쳤을 때는 up -d 로는 안 된다. "Running" 만 찍고 아무 일도 하지 않는다. docker compose restart prometheus 를 해야 새 설정을 읽는다. 이걸로 30분을 날린 적이 있다.


3. Caddy 지표 켜기

Caddy 는 지표를 기본으로 내지 않는다. 켜야 한다. 그런데 그 지표를 어떻게 꺼내는지가 함정이다.

Caddy 의 지표는 관리 API(localhost:2019)에 붙어 있다. 그 API 로는 설정을 바꿀 수도 있다. 밖으로 열 것이 아니다. 게다가 Prometheus 는 컨테이너 안에 있어서 호스트의 127.0.0.1:2019 에 닿지 못한다.

그래서 지표만 내보내는 문을 따로 만든다. /etc/caddy/Caddyfile 맨 앞에 전역 설정을 넣고,

{
	metrics {
		per_host
	}
}

지표 전용 포트를 하나 연다.

:59200 {
	@docker remote_ip 172.16.0.0/12 192.168.0.0/16 10.0.0.0/8 127.0.0.1/32
	handle @docker {
		metrics /metrics
	}
	respond 403
}

per_host 를 켜면 host 라벨이 붙어 도메인별로 나뉜다. 이게 없으면 server="srv1" 하나에 모든 도메인이 합쳐진다. 도메인 수만큼 시계열이 늘어나지만, 다섯 개쯤이면 문제가 안 된다.

@docker 매처가 문지기다. 사설 대역에서 온 요청만 답하고 나머지는 403 이다. 도커 브리지 대역이 172.16~31 안에 있어서 172.16.0.0/12 로 잡았다. 네트워크를 다시 만들어 주소가 바뀌어도 그대로 돈다.

포트 번호를 고르는 요령. 아무 번호나 되지 않는다. 클라우드 인스턴스는 보안 그룹이나 iptables 가 특정 대역만 받는 경우가 많다. 나는 2020 으로 열었다가 컨테이너에서 "No route to host" 를 만났다. 이 서버는 40000~59999 만 열려 있어서 59200 을 골랐다.

그 대역 안에서 나는 이렇게 갈라 뒀다. 임의로 정한 규칙이다.

대역무엇
4만대사람이 쓰는 서비스 — 앱(운영·개발), 방문자 통계
5만대감시와 인프라 — Grafana, Prometheus, exporter, 지표 문, DB

번호를 보면 무엇인지 짐작이 되고, 방화벽 규칙을 쓸 때도 대역으로 묶을 수 있어서 편하다.

바깥에 열리느냐는 이것과 별개다. 위 포트는 거의 다 127.0.0.1 에만 묶여 있다. 대역을 갈라 둔 것은 사람이 알아보기 위한 것이고, 실제로 문을 잠그는 것은 바인딩 주소와 Caddy 다. 두 가지를 섞어 생각하면 "번호가 높으니 안쪽이겠지" 같은 착각을 한다 — 나는 개발 서버가 모든 인터페이스에 열려 있는 것을 이 글을 쓰다가 발견했다. 번호는 4만대였고, 규칙상 "안쪽" 이었지만 실제로는 바깥에서 닿았다.

ss -ltn | grep -E ':(4|5)[0-9]{4}'   # 다 127.0.0.1 인지 본다

반영하고 확인한다.

sudo caddy validate --config /etc/caddy/Caddyfile   # 문법부터 본다
sudo systemctl reload caddy                          # 연결이 끊기지 않는다
curl -s localhost:59200/metrics | head -5
docker compose restart prometheus

reload 는 열린 연결을 끊지 않는다. restart 는 끊는다 — 급한 일이 아니면 쓰지 않는다.


4. Grafana 를 사이트 도메인 밑으로

여기가 이 글에서 가장 많이 헤맨 곳이다. 목표는 두 가지다.

  • 대시보드를 사이트 관리 화면 안에 iframe 으로 끼워 넣는다
  • 문지기는 사이트의 관리자 로그인이다. 사이트에 로그인하면 대시보드도 열린다

왜 서브도메인이 아니라 경로인가

처음에는 grafana.example.duckdns.org 로 따로 뒀다. 그러면 iframe 안에서 비밀번호 창이 뜨지 않는다. 브라우저는 다른 도메인의 basic auth 창을 iframe 안에 띄우지 않는다. 흰 화면만 남는다.

그래서 같은 도메인 밑 /grafana 로 옮겼다. Grafana 는 자기가 어느 주소에 있는지 알아야 링크와 정적 파일 경로를 만든다. 1절 compose 의 이 값들이 그 일을 한다.

없으면
GF_SERVER_ROOT_URL=https://…/grafana/링크가 도메인 루트로 나간다
GF_SERVER_SERVE_FROM_SUB_PATH=true정적 파일 주소에 /grafana 가 안 붙어 화면이 깨진다
GF_SECURITY_ALLOW_EMBEDDING=trueGrafana 가 X-Frame-Options: deny 를 보내 iframe 이 막힌다

ROOT_URL 은 하나만 가질 수 있다. 옛 서브도메인은 Caddy 에서 새 주소로 넘겨 준다.

grafana.example.duckdns.org {
	redir https://example.duckdns.org/grafana{uri} permanent
}

헤더를 경로별로 다르게

사이트 전체에는 프레임을 막는 헤더가 걸려 있다. 그게 같은 사이트 안에서 끼워 넣는 것까지 막는다. 그래서 매처를 만들어 경로별로 다르게 준다.

example.duckdns.org {
	encode zstd gzip

	@grafana path /grafana /grafana/*
	@site not path /grafana /grafana/*

	# 사이트 쪽 보안 헤더
	header @site {
		Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
		X-Frame-Options "DENY"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		Permissions-Policy "camera=(), microphone=(), geolocation=()"
		Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
		-Server
	}

	# Grafana 쪽은 프레임 관련만 다르다
	header @grafana {
		Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
		X-Frame-Options "SAMEORIGIN"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 문지기: 요청마다 앱에 먼저 묻는다
	handle @grafana {
		forward_auth 127.0.0.1:40000 {
			uri /admin/monitoring/authorize
		}
		reverse_proxy 127.0.0.1:53000
	}

	reverse_proxy 127.0.0.1:40000
}

세 가지를 짚어 둔다.

X-Frame-Options 는 DENY 대신 SAMEORIGIN. DENY 는 우리 화면에서 끼워 넣는 것도 막는다.

Grafana 경로에는 CSP 를 아예 붙이지 않았다. 우리 CSP 에는 frame-ancestors 'none' 이 있어서 그대로 두면 역시 막힌다. 게다가 Grafana 는 인라인 스타일과 blob 워커를 써서 우리 CSP 로는 어차피 깨진다.

handle 이지 handle_path 가 아니다. handle_path 는 경로 접두어를 떼고 넘긴다. Grafana 는 serve_from_sub_path/grafana 를 기대하고 있으므로 떼면 안 된다.

문지기 만들기 (앱 쪽)

forward_auth 는 요청이 올 때마다 지정한 곳에 먼저 물어본다. 쿠키를 그대로 넘겨 주기 때문에, 앱은 평소 로그인 검사를 그대로 하면 된다. 2xx 면 통과, 아니면 그 응답이 브라우저로 간다.

// app/admin/monitoring/authorize/route.ts
import { isAdmin } from "@/lib/auth";

export const dynamic = "force-dynamic";

export async function GET() {
  if (!(await isAdmin())) {
    return new Response("관리자만 볼 수 있습니다.\n", {
      status: 401,
      headers: { "content-type": "text/plain; charset=utf-8" },
    });
  }
  return new Response(null, { status: 200 });
}

WWW-Authenticate 헤더는 절대 붙이지 않는다. 붙이면 브라우저가 basic auth 비밀번호 창을 띄우려 하고, iframe 안에서는 그 창이 안 떠서 흰 화면이 된다. 401 만 주고 헤더는 생략한다.

Grafana 자체 로그인을 끈 이유도 적어 둔다. Grafana 는 5분에 5번 틀리면 계정을 잠근다. 맞는 비밀번호를 넣어도 한참 막힌다. 문지기가 Caddy·앱이면 그런 잠금이 없다. 대신 컨테이너를 127.0.0.1:53000 에만 묶어 둔다 — 서버 안에서 직접 붙으면 비밀번호 없이 관리자다. 서버에 들어올 수 있는 사람은 이미 무엇이든 할 수 있으니 그 선까지만 막는다.

확인은 이렇게 한다.

# 로그아웃 상태 = 401
curl -s -o /dev/null -w '%{http_code}\n' https://example.duckdns.org/grafana/

브라우저에서 사이트에 로그인한 뒤 같은 주소를 열면 대시보드가 뜬다.


5. 사이트 화면에 끼워 넣기

Next.js 예시다. 화면은 셋으로 나뉜다 — 페이지, iframe 컴포넌트, 테마 읽기.

// app/admin/monitoring/page.tsx
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import MonitoringDashboard from "@/components/admin/MonitoringDashboard";
import { isAdmin } from "@/lib/auth";

export const metadata: Metadata = {
  title: "감시",
  robots: { index: false, follow: false },
};

export const dynamic = "force-dynamic";

const DASHBOARD = "/grafana/d/myhome/myhome";

export default async function AdminMonitoringPage() {
  if (!(await isAdmin())) redirect("/admin");

  // 대시보드는 넓고 높아야 읽힌다. 본문 폭을 벗어나게 둔다.
  return (
    <div className="mx-auto -mt-8 w-full max-w-[110rem] px-5">
      <MonitoringDashboard title="감시" dashboard={DASHBOARD} />
    </div>
  );
}
// components/admin/MonitoringDashboard.tsx
"use client";

import { useTheme } from "@/components/useTheme";

export default function MonitoringDashboard({
  title,
  dashboard,
}: {
  title: string;
  dashboard: string;
}) {
  const theme = useTheme();
  // kiosk 는 Grafana 자체 메뉴와 위쪽 막대를 숨긴다
  const src = theme ? `${dashboard}?kiosk&theme=${theme}` : null;
  const frame =
    "h-[calc(100vh-11rem)] min-h-[30rem] w-full rounded-lg border border-border";

  return (
    <>
      <div className="mb-3 flex items-baseline justify-between gap-4">
        <h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
          {title}
        </h1>
        <a href={src ?? dashboard} target="_blank" rel="noreferrer">
          새 창에서 ↗
        </a>
      </div>
      {src ? (
        <iframe src={src} title="대시보드" className={frame} />
      ) : (
        // 테마를 알기 전에 그리면 다크로 한 번 뜬 뒤 다시 불러온다.
        // 자리만 잡아 두고 기다린다.
        <div className={frame} aria-hidden="true" />
      )}
    </>
  );
}
// components/useTheme.ts
"use client";

import { useSyncExternalStore } from "react";

export type Theme = "light" | "dark";

/** 직접 전환할 때 알리는 신호. OS 설정 변경과 함께 구독한다. */
export const THEME_EVENT = "themechange";

export function readTheme(): Theme {
  const attr = document.documentElement.getAttribute("data-theme");
  if (attr === "light" || attr === "dark") return attr;
  return window.matchMedia("(prefers-color-scheme: dark)").matches
    ? "dark"
    : "light";
}

export function subscribe(onChange: () => void) {
  const media = window.matchMedia("(prefers-color-scheme: dark)");
  media.addEventListener("change", onChange);
  window.addEventListener(THEME_EVENT, onChange);
  return () => {
    media.removeEventListener("change", onChange);
    window.removeEventListener(THEME_EVENT, onChange);
  };
}

/**
 * 서버에서는 테마를 알 수 없다. 첫 렌더에서는 null 이고 하이드레이션 뒤에
 * 실제 값이 온다 — 그렇게 해야 서버와 클라이언트가 어긋나지 않는다.
 */
export function useTheme() {
  return useSyncExternalStore<Theme | null>(subscribe, readTheme, () => null);
}

주소에 붙는 두 값이 핵심이다.

  • kiosk — Grafana 의 메뉴와 위쪽 막대를 숨긴다. 사이트 안에 넣으면 그게 다 군더더기다
  • theme=light|dark — Grafana 는 주소로만 테마를 받는다. system 이라는 값은 없어서, 사이트가 지금 어느 모드인지 읽어 직접 넘겨야 한다

테마를 알기 전에는 iframe 을 그리지 않는 것도 일부러다. 먼저 그리면 다크로 한 번 떴다가 라이트로 다시 불러오면서 하얗게 번쩍인다.

그림1-3.png


6. 한 번에 훑어보는 확인 목록

다 했으면 이 명령들이 이렇게 나와야 한다.

# 컨테이너 여섯 개가 다 떠 있나
docker compose ps

# 타깃이 다 UP 인가
curl -s localhost:59090/api/v1/targets?state=active \
  | python3 -c "import sys,json;d=json.load(sys.stdin)['data']['activeTargets'];print(sum(1 for t in d if t['health']=='up'),'/',len(d))"

# 지표가 실제로 들어오나
curl -sG localhost:59090/api/v1/query --data-urlencode 'query=up' | head -c 200
확인할 것명령기대값
Caddy 지표curl -s localhost:59200/metrics | head -1지표 한 줄
로그아웃 상태의 대시보드curl -o /dev/null -w '%{http_code}' https://도메인/grafana/401
바깥에서 감시 도구 포트다른 서버에서 curl 공인IP:59090연결 실패

myhome-app 타깃은 아직 실패한다. 다음 글에서 그 문을 연다.

마지막 줄이 중요하다. 다른 서버나 휴대폰 데이터로 자기 공인 IP 의 59090·59100·58080 을 찔러 보자. 하나라도 응답이 오면 127.0.0.1: 을 빼먹은 것이다.


7. 비밀값은 어디에 두나

이 글에서 자리표시자로 바꿔 둔 값들이다. 전부 파일 하나에 모으고 권한을 600 으로 둔다.

어디에만드는 법
Grafana 관리자 비밀번호~/monitoring/.envopenssl rand -base64 24
postgres-exporter 접속 문자열같은 .env읽기 전용 사용자를 따로 만든다 (1절)
Prometheus 화면 basic authCaddyfilecaddy hash-password (bcrypt 해시만 적는다)
$ caddy hash-password
Enter password:
$2a$14$....(해시)....
prometheus.example.duckdns.org {
	basic_auth {
		관리자-이름 $2a$14$....(해시)....
	}
	reverse_proxy 127.0.0.1:59090
}

Caddyfile 에는 해시만 적는다. 평문은 어디에도 남기지 않는다. 나는 Caddyfile 사본을 저장소에 두고 있는데(설정 이력을 남기려고), 그때 해시는 자리표시자로 바꿔서 넣는다.

.env 를 저장소에 올리지 않는 것은 기본이고, .env.example 만 올려 어떤 값이 필요한지 남긴다.

chmod 600 ~/monitoring/.env
git check-ignore -v .env    # 무시 목록에 있는지 확인

왜 이 구성이 안전한가

  • 감시 도구 여섯 개는 전부 127.0.0.1 에만 열려 있다. 인터넷에서 직접 닿지 못한다
  • 웹으로 들어오는 길은 Caddy 하나뿐이고, 그 문에 인증이 걸려 있다
  • Grafana 는 사이트 관리자 로그인으로, Prometheus 화면은 basic auth 로 막는다
  • 지표 포트(59200·59201)는 사설 대역에서만 답한다. 번호는 자기 서버가 받는 대역 안에서 남이 아는 번호가 아닌 것으로 고르는 편이 낫다

8. 그다음 돌보는 일

볼륨. Grafana 와 Prometheus 는 볼륨에 데이터를 쌓는다. 지표는 기본으로 15일 보관이다. 지우면 그래프의 과거가 사라지지만 설정은 파일에 있으니 다시 뜬다 — 프로비저닝으로 바꾼 이유가 이것이다.

업그레이드. 이미지를 올릴 때는 하나씩 한다.

docker compose pull grafana && docker compose up -d grafana

대시보드가 프로비저닝이라 Grafana 를 올려도 화면 구성은 그대로다. 다만 schemaVersion 이 오르면서 경고가 뜰 수 있어, 올린 뒤 대시보드를 한 번 열어 본다.

설정을 어디에 두나. compose·prometheus.yml·blackbox.yml·프로비저닝·생성기 는 다 저장소에 넣어 두고, 서버에는 복사해 쓴다. 다만 감시 스택은 사이트 배포에 묶지 않는다. 사이트 배포가 감시를 건드리다 실패하면 둘 다 죽는다. 그래서 옮기는 것은 손으로 한다.

cp -r ~/repo/infra/monitoring/{docker-compose.yml,prometheus,blackbox,grafana} ~/monitoring/
cd ~/monitoring
docker compose up -d                    # compose 를 고쳤을 때
docker compose restart prometheus       # prometheus.yml 만 고쳤을 때

Caddyfile 은 서버가 원본이다. 사이트 배포가 밀어 넣지 않는다. Caddy 는 사이트만이 아니라 다른 서비스와 인증서까지 다 맡고 있어서, 배포가 그 파일을 건드리다 실패하면 전부 같이 죽는다. 고칠 때는 서버에서 고치고 caddy validatereload 하고, 저장소 사본을 뒤따라 맞춘다.


다음 글

여기까지 하면 서버와 컨테이너, 그리고 바깥에서 본 가용성이 보인다. 정작 앱 안쪽은 아직 아무것도 안 보인다 — 힙이 얼마나 차 있는지, 이벤트 루프가 밀리는지, 글과 댓글이 몇 개인지는 앱만 안다.

그 지표를 만들어 붙이고, 40KB 짜리 대시보드 JSON 을 손으로 고치지 않는 방법은 다음 글에 적었다.

댓글

첫 댓글을 남겨보세요.

이름 20자, 댓글 1000자까지

← 글 목록으로