T-CREATOR

Python 継続的プロファイリング運用:py-spy/Scalene/OTel Profiling の組み込み方

Python 継続的プロファイリング運用:py-spy/Scalene/OTel Profiling の組み込み方

Python アプリケーションの本番環境での性能問題は、開発時には見えにくいものです。突然のレスポンス低下、メモリリーク、CPU 使用率の急上昇など、ユーザー体験を損なう問題を早期に発見するには、継続的なプロファイリングが欠かせません。

本記事では、本番環境でも安全に利用できる 3 つのプロファイリングツール「py-spy」「Scalene」「OpenTelemetry Profiling」の特徴と、それぞれの組み込み方を詳しく解説します。各ツールの強みを理解し、適切に使い分けることで、パフォーマンスの継続的な可視化と改善が実現できるでしょう。

背景

Python アプリケーションのパフォーマンス管理

Python は開発生産性が高い言語として広く採用されていますが、インタプリタ言語という特性上、パフォーマンスのボトルネックが発生しやすい傾向があります。特に機械学習、データ処理、Web API など、処理負荷の高いアプリケーションでは、どこで時間が消費されているのかを正確に把握することが重要になります。

従来、プロファイリングは開発環境で cProfile や line_profiler を使って実施されてきました。しかし、これらのツールは計測のオーバーヘッドが大きく、本番環境での継続的な利用には向いていませんでした。

継続的プロファイリングの必要性

本番環境でのパフォーマンス問題は、開発環境では再現できないケースが多くあります。実際のデータ量、ユーザーのアクセスパターン、外部サービスとの連携など、本番特有の条件下でのみ発生する問題を検知するには、本番環境でのプロファイリングが不可欠です。

継続的プロファイリングを導入することで、以下のような価値が得られます。

#メリット詳細
1リアルタイム検知パフォーマンス劣化を即座に発見
2トレンド分析時系列でのパフォーマンス変化を追跡
3根本原因の特定ボトルネックとなる関数やメモリリークの箇所を特定
4リリース影響の評価デプロイ前後での性能比較

以下の図は、アプリケーション実行時にプロファイリングツールがどのようにデータを収集し、可視化するかを示しています。

mermaidflowchart TB
    app["Python<br/>アプリケーション"]
    profiler["プロファイラ"]
    collector["データ収集"]
    storage["ストレージ"]
    viz["可視化<br/>ダッシュボード"]

    app -->|実行情報| profiler
    profiler -->|サンプリング| collector
    collector -->|メトリクス保存| storage
    storage -->|データ取得| viz
    viz -->|分析・改善| app

プロファイラはアプリケーションの実行情報を低オーバーヘッドで収集し、メトリクスとして蓄積します。蓄積されたデータは可視化ツールで分析され、パフォーマンス改善にフィードバックされるのです。

課題

従来のプロファイリング手法の限界

Python の標準的なプロファイリングツール cProfile は、関数呼び出しのたびに情報を記録するため、実行速度が大幅に低下します。実際の計測では、2〜10 倍程度の速度低下が発生することも珍しくありません。

オーバーヘッドの問題

本番環境で高いオーバーヘッドを伴うプロファイリングを実行すると、ユーザー体験が著しく悪化してしまいます。レスポンスタイムの増加、スループットの低下など、プロファイリング自体がパフォーマンス問題の原因となってしまうのです。

python# cProfile の典型的な利用方法
import cProfile
import pstats

def main():
    # アプリケーションのメイン処理
    process_data()

# プロファイリング実行(大きなオーバーヘッド)
cProfile.run('main()', 'profile_stats')

上記のコードでは、cProfile.run() がすべての関数呼び出しをトレースするため、処理時間が大幅に増加します。

本番環境での利用困難

cProfile や line_profiler は、アプリケーションコードの変更が必要です。デコレータを追加したり、プロファイリング用のコードを埋め込む必要があるため、本番環境への適用が難しいという課題がありました。

python# line_profiler の利用には@profileデコレータが必要
@profile  # 本番コードに残せない
def expensive_function():
    result = []
    for i in range(10000):
        result.append(i ** 2)
    return result

デコレータを本番コードに残すと、line_profiler がインストールされていない環境ではエラーが発生します。

可視化と分析の課題

プロファイリング結果を効果的に可視化し、チーム全体で共有することも重要な課題です。従来のツールは、テキストベースの出力が中心で、時系列での比較やトレンド分析が困難でした。

以下の図は、従来のプロファイリング手法が抱える主な課題を示しています。

