Kubernetes cluster と Grafana Cloud を連携してみる(前編) 〜Getting Started Guide の先へ〜

最近 Grafana Cloud を試したんですが、Getting Started Guide に従って Kuberneters cluster に Alloy をインストールして token の権限を修正するだけで様々なログやメトリクスが取れるようになるし、メトリクスの取得も高速だしでとても感動しました。
ただ、Grafana、Prometheus、OpenTelmetry Collector を浅く使ったことがあるだけの自分にとって、ブラックボックス過ぎてわからないことが多かったので知見についてまとめます。

1 エントリーにまとめようと思ったんですが力尽きたので、前編と後編に分けようと思います。

Getting Started Guide に従って連携してみる

Getting Started Guide にはいくつか選択肢があるんですが、Kubernetes 上で使うなら Kubernetes を選択するのが最も良いです。OpenTelemetry の手順に従うと Fleet Management の設定が追加されるのに生成される token には fleet-management:read の権限が付与されておらず出鼻をくじかれます。
最近は Kubernetes を選択すると「利用状況に応じて料金がかかるけど有効化しますか?」みたいなことが聞かれるようになったんですが、動作確認のために使う分にはよっぽどヘマをしない限り無料枠に収まるので気にせず Activate ボタンを押すと良いと思います。(スクリーンショット撮るの忘れた…)

このガイドでは、回答する内容にしたがって Alloy をインストールするためのコマンドや Terraform ファイルの内容が表示されるようになっています。

Namespace には Alloy のインストール先の namespace 名を指定します。そして Kubernetes サービスとして自身の利用しているサービスを選択します。自分の場合は AKS で使いたかったので AKS を選択しています。

利用する機能についてはデフォルトで Cost metrics (OpenCost) と Energy metrics (Keplr) が有効になっていますが、個人的には必要性を感じなかったので無効にしています。

今回は初めての利用なので新規作成します。これによって access policy とそれに紐付く token が作成されます。access policy の scope は自動で設定されますが、後ほど変更可能です。token の expiration date は後から変更できないようなので、変更したくなったら既存の token を削除して新規作成することになります。

Fleet Management はデフォルトで有効化されているんですが、とりあえず 1 クラスタで利用する分には不要そうなので無効化します。複数クラスタを管理する場合に重宝するかもしれません。

あとは表示されたコマンドを実行するだけで Alloy がインストールされ、クラスタのログやメトリクスが収集されて Grafana 上で見れるようになります。
2025-09-24 時点だと values の内容のみを抜粋すると次のような内容になりました。

cluster:
  name: my-cluster
destinations:
  - name: grafana-cloud-metrics
    type: prometheus
    url: $PROMETHEUS_REMOTE_WRITE_ENDPOINT
    auth:
      type: basic
      username: "$PROMETHEUS_USERNAME"
      password: $GRAFANA_CLOUD_TOKEN
  - name: grafana-cloud-logs
    type: loki
    url: $LOKI_ENDPOINT
    auth:
      type: basic
      username: "$LOKI_USERNAME"
      password: $GRAFANA_CLOUD_TOKEN
  - name: gc-otlp-endpoint
    type: otlp
    url: $OTLP_ENDPOINT
    protocol: http
    auth:
      type: basic
      username: "$GRAFANA_INSTANCE_ID"
      password: $GRAFANA_CLOUD_TOKEN
    metrics:
      enabled: true
    logs:
      enabled: true
    traces:
      enabled: true
clusterMetrics:
  enabled: true
  kube-state-metrics:
    podAnnotations:
      kubernetes.azure.com/set-kube-service-host-fqdn: "true"
clusterEvents:
  enabled: true
podLogs:
  enabled: true
applicationObservability:
  enabled: true
  receivers:
    otlp:
      grpc:
        enabled: true
        port: 4317
      http:
        enabled: true
        port: 4318
    zipkin:
      enabled: true
      port: 9411
integrations:
  alloy:
    instances:
      - name: alloy
        labelSelectors:
          app.kubernetes.io/name:
            - alloy-metrics
            - alloy-singleton
            - alloy-logs
            - alloy-receiver
