Roy-Try-Catch
← Back to list

【監控】Prometheus + Grafana 監控 Docker 服務與 Slack 告警

Roy • Updated 2026-10-15 10:02:25

前言

這是自架 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拉取與儲存指標自己 + 下列所有 exporter1 GB
Grafana儀表板、告警、Slack 通知—768 MB
node-exporter主機層級指標CPU、記憶體、磁碟、I/O、swap、OOM kill、textfile 備份指標128 MB
cAdvisor容器層級指標每個容器的 CPU、記憶體、重啟時間、記憶體上限384 MB
mysqld-exporterMySQL 指標連線數、慢查詢累計、QPS、InnoDB128 MB
redis-exporterRedis 指標(一個 exporter 抓三個實例)redis_up、記憶體、命中率、連線數128 MB
blackbox-exporter外部探測網站 / API 是否可用、回應時間、SSL 到期日64 MB
nginx-prometheus-exporter主機 nginx 狀態stub_status:請求數、連線數、nginx_up64 MB
Jenkins Prometheus pluginCI/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 kill10 分鐘內發生任何一次,立即
容器容器重啟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 維運實戰

保護 → 部署 → 觀測 → 調校,建議依序閱讀:

Comments

No comments yet.

請先登入