産業用カメラ長期稼働クラッシュ調査 - ハンドルリーク編

· 更新日: · · Windows開発, 不具合調査, 産業用カメラ, ハンドルリーク, ログ設計

更新履歴(5件・最終更新 2026年08月02日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
再接続の例で、コールバック登録後に取得開始が失敗したときの後始末を直しました。SDKは登録時に渡したハンドルを保持しているため、登録を外さずにイベントを破棄すると、解放済みのハンドルへシグナルされます。C++版・C#版とも、捨てる前に登録を外す形にしました。
ハンドル数をどこで見るかの表を追加しました(タスクマネージャーの列の出し方、Process Explorer、`handle -s -p`、`Get-Process`、`typeperf`)。数千個のリークでなぜ落ちるのかを、カーネルハンドルの上限ではなくGDIやSDK内部の管理表、32bitのアドレス空間が先に頭を打つという形で整理し、修正後のコードをC++のRAII型とC#の所有権移譲の形で追加しました。
本文中の関連記事へのリンクの文言が、リンク先の現在のタイトルと食い違っていたのを、実際のタイトルに揃えました。本文の内容は変えていません。
初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589607)

この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。

小村 豪(2026)「産業用カメラ長期稼働クラッシュ調査 - ハンドルリーク編」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589607 https://staging.comcomponent.com/blog/2026/03/11/002-handle-leak-industrial-camera-long-run-crash-part1/

DOI(最新版)
10.5281/zenodo.21589607
DOI(この版)
10.5281/zenodo.21732632

Windows アプリが長時間運転後に突然落ちるとき、最初にメモリリークを疑いたくなることはかなり多いです。 ただ、実際には ハンドルリーク が主犯で、数週間後にようやく二次障害として表面化している、というケースも少なくありません。

今回紹介するのは、産業用カメラを制御している Windows アプリが、約 1 か月の連続稼働後に突然落ちる事象を調査した事例です。切り分けを進めた結果、原因は カメラ再接続まわりの失敗経路で起きていたハンドルリーク でした。

前編では、ハンドルリークとは何か、この事象をどう切り分けたか、再発防止のためにどんなログを残すべきかを整理します。 後編では、Application Verifierで作るWindows異常系テスト基盤 として、異常系テスト基盤の話をします。

固有名詞や一部のログ項目は伏せていますが、考え方そのものは Windows の装置制御アプリ全般でかなり共通です。

目次

  1. まず結論(ひとことで)
  2. ハンドルリークとは何か
    • 2.1. ここでいう「ハンドル」
    • 2.2. なぜ長時間運転でだけ表面化しやすいのか
    • 2.3. メモリリークとの違い
  3. 事例: 産業用カメラ制御アプリが 1 か月後に突然落ちる
    • 3.1. 起きていた症状
    • 3.2. 最初に見た指標
    • 3.3. 真因だった漏れ箇所
  4. どう切り分けたか
    • 4.1. 月単位の再現を待たずに時間短縮する
    • 4.2. Handle Count の傾きで見る
    • 4.3. create/openclose/dispose の対応を見る
    • 4.4. ハンドルリークは「落ちた場所」ではなく「漏らした場所」を探す
  5. 再発防止のために必要なログ
    • 5.1. まず残すべき最小セット
    • 5.2. 実際に強化したログ
    • 5.3. どの粒度で取るか
  6. ざっくり使い分け
  7. まとめ
  8. 参考資料

この記事の知識マップ

この記事は、産業用カメラ制御アプリが約1か月の連続稼働後に突然落ちた事例をもとに、ハンドルリークの見つけ方とログ設計を整理したものです。ハンドルリークはtimeoutやreconnectのfailure pathでCreateEventのハンドルがCloseHandleされずに漏れることで起き、戻らないHandle Countとして表れ、GUIが同居するアプリではGDIオブジェクトの上限に先に達して二次障害として表面化します。対策はRAII型やfinallyで所有権と解放の責務を近づけ、CloseHandleの呼び忘れそのものを防ぐことです。長時間運転後クラッシュの切り分けでは、月単位の再現を待たずfailure pathを短いループで大量に踏み、Handle Count・Private Bytes・Thread Countと構造化ログを併せて見ることが土台になり、後編のApplication Verifierはその上に乗る手段として位置付けられます。

