vCluster と Grafana Alloy によるマルチテナント Kubernetes 環境のオブザーバビリティ基盤

Sreake事業部

2026.7.23

自己紹介

千葉工業大学大学院 情報科学研究科 情報科学専攻 修士2年の井上 裕介と申します.

大学では主にメタヒューリスティクスに関する最適化アルゴリズムの研究に従事しております.

はじめに

複数のチームやプロジェクトが同一の Kubernetes 基盤を共有するマルチテナントな構成は, テナント数の増加に伴い次のような課題が顕在化します.

  • 監視基盤の分散: テナント毎に Prometheus や Grafana を個別にデプロイすると、運用コストがテナント数に比例して増大する
  • テレメトリの集約困難: テナントをまたいだメトリクス / トレース / ログの横断分析ができず,障害の全体像が掴みにくい
  • 分離性の確保: 単一クラスタ内の Namespace 分離では Control Plane を共有するため,テナント毎に異なる CRD やクラスタロールを適用しにくい

そこで,専用クラスタという手段がしばしば取られますが,これはコストとテレメトリ集約の問題を解決しません。

クラスタ毎に監視スタックを立ち上げると運用コストはクラスタ数に比例して増大し,テナント横断の分析も依然として困難なままです.

さらに CRD 衝突問題も,テナントを専用クラスタに分けたとしては解消されません.

これらの課題に対するアプローチとして,本記事ではアプリ側への SDK 追加負担を最小化するオブザーバビリティ基盤を提案・構築します.

  • vCluster によるマルチテナント環境の構築: 単一の EKS クラスタ上で軽量な仮想クラスタをテナントとして使用し、数十秒での作成・削除を実現
  • Grafana Alloy を集約ゲートウェイとしたテレメトリパイプラインの構築: 仮想クラスタ内のアプリケーションが出力するメトリクス / トレース / ログを、Alloy を介してホスト側の単一監視基盤に集約するパイプラインを構築
  • Grafana Beyla によるゼロコード自動計装: Beyla を用いることでインフラの構成に大きな変更を加えることなく、コード変更なしでトレースの自動計装を行うことができます

表 1: マルチテナント Kubernetes の構成パターン比較

Namespace 分離専用クラスタvCluster + 集約観測 (本稿の構成)
Control Plane共有テナント別に分離テナント別 (仮想) + ホスト共有
監視コスト単一基盤テナント数倍 ※単一基盤 + テナント単位識別
起動時間即時数分〜十数分数十秒
リソース効率
テレメトリ集約容易だが分離弱テナント別で困難集約 + テナント識別ラベル

※ 各クラスタに Prometheus / Grafana を個別デプロイした場合.中央集約構成を採れば1基盤で済むが,クラスタ間のネットワーク設計が別途必要になる.

上記の基盤を単一の AWS EKS クラスタ上に構築した上で,次の 2 つの観点から検証を行います.

検証 1: 3 種類の計装方式 (OTel SDK + Collector / OTel SDK 直送 / Beyla eBPF) が単一基盤で同時に観測できるか

検証 2: テナント障害が他テナントへ波及しないか + 障害検知 MTTD (Mean Time To Detect) はどの程度か

基盤に用いる技術スタック

vCluster

vCluster は Loft Labs 社が開発している OSS であり,Kubernetes クラスタ上に,論理的に独立した Kubernetes クラスタを構築するためのソリューションです.

現在は OSS の他に,有償プランとして Dev / Prod / Scale が提供されています.

有償プランの特徴として,Istio や KubeVirt などの OSS との連携機能,クラウドプロバイダーとの統合などがサポートされます.

表 2 に vCluster を利用するメリット・デメリットをまとめます.

表 2: vCluster のメリットとデメリット

メリットデメリット
コスト・物理ノードを共有し,インフラコストを大幅削減
・必要時のみ起動する一時環境でリソース効率を最大化
・各 vCluster の Control Plane が CPU・メモリを消費
・小規模ワークロードでは相対的なオーバーヘッドが大きい
運用・ホストクラスタに管理を集中し,個別クラスタのメンテナンス不要• 統一されたモニタリング・ロギング基盤で一元管理・vCluster 固有の概念とベストプラクティスの学習が必要
・トラブルシューティング時にホストと vCluster 両方の理解が必要
開発・テスト・数十秒で環境を作成・削除し,CI/CD パイプラインに容易に統合
ブランチや PR 毎の独立した検証環境を気軽に構築
・初期セットアップと CI/CD への組み込みに設計の工夫が必要
隔離性・独立した API サーバと etcd により CRD やクラスタリソースを完全分離
・テナント間の障害や不正操作の影響を最小化
・ホストクラスタの Node レベル機能 (DaemonSet,特定の Device Plugin) へのアクセスは制限される
柔軟性・ホストとは独立した Kubernetes バージョンを選択可能
・チーム毎に異なるバージョンでの検証を同一基盤上で実現
・一部のクラウドプロバイダー固有機能は追加構成や有償プランが必要
ネットワーク・ホストクラスタのネットワークを活用し,柔軟な通信設計が可能
・LoadBalancer,Ingress による外部公開に対応
・vCluster 間やホスト間の通信設計に注意が必要
・ネットワークポリシーの設定が複雑になる場合がある
ストレージ・ホストクラスタの StorageClass をそのまま利用可能
・PV/PVC の管理をホスト側に委譲
・vCluster 削除時のデータ保持ポリシーを明確に定義する必要
・ストレージクラスの設計がホストクラスタに依存

プラン毎の利用可能な機能についてはこちらをご覧ください.

vCluster | Features and plans

アーキテクチャ

