Kibana Dashboard as Code:Elastic 9.4 如何用 Terraform 和类型化 API 终结“JSON 垃圾袋“

Elastic 9.4 带来了一个专为 Kibana 仪表板设计的类型化 API,以及原生的 Terraform 资源。

这意味着,你的仪表板终于可以像 Kubernetes 配置或数据库 Schema 一样,纳入 GitOps 工作流------支持漂移检测、PR 可审查的 Diff、以及基于 Git 的版本回滚。


一、为什么仪表板需要 API?

如果你曾经尝试过把 Kibana 仪表板纳入版本控制,你一定会遇到一个令人绝望的事实:

Elastic 原有的 Saved Object API 在导出仪表板时,会把它打包成一个巨大的、字符串化的 JSON blob 。所有的可视化状态、内部引用关系、UUID、元数据,像一团乱麻一样被塞进一个叫做 panelsJSON 的字段里。

你想修改一个 Lens 图表的颜色配置。你导出的 JSON 里,panelsJSON 的值是一条长达数千字符的单行字符串。里面嵌套着随机生成的 ID、并行数组、UI 内部的状态快照------这些东西对 Kibana 前端恢复画面有用,但对人类阅读来说,无异于天书。

这直接导致了三个致命伤:

  1. GitOps 不可能:没有清晰的 Diff,代码审查无从谈起。Reviewer 看到一行 2000 字符的变更,根本不可能知道改了什么。
  2. 环境晋升靠运气:你无法用自动化的方式把"测试环境的仪表板"可靠地推送到生产环境,因为里面硬编码着环境特定的 UUID 和内部引用。
  3. 回滚是噩梦:想回滚到上周的稳定版本?你只能手动覆盖整个文档,祈祷别把生产环境搞砸。

甚至对于大语言模型(LLM)来说,这种格式也太复杂、太容易出错。如果未来你想用自然语言生成一个仪表板("给我做一个展示支付服务错误率的仪表板"),LLM 必须先能可靠地读写这种结构------而 Saved Object 格式显然不是为这种场景设计的。


二、新 Dashboards API:把"黑盒"变成"白盒"

Elastic 9.4 引入的 Kibana Dashboards API 不是对旧 API 的简单修补,而是一次从存储层到接口层的彻底重构。

它提供 5 个端点,覆盖仪表板的全生命周期:
#mermaid-svg-wkKNq6MQ4ZCDvhPV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-wkKNq6MQ4ZCDvhPV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .error-icon{fill:#552222;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .marker.cross{stroke:#333333;}#mermaid-svg-wkKNq6MQ4ZCDvhPV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wkKNq6MQ4ZCDvhPV p{margin:0;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .cluster-label text{fill:#333;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .cluster-label span{color:#333;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .cluster-label span p{background-color:transparent;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .label text,#mermaid-svg-wkKNq6MQ4ZCDvhPV span{fill:#333;color:#333;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .node rect,#mermaid-svg-wkKNq6MQ4ZCDvhPV .node circle,#mermaid-svg-wkKNq6MQ4ZCDvhPV .node ellipse,#mermaid-svg-wkKNq6MQ4ZCDvhPV .node polygon,#mermaid-svg-wkKNq6MQ4ZCDvhPV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .rough-node .label text,#mermaid-svg-wkKNq6MQ4ZCDvhPV .node .label text,#mermaid-svg-wkKNq6MQ4ZCDvhPV .image-shape .label,#mermaid-svg-wkKNq6MQ4ZCDvhPV .icon-shape .label{text-anchor:middle;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .rough-node .label,#mermaid-svg-wkKNq6MQ4ZCDvhPV .node .label,#mermaid-svg-wkKNq6MQ4ZCDvhPV .image-shape .label,#mermaid-svg-wkKNq6MQ4ZCDvhPV .icon-shape .label{text-align:center;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .node.clickable{cursor:pointer;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .arrowheadPath{fill:#333333;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-wkKNq6MQ4ZCDvhPV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wkKNq6MQ4ZCDvhPV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-wkKNq6MQ4ZCDvhPV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .cluster text{fill:#333;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .cluster span{color:#333;}#mermaid-svg-wkKNq6MQ4ZCDvhPV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-wkKNq6MQ4ZCDvhPV rect.text{fill:none;stroke-width:0;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .icon-shape,#mermaid-svg-wkKNq6MQ4ZCDvhPV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .icon-shape p,#mermaid-svg-wkKNq6MQ4ZCDvhPV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .icon-shape .label rect,#mermaid-svg-wkKNq6MQ4ZCDvhPV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wkKNq6MQ4ZCDvhPV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-wkKNq6MQ4ZCDvhPV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-wkKNq6MQ4ZCDvhPV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 创建
读取
Upsert