alloy-metrics:
  enabled: true
  controller:
    podAnnotations:
      kubernetes.azure.com/set-kube-service-host-fqdn: "true"
alloy-singleton:
  enabled: true
  controller:
    podAnnotations:
      kubernetes.azure.com/set-kube-service-host-fqdn: "true"
alloy-logs:
  enabled: true
  controller:
    podAnnotations:
      kubernetes.azure.com/set-kube-service-host-fqdn: "true"
alloy-receiver:
  enabled: true
  alloy:
    extraPorts:
      - name: otlp-grpc
        port: 4317
        targetPort: 4317
        protocol: TCP
      - name: otlp-http
        port: 4318
        targetPort: 4318
        protocol: TCP
      - name: zipkin
        port: 9411
        targetPort: 9411
        protocol: TCP
  controller:
    podAnnotations:
      kubernetes.azure.com/set-kube-service-host-fqdn: "true"

Alloy とは何か?

Getting Started Guide ではしれっと Alloy が出てきましたが、Alloy は OpenTelemetry Collector みたいなものです。
Introductoin to Grafana Alloy には “Alloy is a flexible, high performance, vendor-neutral distribution of the OpenTelemetry Collector.” とありますが、ソースコードから設定ファイルの構文まで OpenTelemetry Collector とは全く異なるものなので、Grafana Labs 製独自 OpenTelemetry Collector と考えると良いでしょう。

元々 Grafana Agent の Flow mode というものがあり、Alloy はこれの後継にあたるようです。GitHub のスター数では Grafana Agent は OpenTelemetry に対して差が開く一方でしたが、Alloy は平衡状態にあるようです。

Star History Chart

Alloy と OpenTelemetry Collector の違いは grafana/agent#642 (comment) がよく説明してそうで、Alloy は Prometheus のエコシステムにフォーカスしているのに対して、OpenTelemetry Collector はスコープがもっと広くなっています。また、OpenTelemetry Collector は独自のコンポーネント含め、必要なコンポーネントのみから collector をビルドする仕組みを提供していますが1、Alloy で同様のことをしようと思うと fork するしかなさそうです。

とはいえ、Configure Kubernetes Monitoring with other methods にも次のように書いてあるように、特別な事情がなければ Grafana Cloud を利用する分には OpenTelemetry Collector ではなく Alloy を利用するのが無難でしょう。

You can send metrics, logs, events, cost data, and traces with the Grafana Kubernetes Monitoring Helm chart. This is the recommended approach for sending telemetry data to Grafana Cloud to monitor your Kubernetes fleet.

設定ファイルの構文が特殊ですが、Terraform に似た構文なので、Terraform に慣れている人であれば Alloy configuration syntax を読んで Grafana Alloy tutorials をやれば、helm chart でインストールされた設定ファイルを読み解くことは可能かと思います。

Helm chart の values を理解する

Helm chart を使って Alloy をインストールすると様々なコンポーネントがインストールされるんですが、何がインストールされるかについては Overview of Grafana Kubernetes Monitoring Helm chart を一読するのが良いです。
values によってどのようなカスタマイズができるかは Customize the Kubernetes Monitoring Helm chart を読むとイメージが掴めると思います。

あとは、次のコマンドの出力結果には要所要所に README へのリンクが記載されているので、values.yaml に記載されている内容の意味を知りたい場合に重宝します。colloector (alloy-*) の設定値に関してはリンク先が間違っていて Grafana Alloy collector reference が正しいので注意が必要ですが。

helm show values grafana/k8s-monitoring

このように、ドキュメントはそれなりに充実しているわけですが、config.alloy の内容を変更したい場合にどの値を変更するれば良いか理解するには template を読み解く必要があります。例えば、以下は helm install に --dry-run を付けて実行すると確認できる grafana-k8s-monitoring-alloy-logs ConfigMap の定義ですが、loki.process.pod_logs に処理を追加したい場合にどのように values.yaml を変更すべきか特定するのは至難の業です。