vCluster は仮想 Control Plane と同期メカニズム (Syncer) の2つの主要コンポーネントで構成されています.

各仮想クラスタは専用の Control Plane を持ち,その Control Plane 全体がホストクラスタ上の 1 つの Pod (StatefulSet) として動作します.

主要構成:

  • 仮想 Control Plane: API Server / Controller Manager / Syncer / Backing Store が単一コンテナ内の統一プロセスとして動作
  • Syncer: 仮想クラスタ内の Pod・Service・ConfigMap などのリソースをホスト側 namespace に変換・同期する境界コンポーネント
  • Backing Store: デフォルトは SQLite,本検証では etcd を明示的に有効化

同期の方向:

  • toHost (仮想 → ホスト): Pod・Service・PVC など,実際に動かすリソースをホスト側に実体化
  • fromHost (ホスト → 仮想): Node 情報・StorageClass など,ホスト側のリソースを仮想クラスタ内で参照可能にする

Pod 自体はホストクラスタのノード上 (共有ノードモード) で実行されるため、Beyla DaemonSet がホスト PID 名前空間からすべての仮想クラスタの Pod を透過的に計装できます.

図 1: vCluster のアーキテクチャ
引用元: https://www.vcluster.com/docs/vcluster/introduction/architecture#syncer

テナンシーモデル

vCluster は、 Control Plane とワーカーノードの展開方法に応じて、5つの主要なテナンシーモデルを提供しています。

各モデルは、隔離性、コスト効率、運用の複雑さのトレードオフが異なります.

表 3: テナンシーモデルの比較

モデル物理ノードの扱い分離リソース効率ユースケース
Shared Nodes共有低 (名前空間レベル)最高開発環境,CI/CD,コスト最適化
Dedicated Nodes専有 (論理的)中 (ノードセレクターによる分離)性能予測が必要な商用,特定チーム用
Virtual Nodes仮想化中〜高 (ノード境界を仮想化)セキュリティと効率の両立,SaaS基盤
Private Nodes専有 (物理的)最高 (ハードウェアから分離)高度なコンプライアンス,機密データ
Standalone不要 (VM/単一コンテナ)独立 (ホストK8sに依存しない)調整可能ローカル開発,デモ,エアギャップ環境

本検証では共有ノード (Shared Nodes) モードを採用しています.

Pod がホスト Node 上に実体化されるため,次節で説明する Beyla DaemonSet がホスト PID 名前空間から全 vCluster の Pod を透過的に計装できます.

図 2: Shared Nodes
引用元: https://www.vcluster.com/docs/vcluster/introduction/architecture#shared-nodes

オブザーバビリティ

オブザーバビリティ (Observability) とは,システムの外部出力からその内部状態を推測・理解できる能力を指します.

特にマイクロサービスアーキテクチャでは,サービス間の依存関係が複雑になるため,障害発生時に各サービスで起こっている事象を素早く把握する手段として不可欠です.

オブザーバビリティを構成する主要な要素として、次の 3つのシグナル があります.

表 4: 主要なオブザーバビリティシグナル

シグナル役割
Metrics時系列の数値データ. システムの状態をリアルタイムに監視し,異常を検知する.エラーレート,レイテンシ,CPU 使用率
Tracesリクエストがシステムを通過する際の経路と処理時間の記録.どのサービスのどの処理で遅延が発生したか
Logs各サービスが出力するテキストイベント. 障害の詳細な原因調査に使う.エラーメッセージ,スタックトレース

3つのシグナルをテレメトリ (Telemetry) と呼びます.

テレメトリを組み合わせることでメトリクスで異常を検知し、トレースで問題のあるサービスを特定し、ログで根本原因を調査するという流れで障害対応を効率化できます.

主な監視スタック

本検証では,仮想クラスタ内のデモアプリが出力するテレメトリをホストクラスタ側で収集・可視化するため,以下のコンポーネントを採用しました.

Alloy

  • Grafana Alloy は Grafana Labs が開発する OSS のオブザーバビリティパイプラインエージェントです.
  • OpenTelemetry Protocol (OTLP) を含む複数の受信プロトコルに対応しており,収集したテレメトリを各バックエンドに振り分けるゲートウェイとして機能します.
  • 本構成では Alloy が中心的な役割を担い,仮想クラスタ内の OTel Collector からテレメトリを受け取り,種別毎に適切なバックエンドへ転送します.

Beyla

  • Grafana Labs が開発する eBPF ベースのゼロコード自動計装エージェントです.
  • アプリケーションのソースコードや SDK への変更を一切加えず,Linux カーネルの eBPF プローブから HTTP/gRPC リクエストを直接インターセプトしてトレースとメトリクスを生成します.
  • 本検証では DaemonSet として全ノードに配置し,仮想クラスタ内の go-api-server (port 8080) を Pod ラベル app=go-api-server で自動検出して計装しています.取得した OTLP テレメトリはホスト側 Alloy へ gRPC で転送します.
  • SDK 組み込みと対比させるため,ゼロコード計装として採用します.

Loki

  • Grafana Labs が開発するログアグリゲーターです.
  • 本検証では SingleBinary モードで起動し,TSDB v13 スキーマでファイルシステムにチャンクを保存しています.
  • ログは Alloy の otelcol.exporter.loki 経由で /loki/api/v1/push エンドポイントに送信され,OTLP のリソース属性 (service.name, k8s.namespace.name, etc) がそのまま Loki ラベルに変換されます.
  • Grafana の Loki DataSource では derivedFields を設定し,ログ本文中の "traceID":"(\w+)" パターンを正規表現でマッチさせて Tempo DataSource へのリンクを生成します.
    これにより Explore 画面でログ行をクリックするだけで該当トレースへワンクリック遷移できます

