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 は平衡状態にあるようです。
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 labeling の Additional 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