アプリが遅い — でも、どこが原因?
Prometheusでメトリクスを取得し、Jaegerでトレースを収集し、Lokiでログを管理している。それでも、本番サービスが深夜2時にCPU 90%に跳ね上がったとき、これらのツールはどの関数がCPUを食い潰しているのかを教えてくれない。この空白を埋めるのが継続的プロファイリングだ。
私自身、バッチジョブを処理するGoのマイクロサービスを管理していたときにまったく同じ問題に直面した。メモリ使用量が徐々に増え続け、アラートが上がるほど速くはないが、数日ごとにPodが再起動するほどには着実に増えていた。PrometheusはRSSの増加を示し、ログには何も異常がない。goroutineリークがサードパーティライブラリに潜んでいたことを突き止めるまで、手動でprofスナップショットを3日間取り続けることになった。それ以来、継続的プロファイリングは本番KubernetesのMust-haveリストに入り続けている。
Grafana Pyroscopeはオープンソースの継続的プロファイリングプラットフォームだ。実行中のアプリケーションからコールスタックをサンプリングし、24時間365日フレームグラフを生成し続ける。手動でスナップショットを取る必要はない。GrafanaのObservabilityスタックと統合でき、Go、Java、Python、Rubyなど多くの言語に対応している。
Kubernetesで従来のプロファイリングが通用しない理由
従来のプロファイリングワークフローはこうだ。ローカルで問題を再現し、プロファイラを接続し、スナップショットを取得して分析する。開発者のラップトップでは問題ない。しかしKubernetesでは、すぐに破綻する。
- エフェメラルなPod — 問題に気づいたときには、問題のあったPodはすでに再起動して消えている可能性がある。
- 再現できないスパイク — 本番のトラフィックパターンを再現するのは難しい。深夜2時に実負荷で発生したスパイクは、ローカルマシンでは起きない。
- 複数のレプリカ — パフォーマンスのバグが10個のPodレプリカのうち1つにしか現れない場合、手動プロファイリングはほぼ当てずっぽうになる。デプロイ前に理解すべき基本概念
フレームグラフ
フレームグラフは、各バーが関数コールを表す可視化手法だ。バーの幅は、その関数(および呼び出した関数を含む)が消費したCPU時間(またはメモリ)に対応する。バーが広いほどボトルネックが大きい。Pyroscopeはこれを継続的に構築するため、いつでも照会できる履歴記録が常に存在する。
プル型とプッシュ型のプロファイリング
2つのモードが利用できる。プル型では、Pyroscopeサーバーがアプリケーションの
/debug/pprofエンドポイントをスクレイプする。Goネイティブで、コードの変更は一切不要だ。プッシュ型では、Pyroscope SDKでアプリを計装し、アプリ側からプロファイルをプッシュする。GoやJavaであれば、プル型の方が導入しやすい。eBPFプロファイリング
アプリを計装できない、あるいは計装したくない場合は? PyroscopeはeBPFを使ってカーネルレベルでプロファイリングできる。コード変更なしにあらゆる言語で動作するが、特権を持つDaemonSetが必要になる。C/C++サービスや、ノード全体の可視性が欲しい場合に特に有用だ。eBPFがKubernetesのネットワーキングレイヤーでどう活用されているかを知っておくと、eBPFプロファイリングで取得できるカーネルレベルの情報の意味をより深く理解できる。
KubernetesへのPyroscopeのデプロイ
前提条件
- Kubernetes 1.24以上のクラスター
- Helm 3.x
- kubectlの設定済み環境
- 稼働中のGrafanaインスタンス(またはPyroscopeと並行してデプロイ)
Helmでインストールする
GrafanaのHelmチャートリポジトリを追加し、専用のNamespaceにPyroscopeをデプロイする。HelmによるKubernetesリソース管理に慣れていない場合は、先にその基礎を押さえておくとスムーズだ。
helm repo add grafana https://grafana.github.io/helm-charts helm repo update kubectl create namespace pyroscope helm install pyroscope grafana/pyroscope \ --namespace pyroscope \ --set pyroscope.replicationFactor=1 \ --set minio.enabled=trueデフォルトチャートにはローカルオブジェクトストレージとしてMinIOが含まれている。本番環境ではS3、GCS、またはAzure Blobに切り替えよう。Helmのvaluesで
storage.backend=s3(またはgcs/azure)をバケットの認証情報とともに設定すればよい。デプロイを確認する
kubectl get pods -n pyroscope # NAME READY STATUS RESTARTS # pyroscope-0 1/1 Running 0 # pyroscope-minio-0 1/1 Running 0 kubectl port-forward svc/pyroscope -n pyroscope 4040:4040http://localhost:4040にアクセスすると、PyroscopeのUIを直接確認できる。GoアプリケーションのスクレイプPull型)
GoサービスがすでにPyroscope
net/http/pprofをインポートしているなら、コード変更なしでスクレイプできる。pprofを公開する最小限のGoサービスの例を示す。package main import ( "net/http" _ "net/http/pprof" // /debug/pprof ハンドラーを登録する ) func main() { http.ListenAndServe(":8080", nil) }次に、Pyroscopeがスクレイプするよう設定する。
scrape-config.yamlを作成する。scrapeConfigs: - jobName: my-go-service scrapeInterval: 15s staticConfigs: - targets: - my-go-service.default.svc.cluster.local:8080 profilingConfig: pprof: enabled: true path: /debug/pprof/これをPyroscopeのHelm valuesの一部として適用するか、PodにマウントするConfigMapとして適用する。
PythonアプリのプッシュモードSDK計装)
PythonサービスはPyroscope SDKを使ってプロファイルをプッシュする。
pip install pyroscope-ioimport pyroscope pyroscope.configure( application_name="my-python-service", server_address="http://pyroscope.pyroscope.svc.cluster.local:4040", tags={ "env": "production", # 環境名 "version": "1.2.3", # バージョン番号 } ) # アプリケーションのコードはここから通常通り実行される # Pyroscopeはバックグラウンドでサンプリングを続けるtagsは省略しないこと。これらのラベルは後でプロファイルをフィルタリング・比較するために使う。例えば、デプロイ後にversion=1.2.2とversion=1.2.3を比較して、どこがパフォーマンス低下したかを正確に確認できる。カナリアリリースやブルー/グリーンデプロイメントと組み合わせると、新バージョンのパフォーマンスプロファイルをトラフィックを絞った状態でリアルタイムに検証できる。PyrsocopeをGrafanaに接続する
GrafanaにPyroscopeをデータソースとして追加する。
- Configuration → Data Sources → Add data source を開く
- Grafana Pyroscope を検索する
- URLに
http://pyroscope.pyroscope.svc.cluster.local:4040を設定する - Save & Test をクリックする
接続後はExploreビューを開き、Pyroscopeデータソースを選択してアプリケーションを選ぶと、フレームグラフを閲覧できる。Grafanaの相関機能を使えばさらに強力になる。PrometheusとLokiをPyroscopeと同じパネルに並べて表示し、CPUスパイクが発生したとき、メトリクスからフレームグラフに直接クリックでジャンプできる。タブを切り替える必要も、当てずっぽうも不要だ。
eBPFエージェントをDaemonSetとしてデプロイする
ノード上のすべてのPodを言語に依存せずプロファイリングするには、PyroscopeのeBPFエージェントをデプロイする。
apiVersion: apps/v1 kind: DaemonSet metadata: name: pyroscope-ebpf namespace: pyroscope spec: selector: matchLabels: app: pyroscope-ebpf template: metadata: labels: app: pyroscope-ebpf spec: hostPID: true containers: - name: pyroscope-ebpf image: grafana/pyroscope-ebpf:latest # 本番環境では特定バージョンを指定すること securityContext: privileged: true env: - name: PYROSCOPE_SERVER_ADDRESS value: "http://pyroscope.pyroscope.svc.cluster.local:4040" volumeMounts: - name: host-sys mountPath: /sys readOnly: true volumes: - name: host-sys hostPath: path: /syseBPFには
privileged: trueが必須で、回避策はない。共有環境に展開する前にクラスターのPodセキュリティポリシーを確認し、latestではなく特定のイメージタグを指定すること。フレームグラフの読み方:実践的なウォークスルー
PyroscopeまたはGrafanaでフレームグラフを開いたら、まず最も幅の広いバーに注目する。それがボトルネックだ。覚えておくべきパターンをいくつか挙げる。
- スタックの上部に幅広いバーが1つある — 1つの関数が不均衡なCPUを消費している。そこから調査を始めよう。
- 同じ親から派生した細いバーが多数ある — タイトなループ内で呼び出されている関数だ。キャッシュやバッチ処理が有効なことが多い。
- GC関連のフレームがCPUの10%以上を占めている — GoやJavaでは、ホットパスで短命なアロケーションが多すぎることが原因であることが多い。不要なコピーや中間スライスがないか確認しよう。
- I/O待機フレームが大半を占めている — ボトルネックはCPUではなくレイテンシだ。フレームグラフは、どのデータベース呼び出しやHTTPリクエストがブロックしているかを示してくれる。
差分ビューは早い段階から使いこなしておく価値がある。リリースをデプロイし、そのフレームグラフを前バージョンと比較すると、パフォーマンスプロファイルで何が変わったかが一目でわかる。レイテンシが8%増加したということだけでなく、どの関数が以前の3倍のCPUを消費しているかまで特定できる。
まとめ
メトリクスは何かがおかしいと教えてくれる。トレースはリクエストライフサイクルのどこに問題があるかを示す。プロファイルはなぜ問題が起きているかを教えてくれる — 実際に責任のあるコード行を。Pyroscopeはその空白を埋める存在だ。
セットアップは最小限だ。Helmで1回インストールし、スクレイプ設定かSDKの数行を追加し、Grafanaにデータソースを1つ登録するだけ。次に深夜2時にPodが想定外のCPUを食い始めても、もう当てずっぽうにはならない。原因となった正確な関数を示すフレームグラフが、数日から数週間分の履歴データとともに手元にある。Pyroscopeのデフォルト保持期間は7日間で、ストレージの制限に応じて変更可能だ。
まずはGoやJavaサービスのプル型から始めよう。最も導入しやすく、コードの変更も一切不要だ。フレームグラフから最初の本物のボトルネックを発見したら、Pythonにはプッシュ型を導入し、ノード全体をカバーしたければeBPF DaemonSetを追加していけばよい。

