CI/CDパイプラインを止めるな:失敗したテストのpdbダンプをArtifactとして保存し、ローカルのDevcontainerで完全再現する超実践アーキテクチャ
テックリードの皆さん、日々のCI/CDパイプラインでこんな絶望を味わっていないだろうか。
> 「ローカルでは100%パスするテストが、なぜかGitHub ActionsのLinux環境(Python 3.11)でのみ落ちる。ログには数行のトレースバックがあるだけで、再現のための変数状態が分からない。原因究明のために、わざわざデバッグ用のログ出力コードを追加してコミット・プッシュし、CIをもう一度回す……」
この「CI回し地獄」は、開発チームのベロシティを確実に殺す。1回あたりのパイプライン待ち時間が5分だとすれば、これを3回繰り返すだけで15分が溶ける。Context switchingのコストを考慮すれば、実質的な損失はその倍以上だ。
真にモダンなDevOps環境において、「CIで落ちたテストの現場(メモリ状態)」は、そのまま次のエンジニアへのバトンタッチ用Artifact(成果物)として回収されるべきである。
今回は、Pythonの標準デバッガである `pdb`(および強力な拡張である `IPdb`)のセッションをCI上でキャプチャし、GitHub ActionsのArtifact経由でローカルのDevcontainerに持ち帰り、1秒で「あの瞬間の変数空間」を完全に復活させるプロフェッショナルな運用構成を完全解説する。
—
1. なぜ「ログ」ではなく「pdbダンプ」なのか?
従来のCIトラブルシューティングは、`print` デバッグや、exception時のスタックトレース出力に頼っていた。しかし、複雑なORMのクエリ結果、モックされたAPIレスポンス、マルチスレッドや非同期コンテキストが絡み合う現代のWebアプリケーションにおいて、静的なログから「なぜその分岐に入ったのか」を逆算するのは困難を極める。
Pythonの `pdb` は、ブレークポイントで止めるだけでなく、例外送出時(Post-Mortem)にその場のスタックフレームをオブジェクトとしてキャプチャできる。
import sys
import traceback
import ipdb
def run_test_with_pdb_capture():
try:
# テスト実行コード
target_function_under_test()
except Exception:
# 例外発生時のフレーム情報を取得し、非対話環境でもダンプを保存できるようにする
exc_type, exc_value, exc_tb = sys.exc_info()
# ここでpdbのPost-Mortemセッションをシミュレート、あるいはファイルへリダイレクトする
しかし、CI環境は「非対話型(Headless)」であるため、通常の `breakpoint()` を仕掛けると、標準入力が取得できずにパイプラインが即座にフリーズ、あるいはエラーで強制終了する。
したがって、「CI上では対話入力をエミュレート、あるいは例外時のローカル変数をシリアライズして保存し、ローカルのデバッガーで再ロードする」というブリッジが必要となる。
—
2. 実装:pytestとpdbを連携させるCIトラップ機構
まずは、テストが失敗した瞬間にそのコンテキストを保存する仕組みを構築する。
ここでは、Pythonのテストランナーとしてデファクトである `pytest` と、リッチなデバッグ体験を提供する `IPdb` を組み合わせる。
依存関係の定義 (`pyproject.toml` または `requirements.txt`)
実務では、開発環境(Devcontainer)とCI環境の双方で同一のツールチェーンを保証するため、明示的にピン留めする。
[tool.poetry.dependencies]
python = “^3.11”
pytest = “^7.4.0”
ipdb = “^0.13.13” # シンタックスハイライト、タブ補完が効く最強のpdbラッパー
dill = “^0.3.7” # 標準のpickleよりも広範なオブジェクト(ラムダやジェネレータ等)をシリアライズ可能にする
失敗時自動キャプチャプラグイン (`tests/conftest.py`)
pytestのフック関数 `pytest_runtest_makereport` を利用し、テストが `FAILED` ステータスになった瞬間に、例外のフレーム情報を `dill` を用いてファイルにシリアライズする仕組みを `conftest.py` に埋め込む。
import os
import sys
import traceback
import pytest
import dill
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
“””
テストの実行結果を監視し、失敗(FAILED)した場合には
その時の例外フレームとローカル変数をファイルにダンプするフック関数。
“””
outcome = yield
report = outcome.get_result()
# テストの実行フェーズ(call)で、かつ失敗した場合のみ発火
if report.when == “call” and report.failed:
# CI環境、または明示的にダンプが有効化されている場合のみ実行
if os.environ.get(“CI”) == “true” or os.environ.get(“ENABLE_PDB_DUMP”) == “1”:
excinfo = call.excinfo
if excinfo:
exc_type, exc_value, exc_tb = excinfo._excinfo
# ダンプ保存先のディレクトリを作成
dump_dir = os.path.abspath(“.pdb_dumps”)
os.makedirs(dump_dir, exist_ok=True)
# テスト名をファイル名安全な文字列に置換してダンプファイル名を生成
safe_nodeid = item.nodeid.replace(“/”, “_”).replace(“::”, “__”).replace(“.py”, “”)
dump_path = os.path.join(dump_dir, f”{safe_nodeid}.dill”)
print(f”\n[DevOps Agent] テスト失敗を検知。pdbコンテキストをダンプ中: {dump_path}”)
# フレームからローカル変数等を抽出してシリアライズ
# セキュリティ上の理由から、グローバル変数や秘匿情報(パスワード等)は除外・マスクする処理を入れると尚良い
dump_data = {
“exc_type”: exc_type,
“exc_value”: exc_value,
“tb”: exc_tb,
locals_dict: {
k: v for k, v in excinfo.tb.tb_frame.f_locals.items()
# 必要に応じて巨大なバイナリなどを除外するフィルタリング
}
}
try:
with open(dump_path, “wb”) as f:
dill.dump(dump_data, f)
print(f”[DevOps Agent] ダンプ成功: {dump_path}”)
except Exception as e:
print(f”[DevOps Agent] 警告: ダンプの保存に失敗しました: {e}”, file=sys.stderr)
—
3. GitHub Actionsワークフローの設定
次に、GitHub Actions上でテストが失敗してもワークフローを即座に落とさず(あるいは落とした上で)、生成された `.pdb_dumps/` ディレクトリをArtifactとして確実にアップロードする設定を行う。
`.github/workflows/test.yml` のベストプラクティス構成
name: CI with PDB Artifacts
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: リポジトリのチェックアウト
uses: actions/checkout@v4
- name: Python環境のセットアップ (3.11)
uses: actions/setup-python@v5
with:
python-version: ‘3.11’
cache: ‘pip’
- name: 依存関係のインストール
run: |
python -m pip install –upgrade pip
pip install poetry
poetry config virtualenvs.create false
poetry install –no-interaction –no-ansi
- name: テストの実行 (PDBダンプ有効化)
env:
CI: “true”
ENABLE_PDB_DUMP: “1”
run: |
# テストが失敗しても後続のArtifact保存ステップを実行するため || true を付与、
# あるいはpytestの終了コードをキャプチャする設計にする
poetry run pytest –tb=short || echo “TEST_FAILED=1” >> $GITHUB_ENV
- name: 失敗したテストのPDBダンプをArtifactとして保存
if: always() # テストの成否に関わらず、ダンプディレクトリが存在すれば必ずアップロード
uses: actions/upload-artifact@v4
with:
name: ci-pdb-dumps
path: .pdb_dumps/
retention-days: 3 # デバッグ用なので3日で自動消去しストレージを圧迫しない
if-no-files-found: ignore
- name: テスト結果の判定
if: env.TEST_FAILED == ‘1’
run: |
echo “::error::テストが失敗しました。.pdb_dumps アーティファクトをダウンロードしてローカルで解析してください。”
exit 1
この設定により、CIが赤く染まった際、GitHub ActionsのUIから `ci-pdb-dumps` という名前のZIPアーカイブがダウンロード可能になる。
—
4. Devcontainer連携:1秒でCIのバグ空間へダイブする
ここからが真骨頂である。ダウンロードしたダンプファイルを、手元の開発環境(当然、完全同期された Devcontainer 上)で読み込み、あたかも自分がそのCI環境でテストを実行してブレークポイントで止まったかのように対話デバッグを行う。
ローカルでのリプレイ用スクリプト (`scripts/debug_replay.py`)
プロジェクトルートに、ダンプファイルを読み込んで `ipdb` のポストモーテムセッションを起動するスクリプトを配置する。
!/usr/bin/env python3
“””
CIからダウンロードしたpdbダンプファイルをローカルのDevcontainer環境で
読み込み、インタラクティブなデバッグセッションを復元するスクリプト。
“””
import os
import sys
import glob
import dill
import ipdb
def main():
dump_dir = os.path.abspath(“.pdb_dumps”)
if not os.path.exists(dump_dir):
print(f”エラー: ダンプディレクトリ ‘{dump_dir}’ が見つかりません。”)
print(“GitHub Actionsから ‘ci-pdb-dumps’ アーティファクトをダウンロードし、このディレクトリに展開してください。”)
sys.exit(1)
# ダンプファイルの一覧を取得
dump_files = glob.glob(os.path.join(dump_dir, “.dill”))
if not dump_files:
print(f”エラー: ‘{dump_dir}’ 内に有効な .dill ダンプファイルが存在しません。”)
sys.exit(1)
print(“=== 利用可能な失敗テストのダンプ ===”)
for i, file_path in enumerate(dump_files):
print(f”[{i}] {os.path.basename(file_path)}”)
# ユーザーに対象のダンプを選択させる
try:
choice = input(“\nデバッグするセッションの番号を選択してください [0]: “).strip()
idx = int(choice) if choice else 0
selected_file = dump_files[idx]
except (ValueError, IndexError):
print(“無効な選択です。終了します。”)
sys.exit(1)
print(f”\n[DevOps Agent] ダンプをロード中: {selected_file}”)
with open(selected_file, “rb”) as f:
dump_data = dill.load(f)
exc_type = dump_data[“exc_type”]
exc_value = dump_data[“exc_value”]
tb = dump_data[“tb”]
print(“\n” + “=”60)
print(” 🚀 PDB Post-Mortem セッションへ突入します。”)
print(” ローカル変数、スタックトレースの確認、式の評価が可能です。”)
print(” 終了するには ‘q’ または ‘quit’ を入力してください。”)
print(“=”60 + “\n”)
# 取得した例外のトレースバックを用いてポストモーテムデバッグを開始
ipdb.post_mortem(tb)
if __name__ == “__main__”:
main()
なぜこれが Devcontainer と相性抜群なのか?
Dockerベースの Devcontainer を用いることで、CI環境とローカル開発環境のOS、Pythonバージョン、依存ライブラリのパスが完全に一致する。
もしこれがローカルのホストOS(Mac)とCI(Linux)でパスが異なっていれば、pdbでソースコードを表示しようとした際にファイルが見つからず、デバッグが難航する。
Devcontainer内であれば、CIで起きたエラーの瞬間を、全く同一のファイルパスとメモリ構造のまま、手元のIDE(VS Codeなど)の統合ターミナルで完全に再現できる。
—
5. チーム開発を加速させる:IPdbの神設定とキーボードショートカット
最後に、日々のデバッグ効率を極限まで引き上げるための `IPdb` の設定(`.pdbrc`)を共有する。ホームディレクトリまたはプロジェクトルートに `.pdbrc` を配置することで、デバッグ開始時の初期コマンドを自動化できる。
最強の `.pdbrc` 設定ファイル
.pdbrc – IPdb Initialization Config
画面デザインの調整 (pygmentsのカラースキーマ)
colors = Linux
[alias]
よく使う操作のエイリアス定義(指の移動を最小限にする)
スタックフレームを1つ上に移動
up = u
スタックフレームを1つ下に移動
down = d
現在のフレームのローカル変数を綺麗に一覧表示
ll = longlist
現在の行周辺のコードを表示
n = next
ステップイン
s = step
続行
c = continue
変数の型を表示するショートカット
pt = p type(%1)
辞書やオブジェクトのキーを一覧表示する
keys = p list(%1.keys()) if hasattr(%1, 'keys') else p dir(%1)
[settings]
例外発生時に自動的にポストモーテムに入る設定
store_history = True
現場で即座に使うべきプロのIPdbコマンド
1. `w` (where):
現在のスタックトレースを詳細に表示する。どこから呼び出されてそのエラーに至ったのか、一目で俯瞰できる。
2. `p` / `pp`:
変数の評価。複雑なデータ構造の中身を覗くときは `pp (pretty print)` を使うことで、ネストされたJSONやオブジェクトの構造が整形で出力される。
3. `interact`:
最も強力な機能。現在のブレークポイントのスコープのまま、完全なPythonのインタラクティブシェル(REPL)に移行する。複雑なデータ加工や、バグ修正のコードスニペットの動作確認をその場で試行錯誤できる。
---
まとめ:CIは「テストを通す場所」から「バグを持ち帰る場所」へ進化する
今回のアーキテクチャを導入することで、あなたのチームの開発フローは以下のように劇的に変わる。
1. CIでテストが落ちる
2. 数クリックでArtifact(pdbダンプ)をダウンロード
3. Devcontainerを開き、`python scripts/debug_replay.py` を実行
4. 1秒で「CIが死んだその瞬間のメモリ空間」に立ち会い、原因を特定して修正
「なぜ落ちるのかわからない」という不毛な推測の時間は今日で終わりにしよう。ツールを正しく連携させ、インフラをコード化することで、デバッグは「苦痛な作業」から「秒速の謎解き」へと変わる。
あなたのチームのパイプラインに、今すぐこの仕組みを組み込んでほしい。圧倒的な開発スピードの向上を約束しよう。