Câu chuyện kinh điển: tech lead làm xong dashboard “Error Overview” cực đẹp, share link cho team. Hai tháng sau cluster bị reinstall vì security patch, dashboard bay sạch. Hỏi tech lead, anh ấy đã chuyển team. Hỏi DevOps, “ai làm thì người đó giữ”. Result: cả team mò mẫm dựng lại từ đầu mất 2 ngày.

Vấn đề không phải Kibana. Vấn đề là dashboard sống trong index .kibana của Elasticsearch, không phải trong git. Một disk failure, một reinstall, một typo DELETE /.kibana là mất sạch. Bài này dạy bạn pattern dashboard-as-code: treat saved objects như source code, version-controlled, CI-validated, environment-promoted.

Mục tiêu bài:

  • Hiểu vì sao saved objects phải nằm trong git
  • Tổ chức repo cho NDJSON file
  • Viết GitHub Actions pipeline import vào ES staging và production
  • Validate NDJSON trước khi import (drift detection)
  • Pattern environment promotion: dev -> staging -> prod

Phần 1: Mental model

Kibana saved object là một document trong index .kibana_* của Elasticsearch. Mỗi dashboard, visualization, lens, alert rule, data view là một document có ID + type. Khi bạn click “Save” trong GUI, Kibana POST document vào index đó.

Format export NDJSON chính là dump các document đó, mỗi dòng là một saved object:

{"id":"f00bar","type":"dashboard","attributes":{...},"references":[...]}
{"id":"baz123","type":"lens","attributes":{...},"references":[...]}

References là dependency graph: dashboard reference lens, lens reference data view, alert rule reference connector. Khi import phải đảm bảo order đúng (data view trước lens trước dashboard).

Lý do treat như code:

  1. Versioned: thấy ai sửa cái gì, khi nào, có rollback
  2. Reproducible: rebuild cluster mới = git clone + import script
  3. Promoteable: dev -> staging -> prod theo CI pipeline
  4. Reviewable: PR diff thấy được dashboard JSON thay đổi gì
  5. Backupable: git push = backup nằm trên GitHub, không phụ thuộc ES snapshot

Phần 2: Repo layout

Một layout đã chạy production cho 30+ dashboard:

infra-kibana/
├── README.md
├── .github/workflows/
│   ├── validate.yml          # CI lint, schema check
│   └── deploy.yml             # Deploy on merge to main
├── scripts/
│   ├── export.sh              # Helper: export 1 dashboard
│   ├── import.sh              # Helper: import 1 NDJSON file
│   └── diff.sh                # Compare local vs cluster
├── dashboards/
│   ├── application/
│   │   ├── error-overview.ndjson
│   │   ├── api-latency.ndjson
│   │   └── deployment-tracker.ndjson
│   ├── infrastructure/
│   │   ├── disk-usage.ndjson
│   │   ├── pod-restarts.ndjson
│   │   └── ingress-traffic.ndjson
│   └── security/
│       └── auth-failures.ndjson
├── data-views/
│   ├── app-logs.ndjson
│   ├── metrics.ndjson
│   └── audit-logs.ndjson
├── alert-rules/
│   ├── error-burst.ndjson
│   ├── disk-warning.ndjson
│   └── auth-anomaly.ndjson
└── connectors/
    └── slack.ndjson

Phân nhóm theo domain (application, infrastructure, security) chứ không theo loại object. Lý do: một dashboard “Application Error Overview” cần cả data view + lens + alert rule cùng nằm gần nhau cho dễ navigate.

Một file NDJSON nên gom một dashboard và toàn bộ dependency của nó: lens, data view, search liên quan. Khi import cluster mới, một file là một deliverable hoàn chỉnh.

Phần 3: Export script

scripts/export.sh:

#!/usr/bin/env bash
set -euo pipefail

KIBANA_URL="${KIBANA_URL:-http://localhost:5601}"
API_KEY="${KIBANA_API_KEY}"
DASHBOARD_ID="$1"
OUTPUT_FILE="$2"

if [ -z "$DASHBOARD_ID" ] || [ -z "$OUTPUT_FILE" ]; then
  echo "Usage: $0 <dashboard-id> <output.ndjson>"
  exit 1
fi

curl -sS \
  -H "Authorization: ApiKey ${API_KEY}" \
  -H "kbn-xsrf: true" \
  -H "Content-Type: application/json" \
  -X POST "${KIBANA_URL}/api/saved_objects/_export" \
  -d "{
    \"objects\": [{\"type\":\"dashboard\",\"id\":\"${DASHBOARD_ID}\"}],
    \"includeReferencesDeep\": true
  }" \
  -o "$OUTPUT_FILE"

echo "Exported to: $OUTPUT_FILE"
wc -l "$OUTPUT_FILE"

Quan trọng nhất là includeReferencesDeep: true. Không có flag này, import sẽ fail vì missing reference.

