Kubernetesで始める 実践プラットフォームエンジニアリング 第5回

「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による計装を組み込みます。最後に、テンプレートから作ったサービスを動かし、メトリクスとログを実際に確認します。

図1:今回のアーキテクチャ図

オブザーバビリティとは

システムが正常に動いているのか、問題が起きたときに何が原因なのかを、外部から取得できる情報だけで把握できる能力を「オブザーバビリティ(可観測性)」と呼びます。もともとは制御工学の用語で、システムの外部から得られる出力を手がかりに内部状態をどれだけ推定できるかを表す概念です。ソフトウェアの文脈では、アプリケーションが出力するテレメトリ(観測データ)をもとに、システム内部で起きていることを把握できる状態を指します。

3つのシグナルと可視化

オブザーバビリティを支えるテレメトリは、伝統的に「メトリクス」「ログ」「トレース」の3種類に分類されます。後述するOpenTelemetryではテレメトリのカテゴリを「シグナル」と呼ぶため、本記事でもこの3つをまとめて「3つのシグナル」と呼びます。

これらのシグナルは集めるだけでは十分ではなく、集めたデータを保存し、人が読める形で表示して初めて、システムの状態を把握できるようになります。この表示を担うのがGrafanaのような可視化ツールで、全体としては「メトリクス・ログ・トレースを集める」→「保存する」→「可視化して見る」という関係になります。

図2:メトリクス、ログ、トレース、可視化の関係図

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:OpenTelemetryが標準化する3つのレイヤー

プラットフォームチームで規約を決める場合と比べると、違いは次の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点だけを知っていればよく、保存先が何であるかを意識する必要はありません。

図4:テレメトリが収集、保存、可視化される流れ

なお、オブザーバビリティのコンポーネントをより本格的に運用するための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 update

Prometheusと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: false
helm 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: false
helm upgrade --install loki grafana-community/loki \
  --namespace observability \
  --values observability/loki-values.yaml

Lokiは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: proxy
helm 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ユーザーに割り当てます。

図5:GrafanaAdminグループを作成
図6:ユーザーにGrafanaAdminグループを割り当て

最後に、ユーザーの所属グループがトークンに含まれるように、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
図7:grafanaクライアントへのマッパー追加

以上で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-app

Podが起動するとアプリケーションは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]))
図8:Grafana上で、otel-go-appのリクエスト数がステータスコード別に表示されている様子

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"}
図9:Grafana上で、otel-go-appのログにhello endpoint calledが表示されている様子

ハンドラーに書いた 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-app

Podが入れ替わったら、前節と同じようにリクエストを送ります。

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]))
図10:Grafana上で、200が記録されていた区間の後に、500が立ち上がっている様子

それまで出ていた200が落ち込み、代わりに500が立ち上がっていることが分かります。Podの中を覗かずにエンドポイントが壊れていることに気づけました。

ここで得られたのは「500が出ている」「いつから出始めたか」「どのくらいの量か」という情報です。裏を返すと、メトリクスから分かるのはここまでで、なぜ失敗しているのかは分かりません。集計された数値である以上、個々のリクエストで何が起きたかは失われているからです。

ログで詳細に原因を調査する

そこでログに移ります。データソースをLokiに切り替え、エラーレベルのログだけを絞り込みます。

{service_name="otel-go-app"} | detected_level="error"
図11:Grafana上で、hello endpoint failedが表示されている様子

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
Prometheus3.13.2
Loki3.7.6
Grafana13.1.2
OpenTelemetry Go SDK1.45.0
otelecho0.70.0
Helmチャートバージョン
open-telemetry/opentelemetry-collector0.168.0
prometheus-community/prometheus29.21.0
grafana-community/loki18.7.4
grafana-community/grafana12.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で可視化
ポリシー準拠チェック手動レビュー手動レビュー
認可ポリシーの連携手動実装手動実装

次回は、ポリシーエージェントを導入して、セキュリティ上の決まりごとがデフォルトで守られるようにしていきます。

今回整えたのは「起きたことに気づく」ための仕組みでしたが、次回は「望ましくない状態をそもそも作らせない」ための仕組みを扱います。

人気記事トップ10

人気記事ランキングをもっと見る

企画広告も役立つ情報バッチリ! Sponsored