前言
當你把服務一個個丟進 Kubernetes 叢集之後,一定會遇到這些讓人半夜驚醒的時刻:
「使用者說網站很慢,但我怎麼知道是哪個節點的 CPU 爆了?」
「某個 Pod 一直重啟,到底是記憶體不夠,還是被 OOMKill 了?」
「我想做一張漂亮的儀表板給老闆看,但數據要從哪裡來?」
這些問題的答案,其實都指向同一套解法:Prometheus 負責『收集與儲存』數據,Grafana 負責『把數據畫成人看得懂的圖』。 這兩個工具幾乎是雲原生世界的監控黃金組合。
網路上大部分教學都叫你直接 helm install 一鍵安裝,但那樣你永遠不會知道「箱子裡面到底裝了什麼」。這篇文章反其道而行——我們用手寫 YAML manifests 的方式,在一個 k3s 輕量叢集上,一塊磚一塊磚地把整套監控系統疊起來。等你讀完,你不只會「用」,更會「懂」。
📦 本文所有範例都跑在 k3d 叢集(namespace 為 monitoring)。k3s 是輕量級的 Kubernetes 發行版,而 k3d 則是把 k3s 跑在 Docker 裡的工具,很適合本機學習。指令換成任何標準 K8s 叢集也都通用。
準備工作:在本機開一個 k3s 叢集
⏭️ 已經有 K8s 叢集了嗎? 如果你手邊已經有可用的叢集(不管是 k3s、k3d、minikube、kind 還是雲端的 EKS/GKE),這一節可以直接跳過 ,跳到下一章開始部署監控系統即可。
我們用 k3d 在本機開叢集——它把 k3s 打包進 Docker,一行指令就能開出多節點叢集,用完刪掉也乾淨,最適合學習與實驗。
1. 安裝 k3d 與 kubectl
先確認兩個工具都裝好了(Docker 也要先開著):
1 2 3 4 5 6 7 8 9 curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash k3d version kubectl version --client docker info | head -3
2. 建立一個 3 節點叢集
本文範例輸出是 1 個 server + 2 個 agent (共 3 個節點),這樣才能看到 node-exporter「每節點各一個」的效果。同時把 80 埠映射出來,稍後才能用 prometheus.localhost / grafana.localhost 這類網址從瀏覽器連進去:
1 2 3 k3d cluster create mycluster \ --agents 2 \ --port "80:80@loadbalancer"
--agents 2:開 2 個 worker 節點(加上預設的 1 個 server,共 3 個)。
--port "80:80@loadbalancer":把主機的 80 埠接到叢集的負載平衡器,讓 Traefik Ingress 收得到 *.localhost 的請求。
💡 k3d 建立的叢集內建 Traefik 當 Ingress Controller (這也是 k3s 的預設),所以我們後面 Prometheus / Grafana 的 Ingress 直接就能用,不必額外安裝。
3. 確認叢集就緒
理想輸出:
1 2 3 4 NAME STATUS ROLES AGE VERSION k3d-mycluster-server-0 Ready control-plane,master 40s v1.30.x+k3s1 k3d-mycluster-agent-0 Ready <none> 35s v1.30.x+k3s1 k3d-mycluster-agent-1 Ready <none> 35s v1.30.x+k3s1
看到這三個節點,就代表叢集準備好了——後面文章裡出現的 k3d-mycluster-server-0、k3d-mycluster-agent-0 這些 node 名稱,就是從這裡來的。
🧹 實驗完想清乾淨? 一行就能把整個叢集連同容器刪掉:k3d cluster delete mycluster。
核心概念:這套系統長什麼樣子?
在動手之前,先建立一張心智地圖。整套監控系統其實是「指標來源 → 收集者 → 視覺化 」的資料流:
flowchart LR
subgraph 指標來源["📊 指標來源 (Exporters)"]
NE["node-exporter 主機 CPU/記憶體/磁碟 :9100"]
KSM["kube-state-metrics K8s 物件狀態 :8080"]
end
subgraph 收集者["🔍 收集與儲存"]
PROM["Prometheus 抓取 + 儲存 + 查詢 :9090"]
end
subgraph 視覺化["📈 視覺化"]
GRAF["Grafana 儀表板 :3000"]
end
K8SAPI["Kubernetes API"]
NE ==>|/metrics| PROM
KSM ==>|/metrics| PROM
PROM -.->|服務發現 「現在有哪些目標?」| K8SAPI
KSM -.->|讀取物件狀態| K8SAPI
PROM ==>|PromQL 查詢| GRAF
📖 怎麼看這張圖?
粗實線(➡)= 指標數據的流動 :誰把 metrics 交給誰、誰查詢誰的數據。這是「數據」實際走的路。
虛線(⇢)= 對 Kubernetes API 的詢問 :不是在傳指標,而是「打聽情報」——Prometheus 問 API「現在有哪些目標可以抓」(服務發現),KSM 問 API「各個物件現在是什麼狀態」。
我們可以把每個角色想像成一間餐廳:
角色
餐廳比喻
實際功能
node-exporter
每個廚房裡的溫度計
回報每台機器 的 CPU、記憶體、磁碟
kube-state-metrics
訂單系統的狀態面板
回報每個 K8s 物件 (Pod、Deployment)的狀態
Prometheus
巡邏的店長
定時去每個溫度計「抄數字」並記錄下來
Grafana
給老闆看的營運大螢幕
把店長的紀錄畫成漂亮圖表
理解了這張圖,接下來我們就照著資料流的順序,從指標來源開始,一路蓋到 Grafana 。整趟旅程分成六步:
flowchart LR
S1["Step 1 node-exporter"] --> S2["Step 2 kube-state-metrics"] --> S3["Step 3 Prometheus RBAC"] --> S4["Step 4 Prometheus Config"] --> S5["Step 5 Prometheus 本體"] --> S6["Step 6 Grafana"]
Step 1:node-exporter — 讓每台主機開口說話
🗂️ 先建立 namespace :本文所有資源都放在 monitoring 這個 namespace,動手前先建好它。
1 kubectl create namespace monitoring
第一個問題:Prometheus 要去哪裡抓「這台機器的 CPU 用了多少」?答案是 node-exporter ——一個專門把主機層級 指標(CPU、記憶體、磁碟、網路)輸出成 Prometheus 格式的小程式。
📄 每個 Step 我會把 YAML 拆開講解;完整、可直接複製的檔案都收在文末的**「附錄:完整 manifests」**。你可以把每步的完整 YAML 存成對應檔名(如 node-exporter.yaml),再用下面的 kubectl apply 部署。
為什麼用 DaemonSet?
我們希望每個節點剛好跑一個 node-exporter,這正是 DaemonSet 的專長。不像 Deployment 要你指定「跑幾個副本」,DaemonSet 會自動保證「叢集裡每加一個節點,就補一個 Pod 上去」。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 apiVersion: apps/v1 kind: DaemonSet metadata: name: node-exporter namespace: monitoring labels: app: node-exporter spec: selector: matchLabels: app: node-exporter template: metadata: labels: app: node-exporter spec: hostNetwork: true hostPID: true containers: - name: node-exporter image: prom/node-exporter:v1.8.2
容器怎麼看見「主機」的真實數據?
這裡有個很容易踩坑的觀念:容器預設是被隔離的 ,它讀到的 /proc(Linux 存放系統資訊的地方)只有容器自己的資訊,看不到宿主機。所以我們得動兩個手腳:
打開隔離開關 :hostNetwork: true 和 hostPID: true 讓容器能「看見」主機的網路流量與行程。
把主機的目錄掛進來 ,再叫程式去讀掛進來的路徑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 args: - --path.procfs=/host/proc - --path.sysfs=/host/sys - --path.rootfs=/host/root - --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($|/) ports: - containerPort: 9100 name: metrics volumeMounts: - { name: proc , mountPath: /host/proc , readOnly: true } - { name: sys , mountPath: /host/sys , readOnly: true } - { name: root , mountPath: /host/root , readOnly: true } volumes: - { name: proc , hostPath: { path: /proc } } - { name: sys , hostPath: { path: /sys } } - { name: root , hostPath: { path: / } }
💡 為什麼要 --collector.filesystem.mount-points-exclude?
/proc、/sys、/dev 這些是 Linux 的「偽檔案系統」——它們活在記憶體裡,根本沒有真實硬碟。如果不排除,你的磁碟監控圖表就會被一堆「佔用 0 bytes 的假硬碟」洗版。
用 Headless Service 讓每個節點都被發現
node-exporter 跑起來後,還要給它一個 Service 當「對外窗口」,好讓 Prometheus 找得到它。但這裡藏著一個致命細節:必須用 headless Service 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 apiVersion: v1 kind: Service metadata: name: node-exporter namespace: monitoring labels: app: node-exporter annotations: prometheus.io/scrape: "true" prometheus.io/port: "9100" prometheus.io/path: "/metrics" spec: clusterIP: None selector: app: node-exporter ports: - { name: metrics , port: 9100 , targetPort: 9100 }
為什麼 clusterIP: None 這麼重要?看這張對照圖:
flowchart TB
subgraph 普通["❌ 普通 Service (有 clusterIP)"]
P1["Prometheus"] -->|只給一個 VIP| VIP["虛擬 IP 負載平衡"]
VIP -.->|隨機挑一個| N1["節點1"]
VIP -.-> N2["節點2"]
VIP -.-> N3["節點3"]
end
subgraph 無頭["✅ Headless Service (clusterIP: None)"]
P2["Prometheus"] -->|拿到完整 IP 清單| L["節點1 IP 節點2 IP 節點3 IP"]
L --> M1["直接抓節點1"]
L --> M2["直接抓節點2"]
L --> M3["直接抓節點3"]
end
普通 Service 只給一個虛擬 IP 並做負載平衡,Prometheus 每次只會隨機抓到一台 節點——這樣你就漏掉了其他機器!Headless Service 不做負載平衡,而是把背後每一個 Pod 的真實 IP 都攤開給 Prometheus,這樣才能「一台都不漏地」監控全部節點。
🚀 部署
把上面的 DaemonSet 與 Service 合併存成 node-exporter.yaml(完整內容見附錄),然後套用:
1 2 3 4 kubectl apply -f node-exporter.yaml kubectl -n monitoring get pod -l app=node-exporter -o wide
✅ 驗證:node-exporter 有在吐數據嗎?
部署後,我們可以開一個臨時容器去 curl 它的 /metrics:
1 2 3 4 kubectl -n monitoring run ne-test --rm -i --restart=Never \ --image=curlimages/curl:8.10.1 -- \ curl -s http://node-exporter:9100/metrics 2>&1 \ | grep -E "^node_(memory_MemAvailable|cpu_seconds_total|filesystem_avail)" | head -5
看到 node_memory_MemAvailable_bytes ... 這類輸出,就代表成功了。這行指令的逐字拆解可以參考 [[Kubernetes 常用測試指令]]。
Step 2:kube-state-metrics — 監控「K8s 物件」的狀態
node-exporter 顧的是「機器 健不健康」,但它不知道「Pod 有沒有在跑」「Deployment 該有 3 個副本現在只剩 1 個」。這種 Kubernetes 物件層級 的狀態,交給 kube-state-metrics (簡稱 KSM)。
它會輸出像這樣的指標:
kube_pod_status_phase — Pod 現在是 Running 還是 Pending?
kube_deployment_status_replicas — Deployment 實際有幾個副本?
KSM 為什麼需要 RBAC 權限?
關鍵差異來了:KSM 要輸出這些狀態,它得先去問 Kubernetes API 「現在有哪些 Pod、哪些 Deployment」。而在 K8s 裡,凡是要碰 API 都得先有「身分證」和「許可證」——這就是 RBAC (Role-Based Access Control,角色權限控制)。
我們給 KSM 配三樣東西:身分(ServiceAccount)→ 權限(ClusterRole)→ 綁定(ClusterRoleBinding) 。
flowchart LR
SA["ServiceAccount (身分證:我是 KSM)"]
CR["ClusterRole (許可證:可唯讀這些資源)"]
CRB["ClusterRoleBinding (把許可證發給這個身分)"]
SA --- CRB
CR --- CRB
CRB --> API["✅ 可以呼叫 K8s API"]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 apiVersion: v1 kind: ServiceAccount metadata: name: kube-state-metrics namespace: monitoring --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: kube-state-metrics rules: - apiGroups: ["" ] resources: [nodes , pods , services , endpoints , namespaces , persistentvolumeclaims ] verbs: ["list" , "watch" ] - apiGroups: ["apps" ] resources: [deployments , replicasets , statefulsets , daemonsets ] verbs: ["list" , "watch" ] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: kube-state-metrics roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: kube-state-metrics subjects: - kind: ServiceAccount name: kube-state-metrics namespace: monitoring
🔐 最小權限原則 :這裡只給 list 和 watch(唯讀),完全沒有 create/delete。而且授權的資源清單刻意跟下方 Deployment 的 --resources 參數一一對應——你收集什麼,才授權什麼,多一個都不給。
本體 Deployment
KSM 只需要一個副本就夠了(它不是每節點都要跑),所以用普通的 Deployment:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 apiVersion: apps/v1 kind: Deployment metadata: name: kube-state-metrics namespace: monitoring labels: app: kube-state-metrics spec: replicas: 1 selector: matchLabels: { app: kube-state-metrics } template: metadata: labels: { app: kube-state-metrics } spec: serviceAccountName: kube-state-metrics containers: - name: kube-state-metrics image: registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.13.0 args: - --resources=nodes,pods,deployments,replicasets,daemonsets,statefulsets,services,endpoints,ingresses,namespaces,jobs,persistentvolumeclaims ports: - { containerPort: 8080 , name: metrics } readinessProbe: httpGet: { path: /healthz , port: 8080 } initialDelaySeconds: 5 periodSeconds: 10
Service 的寫法跟 node-exporter 幾乎一樣——一樣貼上 prometheus.io/scrape: "true" 的 annotation,只是 port 換成 8080。注意 KSM 不需要 headless(它只有一個 Pod,沒有「漏抓節點」的問題),用普通 Service 即可。
🚀 部署
把 RBAC(SA/ClusterRole/Binding)+ Deployment + Service 一起存成 kube-state-metrics.yaml(完整內容見附錄)後套用:
1 2 3 4 kubectl apply -f kube-state-metrics.yaml kubectl -n monitoring rollout status deployment/kube-state-metrics
Step 3:Prometheus 的 RBAC — 服務發現的鑰匙
現在輪到主角 Prometheus 登場。但在跑它之前,我們得先給它權限——原因和 KSM 類似但目的不同 ,這是很多人搞混的點:
flowchart TB
subgraph KSM_RBAC["KSM 需要 API 是為了…"]
A1["📤 輸出指標 (讀取物件狀態變成 metrics)"]
end
subgraph PROM_RBAC["Prometheus 需要 API 是為了…"]
B1["🔍 服務發現 (問:現在有哪些節點/Pod/endpoint 可以抓?)"]
end
Prometheus 需要唯讀 nodes、services、endpoints、pods,這正是下一步 kubernetes_sd_configs(服務發現)能運作的基礎:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 apiVersion: v1 kind: ServiceAccount metadata: name: prometheus namespace: monitoring --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: prometheus rules: - apiGroups: ["" ] resources: ["nodes" , "nodes/metrics" , "services" , "endpoints" , "pods" ] verbs: ["get" , "list" , "watch" ] - nonResourceURLs: ["/metrics" ] verbs: ["get" ] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: prometheus roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: prometheus subjects: - kind: ServiceAccount name: prometheus namespace: monitoring
🚀 部署
存成 prometheus-rbac.yaml(完整內容見附錄)後套用。RBAC 沒有 Pod,套用後可用 get 確認物件建立成功:
1 2 3 4 kubectl apply -f prometheus-rbac.yaml kubectl -n monitoring get serviceaccount prometheus kubectl get clusterrole prometheus
Step 4:Prometheus Config — 整套系統最聰明的一步
這一步是整篇文章的靈魂 。Kubernetes 環境最頭痛的地方是:Pod 隨時擴充、重啟、刪除,IP 每次都在變 。如果你要手動維護一份「監控目標清單」,那簡直是惡夢。
Prometheus 的解法是 Service Discovery (服務發現,kubernetes_sd_configs)——它不要你手動列清單,而是直接「問」Kubernetes API :「現在有哪些目標?」清單自動更新,你完全不用管。
設定檔全貌
設定檔以 ConfigMap 形式掛進容器的 /etc/prometheus/prometheus.yml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 apiVersion: v1 kind: ConfigMap metadata: name: prometheus-config namespace: monitoring data: prometheus.yml: | global: scrape_interval: 15s # 每 15 秒抓一次 evaluation_interval: 15s scrape_configs: - job_name: prometheus static_configs: - targets: ["localhost:9090" ] - job_name: kubernetes-service-endpoints kubernetes_sd_configs: - role: endpoints namespaces: names: ["monitoring" ] relabel_configs: ...
🎯 為什麼是 role: endpoints 而不是 role: service?
如果用 role: service,Prometheus 只會抓到 Service 的虛擬 IP ,經過負載平衡後數據會被分散、抓不完整。改用 role: endpoints,Prometheus 會深入到 Service 背後,把對應的每一個 Pod 都揪出來逐一抓取。(跟 Step 1 的 headless Service 是同一個道理!)
relabel_configs:四段「改寫魔法」
relabel_configs 是最讓新手頭暈的部分,但它其實就是一條「加工生產線」:把 K8s API 回傳的原始資料,一步步改寫成 Prometheus 好用的樣子。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 relabel_configs: - source_labels: [__meta_kubernetes_service_annotation_prometheus_io_scrape ] regex: "true" action: keep - source_labels: [__meta_kubernetes_service_annotation_prometheus_io_path ] regex: "(.+)" target_label: __metrics_path__ action: replace - source_labels: [__address__ , __meta_kubernetes_service_annotation_prometheus_io_port ] regex: "([^:]+)(?::\\d+)?;(\\d+)" replacement: "$1:$2" target_label: __address__ action: replace - source_labels: [__meta_kubernetes_service_label_app ] target_label: job - source_labels: [__meta_kubernetes_pod_node_name ] target_label: node - source_labels: [__meta_kubernetes_namespace ] target_label: namespace - source_labels: [__meta_kubernetes_pod_name ] target_label: pod
具體會發生什麼事?一個實例走一遍
光看規則太抽象,我們拿一個真實服務 my-api-service 走一遍。假設它的 metadata 是這樣:
1 2 3 4 5 6 annotations: prometheus.io/scrape: "true" prometheus.io/path: "/api/metrics" prometheus.io/port: "8080" labels: app: my-api
四段規則加工後的「後果」:
規則
動作
效果
(1) keep
檢查 scrape=true?✅ 是
放進監控隊列。若是 false 或沒貼,連出現的資格都沒有
(2) 覆寫路徑
讀到 path=/api/metrics
改抓 http://<PodIP>:8080/api/metrics,而非預設 /metrics
(3) 覆寫 Port
讀到 port=8080
把預設 port 換成 8080,解決「服務埠口不統一」的問題
(4) 美化標籤
讀取各種 meta 標籤
見下表 👇
第 (4) 步把「機器語言」翻譯成「Grafana 語言」:
原始 Metadata(機器看的)
轉化後的 Label(人看的)
__meta_kubernetes_service_label_app
job="my-api"
__meta_kubernetes_pod_node_name
node="k3d-mycluster-server-0"
__meta_kubernetes_namespace
namespace="monitoring"
__meta_kubernetes_pod_name
pod="my-api-74895fbc9-xyz12"
最終,Prometheus 介面上這個目標會長成這樣:
1 2 3 4 5 6 7 目標位址:10.42.0.5:8080 Labels: job: "my-api" node: "k3d-mycluster-server-0" namespace: "monitoring" pod: "my-api-74895fbc9-xyz12" instance: "10.42.0.5:8080"
這套設計對你的三大好處
極度解耦 :監控配置完全脫離應用程式碼。要監控新服務?只要在它的 Service 貼上 prometheus.io/* annotation,完全不用改這個設定檔 。
自動擴展 :當 my-api 從 1 個 Pod 水平擴展到 10 個,Prometheus 透過 role: endpoints 自動偵測到新的 10 個 IP,自動套用規則,你什麼都不用做。
Grafana 超好用 :因為有了 job、namespace 這些標籤,你在 Grafana 能輕鬆寫出這種查詢:1 sum(rate(http_requests_total{job="my-api"}[1m]))
🚀 部署
把這個 ConfigMap 存成 prometheus-config.yaml(完整內容見附錄)後套用。注意:ConfigMap 本身只是「一份設定資料」,要等 Step 5 的 Prometheus 把它掛載進去才會真正生效:
1 2 3 kubectl apply -f prometheus-config.yaml kubectl -n monitoring get configmap prometheus-config
Step 5:Prometheus 本體 — 抓取、儲存、查詢一次搞定
前面鋪陳的權限和設定都是為了這一步。Prometheus 本體同時扮演四個角色:抓取器 + 時序資料庫(TSDB)+ 查詢引擎 + 內建 UI 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 apiVersion: apps/v1 kind: Deployment metadata: name: prometheus namespace: monitoring labels: { app: prometheus } spec: replicas: 1 selector: matchLabels: { app: prometheus } template: metadata: labels: { app: prometheus } spec: serviceAccountName: prometheus containers: - name: prometheus image: prom/prometheus:v2.54.1 args: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.path=/prometheus - --storage.tsdb.retention.time=7d - --web.enable-lifecycle ports: - { containerPort: 9090 , name: web } readinessProbe: httpGet: { path: /-/ready , port: 9090 } initialDelaySeconds: 10 volumeMounts: - { name: config , mountPath: /etc/prometheus } - { name: data , mountPath: /prometheus } volumes: - name: config configMap: { name: prometheus-config } - name: data emptyDir: {}
⚠️ 持久化策略:Prometheus 故意「不」持久化
這裡 data 用的是 emptyDir(記憶體/暫存空間),重啟就清空 。為什麼?因為這是學習環境,時序資料重啟後 15 秒內就會重新抓滿,沒必要為它掛 PVC。等一下你會看到 Grafana 的策略正好相反。
搭配一個普通 Service(:9090)和一個 Ingress,讓你用瀏覽器就能進 Prometheus UI:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: prometheus namespace: monitoring spec: ingressClassName: traefik rules: - host: prometheus.localhost http: paths: - path: / pathType: Prefix backend: service: { name: prometheus , port: { number: 9090 } }
🚀 部署
把 Deployment + Service + Ingress 存成 prometheus.yaml(完整內容見附錄)後套用:
1 2 3 kubectl apply -f prometheus.yaml kubectl -n monitoring rollout status deployment/prometheus
✅ 驗證:Prometheus 到底抓到了哪些目標?
這是確認「設定有沒有生效」最直接的方法——把所有「正在監控中的目標」清單拉出來:
1 2 3 4 5 6 7 8 9 10 11 12 13 POD=$(kubectl -n monitoring get pod -l app=prometheus -o jsonpath='{.items[0].metadata.name}' ) kubectl -n monitoring exec $POD -- \ wget -qO- "http://localhost:9090/api/v1/targets?state=active" > /tmp/t.json 2>/dev/null python3 -c " import json d = json.load(open('/tmp/t.json')) ts = d['data']['activeTargets'] print(f'共 {len(ts)} 個 active target:') for t in ts: print(f\" [{t['health']:6}] job={t['labels'].get('job','?'):22} node={t['labels'].get('node','')}\") "
理想的輸出(3 節點叢集):
1 2 3 4 5 6 共 5 個 active target: [up ] job=kube-state-metrics node=k3d-mycluster-agent-0 [up ] job=node-exporter node=k3d-mycluster-server-0 [up ] job=node-exporter node=k3d-mycluster-agent-0 [up ] job=node-exporter node=k3d-mycluster-agent-1 [up ] job=prometheus node=
看到 node-exporter ×3(每節點一個,證明 headless Service 生效了)+ KSM + Prometheus 自己,全部 up,就大功告成了!你也可以直接開瀏覽器進 http://prometheus.localhost → Status → Targets 用圖形介面看同樣的東西。
Step 6:Grafana — 把數字變成漂亮的圖
Prometheus 的內建 UI 只能做簡單查詢,真正給人看的儀表板要交給 Grafana。Grafana 做的事很單純:連上 Prometheus 當資料來源,把 PromQL 查詢畫成圖表。
兩個關鍵設計決策
Grafana 的 YAML 比較長,但核心只有兩個要理解的決策:
決策一:資料來源用 provisioning 自動接好。 我們不想每次都手動在 UI 裡填 Prometheus 網址,所以用一個 ConfigMap 預先寫好,開機自動載入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 apiVersion: v1 kind: ConfigMap metadata: name: grafana-datasources namespace: monitoring data: datasources.yaml: | apiVersion: 1 datasources: - name: Prometheus type: prometheus access: proxy url: http://prometheus:9090 # 同 namespace,直接用短服務名 isDefault: true
決策二:持久化策略跟 Prometheus 相反——Grafana 要掛 PVC。 因為你在 UI 裡辛苦 import 的儀表板會存進 Grafana 內部的 SQLite(/var/lib/grafana/grafana.db),這個絕對不能重啟就消失:
1 2 3 4 5 6 7 8 9 apiVersion: v1 kind: PersistentVolumeClaim metadata: name: grafana-data namespace: monitoring spec: accessModes: ["ReadWriteOnce" ] resources: requests: { storage: 1Gi }
Prometheus
Grafana
儲存
emptyDir(不持久化)
PVC(持久化)
理由
時序資料重啟秒補回
手動 import 的儀表板不能丟
本體 Deployment
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 apiVersion: apps/v1 kind: Deployment metadata: name: grafana namespace: monitoring labels: { app: grafana } spec: replicas: 1 strategy: type: Recreate selector: matchLabels: { app: grafana } template: metadata: labels: { app: grafana } spec: securityContext: fsGroup: 472 containers: - name: grafana image: grafana/grafana:11.2.0 env: - name: GF_SECURITY_ADMIN_USER valueFrom: { secretKeyRef: { name: grafana-admin , key: admin-user } } - name: GF_SECURITY_ADMIN_PASSWORD valueFrom: { secretKeyRef: { name: grafana-admin , key: admin-password } } ports: - { containerPort: 3000 , name: web } volumeMounts: - { name: data , mountPath: /var/lib/grafana } - { name: datasources , mountPath: /etc/grafana/provisioning/datasources } volumes: - name: data persistentVolumeClaim: { claimName: grafana-data } - name: datasources configMap: { name: grafana-datasources }
帳密用一個 Secret 存放(本文 dev 用途設 admin/admin,正式環境務必改掉 ),再配上和 Prometheus 同款的 Service(:3000)+ Ingress(grafana.localhost)。
🚀 部署
把 Secret + PVC + ConfigMap + Deployment + Service + Ingress 存成 grafana.yaml(完整內容見附錄)後套用:
1 2 3 kubectl apply -f grafana.yaml kubectl -n monitoring rollout status deployment/grafana
⚠️ 踩坑警示:改了 datasource 為什麼沒生效?
這是一個超級常見的困惑:「我改了 ConfigMap 裡的 datasource,怎麼 Grafana 沒反應?」
答案是:用 provisioning(ConfigMap)方式定義的 datasource,改完必須重啟 Grafana Pod 才會生效。 這牽涉到兩種不同的管理模式:
flowchart TB
subgraph P["方法1:Provisioning 模式(本文用的)"]
C["改 ConfigMap"] --> K["K8s 自動同步檔案進 Pod"]
K --> X["❌ 但 Grafana 開機才讀,不會主動偵測"]
X --> R["必須 kubectl rollout restart deployment grafana"]
end
subgraph U["方法2:UI/Database 模式"]
UI["在 UI 手動新增 datasource"] --> DB["存進內部 SQLite (grafana.db)"]
DB --> OK["✅ 即時生效,無需重啟"]
end
Provisioning 模式 :Grafana 把掛進來的檔案當「唯讀」,只在啟動那一刻 讀取。所以改了 ConfigMap 後,得 kubectl rollout restart deployment grafana 讓它重讀。
UI 模式 :在介面上手動新增的 datasource 會存進 SQLite,Grafana 內部機制會即時偵測,**不用重啟。
💡 進階解法:Grafana Sidecar
如果你真的很介意重啟,社群有個熱門的 grafana-sidecar,它跟 Grafana 一起跑,專門監聽 ConfigMap 變動,一有更新就打 HTTP API 通知 Grafana「快刷新!」,這樣就不用重啟 Pod。
✅ 驗證 + 匯入現成儀表板
先確認 Grafana 活著:
1 2 kubectl -n monitoring exec deploy/grafana -- \ wget -qO- "http://localhost:3000/api/health"
接著開瀏覽器進 http://grafana.localhost(帳密 admin / admin)。你不用自己從零畫圖——Grafana 社群有海量現成儀表板,用 import ID 一鍵匯入即可:
Dashboard ID
內容
1860
Node Exporter Full(主機指標大全,最經典)
13332
kube-state-metrics
13473
Kubernetes 叢集總覽
進 Grafana → Dashboards → New → Import ,填入 ID 1860,資料來源選 Prometheus,你就會看到一整片 CPU、記憶體、磁碟的漂亮儀表板——而且因為 Grafana 有 PVC,這些 import 進來的儀表板重啟也不會消失 。
完整回顧:六步串起來
我們從頭到尾走了一遍,把六個 manifest 的角色與依賴關係串成一張總圖:
flowchart TB
subgraph 指標層["指標來源"]
NE["① node-exporter DaemonSet + Headless Service"]
KSM["② kube-state-metrics RBAC + Deployment + Service"]
end
subgraph 收集層["收集與儲存"]
RBAC["③ Prometheus RBAC 服務發現權限"]
CFG["④ Prometheus Config relabel 自動發現"]
PROM["⑤ Prometheus 抓取/儲存/UI (emptyDir)"]
RBAC --> PROM
CFG --> PROM
end
subgraph 視覺層["視覺化"]
GRAF["⑥ Grafana provisioning + PVC"]
end
NE -->|annotation 被發現| PROM
KSM -->|annotation 被發現| PROM
PROM -->|datasource| GRAF
部署順序建議:指標來源(①②)→ 權限與設定(③④)→ Prometheus(⑤)→ Grafana(⑥) 。如果你已經把六個檔案都存好了,也可以一次全部套用(kubectl apply 會自動處理同一目錄下的檔案,順序不影響最終結果):
1 2 3 4 5 6 7 8 9 10 11 kubectl create namespace monitoring kubectl apply -f node-exporter.yaml \ -f kube-state-metrics.yaml \ -f prometheus-rbac.yaml \ -f prometheus-config.yaml \ -f prometheus.yaml \ -f grafana.yaml
全部套用後,用 Step 5 的 targets 驗證指令確認 5 個 target 全 up,再進 Grafana import 1860,一套完整的監控系統就上線了。
結語
恭喜你!你剛剛不是「安裝」了一套監控系統,而是真正理解 了它的每一塊:
🌡️ node-exporter 用 DaemonSet + headless Service,一台不漏地收集主機指標。
📋 kube-state-metrics 靠 RBAC 拿到唯讀權限,輸出 K8s 物件狀態。
🔍 Prometheus 用服務發現 + relabel_configs 四段魔法,零維護 地自動追蹤所有貼了 annotation 的服務。
📈 Grafana 用 provisioning 自動接好資料來源,用 PVC 保住你的儀表板。
最值得帶走的一課是那套 annotation 自動發現 機制:以後你要監控任何新服務,完全不用碰 Prometheus 設定檔 ,只要在它的 Service 貼上三行 annotation:
1 2 3 4 5 metadata: annotations: prometheus.io/scrape: "true" prometheus.io/port: "8000" prometheus.io/path: "/metrics"
Prometheus 就會自動把它納入監控。這種「解耦」的設計思維,正是雲原生系統優雅的地方。動手把它跑起來,然後試著監控你自己的服務吧!🚀
附錄:完整 manifests(可直接複製部署)
前面各章節為了逐段講解,把 YAML 拆開了。這裡附上每個檔案的完整版 ,可以直接複製存檔部署。建議依序 kubectl apply -f 套用(順序同六步:先指標來源、再權限與設定、然後 Prometheus、最後 Grafana)。所有資源都在 monitoring namespace,記得先建立它:
1 kubectl create namespace monitoring
1. node-exporter.yaml(DaemonSet + Headless Service)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 apiVersion: apps/v1 kind: DaemonSet metadata: name: node-exporter namespace: monitoring labels: app: node-exporter spec: selector: matchLabels: app: node-exporter template: metadata: labels: app: node-exporter spec: hostNetwork: true hostPID: true containers: - name: node-exporter image: prom/node-exporter:v1.8.2 args: - --path.procfs=/host/proc - --path.sysfs=/host/sys - --path.rootfs=/host/root - --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($|/) ports: - containerPort: 9100 name: metrics resources: requests: { memory: "32Mi" , cpu: "50m" } limits: { memory: "128Mi" , cpu: "200m" } volumeMounts: - { name: proc , mountPath: /host/proc , readOnly: true } - { name: sys , mountPath: /host/sys , readOnly: true } - { name: root , mountPath: /host/root , readOnly: true } volumes: - { name: proc , hostPath: { path: /proc } } - { name: sys , hostPath: { path: /sys } } - { name: root , hostPath: { path: / } } --- apiVersion: v1 kind: Service metadata: name: node-exporter namespace: monitoring labels: app: node-exporter annotations: prometheus.io/scrape: "true" prometheus.io/port: "9100" prometheus.io/path: "/metrics" spec: clusterIP: None selector: app: node-exporter ports: - name: metrics port: 9100 targetPort: 9100
2. kube-state-metrics.yaml(RBAC + Deployment + Service)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 apiVersion: v1 kind: ServiceAccount metadata: name: kube-state-metrics namespace: monitoring --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: kube-state-metrics rules: - apiGroups: ["" ] resources: [nodes , pods , services , endpoints , namespaces , persistentvolumeclaims ] verbs: ["list" , "watch" ] - apiGroups: ["apps" ] resources: [deployments , replicasets , statefulsets , daemonsets ] verbs: ["list" , "watch" ] - apiGroups: ["batch" ] resources: [jobs ] verbs: ["list" , "watch" ] - apiGroups: ["networking.k8s.io" ] resources: [ingresses ] verbs: ["list" , "watch" ] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: kube-state-metrics roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: kube-state-metrics subjects: - kind: ServiceAccount name: kube-state-metrics namespace: monitoring --- apiVersion: apps/v1 kind: Deployment metadata: name: kube-state-metrics namespace: monitoring labels: app: kube-state-metrics spec: replicas: 1 selector: matchLabels: { app: kube-state-metrics } template: metadata: labels: { app: kube-state-metrics } spec: serviceAccountName: kube-state-metrics containers: - name: kube-state-metrics image: registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.13.0 args: - --resources=nodes,pods,deployments,replicasets,daemonsets,statefulsets,services,endpoints,ingresses,namespaces,jobs,persistentvolumeclaims ports: - { containerPort: 8080 , name: metrics } readinessProbe: httpGet: { path: /healthz , port: 8080 } initialDelaySeconds: 5 periodSeconds: 10 resources: requests: { memory: "64Mi" , cpu: "50m" } limits: { memory: "256Mi" , cpu: "200m" } --- apiVersion: v1 kind: Service metadata: name: kube-state-metrics namespace: monitoring labels: app: kube-state-metrics annotations: prometheus.io/scrape: "true" prometheus.io/port: "8080" prometheus.io/path: "/metrics" spec: selector: app: kube-state-metrics ports: - name: metrics port: 8080 targetPort: 8080
3. prometheus-rbac.yaml(ServiceAccount + ClusterRole + Binding)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 apiVersion: v1 kind: ServiceAccount metadata: name: prometheus namespace: monitoring --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: prometheus rules: - apiGroups: ["" ] resources: ["nodes" , "nodes/metrics" , "services" , "endpoints" , "pods" ] verbs: ["get" , "list" , "watch" ] - nonResourceURLs: ["/metrics" ] verbs: ["get" ] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: prometheus roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: prometheus subjects: - kind: ServiceAccount name: prometheus namespace: monitoring
4. prometheus-config.yaml(ConfigMap,完整 relabel_configs)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 apiVersion: v1 kind: ConfigMap metadata: name: prometheus-config namespace: monitoring data: prometheus.yml: | global: scrape_interval: 15s evaluation_interval: 15s scrape_configs: - job_name: prometheus static_configs: - targets: ["localhost:9090" ] - job_name: kubernetes-service-endpoints kubernetes_sd_configs: - role: endpoints namespaces: names: ["monitoring" ] relabel_configs: - source_labels: [__meta_kubernetes_service_annotation_prometheus_io_scrape ] regex: "true" action: keep - source_labels: [__meta_kubernetes_service_annotation_prometheus_io_path ] regex: "(.+)" target_label: __metrics_path__ action: replace - source_labels: [__address__ , __meta_kubernetes_service_annotation_prometheus_io_port ] regex: "([^:]+)(?::\\d+)?;(\\d+)" replacement: "$1:$2" target_label: __address__ action: replace - source_labels: [__meta_kubernetes_service_label_app ] target_label: job - source_labels: [__meta_kubernetes_pod_node_name ] target_label: node - source_labels: [__meta_kubernetes_namespace ] target_label: namespace - source_labels: [__meta_kubernetes_pod_name ] target_label: pod
5. prometheus.yaml(Deployment + Service + Ingress)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 apiVersion: apps/v1 kind: Deployment metadata: name: prometheus namespace: monitoring labels: { app: prometheus } spec: replicas: 1 selector: matchLabels: { app: prometheus } template: metadata: labels: { app: prometheus } spec: serviceAccountName: prometheus containers: - name: prometheus image: prom/prometheus:v2.54.1 args: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.path=/prometheus - --storage.tsdb.retention.time=7d - --web.enable-lifecycle ports: - { containerPort: 9090 , name: web } readinessProbe: httpGet: { path: /-/ready , port: 9090 } initialDelaySeconds: 10 periodSeconds: 10 resources: requests: { memory: "256Mi" , cpu: "100m" } limits: { memory: "512Mi" , cpu: "500m" } volumeMounts: - { name: config , mountPath: /etc/prometheus } - { name: data , mountPath: /prometheus } volumes: - name: config configMap: { name: prometheus-config } - name: data emptyDir: {} --- apiVersion: v1 kind: Service metadata: name: prometheus namespace: monitoring spec: selector: app: prometheus ports: - name: web port: 9090 targetPort: 9090 --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: prometheus namespace: monitoring spec: ingressClassName: traefik rules: - host: prometheus.localhost http: paths: - path: / pathType: Prefix backend: service: name: prometheus port: { number: 9090 }
6. grafana.yaml(Secret + PVC + ConfigMap + Deployment + Service + Ingress)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 apiVersion: v1 kind: Secret metadata: name: grafana-admin namespace: monitoring type: Opaque stringData: admin-user: admin admin-password: admin --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: grafana-data namespace: monitoring spec: accessModes: ["ReadWriteOnce" ] resources: requests: { storage: 1Gi } --- apiVersion: v1 kind: ConfigMap metadata: name: grafana-datasources namespace: monitoring data: datasources.yaml: | apiVersion: 1 datasources: - name: Prometheus type: prometheus access: proxy url: http://prometheus:9090 isDefault: true editable: true --- apiVersion: apps/v1 kind: Deployment metadata: name: grafana namespace: monitoring labels: { app: grafana } spec: replicas: 1 strategy: type: Recreate selector: matchLabels: { app: grafana } template: metadata: labels: { app: grafana } spec: securityContext: fsGroup: 472 containers: - name: grafana image: grafana/grafana:11.2.0 env: - name: GF_SECURITY_ADMIN_USER valueFrom: { secretKeyRef: { name: grafana-admin , key: admin-user } } - name: GF_SECURITY_ADMIN_PASSWORD valueFrom: { secretKeyRef: { name: grafana-admin , key: admin-password } } ports: - { containerPort: 3000 , name: web } readinessProbe: httpGet: { path: /api/health , port: 3000 } initialDelaySeconds: 10 periodSeconds: 10 resources: requests: { memory: "128Mi" , cpu: "100m" } limits: { memory: "512Mi" , cpu: "500m" } volumeMounts: - { name: data , mountPath: /var/lib/grafana } - { name: datasources , mountPath: /etc/grafana/provisioning/datasources } volumes: - name: data persistentVolumeClaim: { claimName: grafana-data } - name: datasources configMap: { name: grafana-datasources } --- apiVersion: v1 kind: Service metadata: name: grafana namespace: monitoring spec: selector: app: grafana ports: - name: web port: 3000 targetPort: 3000 --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: grafana namespace: monitoring spec: ingressClassName: traefik rules: - host: grafana.localhost http: paths: - path: / pathType: Prefix backend: service: name: grafana port: { number: 3000 }
相關連結
[[Kubernetes 常用測試指令]] - 本文用到的臨時容器 curl 除錯指令詳解。
[[Index]] - 回到知識庫目錄。