CI/CDでデバッグを自動化せよ!LLDBによるテスト失敗時の自動ダンプ収集
開発現場において、CI/CDパイプライン上のテストが「原因不明のセグメンテーション違反(SIGSEGV)」や「アサーション失敗」で沈んだ時、あなたはどうしているか。
「ローカル環境では再現しない」「ログには `Process finished with exit code 139` しか残っていない」「コアダンプのサイズ制限や権限設定でハマり、結局コンテナに入って手動でgdb/lldbを叩く羽目になった」——。この非効率なデバッグループに、いい加減終止符を打つべき時が来ている。
本稿では、GitHub Actions等のCI環境において、ネイティブバイナリのテストが異常終了したその瞬間を捕捉し、LLDBを無人(ヘッドレス)モードでアタッチ、自動でバックトレース、レジスタ状態、メモリリークのヒント、そして全スレッドのコンテキストを構造化データとしてダンプし、Artifactsへ封印する完全自動化パイプラインの構築手法を解説する。
単なるマニュアルのコピペではない。コンテナのカーネル制約、ptraceのセキュリティ制約、そしてLLDBのPython API(Scripting Bridge)を極限まで駆動させる、現場のDevOpsアーキテクトが喉から手が出るほど欲しかった知見のすべてをここに開示する。
—
1. 内部アーキテクチャ:なぜCIでコアダンプとLLDBが必要なのか
モダンなCI/CDランナー(GitHub Actionsの `ubuntu-latest` など)は、セキュリティとリソース分離の観点からDockerコンテナ上でビルドとテストを実行する。ここでC/C++やRust、Goなどのネイティブコードがクラッシュした際、OS(Linux)はデフォルトで `core` ファイルを出力しようとするが、以下の壁に阻まれる。
1. `ulimit -c` の制限: コンテナ内のデフォルトではコアダンプのサイズが `0` に制限されている。
2. コアパターンのルーティング: 現代のLinuxディストリビューションは、クラッシュ時に `apport` や `systemd-coredump` などの外部サービスへコアをルーティングするため、単純なカレントディレクトリにファイルが落ちない。
3. シンボルファイルの欠落: ビルド直後のストリップ(Strip)されたバイナリでは、関数名やソースコードの行番号が失われ、スタックトレースがメモリアドレスの羅列と化す。
これらをすべてクリアし、「テストランナープロセスを生きたまま、あるいは死んだ瞬間にLLDBにキャプチャさせ、必要な診断情報を一滴残らず絞り取る」アーキテクチャを設計する必要がある。
—
2. Dockerコンテナ環境における前提条件のハック
CI環境でLLDBを完全自動稼働させるためには、OSレベルのセキュリティ制約を事前に解除しなければならない。特に重要なのが `ptrace` のスコープ設定 と コアダンプの出力許可 だ。
Docker環境でテストを実行する際、コンテナが特権モード(Privileged)でない場合、デフォルトの `ptrace` 制限によりデバッガーがプロセス内部へアタッチすることをカーネルが拒絶する。これを回避するため、CIのランタイム設定やコンテナ起動スクリプトに手を入れる。
実行コンテキストの準備(Dockerfile / CI設定の要点)
デバッグツールとLLDB、LLVMシンボルライブラリの徹底インストール
最新のLLVM/Clang公式リポジトリから安定版のlldbを導入することを強く推奨する
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y –no-install-recommends \
software-properties-common \
gnupg \
wget \
git \
make \
cmake \
# LLDB本体とPython3バインディング
lldb \
liblldb-dev \
python3-lldb \
# デバッグに必要なビルドツール
build-essential \
&& rm -rf /var/lib/apt/lists/
—
3. LLDBを無人駆動させるPython自動化スクリプトの核心
CI環境では人間が対話的にLLDBのプロンプト(`(lldb)`)を操作することはできない。そのため、LLDBが内蔵するPythonインターフェース(`lldb` モジュール)を駆使し、クラッシュ発生時に自動実行されるバッチスクリプト を用意する。
このスクリプトは、プロセスがシグナルを受け取って停止した瞬間、あるいはCIのテストランナーからシグナルをフックして呼び出され、全スレッドのバックトレース、レジスタのDump、メモリの状況をJSONや構造化テキストとしてファイルに出力する。
圧倒的な情報量を吐き出す自動ダンプ・ドライバ (`ci_lldb_dump.py`)
!/usr/bin/env python3
import lldb
import sys
import json
import os
from datetime import datetime
def generate_crash_report(target_path, core_path=None, pid=None):
# LLDBの初期化
debugger = lldb.SBDebugger.Create()
debugger.SetAsync(False)
# ターゲット(実行バイナリ)のロード
target = debugger.CreateTarget(target_path)
if not target.IsValid():
print(f”[Error] Failed to create target from {target_path}”)
sys.exit(1)
process = None
if core_path and os.path.exists(core_path):
# コアファイルからターゲットを復元する場合
print(f”[] Loading core dump from {core_path}”)
process = target.LoadCore(core_path)
elif pid:
# 稼働中のプロセス(または停止中のプロセス)にアタッチする場合
print(f”[] Attaching to PID: {pid}”)
listener = debugger.GetListener()
error = lldb.SBError()
process = target.AttachToProcessWithID(listener, int(pid), error)
if not error.Success():
print(f”[Error] Attachment failed: {error.GetCString()}”)
sys.exit(1)
if not process or not process.IsValid():
print(“[Error] Process is invalid.”)
sys.exit(1)
report = {
“timestamp”: datetime.utcnow().isoformat(),
“target”: target_path,
“threads”: []
}
# スレッドごとのコンテキスト(バックトレース、レジスタ)を全収穫
for thread in process:
thread_info = {
“id”: thread.GetThreadID(),
“name”: thread.GetName() or “unknown”,
“stop_reason”: str(thread.GetStopReason()),
“frames”: []
}
# バックトレースのフレームを深掘り
for frame in thread:
if not frame.IsValid():
continue
line_entry = frame.GetLineEntry()
file_spec = line_entry.GetFileSpec()
frame_info = {
“index”: frame.GetFrameID(),
“function”: frame.GetFunctionName() or “unknown”,
“file”: f”{file_spec.GetDirectory()}/{file_spec.GetFilename()}” if file_spec.IsValid() else “unknown”,
“line”: line_entry.GetLine(),
“pc”: hex(frame.GetPC())
}
# ローカル変数と引数の取得(デバッグビルドであれば強力な情報源になる)
variables = []
value_list = frame.GetVariables(True, True, True, True)
for val in value_list:
variables.append({
“name”: val.GetName(),
“type”: val.GetTypeName(),
“value”: val.GetValue() or “
})
frame_info[“variables”] = variables
thread_info[“frames”].append(frame_info)
report[“threads”].append(thread_info)
# 構造化された診断データをJSONとして保存
output_filename = f”lldb_crash_report_{datetime.now().strftime(‘%Y%m%d_%H%M%S’)}.json”
with open(output_filename, “w”) as f:
json.dump(report, f, indent=4)
print(f”[+] Crash report successfully generated: {output_filename}”)
# デバッガーの後始末
lldb.SBDebugger.Terminate(debugger)
if __name__ == “__main__”:
if len(sys.argv) < 2:
print("Usage: python3 ci_lldb_dump.py
sys.exit(1)
binary = sys.argv[1]
c_path = sys.argv[2] if len(sys.argv) > 2 and not sys.argv[2].isdigit() else None
p_id = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2].isdigit() else None
generate_crash_report(binary, core_path=c_path, pid=p_id)
—
4. GitHub Actions ワークフローへの完全統合
ここからが本番だ。上記のPythonスクリプトとLLDBを、GitHub ActionsのCIパイプラインにシームレスに組み込む。
テストコマンドがクラッシュ(非ゼロ終了、あるいはシグナル検知)した瞬間に、自動でフォールバックトリガーが作動し、バイナリとコア(またはプロセス状態)をLLDBに渡すラッパー機構を構築する。
実践的CIワークフロー設定 (`.github/workflows/test_with_lldb.yml`)
name: Native Test with LLDB Auto-Dump
on:
push:
branches: [ “main”, “master” ]
pull_request:
branches: [ “main”, “master” ]
jobs:
test-and-debug:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install LLDB and Python Dependencies
run: |
sudo apt-get update
sudo apt-get install -y lldb python3-lldb
- name: Configure Linux Core Dump Settings
run: |
# カーネルのコアダンプ出力先とファイル名パターンの設定
sudo sysctl -w kernel.core_pattern=core.%e.%p.%t
# 現在のセッションにおけるコアファイルサイズ制限を無制限に解除
ulimit -c unlimited
echo “Core dump limit set to: $(ulimit -c)”
- name: Build Target with Debug Symbols (-g)
run: |
# 確実にシンボル情報を残すため、最適化を抑えつつデバッグシンボルを付与してビルド
mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Debug ..
make -j$(nproc)
- name: Run Tests with Crash Capture Wrapper
id: run_tests
continue-on-error: true # テスト失敗時でも後続のダンプ収集ステップへ流すため一時的にエラーを吸収
run: |
cd build
# テストバイナリの実行。ここでは例として ‘./my_unit_test’ を想定
# 実行時エラーが発生した場合、exitコードが非ゼロになる
./my_unit_test
echo “TEST_EXIT_CODE=$?” >> $GITHUB_ENV
- name: Trigger LLDB Automated Dump on Failure
if: always() && (steps.run_tests.outcome == ‘failure’ || env.TEST_EXIT_CODE != ‘0’)
run: |
echo “[-] Test execution failed! Initiating LLDB automated context capture…”
cd build
# 生成された最新のコアファイルを特定
CORE_FILE=$(ls -t core.my_unit_test. 2>/dev/null | head -n 1)
BINARY_PATH=”./my_unit_test”
if [ -n “$CORE_FILE” ]; then
echo “[+] Found core dump: $CORE_FILE”
# Pythonスクリプトを呼び出し、コアファイルから完全なバックトレースをJSON化
python3 ../ci_lldb_dump.py “$BINARY_PATH” “$CORE_FILE”
else
echo “[!] Warning: No core dump file found, capturing static binary info via LLDB…”
python3 ../ci_lldb_dump.py “$BINARY_PATH”
fi
- name: Upload LLDB Crash Reports to Artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: lldb-crash-diagnostic-reports
path: build/lldb_crash_report_.json
retention-days: 14
—
5. パフォーマンス・セキュリティ・運用上の最適化ハック
この仕組みを大規模なプロダクション環境のCIに導入する際、シニアアーキテクトが考慮すべき「エッジケースと最適化の知見」を共有する。
1. シンボルファイル(dSYM / PDB / Debug Symbols)の分離と最適化
商用ビルドではバイナリサイズ削減のためにシンボルをストリップ(Strip)するが、CIのデバッグビルドやテスト用ビルドでは絶対にストリップしてはならない。もしバイナリとシンボルを分離している場合(DWARFファイルや `.dSYM` を生成する場合)、LLDBに対してシンボルファイルの明示的なロードをPythonスクリプト側で指示する必要がある:
target.AddModule(lldb.SBFileSpec(“/path/to/symbols.debug”))
これにより、ストリップされた本番同等バイナリであっても、CI上で完璧な関数名解決が可能になる。
2. ディスク容量とアーティファクトの肥大化対策
巨大なC++プロジェクトのコアダンプは数GBに達することがある。GitHub Actionsのストレージ制限やアップロード時間を圧迫するため、`core_pattern` の設定においてシステムライブラリ(libc等)のメモリ領域をダンプから除外するようカーネルパラメータ(`/proc/self/coredump_filter`)を調整せよ。
匿名プライベートメモリとヒープのみをダンプ対象にし、容量を劇的に削減する
echo 0x33 > /proc/self/coredump_filter
3. 非同期(Async)実行時のデッドロック回避
LLDBのPython APIを使用する際、プロセスが長時間ブロックされる可能性がある。CI環境でタイムアウトを引き起こさないよう、LLDBのイベントリスナー(`SBListener`)を用いてタイムアウト機構を実装し、一定時間以上ダンプ処理が完了しない場合は強制終了して部分的なログだけでも回収するガードレールを設けることが、安定したCIパイプライン運用の要諦である。
—
結び:障害対応の「秒速化」がもたらす開発体験
テストが落ちた。画面を見れば、LLDBが自動でクラッシュの瞬間のローカル変数、コールスタック、メモリの断片をJSONに閉じ込め、GitHubのArtifactsに鎮座している。エンジニアは「再現しないバグ」の呪縛から解放され、ダウンロードしたJSONを開くだけで、どの関数のどの変数が不正なポインタを保持していたかを一目で特定できる。
低レイヤのメカニズムを理解し、CIの制約をねじ伏せて構築されたこの自動化の仕組みこそが、真にスケーラブルで強靭なエンジニアリング組織を支える基盤となる。今すぐこのワークフローをあなたのパイプラインに組み込み、デバッグの闇を完全に駆逐せよ。