昨天盤點的結論是三個洞:Loki 的設定、Tempo 的顯式設定、五個版本號。今天先處理另一件事——Grafana 裡面的東西。
它跟前面所有東西都不一樣:儀表板、資料夾、使用者不是 Kubernetes 資源,它們存在 Grafana 自己的資料庫裡。kubectl get dashboard 這種指令不存在。Helm 裝得了 Grafana 這個程式,管不到它裡面裝了什麼。
這就是 Terraform 的位置:Helm 管「裝什麼」,Terraform 管「裝完之後裡面長什麼樣」。
我原本規劃的今天是:把儀表板從網頁拖出來的狀態,變成 Terraform 管理的檔案。
但這個目標在動手前就已經過時了。第二十天 Grafana 被 helm upgrade 洗掉之後,我就把兩張儀表板改成 ConfigMap 了,sidecar 會自動載入。它們早就是檔案。
所以今天真正的問題變成:同一張儀表板,ConfigMap 和 Terraform 兩種管法,該選哪個?
我決定兩個都寫,讓它們撞一次,看看會發生什麼。結果比我想的乾脆很多。
Terraform 是一個管理基礎設施的工具,核心是宣告式:
你不用寫「如果不存在就建立、如果存在就更新」這種判斷。這個概念前面其實一直在用——kubectl apply -f k8s/ 就是宣告式的,同一個指令跑十次跟跑一次結果一樣。
三個名詞:
| 是什麼 | |
|---|---|
| Provider | 讓它知道怎麼跟某個服務溝通的外掛。有 AWS 的、Kubernetes 的,也有 Grafana 的 |
| Resource | 你要管理的一個東西。一張儀表板是一個、一個資料夾是一個 |
| State | 它記錄「我上次建了哪些東西」的檔案 |
Terraform 在 2023 年把授權從 MPL 改成 BUSL(Business Source License),那是一種有使用限制的授權。社群隨即分叉出 OpenTofu,維持 MPL,後來交給 Linux 基金會。
兩者的語法、指令、設定檔完全共用,Grafana 的 provider 也是 MPL,兩邊都能用。我這篇的指令是 tofu 開頭,你換成 terraform 一樣會動。
我選 OpenTofu 只是因為授權單純。但這件事值得知道,不然你照著別人 2023 年以前的文章做,會以為它們是同一個東西。
裝的時候踩了一個坑:brew install opentofu 直接失敗,因為 Homebrew 要從 ghcr.io 抓東西而我連不上。改抓官方的二進位檔,28 MB 的壓縮檔斷了兩次,最後用 curl -C - 續傳才完成。這種事寫出來不是抱怨,是因為它會發生在別人身上。
state 是新手最容易踩的地方。 Terraform 靠它把「你寫的設定」和「線上實際的東西」對應起來。
如果你把 state 刪掉,它會以為什麼都還沒建,於是想再建一次——然後撞到已存在的東西報錯。反過來,如果線上的東西被別人刪了而 state 還記著,它會以為那個東西還在。
這句話我本來是當理論寫的,後來自己撞到了。為了重跑一次乾淨的 plan,我把資料夾從 state 移掉:
tofu state rm grafana_folder.observability
再 apply,第一件事就爆:
Error: failed to create folder: [POST /folders] createFolder (status 412): {}
412 是「前置條件不符」——資料夾已經存在。Grafana 上它好好的,只是 Terraform 不記得了。設定沒改、線上沒動,只有 state 被我弄丟,整個 apply 就停在第一步。