更新或创建
删除
分页列表
🆕 POST /api/dashboards
Dashboard
📖 GET /api/dashboards/{id}
🔄 PUT /api/dashboards/{id}
❌ DELETE /api/dashboards/{id}
📋 GET /api/dashboards
所有 Dashboards

更重要的是,它与旧 API 有着本质区别:

特性 旧 Saved Object API 新 Dashboards API
数据结构 panelsJSON 字符串黑盒 每个面板独立的类型化 Schema
可视化类型 统一吞进字符串 12 种类型分别验证(XY、Metric、Pie、Gauge、Heatmap、Data Table、Treemap 等)
查询语言 混杂在内部状态里 清晰区分 Data View 与 ES|QL 两种模式
过滤器 嵌套在 UI 状态深处 Dashboard 级别的类型化过滤器(israngeexistsand/or 组合)
布局 不可控 48 列网格系统 + 可折叠分组
写入验证 保存时不管,渲染时报错 写入时即验证,错误在 API 调用阶段就暴露
面板来源 仅限内嵌 支持内嵌面板 + Library 面板(跨仪表板复用)

这意味着,当你导出一个仪表板时,你得到的是一份人类可读、机器可验证、版本控制友好的结构化 JSON。


三、幕后功臣:Transforms Layer(转换层)

新 API 的诞生并非一蹴而就。Kibana 从第一天起就把用户创建的 UI 内容直接快照化存进 Elasticsearch。当时的假设很简单:只要能被 UI 读回来还原画面,存储格式长什么样根本不重要。

于是,前端把当时的 React 组件状态、Redux store 切片、甚至一些临时性的内部标记,原封不动地塞进了 panelsJSONuiStateJSONvisState 等字段里。这些字段被字符串化后存入 Elasticsearch,团队内部给它们起了个贴切的外号:"JSON Bags"(JSON 垃圾袋)

当你解开这些垃圾袋,看到的不是整洁的数据结构,而是深层次的嵌套、随机生成的 ID、并行数组、以及不同状态块之间的隐式引用。这些东西在前端代码里或许合理,但作为公共 API 的契约,它们是灾难。

3.1 面临的选择

走到了一个岔路口:

  • 选项 A:保留现有 Kibana 仪表板系统的所有代码,在旁边另起炉灶建一个全新的 API,绕过旧系统,只支持最常用的功能。
  • 选项 B :把新的类型化 Schema 注入现有系统,在旧 UI 和新 API 之间架设一层中间件,让同一套 Schema 同时驱动 UI 和公共端点。

选项 B------因为这意味着用户现有的所有仪表板都能无缝迁移,不需要重建任何东西。

3.2 Transforms Layer 如何工作