産業用カメラ長期稼働クラッシュ調査(ハンドルリーク編)の知識マップ長時間運転後クラッシュの切り分けでHandle CountとPrivate Bytes・Thread Countを併せて見ること、failure pathでの解放漏れがハンドルリークを経て二次障害として表面化すること、RAIIとCloseHandleによる防止、構造化ログとApplication Verifierの位置付けを示す図。原因になり得るで確認できるで確認できる前提とする推奨される対応より先に行うべき防止する利用する軽減する原因になり得る推奨される対応防止する原因になり得る推奨される対応推奨される対応推奨される対応推奨される対応推奨される対応ハンドルリークHandle Countfailure path(異常系の途中失敗経路)メモリリークPrivate BytesCreateEventCloseHandle構造化ログ(structured log)Application VerifierRAII(Resource Acquisition Is Initialization)二次障害(secondary failure)GDIオブジェクト長時間運転後クラッシュの切り分けThread Count

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全18件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

1. まず結論(ひとことで)

  • 長時間運転後にだけ落ちる制御アプリでは、Private Bytes だけでなく Handle Count も必ず見る
  • ハンドルリークは、通常系ではなく timeout / reconnect / 途中失敗 / early return の経路に潜みやすい
  • 実際に落ちる行は、漏らした場所ではなく、後から新しいハンドルを作れなくなった場所であることが多い
  • まず必要なログは、operation/session の文脈、process の handle count、resource の open/close 対応、Win32 / HRESULT / SDK エラー
  • 月単位の再現を待つより、接続・切断・再接続・失敗経路を短いループで何千回も回した方が早い
  • 後編で触れる Application Verifier はかなり有効ですが、その前に 自前のログで lifetime の崩れを追えるようにしておく のが土台です

要するに、こういう案件で先にやるべきなのは、 「長期間のあとで落ちた」ことを眺めることではなく、資源の増え方と失敗経路を観測できる形にすること です。

ハンドルリークは、見つかったときにはすでに二次障害の顔をしていることが多いです。 そのため、落ちた瞬間の例外だけ見ていると、だいぶ見当違いな方向へ歩きがちです。

2. ハンドルリークとは何か

2.1. ここでいう「ハンドル」

ここでいうハンドルは、Windows のプロセスが OS の資源を参照するための識別子です。 対象になるのは、たとえばこういうものです。

分類
カーネルオブジェクト event, mutex, semaphore, thread, process, waitable timer
I/O 系 file, pipe, socket, device への open
装置制御でよく出るもの カメラ SDK の内部 event、callback 登録に紐づく待機オブジェクト、撮像スレッド関連ハンドル

制御アプリで特に問題になりやすいのは、「ある操作のために一時的に開いた資源を、途中失敗の経路で閉じ忘れる」 パターンです。

典型的にはこういう流れです。

  • 再接続のたびに event を 1 個作る
  • callback 登録や撮像開始が途中で失敗する
  • success path では close されるが、failure path では close されない
  • 普段の短いテストでは成功経路ばかり通るので見逃す

このタイプは、コードレビューでも実運用でも、かなり普通に潜ります。

2.2. なぜ長時間運転でだけ表面化しやすいのか

ハンドルリークは、1 回で派手に壊れるとは限りません。 むしろ厄介なのは、1 回の失敗で 1 個だけ漏れる ような、小さい傾きの漏れです。

通常運転たまに timeout / reconnect失敗経路で Event Handle を作るCloseHandle が呼ばれないHandle Count が少しだけ増える何百回も繰り返すCreateEvent / SDK open が失敗別の場所でクラッシュ / 停止

