「OpenTelemetry」と「Grafana」でプロダクトチームが自分のサービスを監視できる基盤を作ろう
第5回の今回は、「OpenTelemetry」「Prometheus」「Loki」「Grafana」によるオブザーバビリティをKubernetesに構築し、ゴールデンパスに計装を組み込む方法について解説します。
6:30
はじめに
ここまで、全4回に渡ってKubernetes上に最小限のプラットフォームを構築し、共有ゲートウェイによるルーティングと認可、開発者ポータルBackstageによるゴールデンパスの提供、そしてプロダクトチームからフィードバックを受け取る仕組みまでを整えてきました。プロダクトチームはBackstageのテンプレートから新しいサービスを作り始め、決められた手順でKubernetesにデプロイできるようになっています。
さらに、前回追加したフィードバックの仕組みにより、プラットフォームチームは提供した機能がプロダクトチームにどう受け止められているかを知ることもできるようになりました。
しかし、デプロイした後のことを考えると、まだ大きな課題が残っています。それが「監視」です。現状、プロダクトチームが自分のサービスの状態を知る手段はコンテナのログをkubectl logsで覗く程度に限られており、「サービスは今どのくらいのリクエストをさばいているのか」「エラーが増えていないか、その原因は何か」といった問いに、すぐには答えられません。
本記事ではこの課題を解決すべく、プラットフォームチーム側でオブザーバビリティの基盤を用意し、さらにプロダクトチームへ計装(アプリケーションに観測データを出力させる設定)をあらかじめ組み込んだゴールデンパステンプレートを提供します。これにより、プロダクトチームはテンプレートから作ったサービスをデプロイするだけで、計装のコードを自分で書くことなく、自分のサービスのメトリクスとログを見られるようになります。
まずオブザーバビリティの基本的な考え方を整理し、続いて観測データを収集・保存・可視化する基盤(「OpenTelemetry Collector」「Prometheus」「Loki」「Grafana」)をKubernetesにデプロイします。その後、第3回のゴールデンパステンプレートにOpenTelemetryによる計装を組み込みます。最後に、テンプレートから作ったサービスを動かし、メトリクスとログを実際に確認します。
オブザーバビリティとは
システムが正常に動いているのか、問題が起きたときに何が原因なのかを、外部から取得できる情報だけで把握できる能力を「オブザーバビリティ(可観測性)」と呼びます。もともとは制御工学の用語で、システムの外部から得られる出力を手がかりに内部状態をどれだけ推定できるかを表す概念です。ソフトウェアの文脈では、アプリケーションが出力するテレメトリ(観測データ)をもとに、システム内部で起きていることを把握できる状態を指します。
3つのシグナルと可視化
オブザーバビリティを支えるテレメトリは、伝統的に「メトリクス」「ログ」「トレース」の3種類に分類されます。後述するOpenTelemetryではテレメトリのカテゴリを「シグナル」と呼ぶため、本記事でもこの3つをまとめて「3つのシグナル」と呼びます。
これらのシグナルは集めるだけでは十分ではなく、集めたデータを保存し、人が読める形で表示して初めて、システムの状態を把握できるようになります。この表示を担うのがGrafanaのような可視化ツールで、全体としては「メトリクス・ログ・トレースを集める」→「保存する」→「可視化して見る」という関係になります。
3つのシグナルはデータの形が異なり、答えられる問いもそれぞれ違います。以下にそれぞれの特徴をまとめます。
| メトリクス | ログ | トレース | |
| データの形 | 一定間隔で計測される数値の時系列 | 時刻付きで1件ずつ記録されるイベント | サービスを横断する スパン*の連なり |
| データの例 | 1秒あたりのリクエスト数、エラーの割合 | アクセスログ、アプリケーションの出力 | 1本のリクエストが通過した処理区間の記録 |
| 答えられる問い | 異常があるか・どの程度か | その1件で何が起きたか | どのサービスが原因か |
*:「スパン」とは、1本のリクエストが複数サービスをたどる際のそれぞれの処理区間のこと。スパンの連なりが1つのトレースを構成し、これによって、どの区間で何が起こったか、サービスをまたいで追跡できる。
なお、本記事では伝統的な3つのシグナルに絞って扱いますが、OpenTelemetryが定義するシグナルは、この3つだけではありません。サービス間で文脈情報を受け渡す「Baggage」は保存や可視化の対象ではなく他のシグナルを補うものですが、これもシグナルの1つとされています。
ほかにも、コードレベルの資源消費を記録する「Profiles」の追加も進んでいます。最新の一覧は公式ドキュメントを参照してください。
本記事で扱う範囲
本記事では、この3つのシグナルのうちメトリクスとログを扱います。運用の現場で状態把握の起点になりやすいのがこの2つであり、いずれもアプリケーション自身が計装して出力することで、そのサービスに固有の状態まで把握できるようになるからです。
今回はもう1つのトレースは扱いませんが、今回実装するオブザーバビリティの仕組みをそのまま使って後から追加できます。これらのシグナルを収集するための計装には、特定の監視製品に依存しない業界標準であるOpenTelemetry を用います。
また、テレメトリを見るのはプロダクトチームだけではありません。プラットフォームチームも、自分たちが提供する基盤の状態を知るためにテレメトリを参照します。両者の関心は次のように分かれます。
| 見る人 | 何を知りたいか | 主に見る対象 | 本記事 での扱い |
| プロダクトチーム | 自分のサービスが正常か。異常なら原因は何か | 自分のサービスが出すメトリクスとログ | 扱う |
| プラットフォームチーム | 基盤が正常か。どのテナントがどれだけ使っているか | Collectorや保存先などの基盤コンポーネント、クラスタ全体 | 扱わない |
本記事では、プロダクトチームが自分のサービスを自分で観測できるようにすることに絞ります。プラットフォームチーム側の観測に必要なクラスタやノードのメトリクス収集は、今回の最小構成では有効化していません。
OpenTelemetryとは
OpenTelemetryは、アプリケーションからテレメトリを生成し、収集して送り出すための、特定のベンダーに依存しないオープンソースの仕様とツール群です。図 2における「収集」の部分を担当します。
プロジェクトとしては、2019年にCNCF(Cloud Native Computing Foundation)に加わり、その後、2026年5月にCNCFのGraduatedプロジェクトとなっています。GraduatedはCNCFの成熟度で最上位にあたり、KubernetesやPrometheusと同じ位置づけです。
なぜOpenTelemetryを使うのか
プラットフォームエンジニアリングでは、プロダクトチームが毎回悩む部分をプラットフォームチームが共通の型として用意して提供し、認知負荷を軽減することを重視します。本連載でも、第2回では共有ゲートウェイと標準化されたルーティング・認可の定義を、第3回ではアプリケーション開発の雛形となるゴールデンパステンプレートを提供してきました。「計装をどう書き、どこへ送り、どこで受けるか」も、放っておけばサービスごとにばらばらになりがちな、まさにこうした共通化の対象です。
ただし、メトリクスやログを集める方法はOpenTelemetryだけではありません。例えば、メトリクスであればPrometheusがアプリケーションのエンドポイントを直接読みに行く方法もありますし、ログであればコンテナが標準出力に書き出したものを収集エージェントで拾う方法もあります。いずれも広く使われている方法ですが、シグナルごとに別々の仕組みであり、計装の書き方まで含めて揃えようとすると、プラットフォームチームで規約を決めることになります。
ここで、共通の型を用意して配ること自体は、OpenTelemetryを採用せずにプラットフォームチームで規約を決めても実現できます。分かれ目は、その型の中身を誰が用意し、維持するかです。プラットフォームチームで決める場合は、何をどう名付けるかを設計し、言語が増えるたびに書き方を用意し、シグナルごとに別の仕組みを選び、それらを維持し続けることになります。
OpenTelemetryを採用するのは、この「型の中身」が業界標準としてすでに用意されているからです。OpenTelemetryは、この計装のやり方を次の3つのレイヤーで標準化します。
- 計装API(Instrumentation API):アプリケーションがテレメトリを出力するために呼び出すAPIです。どの言語でも共通の考え方で計装でき、特定の監視製品のライブラリに縛られません。
- プロトコル(OTLP):計装したデータを送信するための共通プロトコルです。アプリケーションから収集基盤までを、決まった形式でやり取りします。
- コレクター(Collector):テレメトリを受け取り、加工し、保存先へ振り分ける収集の入り口です。アプリケーションはCollectorに送ることだけを知っていればよく、実際の保存先はCollector側で決められます。
プラットフォームチームで規約を決める場合と比べると、違いは次の3点にあらわれます。
1つ目は、3つのシグナルを1つの作法で扱えることです。データの名前や意味の付け方を定めたセマンティック規約も含まれるため、言語やサービスが違っても同じ名前でデータが揃います。
2つ目は、規約だけでなく実装まで用意されていることです。後述しますが、ミドルウェアを1行追加するだけで、HTTPリクエストの件数や所要時間がルートやステータスコード別に収集されます。
3つ目は、前述のCollectorが、そのままプラットフォームチームの制御点になることです。テレメトリはすべてここを通るため、保存先の変更に加え、属性の付与や大量のデータの間引きもまとめて行えます。いずれもアプリケーションを1行も変更せずに適用できます。今回はメトリクスをPrometheus、ログをLokiに保存しますが、あとから保存先を差し替えたり、トレースの保存先を追加したりできるのも、この仕組みによるものです。
このように計装の中身とその受け口が標準として決まっているからこそ、そのままゴールデンパステンプレートに組み込めます。プラットフォームチームは規約をゼロから設計して維持する必要がなく、プロダクトチームはテンプレートを利用するだけで標準化された計装を最初から備えたアプリケーションを実装できます。
観測基盤を構築する
ここからは構築作業に移っていきます。まずはプラットフォームチームの作業です。プロダクトチームのアプリケーションが送ってくるテレメトリを受け取り、保存し、可視化するための基盤を用意します。本記事では次の4つのコンポーネントを、いずれもHelmで導入します。
- OpenTelemetry Collector:アプリケーションからOTLPでテレメトリを受け取り、種類ごとに保存先へ振り分ける収集の入り口
- Prometheus:メトリクスの保存先
- Loki:ログの保存先
- Grafana:メトリクスとログをまとめて表示する画面
これらは専用のNamespace observability にまとめて配置します。データの流れは図4のとおりで、アプリケーションはCollectorにだけ送信し、CollectorがメトリクスをPrometheusへ、ログをLokiへ振り分けます。プロダクトチームから見れば「Collectorに送る」という1点だけを知っていればよく、保存先が何であるかを意識する必要はありません。

