前言
這是自架 VM 系列的第 3 篇。第 1 篇用 Cloudflare Access 把後台擋在登入後面,第 2 篇用 Jenkins 做 CI/CD,服務是上線了,但我還有一個很心虛的問題:它現在健康嗎?磁碟快滿了我不知道、某個容器半夜被 OOM kill 重啟了我不知道、MySQL 備份已經兩天沒成功我更不知道,每次都是使用者或我自己點到壞掉的頁面才發現。
【系列】自架 VM 維運實戰 第 3 篇/共 5 篇
上一篇:【CI/CD】Jenkins 打造安全的 Laravel 部署:JCasC 與受限 SSH
下一篇:【監控】Loki + Alloy 集中 Laravel 與 Nginx Log:遮罩與踩坑
這篇記錄我怎麼用 Prometheus + Grafana,在一台 2 核 / 12 GB 的 ARM VM 上,替跑在 Docker 裡的三個服務(app-a、app-b、app-c)架一套「從零到能用」的監控:七種 exporter 抓指標、Grafana 全部用 provisioning 進版控、自己設計首頁總覽、再配上 22 條 Slack 告警。讀完你應該能照著做出一套一模一樣的東西,也能避開我踩過的五個坑。
環境先交代:Prometheus v3.13.3、Grafana 12.4.11、cAdvisor v0.60.6,全部用 Docker Compose 跑,每個容器都設 mem_limit,有發佈到主機的 port 一律只綁 127.0.0.1。Grafana 前面用 Cloudflare Access 加 JWT 自動登入,過了 Access 就直接進去,細節見系列第 1 篇,這裡不再重複。
架構與元件
Prometheus 是 pull 模型:它每 30 秒去各個 exporter 的 /metrics 拉一次資料,存進本機 TSDB;Grafana 用 PromQL 畫圖,也用同一套 PromQL 評估告警規則,再把結果送 Slack。整條管線不需要對外開任何 port。唯一要注意的是 node-exporter 與 nginx-exporter 用 host network(要讀主機資訊),會聽在所有網卡上,所以要靠主機防火牆或雲端安全規則擋掉外部對 9100、9113 的存取。
| 元件 | 用途 | 抓什麼 | mem_limit |
|---|---|---|---|
| Prometheus | 拉取與儲存指標 | 自己 + 下列所有 exporter | 1 GB |
| Grafana | 儀表板、告警、Slack 通知 | — | 768 MB |
| node-exporter | 主機層級指標 | CPU、記憶體、磁碟、I/O、swap、OOM kill、textfile 備份指標 | 128 MB |
| cAdvisor | 容器層級指標 | 每個容器的 CPU、記憶體、重啟時間、記憶體上限 | 384 MB |
| mysqld-exporter | MySQL 指標 | 連線數、慢查詢累計、QPS、InnoDB | 128 MB |
| redis-exporter | Redis 指標(一個 exporter 抓三個實例) | redis_up、記憶體、命中率、連線數 | 128 MB |
| blackbox-exporter | 外部探測 | 網站 / API 是否可用、回應時間、SSL 到期日 | 64 MB |
| nginx-prometheus-exporter | 主機 nginx 狀態 | stub_status:請求數、連線數、nginx_up | 64 MB |
| Jenkins Prometheus plugin | CI/CD 指標 | 建置結果、agent 是否離線 | (Jenkins 本身) |
全部加起來 mem_limit 上限約 2.6 GB,佔 12 GB 的兩成出頭,實際用量遠低於上限。為什麼每個容器都要設 mem_limit?因為監控系統最不能做的事就是「自己把主機拖垮」:萬一 Prometheus 因為 cardinality 暴增吃光記憶體,被 OOM kill 的應該是它自己,而不是 app-b 的 MySQL。這個設計後面會再用一條告警把它閉環。
為什麼選 Prometheus,不用雲端現成的監控?
雲端供應商的 Monitoring 只看得到 VM 層級的 CPU 與網路,看不到容器、看不到 MySQL 慢查詢、更看不到「備份檔小了一半」。自架 Prometheus 的成本是多養一套服務,但換到的是可以用 PromQL 問任何問題。順帶一提,主機本身掛掉的時候,同一台機器上的 Prometheus 與 Grafana 也會一起掛,沒有人會幫你發告警,所以我另外在雲端供應商那邊設了一條外部的主機存活告警,兩套互為備援,這點後面小結再提。
Docker Compose 與 Prometheus 設定
先看 Compose 裡最核心的兩個服務。幾個細節值得說明:
ports全部寫成127.0.0.1:9090:9090,只有本機看得到,要看就走 Cloudflare Access 或 SSH tunnel。- retention 同時設時間(15 天)與大小(5 GB),誰先到誰先砍,避免磁碟被 TSDB 默默吃掉。
- Prometheus 要同時加入
monitoring與jenkins兩個 network,才連得到jenkins:8080;而 node-exporter 與 nginx exporter 用 host network,所以 Prometheus 用host.docker.internal去接它們。 - Grafana 的 provisioning 目錄與 dashboards 目錄都是
:ro掛載,設定的唯一來源就是 Git 裡的檔案。
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
prometheus:
image: prom/prometheus:v3.13.3
container_name: mon-prometheus
logging: *default-logging
restart: unless-stopped
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
- --storage.tsdb.retention.size=5GB
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro,Z
- prometheus-data:/prometheus
ports:
- "127.0.0.1:9090:9090"
extra_hosts:
- "host.docker.internal:host-gateway"
networks: [monitoring, jenkins]
mem_limit: 1g
grafana:
image: grafana/grafana:12.4.11
container_name: mon-grafana
logging: *default-logging
restart: unless-stopped
environment:
GF_SERVER_ROOT_URL: https://grafana.example.com
GF_USERS_ALLOW_SIGN_UP: "false"
GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: /var/lib/grafana/dashboards/總覽/overview.json
GF_AUTH_JWT_ENABLED: "true" # 信任 Cloudflare Access 簽發的 JWT
GF_AUTH_JWT_HEADER_NAME: Cf-Access-Jwt-Assertion
SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL} # 值放 .env、不進版控;contact point 再用 ${SLACK_WEBHOOK_URL} 引用
TZ: Asia/Taipei
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro,Z
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro,Z
ports:
- "127.0.0.1:3000:3000"
networks: [monitoring]
mem_limit: 768m
上面的 Grafana 環境變數我只留了關鍵幾個,完整的 JWT 設定(JWK 網址、audience 驗證、角色對應)屬於第 1 篇的範圍。這裡要注意的是 GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH,它讓「總覽」變成登入後的首頁,後面會用到。
接著是 prometheus.yml。全域設 scrape_interval: 30s、evaluation_interval: 30s,對一台機器而言綽綽有餘;再短只是讓 TSDB 變大,看不出更多東西。下面是代表性的幾個 job:
global:
scrape_interval: 30s
evaluation_interval: 30s
scrape_configs:
- job_name: node
static_configs:
- targets: ["host.docker.internal:9100"]
labels: { instance: my-vm }
- job_name: cadvisor
static_configs:
- targets: ["cadvisor:8080"]
- job_name: mysql
static_configs:
- targets: ["mysqld-exporter:9104"]
labels: { instance: shared-mysql }
# 一個 redis-exporter 抓三個 Redis:用 multi-target 模式
- job_name: redis
metrics_path: /scrape
static_configs:
- targets:
- redis://app-a-redis:6379
- redis://app-b-redis:6379
- redis://app-c-redis:6379
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
regex: redis://([^:]+):.*
target_label: instance
- target_label: __address__
replacement: redis-exporter:9121
- job_name: nginx
static_configs:
- targets: ["host.docker.internal:9113"]
labels: { instance: my-vm-nginx }
- job_name: jenkins
metrics_path: /prometheus/
static_configs:
- targets: ["jenkins:8080"]
Redis 那段是整份設定裡最繞的,值得拆開看。三個 Redis 分屬三個服務、各自在獨立的 Docker network 裡,如果每個都配一個 exporter,光是 exporter 就吃掉 3 × 128 MB。改用 redis-exporter 的 multi-target 模式:Prometheus 把 __address__(也就是 Redis 位址)搬到 __param_target 當查詢參數,再把真正要連的 __address__ 改成 exporter 自己,同時用 regex 把 instance 標籤整理成 app-a-redis 這種好讀的名字。這招 blackbox 也用得到,下一節會看到。代價是 redis-exporter 必須加入三個服務的 network,我在 Compose 的 networks 裡一一列出,這是唯一一個跨多個服務 network 的容器。
第一次啟動後先驗證
設定寫完不要急著做儀表板,先確認資料真的進來了。我習慣用這幾步,由外到內:
- 用
promtool check config檢查prometheus.yml語法,比啟動後才發現容器一直重啟好很多。 - 打開 Prometheus 的 Status → Targets,所有 job 都該是 UP;有紅色的先看錯誤訊息,九成是 network 沒加對。
- 在 Graph 頁查
up,應該每個 target 一筆、值都是 1;再查count(container_last_seen{name!=""}),數字要等於你實際跑的容器數。
建議的建置順序是:Prometheus → node-exporter(先有主機指標)→ cAdvisor → 其餘 exporter → Blackbox → Grafana datasource → 儀表板 → 最後才是告警。因為告警的資料來源都是指標,指標沒穩定之前先寫告警,只會得到一堆 no data。
各 exporter 重點
node-exporter:主機指標與 textfile collector
node-exporter 要看的是「主機」而不是「容器」,所以設定跟一般容器相反:network_mode: host、pid: host,再把根目錄以唯讀掛進去,並用 --path.rootfs=/host 告訴它去哪裡讀。我另外打開三個預設不開的 collector:
--collector.systemd:看得到 systemd unit 是否 failed,備份服務失敗時多一個佐證。--collector.processes與--collector.tcpstat:排查連線與行程數用。--collector.textfile.directory:這個最重要,下面專門講。
node-exporter:
image: prom/node-exporter:v1.12.1
container_name: mon-node-exporter
restart: unless-stopped
network_mode: host
pid: host
command:
- --path.rootfs=/host
- --web.listen-address=:9100
- --collector.textfile.directory=/host/var/lib/node_exporter/textfile
- --collector.processes
- --collector.systemd
- --collector.tcpstat
volumes:
- /:/host:ro,rslave
- /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket:ro
mem_limit: 128m
textfile collector 是整套監控裡我最愛的一個功能:任何腳本只要把 Prometheus 文字格式寫進指定目錄下的 *.prom 檔,node-exporter 就會在每次被拉取時一併吐出去。cron 與備份腳本這類「跑完就結束」的程式,沒辦法自己開一個 HTTP endpoint,這就是它們接進 Prometheus 的最短路徑。我的 MySQL 備份腳本最後幾行長這樣(節錄):
TEXTFILE_DIR=/var/lib/node_exporter/textfile
METRICS=$TEXTFILE_DIR/mysql_backup.prom
# 先寫到暫存檔,最後 mv 原子替換,避免 Prometheus 讀到寫一半的檔案
tmp=$(mktemp "$TEXTFILE_DIR/.mysql_backup.XXXX")
{
echo "# TYPE mysql_backup_success gauge"
echo "mysql_backup_success $ok"
echo "# TYPE mysql_backup_duration_seconds gauge"
echo "mysql_backup_duration_seconds $((end - start))"
echo "# TYPE mysql_backup_size_bytes gauge"
for db in "${!sizes[@]}"; do
echo "mysql_backup_size_bytes{db=\"$db\"} ${sizes[$db]}"
done
} > "$tmp"
# 「最後成功時間」只在成功時更新,失敗就沿用上一次的值
prev_ok=$(grep -h '^mysql_backup_last_success_timestamp_seconds' "$METRICS" 2>/dev/null | awk '{print $2}' || true)
echo "# TYPE mysql_backup_last_success_timestamp_seconds gauge" >> "$tmp"
echo "mysql_backup_last_success_timestamp_seconds $([ "$ok" = 1 ] && echo "$end" || echo "${prev_ok:-0}")" >> "$tmp"
chmod 644 "$tmp"; mv "$tmp" "$METRICS"
這段有三個設計重點。第一,mktemp 加 mv 是原子替換,Prometheus 不會讀到半成品。第二,「最後成功時間」是 timestamp 型指標而不是「成功與否」的 0/1:告警用 time() - 那個時間戳 算出「幾小時沒成功」,就算腳本連續失敗十天、或 cron 根本沒觸發,數字都會誠實地越長越大。第三,上傳到雲端儲存的成功時間用同樣的做法,另外一個指標、另外一條告警。
cAdvisor:容器指標,版本要夠新
cAdvisor 負責每個容器的 CPU、記憶體與 container_start_time_seconds(偵測重啟靠它)。幾個參數我會調:--docker_only=true 只看 Docker 容器;--housekeeping_interval=30s 對齊 scrape 間隔,降低 CPU;--store_container_labels=false 不把一大堆 Docker label 都變成指標標籤,這是控制 cardinality 最有效的一個旗標。注意它需要 privileged 與一批唯讀掛載,是整套監控裡權限最大的容器,所以要鎖版本、不要用 latest。
映像檔來源已經改成 ghcr.io/google/cadvisor,我用的是 v0.60.6。這個版本號不是隨便選的,後面的踩坑會講。
mysqld-exporter 與 redis-exporter
mysqld-exporter 連的是同一個共用的 MySQL 容器,我替它建了一個專用帳號 exporter(只給監控需要的最小權限,不要用 root),密碼放環境變數檔、不進版控,並加上 --collect.info_schema.processlist 看執行中的連線。它提供的 mysql_up、mysql_global_status_slow_queries 就是兩條告警與首頁面板的資料來源。redis-exporter 則如前面所述,用 multi-target 抓三個實例,redis_up 用 min by (instance) 聚合,任何一個失聯就觸發。
nginx-prometheus-exporter
主機 nginx 要先開 stub_status,我把它放在一個只監聽本機的獨立 server block,用 location = /stub_status 精準比對,不對外服務:
server {
listen 127.0.0.1:8008;
location = /stub_status {
stub_status;
access_log off; # exporter 每 30 秒來一次,不記 log
}
}
exporter 這邊就是 --nginx.scrape-uri=http://127.0.0.1:8008/stub_status,因為 nginx 在主機上、不在容器裡,所以 exporter 也用 host network。它給兩個東西:nginx_up(nginx 還活著嗎)與 nginx_http_requests_total(累計請求數,首頁的請求量面板就靠它)。
Jenkins Prometheus plugin
Jenkins 這邊不用另外裝 exporter,裝 Prometheus plugin 後它自己就在 /prometheus/ 吐指標,抓的 job 如前面所示。我主要用到兩個:default_jenkins_builds_last_build_result_ordinal(每個 job 最近一次建置結果,0 成功、1 不穩定、2 失敗)與 jenkins_node_offline_value(agent 是否離線)。Jenkins 本身的部署見系列第 2 篇。
blackbox-exporter:從外面看服務
前面所有 exporter 都是「從裡面看」,但服務可能在內部一切正常、使用者卻打不開(DNS、憑證、反向代理出問題)。Blackbox 的角色是扮演使用者,定期去打真正的網址。它的設定非常短:
modules:
http_2xx:
prober: http
timeout: 10s
http:
preferred_ip_protocol: ip4
follow_redirects: true
valid_status_codes: [200]
# API 健康檢查:200 且回應內容有 "status": true,不跟隨轉址
http_api_ok:
prober: http
timeout: 10s
http:
preferred_ip_protocol: ip4
follow_redirects: false
valid_status_codes: [200]
fail_if_body_not_matches_regexp:
- '"status"\s*:\s*true'
一般網站用 http_2xx:回 200 就算活著。但 API 不一樣:反向代理或 CDN 完全可能回一頁狀態碼 200 的錯誤頁,HTTP 狀態碼看起來沒問題,內容卻根本不是 API 的回應。所以我另外寫了 http_api_ok 模組:要求 200、body 必須符合 "status"\s*:\s*true、而且不跟隨轉址(被轉去登入頁也算失敗)。
API 探測要用另一個 scrape job 去指定 module: [http_api_ok],但我希望它和一般網站共用同一組告警,所以在 relabel 最後加一行 target_label: job、replacement: blackbox,把 job 標籤改回 blackbox:
- job_name: blackbox-api
metrics_path: /probe
params:
module: [http_api_ok]
static_configs:
- targets:
- https://app-c.example.com/api/health
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115
- target_label: job
replacement: blackbox # 沿用 job=blackbox,告警自動涵蓋
這樣「網站無法存取」與「回應過慢」兩條告警寫的是 probe_success{job="blackbox"},之後新增任何探測目標都不用改告警。
設定寫好之後我一定會做正反向驗證,確認內容檢查真的有生效,而不是永遠都過:
# 正向:真正的 API 端點 + http_api_ok,應該成功
# blackbox-exporter 沒有對主機發佈 port,所以在 monitoring 網路裡開一個臨時容器來打
docker run --rm --network monitoring curlimages/curl:8.10.1 -s \
'http://blackbox-exporter:9115/probe?module=http_api_ok&target=https://app-c.example.com/api/health' \
| grep -E '^probe_(success|failed_due_to_regex)'
# probe_success 1 / probe_failed_due_to_regex 0
# 反向:拿網站首頁 HTML 套 http_api_ok,應該失敗
docker run --rm --network monitoring curlimages/curl:8.10.1 -s \
'http://blackbox-exporter:9115/probe?module=http_api_ok&target=https://app-c.example.com/' \
| grep -E '^probe_(success|failed_due_to_regex)'
# probe_success 0 / probe_failed_due_to_regex 1
反向測試拿首頁 HTML 去套 API 的規則,結果 probe_failed_due_to_regex 是 1、probe_success 是 0,證明內容檢查有在工作。Blackbox 還送了一個副產品:probe_ssl_earliest_cert_expiry,也就是憑證到期時間,「SSL 14 天內到期」告警就是從這來的,不需要另外裝任何東西。
Grafana provisioning:設定即程式碼
Grafana 很容易變成「點出來的雪花」:datasource 在 UI 裡加、儀表板在 UI 裡拉、告警在 UI 裡設,哪天容器壞掉、volume 掉了,全部要重來。我的原則是所有東西都放進 provisioning/ 與 dashboards/ 目錄、進 Git,UI 只用來看與試驗。目錄結構如下:
grafana/
├── provisioning/
│ ├── datasources/
│ │ └── prometheus.yml
│ ├── dashboards/
│ │ └── default.yml
│ └── alerting/
│ ├── contact-points.yaml # contact point 與通知路由 policy(檔名可自訂)
│ └── rules.yaml # 告警規則(Infra、Logs 兩個資料夾)
└── dashboards/
├── 總覽/overview.json
├── 服務與 Log/…
├── Infra/…
└── CI-CD/jenkins.json
Datasource
最關鍵的一個欄位是 uid。如果不指定,Grafana 會隨機產生,之後所有儀表板與告警規則引用 datasource 的地方都會對不起來;固定成 prometheus 之後,JSON 裡寫 "datasource": {"type":"prometheus","uid":"prometheus"} 在任何環境都成立:
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
Dashboards:資料夾結構就是 Grafana 資料夾
儀表板的 provider 只要一個設定:foldersFromFilesStructure: true。開啟後,磁碟上的子目錄名稱會直接變成 Grafana 的資料夾,不用在 YAML 裡一個個宣告:
apiVersion: 1
providers:
- name: default
type: file
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: true # 子目錄名稱 = Grafana 資料夾名稱
我分成四個資料夾:總覽(首頁)、服務與 Log(blackbox、nginx、應用程式 log)、Infra(node-exporter、cAdvisor、MySQL、Redis)、CI-CD(Jenkins)。allowUiUpdates: true 讓我能在 UI 裡調整試驗,調好之後再把 JSON 匯出放回目錄;因為目錄是唯讀掛載,UI 改的東西不會寫回檔案,重啟容器就回到 Git 的版本,這是刻意的。
Infra 與 Jenkins 那幾張,我是從 grafana.com 匯入社群儀表板再放進目錄的,不需要自己從零畫 CPU 與記憶體圖。但這裡有個坑:有些社群儀表板的面板 datasource 是 null,在 provisioning 環境下會噴 400 錯誤,做法是把它們全部取代成明確的 {"type":"prometheus","uid":"prometheus"},這個坑在踩坑整理會再提。
最後,總覽是首頁,靠的是前面 Compose 裡那個環境變數 GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH,指到 /var/lib/grafana/dashboards/總覽/overview.json。登入就是總覽,沒有多餘的點擊。
首頁總覽怎麼設計(v1 到 v2)
首頁只回答一個問題:現在需不需要我介入?我把版面切成五列,由上到下是:健康狀態、主機、流量與錯誤、備份與部署、正在響的告警。社群儀表板資訊量大,但不適合當首頁;總覽 JSON 太長、手工維護容易出錯,所以我寫了一支 Python 腳本產生它,用 helper 函式 stat()、timeseries()、prom() 組面板,統一字級、顏色與連結:
def stat(title, targets, x, y, w, h=4, unit="none", steps=None, mappings=None,
decimals=None, graph=False, link=None, desc=None, text_mode="value",
color_mode="value", ds=PROM, no_value="—", value_size=30, title_size=12):
...
# 主機列:CPU 用 [2m],閾值 70% 橘、85% 紅
panels.append(stat("CPU",
[prom('100 - avg(rate(node_cpu_seconds_total{mode="idle"}[2m])) * 100', "", False)],
0, y, 5, 4, unit="percent", decimals=0, graph=True,
steps=[("green", None), ("orange", 70), ("red", 85)], link=host_link))
# 流量列:nginx 請求量
panels.append(timeseries("請求量(nginx,req/s)",
[prom('sum(rate(nginx_http_requests_total[$__rate_interval]))', "全部", False)],
0, y, 8, 8, unit="reqps", link="/d/MsjffzSZz"))
用腳本產生有兩個好處:改一個全域參數(例如字級)全部面板一起變;而且 diff 看得懂,不會是幾千行 JSON 裡不知道哪裡動了。每個面板都帶一個「查看詳細」連結,點下去直接跳到對應的詳細儀表板,首頁因此只需要放最少的資訊。
v1 的問題與 v2 的修正
第一版我犯了典型的「什麼都想放」的錯,上線隔天就自己改了一輪。整理成對照表:
| 問題(v1) | 修正(v2) | 原因 |
|---|---|---|
| 正常狀態用大片綠色色塊 | 改成綠色文字,只有異常才變紅 | 整頁綠色塊太吵,真正異常的紅色反而被淹沒;「正常就該安靜」 |
| 每個面板字級不一 | 統一標題 12、數值 30(健康列 20) | 視覺雜亂,掃一眼抓不到重點 |
| 標題被截斷 | 縮短標題(例如「上傳雲端」「MySQL 慢查詢 1h」) | 面板窄的時候標題只剩半個字,等於沒標 |
| 錯誤 log 圖例顯示「Value #A」 | 設定 legendFormat: {{app}} | Loki 查詢沒設 legend,Grafana 拿預設的 refId 當名稱 |
CPU 用 rate(...[5m]) | 改成 [2m] | 壓測時 CPU 滿載 2 分鐘,[5m] 把尖峰抹平成 53%,同一時間 [1m] 是 99% |
| 首頁沒有流量指標 | 補一個請求量面板(nginx req/s) | 出事時第一個問題是「流量是不是變多了」 |
其中 CPU 那條最值得多說兩句。rate() 的視窗等於是平均的長度:視窗越長,尖峰越被抹平。我在做 k6 壓測時(系列第 5 篇)親眼看到:機器明明被打到 99%,首頁卻只顯示 53%,差點誤判「機器還有餘裕」。把總覽的 CPU 改成 [2m] 之後,尖峰能被看見,同時又不會像 [30s] 那樣抖動到沒辦法看。這是「取樣視窗」的取捨,沒有標準答案,但至少要知道自己選了什麼。注意告警裡的 CPU 規則我反而刻意維持 [5m]、持續 15 分鐘,下一節會解釋為什麼首頁與告警用不同的視窗。
告警規則設計
告警的第一原則是少而有意義:每一條響起來都應該有一個明確的下一步動作。我目前共有 22 條基礎設施規則(放在 Infra 資料夾),再加兩條 log 規則(Logs 資料夾,下一篇 Loki 會講),全部用 provisioning 管理、全部送同一個 Slack contact point。
| 分類 | 規則 | 條件 / 持續時間 |
|---|---|---|
| 主機 | CPU 使用率 | > 85%,持續 15 分鐘 |
| 可用記憶體 | < 15%,持續 10 分鐘 | |
| 根目錄磁碟使用率 | > 80%,持續 10 分鐘 | |
| 根目錄磁碟預計 3 天內寫滿 | predict_linear 預測 < 0,持續 30 分鐘 | |
| 磁碟 I/O 飽和 | > 90%,持續 10 分鐘 | |
| Swap 使用 | > 50%,持續 15 分鐘 | |
| OOM kill | 10 分鐘內發生任何一次,立即 | |
| 容器 | 容器重啟 | 10 分鐘內重啟過,立即 |
| 容器記憶體接近上限 | > mem_limit 的 90%,持續 10 分鐘 | |
| 服務 | MySQL、Redis 無法連線 | up < 1,持續 1~2 分鐘 |
| 主機 nginx、Jenkins 無回應 | nginx_up、up{job="jenkins"} < 1,持續 2~5 分鐘 | |
| Jenkins agent 離線 | > 0,持續 10 分鐘 | |
| 監控目標失聯 | up == 0,持續 5 分鐘 | |
| 網站無法存取 | probe_success < 1,持續 3 分鐘 | |
| 網站回應過慢 | probe_duration_seconds > 3 秒,持續 10 分鐘 | |
| SSL 憑證到期 | 剩餘 < 14 天,持續 1 小時 | |
| MySQL 慢查詢增加 | 10 分鐘內增加 > 10 筆(info 等級) | |
| 備份 | 備份超過 26 小時沒成功 | > 26 小時,持續 10 分鐘 |
| 備份超過 26 小時沒上傳到雲端 | > 26 小時,持續 10 分鐘 | |
| 備份檔大小異常縮水 | < 近 3 天最大值的 50% |
for 是用來擋誤報的
幾乎每條規則都有 for(持續時間):條件要連續成立那麼久才會真的發出通知。CPU 偶爾衝到 90% 持續十秒鐘是正常的,一個 cron 就能做到;但持續 15 分鐘就不是了。反過來,「容器重啟」與「OOM kill」這種事件型的條件,不存在「持續」的概念,所以 for: 0s,發生就通知。這也是為什麼我壓測的時候 CPU 滿載 2 分鐘,告警不會響:這是刻意的,告警要抓的是「持續的異常」,不是每一次尖峰;尖峰靠首頁的 [2m] 面板看,告警靠 [5m] 加 15 分鐘擋噪音。
另外 noDataState 要分兩類處理:多數規則沒有資料時視為 OK(例如沒有任何容器重啟,查詢本來就空);但「MySQL 無法連線」「備份太久沒成功」這類,沒有資料本身就是異常,所以設成 Alerting。這兩種設錯,都會變成「該響的不響」。
為什麼規則放 Grafana,不用 Alertmanager
Prometheus 生態系的標準做法是把規則寫在 Prometheus、再接 Alertmanager 發通知。我選擇全部放在 Grafana 的 unified alerting,理由有三個:少養一個容器與一份設定;下一篇的 Loki log 告警也能用同一套介面與 contact point;規則可以 provisioning、跟儀表板一起進版控。代價是告警評估綁在 Grafana 身上,Grafana 掛了告警就停,這也是前面要另外設外部存活告警的另一個原因。如果你的環境有多個 Prometheus、需要複雜的抑制與路由,Alertmanager 依然是比較成熟的選擇;以單機而言,我覺得不值得。
另外兩個維運上的提醒:一是 cardinality,標籤值的組合數量就是 Prometheus 的記憶體成本,所以 cAdvisor 我關掉了 container label、redis 的 instance 只留名稱,不把 URL 之類的長字串塞進標籤;二是 retention,15 天與 5 GB 是我給這台機器的預算,如果你想看更長期的趨勢,與其把保留期拉長,不如考慮另外做降採樣或遠端儲存。
代表性規則範例
Grafana 的 provisioning 規則檔內容很長,每條規則都是一個「查詢 A → 閾值 C」的結構。以「容器記憶體接近上限」為例,節錄重點欄位:
{
"uid": "container-mem-near-limit",
"title": "容器記憶體接近上限 > 90%",
"condition": "C",
"for": "10m",
"noDataState": "OK",
"execErrState": "Error",
"labels": { "severity": "warning" },
"annotations": {
"summary": "{{ $labels.name }} 用到記憶體上限的 {{ printf \"%.0f\" $values.A.Value }}%,再上去會被 OOM kill"
},
"data": [
{
"refId": "A",
"datasourceUid": "prometheus",
"model": {
"expr": "container_memory_working_set_bytes{name!=\"\"} / (container_spec_memory_limit_bytes{name!=\"\"} > 0) * 100",
"instant": true
}
},
{
"refId": "C",
"datasourceUid": "__expr__",
"model": {
"type": "threshold",
"expression": "A",
"conditions": [ { "evaluator": { "type": "gt", "params": [90] } } ]
}
}
]
}
這條規則裡有個細節:分母用 (container_spec_memory_limit_bytes > 0),沒設 mem_limit 的容器上限會是 0,除以 0 會變成 Inf,加上 > 0 的過濾就直接排除。這也是我為什麼堅持「每個容器都要設 mem_limit」,沒設的容器不但可能拖垮主機,連這條告警也看不到它。
這條規則新增的當下就立刻抓到兩個:cAdvisor 用了上限的 89%、Grafana 用了 86%,雖然都還沒超過 90%,但已經在邊緣。我因此把 cAdvisor 的 mem_limit 從 256 MB 調到 384 MB、Grafana 從 512 MB 調到 768 MB,即上面 Compose 裡你看到的數字。告警還沒響就先有收穫,算是物超所值。
其他幾條我覺得值得抄的 PromQL:
# 磁碟 3 天內寫滿:用最近 6 小時的趨勢外推,預測值 < 0 就是寫滿
predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 3 * 86400) < 0
# 容器重啟:10 分鐘內 container_start_time 有變過
changes(container_start_time_seconds{name!=""}[10m]) > 0
# OOM kill
increase(node_vmstat_oom_kill[10m]) > 0
# SSL 憑證剩餘天數
(probe_ssl_earliest_cert_expiry{job="blackbox"} - time()) / 86400 < 14
# 備份最後一次成功距今幾小時
(time() - mysql_backup_last_success_timestamp_seconds) / 3600 > 26
磁碟告警我同時放「使用率 > 80%」與「3 天內預測寫滿」兩條。前者是現況,後者是趨勢:如果某個 log 目錄突然以每小時 3 GB 暴增,使用率可能才 40%,但預測告警會在幾小時內響,給我足夠的時間處理,而不是等到 80% 才發現。
備份縮水告警與 T0 重設
備份告警我特別有感:「備份有跑」不等於「備份有用」,如果某天資料表被意外清空,備份腳本照樣成功、檔案卻只剩幾 KB。所以第三條「備份檔大小異常縮水」把目前大小跟近 3 天最大值相比,低於 50% 就響。表達式如下:
mysql_backup_size_bytes
/ max_over_time(
(mysql_backup_size_bytes and on() (vector(time()) > 1790000000))[3d:5m] offset 1h
)
# 閾值:< 0.5
這裡的 and on() (vector(time()) > T0) 是個有趣的設計。我在除錯時曾經把一個資料表清空,之後備份檔變小是預期中的新常態,但告警拿它跟 3 天前比,還是持續誤報。我的解法是在表達式裡放一個固定的 Unix timestamp T0(範例中的 1790000000 是示意,換成你想重新計算的那一刻),只有 time() > T0 之後的樣本才會被納入「近 3 天最大值」,等於把比較基準重設到 T0 之後;T0 之前的資料被過濾掉,舊的大檔不再影響結果。
Slack 通知
通知路由很簡單:一個 contact point(Slack incoming webhook)、一個預設 policy。我刻意不做複雜的分流,因為一個人維運,所有告警都進同一個頻道最不容易漏看。結構如下(webhook URL 等同密碼,不要寫進會進版控的檔案,我用環境變數注入,Grafana 的 provisioning 檔支援 ${VAR} 展開):
apiVersion: 1
contactPoints:
- orgId: 1
name: slack-ops
receivers:
- uid: slack-ops
type: slack
disableResolveMessage: false # 恢復時也通知
settings:
url: ${SLACK_WEBHOOK_URL} # 由環境變數注入,不把真實網址寫進檔案
username: Grafana
policies:
- orgId: 1
receiver: slack-ops
group_by: [grafana_folder, alertname]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
幾個參數的意義:group_by 把同資料夾、同名稱的告警合併成一則,避免一次十個容器重啟洗版;group_wait: 30s 讓第一則通知稍微等一下,把同時發生的事件打包;repeat_interval: 4h 是同一個告警沒解決時,每 4 小時提醒一次,既不會被遺忘也不會吵到煩。disableResolveMessage: false 讓「已恢復」也發一則,這樣我不用特地跑去看是否真的好了。severity 標籤(critical、warning、info)目前只用來辨識,沒有影響路由。
最後補一個架構上的盲點:監控本身掛掉誰來通知?Prometheus、Grafana 與被監控的服務在同一台主機上,主機當機、網路斷線或 Docker 整個掛掉,我收不到任何 Slack。所以我在雲端供應商的 Monitoring 另外設了外部告警(主機存活),這兩套的分工是:內部告警看「細節」,外部告警看「這台機器還在不在」。再加上上面那條「監控目標失聯 up == 0」,管 Prometheus 抓不到某個 exporter 的情況。
怎麼測告警真的會響
寫完告警最怕的是「它看起來對,但從來沒響過」。我的驗證方式很土但有效:先把閾值暫時調到一定成立(例如 CPU 規則改成 > 1、for 改成 0s),確認 Slack 收到 firing 一則、改回來之後又收到 resolved 一則,整條鏈路(規則、contact point、policy、webhook)才算打通。事件型的規則(容器重啟)可以直接重啟一個不重要的容器來觸發。每新增一條規則都做一次,不要等到真的出事才知道 webhook 網址貼錯。
踩坑整理
坑 1:Docker 29 之後,舊版 cAdvisor 抓不到容器指標
Docker 29 預設改用 containerd image store,舊版 cAdvisor 抓不到容器層級的指標,container_* 系列沒有資料,連「容器重啟」「容器記憶體接近上限」兩條告警都會跟著失明。主機層級(node-exporter)完全正常,只有容器這一塊壞掉;除了 image store 改變,Docker 29 也會拒絕舊版 cAdvisor 使用的舊 API 版本。我實測換成 ghcr.io/google/cadvisor:v0.60.6 就恢復正常。教訓:升級 Docker 大版本之後,把監控的「容器數」面板看一眼,數字掉到 0 就是這個問題。
坑 2:社群儀表板的 datasource 是 null,面板 400
從 grafana.com 匯入的社群儀表板,有些面板的 datasource 是 null。用 UI 匯入時 Grafana 會讓你選 datasource,但 provisioning 直接放檔案就沒有人幫你補,這些面板會噴 400 錯誤。解法:用腳本把 JSON 裡所有 null 的 datasource 取代成明確的 {"type":"prometheus","uid":"prometheus"},這也是前面 datasource 一定要固定 uid 的原因。
坑 3:Grafana 12 的儀表板不在 dashboard 表
我想確認「儀表板有沒有被 provisioning 載入」,習慣性去查 Grafana 資料庫的 dashboard 表,結果判斷錯誤,差點誤以為沒載入。Grafana 12 把儀表板存在新的 resource 表,舊的 dashboard 表已經不是事實來源。判斷有沒有載入,請改用 Grafana 的 HTTP API(/api/search)或直接看 UI,不要憑舊的表格結構下結論。
坑 4:單檔 bind mount 用 sed -i 會讓容器看不到
prometheus.yml 是用單檔 :ro bind mount 進容器的。我用 sed -i 改設定,結果容器內的內容完全沒變,送 SIGHUP 也沒用。原因是 sed -i 會建一個新檔案再取代原檔,換了 inode,而 bind mount 綁的是舊 inode,容器看到的還是舊檔。解法是用 tee 或 cat > file 原地覆寫(inode 不變),或乾脆重建容器。這個坑在下一篇 Loki + Alloy 改 config.alloy 時還會再遇到一次。
坑 5:重啟監控容器前要先設 Silence
我調高 cAdvisor 與 Grafana 的 mem_limit 需要重建容器,結果一重啟,「容器重啟」告警立刻響了,Slack 收到一則我自己造成的緊急通知。以後只要要動監控容器(包含被監控的其他容器),我先到 Grafana 的 Alerting → Silences 建一個 10 分鐘的靜音,條件用 alertname = 容器重啟,動完再讓它自然過期,這樣告警既能保留又不會誤報。
小結
一台 2 核 12 GB 的小機器,用 Prometheus + Grafana 加七種 exporter,我把主機、容器、MySQL、Redis、nginx、Jenkins、外部探測與備份狀態都收進了同一個畫面。整理幾個最值得帶走的重點:
- 所有東西進 Git:datasource(固定 uid)、儀表板(foldersFromFilesStructure 依資料夾分類)、告警規則與 contact point 全部 provisioning,環境重建不會丟設定。
- 每個容器都設 mem_limit,再用「容器記憶體接近上限」告警閉環;新增當下就抓到 cAdvisor 89%、Grafana 86%。
- 備份用 textfile collector 接進來:timestamp 型指標加 26 小時門檻,連「備份變小」都能偵測。
- Blackbox 從使用者角度看:一般網站用 http_2xx,API 自訂 http_api_ok 做內容檢查,並用 relabel 統一 job 標籤。
- 首頁只回答「要不要介入」:正常用綠字、異常才變紅,CPU 用 [2m] 避免抹平尖峰;告警用 for 擋誤報,兩者用不同視窗是刻意的。
- 五個坑:cAdvisor 要 ≥ v0.60、社群儀表板 datasource 要改明確 uid、Grafana 12 查 resource 表、單檔 bind mount 別用 sed -i、重啟前先設 Silence。
有了指標,只能回答「出事了」,回答不了「為什麼出事」。告警響了之後我還是得 ssh 進去 tail log,這正是下一篇要解決的:有了指標,下一步是 log。我會用 Loki + Alloy 把三個服務、nginx 與 MySQL 慢查詢的 log 集中起來,放進同一個 Grafana,讓指標和 log 能在同一個畫面裡對照。
系列文章:自架 VM 維運實戰
保護 → 部署 → 觀測 → 調校,建議依序閱讀:
- 第 1 篇:【資安】Cloudflare Access 實戰:不用 VPN 保護後台與 Grafana
- 第 2 篇:【CI/CD】Jenkins 打造安全的 Laravel 部署:JCasC 與受限 SSH
- 第 3 篇:【監控】Prometheus + Grafana 監控 Docker 服務與 Slack 告警(本篇)
- 第 4 篇:【監控】Loki + Alloy 集中 Laravel 與 Nginx Log:遮罩與踩坑
- 第 5 篇:【效能】k6 壓測 Laravel Octane:找出 2 核 VM 的真實上限
No comments yet.