建議標題:打造 Caddy 流量與資安可視化戰情室:Prometheus + Loki + Grafana 實戰
+ test
- test上一篇把 Caddy 跟 CrowdSec 接通、也修完了金鑰洩漏的資安坑,這篇接著處理「看得到」的問題——CrowdSec 平常擋掉了多少攻擊、一般流量長什麼樣子、被掃描的都是哪些路徑,這些光靠 cscli 指令一條條查太慢,需要一套可視化。這篇記錄怎麼把 Prometheus、Loki、Grafana 接進既有的監控架構,以及過程中遇到的幾個容易讓人卡關的坑。
前提架構
監控用的是另一台 LXC(Alpine + Docker Compose),已經有一份 docker-compose.yml,跑著 Prometheus、Grafana、PVE Exporter 三個 container,用來監控 Proxmox 叢集本身的資源狀況。這篇是在這份既有架構上加東西,把 Caddy 那台機器(192.168.1.2)的資料也接進來,不是從零開始重新架一套。
整體資料流長怎樣
在動手之前,先畫出完整的資料流向,後面每一步都是在把這張圖的其中一段接起來:
兩條資料路徑並行:流量細節(誰、什麼時候、打了哪個路徑)走 Loki,統計數字(每秒幾個請求、有幾個封鎖決策)走 Prometheus。兩者互補——Prometheus 適合看趨勢、做告警閾值,Loki 適合事後追蹤「這個 IP 到底做了什麼」。
第一步:Caddy 的 metrics API,跟 admin API 要分開曝露
Caddy 內建 Prometheus 相容的 metrics 端點,最直覺的用法是直接 curl localhost:2019/metrics——但這是 admin API,Caddy 刻意只綁定 127.0.0.1,從監控主機連不到。這個限制不是 bug,是刻意的安全設計:admin API 不只能讀 metrics,還能透過 REST 介面直接改整個 Caddy 執行中的設定(等同完整控制權),只要能連到這個 port,理論上就能重寫你的反向代理規則。絕對不應該把它對區網或公網開放。
正確做法是用 metrics handler 指令,另外開一個獨立的、唯讀的 metrics 站台,跟 admin API 完全分開:
{
email you@example.com
metrics {
per_host
}
}per_host 這個選項很重要——沒加的話所有站台的請求數會被合併成一個總數,看不出是哪個網域被打了多少流量,Grafana 上也做不出「各站台流量排行」這種面板。
:9100 {
bind 192.168.1.2
metrics /metrics
}這個 site block 沒有網域名稱,只綁 LAN 介面(bind 明確指定),不會嘗試申請 TLS 憑證,也不會意外暴露到公網——即使你的 80/443 port 是走 Cloudflare 代理對外,這個獨立的 9100 port 也完全不在那條路徑上。
套用後驗證:
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy
curl http://192.168.1.2:9100/metrics | grep caddy_http_requests_total會看到類似:
caddy_http_requests_total{handler="reverse_proxy",host="home.example.com",...} 42不要把多個 handler 的數字加總
同一個請求會依序經過多個 handler(log、metrics、crowdsec、reverse_proxy),每個 handler 都會各自累計一次計數,如果把所有 handler 的數字加總會嚴重膨脹失真。要看「實際請求數」,只看單一個具體 handler(通常是 reverse_proxy,因為那代表請求真的被轉發出去,沒被前面的 handler 攔截)的數字才準確。
第二步:CrowdSec 的 Prometheus metrics 預設也只聽本機
CrowdSec 自己也有一份 metrics(引擎健康度、觸發了哪些場景、目前生效的決策數量、bouncer 連線狀態),預設監聽在 127.0.0.1:6060:
# /etc/crowdsec/config.yaml
prometheus:
enabled: true
level: full # full 會輸出更細緻的每個 scenario/parser 統計,homelab 規模開著沒差
listen_addr: 127.0.0.1 # 改成 0.0.0.0
listen_port: 6060改完 listen_addr 記得一定要重啟服務,只改設定檔不重啟是這次除錯過程中最容易漏掉的一步:
systemctl restart crowdsec
# 確認真的監聽在對外介面,而不是還停在 127.0.0.1
ss -tlnp | grep 6060
# 應該看到 0.0.0.0:6060,不是 127.0.0.1:6060LAPI 的 8080 port 不要跟著開
CrowdSec 設定裡另外有一段 trusted_ips,是 LAPI(能新增/刪除封鎖決策的管理介面)的白名單,這個維持 127.0.0.1 就好,不要因為要開放 metrics 就順手把它也對外開放。Metrics 是唯讀的統計數字,LAPI 是能直接操控你資安防護規則的管理端點,兩者權限層級完全不同,混在一起開放等於把 CrowdSec 本身的控制權暴露到區網。
第三步:把 Loki 加進既有的 docker-compose,不要另外裝一套
監控 LXC 已經是 Docker Compose 架構,Loki 也照這個模式加,不要照著某些教學文章直接在系統層 apt install loki,那是給裸機環境用的部署方式,跟現有架構不一致,之後升級、備份、管理都要用兩套不同的心智模型,得不償失。
mkdir -p /opt/monitoring/lokiLoki 有兩種部署模式:single binary(所有元件擠在一個 process 裡,設定簡單,適合小規模)跟 microservices/simple scalable(拆成多個獨立元件,能水平擴展,但複雜度高很多)。Homelab 這種規模用 single binary 加檔案系統儲存(不用另外架 S3/GCS 這類物件儲存)就綽綽有餘:
# /opt/monitoring/loki/loki-config.yaml
auth_enabled: false
server:
http_listen_port: 3100
common:
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
在既有的 docker-compose.yml 加一個服務,跟 prometheus、grafana 平級:
loki:
image: grafana/loki:latest
container_name: loki
restart: unless-stopped
ports:
- "3100:3100"
volumes:
- ./loki/loki-config.yaml:/etc/loki/loki-config.yaml:ro
- loki_data:/loki
command:
- '-config.file=/etc/loki/loki-config.yaml'
networks:
- monitoringvolumes: 區塊記得也加一行 loki_data:,然後:
cd /opt/monitoring
docker compose up -d loki # 只會啟動新加的這個服務,不影響已經在跑的其他 container
docker compose ps順手把 Grafana 的 datasource provisioning 也補上,讓 Grafana 開機就自動接上 Loki,不用手動在 UI 設定:
# /opt/monitoring/grafana/provisioning/datasources/loki.yml
apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
url: http://loki:3100 # 用 docker network 內的 service 名稱,不是 IP
editable: truedocker compose restart grafana3100 port 只該給監控 LXC 以外的機器連
Loki 的 3100 port 沒有內建認證機制(auth_enabled: false 的情況下),任何能連到這個 port 的機器都能推送資料進去或查詢資料。因為要讓 Caddy CT 的 Promtail 能推資料進來,這個 port 勢必要對外(至少對區網)開放,但務必確認防火牆只放行你信任的機器來源,不要對公網開放。
第四步:Caddy CT 上裝 Promtail,讀 journald 推去 Loki
重要:Promtail 已進入 EOL
根據 Grafana 官方文件,Promtail 已於 2026 年 3 月 2 日進入 End of Life,官方商業支援已經終止,未來所有新功能只會在 Grafana Alloy 上開發。這篇仍然示範 Promtail,是因為現有的教學、範例、社群經驗都還是以它為主,短期內能正常運作;但如果是全新架設的環境,應該優先評估直接上 Alloy——Grafana 官方也提供了 Promtail 設定檔轉換成 Alloy 設定的遷移工具,之後有機會再另外寫一篇遷移紀錄。
因為 Caddy 的 log 走 journald(上一篇文章裡的架構決定,不是寫進檔案),Promtail 直接用 journal 這種 scrape config 讀,不用額外輸出檔案、也不用改動任何 Caddyfile 設定:
mkdir -p /etc/apt/keyrings/
wget -q -O - https://apt.grafana.com/gpg.key | gpg --dearmor > /etc/apt/keyrings/grafana.gpg
echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" | tee /etc/apt/sources.list.d/grafana.list
apt update
apt install -y promtail
# 讓 promtail 這個系統帳號有權限讀 systemd journal
usermod -aG systemd-journal promtail設定檔:
# /etc/promtail/config.yml
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml # 記錄讀到哪裡了,重啟後從這個位置繼續讀,不會重複推送
clients:
- url: http://<監控LXC_IP>:3100/loki/api/v1/push
scrape_configs:
- job_name:
幾個容易忽略的細節:
action: keep這一步很重要:journal scrape 預設會抓到整台機器所有服務的 log(sshd、crowdsec 本身、systemd 自己的訊息…),沒有這條篩選規則,Loki 裡會混進大量跟 Caddy 無關的雜訊,浪費儲存空間,之後查詢{job="caddy"}以外的東西也會變得更難找。positions.filename:這個檔案記錄 Promtail 上次讀到 journal 的哪個位置(cursor),重啟 Promtail 後會從這裡繼續讀,不會從頭重新灌一次歷史資料,也不會漏掉重啟期間新產生的 log。__journal_前綴的欄位:journal scrape 讀到的所有 systemd 欄位都會帶__journal_前綴(例如__journal__systemd_unit),這類雙底線開頭的都是內部標籤,預設不會被送進 Loki,要透過relabel_configs把它們「改名」成不帶底線前綴的正式 label(像範例裡的unit),才會真的存進 Loki 可以查詢。
持久化 journal 的前提
如果 /var/log/journal 目錄不存在,代表這台機器的 journald 是易失性儲存(只存在 /run/log/journal,重開機就消失),Promtail 會安靜地讀不到任何東西、不會報錯,這種「什麼都沒發生」的沉默失敗最難排查。確認方式:
ls -ld /var/log/journal不存在就啟用持久化:
mkdir -p /var/log/journal
systemd-tmpfiles --create --prefix /var/log/journal
systemctl restart systemd-journald
systemctl restart promtail重啟後驗證資料真的有進 Loki(跳過 Grafana UI,直接打 Loki 的 API 比較快確認,不用等頁面渲染):
curl -s "http://<監控LXC_IP>:3100/loki/api/v1/label/job/values"
# {"status":"success","data":["caddy"]}看到 "caddy" 出現在回傳的陣列裡,代表 Promtail → Loki 這段鏈路已經打通。
第五步:匯入社群 Dashboard,卻發現 log 面板是空的
Grafana 社群有現成的 Caddy Monitoring dashboard(ID 20802),涵蓋請求數統計、回應時間熱力圖、狀態碼分佈圓餅圖,還有原始 log 面板,直接匯入能省下大量手刻面板的時間。匯入後 Prometheus 那些面板(heatmap、pie chart)都運作正常,唯獨 Loki 相關的面板(Raw logs、Client IP 表格)完全是空的,即使確認 Loki 裡已經有資料(前一步驗證過)也一樣。
追進 dashboard 的 JSON 原始碼才發現,所有 Loki 查詢用的 stream selector 是:
{filename="$logfile"}filename 這個 label,只有用「檔案路徑」方式 scrape(設定裡有 __path__: /var/log/xxx.log 這種寫法)時,Promtail 才會自動幫每個檔案產生一個對應的 filename label。我們第四步用的是 journal 來源讀 journald,根本不會有這個 label——這份社群 dashboard 的作者是用另一種 Promtail 設定方式(讀實體檔案)做的,兩者的 Loki label schema 從根本上就對不上,這不是哪邊設定錯了,是兩套本來設計給不同架構用的東西湊在一起。
發現這種 schema 不對版的問題後,有兩種修法可以選:
在 Promtail 的 relabel_configs 加一條,補一個假的 filename label:
- target_label: 'filename'
replacement: 'caddy-journal'缺點:因為改的是 Promtail 送出去的 label 組合,等於是幫這串 log 開了一條全新的 stream,Loki 會把它當成跟原本 {job="caddy"} 不同的資料流。只有「改設定之後、之後新進來」的 log 才會帶有這個 filename label,改設定之前已經收集到的歷史 log 完全查不到,等於平白損失了存量資料,而且系統裡多了一個名不符實的 label(明明沒有檔案,卻叫 filename),之後接手維護的人看了會困惑。
這個案例背後的通用心法
遇到「兩套本來設計給不同架構用的工具,資料對不起來」的狀況時,先確認雙方的資料 schema(這裡是 Loki 的 label),不要急著改資料源去遷就介面——尤其當資料源的改動會犧牲歷史資料時,改介面/查詢邏輯通常才是正確方向。這個判斷原則不只適用於 Loki,任何「串接兩個各自獨立演進的系統」的場景都適用。
下一步:把「哪個網域」也變成可篩選的維度
目前的 Loki 查詢能在 log 內文裡看到 request.host(也就是被訪問的網域名稱),但因為它只是內文欄位、不是 Loki label,沒辦法在 Grafana 上做下拉選單篩選「只看某個網域的流量」。要做到這件事,需要在 Promtail 的 pipeline 裡多加一個 labels stage,把 request.host 從內文「升級」成真正的 label:
pipeline_stages:
- json:
expressions:
status: status
domain: request.host
- labels:
domain:配合 dashboard 加一個 domain 篩選變數(結構跟前面被隱藏的 logfile 變數類似,只是改查詢 domain 這個 label)、把 log 顯示格式重新排版讓網域顯示在最前面,這部分因為涉及多個檔案的協同修改,交給 Claude Code 這類編碼助理處理會比手動改更有效率。
Label cardinality 提醒
13 個網域拿來當 label 完全沒問題,Loki 官方建議單一 label 的相異值數量(cardinality)不要超過幾百個,才會開始出現索引效能疑慮,13 遠遠不到需要擔心的門檻。如果之後打算把「來源 IP」也做成 label 就要三思——IP 的相異值可能上看幾千甚至幾萬,會讓 Loki 的索引急速膨脹,這種高基數的欄位適合留在內文用 | json 查詢時篩選,而不是變成 label。
常用查詢速查
LogQL(查 Loki 的流量細節)
{job="caddy"} # 所有 Caddy log
{job="caddy"} | json | status="403" # 被擋下的請求
{job="caddy"} | json | status>=400 # 所有異常狀態碼
{job="caddy"} | json | client_ip="1.2.3.4" # 追蹤特定來源 IP 的所有行為
# 每小時各 host 的請求數,適合做成長條圖面板
sum by (host) (count_over_time({job="caddy"} | json [1h]))
# 每分鐘的錯誤率(4xx/5xx 佔比),適合做告警閾值
sum(rate({job="caddy"} | json | status>=400 [5m]))PromQL(查 Caddy / CrowdSec 的統計數字)
# Caddy 各站台的請求數(注意只看單一 handler,避免加總失真)
sum by (host) (caddy_http_requests_total{handler="reverse_proxy"})
# CrowdSec 目前生效中的封鎖決策數量
crowdsec_lapi_decisions
# CrowdSec bouncer 是否正常連線(1 = 正常)
up{job="crowdsec"}小結
這篇踩的坑跟上一篇性質不太一樣——上一篇多半是「功能沒生效」,這篇的核心坑是兩套各自獨立演進的工具(不同 Promtail 設定方式產生的 label schema)湊在一起時對不上。串接監控系統時,這種「表面上都叫同一個名字(都是 Promtail、都是 Loki),實際上內部資料模型不一樣」的狀況會反覆出現,養成「先看 schema 對不對,再決定要改哪一邊」的習慣,會比每次都想著「哪邊比較好改」更能少走冤枉路。
參考連結