这个中间件就是 Transforms Layer 。它的职责只有一个:在" legacy UI 状态形状"和"干净的 API 形状"之间双向翻译
#mermaid-svg-LL0fKmNQwcfWNzjA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-LL0fKmNQwcfWNzjA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LL0fKmNQwcfWNzjA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LL0fKmNQwcfWNzjA .error-icon{fill:#552222;}#mermaid-svg-LL0fKmNQwcfWNzjA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LL0fKmNQwcfWNzjA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LL0fKmNQwcfWNzjA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LL0fKmNQwcfWNzjA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LL0fKmNQwcfWNzjA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LL0fKmNQwcfWNzjA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LL0fKmNQwcfWNzjA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LL0fKmNQwcfWNzjA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LL0fKmNQwcfWNzjA .marker.cross{stroke:#333333;}#mermaid-svg-LL0fKmNQwcfWNzjA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LL0fKmNQwcfWNzjA p{margin:0;}#mermaid-svg-LL0fKmNQwcfWNzjA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-LL0fKmNQwcfWNzjA .cluster-label text{fill:#333;}#mermaid-svg-LL0fKmNQwcfWNzjA .cluster-label span{color:#333;}#mermaid-svg-LL0fKmNQwcfWNzjA .cluster-label span p{background-color:transparent;}#mermaid-svg-LL0fKmNQwcfWNzjA .label text,#mermaid-svg-LL0fKmNQwcfWNzjA span{fill:#333;color:#333;}#mermaid-svg-LL0fKmNQwcfWNzjA .node rect,#mermaid-svg-LL0fKmNQwcfWNzjA .node circle,#mermaid-svg-LL0fKmNQwcfWNzjA .node ellipse,#mermaid-svg-LL0fKmNQwcfWNzjA .node polygon,#mermaid-svg-LL0fKmNQwcfWNzjA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-LL0fKmNQwcfWNzjA .rough-node .label text,#mermaid-svg-LL0fKmNQwcfWNzjA .node .label text,#mermaid-svg-LL0fKmNQwcfWNzjA .image-shape .label,#mermaid-svg-LL0fKmNQwcfWNzjA .icon-shape .label{text-anchor:middle;}#mermaid-svg-LL0fKmNQwcfWNzjA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-LL0fKmNQwcfWNzjA .rough-node .label,#mermaid-svg-LL0fKmNQwcfWNzjA .node .label,#mermaid-svg-LL0fKmNQwcfWNzjA .image-shape .label,#mermaid-svg-LL0fKmNQwcfWNzjA .icon-shape .label{text-align:center;}#mermaid-svg-LL0fKmNQwcfWNzjA .node.clickable{cursor:pointer;}#mermaid-svg-LL0fKmNQwcfWNzjA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-LL0fKmNQwcfWNzjA .arrowheadPath{fill:#333333;}#mermaid-svg-LL0fKmNQwcfWNzjA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-LL0fKmNQwcfWNzjA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-LL0fKmNQwcfWNzjA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LL0fKmNQwcfWNzjA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-LL0fKmNQwcfWNzjA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LL0fKmNQwcfWNzjA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-LL0fKmNQwcfWNzjA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-LL0fKmNQwcfWNzjA .cluster text{fill:#333;}#mermaid-svg-LL0fKmNQwcfWNzjA .cluster span{color:#333;}#mermaid-svg-LL0fKmNQwcfWNzjA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-LL0fKmNQwcfWNzjA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-LL0fKmNQwcfWNzjA rect.text{fill:none;stroke-width:0;}#mermaid-svg-LL0fKmNQwcfWNzjA .icon-shape,#mermaid-svg-LL0fKmNQwcfWNzjA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LL0fKmNQwcfWNzjA .icon-shape p,#mermaid-svg-LL0fKmNQwcfWNzjA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-LL0fKmNQwcfWNzjA .icon-shape .label rect,#mermaid-svg-LL0fKmNQwcfWNzjA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LL0fKmNQwcfWNzjA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-LL0fKmNQwcfWNzjA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-LL0fKmNQwcfWNzjA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 消费者
服务端:Transforms Layer
存储层(Elasticsearch)
各面板独立 Transform
写入 UI State
写入 Typed Schema
序列化为 JSON Bags
反序列化
返回 Typed Schema
返回 Typed Schema
运行时验证
内部 UI State

JSON Bags

panelsJSON / uiStateJSON
Panel Transform

Registry

面板转换注册表
Schema Registry

公共 API 契约库
XY Chart

Transform

序列化 ↔ 反序列化
Metric Panel

Transform
Markdown

Transform
Controls

Transform
...更多面板
Kibana UI