Sau khi export, mở file ra. NDJSON sẽ có một số field volatile cần normalize trước khi commit:

# Sort keys, strip volatile fields
jq -c 'del(.updated_at, .created_at, .version)' \
  export.ndjson > dashboards/application/error-overview.ndjson

Strip updated_at, created_at, version để diff trong git không noise. Khi import lại, Kibana sẽ tự gen field này.

Phần 4: Import script

scripts/import.sh:

#!/usr/bin/env bash
set -euo pipefail

KIBANA_URL="${KIBANA_URL:-http://localhost:5601}"
API_KEY="${KIBANA_API_KEY}"
NDJSON_FILE="$1"
OVERWRITE="${2:-false}"

if [ -z "$NDJSON_FILE" ]; then
  echo "Usage: $0 <file.ndjson> [overwrite]"
  exit 1
fi

ENDPOINT="${KIBANA_URL}/api/saved_objects/_import"
if [ "$OVERWRITE" = "true" ]; then
  ENDPOINT="${ENDPOINT}?overwrite=true"
fi

RESPONSE=$(curl -sS \
  -H "Authorization: ApiKey ${API_KEY}" \
  -H "kbn-xsrf: true" \
  -X POST "${ENDPOINT}" \
  --form file=@"${NDJSON_FILE}")

SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
COUNT=$(echo "$RESPONSE" | jq -r '.successCount')

if [ "$SUCCESS" != "true" ]; then
  echo "Import FAILED: $RESPONSE" >&2
  exit 1
fi

echo "Imported ${COUNT} objects from ${NDJSON_FILE}"

Lưu ý: response có success: true nhưng vẫn có thể có successResults chứa warning. Production CI nên parse cả successResults và alert nếu có conflict.

Phần 5: GitHub Actions pipeline

.github/workflows/validate.yml:

name: Validate Kibana objects

on:
  pull_request:
    paths:
      - 'dashboards/**'
      - 'data-views/**'
      - 'alert-rules/**'
      - 'connectors/**'

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Check JSONL syntax
        run: |
          for f in $(find dashboards data-views alert-rules -name '*.ndjson'); do
            while IFS= read -r line; do
              echo "$line" | jq empty || { echo "Invalid JSON in $f"; exit 1; }
            done < "$f"
          done

      - name: Verify references exist
        run: |
          python3 scripts/check-references.py

.github/workflows/deploy.yml:

name: Deploy to Kibana

on:
  push:
    branches: [main]
    paths:
      - 'dashboards/**'
      - 'data-views/**'
      - 'alert-rules/**'
      - 'connectors/**'

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4

      - name: Import to staging
        env:
          KIBANA_URL: ${{ secrets.KIBANA_URL_STAGING }}
          KIBANA_API_KEY: ${{ secrets.KIBANA_API_KEY_STAGING }}
        run: |
          for f in $(find data-views dashboards alert-rules connectors -name '*.ndjson'); do
            ./scripts/import.sh "$f" true
          done

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://kibana.example.com
    steps:
      - uses: actions/checkout@v4

      - name: Import to production
        env:
          KIBANA_URL: ${{ secrets.KIBANA_URL_PROD }}
          KIBANA_API_KEY: ${{ secrets.KIBANA_API_KEY_PROD }}
        run: |
          for f in $(find data-views dashboards alert-rules connectors -name '*.ndjson'); do
            ./scripts/import.sh "$f" true
          done

GitHub environment: production cho phép manual approval trước khi deploy. Đây là gate quan trọng tránh bug ở staging dashboard leak sang production.

Import order quan trọng: data-views -> dashboards -> alert-rules. Lý do: alert rule có thể reference dashboard ID, dashboard reference data view ID. Sai order = import fail.

Phần 6: Drift detection

Vấn đề kinh điển: dev sửa dashboard trên UI staging mà không commit về git. Sau pipeline deploy, dashboard bị overwrite về version cũ. Cãi nhau bùng nổ.

Pattern drift detection: chạy job định kỳ so sánh local NDJSON với cluster state:

scripts/diff.sh:

#!/usr/bin/env bash
set -euo pipefail

KIBANA_URL="$1"
API_KEY="$2"
LOCAL_DIR="dashboards"

TEMP_DIR=$(mktemp -d)

for local_file in $(find "$LOCAL_DIR" -name '*.ndjson'); do
  DASHBOARD_ID=$(jq -r 'select(.type=="dashboard") | .id' "$local_file" | head -1)

  if [ -z "$DASHBOARD_ID" ]; then
    continue
  fi

  REMOTE_FILE="${TEMP_DIR}/$(basename "$local_file")"

  curl -sS \
    -H "Authorization: ApiKey ${API_KEY}" \
    -H "kbn-xsrf: true" \
    -H "Content-Type: application/json" \
    -X POST "${KIBANA_URL}/api/saved_objects/_export" \
    -d "{\"objects\":[{\"type\":\"dashboard\",\"id\":\"${DASHBOARD_ID}\"}],\"includeReferencesDeep\":true}" \
    | jq -c 'del(.updated_at, .created_at, .version)' \
    > "$REMOTE_FILE"

  if ! diff -q <(sort "$local_file") <(sort "$REMOTE_FILE") > /dev/null; then
    echo "DRIFT detected in $local_file"
    diff <(sort "$local_file") <(sort "$REMOTE_FILE") | head -20
  fi
