この記事は、GROOVE X Advent Calendar 2025 の24日目の記事です
はじめに
こんにちは!多忙を理由に記事執筆を後ろ倒ししていたら、クリスマスイブ担当になってしまった sutetako です!🫠 なんということでしょう
LOVOT Frameworkチームという、LOVOTのOSや開発基盤自体を開発するチームに所属しています。なお、専門は音声認識です*1 。
さて、今回は、自社開発に用いられている、C/C++ の Crash Reporting 向けライブラリの仕組みの一端をご紹介したいと思います。
コミットログを見てみたら、作ったのが2019年 と、ずいぶん昔話で、多少レガシーなお話にはなってしまいますが、、、
まぁちょっとだけ面白い仕組みなので、見てみましょう!
そもそも Crash Reportingって?
ソフトウェアはよく死にます 😇
要因は、ロジックミスはもとより、ロボットの場合はより広範です。
各種組み込みデバイス・FW自体の不具合
組み込みOSおよびドライバの不具合
IOエラー
メモリ、ディスク、通信経路
宇宙線によるビット反転
などなど…非常に多岐にわたります。
ロボットの内部で動くソフトウェアは、「常に安定的に稼働し続けること」 が求められます。
多少の例外で死んでしまうようでは、「おお 勇者よ!…」 と、どこからか嘆きの声が聞こえてきそうですね。
あらゆる問題・例外に対応できるようなソフトウェアを完璧に書ければいいのですが…人間様が考えられるエラーケースなど簡単に飛び越えてくるのがロボット開発というものだったりします 🙃 まず無理っす。
そこで登場するのが、Crash Reporting という仕組みです。
クラッシュ(異常終了)時に、エラーが発生したソフトウェア情報、ログ、コールスタックなどをレポートとしてまとめることで、
ファイルシステムに保存しておいて、あとから効率的に解析する
エラー追跡用のプラットフォーム(Sentry など)と連携・レポートを送信し、集約・解析する
などなどすることが可能になります。
C/C++ だとどうやるのがいい?
「まぁ、まず core 吐かせて、後で gdb でごにょごにょ」
手元でデバッグバイナリ使ってTRY&ERRORするなら、全然これでいいと思います。
しかしね…問題が起きるケースって本番想定で組み込んで稼働・検証してる状態だったりするんですよね…したがって:
core のサイズが莫大になるので、ファイルシステムの容量が簡単に枯渇する*2
core を吐く処理で、リソースが枯渇する*3
core を吐いている間は対象のサービスが停止し続けてしまう*4
と、全体のシステム稼働をも阻害する致命的なことが起きます。連続して発生したら、検証どころの話ではありませんね。
そこで登場するのが、Minidump と Breakpad *5 です。
たぶん、Gemini さんに聞いたほうがしっかり解説してくれそう…というところで聞いてみた内容を抜粋紹介すると:
1. Minidump とは何か?
元々は Windows オペレーティングシステムの標準的なクラッシュダンプ形式(.dmp)です。
通常の「フルダンプ」がメモリ全体(数GBになることもある)を保存するのに対し、Minidump はデバッグに必要な最小限の情報のみを保存するため、サイズが数KB〜数MBと非常に小さいのが特徴です。
...
2. Google Breakpad とは?
Google Breakpad は、この Windows 発祥の「Minidump」というフォーマットを、macOS、Linux、Android、iOS など、あらゆるプラットフォームで統一的に扱えるようにした OSS ライブラリです。
Chrome ブラウザや Firefox などで長年採用されてきました。Breakpad が登場する前は、OS ごとに異なるクラッシュレポート形式(LinuxならCore dumpなど)を扱う必要がありましたが、Breakpad のおかげで開発者は「すべてのOSのクラッシュを Minidump 形式で収集・解析する」ことが可能になりました。
...
雑にまとめると、 「ちっちゃくて取り回しのしやすい core っぽいやつ」 です!
これなら、一つのサービスのクラッシュが、全体に与える影響を非常に小さくできます。
実装サンプル
Sentry の解説記事 にわかりやすいものがあるので、日本語コメントつけつつ引用します。
#include "client/linux/handler/exception_handler.h"
using namespace google_breakpad;
namespace {
bool callback (const MinidumpDescriptor &descriptor,
void *context,
bool succeeded) {
return succeeded;
}
}
int main (int argc, char *argv[]) {
MinidumpDescriptor descriptor ("path/to/cache" );
ExceptionHandler eh (
descriptor,
nullptr ,
callback,
nullptr ,
true ,
-1
);
return 0 ;
}
簡単ですね!
なお、動作させるには Breakpad をビルド・リンクさせる必要がありますので、あしからず。
どういうライブラリを作ったの?
前置きが長くなりましたが、ようやく本題です。
上に書いたとおり、Minidump と Breakpad を使えば良さそうです。
でも、思ったんですよね。
「対象バイナリのソースコードにいちいち処理差し込むの面倒すぎね?」
そう、我々の製品内は多量のOSSと自社開発サービスがわんさか立ってるわけで、、、これは布教・普及するの面倒だぞと。
というわけで、対象バイナリを改変することなしに、Minidumpを生成できるようにする 形でライブラリを構築することにしました。
LD_PRELOAD
悪名高いこれを使います。
一応軽く解説しておくと、LD_PRELOAD はLinuxの環境変数で、動的リンク実行時、本来リンク対象ではなかったライブラリにおいても、すべてのリンク対象のライブラリより「先に」読み込ませてシンボル解決させることができる、というやべーやつです。
どうして悪名高いかというと、シンボルを置き換えることができるので、クレデンシャルを受け取るような関数を乗っ取って標準出力することもできたりするわけですね*6 。
一方、ヒープアロケーションの仕組みを拡張して解析やエラー検出に使ったり(Valgrind さんも一部利用)、より効率的なアルゴリズムに置き換えてキャッシュヒット率を上げて処理性能を上げたり(TCMalloc や jemalloc など)等、正の側面も大きい仕組みです。
このように使うイメージですね。
$ LD_PRELOAD =libminidump.so foo_binary
けれど、このままだと「何のシンボルを置き換えるの?」というお話になりそうです。
また、プログラム実行前にBreakpad の設定ができないと、捕捉できる異常終了が限られてきてしまいます。
これを解決するのが関数属性*7 です。
__attribute__((constructor))
詳しくはこちら 。
main 関数の実行「前」に処理を差し込むことができます。
実装例を参考にすると、下記のようにライブラリが書けそうです*8 。
#include "client/linux/handler/exception_handler.h"
using namespace google_breakpad;
static std ::unique_ptr <MinidumpDescriptor> desc (nullptr );
static std ::unique_ptr <ExceptionHandler> eh (nullptr );
static bool callback (const MinidumpDescriptor &descriptor, void *context,
bool succeeded) {
const char *path = descriptor.path ();
if (succeeded) {
}
return succeeded;
}
__attribute__ ((constructor)) void init (void ) {
desc.reset (new MinidumpDescriptor ("/tmp" ));
eh.reset (new ExceptionHandler (*(desc.get ()), nullptr , callback,
nullptr , true , -1 ));
}
簡単ですね!!
これらにより、
LD_PRELOAD で動的リンクを強制・先読み
main 関数より前に Breakpad の初期化処理を差し込む
ことができ、バイナリを変更することなく Crash Reporting 機能の追加ができました 🎉
Coffee Break ☕
せっかくなので、「引っかかった罠」 についても、いくつか紹介しておきます。
Systemd Service の Environment
LD_PRELOAD は環境変数なんだし、Environment に書いておけば、ExecStart に自動適用してくれるよね!
はい、大正解です。
けれど、たとえば下記のような雑な 記述の .service ファイルに適用すると…
[Service]
User=bar
Group=bar
Environment="LD_PRELOAD=libminidump.so"
ExecStartPre=+/bin/mkdir -p /var/log/foo
ExecStartPre=+/bin/chown bar:bar /var/log/foo
ExecStart=/bin/foo_service
ExecStartPost=/bin/rm -rf /var/log/foo
ExecStartPre/Post の関係ないバイナリ群、mkdir, chown, rm にも見事適用してくれます 😇
対象を絞りたい場合は、やや冗長な書き方にはなってしまいますが、下記のように書けます。
[Service]
User=bar
Group=bar
-Environment="LD_PRELOAD=libminidump.so"
ExecStartPre=+/bin/mkdir -p /var/log/foo
ExecStartPre=+/bin/chown bar:bar /var/log/foo
-ExecStart=/bin/foo_service
+ExecStart=/bin/sh -c "LD_PRELOAD=libminidump.so foo_service"
ExecStartPost=/bin/rm -rf /var/log/foo
C++の標準出力
ライブラリ内の init の処理で、下記のようなエラー出力を書いてたんですが、ここに差し掛かると abort する事象が起きました。
std ::cerr << "failed to initialize breakpad" << std ::endl ;
しかも、適用するバイナリによって起きたり起きなかったり。
gdb してごにょごにょ調べてみると、最終的に下記に行き当たります。
(gdb) p std::cout
$1 = {<std::basic_ios<char, std::char_traits<char> >> = <invalid address>, _vptr.basic_ostream = 0x0}
標準出力が初期化されていない という...
実は __attribute__((constructor)) のドキュメント読むとちゃんと書いてました(調べてた当時は気づいてなかったけども)
The order in which constructors for C++ objects with static storage duration are invoked relative to functions decorated with attribute constructor is normally unspecified.
しっかり順番を強制する方法もある模様ですが、こだわるところでもなかったので、下記のようにワークアラウンド。
こだわりたい方は、詳しく追ってもよさそうですね!
- std::cerr << "failed to initialize breakpad" << std::endl;
+ fprintf(stderr, "failed to initialize breakpad\n");
Sentry SDK 使えば自作する必要なかったんじゃね?
当時の日報の抜粋貼っておきますね。
2019/11/05 の日報
まだまだ当時は黎明期だった模様で…
ドキュメント上は「対応してる」とあるものの、実装の中身が空 *9
(やたらと怒ってるのは)その他にもドキュメントと実装の乖離、ビルドの不安定さ、などなどでヘイトが溜まってた
今はそんなことないんだと思います!!
Symbolication
おまけにはなりますが、Symbolication の例についても解説しておきます。
Minidump ファイルそのものにはソースコード含むデバッグ情報は存在しません。
Symbolication は、Minidumpファイルの情報と既知のデバッグ情報群を突き合わせて、ソースコードのどこで異常終了が起きたかを明らかにする処理です。
コールスタックに含まれるアドレス・ライブラリ名や周辺情報から異常終了時の状態を推定して改善することも不可能ではない(実際、初期はそうやってました)んですが、わかりやすいに越したことはないですね!
Sentry を利用した解析
Sentry は Minidumpファイルを受理できるほか、Symbolication にも対応しています。
なお、詳細な手順(Sentryプロジェクトの準備等)を含むと長くなってしまうため、それらは公式のドキュメントに譲ります。
ここでは、Symbolicationまわりを中心としたサンプルベースの解説とさせていただきます。
サンプルコード
下記のようなクラッシュコードでテストしてみます。
void crash () {
volatile int *i = reinterpret_cast <int *>(0x45 );
*i = 5 ;
}
void start () {
crash ();
}
int main (void ) {
start ();
}
DIFs のアップロード
DIFs (Debug Information Files) を Sentryに事前アップロードしておくことで、Sentryが受理したMinidump のバイナリと同じビルドIDのDIFs を突き合わせて解析してくれるようになります。
-g 付きでビルドして、そのバイナリを元に DIFs を生成します。
下記のように、sentry-cli を使ったDIFsの生成とアップロード処理がとても楽なのでおすすめです。CIにも組み込みやすいです。
g++ -g onepass.cpp -o ${YOUR_BINARY}
mkdir -p /tmp/debug
cp ${YOUR_BINARY} /tmp/debug/
sentry-cli difutil bundle-sources -o /tmp/debug ${YOUR_BINARY}
sentry-cli --url ${SENTRY_URL} --auth-token ${SENTRY_AUTH_TOKEN} upload-dif -o ${SENTRY_ORG} -p ${SENTRY_PROJECT} --wait /tmp/debug
デバッグ情報のStrip(Optional)
デバッグ情報はバイナリをとても太らせてしまうので、下記のように取り除いてあげるといいです。
$ objcopy --strip-debug --strip-unneeded ${YOUR_BINARY}
なお、DIFs がちゃんとアップロードされていれば、実行環境で動くバイナリにデバッグ情報がなくても、Symbolication は動作します。
出力例
クラッシュコードを動作させて、
生成されたMinidumpをSentryにアップロードすると、下記のような画面を表示することができます。
Sentry による Symbolication サンプル
やったね 🎉
おわりに
むっかーしに書いた Crash Reporting 向けのライブラリのお話でした。
今でもメンテしつつなんだかんだ使われていたりしますね。
(どこかに仕組み書こうと思ってようやく書けたので、すっきり)
最後まで読んでいただきありがとうございました!
LOVOT Frameworkチームでは、現在エンジニアを募集しています!
本記事を読んで、開発基盤やOS開発にご興味が出てきた方がいらっしゃいましたら、ぜひぜひカジュアルにお声掛けください!!
(音声認識エンジニアも待ってます!!)
recruit.jobcan.jp