mermaidflowchart LR
    dev["開発環境"]
    prod["本番環境"]

    dev -->|cProfile実行| overhead["高オーバーヘッド"]
    overhead -->|問題| slow["実行速度低下"]

    dev -->|コード変更必要| modify["デコレータ追加"]
    modify -->|問題| deploy["本番適用困難"]

    prod -->|プロファイル実行| impact["ユーザー影響"]
    impact -->|問題| ux["UX悪化"]

    dev -->|テキスト出力| text["可視化不足"]
    text -->|問題| analysis["分析困難"]

このように、オーバーヘッド、本番適用の難しさ、可視化の不足という 3 つの大きな課題が、継続的プロファイリングの導入を妨げてきました。

解決策

最新プロファイリングツールの特徴

本番環境での継続的プロファイリングを実現するため、低オーバーヘッドで動作する新しいツールが登場しています。py-spy、Scalene、OpenTelemetry Profiling は、それぞれ異なるアプローチでこれらの課題を解決しているのです。

py-spy:サンプリングベースの軽量プロファイラ

py-spy は、Rust で実装された外部プロセス型のプロファイラです。Python プロセスに外部からアタッチし、定期的にスタックトレースをサンプリングすることで、オーバーヘッドを 1〜3% 程度に抑えています。

py-spy の主な特徴

#特徴説明
1低オーバーヘッドサンプリング方式により 1〜3% のオーバーヘッド
2コード変更不要外部からプロセスにアタッチ可能
3フレームグラフ出力SVG 形式の視覚的な出力
4本番環境対応実行中のプロセスに影響を与えにくい
bash# py-spyのインストール
pip install py-spy

# 実行中のPythonプロセスにアタッチ
py-spy record -o profile.svg --pid 12345

この単純なコマンドで、プロセス ID 12345 の Python アプリケーションをプロファイリングし、結果を SVG ファイルに出力できます。

Scalene:詳細なメモリと CPU 分析

Scalene は、CPU 時間だけでなく、メモリ使用量や GPU 使用率まで詳細に分析できる高機能プロファイラです。行単位でのプロファイリングが可能で、どの行がボトルネックになっているかを正確に把握できます。

Scalene の主な特徴

#特徴説明
1マルチメトリクスCPU、メモリ、GPU を同時計測
2行単位分析コードの行ごとに詳細な情報を提供
3メモリリーク検出メモリの増減を可視化
4Web UIブラウザベースの見やすいインターフェース
bash# Scaleneのインストール
pip install scalene

# Scaleneでスクリプトを実行
scalene your_script.py

Scalene は実行後、自動的にブラウザで結果を表示し、対話的な分析が可能になります。

OpenTelemetry Profiling:分散システム対応

OpenTelemetry Profiling は、マイクロサービスや分散システムでのプロファイリングに特化したツールです。トレースとプロファイリングを統合し、リクエスト全体のパフォーマンスを可視化できます。

OpenTelemetry Profiling の主な特徴

#特徴説明
1分散トレース統合リクエストの全経路をプロファイリング
2標準化されたフォーマットOTLP プロトコルで統一的にデータ送信
3バックエンド柔軟性Jaeger、Grafana など多様なバックエンドに対応
4継続的収集常時稼働でのプロファイリングが可能

以下の図は、3 つのツールの特徴を比較したものです。

mermaidflowchart TB
    subgraph pyspy["py-spy"]
        ps1["サンプリング方式"]
        ps2["CPU プロファイル"]
        ps3["フレームグラフ"]
    end

    subgraph scalene["Scalene"]
        sc1["詳細分析"]
        sc2["CPU + メモリ + GPU"]
        sc3["行単位計測"]
    end

    subgraph otel["OTel Profiling"]
        ot1["分散トレース"]
        ot2["標準化プロトコル"]
        ot3["継続的収集"]
    end

    use_case["利用シーン"]

    use_case -->|シンプルな<br/>CPU分析| pyspy
    use_case -->|詳細な<br/>メモリ調査| scalene
    use_case -->|分散システム<br/>全体監視| otel

各ツールは異なる強みを持っており、目的に応じて使い分けることが重要です。

具体例

py-spy の組み込み方

py-spy を本番環境で利用する際の具体的な手順を見ていきましょう。Docker コンテナで動作する Flask アプリケーションを例に説明します。

Flask アプリケーションの準備

まず、プロファイリング対象となる Flask アプリケーションを用意します。

python# app.py - Flaskアプリケーション本体
from flask import Flask, jsonify
import time