# Source: k8s-monitoring/templates/alloy-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: grafana-k8s-monitoring-alloy-logs
  namespace: alloy
data:
  config.alloy: |
    // Feature: Pod Logs
    declare "pod_logs" {
      argument "logs_destinations" {
        comment = "Must be a list of log destinations where collected logs should be forwarded to"
      }
    
      discovery.relabel "filtered_pods" {
        targets = discovery.kubernetes.pods.targets
        rule {
          source_labels = ["__meta_kubernetes_namespace"]
          action = "replace"
          target_label = "namespace"
        }
        rule {
          source_labels = ["__meta_kubernetes_pod_name"]
          action = "replace"
          target_label = "pod"
        }
        rule {
          source_labels = ["__meta_kubernetes_pod_container_name"]
          action = "replace"
          target_label = "container"
        }
        rule {
          source_labels = ["__meta_kubernetes_namespace", "__meta_kubernetes_pod_container_name"]
          separator = "/"
          action = "replace"
          replacement = "$1"
          target_label = "job"
        }
    
        // set the container runtime as a label
        rule {
          action = "replace"
          source_labels = ["__meta_kubernetes_pod_container_id"]
          regex = "^(\\S+):\\/\\/.+$"
          replacement = "$1"
          target_label = "tmp_container_runtime"
        }
    
        // make all labels on the pod available to the pipeline as labels,
        // they are omitted before write to loki via stage.label_keep unless explicitly set
        rule {
          action = "labelmap"
          regex = "__meta_kubernetes_pod_label_(.+)"
        }
    
        // make all annotations on the pod available to the pipeline as labels,
        // they are omitted before write to loki via stage.label_keep unless explicitly set
        rule {
          action = "labelmap"
          regex = "__meta_kubernetes_pod_annotation_(.+)"
        }
    
        // explicitly set service_name. if not set, loki will automatically try to populate a default.
        // see https://grafana.com/docs/loki/latest/get-started/labels/#default-labels-for-all-users
        //
        // choose the first value found from the following ordered list:
        // - pod.annotation[resource.opentelemetry.io/service.name]
        // - pod.label[app.kubernetes.io/name]
        // - k8s.pod.name
        // - k8s.container.name
        rule {
          action = "replace"
          source_labels = [
            "__meta_kubernetes_pod_annotation_resource_opentelemetry_io_service_name",
            "__meta_kubernetes_pod_label_app_kubernetes_io_name",
            "__meta_kubernetes_pod_container_name",
          ]
          separator = ";"
          regex = "^(?:;*)?([^;]+).*$"
          replacement = "$1"
          target_label = "service_name"
        }
    
        // explicitly set service_namespace.
        //
        // choose the first value found from the following ordered list:
        // - pod.annotation[resource.opentelemetry.io/service.namespace]
        // - pod.namespace
        rule {
          action = "replace"
          source_labels = [
            "__meta_kubernetes_pod_annotation_resource_opentelemetry_io_service_namespace",
            "namespace",
          ]
          separator = ";"
          regex = "^(?:;*)?([^;]+).*$"
          replacement = "$1"
          target_label = "service_namespace"
        }
    
        // explicitly set service_instance_id.
        //
        // choose the first value found from the following ordered list:
        // - pod.annotation[resource.opentelemetry.io/service.instance.id]
        // - concat([k8s.namespace.name, k8s.pod.name, k8s.container.name], '.')
        rule {
          source_labels = ["__meta_kubernetes_pod_annotation_resource_opentelemetry_io_service_instance_id"]
          target_label = "service_instance_id"
        }
        rule {
          source_labels = ["service_instance_id", "namespace", "pod", "container"]
          separator = "."
          regex = "^\\.([^.]+\\.[^.]+\\.[^.]+)$"
          target_label = "service_instance_id"
        }
    
        // set resource attributes
        rule {
          action = "labelmap"
          regex = "__meta_kubernetes_pod_annotation_resource_opentelemetry_io_(.+)"
        }
        rule {
          source_labels = ["__meta_kubernetes_pod_annotation_k8s_grafana_com_logs_job"]
          regex = "(.+)"
          target_label = "job"
        }
        rule {
          source_labels = ["__meta_kubernetes_pod_label_app_kubernetes_io_name"]
          regex = "(.+)"
          target_label = "app_kubernetes_io_name"
        }
      }
    
      discovery.kubernetes "pods" {
        role = "pod"
        selectors {
          role = "pod"
          field = "spec.nodeName=" + sys.env("HOSTNAME")
        }
      }
    
      discovery.relabel "filtered_pods_with_paths" {
        targets = discovery.relabel.filtered_pods.output
    
        rule {
          source_labels = ["__meta_kubernetes_pod_uid", "__meta_kubernetes_pod_container_name"]
          separator = "/"
          action = "replace"
          replacement = "/var/log/pods/*$1/*.log"
          target_label = "__path__"
        }
      }
    
      local.file_match "pod_logs" {
        path_targets = discovery.relabel.filtered_pods_with_paths.output
      }
    
      loki.source.file "pod_logs" {
        targets    = local.file_match.pod_logs.targets
        forward_to = [loki.process.pod_logs.receiver]
      }
    
      loki.process "pod_logs" {
        stage.match {
          selector = "{tmp_container_runtime=~\"containerd|cri-o\"}"
          // the cri processing stage extracts the following k/v pairs: log, stream, time, flags
          stage.cri {}
    
          // Set the extract flags and stream values as labels
          stage.labels {
            values = {
              flags  = "",
              stream  = "",
            }
          }
        }
    
        stage.match {
          selector = "{tmp_container_runtime=\"docker\"}"
          // the docker processing stage extracts the following k/v pairs: log, stream, time
          stage.docker {}
    
          // Set the extract stream value as a label
          stage.labels {
            values = {
              stream  = "",
            }
          }
        }
    
        // Drop the filename label, since it's not really useful in the context of Kubernetes, where we already have cluster,
        // namespace, pod, and container labels. Drop any structured metadata. Also drop the temporary
        // container runtime label as it is no longer needed.
        stage.label_drop {
          values = [
            "filename",
            "tmp_container_runtime",
          ]
        }
        stage.structured_metadata {
          values = {
            "k8s_pod_name" = "k8s_pod_name",
            "pod" = "pod",
            "service_instance_id" = "service_instance_id",
          }
        }
    
        // Only keep the labels that are defined in the `keepLabels` list.
        stage.label_keep {
          values = ["__tenant_id__","app_kubernetes_io_name","container","instance","job","level","namespace","service_name","service_namespace","deployment_environment","deployment_environment_name","k8s_namespace_name","k8s_deployment_name","k8s_statefulset_name","k8s_daemonset_name","k8s_cronjob_name","k8s_job_name","k8s_node_name"]
        }
    
        forward_to = argument.logs_destinations.value
      }
    }
    pod_logs "feature" {
      logs_destinations = [
        loki.write.grafana_cloud_logs.receiver,
      ]
    }
    
    
    
    
    // Destination: grafana-cloud-logs (loki)
    otelcol.exporter.loki "grafana_cloud_logs" {
      forward_to = [loki.write.grafana_cloud_logs.receiver]
    }
    
    loki.write "grafana_cloud_logs" {
      endpoint {
        url = "https://logs-prod-030.grafana.net/loki/api/v1/push"
        basic_auth {
          username = convert.nonsensitive(remote.kubernetes.secret.grafana_cloud_logs.data["username"])
          password = remote.kubernetes.secret.grafana_cloud_logs.data["password"]
        }
        tls_config {
          insecure_skip_verify = false
        }
        min_backoff_period = "500ms"
        max_backoff_period = "5m"
        max_backoff_retries = "10"
      }
      external_labels = {
        "cluster" = "my-cluster",
        "k8s_cluster_name" = "my-cluster",
      }
    }
    
    remote.kubernetes.secret "grafana_cloud_logs" {
      name      = "grafana-cloud-logs-grafana-k8s-monitoring"
      namespace = "alloy"
    }