1 回の reconnect で 1 個しか漏れないなら、数分では何も起きません。 ただ、24/7 で動いている装置制御アプリでは、timeout、再初期化、切断復旧のような境界条件が何度も起きます。 その結果、数週間後にだけ表面化する、という妙な見え方になります。

ここで大事なのは、ハンドルリークそのものがクラッシュ行になるとは限らない ことです。 多いのはこういう壊れ方です。

  • 新しい event / file / thread を作る API が失敗する
  • SDK が内部で必要な資源を作れず、一般的な失敗コードだけ返す
  • 失敗後のエラーハンドリングが薄く、null / invalid handle を踏んで落ちる
  • timeout が増えて、結果として watchdog や上位制御に kill される

つまり、クラッシュ地点は「最後の被害者」であって、「最初の犯人」とは限りません。

ここで素朴な疑問が出ます。たかだか数千個のハンドルで、なぜ落ちるのか です。

数字だけ見ると、上限はかなり遠いです。カーネルオブジェクトのハンドルは、プロセスあたり 2^24(約 1677 万)が理論上の上限です。ただし、ハンドルはページプールに置かれるので、実際に作れる数は使えるメモリ次第で決まりますし、32bit Windows では理論値よりずっと少なくなります。

要するに、理論上限に達して落ちるケースはむしろ少数派 です。実際に先に効くのは、たいてい次のどれかです。

先に頭を打つもの 目安 効く場面
GDI オブジェクト セッションあたり理論上 65,536。加えてプロセスあたりの既定上限があり、レジストリの GDIProcessHandleQuota で 256〜65,536 の範囲で変更できる GUI が同居しているアプリ。数千個の桁で普通に頭を打ちます
SDK 内部の管理表 ベンダー次第 カメラ SDK が内部で持つハンドルテーブルや固定長配列が先に埋まる
ページプールなどのカーネル資源 マシン全体で共有 ハンドル以外の資源も一緒に食っている場合
32bit プロセスの仮想アドレス空間 2GB / 3GB ハンドルそのものより、ハンドルに付随して確保されるバッファが効く

つまり、「上限までまだ余裕があるから大丈夫」という読み方は成り立ちません。上限に達するかどうかではなく、戻るべきものが戻っていないかどうか で見るべきです。傾きが立った時点で、すでに異常だと考えたほうが安全です。

2.3. メモリリークとの違い

長時間運転後の不具合では、まずメモリリークを疑いたくなります。 もちろんそれ自体は自然ですが、ハンドルリークは別の軸で見たほうが早いことがあります。

観点 メモリリーク ハンドルリーク
まず見る指標 Private Bytes, Commit, Working Set Handle Count
典型症状 メモリ逼迫、paging、遅くなる、OOM Create* / Open* / SDK 内部初期化失敗、二次障害
潜みやすい場所 キャッシュ、参照保持、解放忘れ create/openclose/dispose の非対称
見え方 メモリがじわじわ増える handle count がじわじわ増えて戻らない

なので、長時間運転の切り分けでは 「メモリだけを見る」だと片目で運転している状態 になりやすいです。 少なくとも Handle CountThread Count は一緒に見た方がかなり整理しやすくなります。

3. 事例: 産業用カメラ制御アプリが 1 か月後に突然落ちる

3.1. 起きていた症状

事象はシンプルでした。

  • 産業用カメラを制御する Windows アプリが 24/7 で動いている
  • 通常時は普通に動く
  • 約 1 か月ほどたつと、ある日いきなりアプリが落ちる
  • 再起動すると、またしばらくは動く

最初に困るのは、「落ちるまでが長い」 ことです。 1 回ごとの再現に 1 か月待つのは、調査としてかなり厳しいです。

さらに厄介だったのは、落ちる場所が毎回ぴったり同じではなかったことです。 あるときは再接続開始直後、あるときは撮像開始時、あるときは SDK 呼び出しの失敗後でした。

