Skip to content
Published on

PyroscopeでKubernetesアプリケーションのContinuous Profilingを構築する

Authors
  • Name
    Twitter

はじめに

Metrics、Logs、Tracesに続くオブザーバビリティの第4の柱がContinuous Profilingです。CPUは十分なのにレイテンシが高い場合は?メモリ使用量が徐々に増加している場合は?従来のモニタリングでは原因を特定するのが困難です。

PyroscopeはGrafanaエコシステムに統合されたcontinuous profilingツールで、コードレベルでパフォーマンスのボトルネックを継続的に追跡します。

Continuous Profilingとは?

従来のプロファイリングは「問題が発生した後」に手動で実行していましたが、Continuous Profilingは常にオンのプロファイラーです。

項目従来のプロファイリングContinuous Profiling
実行時点問題発生後に手動常時自動収集
オーバーヘッド高い(10-50%)低い(2-5%)
履歴なし時系列で保存
環境開発/ステージングプロダクション
比較分析困難時間帯別の比較が可能

Pyroscope アーキテクチャ

Pyroscopeは2つのコンポーネントで構成されます:

  • Pyroscope Server: プロファイルデータの収集、保存、クエリ
  • Agent/SDK: アプリケーションからプロファイルデータを収集しサーバーに送信

KubernetesへのPyroscopeのインストール

# HelmでPyroscopeをインストール
helm repo add grafana https://grafana.github.io/helm-charts
helm repo update

helm install pyroscope grafana/pyroscope \
  --namespace monitoring \
  --create-namespace \
  --set pyroscope.extraArgs.storage.backend=filesystem \
  --set persistence.enabled=true \
  --set persistence.size=50Gi

Grafana AlloyによるeBPFプロファイリング

Grafana Alloy(旧Grafana Agent)を使用すると、コード修正なしにeBPFでプロファイルを収集できます。

# alloy-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: alloy-config
  namespace: monitoring
data:
  config.alloy: |
    pyroscope.ebpf "instance" {
      forward_to = [pyroscope.write.endpoint.receiver]
      targets_only = false
      default_target = {"service_name" = "unspecified"}

      targets = discovery.kubernetes.pods.targets
    }

    discovery.kubernetes "pods" {
      role = "pod"
    }

    pyroscope.write "endpoint" {
      endpoint {
        url = "http://pyroscope:4040"
      }
    }
# alloy-daemonset.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: alloy
  namespace: monitoring
spec:
  selector:
    matchLabels:
      app: alloy
  template:
    metadata:
      labels:
        app: alloy
    spec:
      hostPID: true # eBPFに必須
      containers:
        - name: alloy
          image: grafana/alloy:latest
          args:
            - run
            - /etc/alloy/config.alloy
          securityContext:
            privileged: true
            runAsUser: 0
          volumeMounts:
            - name: config
              mountPath: /etc/alloy
            - name: sys-kernel
              mountPath: /sys/kernel
      volumes:
        - name: config
          configMap:
            name: alloy-config
        - name: sys-kernel
          hostPath:
            path: /sys/kernel

言語別SDK連携

Python (FastAPI)

# pip install pyroscope-io
import pyroscope

pyroscope.configure(
    application_name="order-service",
    server_address="http://pyroscope:4040",
    tags={
        "region": "ap-northeast-2",
        "env": "production",
    },
)

from fastapi import FastAPI
app = FastAPI()

@app.get("/orders/{order_id}")
async def get_order(order_id: str):
    # この関数のCPU/メモリ使用量が自動的にプロファイルされる
    order = await db.fetch_order(order_id)
    return order

Go

package main

import (
    "github.com/grafana/pyroscope-go"
    "net/http"
)

func main() {
    pyroscope.Start(pyroscope.Config{
        ApplicationName: "api-gateway",
        ServerAddress:   "http://pyroscope:4040",
        ProfileTypes: []pyroscope.ProfileType{
            pyroscope.ProfileCPU,
            pyroscope.ProfileAllocObjects,
            pyroscope.ProfileAllocSpace,
            pyroscope.ProfileInuseObjects,
            pyroscope.ProfileInuseSpace,
            pyroscope.ProfileGoroutines,
            pyroscope.ProfileMutexCount,
            pyroscope.ProfileMutexDuration,
            pyroscope.ProfileBlockCount,
            pyroscope.ProfileBlockDuration,
        },
        Tags: map[string]string{
            "env": "production",
        },
    })

    http.HandleFunc("/health", healthHandler)
    http.ListenAndServe(":8080", nil)
}

Java (Spring Boot)

// build.gradle
// implementation 'io.pyroscope:agent:0.13.1'

// application.yml
// pyroscope:
//   application-name: user-service
//   server-address: http://pyroscope:4040
//   format: jfr

import io.pyroscope.javaagent.PyroscopeAgent;
import io.pyroscope.javaagent.config.Config;

@SpringBootApplication
public class UserServiceApplication {
    public static void main(String[] args) {
        PyroscopeAgent.start(
            new Config.Builder()
                .setApplicationName("user-service")
                .setServerAddress("http://pyroscope:4040")
                .setProfilingEvent(EventType.ITIMER)
                .setFormat(Format.JFR)
                .build()
        );
        SpringApplication.run(UserServiceApplication.class, args);
    }
}

Flame Graph 分析

Flame Graphの読み方