正確的處理不是去 Grafana 把資料夾刪掉重來,是把既有的東西接回 state:
tofu import grafana_folder.observability 1:observability
1:observability 就是前面那個「組織 ID 加 uid」。接回來之後 plan 就正常了。
這是 state 這個概念最實際的一課:Terraform 的世界觀完全建立在那個檔案上,它不會主動去線上核對有沒有漏掉什麼。
在本機它就是一個檔案 terraform.tfstate。多人協作時要放在共用的地方,不然兩份 state 會打架。
實際打開來看,我那個資料夾在裡面長這樣:
{
"type": "grafana_folder",
"name": "observability",
"attributes": {
"id": "1:observability",
"org_id": "1",
"title": "可觀測性",
"uid": "observability",
"url": "http://localhost:3000/dashboards/f/observability/..."
}
}
注意 id 是 1:observability——組織 ID 加資料夾 uid。這串東西是 Terraform 認人的依據,不是我寫在 .tf 裡的名字。state 沒了,這個對應就沒了。
這個例子裡沒有敏感資料,但如果你管的是資料源,連線字串和密碼都會原封不動躺在裡面。所以第一件事是把它加進 .gitignore。
*.tfstate
*.tfstate.*
.terraform/
terraform {
required_version = ">= 1.6"
required_providers {
grafana = {
source = "grafana/grafana"
version = "~> 4.0" # 4.x 可以,5.0 不行
}
}
}
provider "grafana" {
url = "http://localhost:3000"
}
~> 4.0 這個寫法的意思是「4.x 可以,5.0 不行」。init 之後實際裝到的是 v4.46.0——大版本鎖住、小版本讓它自己跟。
版本鎖定是 IaC 的基本功。 不鎖的話,同一份檔案今天和三個月後可能建出不同的東西,那就失去「可重現」的意義了。這跟昨天盤點出來「五個 chart 只有一個記了版本」是同一件事,只是這次我在寫的時候就記了。
init 還會產生一個 .terraform.lock.hcl,記下實際解析到的精確版本和雜湊值。那個檔案應該進 git——它是「這份設定被驗證過的環境」的證據。我的 .gitignore 一開始把它一起擋掉了,那是錯的,後來拿掉。
url 指向 kubectl port-forward 開在本機的埠,跟前面那隻 agent 一樣不進叢集。
帳密不寫在檔案裡,provider 會讀環境變數 GRAFANA_AUTH,格式是 帳號:密碼:
export GRAFANA_AUTH="admin:$(kubectl -n monitoring get secret kps-grafana \
-o jsonpath='{.data.admin-password}' | base64 -d)"
然後是三個資源——一個資料夾、兩張儀表板:
resource "grafana_folder" "observability" {
title = "可觀測性"
uid = "observability"
}
resource "grafana_dashboard" "checkout_sli" {
folder = grafana_folder.observability.uid
config_json = file("${path.module}/dashboards/checkout-sli.json")
overwrite = true
}
這裡有一個小地方要注意:folder 給的是 uid 不是 id。我參考的舊範例寫的是 .id,現在官方範例是 .uid。這種欄位改名在 provider 裡很常見,照著舊文章抄會卡住。
overwrite = true 的意思是「已經有同 uid 的儀表板時覆蓋它,不要報錯」。我特地加了這行,因為那兩張圖現在確實已經存在。
tofu init # 下載 provider,每個目錄第一次跑
tofu plan # 顯示「如果執行會做什麼」,不動手
tofu apply # 真的做
plan 的輸出:
Plan: 3 to add, 0 to change, 0 to destroy.
三個都是 add。 但那兩張儀表板明明已經在 Grafana 上了。
原因是 state 是空的。Terraform 不是去問 Grafana「有沒有這張圖」,它是問自己的 state「我建過這張圖嗎」。沒建過,所以它要建。

plan 最該養成習慣的用法是看有沒有意外的 destroy。 那代表你的設定跟線上差太多,Terraform 打算砍掉重建。在正式環境這可能是災難。今天沒有,因為我還沒建過任何東西。
Error: [POST /dashboards/db][400] postDashboardBadRequest
{"message":"Cannot save provisioned dashboard"}