この見え方だと、最初は次のどれも疑えます。

  • カメラ SDK 側の不安定さ
  • 通信やデバイス切断起因の一時障害
  • メモリリーク
  • スレッドまわりの race
  • ログに出ていない初期化失敗

つまり、「なんとなく怪しいもの」が多すぎる 状態でした。

3.2. 最初に見た指標

そこで最初にやったのは、process 全体の資源の増え方を見ることでした。 今回の事例では、観測結果はおおよそこんな傾向でした。

指標 観測された傾向 読み
Handle Count reconnect や timeout 後に少しずつ増え、戻らない ハンドルリークを疑う
Private Bytes 増減はあるが、単調増加の傾きは弱い 主犯が heap とは限らない
Thread Count ほぼ横ばい thread leak の可能性は低い
落ちる場所 毎回少し違う 二次障害の可能性が高い

この時点で、視線はかなり絞れました。 「1 か月後に落ちる」のではなく、「途中で何かを少しずつ漏らしていて、その結果 1 か月後に落ちる」 と見たほうが自然だったからです。

3.3. 真因だった漏れ箇所

最終的に原因だったのは、カメラ再接続時の初期化失敗経路で作成した event handle の close 漏れ でした。

流れを簡略化すると、こうなります。

カメラSDKWindows制御アプリカメラSDKWindows制御アプリfailure path で returnCloseHandle が呼ばれないloop[何度も reconnect]CreateEventcallback 登録途中失敗 / timeoutHandle Count が少しずつ増える次の CreateEvent / Open失敗二次障害としてクラッシュ

コードのイメージとしては、こういう漏れです。

handle = CreateEvent(...)

if (!RegisterCallback(handle))
{
    return Error;   // CloseHandle(handle) が抜けている
}

if (!StartAcquisition())
{
    return Error;   // ここでも close が抜ける
}

...
CloseHandle(handle)

短いテストで見逃しやすい理由も、かなり分かりやすいです。

  • 正常起動 -> 正常終了では close される
  • 失敗するのは reconnect の途中だけ
  • その failure path を大量に踏むテストが無い
  • 本番では数週間かけて少しずつ蓄積する

つまり、「通常系だけ見ていると見えないが、異常系では普通に漏れる」 という構造でした。

修正方針は派手ではありません。

  • create/openclose/dispose の責務を近づける
  • 途中失敗でも必ず解放されるように finally / destructor / session object 側へ寄せる
  • callback 登録や撮像開始の前後で ownership を明確にする
  • 「誰が閉じるか」を comments ではなくコードの責務で表す

文章だけだと分かりにくいので、同じ処理を書き直した形も置いておきます。

C++ なら、ハンドルを持つ小さな RAII 型を 1 個用意して、生の HANDLE を関数の中に置かないようにします。

// C++17 / Windows
#include <windows.h>
#include <utility>

class UniqueHandle
{
public:
    UniqueHandle() noexcept = default;
    explicit UniqueHandle(HANDLE h) noexcept : h_(h) {}

    UniqueHandle(const UniqueHandle&) = delete;
    UniqueHandle& operator=(const UniqueHandle&) = delete;

    UniqueHandle(UniqueHandle&& other) noexcept
        : h_(std::exchange(other.h_, nullptr)) {}

    UniqueHandle& operator=(UniqueHandle&& other) noexcept
    {
        if (this != &other)
        {
            reset(std::exchange(other.h_, nullptr));
        }
        return *this;
    }

    ~UniqueHandle() { reset(); }

    HANDLE get() const noexcept { return h_; }
    explicit operator bool() const noexcept { return h_ != nullptr; }

    void reset(HANDLE h = nullptr) noexcept
    {
        if (h_ != nullptr)
        {
            ::CloseHandle(h_);
        }
        h_ = h;
    }

private:
    HANDLE h_ = nullptr;
};

これを使うと、失敗経路に CloseHandle を書き足す必要がなくなります。