なお、オブザーバビリティのコンポーネントをより本格的に運用するためのKubernetes Operator(Prometheus Operatorなど)も存在しますが、本記事では構成を単純に保つため、Helmで直接導入する方法を採ります。
Namespaceの作成とHelmリポジトリの追加
kubectl create namespace observability
helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo add grafana-community https://grafana-community.github.io/helm-charts
helm repo updatePrometheusとLokiの導入
以降の導入では、設定をコマンドに直接並べる代わりに、コンポーネントごとに設定ファイル(values.yaml)を用意し、helm upgrade --install で適用します。これは未導入ならインストール、導入済みなら設定を反映して更新するコマンドで、途中で設定を修正して同じコマンドを再実行しても安全です。設定をファイルに分けておくことで、内容を見返しやすく、リポジトリでの管理もしやすくなります。
まず、メトリクスの保存先となるPrometheusを導入します。Collectorからメトリクスを受け取れるように、リモートライトの受信を有効にします。なお、今回は最小構成として周辺コンポーネントは無効化し、Prometheusサーバー本体のみを起動します。
# observability/prometheus-values.yaml
server:
extraFlags:
- web.enable-remote-write-receiver
# 周辺コンポーネントは無効化
alertmanager:
enabled: false
prometheus-node-exporter:
enabled: false
prometheus-pushgateway:
enabled: false
kube-state-metrics:
enabled: falsehelm upgrade --install prometheus prometheus-community/prometheus \
--namespace observability \
--values observability/prometheus-values.yaml続いて、ログの保存先となるLokiを導入します。Lokiには規模に応じて3つの構成モードがありますが、シンプルスケーラブル構成とマイクロサービス構成はオブジェクトストレージが必須です。そのため、今回はローカルのファイルシステムで動かせるモノリシック構成を選びます。なお、Prometheus同様に周辺コンポーネントは無効化します。
# observability/loki-values.yaml
deploymentMode: Monolithic
loki:
auth_enabled: false
commonConfig:
replication_factor: 1
storage:
type: filesystem
# スキーマ定義は必須
schemaConfig:
configs:
- from: "2024-01-01"
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
# OTLPで受け取った構造化メタデータを許可
limits_config:
allow_structured_metadata: true
# モノリシック構成を1レプリカで起動
singleBinary:
replicas: 1
# スケーラブル構成用のコンポーネントは起動しない
backend:
replicas: 0
read:
replicas: 0
write:
replicas: 0
# 周辺コンポーネントは無効化
chunksCache:
enabled: false
resultsCache:
enabled: false
lokiCanary:
enabled: false
test:
enabled: falsehelm upgrade --install loki grafana-community/loki \
--namespace observability \
--values observability/loki-values.yamlLokiは3.0以降、OpenTelemetry形式のログをOTLPで直接受け取れるようになっており、後述するCollectorからはこのエンドポイントに送信します。上記の allow_structured_metadata の設定は、そのOTLP経由のログを受け付けるために必要です。
なお、なお、このチャートはLoki本体(loki-0)に加えて、受け口となるゲートウェイ(loki-gateway)も一緒に配置します。そのため、クラスタ内からログを送る際の宛先はLoki本体ではなく、このゲートウェイ(loki-gateway.observability.svc)になります。後続のCollectorやGrafanaの設定でも、この宛先を使います。
OpenTelemetry Collectorの導入と設定
次はCollectorを導入します。CollectorはアプリケーションからOTLPで受け取ったメトリクスとログを、それぞれPrometheusとLokiに振り分けます。service.pipelinesでそれぞれの振り分けを定義します。
なお、Collectorのチャートでは、使用するCollectorのディストリビューションを明示的に選ぶ必要があります。Collectorには最小構成のcore版、多数のコンポーネントを同梱したcontrib版、Kubernetes監視に特化したk8s版などがあり、それぞれ同梱するコンポーネントが異なります。今回はメトリクスをPrometheusへ送るprometheusremotewriteエクスポーターを使うため、それを同梱するcontrib版を指定します。
# observability/collector-values.yaml
mode: deployment
# contrib版ディストリビューションを指定
image:
repository: otel/opentelemetry-collector-contrib
command:
name: otelcol-contrib
# リソース制限を設定
resources:
limits:
memory: 512Mi
requests:
cpu: 100m
memory: 256Mi
config:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch: {}
exporters:
# メトリクスをPrometheusのwriteエンドポイントへ送る
prometheusremotewrite:
endpoint: http://prometheus-server.observability.svc/api/v1/write
# ログをLokiのネイティブOTLPエンドポイントへ送る
otlphttp/logs:
endpoint: http://loki-gateway.observability.svc/otlp
service:
pipelines:
metrics:
receivers: [otlp]
processors: [batch]
exporters: [prometheusremotewrite]
logs:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/logs]helm upgrade --install otel-collector open-telemetry/opentelemetry-collector \
--namespace observability \
--values observability/collector-values.yamlこれで、アプリケーションが otel-collector-opentelemetry-collector.observability.svc:4317 にOTLPで送信すれば、メトリクスとログがそれぞれの保存先に届くようになります。
Grafanaの導入とデータソースの設定
最後に、メトリクスとログを可視化するためのGrafanaを導入します。チャートにはPrometheusとLokiをデータソースとして登録しておきます。
# observability/grafana-values.yaml
datasources:
datasources.yaml:
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
url: http://prometheus-server.observability.svc
access: proxy
- name: Loki
type: loki
url: http://loki-gateway.observability.svc
access: proxyhelm upgrade --install grafana grafana-community/grafana \
--namespace observability \
--values observability/grafana-values.yamlこれでGrafanaのPodが起動し、PrometheusとLokiがデータソースとして登録された状態になります。ただし、この時点ではクラスタ外からGrafanaの画面にアクセスする経路がないため、ブラウザから開けるように設定を追加します。
Grafanaを共有ゲートウェイ経由で公開する
第2回で構築した共有ゲートウェイを使えば、KeycloakやBackstage、echo APIと同じように、grafana.10.50.1.5.nip.io のようなホスト名でブラウザからアクセスできるようになります(10.50.1.5 の部分は環境のホストのIPアドレスに読み替えてください)。
仕組みは第2回と同じで、共有ゲートウェイにリスナーを追加し、GrafanaのServiceに向けたHTTPRouteを作り、ゲートウェイ全体に掛かっているdeny-allのAuthPolicyを、このルートだけ許可するように上書きします。
なお、Grafanaはそれ自身が認証機能を持つため、ゲートウェイ側では認証をかけず、GrafanaとKeycloakを直接連携させてシングルサインオンにします。Keycloak側のクライアント作成とユーザー設定の手順は、第4回でBackstage用のクライアントを作成したときと同様です。ここでも第4回で作成したRealm platform を引き続き利用します。
まずは、以下の設定でGrafana用のクライアントを作成します。
- Client ID: grafana
- Client authenticationを有効化
- Standard flowにチェック
- Valid redirect URIs:
http://grafana.10.50.1.5.nip.io/login/generic_oauth - Web origins:
http://grafana.10.50.1.5.nip.io
その後、Groups > Create group から GrafanaAdmin というグループを作成し、前回作成したKeycloakユーザーに割り当てます。
最後に、ユーザーの所属グループがトークンに含まれるように、grafanaクライアントにマッパーを追加します。Keycloakは既定ではグループをトークンに含めないため、この設定がないとGrafana側でグループを判定できず、すべてのユーザーがViewerになります。
なお、第4回のBackstageでは、Keycloakの同期プラグインを使って管理APIから直接ユーザーとグループを取得していたため、この設定は不要でした。今回のGrafanaはログイン時に受け取るIDトークンからグループを読み取るため、トークンに含める設定が必要になります。
Clients > grafana > Client scopes > grafana-dedicated を開き、Configure a new mapper から「Group Membership」を選んで、次の設定で作成します。
- Name: groups
- Token Claim Name: groups
以上でKeycloakの設定は完了です。続いてKubernetes側の設定を行っていきます。
まず、共有ゲートウェイの listeners に、Grafana用のリスナーを追加します。既存のリスナーと同じく、observability ネームスペースからのルートだけを許可します。
# shared-gateway/shared-gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: shared-gateway
namespace: shared-gateway
spec:
gatewayClassName: envoy-gateway
listeners:
# Keycloak用リスナー
- name: keycloak
protocol: HTTP
port: 80
hostname: keycloak.10.50.1.5.nip.io
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
kubernetes.io/metadata.name: keycloak
kinds:
- kind: HTTPRoute
# backstage用リスナー
- name: backstage
protocol: HTTP
port: 80
hostname: "backstage.10.50.1.5.nip.io"
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
kubernetes.io/metadata.name: backstage
kinds:
- kind: HTTPRoute
# Grafana用リスナー(今回追加)
- name: grafana
protocol: HTTP
port: 80
hostname: "grafana.10.50.1.5.nip.io"
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
kubernetes.io/metadata.name: observability
kinds:
- kind: HTTPRoute
# echo API用リスナー(テナント)
- name: echo
protocol: HTTP
port: 80
hostname: "echo.10.50.1.5.nip.io"
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
kubernetes.io/metadata.name: echo
kinds:
- kind: HTTPRoute次に、GrafanaのServiceに向けたHTTPRouteを作成します。sectionName で、先ほど追加したリスナーに紐付けます。
# observability/grafana-httproute.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: grafana-route
namespace: observability
spec:
hostnames:
- "grafana.10.50.1.5.nip.io"
parentRefs:
- name: shared-gateway
namespace: shared-gateway
sectionName: grafana
rules:
- name: root
matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: grafana
port: 80
kind: Service
weight: 1最後に、ゲートウェイ全体に掛かっているdeny-allを、このルートだけ許可するようにAuthPolicyで上書きします。
# observability/grafana-authpolicy.yaml
apiVersion: kuadrant.io/v1
kind: AuthPolicy
metadata:
name: grafana-authpolicy
namespace: observability
spec:
targetRef:
group: gateway.networking.k8s.io
kind: HTTPRoute
name: grafana-route
defaults:
strategy: atomic
rules:
authorization:
allow-all:
opa:
rego: |
allow = true
response:
unauthorized:
headers:
"content-type":
value: application/json
body:
value: |
{
"error": "Forbidden",
"message": "403 Forbidden: policy check failed."
}これらを適用します。
kubectl apply -f shared-gateway/shared-gateway.yaml
kubectl apply -f observability/grafana-httproute.yaml
kubectl apply -f observability/grafana-authpolicy.yaml続いて、Grafana側にKeycloakとの連携設定を追加します。クライアントシークレットはSecretとして登録し、環境変数としてGrafanaに渡します。
kubectl create secret generic grafana-secret \
--namespace observability \
--from-literal=GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET='<Keycloakで発行したgrafanaクライアントのシークレット>'grafana-values.yaml に、次の設定を追加します。
# observability/grafana-values.yaml
datasources:
# ...変更なし...
envFromSecret: grafana-secret
grafana.ini:
server:
root_url: http://grafana.10.50.1.5.nip.io
auth.generic_oauth:
enabled: true
name: Keycloak
allow_sign_up: true
client_id: grafana
scopes: openid email profile
email_attribute_path: email
login_attribute_path: preferred_username
name_attribute_path: name
auth_url: http://keycloak.10.50.1.5.nip.io/realms/platform/protocol/openid-connect/auth
token_url: http://keycloak.10.50.1.5.nip.io/realms/platform/protocol/openid-connect/token
api_url: http://keycloak.10.50.1.5.nip.io/realms/platform/protocol/openid-connect/userinfo
role_attribute_path: contains(groups[*], '/GrafanaAdmin') && 'Admin' || 'Viewer'role_attribute_path は、KeycloakのグループをGrafanaの権限に対応付ける設定です。ここでは GrafanaAdmin グループに所属していればAdmin、それ以外はすべてViewerとしています。role_attribute_path に記載のグループ名の先頭にスラッシュが付いているのは、先ほどのマッパーがグループの完全パスをトークンに含めるためです。
設定を反映します。
helm upgrade --install grafana grafana-community/grafana \
--namespace observability \
--values observability/grafana-values.yaml適用後、ブラウザで http://grafana.10.50.1.5.nip.io を開くと、Grafanaのログイン画面に「Sign in with Keycloak」のボタンが表示されます。これを押すとKeycloakのログイン画面に遷移し、認証が済むとGrafanaに戻ってきます。
この時点ではまだアプリケーション側の計装がないため、表示するデータはありません。次節から、このデータを送り出す計装を、ゴールデンパスに組み込んでいきます。
計装の規約を定める
前節まででテレメトリを受け取る基盤が整ったので、ここからはプロダクトチーム向けにゴールデンパスを整備していきます。具体的には、第3回で作成したゴールデンパステンプレートのアプリケーションスケルトンにOpenTelemetryの計装を組み込んでいくのですが、その前にプラットフォームチームとしてどう計装するかの規約を定めておきます。
本記事で定める規約は、次のとおりです。
| 項目 | 規約 |
| 計装ライブラリ | OpenTelemetry Go SDKを用いる。HTTPサーバーの計装にはEcho用のotelechoミドルウェアを使う |
| メトリクス | otelechoが出力するHTTPサーバーのメトリクス(リクエストの所要時間やサイズをメソッド・ルート・ステータスコード別に集計したもの)とGoランタイムのメトリクスを収集する |
| ログ | Goの標準ログ(log/slog)をOpenTelemetryのログとして送出する(otelslog)。下記のリソース属性が自動的に付与される |
| リソース属性 | service.name・service.namespace・deployment.environment.name を必ず付与する。テレメトリがどのサービスのものかを識別できるようにする |
| 送信先 | すべてのテレメトリを、gRPCのOTLPでCollector(otel-collector-opentelemetry-collector.observability.svc:4317)へ送る |
いくつか、この規約の意図を補足します。
- メトリクスは自動で得られるものを基本とする:プロダクトチーム側でメトリクスを定義・集計するコードを書く必要をなくします。アプリケーション固有の値を測りたい場合は、同じOpenTelemetryのAPIを使って、この上に独自のメトリクスを追加できます。
- リソース属性でメトリクスとログを紐づける:リソース属性をすべてのテレメトリに共通で付与することで、Grafana上で「このサービスのメトリクス」と「このサービスのログ」を同じ視点で見られるようになります。
- 送信先を固定する:アプリケーション側は、保存先がPrometheusなのかLokiなのかを意識する必要がなくなります。
計装をゴールデンパスに組み込む
規約が定まったところで、実際に第3回のゴールデンパステンプレートに組み込んでいきます。第3回のテンプレートは、Goのechoフレームワークで書かれたシンプルなWebサーバーを生成します。ここに計装を追加することで、テンプレートから作られたアプリケーションが最初から計装済みで生成されるようにします。
変更するのは、skeletonに含まれるアプリケーションのコードと、そのデプロイ設定です。テンプレートの定義(template.yaml)やワークフローそのものは変更しません。追加する計装コードは送信先やサービス名といった環境ごとに変わる値を環境変数から読み取るため、環境に応じた書き換えは不要です。
なお、今回作成するテンプレートもGitHubリポジトリに公開しているので、動作確認が目的であればそちらを利用するのが手軽です。
依存関係の追加
計装に必要なライブラリを追加します。
go get go.opentelemetry.io/otel
go get go.opentelemetry.io/otel/sdk
go get go.opentelemetry.io/otel/sdk/metric
go get go.opentelemetry.io/otel/sdk/log
go get go.opentelemetry.io/otel/log/global
go get go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc
go get go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc
go get go.opentelemetry.io/contrib/bridges/otelslog
go get go.opentelemetry.io/contrib/instrumentation/runtime
go get go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho計装の初期化コード
OpenTelemetryのメトリクスとログを初期化する処理をinternal/telemetry/telemetry.go として追加します。メトリクス用とログ用のプロバイダーをそれぞれ設定し、グローバルに登録します。送信先(Collector)のアドレスやサービス名は、環境変数から自動的に読み取られます。
package telemetry
import (
"context"
"errors"
"log/slog"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/contrib/instrumentation/runtime"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/log/global"
sdklog "go.opentelemetry.io/otel/sdk/log"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
)
func Setup(ctx context.Context) (func(context.Context) error, error) {
res, err := resource.New(ctx,
resource.WithFromEnv(),
resource.WithTelemetrySDK(),
)
if err != nil {
return nil, err
}
// メトリクス:OTLP(gRPC)エクスポーターとMeterProviderを設定
metricExporter, err := otlpmetricgrpc.New(ctx)
if err != nil {
return nil, err
}
meterProvider := sdkmetric.NewMeterProvider(
sdkmetric.WithResource(res),
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExporter)),
)
otel.SetMeterProvider(meterProvider)
// Goランタイムのメトリクスを収集
if err := runtime.Start(runtime.WithMeterProvider(meterProvider)); err != nil {
return nil, err
}
// ログ:OTLP(gRPC)エクスポーターとLoggerProviderを設定
logExporter, err := otlploggrpc.New(ctx)
if err != nil {
return nil, err
}
loggerProvider := sdklog.NewLoggerProvider(
sdklog.WithResource(res),
sdklog.WithProcessor(sdklog.NewBatchProcessor(logExporter)),
)
global.SetLoggerProvider(loggerProvider)
// 標準ログ(log/slog)をOpenTelemetryへ橋渡しし、DefaultのLoggerにする
slog.SetDefault(otelslog.NewLogger("${{values.k8sResource.name}}"))
shutdown := func(ctx context.Context) error {
return errors.Join(
meterProvider.Shutdown(ctx),
loggerProvider.Shutdown(ctx),
)
}
return shutdown, nil
}なお、main.go からこのパッケージを読み込むときのimportパスは、リポジトリのmodule名に依存します。第3回のテンプレートではgo.mod のmodule名を生成先のリポジトリから組み立てているため、雛形の中では次のように変数のまま書かれており、生成時に実際のパスへ置き換わります。自分のテンプレートに計装を追加する場合は、この点を確認してください。
"github.com/${{values.destination.owner + "/" + values.destination.repo}}/internal/telemetry"アプリケーション本体への組み込み
main.go に起動時の計装初期化とHTTPサーバーの計装ミドルウェアを追加します。ミドルウェアを1つ差し替えるだけで、HTTPリクエストのメトリクスが自動的に収集されます。
第3回のテンプレートのミドルウェア登録は、次のようになっていました。
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Use(middleware.CORS())これを次のように変更します。
e := echo.New()
e.Use(middleware.Recover())
e.Use(middleware.CORS())
e.Use(otelecho.Middleware("${{values.k8sResource.name}}")) // 今回追加otelecho の追加と同時に、middleware.Logger() を削除している点に注目してください。これは、アプリケーションのログをOpenTelemetry経由でLokiに集約する方針を取るためです。middleware.Logger() はechoが標準出力へ独自形式のアクセスログを書き出すミドルウェアで(echo v4では非推奨とされています)、これを残すとLokiに集約されない経路でログが出続けることになります。
今回はHTTPリクエストの記録は otelecho が出すメトリクスが担い、アプリケーションが意図して残したいログは後述の slog で出力する、という役割分担にします。なお middleware パッケージ自体は Recover() で引き続き使うため、importはそのまま残します。
以上を踏まえた main.go の全体像は次のとおりです。
func main() {
ctx := context.Background()
// Init OpenTelemetry
shutdown, err := telemetry.Setup(ctx)
if err != nil {
slog.Error("failed to set up telemetry", "error", err)
return
}
defer func() { _ = shutdown(context.Background()) }()
e := echo.New()
e.Use(middleware.Recover())
e.Use(middleware.CORS())
e.Use(otelecho.Middleware("${{values.k8sResource.name}}")) // 今回追加
server := api.NewServer()
api.RegisterHandlers(e, server)
slog.Info("Starting server on :8080")
if err := e.Start(":8080"); err != nil {
slog.Error("server stopped", "error", err)
}
}あわせて、ハンドラー内でログを出力する箇所を slog に統一しておくと、そのログがOpenTelemetry経由でLokiに送られます。ハンドラーを実装している internal/api/server.go にlog/slog のimportを追加します。
import (
// net/httpの上にlog/slogを追加(アルファベット順)
"log/slog"
"net/http"
"github.com/labstack/echo/v4"
)そのうえで、GetHello関数の中身をリクエストのコンテキストに渡して、ハンドラーからログを出力するように修正します。
func (s *Server) GetHello(ctx echo.Context) error {
slog.InfoContext(ctx.Request().Context(), "hello endpoint called")
return ctx.JSON(http.StatusOK, HelloResponse{Message: "Hello, World!"})
}デプロイ設定への環境変数の追加
計装の送信先とサービス名を、テンプレートのConfigMap(skeleton/deployments/kustomize/base/configmap.yaml)に環境変数として追加します。既存の PORT や LOG_LEVEL に並べて、次を加えます。
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector-opentelemetry-collector.observability.svc:4317"
OTEL_SERVICE_NAME: "${{values.k8sResource.name}}"
OTEL_RESOURCE_ATTRIBUTES: "service.namespace=${{values.k8sResource.namespace}},deployment.environment.name=development"OTEL_EXPORTER_OTLP_ENDPOINT にCollectorのアドレスを、OTEL_SERVICE_NAME と OTEL_RESOURCE_ATTRIBUTES に前節で定めたリソース属性を与えます。deployment.environment.name は、base の既定値として development を入れています。本番向けの設定をまとめた production のオーバーレイでは、この値を production に上書きします。
テンプレートをBackstageに登録する
計装を組み込んだテンプレートを、Backstageのカタログに登録します。第4回で構築したBackstageの設定(app-config)の catalog.locations に、このテンプレートの template.yaml のURLを追加します。
catalog:
locations:
- type: url
target: https://github.com/<組織>/<リポジトリ>/blob/main/article/thinkit-smallplatform/03-backstage-software-template/template/template.yaml
rules:
- allow: [Template]
# 今回追加
- type: url
target: https://github.com/<組織>/<リポジトリ>/blob/main/article/thinkit-smallplatform/05-observability/template/template.yaml
rules:
- allow: [Template]プロダクトチームがセルフサービスで観測する
ここまでで、プラットフォームチームの作業は完了です。ここからは視点を変えて、プロダクトチームがこのテンプレートを使う場面を見ていきましょう。
テンプレートからサービスを生成する
プロダクトチームの操作は、第3回とまったく同じです。Backstageの「Create」から今回登録したテンプレートを選び、サービス名やデプロイ先のNamespaceといったパラメータを入力するとGitHubにリポジトリが作られ、同時にBackstageのカタログにコンポーネントとして登録されます。オブザーバビリティのために追加で入力する項目はありません。
第3回と違うのは、生成されたリポジトリの中身です。アプリケーションのコードにはすでにOpenTelemetryの計装が組み込まれ、デプロイ設定には送信先とサービス名の環境変数が入っています。プロダクトチームは計装のコードを1行も書いていませんし、Collectorのアドレスを調べてもいません。
以降では、例としてotel-go-app という名前でサービスを生成したものとして進めます。
生成されたサービスをデプロイする
生成されたリポジトリをクローンし、Kubernetesにデプロイします。なお、計装したテレメトリが基盤に届くことを確認するという目的には base で十分なため、今回は認証などの本番向け設定が加わらない base の構成でデプロイします。
まず、デプロイ先のNamespaceを作成します。
kubectl create namespace otel-go-app次に、コンテナイメージを取得するためのSecretを作成します。Secretは名前空間ごとに必要です。
kubectl create secret docker-registry regcred \
--docker-server=ghcr.io \
--docker-username=<GitHubのユーザー名> \
--docker-password=<個人アクセストークン> \
--namespace otel-go-appそして、kustomizeでデプロイします。
kubectl apply -k deployments/kustomize/base --namespace otel-go-appPodが起動するとアプリケーションはOpenTelemetryを初期化し、ConfigMapで与えられたアドレスのCollectorへメトリクスとログを送り始めます。プロダクトチームが観測のために行った作業は、テンプレートから生成してデプロイしただけです。
ここまでの分担を整理すると、プラットフォームチームが観測基盤と計装の規約を用意し、プロダクトチームはそれをテンプレート経由で受け取るだけ、という形になっています。第2回の共有ゲートウェイ、第3回のゴールデンパステンプレートと同じく、毎回悩む部分を一度決めて型として配る、という考え方の延長です。
メトリクスとログを確認する
次に、デプロイしたサービスのテレメトリが基盤に届いているかをGrafanaで確認します。ブラウザで http://grafana.10.50.1.5.nip.io を開き、左メニューの「Explore」からデータソースを切り替えて、メトリクスとログをそれぞれ見ていきます。
リクエストを発生させる
観測するデータがなければ何も表示されないので、まずアプリケーションにリクエストを送ります。テンプレートの base にもHTTPRouteは含まれていますが、ホスト名がプレースホルダのままで実際に公開されるのは production のオーバーレイでホスト名が差し替えられてからになるため、今回はポートフォワードで直接アクセスします。
kubectl port-forward --namespace otel-go-app svc/otel-go-app 8080:80別のターミナルから、/hello を繰り返し呼び出します。
for i in $(seq 1 300); do curl -s http://localhost:8080/hello > /dev/null; sleep 1; doneメトリクスを確認する
「Explore」でデータソースにPrometheusを選び、次のクエリを実行します。
sum by (http_response_status_code) (increase(http_server_request_duration_seconds_count{job="otel-go-app/otel-go-app"}[5m]))
otelechoが出力するHTTPサーバーのメトリクスは、リクエストの所要時間のヒストグラムです。その件数を表す_countは起動時からの累計値として記録されるため、increaseで一定時間(今回は5分)あたりの増加量に変換したうえで、ステータスコード別に合計しています。
グラフには、/helloへの200のほかに204も現れます。これはKubernetesのヘルスチェックが数秒おきに/healthを叩いている分で、アプリケーションが動いている限り常時発生します。
このメトリクスは、ステータスコードのほかにhttp_route(ルート)や http_request_method(メソッド)といったラベルも持っています。sum by (http_route) (...)のように分ければ、エンドポイントごとの傾向も見られます。
ログを確認する
GrafanaでデータソースをLokiに切り替え、次のクエリを実行します。
{service_name="otel-go-app"}
ハンドラーに書いた slog.InfoContext(...) のログが、hello endpoint called として表示されます。
異常を検知し、原因を調査する
ここまでで、サービスのメトリクスとログが見られるようになりました。しかし、オブザーバビリティの価値は正常なときのグラフを眺められることではなく、何かがおかしくなったときに気づき、原因にたどり着けることにあります。
そこで今回の締めくくりとして、実際に障害を起こし、その障害をメトリクスで検知してログで原因を特定する、という一連の流れをたどってみましょう。
不具合を含むバージョンをデプロイする
ここでは、障害を再現するために /hello が必ず失敗するバージョンを意図的に用意します。リリースに不具合が混入し、それに気づかないままデプロイしてしまった、という状況の再現です。
internal/api/server.go のハンドラーを、エラーログを出して500を返すように書き換えます。
func (s *Server) GetHello(ctx echo.Context) error {
slog.ErrorContext(ctx.Request().Context(), "hello endpoint failed", "reason", "simulated failure")
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}これを別のタグでビルドし、コンテナレジストリにプッシュします。実際の運用では第3回で用意したワークフローがこの手順を担いますが、ここでは手元で直接ビルドします。
docker build -t ghcr.io/<組織>/otel-go-app:error .
docker push ghcr.io/<組織>/otel-go-app:errorデプロイ中のイメージを、この不具合入りのバージョンに差し替えます。
kubectl set image deployment/otel-go-app \
app=ghcr.io/<組織>/otel-go-app:error \
--namespace otel-go-appPodが入れ替わったら、前節と同じようにリクエストを送ります。
kubectl port-forward --namespace otel-go-app svc/otel-go-app 8080:80
for i in $(seq 1 300); do curl -s http://localhost:8080/hello > /dev/null; sleep 1; doneメトリクスで異常に気づく
Grafanaで、前節と同じメトリクスをステータスコード別に見ます。ここでは変化を見やすくするため、常時発生するヘルスチェック(204)を除外しています。
sum by (http_response_status_code) (increase(http_server_request_duration_seconds_count{job="otel-go-app/otel-go-app", http_response_status_code!="204"}[5m]))
それまで出ていた200が落ち込み、代わりに500が立ち上がっていることが分かります。Podの中を覗かずにエンドポイントが壊れていることに気づけました。
ここで得られたのは「500が出ている」「いつから出始めたか」「どのくらいの量か」という情報です。裏を返すと、メトリクスから分かるのはここまでで、なぜ失敗しているのかは分かりません。集計された数値である以上、個々のリクエストで何が起きたかは失われているからです。
ログで詳細に原因を調査する
そこでログに移ります。データソースをLokiに切り替え、エラーレベルのログだけを絞り込みます。
{service_name="otel-go-app"} | detected_level="error"
hello endpoint failed というメッセージが表示されます。ハンドラーが slog.ErrorContext で出したログが、そのまま原因の手がかりになっています。
detected_level は、Lokiが受け取ったOpenTelemetryのログから重要度を読み取って自動的に付けるラベルです。アプリケーション側でログレベルを表す独自のフィールドを設計しなくても、slog.Error と slog.Info の区別がそのまま検索条件として使えます。これも、計装を標準化しておいたことの効果です。
切り戻す
原因が分かったので、正常なバージョンに戻します。
kubectl set image deployment/otel-go-app \
app=ghcr.io/<組織>/otel-go-app:latest \
--namespace otel-go-app再びリクエストを送るとメトリクスの500が止まり、200が戻ってくることを確認できます。「検知 → 原因特定 → 対処 → 確認」という一巡が、すべてGrafanaの画面の中で追えるようになりました。
利用したソフトウェアのバージョン
| コンポーネント | バージョン |
| OpenTelemetry Collector(contrib) | 0.157.0 |
| Prometheus | 3.13.2 |
| Loki | 3.7.6 |
| Grafana | 13.1.2 |
| OpenTelemetry Go SDK | 1.45.0 |
| otelecho | 0.70.0 |
| Helmチャート | バージョン |
| open-telemetry/opentelemetry-collector | 0.168.0 |
| prometheus-community/prometheus | 29.21.0 |
| grafana-community/loki | 18.7.4 |
| grafana-community/grafana | 12.10.3 |
まとめ
今回は、OpenTelemetry Collectorでテレメトリを受け取り、メトリクスをPrometheus、ログをLokiに保存してGrafanaでまとめて見られるようにしたうえで、その計装を第3回のテンプレートに組み込みました。これにより、プロダクトチームは、テンプレートから作ってデプロイするだけで、自分のサービスのメトリクスとログを見られるようになりました。
本連載を通じて追ってきた開発プロセスの変化を下表にまとめます。今回変わったのは「監視・可観測性の導入」の1行で、これまで「ログ出力と手動確認」のまま据え置かれていたものが、テンプレートから作るだけでメトリクスとログが集まり、Grafanaで確認できる状態になりました。
| 大プロセス | 小プロセス | 第3回のゴールデンパステンプレート導入後 | 第5回のオブザーバビリティ基盤導入後 |
| セキュリティ 要件定義 | 認証要件の設計 | Keycloakで標準化 | Keycloakで標準化 |
| 認可要件の設計 | Authorinoで部分自動化 | Authorinoで部分自動化 | |
| OpenAPI 仕様作成 | OpenAPIの設計 | テンプレートから開発開始 | テンプレートから開発開始 |
| セキュリティスキーマの定義 | Keycloak定義 | Keycloak定義 | |
| API仕様の公開 | Backstageで公開 | Backstageで公開 | |
| 実装 | APIの実装 | Open API仕様からエンドポイントのインターフェースを自動生成 | Open API仕様からエンドポイントのインターフェースを自動生成 |
| 認証・認可の実装 | Authorinoで自動化 | Authorinoで自動化 | |
| ルーティング設定 | Gateway APIで宣言管理 | Gateway APIで宣言管理 | |
| Kubernetesマニフェスト実装 | テンプレートから開発 | テンプレートから開発 | |
| デプロイと 運用 | デプロイ | Kubernetesで自動化 | Kubernetesで自動化 |
| 監視・可観測性の導入 | ログ出力と手動確認 | 計装済みテンプレートでメトリクスとログを収集しGrafanaで可視化 | |
| ポリシー準拠チェック | 手動レビュー | 手動レビュー | |
| 認可ポリシーの連携 | 手動実装 | 手動実装 |
次回は、ポリシーエージェントを導入して、セキュリティ上の決まりごとがデフォルトで守られるようにしていきます。
今回整えたのは「起きたことに気づく」ための仕組みでしたが、次回は「望ましくない状態をそもそも作らせない」ための仕組みを扱います。
- この記事のキーワード
この記事をシェアしてください