done

Run hourly hoặc daily, alert Slack nếu có drift. Một incident tại dự án internal trước đây bắt được dev edit dashboard trên prod lúc 11 giờ đêm để fix demo, drift detector ping Slack 12 phút sau, ai cũng biết và team kịp rollback.

Phần 7: Pitfall thực tế

Pitfall 1: ID conflict giữa staging và prod

Dev tạo dashboard mới trên staging với UUID auto-generated, ví dụ 8f7e6d5c-.... Export, commit, deploy prod. Bug: dashboard ID trùng với một dashboard production khác đã tồn tại với cùng UUID.

Fix: trước khi commit dashboard mới, dùng UUID rõ ràng và stable. Cách 1: tạo dashboard từ API thay vì GUI để control ID:

curl -X POST "${KIBANA_URL}/api/saved_objects/dashboard/error-overview-prod-stable" \
  -H "kbn-xsrf: true" \
  -H "Authorization: ApiKey ${API_KEY}" \
  -d '{...}'

Cách 2: sửa ID trong NDJSON sau khi export, dùng slug có ý nghĩa.

Pitfall 2: Reference dangling sau khi merge

Hai dev tạo lens cùng ID trên hai branch khác nhau. Merge tới nơi, một lens overwrite lens kia, dashboard reference tới lens cũ bị broken.

Fix: validate reference trong CI. Script Python kiểm tra mọi reference ID có tồn tại trong cùng repo:

import json
import sys
from pathlib import Path

all_ids = set()
all_refs = set()

for ndjson in Path('.').rglob('*.ndjson'):
    with ndjson.open() as f:
        for line in f:
            obj = json.loads(line)
            all_ids.add((obj['type'], obj['id']))
            for ref in obj.get('references', []):
                all_refs.add((ref['type'], ref['id']))

missing = all_refs - all_ids
if missing:
    print(f"Missing references: {missing}")
    sys.exit(1)

Pitfall 3: API key permission không đủ

CI deploy báo Unauthorized mặc dù API key đã set. Lý do: key chỉ có quyền read trên .kibana thay vì manage_saved_objects.

Fix: tạo API key với role descriptor đủ rộng:

{
  "kibana_admin_ci": {
    "cluster": ["monitor"],
    "indices": [{"names": [".kibana*"], "privileges": ["all"]}],
    "applications": [{
      "application": "kibana-.kibana",
      "privileges": ["all"],
      "resources": ["*"]
    }]
  }
}

Tách riêng key cho CI khỏi key của dev daily use. Rotate riêng.

Phần 8: Production checklist

Trước khi declare workflow này “ready”:

  • Repo có README giải thích quy trình
  • Script export/import có error handling
  • CI validate JSON syntax + reference integrity
  • Deploy gate qua manual approval cho production
  • Drift detection chạy định kỳ
  • API key có scope hẹp, expiration 90 ngày
  • Secret lưu trong GitHub Actions Secrets, không hardcode
  • Rollback plan: revert PR + redeploy
  • Audit log: ai merge PR nào, khi nào

Cheatsheet

ViệcCommand
Export 1 dashboard./scripts/export.sh <id> file.ndjson
Import overwrite./scripts/import.sh file.ndjson true
Strip volatile fieldsjq -c 'del(.updated_at, .created_at, .version)'
List all dashboardscurl -H "Authorization: ApiKey ${KEY}" "${URL}/api/saved_objects/_find?type=dashboard"
Bulk delete by typecurl -X DELETE "${URL}/api/saved_objects/_bulk_delete" -d '[...]'
Validate NDJSON syntaxwhile read line; do echo "$line" | jq empty; done < file.ndjson
Import orderdata-views -> dashboards -> alert-rules -> connectors

Lời kết

Treat saved objects như source code không phải là over-engineering. Đó là điều tối thiểu nếu bạn không muốn mất ngủ mỗi lần ES upgrade hay disk failure. Mỗi dashboard là một deliverable, và deliverable phải versioned.

Bài tiếp theo trong series Kibana từ A đến Z sẽ đi sâu vào Kibana API: cách bulk tạo user, mass update dashboard, viết script automation nhiều bước, và pattern wrap API thành CLI nội bộ cho team. Một khi đã có config-as-code, bước kế tiếp là tự động hoá mọi tác vụ lặp lại.