// CameraSession のメンバー: UniqueHandle frameReady_;
bool CameraSession::Reconnect()
{
    UniqueHandle frameReady{ ::CreateEventW(nullptr, TRUE, FALSE, nullptr) };
    if (!frameReady)
    {
        return false;   // 作成自体が失敗。閉じるものはない
    }

    if (!RegisterCallback(frameReady.get()))
    {
        return false;   // ここで return しても、デストラクタが閉じる
    }

    if (!StartAcquisition())
    {
        // 登録が済んだあとの失敗は、閉じる前に登録を外す。
        // 外さずに抜けるとデストラクタは CloseHandle するのに、SDK 側は
        // 渡したハンドルを持ったまま残る。次のフレームで解放済みの番号へ
        // シグナルすることになり、その番号が別の資源に再利用されていれば
        // 「無関係なイベントが勝手に立つ」という形で表に出てくる
        UnregisterCallback();
        return false;
    }

    // 成功したときだけ、所有権を session 側へ移す
    frameReady_ = std::move(frameReady);
    return true;
}

C# なら、using の一発で済ませられないケースが多いので、「所有権を渡せたかどうか」をフラグにして、渡せなかったときだけ finally で捨てる 形にします。単純に using var と書くと、成功したときまで破棄されてしまうためです。

// C# / .NET 8
// CameraSession のフィールド: private ManualResetEvent? _frameReady;
public bool Reconnect()
{
    var frameReady = new ManualResetEvent(false);
    var handedOver = false;
    var registered = false;

    try
    {
        if (!RegisterCallback(frameReady))
        {
            return false;
        }

        registered = true;

        if (!StartAcquisition())
        {
            return false;
        }

        _frameReady?.Dispose();
        _frameReady = frameReady;
        handedOver = true;
        return true;
    }
    finally
    {
        if (!handedOver)
        {
            // 捨てる前に、外部が握っている参照を先に外す。
            // SDK は登録時に渡したハンドルを保持しているので、
            // 順序を逆にすると解放済みのハンドルを叩かれる
            if (registered)
            {
                UnregisterCallback();
            }

            frameReady.Dispose();
        }
    }
}

どちらも、やっていることは同じです。途中でどこから抜けても、所有者が決まっていない資源は必ず捨てられる という構造にしています。「失敗したら閉じる」を人間が毎回書くのではなく、型と finally に肩代わりさせる、ということです。

ここは、特別なテクニックというより、資源寿命をコードに埋め込む整理です。

4. どう切り分けたか

この章から、調査まわりの英語がそのまま出てきます。先に短く訳注を置いておきます。

用語 日本語で言うと この記事での意味
baseline 基準値 ウォームアップが終わって落ち着いた時点の値。ここからの差分で見ます
leakSlope 漏れの傾き 1 サイクルあたり何個増えたか。増え方の速さを表す自作の指標です
structured log 構造化ログ 文章ではなく、key=value のように項目を決めて出すログ。あとで機械的に集計できます
heartbeat 定期報告 一定間隔で、生存確認と資源の値を出し続けるログ
harness 試験用の外枠 本体アプリの代わりに、試したい処理だけを繰り返し動かす小さな実行プログラム
phase 局面 OpenStartReconnectStart のような、いま処理のどの段階にいるかの印

4.1. 月単位の再現を待たずに時間短縮する

こういう調査で、1 か月を毎回待つのは筋が悪いです。 やるべきなのは、怪しい経路を短時間に何度も通すこと です。

今回の事例では、こういうループを回して再現を圧縮しました。

はいいいえ起動カメラ open撮像開始擬似 timeout / 切断再接続撮像再開N回繰り返す終了時の差分を確認

ポイントは、通常の「撮れている」時間ではなく、境界の寿命操作に時間を使う ことです。

具体的に効くのは、こんなシナリオです。

  • open -> start -> stop -> close を大量に回す
  • timeout を意図的に発生させて reconnect を回す
  • callback 登録直後に失敗させる
  • 切断中断、再接続中断、shutdown 競合を入れる

