更新履歴(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 の装置制御アプリ全般でかなり共通です。
目次
- まず結論(ひとことで)
- ハンドルリークとは何か
- 2.1. ここでいう「ハンドル」
- 2.2. なぜ長時間運転でだけ表面化しやすいのか
- 2.3. メモリリークとの違い
- 事例: 産業用カメラ制御アプリが 1 か月後に突然落ちる
- 3.1. 起きていた症状
- 3.2. 最初に見た指標
- 3.3. 真因だった漏れ箇所
- どう切り分けたか
- 4.1. 月単位の再現を待たずに時間短縮する
- 4.2.
Handle Countの傾きで見る - 4.3.
create/openとclose/disposeの対応を見る - 4.4. ハンドルリークは「落ちた場所」ではなく「漏らした場所」を探す
- 再発防止のために必要なログ
- 5.1. まず残すべき最小セット
- 5.2. 実際に強化したログ
- 5.3. どの粒度で取るか
- ざっくり使い分け
- まとめ
- 参考資料
この記事の知識マップ
この記事は、産業用カメラ制御アプリが約1か月の連続稼働後に突然落ちた事例をもとに、ハンドルリークの見つけ方とログ設計を整理したものです。ハンドルリークはtimeoutやreconnectのfailure pathでCreateEventのハンドルがCloseHandleされずに漏れることで起き、戻らないHandle Countとして表れ、GUIが同居するアプリではGDIオブジェクトの上限に先に達して二次障害として表面化します。対策はRAII型やfinallyで所有権と解放の責務を近づけ、CloseHandleの呼び忘れそのものを防ぐことです。長時間運転後クラッシュの切り分けでは、月単位の再現を待たずfailure pathを短いループで大量に踏み、Handle Count・Private Bytes・Thread Countと構造化ログを併せて見ることが土台になり、後編のApplication Verifierはその上に乗る手段として位置付けられます。
flowchart LR
accTitle: 産業用カメラ長期稼働クラッシュ調査(ハンドルリーク編)の知識マップ
accDescr: 長時間運転後クラッシュの切り分けでHandle CountとPrivate Bytes・Thread Countを併せて見ること、failure pathでの解放漏れがハンドルリークを経て二次障害として表面化すること、RAIIとCloseHandleによる防止、構造化ログとApplication Verifierの位置付けを示す図。
handle_leak["ハンドルリーク"]
handle_count["Handle Count"]
failure_path["failure path(異常系の途中失敗経路)"]
memory_leak["メモリリーク"]
private_bytes["Private Bytes"]
createevent_api["CreateEvent"]
closehandle["CloseHandle"]
structured_logging["構造化ログ(structured log)"]
application_verifier["Application Verifier"]
raii["RAII(Resource Acquisition Is Initialization)"]
secondary_failure["二次障害(secondary failure)"]
gdi_object["GDIオブジェクト"]
long_run_crash_investigation["長時間運転後クラッシュの切り分け"]
thread_count["Thread Count"]
failure_path -->|"原因になり得る"| handle_leak
handle_leak -->|"で確認できる"| handle_count
memory_leak -->|"で確認できる"| private_bytes
createevent_api -->|"前提とする"| closehandle
structured_logging -->|"推奨される対応"| handle_leak
structured_logging -->|"より先に行うべき"| application_verifier
raii -->|"防止する"| handle_leak
raii -.->|"利用する"| closehandle
application_verifier -.->|"軽減する"| handle_leak
handle_leak -->|"原因になり得る"| secondary_failure
structured_logging -->|"推奨される対応"| secondary_failure
closehandle -->|"防止する"| handle_leak
gdi_object -.->|"原因になり得る"| secondary_failure
handle_count -->|"推奨される対応"| long_run_crash_investigation
structured_logging -->|"推奨される対応"| long_run_crash_investigation
failure_path -->|"推奨される対応"| long_run_crash_investigation
private_bytes -->|"推奨される対応"| long_run_crash_investigation
thread_count -->|"推奨される対応"| long_run_crash_investigation
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全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 個だけ漏れる ような、小さい傾きの漏れです。
flowchart LR
A[通常運転] --> B[たまに timeout / reconnect]
B --> C[失敗経路で Event Handle を作る]
C --> D[CloseHandle が呼ばれない]
D --> E[Handle Count が少しだけ増える]
E --> F[何百回も繰り返す]
F --> G[CreateEvent / SDK open が失敗]
G --> H[別の場所でクラッシュ / 停止]
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/open と close/dispose の非対称 |
| 見え方 | メモリがじわじわ増える | handle count がじわじわ増えて戻らない |
なので、長時間運転の切り分けでは 「メモリだけを見る」だと片目で運転している状態 になりやすいです。
少なくとも Handle Count と Thread 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 漏れ でした。
流れを簡略化すると、こうなります。
sequenceDiagram
participant App as 制御アプリ
participant OS as Windows
participant SDK as カメラSDK
App->>OS: CreateEvent
App->>SDK: callback 登録
SDK-->>App: 途中失敗 / timeout
Note over App: failure path で return
Note over App: CloseHandle が呼ばれない
loop 何度も reconnect
App->>OS: Handle Count が少しずつ増える
end
App->>OS: 次の CreateEvent / Open
OS-->>App: 失敗
App-->>App: 二次障害としてクラッシュ
コードのイメージとしては、こういう漏れです。
handle = CreateEvent(...)
if (!RegisterCallback(handle))
{
return Error; // CloseHandle(handle) が抜けている
}
if (!StartAcquisition())
{
return Error; // ここでも close が抜ける
}
...
CloseHandle(handle)
短いテストで見逃しやすい理由も、かなり分かりやすいです。
- 正常起動 -> 正常終了では close される
- 失敗するのは reconnect の途中だけ
- その failure path を大量に踏むテストが無い
- 本番では数週間かけて少しずつ蓄積する
つまり、「通常系だけ見ていると見えないが、異常系では普通に漏れる」 という構造でした。
修正方針は派手ではありません。
create/openとclose/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 | 局面 | OpenStart、ReconnectStart のような、いま処理のどの段階にいるかの印 |
4.1. 月単位の再現を待たずに時間短縮する
こういう調査で、1 か月を毎回待つのは筋が悪いです。 やるべきなのは、怪しい経路を短時間に何度も通すこと です。
今回の事例では、こういうループを回して再現を圧縮しました。
flowchart LR
A[起動] --> B[カメラ open]
B --> C[撮像開始]
C --> D[擬似 timeout / 切断]
D --> E[再接続]
E --> F[撮像再開]
F --> G{N回繰り返す}
G -- はい --> D
G -- いいえ --> H[終了時の差分を確認]
ポイントは、通常の「撮れている」時間ではなく、境界の寿命操作に時間を使う ことです。
具体的に効くのは、こんなシナリオです。
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 では続きません。
ハンドルリーク調査では、絶対値だけ見ても分かりにくいことがあります。 大事なのは、戻るべき操作のあとに戻っているか と、何回の操作で何個増えるか です。
見方としては、だいたい次の順が分かりやすいです。
- ウォームアップ後の baseline を決める
- reconnect / start-stop / close 後に
Handle Countを記録する - 1 サイクルごとの差分を見る
- 何サイクルかまとめた傾きも見る
たとえば、こういう見方です。
leakSlope =
(currentHandleCount - baselineHandleCount)
/ reconnectCount
絶対値 2000 が多いか少ないかは、アプリ次第でぶれます。 ただ、reconnect 1 回につき +1 で戻らない なら、それはかなり怪しいです。
では正常系はどう見えるべきか、という目安も書いておきます。数値そのものはアプリ次第なので、形で判断します。
- 起動直後は増えます。ここは読みません
- ウォームアップが終わったら、操作に応じて増減しつつ、一定の範囲を出入りする 形になるはずです
open -> start -> stop -> closeを 1 サイクル回したあと、値が サイクル前とほぼ同じ に戻るのが正常です- 100 サイクル回して、baseline との差が数個以内に収まっているなら、まず健全です
- 逆に、サイクル数に比例してきれいに右肩上がりになるなら、その傾きの分だけ毎回漏れています
見るのは「多いか少ないか」ではなく、戻るか戻らないか です。ここを取り違えると、正常なアプリを疑って時間を溶かします。
ここでのコツは、Handle Count 単独で見るのではなく、最低でも次を併記することです。
Handle CountPrivate BytesThread CountReconnectCount- 今どの phase か
これで、「メモリが増えているのか」「スレッドが増えているのか」「再接続のたびに資源が戻っていないのか」がかなり早く分かります。
4.3. create/open と close/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 のハンドル値は後で再利用されることがあるので、ログ上では少なくとも次を持たせた方が追いやすいです。
sessionIdresourceIdkindaction(Create/Open/Register/Close/Dispose/Unregister)osHandlephase
こうしておくと、Create はあるのに Close が無い、という片肺の流れが見つけやすくなります。
4.4. ハンドルリークは「落ちた場所」ではなく「漏らした場所」を探す
ここはかなり重要です。
ハンドルリークは、よくこういう形で見えます。
- 落ちた行:
CreateEvent失敗 - 本当の漏れ: 数日前から failure path で
CloseHandleが抜けていた
つまり、最後に落ちた API は 被害の出口 であって、原因の入口 とは限りません。
なので、調査の順番としては、
- どの資源が増え続けているかを見る
- どの操作境界で戻っていないかを見る
create/openとclose/disposeの対が崩れている箇所を探す- 最後にクラッシュ地点を読む
この順の方が、だいぶ迷子になりにくいです。
5. 再発防止のために必要なログ
5.1. まず残すべき最小セット
今回の調査で効いたのは、単にログ量を増やすことではありません。 「あとで原因にたどり着ける情報」を整理して増やすこと でした。
最低限、次は残しておきたいです。
| 分類 | 最低限ほしい項目 | 理由 |
|---|---|---|
| 操作文脈 | cameraId, sessionId, operationId, reconnectCount, phase |
どの操作の何回目で起きたかを結びつけるため |
| process 資源 | handleCount, privateBytes, workingSet, threadCount |
何が増えているのかをまず切り分けるため |
| resource lifecycle | action, resourceId, kind, osHandle, owner |
create/open と close/dispose の対を追うため |
| 外部呼び出し結果 | win32Error, HRESULT, sdkError, timeoutMs |
失敗の種類を後で比較するため |
| 状態遷移 | OpenStart, OpenDone, ReconnectStart, ReconnectDone, ShutdownStart など |
どの phase の途中で崩れたかを知るため |
| 実行環境 | pid, tid, buildVersion, machineName |
dump / symbol / 配布物との対応を取るため |
これで十分、とは言いません。 ただ、少なくともこれが無いと、「落ちた」という事実しか残らないログ になりやすいです。
5.2. 実際に強化したログ
この事例では、ログを次の方向で強化しました。
- 定期 heartbeat
- 1〜5 分おきに
Handle Count/Private Bytes/Thread Count/ReconnectCountを出す
- 1〜5 分おきに
- カメラ session 単位の境界ログ
OpenStartCallbackRegisteredAcquisitionStartTimeoutDetectedReconnectStartReconnectDoneCloseStartCloseDone
- 資源ライフサイクルログ
- event / thread / file / timer / SDK registration token の
Create/Open/RegisterとClose/Dispose/Unregister
- event / thread / file / timer / SDK registration token の
- エラーの正規化
- 例外 message だけで終わらせず、
win32Error,HRESULT,sdkError,phaseを同時に出す
- 例外 message だけで終わらせず、
重要なのは、成功時と失敗時でログの型を変えない ことです。 異常時だけ別形式になると、あとで集計しづらくなります。
5.3. どの粒度で取るか
ここでやりがちなのが、「とりあえず全部 INFO で吐く」です。 ただ、それをやると、あとで読むときにログの壁ができます。これはだいぶしんどいです。
粒度としては、だいたい次の分け方が現実的です。
- 定期監視
Handle Count,Private Bytes,Thread Count,ReconnectCount
- 操作境界
- session の start / done / fail
- 資源境界
create/open/registerとclose/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/open と close/dispose の責務を近づけ、session / operation 単位で文脈を持ったログを残し、process 資源と resource lifecycle の両方を記録しておく。テストでは、月単位の再現を待たずに timeout / reconnect / shutdown を短いループで回し、「壊れないこと」だけでなく「壊れたときに追えること」を合格条件にする。今回効いたのは、この組み合わせです。後編では Application Verifier を使って、メモリ不足やハンドル異常のような出にくい壊れ方を前倒しで表面化させます。
制御アプリでは、正常系が通ることも大事ですが、 壊れたときに「何が起きたか分かる」こと が長期運用ではかなり効きます。
ハンドルリークは、まさにその差が効くタイプの不具合です。 起きた瞬間にだけ見るのではなく、増え方、境界、責務の対で見るようにすると、かなり追いやすくなります。
後編: Application Verifierで作るWindows異常系テスト基盤
8. 参考資料
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
Application Verifierで作るWindows異常系テスト基盤
Application Verifier とは何かを、Handles、Heaps、Low Resource Simulation、!htrace を使った Windows の異常系テスト基盤づくりとあわせて整理します。
障害対応は復旧で終わらない ── 小さな開発チームのためのポストモーテム(再発防止)の型
障害を「直して謝って終わり」にすると同じ障害を繰り返します。blameless postmortemを小規模チーム向けに翻訳し、1時間で書けるテンプレート、再発防止策の強度判断表、実施のトリアージまでをまとめます。
Windowsイベントログ・ETW入門 ── 業務アプリのログをOS標準の仕組みに乗せる
イベントログとETWは、運用担当者やOS標準ツールから見える別レイヤーの記録です。ファイルログを含む3手段の使い分け、.NETからの書き込み方法、EventSourceによるETW計装、収集・調査の実務と落とし穴まで解説します。
TCP再送で産業用カメラ通信が止まる原因と切り分け
TCP 再送で産業用カメラ通信が数秒停止するときの切り分け方を、パケットロス、RTO、RFC1323 タイムスタンプ、Wireshark の確認ポイントとあわせて整理します。
スリープ・休止・Modern Standbyと長時間稼働アプリ ── 「夜中に止まっていた」を設計で防ぐ
長時間動き続けるWindowsアプリが「朝見たら止まっていた」となる原因を、S3スリープ/休止/Modern Standbyの違いから整理します。スリープ中のタイマーやTCP接続の挙動、SetThreadExecutionStateによる抑止まで解説します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
不具合調査 / 長期稼働テーマ
再現しにくい不具合、通信停止、長期稼働障害、失敗パス検証を整理するトピックです。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
不具合調査・原因解析
長期稼働後にだけ落ちる障害の切り分けは、不具合調査・原因解析 として非常に相性がよいテーマです。
Windowsアプリ開発
ログ設計や運用観測を含めて Windows アプリ側の作りを見直したい場合は、Windowsアプリ開発 の相談にもつながります。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- ハンドルリークとは何ですか?
- 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で探し、最後にクラッシュ地点を読む順序が迷子になりにくいです。