Tempo Grafana

  • Labs が開発する分散トレースバックエンドです.
  • 本検証では特に Metrics Generator を有効化し,受信したスパンから以下の 2 種類のメトリクスを生成して Prometheus へ remote_write しています.
  • これは OTel SDK の HTTP 5xx が span.status=ERROR に自動マッピングされない semconv 仕様への回避策にもなっています.
  • ServiceGraph (traces_service_graph_*): スパン間の親子関係から自動構築されるサービス間呼び出しメトリクス. Grafana の Tempo DataSource で Service Map として可視化され,障害がどのサービスから伝播したかを視覚的に追跡できます.
  • Tempo DataSource ではtracesToLogs を Loki にマッピングしており,トレースのスパンから対応するログへ逆方向の遷移も可能です.
  • Loki → Tempo (TraceID 経由) と Tempo → Loki (時刻+サービス名経由) の双方向リンクで,メトリクス・トレース・ログを行き来する SRE 動線を実現します.

kube-prometheus-stack (Prometheus Operator)

  • Prometheus・Alertmanager・Grafana・各種 Exporter を Helm チャート経由で一括導入することが可能です.
  • Prometheus Operator により ServiceMonitor / PodMonitor / PrometheusRule などの Kubernetes CRD ベースで宣言的に監視対象とアラートを管理できます.
  • 本構成では Alloy 自身の自己メトリクスや,メトリクスのエンドポイントをアノテーションベースで自動スクレイプし,アラート評価の基盤として機能させています.

OpenTelemetry Collector

  • CNCF の OpenTelemetry プロジェクトが提供するテレメトリ収集エージェントです.
  • vCluster の仮想クラスタ内に sidecar として配置し,アプリケーション SDK が出力した OTLP を一旦受け取ってバッチング・属性付与を行ったうえで,ホスト側の Alloy へ転送する役割を担います.

テレメトリパイプラインの設計

仮想クラスタから外部の監視基盤にテレメトリを転送するため,networking.replicateservices を使用してホストクラスタ側のサービスを仮想クラスタ内に公開しています。

これにより,仮想クラスタ内の OTel Collector は otelcol-to-alloy:4317 という仮想サービス経由でホスト側の Alloy にテレメトリを送信できます.

図 3: 検証環境のアーキテクチャ
各 vCluster は networking.replicateServices.fromHostでホスト側の Alloy Service を仮想クラスタ内に複製することで,仮想クラスタ内のアプリが alloy:4317 宛に OTLP を送信できる

OpenTelemetry による計装

3 つのシグナルをアプリケーションから出力するためには,計装 (Instrumentation) という作業が必要です.

計装とは,アプリケーションのソースコードにオブザーバビリティ用のコードを組み込み,テレメトリを生成・送信できるようにすることです.

OTel の構成: API と SDK の分離

OTel の Go ライブラリは API と SDK の 2 層に分かれています(表 5).

表 5: OTel ライブラリの構成

パッケージ役割
APIgo.opentelemetry.io/otelテレメトリを記録するためのインターフェース を定義する.アプリやライブラリのコードはこの API のみに依存する
SDKgo.opentelemetry.io/otel/sdkAPI の実装.プロバイダーの設定・バッファリング・エクスポーターへの転送など「どこに・どう送るか」を担う.アプリ起動時に 1 度だけ初期化する
Exportergo.opentelemetry.io/otel/exporters/otlp/…収集したテレメトリを OTLP (OpenTelemetry Protocol) 形式で Alloy に送信する.gRPC 版と HTTP 版がある

OTel SDK を使う際は、シグナル毎に Provider を初期化してグローバルに登録します。

// 1. TracerProvider: トレース (スパン) の生成・エクスポートを管理
tp := sdktrace.NewTracerProvider(
    sdktrace.WithBatcher(traceExporter), // バッチで OTLP 送信
    sdktrace.WithResource(res),          // service.name などのリソース属性を付与
)
otel.SetTracerProvider(tp)

// 2. MeterProvider: メトリクスの生成・エクスポートを管理
mp := sdkmetric.NewMeterProvider(
    sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExporter,
        sdkmetric.WithInterval(60*time.Second), // 60s毎に送信
    )),
    sdkmetric.WithResource(res),
)
otel.SetMeterProvider(mp)

// 3. LoggerProvider: 構造化ログ
lp := sdklog.NewLoggerProvider(
    sdklog.WithProcessor(sdklog.NewBatchProcessor(logExporter)),
    sdklog.WithResource(res),
)
global.SetLoggerProvider(lp)

Resource: テレメトリの出所を識別する

resource.Resource はテレメトリがどのサービスから来たものかをバックエンドが識別するための属性セットです.

service.name に設定した値が Prometheus ラベルに変換され,Grafana のダッシュボードやトレース画面でサービスを絞り込む際に使われます.

res, _ := resource.Merge(
    resource.Default(), // Go バージョン・ホスト名などの標準属性
    resource.NewWithAttributes(
        semconv.SchemaURL,
        semconv.ServiceName("go-api-server-pattern-a"), // ← Prometheus の job ラベルになる
    ),
)

自動計装: otelhttp ミドルウェア

HTTP サーバへの計装で最も手間がかかるのが,リクエストの受信スパン生成です.

otelhttp パッケージが提供する otelhttp.NewHandler でルーターをラップするだけで,以下が自動的に行われます.

  • リクエスト毎に HTTPスパンを生成し http.request.methodurl.pathhttp.response.status_code などの標準属性を付与 (semconv v1.21+)
  • W3C Trace Context ヘッダーを伝播し,上流サービスのスパンと接続
  • http.server.request.duration などの HTTP メトリクスを自動計測