1 か月分の実運用を完璧に再現する必要はありません。 むしろ、疑っている lifetime edge を何千回も踏む ほうが、原因にはずっと近いです。

4.2. Handle Count の傾きで見る

その前に、Handle Count はどこで見るのか を書いておきます。ここが分からないと、この節は全部絵に描いた餅です。

手段 操作 向いている場面
タスクマネージャー 「詳細」タブを開き、列見出しを右クリック →「列の選択」→「ハンドル」にチェックを入れる いま何個かをすぐ見たい
Process Explorer プロセスを選んでプロパティを開き、Process Performance タブの Handle Count を見る。下ペインの Handles ビューを Type 順に並べると種類別の内訳も分かる 何のハンドルが増えているか知りたい
handle.exe handle -s -p CameraApp で種類別の集計をテキストで取る 定点観測をログに残したい
PowerShell Get-Process -Name CameraApp \| Select-Object Name, Id, HandleCount スクリプトで定期取得したい
typeperf typeperf "\Process(CameraApp)\Handle Count" -si 60 -sc 1440 -o handles.csv CSV で長時間そのまま記録したい
アプリ自身 GetProcessHandleCount または Process.HandleCount を heartbeat ログに埋め込む 本番機でログだけ回収したい

種類別の内訳や、名前なしイベントの増え方の追い方は、Process Explorer / Handle / VMMap実践 側に手順としてまとめてあります。

長期運転の調査で本命になるのは、いちばん下の「アプリ自身が出す」です。人がタスクマネージャーを見張るのは 24/7 では続きません。

ハンドルリーク調査では、絶対値だけ見ても分かりにくいことがあります。 大事なのは、戻るべき操作のあとに戻っているか と、何回の操作で何個増えるか です。

見方としては、だいたい次の順が分かりやすいです。

  1. ウォームアップ後の baseline を決める
  2. reconnect / start-stop / close 後に Handle Count を記録する
  3. 1 サイクルごとの差分を見る
  4. 何サイクルかまとめた傾きも見る

たとえば、こういう見方です。

leakSlope =
    (currentHandleCount - baselineHandleCount)
    / reconnectCount

絶対値 2000 が多いか少ないかは、アプリ次第でぶれます。 ただ、reconnect 1 回につき +1 で戻らない なら、それはかなり怪しいです。

では正常系はどう見えるべきか、という目安も書いておきます。数値そのものはアプリ次第なので、形で判断します。

  • 起動直後は増えます。ここは読みません
  • ウォームアップが終わったら、操作に応じて増減しつつ、一定の範囲を出入りする 形になるはずです
  • open -> start -> stop -> close を 1 サイクル回したあと、値が サイクル前とほぼ同じ に戻るのが正常です
  • 100 サイクル回して、baseline との差が数個以内に収まっているなら、まず健全です
  • 逆に、サイクル数に比例してきれいに右肩上がりになるなら、その傾きの分だけ毎回漏れています

見るのは「多いか少ないか」ではなく、戻るか戻らないか です。ここを取り違えると、正常なアプリを疑って時間を溶かします。

ここでのコツは、Handle Count 単独で見るのではなく、最低でも次を併記することです。

  • Handle Count
  • Private Bytes
  • Thread Count
  • ReconnectCount
  • 今どの phase か

これで、「メモリが増えているのか」「スレッドが増えているのか」「再接続のたびに資源が戻っていないのか」がかなり早く分かります。

4.3. create/openclose/dispose の対応を見る

process 全体の Handle Count が怪しいと分かっても、それだけでは漏れ箇所までは行けません。 次に必要なのは、資源のライフサイクルを対にして見るログ です。

イメージとしては、こういう structured log です。

CameraSession session=421 cameraId=CAM01 phase=ReconnectStart reason=FrameTimeout handleCount=1824 privateBytesMB=418

CameraResource session=421 resourceId=evt-884 kind=Event name=FrameReady action=Create osHandle=0x00000ABC handleCount=1825

