읽는 데 17분조회 3
홈페이지에 Grafana 대시보드 달기 (1) — Prometheus·Docker 로 세우고 사이트 안에 끼워 넣기
버전 1.0.0
작성 기준: 2026-09-11
서버 한 대에 서비스가 늘었다. 홈페이지, 개발 인스턴스, 방문자 통계, DB, 집에 있는 책 서재. 바깥으로 나가는 문을 Caddy 하나로 정리한 다음, 정작 그 서비스들이 어떻게 돌고 있는지는 내가 안 보고 있다는 것을 알았다.
그래서 Prometheus 로 지표를 모으고 Grafana 로 그렸다. 그리고 그 대시보드를 사이트 관리 화면 안에 끼워 넣었다. 사이트에 로그인하면 대시보드도 열린다 — 비밀번호를 따로 외울 것이 없다.
이 글은 그대로 따라 할 수 있게 쓴 것이다. 설정 파일은 줄이지 않고 전부 넣었다. 비밀번호와 접속 문자열만 자리표시자로 바꿨고, 그 값을 어떻게 만들고 어디에 두는지는 7절에 따로 적었다.
앱 안쪽 지표(힙, 이벤트 루프, 글·댓글 수)를 붙이는 이야기와 대시보드를 코드로 만드는 이야기는 다음 글에 있다.

인터넷 ──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 로 찍힌다. 어느 주소 결과인지 알 수 없다. 그래서
세 줄로 라벨을 바꿔 붙인다.
- 타깃 주소를
target파라미터로 옮긴다 - 그 값을
instance라벨로 복사한다 - 실제로 요청 보낼 곳은 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']]"
caddy 와 myhome-app 은 아직 실패한다. 3절과 6절에서 그 문을 연다.

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=true | Grafana 가 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 을 그리지 않는 것도 일부러다. 먼저 그리면 다크로 한 번 떴다가 라이트로 다시 불러오면서 하얗게 번쩍인다.

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/.env | openssl rand -base64 24 |
| postgres-exporter 접속 문자열 | 같은 .env | 읽기 전용 사용자를 따로 만든다 (1절) |
| Prometheus 화면 basic auth | Caddyfile | caddy 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 validate → reload 하고, 저장소 사본을 뒤따라 맞춘다.
다음 글
여기까지 하면 서버와 컨테이너, 그리고 바깥에서 본 가용성이 보인다. 정작 앱 안쪽은 아직 아무것도 안 보인다 — 힙이 얼마나 차 있는지, 이벤트 루프가 밀리는지, 글과 댓글이 몇 개인지는 앱만 안다.
그 지표를 만들어 붙이고, 40KB 짜리 대시보드 JSON 을 손으로 고치지 않는 방법은 다음 글에 적었다.
댓글
첫 댓글을 남겨보세요.