// main.go より抜粋 (go-api-server)

mux := http.NewServeMux()
mux.HandleFunc("/", handleRoot)
mux.HandleFunc("/health", handleHealth)

// ルーター全体を otelhttp でラップ → 全エンドポイントが自動計装される
handler := otelhttp.NewHandler(mux, "go-api-server")

srv := &http.Server{Addr: ":" + port, Handler: handler}

手動計装: スパンと構造化ログの追加

自動計装で生成される HTTP スパンをさらに詳細に記録したい場合は,ハンドラ内部で子スパンを手動で作成します.

var tracer = otel.Tracer("go-api-server")

func handleRoot(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "handle-root")
defer span.End()

// ...

span.SetAttributes(
    attribute.String("http.request.method", r.Method),
    attribute.String("url.path", r.URL.Path),
)

// ...

// ctx を渡すことで TraceID がログに自動埋め込みされる
// → Loki でログ行から Tempo のトレースへジャンプ可能
var rec otellog.Record
rec.SetBody(otellog.StringValue("handling GET /"))
global.GetLoggerProvider().Logger("go-api-server").Emit(ctx, rec)

json.NewEncoder(w).Encode(map[string]string{"message": "Hello"})

ログに ctx を渡すことで各ログレコードに現在の TraceID / SpanID が紐付けられます.,

Grafana でトレースを表示した際にこのスパン中に出力されたログへ直接ジャンプできるのはこの仕組みによるものです.

以上をまとめると,OTel 計装を組み込んだ go-api-server のテレメトリ生成フローは以下の通りです.

環境構築

ここからは GitHub リポジトリにあるファイルの使用を前提に解説を行います.

本検証では AWS EKS を使用します.

表 6 に検証で使用する各種コンポーネントを掲載します.

表 6: 検証環境のバージョン

ComponentVersion
OS (作業マシン)macOS Tahoe 26.3.1 arm64
Terraform1.15.5
Helm4.2.0
Helmfile1.5.2
kubectlClient: v1.36.x / Server: v1.34-eks
vCluster / vCluster CLIv0.34.2
kube prometheus (Grafana / Prometheus)chart 86.2.0 / Prom Operator v0.91.0
Loki3.6.7 (chart 6.55.0)
Tempo2.9.0 (chart 1.24.4)
Alloyv1.16.1 (chart 1.8.2)
BeylaOBI 3.20.0 (chart 1.16.8)

構築手順

1.GitHub リポジトリをローカルに Clone し,ディレクトリを移動する

2.terraform ディレクトリへ移動

cd terraform/

3.IAM ユーザまたは IAM ロールの ARN を取得し、terraform.tfvars を作成する

echo "eks_access_entry_principal_arn = $ (aws sts get-caller-identity --output json --no-cli-pager | jq '.Arn') " > terraform.tfvars

4.Terraform の初期化

# 初回
terraform init

# 2回目以降
terraform init -reconfigure

5.terraform plan を実行 。tf ファイルが実行可能かテストを行う

terraform plan -var-file="terraform.tfvars"

6.terraform apply を実行 。インフラを作成

terraform apply -var-file="terraform.tfvars"

# 出力結果
Apply complete! Resources: 58 added, 0 changed, 0 destroyed.

Outputs:

cluster_endpoint = "https://0000.xxx.ap-northeast-1.eks.amazonaws.com"
cluster_name = "demo-eks-vcluster"

7.リージョンとクラスタ名を変数に保存

export REGION="ap-northeast-1"
export CLUSTER_NAME="demo-eks-vcluster"
echo "$REGION\n$CLUSTER_NAME"

8.クレデンシャルを取得。クラスタに接続できることを確認

aws eks update-kubeconfig --region $REGION --name $CLUSTER_NAME
kubectl cluster-info
kubectl get nodes

9.EBS CSI Driverが起動していることを確認

kubectl get pods -n kube-system | grep ebs

# 出力結果
ebs-csi-controller-f7cf9bc5f-xsmnd   6/6     Running   0          15h
ebs-csi-controller-f7cf9bc5f-zszbd   6/6     Running   0          15h
ebs-csi-node-44g8v                   3/3     Running   0          15h
ebs-csi-node-bcrm4                   3/3     Running   0          15h

10.gp3 StorageClassを作成

cd ..
kubectl apply -f manifests/storageclass/gp3-storageclass.yaml

go-api-server コンテナイメージのビルドと ECR へのプッシュ

仮想クラスタにデプロイする go-api-server は、あらかじめ ECR にイメージをプッシュしておく必要があります.

ECR リポジトリは terraform apply 済みのため,出力から URL を取得します.

1.ECR の URL と ID を取得

export ECR_REPO=$(cd terraform && terraform output -raw ecr_repository_url)
export AWS_ACCOUNT_ID=$(cd terraform && terraform output -raw aws_account_id)

2.マニフェストのプレースホルダーを ECR の URL に置換する

sed -i '' "s|<ECR_REPOSITORY_URL>|${ECR_REPO}|g" \
  manifests/pattern-a/deploy.yaml \
  manifests/pattern-b/deploy.yaml \
  manifests/pattern-c/deploy.yaml

3.ECR にログインする

aws ecr get-login-password --region $REGION | \
  docker login --username AWS --password-stdin \
  "${AWS_ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com"

4.イメージをビルドして ECR にプッシュする.Apple Silicon の場合は –platform linux/amd64 を指定

docker buildx build --platform linux/amd64 \
  --target runtime \
  -t "${ECR_REPO}:latest" \
  --push \
  src/server/

helmfile を用いてコンポーネントをインストール