原有代码

无感知
API Client

Terraform / curl / LLM

关键机制:

  1. 逐面板注册:每个面板类型(XY Chart、Metric、Markdown 等)都向仪表板注册自己的 Transform 函数。负责该面板的团队会迭代优化 Schema,并同步修改 UI 代码,确保 UI 和新 API 看到的是同一套逻辑结构。
  2. Schema 即契约 :每个面板在注册 Transform 时,必须同时注册一个完整、严格的 Schema。这个 Schema 成为公共 API 契约的一部分。如果一个面板类型还没准备好(比如 Links 面板、ML 面板),它就不会出现在公共 API 的响应里,从而避免未来发生破坏性变更。
  3. 向后兼容是底线:整个重构是在"用户每天仍在保存和加载仪表板"的线上环境中完成的。任何时候都不能破坏现有用户的体验。

四、实战:从 UI 到 API 的完整 Walkthrough

新 API 在 Elastic 9.4 中默认可用,并且兼容你现有的所有仪表板。下面是一个从 UI 出发,快速体验 API 的完整流程。

步骤 1:在 Kibana 中打开一个现有仪表板

假设你打开了 "Logs Web Traffic" 之类的示例仪表板。顶部菜单点击 Share → Export JSON

步骤 2:查看导出的结构化 JSON

你会看到一个 Flyout 弹出层,里面不再是密密麻麻的单行字符串,而是格式清晰的 JSON。它包含:

  • titledescriptiontags
  • time_rangerefresh_interval
  • query(KQL 或 Lucene)
  • panels 数组,每个面板有明确的 typegrid 位置、xy_chart_configmetric_chart_config

步骤 3:一键导入到 Console

点击 Open in Console ,Kibana 会自动在 Dev Tools 中为你生成一个预填充的 POST 请求:

http 复制代码
POST kbn:/s/production/api/dashboards
{
  "title": "Operations overview",
  "description": "Production traffic metrics",
  "tags": ["observability", "production"],
  "time_range": { "from": "now-1h", "to": "now" },
  "query": { "language": "kql", "text": "service.name:payments" },
  "panels": [ ... ]
}

注意 URL 中的 /s/production/------这是 Kibana Space ID。新 API 完全支持 Space 级别的多租户隔离。

步骤 4:验证

切换到 production Space,你会看到仪表板已经原样创建,包含原来的 Controls、Metrics 和 Time Series 图表。


五、Terraform 原生支持:把 HCL 变成仪表板的"源代码"

如果说 Dashboards API 解决了数据结构的问题,那么 Elastic Stack Terraform Provider 则解决了基础设施编排的问题。

Provider 中新增的 elasticstack_kibana_dashboard 资源,把 API 的每一个类型化字段都映射成了原生的 HCL(HashiCorp Configuration Language)。这意味着你可以:

  • terraform plan 预览变更
  • terraform apply 部署
  • 用原生机制做 Drift Detection(漂移检测)
  • terraform import 把现有仪表板纳入管理

5.1 一个完整的 Terraform 示例

下面是一个支付服务的监控仪表板,包含一个时序面积图和一个核心指标 KPI:

hcl 复制代码
terraform {
  required_providers {
    elasticstack = {
      source  = "elastic/elasticstack"
      version = "~> 0.14"
    }
  }
}

provider "elasticstack" {
  kibana {}
}