app = Flask(__name__)

Flask の基本的なインスタンスを作成します。

python# ルートエンドポイントの定義
@app.route('/api/process')
def process_data():
    """データ処理を行うエンドポイント"""
    result = heavy_computation()
    return jsonify({'result': result})

このエンドポイントは、後述する重い計算処理を実行します。

python# 重い計算処理をシミュレート
def heavy_computation():
    """CPUを使う処理の例"""
    total = 0
    for i in range(1000000):
        total += i ** 2
    time.sleep(0.1)  # I/O待機をシミュレート
    return total

この関数は CPU 負荷の高い処理と I/O 待機を含み、プロファイリングのテストに適しています。

python# アプリケーションの起動
if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

アプリケーションをすべてのネットワークインターフェースで待ち受けるように設定します。

Docker での py-spy 統合

Docker コンテナで py-spy を利用するための Dockerfile を作成します。

dockerfile# Dockerfile - ベースイメージの指定
FROM python:3.11-slim

# 作業ディレクトリの設定
WORKDIR /app

Python 3.11 のスリムイメージを使用し、作業ディレクトリを設定します。

dockerfile# py-spyのインストール
RUN pip install py-spy flask

# アプリケーションファイルのコピー
COPY app.py .

必要なパッケージをインストールし、アプリケーションコードをコピーします。

dockerfile# プロファイリングスクリプトの追加
COPY profiling.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/profiling.sh

後述するプロファイリングスクリプトを追加し、実行権限を付与します。

bash# profiling.sh - プロファイリング実行スクリプト
#!/bin/bash

# Flaskアプリケーションをバックグラウンドで起動
python app.py &
APP_PID=$!

# アプリケーションの起動を待機
sleep 5

Flask アプリケーションをバックグラウンドで起動し、プロセス ID を取得します。

bash# py-spyでプロファイリング開始(60秒間)
py-spy record \
  --pid $APP_PID \
  --duration 60 \
  --rate 100 \
  --format flamegraph \
  --output /tmp/profile.svg

# アプリケーションを継続実行
wait $APP_PID

py-spy を使って 60 秒間、サンプリングレート 100Hz でプロファイリングを実行します。--format flamegraph オプションでフレームグラフ形式の SVG ファイルが生成されます。

Kubernetes での継続的プロファイリング

Kubernetes 環境で py-spy を継続的に実行するための設定を見ていきます。

yaml# deployment.yaml - Deploymentの基本設定
apiVersion: apps/v1
kind: Deployment
metadata:
  name: flask-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: flask-app

2 つのレプリカで Flask アプリケーションをデプロイします。

yaml# Pod仕様とコンテナ設定
template:
  metadata:
    labels:
      app: flask-app
  spec:
    containers:
      - name: app
        image: flask-app:latest
        ports:
          - containerPort: 5000

メインのアプリケーションコンテナを定義します。

yaml# サイドカーコンテナでpy-spyを実行
- name: profiler
  image: pyspy:latest
  command:
    - /bin/sh
    - -c
    - |
      while true; do
        py-spy record \
          --pid 1 \
          --duration 300 \
          --rate 100 \
          --output /profiles/profile-$(date +%s).svg
        sleep 60
      done

サイドカーコンテナで 5 分ごとにプロファイリングを実行し、結果をタイムスタンプ付きファイルで保存します。

yaml# 共有ボリュームの設定
        volumeMounts:
        - name: profiles
          mountPath: /profiles
      volumes:
      - name: profiles
        emptyDir: {}

プロファイル結果を保存するための共有ボリュームを設定します。

Scalene の組み込み方

Scalene は開発環境での詳細分析に最適です。データ処理スクリプトを例に使い方を見ていきましょう。

データ処理スクリプトの例

python# data_processor.py - インポート文
import pandas as pd
import numpy as np
from typing import List, Dict

必要なライブラリをインポートします。

python# データ読み込み関数
def load_data(filepath: str) -> pd.DataFrame:
    """CSVファイルからデータを読み込む"""
    df = pd.read_csv(filepath)
    return df

CSV ファイルを pandas DataFrame として読み込みます。

python# データ変換処理
def transform_data(df: pd.DataFrame) -> pd.DataFrame:
    """データの変換処理を実行"""
    # 数値列の正規化
    numeric_cols = df.select_dtypes(include=[np.number]).columns
    df[numeric_cols] = (df[numeric_cols] - df[numeric_cols].mean()) / df[numeric_cols].std()

    # 不要な列を削除
    df = df.drop(columns=['temp_col'], errors='ignore')

    return df