helmfileではホストクラスタと仮想クラスタに別々のコンポーネントをインストールします.

まずは、ホストクラスタに必要なコンポーネントを Helm 経由でインストールします.

ホストクラスタへの基盤コンポーネントのデプロイ

ホストクラスタに,監視スタックおよびマルチテナント環境に必要なコンポーネントを Helm 経由でインストールします.

表 7 に本検証でホストクラスタへデプロイするコンポーネントを掲載します.

表 7: ホストクラスタにデプロイするコンポーネント

ComponentChartVersionNamespaceRole
Alloygrafana/alloyv1.16.1 (chart 1.8.2)monitoringOTLP Receiver,Tempo / Prometheus / Loki への振り分け
Tempografana/tempo2.9.0 (chart 1.24.4)monitoringTraces 保存,SpanMetrics 生成
Lokigrafana/loki3.6.7 (chart 6.55.0)monitoringLogs 保存,TraceID 相関
kube-prometheus-stackprometheus-community/kube-prometheus-stackchart 86.2.0monitoring1.5.2Metrics 保存,アラート評価,Grafana
vCluster loft/vcluster0.34.2vcluster-system仮想クラスタの構築と管理
Beylagrafana/beylaOBI 3.20.0 (chart 1.16.8)beyla-systemeBPF 計装 DaemonSet

1.Helm リポジトリを登録

helmfile repos -f helm/helmfile.yaml
helm repo update

2.監視スタックをデプロイ

helmfile sync -f helm/helmfile.yaml

3.Pod 起動確認 (全 Pod が Running になるまで待機)

kubectl get pods -n monitoring
kubectl get pods -n beyla-system

4.Grafana にアラートルールとダッシュボードを適用. grafana.sidecar.alerts が ConfigMap を検知し Grafana へ自動的にロードされる

kubectl apply -f manifests/monitoring/

5.Port-forward を行う

kubectl port-forward svc/kube-prometheus-stack-grafana 3000:80 -n monitoring

6.Grafana にログインするため,パスワードを取得する

kubectl get secret -n monitoring kube-prometheus-stack-grafana -o jsonpath="{.data.admin-password}" | base64 --decode ; echo ""

7.Grafanaのダッシュボードに接続する (localhost:3000でブラウザから開きます)

図 5: クラスタのメトリクスを表示するダッシュボードの様子

仮想クラスタの作成と計装サンプルサーバーのデプロイ

ここからは vCluster を使用して 3 つの仮想クラスタを構築し,計装方式の異なる Go 製サンプルサーバー go-api-server をそれぞれにデプロイします.

表 8 に本検証で構築する 3 つの仮想クラスタと計装方式の対応を掲載します.

表 8: 3 つの仮想クラスタと計装方式の対応

vCluster計装方式Pod 構成service.name (job ラベル)
vcluster-1 (Pattern A)OTel SDK + OTel Collectorgo-api-server + otelcolgo-api-server-pattern-a
vcluster-2 (Pattern B)OTel SDK 直送go-api-servergo-api-server-pattern-b
vcluster-3 (Pattern C)Beyla eBPF + scrapego-api-server (SDK なし)vcluster-3/go-api-server-pattern-c

仮想クラスタの要点:

  1. 各 vCluster は replicateServices: fromHost: [monitoring/alloy] でホストの alloy Service を仮想クラスタ内に alloy:4317 として複製し,仮想クラスタ越しに OTLP 送信を可能にする
  2. Pattern C は networking.replicateServices.fromHost annotation で service.name を明示的に設定し,Beyla はそれを参照してテレメトリにサービス名を付与する

1.vcluster-1 (Pattern A) の作成とアプリのデプロイ
クラスタ作成後,kubectl のコンテキストが自動で仮想クラスタに切り替わる

vcluster create vcluster-1 \
  --namespace vcluster-1 \
  --upgrade \
  --values manifests/vcluster/vcluster-1-config.yaml

kubectl apply -f manifests/pattern-a/deploy.yaml
vcluster disconnect

2.vcluster-2 (Pattern B) の作成とアプリのデプロイ

vcluster create vcluster-2 \
  --namespace vcluster-2 \
  --upgrade \
  --values manifests/vcluster/vcluster-2-config.yaml

kubectl apply -f manifests/pattern-b/deploy.yaml
vcluster disconnect

3.vcluster-3 (Pattern C) の作成とアプリのデプロイ

vcluster create vcluster-3 \
  --namespace vcluster-3 \
  --upgrade \
  --values manifests/vcluster/vcluster-3-config.yaml

kubectl apply -f manifests/pattern-c/deploy.yaml
vcluster disconnect

4.作成した仮想クラスタの確認

vcluster list

# 結果
      NAME    |   NAMESPACE  | STATUS  | VERSION | CONNECTED |  AGE
  ------------+--------------+---------+---------+-----------+-------
   vcluster-1 | vcluster-1   | Running | 0.34.2  |           | 5m
   vcluster-2 | vcluster-2   | Running | 0.34.2  |           | 4m
   vcluster-3 | vcluster-3   | Running | 0.34.2  |           | 3m

5.Beyla が vcluster-3 の go-api-server を検出していることを確認

kubectl logs -l app.kubernetes.io/name=beyla -n beyla-system | grep -i "vcluster-3"

6.仮想クラスタへの接続 / 切断

# 接続
vcluster connect vcluster-1 -n vcluster-1

# 切断
vcluster disconnect

以降の検証では,仮想クラスタに接続して作業することはほとんどないので,切断しておくことを推奨します.

検証 1: 3パターン計装方式の同時観測