Flame Graphでは:

  • X軸: サンプル比率(幅が広いほど、その関数でより多くの時間を消費)
  • Y軸: コールスタックの深さ(下から上へ)
  • : ランダム(区別用)
# Pyroscope CLIでプロファイルを照会
pyroscope query \
  --app-name order-service \
  --from "now-1h" \
  --until "now" \
  --profile-type cpu

ボトルネック診断パターン

CPUボトルネックの例:

main.handleRequest (40%)
  └── db.QueryRow (35%)
      └── net/http.(*conn).readRequest (30%)
          └── crypto/tls.(*Conn).Read (25%)

解釈:TLSハンドシェイクがCPUの25%を占有 → コネクションプーリングで解決

メモリリークの例:

runtime.mallocgc (60%)
  └── encoding/json.(*Decoder).Decode (45%)
      └── main.processLargePayload (40%)

解釈:JSONデコーディングで大量のメモリ割り当て → ストリーミングパーサーに切り替え

Grafana ダッシュボード連携

# GrafanaにPyroscopeデータソースを追加
# Settings > Data Sources > Add > Grafana Pyroscope
# URL: http://pyroscope:4040

便利なGrafanaパネル設定

{
  "targets": [
    {
      "datasource": { "type": "grafana-pyroscope-datasource" },
      "profileTypeId": "process_cpu:cpu:nanoseconds:cpu:nanoseconds",
      "labelSelector": "{service_name=\"order-service\"}",
      "queryType": "profile"
    }
  ]
}

TracesとProfilingの連携

Tempo(分散トレーシング)とPyroscopeを連携すると、遅いtraceからその時点のプロファイルを直接確認できます。

# Tempo設定にPyroscope連携を追加
# tempo.yaml
overrides:
  defaults:
    profiles:
      pyroscope:
        url: http://pyroscope:4040

実践的なパフォーマンス改善事例

事例 1: N+1クエリの発見

# プロファイルでdb.fetch_orderがCPUの80%を占有
# Flame Graphで同じクエリが繰り返し呼び出されていることを確認

# Before: N+1
async def get_orders_with_items(user_id):
    orders = await db.fetch_orders(user_id)
    for order in orders:
        order.items = await db.fetch_items(order.id)  # N回呼び出し!
    return orders

# After: JOINで1回のクエリ
async def get_orders_with_items(user_id):
    return await db.fetch_orders_with_items(user_id)  # 1回の呼び出し

事例 2: メモリリークの追跡

// プロファイルでruntime.mallocgcが継続的に増加
// inuse_spaceプロファイルで確認

// Before: 毎回新しいバッファを割り当て
func processRequest(data []byte) {
    buf := make([]byte, 1024*1024)  // リクエストごとに1MB割り当て
    // ...
}

// After: sync.Poolでバッファを再利用
var bufPool = sync.Pool{
    New: func() interface{} {
        buf := make([]byte, 1024*1024)
        return &buf
    },
}

func processRequest(data []byte) {
    buf := bufPool.Get().(*[]byte)
    defer bufPool.Put(buf)
    // ...
}

プロファイル比較(Diff View)

# デプロイ前後のプロファイルを比較
pyroscope diff \
  --app-name order-service \
  --left-from "2026-03-02T10:00:00Z" \
  --left-until "2026-03-02T11:00:00Z" \
  --right-from "2026-03-03T10:00:00Z" \
  --right-until "2026-03-03T11:00:00Z" \
  --profile-type cpu

Grafanaでも、Exploreパネルで2つの時間帯のプロファイルをオーバーレイして比較できます。赤色は増加、緑色は減少を示します。

まとめ

Continuous Profilingはオブザーバビリティの最後のピースです:

  • Metrics: 「何が」遅いかを教えてくれる
  • Logs: 「何が起きた」かを教えてくれる
  • Traces: 「どこで」遅いかを教えてくれる
  • Profiles: 「なぜ」遅いかを教えてくれる(コードレベル)

Pyroscope + Grafanaの組み合わせで、プロダクション環境のパフォーマンス問題をコードレベルまで追跡しましょう。


クイズ:Continuous Profiling 理解度チェック(7問)

Q1. Continuous Profilingが従来のプロファイリングと異なる点は?

常にオンでプロダクション環境で継続的に収集し、オーバーヘッドが2-5%と低く、時間帯別の比較が可能です。

Q2. eBPFプロファイリングの利点は?

コード修正なしにカーネルレベルですべてのプロセスのプロファイルを収集できます。

Q3. Flame GraphのX軸が意味するものは?

その関数(とその子関数)で消費した時間の比率です。幅が広いほど多くの時間を使用しています。

Q4. Grafana Alloy DaemonSetでhostPID: trueが必要な理由は?

eBPFがホストのプロセス情報にアクセスするには、ホストPIDネームスペースが必要です。

Q5. メモリリークの追跡に適したプロファイルタイプは?

inuse_space(現在使用中のメモリ)プロファイルで、時間とともに増加する割り当てを追跡します。

Q6. Diff Viewの活用事例は?

デプロイ前後のプロファイルを比較して、新しいコードがパフォーマンスに与えた影響を確認します。

Q7. TracesとProfilingを連携するとどのような利点がありますか?

遅いtraceから該当時点のコードレベルのプロファイルに直接移動でき、ボトルネックの原因を正確に特定できます。