Pod のログを収集している Alloy の設定だからと Grafana Alloy collector reference を確認してもそれらしい設定値は見つかりません。

“Source: k8s-monitoring/templates/alloy-config.yaml” と記載されていることから k8s-monitoring/templates/alloy-config.yaml から生成されていることは容易に想像が付くのですが、これを読み解くのはなかなか大変です。JetBrains 製の IDE であれば include 対象の定義にジャンプできるので、IDE を使って処理を追いかければ理解が捗るかもしれません。このご時世だと AI も駆使できるかもしれません。

他の手段としては愚直に grep ですね。今回の場合、loki.process "pod_logs" で grep すればテンプレートファイルがすぐ見つかり、このファイルの中で extraLogProcessingStages という value が使われていることがわかります。

$ git grep 'loki.process "pod_logs"' charts/k8s-monitoring ':!*/docs/*' ':!*/tests/*'
charts/k8s-monitoring/charts/feature-pod-logs/templates/_common_log_processing.alloy.tpl:2:loki.process "pod_logs" {

あとは extraLogProcessingStages でドキュメントを漁れば使用例が出てきます。

$ git grep extraLogProcessingStages charts/k8s-monitoring/docs/examples
charts/k8s-monitoring/docs/examples/extra-rules/README.md:16:*   `extraLogProcessingStages` - Rules that control log processing, such as modifying labels or modifying content.
charts/k8s-monitoring/docs/examples/extra-rules/README.md:98:  extraLogProcessingStages: |-
charts/k8s-monitoring/docs/examples/extra-rules/description.txt:12:*   `extraLogProcessingStages` - Rules that control log processing, such as modifying labels or modifying content.
charts/k8s-monitoring/docs/examples/extra-rules/values.yaml:77:  extraLogProcessingStages: |-
charts/k8s-monitoring/docs/examples/log-metrics/README.md:32:  extraLogProcessingStages: |-
charts/k8s-monitoring/docs/examples/log-metrics/values.yaml:17:  extraLogProcessingStages: |-

なお、これに関しては Customize the Kubernetes Monitoring Helm chart をよく読めば答えにたどり着くことはできて、Processing and labelingAdditional processing で紹介されています。

Grafana Cloud の token 管理

helm chart のインストール時には Grafana Cloud の token を指定する必要があります。この情報から Kubernetes 上に Secret が作成され、config.alloy ではこの Secret を利用するようになっています。
ところが、Alloy の管理を Argo CD などで行おうと思うと、token 情報を直接指定することは避けたいものです。

Destinations のドキュメントには各 type のドキュメントへのリンクがあり、どの type も secret.create に false を指定することで helm chart の Secret 作成処理をスキップできることがわかります。

destinations:
  - name: grafana-cloud-metrics
    type: prometheus
    url: $PROMETHEUS_REMOTE_WRITE_ENDPOINT
    auth:
      type: basic
    secret:
      create: false
  - name: grafana-cloud-logs
    type: loki
    url: $LOKI_ENDPOINT
    auth:
      type: basic
    secret:
      create: false
  - name: gc-otlp-endpoint
    type: otlp
    url: $OTLP_ENDPOINT
    protocol: http
    auth:
      type: basic
    secret:
      create: false
    metrics:
      enabled: true
    logs:
      enabled: true
    traces:
      enabled: true

あとは各組織の Secret の管理方法に従うと良いでしょう。

Azure Kubernetes Service における Secret 管理の例

例えば、Azure Kubernetes Service の場合、Key Vault の secret と Kubernetes の Secret を連携させることができます。
cf. Connect your Azure identity provider to the Azure Key Vault Secrets Store CSI Driver in Azure Kubernetes Service (AKS)

以下の Terraform ファイルはそのために必要な managed identity や federated identity credential を作成する例です。

terraform {
  required_version = ">=1.11"
  required_providers {
    azurekv = {
      source = "abicky/azurekv"
    }
  }
}

data "azurerm_subscription" "default" {}

resource "azurerm_key_vault" "this" {
  name                = random_string.name.result
  location            = var.location
  resource_group_name = var.resource_group_name
  tenant_id           = data.azurerm_subscription.default.tenant_id
  sku_name            = "standard"

  enable_rbac_authorization = true
}

resource "azurekv_secret" "this" {
  for_each = toset([
    "prometheus-username",
    "loki-user-name",
    "grafana-instance-id",
    "grafana-cloud-token",
  ])

  name             = each.key
  key_vault_id     = azurerm_key_vault.this.id
  value_wo         = "This value is manged outside of Terraform"
  value_wo_version = 1
}

resource "azurerm_user_assigned_identity" "alloy_secret_creator" {
  name                = "alloy-secret-creator"
  location            = var.location
  resource_group_name = var.resource_group_name
}

resource "azurerm_role_assignment" "alloy_secret_creator" {
  principal_id         = azurerm_user_assigned_identity.alloy_secret_creator.principal_id
  role_definition_name = "Key Vault Secrets User"
  scope                = azurerm_key_vault.this.id
}

resource "azurerm_federated_identity_credential" "alloy_secret_creator" {
  name                = "aks"
  resource_group_name = var.resource_group_name
  audience            = ["api://AzureADTokenExchange"]
  issuer              = var.kubernetes_cluster_oidc_issuer_url
  parent_id           = azurerm_user_assigned_identity.alloy_secret_creator.id
  subject             = "system:serviceaccount:alloy:${azurerm_user_assigned_identity.alloy_secret_creator.name}"
}

Key Vault の secret を Kubernetes の Secret として作成するには Key Vault の secret を mount する container が最低 1 つは存在していないといけないので、例えば次のように sleep infinity する Pod の Deployment を作成すると Kuberntes の Secret を維持できます。

apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: alloy-secret-provider
spec:
  provider: azure
  parameters:
    usePodIdentity: "false"
    clientID: "$USER_ASSIGNED_CLIENT_ID"
    keyvaultName: $KEYVAULT_NAME
    objects:  |
      array:
        - |
          objectName: prometheus-username
          objectType: secret
        - |
          objectName: loki-user-name
          objectType: secret
        - |
          objectName: grafana-instance-id
          objectType: secret
        - |
          objectName: grafana-cloud-token
          objectType: secret
    tenantId: "$TENANT_ID"
  # cf. https://learn.microsoft.com/en-us/azure/aks/csi-secrets-store-configuration-options#sync-mounted-content-with-a-kubernetes-secret
  secretObjects:
  - data:
    - key: username
      objectName: prometheus-username
    - key: password
      objectName: grafana-cloud-token
    secretName: grafana-cloud-metrics-grafana-k8s-monitoring
    type: Opaque
  - data:
    - key: username
      objectName: loki-username
    - key: password
      objectName: grafana-cloud-token
    secretName: grafana-cloud-logs-grafana-k8s-monitoring
    type: Opaque
  - data:
    - key: username
      objectName: grafana-instance-id
    - key: password
      objectName: grafana-cloud-token
    secretName: gc-otlp-endpoint-grafana-k8s-monitoring
    type: Opaque
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: alloy-secret-creator
  annotations:
    azure.workload.identity/client-id: $USER_ASSIGNED_CLIENT_ID
automountServiceAccountToken: true
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: alloy-secret-creator
  labels:
    app: alloy-secret-creator
spec:
  replicas: 1
  selector:
    matchLabels:
      app: alloy-secret-creator
  template:
    metadata:
      labels:
        app: alloy-secret-creator
        azure.workload.identity/use: "true"
    spec:
      containers:
      - name: main
        image: alpine
        command:
        - sleep
        - infinity
        volumeMounts:
        - name: secrets
          mountPath: "/mnt/secrets-store"
          readOnly: true
      serviceAccountName: alloy-secret-creator
      volumes:
      - name: secrets
        csi:
          driver: secrets-store.csi.k8s.io
          readOnly: true
          volumeAttributes:
            secretProviderClass: alloy-secret-provider