本検証のアーキテクチャでは,vcluster-1〜3 から異なる経路でテレメトリが流れてきます.

それぞれのシグナルが混線せず,テナント毎に識別できる形で Grafana まで届くことを実際に確かめます.

計装パターンの整理

3 つの vCluster はそれぞれ異なる「テレメトリの出し方」を採用しています.

表 9: 3 パターンの計装方式と経路

Pattern計装方法テレメトリの経路Prometheus 識別ラベル
A (vcluster-1)OTel SDK + OTel CollectorApp → Collector → Alloy → Prom / Tempo / Lokijob=go-api-server-pattern-a
B (vcluster-2)OTel SDKApp → Alloy → Prom / Tempo / Lokijob=go-api-server-pattern-b
C (vcluster-3)Beyla eBPFBeyla → Alloy → Prom / Tempo ※job=vcluster-3/go-api-server-pattern-c
C 補助Alloy /metrics scrapeAlloy scrape → Promservice_name=go-api-server-pattern-c

※ Beyla は HTTP/gRPC リクエストを eBPF でインターセプトしてメトリクスとトレースを生成しますが,構造化ログ (application logs) は生成しません.そのため Pattern C の Loki 連携は対象外です.

注意が必要なのが ラベル体系です.

OTel スタックでは,テレメトリの送り方によって Prometheus 上のラベル名が変わります.

  • OTel SDK 経由 → job ラベル
  • Alloy が scrape → service_name ラベル
  • Tempo SpanMetrics → service ラベル

同じサービスでも 3 種類のラベルに分岐するため,ダッシュボードを作るときはどのラベルを使うかを意識する必要があります.

検証手順

1.各 vcluster の go-api-server を再起動してカウンタをリセット

for vc in vcluster-1 vcluster-2 vcluster-3; do
  kubectl rollout restart deployment/go-api-server -n $vc
done

2.3 つの vcluster に対して 600 秒間,2 秒毎に GET / を並列送信

END=$(($(date +%s) + 600))
while [[ $(date +%s) -lt $END ]]; do
  curl -sf http://localhost:8081/ > /dev/null
  curl -sf http://localhost:8082/ > /dev/null
  curl -sf http://localhost:8083/ > /dev/null
  sleep 2
done

3.600 秒後に Pattern A/B に GET /status/500 を各 10 回送信する

for i in $(seq 1 10); do
  curl -sf http://localhost:8081/status/500 > /dev/null || true
  curl -sf http://localhost:8082/status/500 > /dev/null || true
done

4.Prometheus / Tempo / Loki に届いているか確認
以降の PromQL と Grafana 画面で各シグナルの到達を確認します.
port-forward が必要な場合は以下を実行してください.

kubectl port-forward svc/kube-prometheus-stack-grafana 3000:80 -n monitoring &
kubectl port-forward svc/kube-prometheus-stack-prometheus 9090:9090 -n monitoring &
kubectl port-forward svc/tempo 3200:3200 -n monitoring &
kubectl port-forward svc/loki 3100:3100 -n monitoring &

結果

図 6: 検証 1 – テナント別リクエストレート・P99 一覧
図 7: Service Overview ダッシュボード (3 パターン同時表示)

計装方式が異なる 3 つのテナントのテレメトリが,単一の Alloy ハブで識別可能な形で集約できることを確認できました.

表 10: 検証 1 結果サマリ

確認項目Pattern APattern BPattern C
Metrics → Prometheus⚪︎⚪︎⚪︎
Traces → Tempo⚪︎(10件)⚪︎(10件)⚪︎(10件)
Logs → Loki⚪︎⚪︎×
Trace → Log 相関⚪︎⚪︎N/A
エラーレート検出3.3%3.3%N/A
図 8: Tempo の Trace 一覧

Pattern C の P99 レイテンシは 4.98 ms で,Pattern A/B と同水準でした.

表 11: 取得できたメトリクスの Pattern 別比較

メトリクスPattern A (OTel SDK)Pattern B (OTel SDK)Pattern C (Beyla eBPF)
http_server_request_duration_seconds_*⚪︎⚪︎⚪︎
http_server_request_body_size_bytes_*××⚪︎
Go ランタイムメトリクス (go_* 31種)××⚪︎
/metrics スクレイプのリクエスト混入なしなしあり (2 件 / 10 分)
P99 レイテンシ計測値あり計測値あり4.98 ms
ログ×
図 9: Pattern A/B/C の P99 横並び比較

Explore → Prometheus で以下の PromQL を実行すると 3 パターンの P99 を横並びで確認できます.

histogram_quantile(0.99, sum by (le, job) (
  rate(http_server_request_duration_seconds_bucket{
    job=~"go-api-server-pattern-.*|vcluster-3/.*"
  }[5m])
)) * 1000

jobラベルで Pattern A/B は go-api-server-pattern-a/b,Pattern C は vcluster-3/go-api-server-pattern-c と表示されます.
これが前述したラベル体系の 3 分岐の実例です.

図 10: 検証 1 – Pattern A / B のエラーレート

検証 1 では Pattern A / B に各 10 回だけエラーを注入しました.

100 * sum by (job) (
  rate(http_server_request_duration_seconds_count{
    job=~"go-api-server-pattern-.*|vcluster-3/.*",
    http_response_status_code=~"5.."
  }[5m])
) / sum by (job) (
  rate(http_server_request_duration_seconds_count{
    job=~"go-api-server-pattern-.*|vcluster-3/.*"
  }[5m])
)

increase()[10m] で計算すると 10 エラー / 約 300 リクエスト ≒ 3.3% ですが,グラフ上は 0% に張り付いていることがわかります.
検証 2 では継続注入して様子を確認します.