データの正規化処理を行います。この部分がメモリを多く消費する可能性があります。

python# メモリを多く消費する処理
def aggregate_data(df: pd.DataFrame) -> Dict[str, float]:
    """集計処理を実行"""
    results = {}

    # グループごとに集計(メモリ負荷が高い)
    for group in df['category'].unique():
        subset = df[df['category'] == group]
        results[group] = subset['value'].sum()

    return results

カテゴリごとにデータを抽出して集計します。この処理はメモリ効率が悪い可能性があります。

python# メイン処理
def main():
    """メイン処理フロー"""
    df = load_data('large_dataset.csv')
    df = transform_data(df)
    results = aggregate_data(df)
    print(f"処理完了: {len(results)} グループ")

if __name__ == '__main__':
    main()

すべての処理を統合して実行します。

Scalene でのプロファイリング実行

bash# Scaleneでプロファイリング実行
scalene --html --outfile profile.html data_processor.py

# CPUとメモリのみを計測する場合
scalene --cpu --memory data_processor.py

--html オプションで HTML 形式のレポートが生成され、ブラウザで詳細な分析が可能になります。

Scalene の出力解析

Scalene は以下のような情報を行単位で提供します。

#メトリクス説明
1CPU 時間その行の実行にかかった CPU 時間の割合
2メモリ使用量その行で確保されたメモリの量
3メモリ増減その行でメモリが増加したか減少したか
4GPU 使用率GPU を使用している場合の使用率

Scalene の出力から、aggregate_data 関数の for ループがメモリを多く消費していることが判明した場合、以下のように改善できます。

python# 改善版:groupbyを使用してメモリ効率を向上
def aggregate_data_optimized(df: pd.DataFrame) -> Dict[str, float]:
    """改善された集計処理"""
    # groupbyを使用して効率的に集計
    results = df.groupby('category')['value'].sum().to_dict()
    return results

pandas の groupby を使うことで、メモリ効率が大幅に向上します。

OpenTelemetry Profiling の組み込み方

OpenTelemetry Profiling を使って、マイクロサービス全体のプロファイリングを実装します。

OpenTelemetry のセットアップ

python# otel_config.py - OpenTelemetryの初期化
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

OpenTelemetry の必要なコンポーネントをインポートします。

python# トレーサープロバイダの設定
def setup_tracing(service_name: str):
    """OpenTelemetryトレーシングの初期化"""
    provider = TracerProvider()
    trace.set_tracer_provider(provider)

    # OTLPエクスポーターの設定
    otlp_exporter = OTLPSpanExporter(
        endpoint="http://otel-collector:4317",
        insecure=True
    )

    # バッチプロセッサの追加
    provider.add_span_processor(BatchSpanProcessor(otlp_exporter))

    return trace.get_tracer(service_name)

OTLP プロトコルで OpenTelemetry Collector にデータを送信する設定を行います。

FastAPI アプリケーションへの統合

python# main.py - FastAPIアプリケーション
from fastapi import FastAPI
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from otel_config import setup_tracing

app = FastAPI()

# OpenTelemetryの初期化
tracer = setup_tracing("user-service")

# FastAPIの自動計装
FastAPIInstrumentor.instrument_app(app)

FastAPI に OpenTelemetry を統合します。FastAPIInstrumentor が自動的にすべてのエンドポイントをトレースします。

python# エンドポイントの定義
@app.get("/api/users/{user_id}")
async def get_user(user_id: int):
    """ユーザー情報を取得"""
    with tracer.start_as_current_span("get_user_from_db"):
        user = await fetch_user_from_database(user_id)

    with tracer.start_as_current_span("enrich_user_data"):
        enriched = await enrich_user_data(user)

    return enriched

手動でスパンを作成することで、特定の処理単位をプロファイリングできます。

python# データベースアクセス処理
async def fetch_user_from_database(user_id: int):
    """データベースからユーザーを取得"""
    # スパンに属性を追加
    span = trace.get_current_span()
    span.set_attribute("user_id", user_id)
    span.set_attribute("db.system", "postgresql")

    # データベースクエリの実行
    # (実際のDB処理)
    return {"id": user_id, "name": "User Name"}

スパンに属性を追加することで、より詳細な情報を記録できます。

Docker Compose でのシステム構成

OpenTelemetry Collector と Jaeger を使った監視環境を構築します。

yaml# docker-compose.yml - サービス定義
version: '3.8'