resource "elasticstack_kibana_dashboard" "service_overview" {
  title       = "Service Overview"
  description = "Key metrics for the payments service"
  tags        = ["production", "payments"]

  time_range       = { from = "now-1h", to = "now" }
  refresh_interval = { pause = false, value = 30000 }
  query            = { language = "kql", text = "service.name:payments" }

  panels = [
    {
      type = "vis"
      grid = { x = 0, y = 0, w = 32, h = 15 }
      xy_chart_config = {
        axis = {
          x = { title = { visible = true } }
          y = {
            title       = { visible = true }
            scale       = "linear"
            domain_json = jsonencode({ type = "fit" })
          }
        }
        legend      = { visibility = "visible", inside = false, position = "right" }
        fitting     = { type = "none" }
        decorations = { fill_opacity = 0.3 }
        query       = { language = "kql", expression = "" }
        layers = [{
          type = "area"
          data_layer = {
            ignore_global_filters = false
            sampling              = 1
            data_source_json      = jsonencode({ 
              type = "data_view_spec", 
              index_pattern = "logs-*" 
            })
            y = [{
              config_json = jsonencode({ 
                operation = "count", 
                empty_as_null = true, 
                color = { type = "auto" } 
              })
            }]
          }
        }]
      }
    },
    {
      type = "vis"
      grid = { x = 32, y = 0, w = 16, h = 15 }
      metric_chart_config = {
        ignore_global_filters = false
        sampling              = 1
        data_source_json      = jsonencode({ 
          type = "data_view_spec", 
          index_pattern = "logs-*", 
          time_field = "@timestamp" 
        })
        query = { language = "kql", expression = "" }
        metrics = [{
          config_json = jsonencode({
            type      = "primary"
            operation = "count"
            empty_as_null = false
            color     = { type = "auto" }
            format    = { type = "number", decimals = 2, compact = false }
          })
        }]
      }
    }
  ]
}

💡 关于 jsonencode 的说明 :你可能会注意到某些字段(如 data_source_jsonconfig_jsondomain_json)仍然使用了 jsonencode()。这不是设计倒退,而是 Terraform HCL 类型系统与某些高度动态的面板配置之间的务实妥协。核心结构(面板类型、网格位置、查询、过滤器)已经是完全类型化的 HCL 块,而这些内部配置对象由于各面板差异极大,暂时以 JSON 字符串形式承载。未来 Provider 的迭代会把更多常用配置展开为原生 HCL 属性。

5.2 访问控制:防止"绕过 Terraform 的手动修改"

生产环境中最怕什么?最怕有人登录 Kibana UI,随手把仪表板改了,导致 Terraform 状态与实际环境不一致。

Provider 支持仪表板的访问控制模型:

hcl 复制代码
resource "elasticstack_kibana_dashboard" "protected" {
  title = "Production SLOs"
  # ... 其他配置 ...

  access_control = {
    access_mode = "write_restricted"
  }
}

access_mode 设为 "write_restricted" 时,只有创建者(即 Terraform 服务账号)拥有写入权限。这相当于给仪表板加了一把锁:所有变更必须流经 Terraform Pipeline,任何人直接在 UI 里点击"保存"都会被拒绝。


六、GitOps 工作流:仪表板终于成为一等基础设施公民