検証 2: テナント障害の分離と検知時間の計測

マルチテナント基盤として最も重要な性質のひとつが障害の隔離です.
あるテナントの問題が別テナントのメトリクスに影響を与えてしまうと,誤検知アラートが飛びつづける運用地獄になります.
加えて,アラートが実際に何分後に発火するか (MTTD) を実測します.
SLO/SLI の設計において,障害発生から担当者がそれに気づくまでの時間は非常に重要な数字ですが,実測値で語られることは意外と少ないです.
vcluster-1 が障害を起こしても,vcluster-2/3 は正常のまま vcluster-1 のみが正常にアラートが発火するか,それは障害発生から何分後かを先ほどの環境で検証します.

検証手順

1.各 vcluster の Pod を再起動してカウンタをリセット

for vc in vcluster-1 vcluster-2 vcluster-3; do
  kubectl rollout restart deployment/go-api-server -n $vc
done

2.3 つの vcluster 全てに通常リクエスト (GET /) を 2 秒毎に 600 秒間送信し続ける

END=$(($(date +%s) + 600))
while [[ $(date +%s) -lt $END ]]; do
  curl -sf http://localhost:8081/ > /dev/null
  curl -sf http://localhost:8082/ > /dev/null
  curl -sf http://localhost:8083/ > /dev/null
  sleep 2
done

3.別のターミナルを新たに開き,同時に vcluster-1 だけに GET /status/500 を 2 秒毎に送り続ける

END=$(($(date +%s) + 600))
while [[ $(date +%s) -lt $END ]]; do
  curl -sf http://localhost:8081/status/500 > /dev/null || true
  sleep 2
done

4.HighErrorRate アラートが発火した時刻から MTTD を計算

障害分離の確認:

Explore → Prometheus → 以下の PromQL でテナント毎のエラーレートを比較

100 * sum by (service_name) (
  rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m])
) / sum by (service_name) (
  rate(http_server_request_duration_seconds_count[5m])
)

5xx 増分の確認:

Explore → Prometheus →increase() で期間内の差分を確認

increase(
  http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[10m]
)

MTTD の確認:

Alerting → Alert rules →HighErrorRateState history を開き,Normal → Pending → Firing の遷移時刻を読み取る

Trace の分離確認:

Explore → Tempo → status=error で検索し,Pattern A のみにエラースパンが出ていることを確認

Log の分離確認:

Explore → Loki → {service_name="go-api-server-pattern-b"} |~ "500" で Pattern B のエラーログが 0 件であることを確認

結果

図 11: 検証 2 – vcluster-1 のエラーレートスパイクと vcluster-2/3 のフラット

計装パターン比較ダッシュボードを確認すると, vcluster-1 のエラーレートが最大 44.9% に達する一方,vcluster-2/3 は完全 0% のままである.

リクエストレートも vcluster-1 他と比較して約 2 倍 (通常 + エラー注入) になっていることがわかる.

図 12: エラーレートスパイクとフラット

Explore → Prometheus で以下の PromQL を実行すると, 3 パターンのエラーレートを時系列で比較できます.

100 * sum by (job) (
  rate(http_server_request_duration_seconds_count{
    job=~"go-api-server-pattern-.*|vcluster-3/.*",
    http_response_status_code=~"5.."
  }[5m])
) / sum by (job) (
  rate(http_server_request_duration_seconds_count{
    job=~"go-api-server-pattern-.*|vcluster-3/.*"
  }[5m])
)

グラフを確認すると,障害注入の対象外である Pattern C と Pattern B が終始 0 % で安定しているのに対し, Pattern A は注入開始の T0 から 60 秒のタイミング,すなわち T1 (rate[5m] > 5% の初観測時点) からエラーレートが上昇し始め, 18.3 % を経て最終的に 44.9 % で安定していることがわかります.

設定されている HighErrorRate アラートには for: 5m の条件があるため,この T1 の発生から 5 分間継続した T0 + 360 秒のタイミングでアラートが発報状態に遷移したと考えられます.

これらの結果から,今回の検証における MTTD は 6 分であったと推定されます.

表 12: 障害分離の実測値

指標Pattern A(障害注入)Pattern B Pattern C
エラーレート (mean)31.6%0.0%0.0%
エラーレート (max)44.9%0.0%0.0%
10 分間の 5xx 増分301 件0 件0 件
Tempo トレース数80 件352 件422 件
Loki 5xx ログ行3 件0 件N/A

表 12 より, vcluster-1 のエラーレートが 45% 近くまで跳ね上がっている間も,vcluster-2/3 のシグナルは完全にフラットなままでした.

vCluster が各テナントに独立した API Server + etcd を提供しているため,アプリ層の障害がコントロールプレーンを通じて他テナントに波及しない構造が,正常に機能したと言えます.

Tempo の全トレース数は,エラースパンの数ではなく実験期間中に記録された全リクエストのトレース数です.

Pattern A は障害注入によって正常に届いたトレース数が他と比べて少なくなっていることがわかります.

また,OTel semconv では HTTP 5xx は span.status=ERROR に自動マップされないため,エラー件数は Prometheus の 5xx 増分で判断しています.

図 13: HighErrorRate の State history

続いて,障害注入からアラート発火までのタイムラインは以下の通りです.

表 13: MTTD タイムライン

経過時間出来事
0 秒GET /status/500 注入開始
+60 秒rate[5m] が初めて 5% を超えた (18.3%)
+360 秒HighErrorRate alert が Firing に遷移

表 14: MTTD 計測結果

指標意味
MTTD_T160 秒rate[5m] > 5% を初観測するまで
MTTD_T3360 秒HighErrorRate alert firing 推定時刻 (T1 + for: 5m)