services:
  app:
    build: .
    ports:
      - '8000:8000'
    environment:
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
    depends_on:
      - otel-collector

FastAPI アプリケーションを定義し、環境変数で OpenTelemetry Collector のエンドポイントを指定します。

yaml# OpenTelemetry Collectorの設定
otel-collector:
  image: otel/opentelemetry-collector:latest
  command: ['--config=/etc/otel-collector-config.yaml']
  volumes:
    - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
  ports:
    - '4317:4317' # OTLP gRPC
    - '4318:4318' # OTLP HTTP

OpenTelemetry Collector がトレースデータを受信します。

yaml# Jaegerバックエンドの設定
jaeger:
  image: jaegertracing/all-in-one:latest
  ports:
    - '16686:16686' # Jaeger UI
    - '14250:14250' # gRPC
  environment:
    - COLLECTOR_OTLP_ENABLED=true

Jaeger でトレースデータを可視化します。

OpenTelemetry Collector の設定

yaml# otel-collector-config.yaml - レシーバーの設定
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

OTLP プロトコルでデータを受信する設定を行います。

yaml# プロセッサの設定
processors:
  batch:
    timeout: 10s
    send_batch_size: 1024
  memory_limiter:
    check_interval: 1s
    limit_mib: 512

バッチ処理とメモリ制限を設定し、Collector の安定性を確保します。

yaml# エクスポーターの設定
exporters:
  jaeger:
    endpoint: jaeger:14250
    tls:
      insecure: true
  logging:
    loglevel: debug

Jaeger にデータを送信する設定と、デバッグ用のログ出力を設定します。

yaml# パイプラインの定義
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [jaeger, logging]

受信したトレースデータを処理し、Jaeger に送信するパイプラインを定義します。

以下の図は、OpenTelemetry を使った監視システム全体の流れを示しています。

mermaidflowchart LR
    app["FastAPI<br/>アプリケーション"]
    otel["OTel<br/>Collector"]
    jaeger["Jaeger<br/>バックエンド"]
    ui["Jaeger UI"]

    app -->|"OTLP(gRPC)"| otel
    otel -->|"トレース<br/>データ"| jaeger
    jaeger -->|"可視化"| ui
    ui -->|"分析結果"| dev["開発者"]

    dev -->|"改善"| app

アプリケーションから Jaeger UI まで、データが一貫したフォーマットで流れることで、マイクロサービス全体のパフォーマンスを統合的に監視できます。

3 つのツールの使い分け

実際のプロジェクトでは、これらのツールを組み合わせて使用することが効果的です。

#ツール利用シーン環境
1py-spyCPU ボトルネックの特定本番環境
2Scaleneメモリリークの調査開発・ステージング環境
3OTel Profilingマイクロサービス全体の監視本番環境
4py-spy + OTel分散システムでの詳細分析本番環境

たとえば、本番環境で CPU 使用率が高いことを OTel Profiling で検知した場合、該当サービスに py-spy をアタッチして詳細なフレームグラフを取得します。メモリリークが疑われる場合は、ステージング環境で同じ負荷をかけながら Scalene で行単位の分析を実施するといった使い分けが有効です。

まとめ

Python アプリケーションの継続的プロファイリングは、本番環境でのパフォーマンス管理に欠かせない技術となっています。従来のプロファイリングツールが持つ高いオーバーヘッドや本番適用の難しさという課題は、py-spy、Scalene、OpenTelemetry Profiling といった最新ツールによって解決されつつあります。

py-spy は低オーバーヘッドでシンプルな CPU プロファイリングを実現し、本番環境での継続的な監視に最適です。Scalene は CPU、メモリ、GPU を詳細に分析でき、開発段階でのパフォーマンス最適化に強力な威力を発揮します。OpenTelemetry Profiling は分散システム全体を統合的に監視し、マイクロサービスアーキテクチャでのボトルネック特定を可能にします。

これらのツールを適切に使い分け、継続的にプロファイリングデータを収集・分析することで、パフォーマンス問題の早期発見と迅速な改善が実現できるでしょう。プロファイリングを開発フローに組み込み、データドリブンなパフォーマンス改善を進めていくことが、高品質な Python アプリケーションを提供する鍵となります。

まずは開発環境で Scalene を試し、ステージング環境で py-spy を実行し、最終的に本番環境で OpenTelemetry Profiling を導入するという段階的なアプローチをお勧めします。各ツールの特性を理解し、チームの開発フローに合わせて最適な組み合わせを見つけていってください。

関連リンク