有了类型化 API 和 Terraform 支持,Kibana 仪表板可以像 Kubernetes Deployment 或 AWS VPC 一样,被完全纳入 GitOps 工作流。
#mermaid-svg-zBxF0n9YhH0E3L2g{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zBxF0n9YhH0E3L2g .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zBxF0n9YhH0E3L2g .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zBxF0n9YhH0E3L2g .error-icon{fill:#552222;}#mermaid-svg-zBxF0n9YhH0E3L2g .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zBxF0n9YhH0E3L2g .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zBxF0n9YhH0E3L2g .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zBxF0n9YhH0E3L2g .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zBxF0n9YhH0E3L2g .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zBxF0n9YhH0E3L2g .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zBxF0n9YhH0E3L2g .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zBxF0n9YhH0E3L2g .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zBxF0n9YhH0E3L2g .marker.cross{stroke:#333333;}#mermaid-svg-zBxF0n9YhH0E3L2g svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zBxF0n9YhH0E3L2g p{margin:0;}#mermaid-svg-zBxF0n9YhH0E3L2g .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zBxF0n9YhH0E3L2g .cluster-label text{fill:#333;}#mermaid-svg-zBxF0n9YhH0E3L2g .cluster-label span{color:#333;}#mermaid-svg-zBxF0n9YhH0E3L2g .cluster-label span p{background-color:transparent;}#mermaid-svg-zBxF0n9YhH0E3L2g .label text,#mermaid-svg-zBxF0n9YhH0E3L2g span{fill:#333;color:#333;}#mermaid-svg-zBxF0n9YhH0E3L2g .node rect,#mermaid-svg-zBxF0n9YhH0E3L2g .node circle,#mermaid-svg-zBxF0n9YhH0E3L2g .node ellipse,#mermaid-svg-zBxF0n9YhH0E3L2g .node polygon,#mermaid-svg-zBxF0n9YhH0E3L2g .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zBxF0n9YhH0E3L2g .rough-node .label text,#mermaid-svg-zBxF0n9YhH0E3L2g .node .label text,#mermaid-svg-zBxF0n9YhH0E3L2g .image-shape .label,#mermaid-svg-zBxF0n9YhH0E3L2g .icon-shape .label{text-anchor:middle;}#mermaid-svg-zBxF0n9YhH0E3L2g .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zBxF0n9YhH0E3L2g .rough-node .label,#mermaid-svg-zBxF0n9YhH0E3L2g .node .label,#mermaid-svg-zBxF0n9YhH0E3L2g .image-shape .label,#mermaid-svg-zBxF0n9YhH0E3L2g .icon-shape .label{text-align:center;}#mermaid-svg-zBxF0n9YhH0E3L2g .node.clickable{cursor:pointer;}#mermaid-svg-zBxF0n9YhH0E3L2g .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zBxF0n9YhH0E3L2g .arrowheadPath{fill:#333333;}#mermaid-svg-zBxF0n9YhH0E3L2g .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zBxF0n9YhH0E3L2g .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zBxF0n9YhH0E3L2g .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zBxF0n9YhH0E3L2g .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zBxF0n9YhH0E3L2g .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zBxF0n9YhH0E3L2g .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zBxF0n9YhH0E3L2g .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zBxF0n9YhH0E3L2g .cluster text{fill:#333;}#mermaid-svg-zBxF0n9YhH0E3L2g .cluster span{color:#333;}#mermaid-svg-zBxF0n9YhH0E3L2g div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zBxF0n9YhH0E3L2g .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zBxF0n9YhH0E3L2g rect.text{fill:none;stroke-width:0;}#mermaid-svg-zBxF0n9YhH0E3L2g .icon-shape,#mermaid-svg-zBxF0n9YhH0E3L2g .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zBxF0n9YhH0E3L2g .icon-shape p,#mermaid-svg-zBxF0n9YhH0E3L2g .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zBxF0n9YhH0E3L2g .icon-shape .label rect,#mermaid-svg-zBxF0n9YhH0E3L2g .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zBxF0n9YhH0E3L2g .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zBxF0n9YhH0E3L2g .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zBxF0n9YhH0E3L2g :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 🛡️ 运维与治理
🚀 部署阶段
🔧 开发阶段
git push
CI 运行

terraform plan
合并到 main
CD 触发

terraform apply
自动化测试通过
产生
定时/手动

terraform plan
git revert

terraform apply
持续监控
恢复一致
开发者在 HCL 中

定义/修改仪表板
创建 Pull Request
PR 中展示清晰 Diff

Reviewer 逐行审查
主分支代码
测试环境

Staging
生产环境

Production
某人登录 Kibana UI

试图手动修改
配置漂移 Drift
CI 报警:

发现与代码不一致
秒级回滚到

已知良好状态

这个工作流带来的好处是实实在在的:

  1. 定义即代码:仪表板与 Elasticsearch 索引、数据视图(Data View)、告警规则(Alerting Rules)放在同一个代码仓库里,上下文完整。
  2. 审查即安全terraform plan 的输出会精确告诉你------"这个 Metric 面板的小数位数从 2 改成了 0"、"时间范围从 now-1h 改成了 now-15m"。
  3. 环境一致性 :通过 Terraform Workspace 或变量文件(terraform.tfvars)管理不同环境的差异,比如测试环境用 logs-staging-*,生产环境用 logs-production-*
  4. 漂移检测 :当有人(无论是有意还是无意)绕过了流程,在 UI 里改了仪表板,下一次 terraform plan 会立即发现差异。
  5. 基于 Git 的回滚 :生产出问题?git revert 上一次提交,然后 terraform apply。30 秒内回到上一个稳定版本,不需要在 Kibana 里手忙脚乱地找"上一个版本"的导出文件。