CameraResource session=421 resourceId=evt-884 kind=Event name=FrameReady action=Close osHandle=0x00000ABC handleCount=1824

ここで大事なのは、osHandle だけに頼らないことです。 Windows のハンドル値は後で再利用されることがあるので、ログ上では少なくとも次を持たせた方が追いやすいです。

  • sessionId
  • resourceId
  • kind
  • action(Create/Open/Register/Close/Dispose/Unregister)
  • osHandle
  • phase

こうしておくと、Create はあるのに Close が無い、という片肺の流れが見つけやすくなります。

4.4. ハンドルリークは「落ちた場所」ではなく「漏らした場所」を探す

ここはかなり重要です。

ハンドルリークは、よくこういう形で見えます。

  • 落ちた行: CreateEvent 失敗
  • 本当の漏れ: 数日前から failure path で CloseHandle が抜けていた

つまり、最後に落ちた API は 被害の出口 であって、原因の入口 とは限りません。

なので、調査の順番としては、

  1. どの資源が増え続けているかを見る
  2. どの操作境界で戻っていないかを見る
  3. create/openclose/dispose の対が崩れている箇所を探す
  4. 最後にクラッシュ地点を読む

この順の方が、だいぶ迷子になりにくいです。

5. 再発防止のために必要なログ

5.1. まず残すべき最小セット

今回の調査で効いたのは、単にログ量を増やすことではありません。 「あとで原因にたどり着ける情報」を整理して増やすこと でした。

最低限、次は残しておきたいです。

分類 最低限ほしい項目 理由
操作文脈 cameraId, sessionId, operationId, reconnectCount, phase どの操作の何回目で起きたかを結びつけるため
process 資源 handleCount, privateBytes, workingSet, threadCount 何が増えているのかをまず切り分けるため
resource lifecycle action, resourceId, kind, osHandle, owner create/openclose/dispose の対を追うため
外部呼び出し結果 win32Error, HRESULT, sdkError, timeoutMs 失敗の種類を後で比較するため
状態遷移 OpenStart, OpenDone, ReconnectStart, ReconnectDone, ShutdownStart など どの phase の途中で崩れたかを知るため
実行環境 pid, tid, buildVersion, machineName dump / symbol / 配布物との対応を取るため

これで十分、とは言いません。 ただ、少なくともこれが無いと、「落ちた」という事実しか残らないログ になりやすいです。

5.2. 実際に強化したログ

この事例では、ログを次の方向で強化しました。

  1. 定期 heartbeat
    • 1〜5 分おきに Handle Count / Private Bytes / Thread Count / ReconnectCount を出す
  2. カメラ session 単位の境界ログ
    • OpenStart
    • CallbackRegistered
    • AcquisitionStart
    • TimeoutDetected
    • ReconnectStart
    • ReconnectDone
    • CloseStart
    • CloseDone
  3. 資源ライフサイクルログ
    • event / thread / file / timer / SDK registration token の Create/Open/RegisterClose/Dispose/Unregister
  4. エラーの正規化
    • 例外 message だけで終わらせず、win32Error, HRESULT, sdkError, phase を同時に出す

重要なのは、成功時と失敗時でログの型を変えない ことです。 異常時だけ別形式になると、あとで集計しづらくなります。

5.3. どの粒度で取るか

ここでやりがちなのが、「とりあえず全部 INFO で吐く」です。 ただ、それをやると、あとで読むときにログの壁ができます。これはだいぶしんどいです。

粒度としては、だいたい次の分け方が現実的です。

  • 定期監視
    • Handle Count, Private Bytes, Thread Count, ReconnectCount
  • 操作境界
    • session の start / done / fail
  • 資源境界
    • create/open/registerclose/dispose/unregister
  • 異常時詳細
    • error code、stack、dump 採取トリガ

毎フレームの詳細ログは、通常は不要です。 むしろ、「どの責務が開いて、どの責務が閉じたか」 が読めるログのほうが、長時間不具合には効きます。