兩張儀表板都失敗,只有資料夾建起來了。
provisioned 這個字是線索。去翻 Grafana 那份 sidecar 產生的 provider 設定:
providers:
- name: 'sidecarProvider'
type: file
allowUiUpdates: false
options:
path: /tmp/dashboards
allowUiUpdates: false。
一個布林值。從 ConfigMap provision 進去的儀表板是唯讀的——網頁上改不了、API 改不了、Terraform 也改不了。Grafana 認得出那張圖是誰送的,不讓第二個人動它。
我原本準備寫的是另一件事。
Terraform 管理儀表板有一個很有名的麻煩:JSON 會漂移。你用檔案建好圖,然後有人在網頁上調一下,線上的 JSON 就跟檔案不一樣了;下次 plan 一堆差異。而且 Grafana 自己也會塞 version 之類的欄位進去,你什麼都沒改 plan 還是有東西。
常見的處理方式是用 ignore_changes 忽略某些欄位,或是接受「檔案是唯一事實來源、網頁上的修改隨時會被覆蓋」。
但 Grafana 的答案比這兩個都乾脆:不讓它漂移。
被 provision 的東西直接鎖成唯讀,於是「兩個權威來源」這個問題在源頭就不存在。我花了半天想解決的問題,這個生態系已經用一個布林值擋掉了。
代價是你得挑一邊——而挑哪一邊,現在有實際的判準了。
| ConfigMap + sidecar | OpenTofu | |
|---|---|---|
| 叢集重建後自動回來 | ✅ | ❌ 要另外跑一次 |
| 需要額外工具與 state | 不用 | 要 |
| 防止有人在網頁上亂改 | ✅ 直接鎖成唯讀 | ❌ 只能事後發現 |
| 分資料夾 | ❌ 預設全丟 General | ✅ |
| 管使用者、權限、組織 | ❌ | ✅ |
我選 ConfigMap 管儀表板,理由是第一列:它跟叢集一起回來,不用記得多跑一個指令。 最後一天要砍掉叢集重建,少一個步驟就少一個會忘的地方。
而且那 26 張 chart 自己帶的儀表板本來就是用 ConfigMap 送的——這不是我發明的做法,是這個生態系的預設。
那 Terraform 管什麼?管 sidecar 做不到的事。
順帶一提資料源也適用同一條規則。Prometheus、Loki、Tempo 這三個資料源是在 kps 的 values 裡宣告的,chart 把它們變成一個帶 grafana_datasource=1 標籤的 ConfigMap,一樣由 sidecar 送進去、一樣被鎖成唯讀。所以我沒有把它們搬到 Terraform——已經在檔案裡的東西,換一個工具管不會變得更在檔案裡。
那 exemplar 那段設定呢?就是查指標時可以直接跳到 trace 的那個關聯。它現在是 kps values 裡的三行。如果那台 Grafana 是別人裝的、我只有 API 權限,那它就該由 Terraform 管。判準不是「哪個工具比較好」,是「這台 Grafana 是不是我用 Helm 裝的」。
所以今天 Terraform 實際管的只有一個東西:資料夾。
Grafana 上現在有 28 張儀表板,26 張來自 chart,我的兩張混在 General 裡。sidecar 預設把所有圖丟進同一個地方,沒有分類。
分資料夾要兩邊配合。Helm values 這側打開註記支援:
grafana:
sidecar:
dashboards:
folderAnnotation: grafana_folder
provider:
foldersFromFilesStructure: true
ConfigMap 那側加一個註記:
metadata:
labels:
grafana_dashboard: "1"
annotations:
grafana_folder: "可觀測性"
而那個資料夾是 Terraform 建的。升級完 Grafana 再看一次:
uid=agent-observability folder=可觀測性
uid=checkout-sli folder=可觀測性

兩張圖進去了。再跑一次 plan 確認 Terraform 還認得那個資料夾:
No changes. Your infrastructure matches the configuration.

它們不是搶同一個東西,是各管一段,靠「可觀測性」這個名字對上。 這比我原本想的「二選一」好,也比「兩個都管同一張圖」實際。
明天處理叢集裡面的東西:把五個 helm install 收成一個檔案,順便把昨天那三個洞補起來。