七、快速开始:三步走

1. 配置 Provider

确保你的 Elastic Stack 版本 ≥ 9.4,然后配置 Provider:

hcl 复制代码
terraform {
  required_providers {
    elasticstack = {
      source  = "elastic/elasticstack"
      version = "~> 0.14"
    }
  }
}

provider "elasticstack" {
  kibana {}
}

2. 定义并部署

参考上面的 elasticstack_kibana_dashboard 示例,运行:

bash 复制代码
terraform plan   # 预览变更
terraform apply  # 创建或更新

3. 导入存量仪表板

如果你已经有大量手动创建的仪表板,可以用一行命令把它们纳入 Terraform 管理:

bash 复制代码
terraform import elasticstack_kibana_dashboard.my_dashboard <space_id>/<dashboard_id>

📖 完整的资源 Schema 和文档请查阅 Terraform Registry - Elastic Stack Provider


八、Roadmap:还在快速进化

Dashboards API 和 Terraform Provider 在 Elastic 9.4 中处于 Technical Preview(技术预览) 阶段,两者都在积极迭代中。

短期内即将补齐的能力:

领域 当前状态 路线图
API 面板覆盖 支持 12 种核心可视化 + Markdown + Controls Links、Machine Learning、Alerts、Log analysis、Vega、Maps 等面板即将加入
Terraform 面板类型 XY Chart、Metric、Markdown 等 Image、Links、SLO(服务级别目标)、Synthetics 等已支持 API 的类型将展开为原生 HCL
Terraform 过滤器 基础 Dashboard 查询 Dashboard-level 的 Filter Pills、Controls、Drilldowns 将在 HCL 中完整暴露

结语

Elastic 9.4 的 Dashboards API 和 Terraform 支持,本质上是在解决一个被忽视已久的问题:可视化不应该只是 UI 的附庸,它应该是一种可版本化、可审查、可自动化的基础设施资产。

panelsJSON 垃圾袋到类型化 Schema,从手动导出导入到 terraform plan 的清晰 Diff,从"谁改了生产仪表板"的悬案到 write_restricted 的访问控制------这套组合拳让 Kibana 仪表板第一次真正拥有了"基础设施即代码"的尊严。

如果你正在管理超过三个环境的 Elastic Stack,或者你的团队已经开始用 Terraform 管理索引和告警规则,那么现在就是把仪表板也纳入同一条流水线的最佳时机。

相关推荐
geshifei4 小时前
K8s 容器运行 UnixBench — 代理机器执行记录
云原生·容器·kubernetes
阿里云云原生8 小时前
可观测性的终局?从“面向数据”到“面向对象”,UModel 如何为 AI Agent 注入认知地图
云原生·agent
李南想做条咸鱼9 小时前
k8s集群容器访问域名第一次不通,第二次必通如何解决
云原生·容器·kubernetes
ん贤9 小时前
Volcano 详细笔记
云原生·volcano
前网易架构师-高司机11 小时前
带标注的交警识别数据集,可识别交警和非交警,5587张图,支持yolo,coco json,voc xml,文末有模型训练代码
xml·yolo·json·数据集·交警
●VON11 小时前
鸿蒙Flutter实战:放弃sqflite选纯Dart JSON文件存储
flutter·华为·json·harmonyos·鸿蒙
Elastic 中国社区官方博客12 小时前
Elasticsearch Agent Builder 黑客松(Hackathon)
大数据·人工智能·elasticsearch·搜索引擎·云原生·全文检索
MageGojo13 小时前
给起名工具接入八字起名 API:参数设计、JSON 示例和应用场景
json·apache
jieyucx13 小时前
Go 语言 JSON 序列化/反序列化:Tag 用法完全指南
开发语言·golang·json·序列化·tag