表 15: Pattern A エラーレートの時系列推移

経過時間エラーレートコメント
T0+0s0%注入開始
T0+60s (T1)18.3%rate[5m] が初めて 5% を超えた瞬間
T0+120s32.6%
T0+180s39.5%
T0+300s44.7%ピーク帯到達
T0+360s (T3)44.9%HighErrorRate firing 推定時刻
T0+600s44.9%注入累積 301 件

特に表 15 を確認すると,今回は注入開始からわずか 1 分で rate[5m] が 5% を超えたことがわかります.

これは通常リクエストとエラー注入をそれぞれ 2 秒毎にを T0 から同時に送り続けたため, rate[5m]  のウィンドウが埋まり始めた直後からエラーレートが約 50% に達したからです.

rate[5m]  は 5 分間の移動平均であるため,注入量が少ない場合,例えば検証 1 の条件では閾値を下回り検知されません.

継続的な障害ほど早く検知できる一方,一時的なスパイクは見逃す可能性があります.

これは観測ウィンドウの長さと検知感度のトレードオフです.

rate[1m] に変えれば一時的なスパイクも検知できますが,かえってノイズとなり誤報が増える可能性があります.

図 14: Grafana Explore でのエラー率時系列グラフ

考察

マルチテナント監視基盤としての評価

今回の検証を通じて,3 テナント × 3 計装方式を単一の Alloy で同時に管理できることが実証できました.

1 時間の検証期間を通じて Alloy の自己メトリクスを観測した結果,スパン受信ロスも送信失敗もゼロでした.

表 16: Alloy 自己メトリクス

メトリクス
otelcol_receiver_accepted_spans_total (increase)3,656 スパン
otelcol_exporter_sent_spans_total (increase)3,656 スパン
otelcol_exporter_send_failed_spans_total (increase)0
otelcol_exporter_queue_size (max)0

今回の負荷規模では単一インスタンスで十分ですが,秒数千 req のプロダクション規模でのスループット限界検証は今後の課題です.

また,単一クラスタの Namespace 分離では Control Plane が共有されるため,監視基盤自体が全テナントの影響を受けるリスクがあります.

vCluster + Alloy 構成では各テナントが独立した API Server + etcd を持つため,障害の検知と影響範囲の特定を論理的に切り離せる点が大きなメリットです.

計装方式のトレードオフ

Pattern A vs Pattern B

今回の検証では A/B どちらも同じエラーレート (3.3 %) を示しており,数値上の差はありませんでした.

これは,サーバの負荷が低く,OTel Collector 設定もシンプルだったためです.

ただし OTel Collector を挟む Pattern A は,将来の柔軟性を確保する前提で構成するため,数値に現れない運用上の利点を持ちます.

  • バッファリングと再送: Alloy への接続が一時的に切れた場合でも,Collector がキューに保持して再送できる
  • Processor パイプライン: フィルタリング・属性付与・テール基盤サンプリングなどをアプリ外で一元設定できる
  • ベンダー中立: バックエンドを切り替える際にアプリ側の変更が不要 (Collector の exporter 設定だけ)

Pattern C (Beyla eBPF)

k8s_pod_labels 設定を工夫した結果, 4.98 ms という値となりました.

ただし,/metrics や /health への Prometheus scrape リクエストが Beyla の計装対象に混入することが確認できました.

routes.ignored_patterns で除外するなど,さらに設定を工夫できたと思います.

今後の課題

今回の検証を通じて基本的な動作実証は完了しましたが,プロダクション環境への適用に向けた課題もいくつか明確になっています.

まず性能面では,秒間数千リクエスト環境におけるAlloy周辺のボトルネック特定や,テナント数を100規模まで拡大した際のスケーラビリティ検証が必要です.

また,運用面においては Collector 停止時のバッファ挙動や,Beyla から不要なヘルスチェック等のエンドポイントを除外した上での P99 レイテンシの計測が求められます.

vCluster の API Server で OOM が発生した場合他テナントへ影響しないか,という障害分離の担保も重要です.

環境の撤収

検証が完了したら以下の順番で環境を削除します.

1.仮想クラスタを削除する

vcluster delete vcluster-1 --namespace vcluster-1
vcluster delete vcluster-2 --namespace vcluster-2
vcluster delete vcluster-3 --namespace vcluster-3

2.監視スタックを削除する

helmfile destroy -f helm/helmfile.yaml

3.EKS クラスタを削除する

cd terraform/
terraform destroy -var-file="terraform.tfvars"

おわりに

本記事では,vCluster × Grafana Alloy × Beyla によるマルチテナント Kubernetes オブザーバビリティ基盤を最新版コンポーネントで構築し,以下を実証しました.

  • 3 種類の計装方式 (OTel SDK + Collector / OTel SDK / Beyla eBPF) が単一の Alloy ハブで同時集約・テナント識別可能
  • アプリ層の 5xx 障害が他テナントのシグナルに波及しないことを実測で確認
  • MTTD の実測値 | 初検知まで 60 秒,HighErrorRate アラート発火まで 360 秒

コード変更ゼロで計装できる Beyla とテナントをまたいだ単一観測基盤の組み合わせは,既存アプリを抱えながらオブザーバビリティを段階的に導入したい組織に有効な選択肢だと考えています.

なお,本検証で使用した Terraform / Helmfile / Kubernetes マニフェストは GitHub リポジトリ にて公開しています.

参考資料

ブログ一覧へ戻る

お気軽にお問い合わせください

SREの設計・技術支援から、
SRE運用で使用する
ツールの導入など、
SRE全般についてご支援しています。

資料請求・お問い合わせ