6. ざっくり使い分け

  • 数日〜数週間後にだけ落ちる
    • まず Handle Count / Private Bytes / Thread Count の heartbeat を入れる
  • retry / reconnect / shutdown がある
    • その境界だけを大量に回す harness を先に作る
  • native SDK / P/Invoke / Win32 を多く使う
    • 後編の Application Verifier を当てる価値が高いです
  • GUI も同居している
    • Handle Count に加えて GDI Objects / USER Objects も見た方がよいです
  • 落ちた瞬間の例外だけでは何も分からない
    • operation / session / resource lifecycle の structured log を先に整えた方が早いです

最後の 1 項目は、かなり大事です。 不具合調査では、解析技術そのものより、観測できる形にしてあるか が勝負を決めることがよくあります。

7. まとめ

長時間運転後にだけ落ちるアプリでは、メモリだけでなく Handle Count も見ること。ハンドルリークは通常系ではなく異常系の failure path に潜みやすく、クラッシュ地点は漏らした地点ではなく二次障害の出口であることが多い。症状の読み方としては、結局この 3 点に尽きます。

再発防止としては、create/openclose/dispose の責務を近づけ、session / operation 単位で文脈を持ったログを残し、process 資源と resource lifecycle の両方を記録しておく。テストでは、月単位の再現を待たずに timeout / reconnect / shutdown を短いループで回し、「壊れないこと」だけでなく「壊れたときに追えること」を合格条件にする。今回効いたのは、この組み合わせです。後編では Application Verifier を使って、メモリ不足やハンドル異常のような出にくい壊れ方を前倒しで表面化させます。

制御アプリでは、正常系が通ることも大事ですが、 壊れたときに「何が起きたか分かる」こと が長期運用ではかなり効きます。

ハンドルリークは、まさにその差が効くタイプの不具合です。 起きた瞬間にだけ見るのではなく、増え方、境界、責務の対で見るようにすると、かなり追いやすくなります。

後編: Application Verifierで作るWindows異常系テスト基盤

8. 参考資料

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

実際の整理や改善の進め方が近い事例ページです。

この記事は次のサービスページにつながります。近い入口からご覧ください。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

ハンドルリークとは何ですか?
Windowsのプロセスがevent、mutex、file、socketなどのOS資源を参照するためのハンドルを閉じ忘れて、Handle Countが増え続けることです。特に、ある操作のために一時的に開いた資源をtimeout・reconnect・early returnなどの途中失敗の経路で閉じ忘れるパターンが多く、普段の短いテストでは成功経路ばかり通るため見逃されやすいです。
メモリリークとハンドルリークはどう見分けますか?
見る指標が違います。メモリリークはPrivate BytesやCommitがじわじわ増えるのに対し、ハンドルリークはHandle Countがじわじわ増えて戻りません。長時間運転の切り分けではメモリだけを見ると片目で運転している状態になりやすいので、Handle CountとThread Countも一緒に見るのが基本です。GUIが同居するならGDI Objects / USER Objectsも見ます。
ハンドルリークがあると、なぜ長時間運転後にだけ落ちるのですか?
1回の失敗で1個だけ漏れるような小さい傾きの漏れは数分では何も起きませんが、24/7稼働ではtimeoutや再接続などの境界条件が何度も起き、数週間かけて蓄積するためです。最終的に新しいevent/file/threadを作るAPIが失敗した時点で二次障害として表面化します。クラッシュ地点は漏らした場所ではなく最後の被害者であることが多い点も重要です。
ハンドルリークはどうやって調査すればよいですか?
月単位の再現を待たず、open -> start -> stop -> close やtimeout・reconnectなど怪しい寿命操作の境界を短いループで何千回も回して再現を圧縮します。ウォームアップ後のbaselineを決めてHandle Countの1サイクルごとの差分と傾きを見て、create/openとclose/disposeの対応が崩れている箇所をsessionId・resourceId・actionを持つstructured logで探し、最後にクラッシュ地点を読む順序が迷子になりにくいです。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る