更新履歴(7件・最終更新 2026年08月22日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- レビュー指摘に対応し、今日追加した図のうち幅が大きすぎたものを縦向きの構成に直し、一部の図とキャプションの表現を本文の記述に合わせて正確にしました。本文の文章は変えていません。
- 漏れ方の構造や切り分けの順序を図でも追えるように、Mermaid図を14点追加しました(地の文500〜750字につき1図の規約に合わせたものです)。既存の図には通し番号付きのキャプションを付けました。本文の文章は変えていません。
- 記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
- 外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
- 再接続の例で、コールバック登録後に取得開始が失敗したときの後始末を直しました。SDKは登録時に渡したハンドルを保持しているため、登録を外さずにイベントを破棄すると、解放済みのハンドルへシグナルされます。C++版・C#版とも、捨てる前に登録を外す形にしました。
- ハンドル数をどこで見るかの表を追加しました(タスクマネージャーの列の出し方、Process Explorer、`handle -s -p`、`Get-Process`、`typeperf`)。数千個のリークでなぜ落ちるのかを、カーネルハンドルの上限ではなくGDIやSDK内部の管理表、32bitのアドレス空間が先に頭を打つという形で整理し、修正後のコードをC++のRAII型とC#の所有権移譲の形で追加しました。
- 本文中の関連記事へのリンクの文言が、リンク先の現在のタイトルと食い違っていたのを、実際のタイトルに揃えました。本文の内容は変えていません。
- 初版公開
この記事を引用する(DOI(登録済みアーカイブ): 10.5281/zenodo.21589607)
以下のDOIは過去に登録されたアーカイブを指しており、現在の本文とは一致しない場合があります。現在の本文を参照するときは、このページのURLを使用してください。
小村 豪(2026)「産業用カメラ長期稼働クラッシュ調査 - ハンドルリーク編」合同会社小村ソフト. https://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. どの粒度で取るか
- ざっくり使い分け
- まとめ
- 参考資料
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全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 の崩れを追えるようにしておく のが土台です
要するに、こういう案件で先にやるべきなのは、 「長期間のあとで落ちた」ことを眺めることではなく、資源の増え方と失敗経路を観測できる形にすること です。
ハンドルリークは、見つかったときにはすでに二次障害の顔をしていることが多いです。 そのため、落ちた瞬間の例外だけ見ていると、だいぶ見当違いな方向へ歩きがちです。
flowchart TB
accTitle: 先にやるべきことの構図
accDescr: 落ちた瞬間の例外だけを眺めると見当違いの方向へ進みやすく、先に資源の増え方と失敗経路を観測できる形にすることで、二次障害の顔をしたハンドルリークにたどり着けることを示す。
crash["落ちた瞬間の例外だけを見る"] -.-> wrong["見当違いの方向へ歩く"]
obs["資源の増え方と失敗経路を観測"] --> right["漏れの正体にたどり着ける"]
図1: 「落ちた事実」を眺めるより先に、増え方と失敗経路を観測できる形にする。
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 されない
- 普段の短いテストでは成功経路ばかり通るので見逃す
このタイプは、コードレビューでも実運用でも、かなり普通に潜ります。
flowchart TB
accTitle: 失敗経路でだけ漏れる典型パターン
accDescr: 再接続のたびにeventを作り、callback登録や撮像開始が成功すればcloseされるが、途中失敗の経路ではcloseされずに漏れ、短いテストは成功経路ばかり通るため見逃されることを示す。
ev["再接続のたびにeventを作る"] --> q{"登録・開始は成功したか"}
q -->|"成功"| close["success pathでcloseされる"]
q -->|"途中失敗"| leak["failure pathでcloseされない"]
leak -.-> unseen["短いテストは成功経路ばかりで見逃す"]
図2: 一時的に開いた資源を途中失敗の経路で閉じ忘れる。制御アプリで特に多い形。
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[別の場所でクラッシュ / 停止]
図3: 1回に1個の小さい漏れが、24/7稼働の境界条件で何百回も積み重なって表面化する。
1 回の reconnect で 1 個しか漏れないなら、数分では何も起きません。 ただ、24/7 で動いている装置制御アプリでは、timeout、再初期化、切断復旧のような境界条件が何度も起きます。 その結果、数週間後にだけ表面化する、という妙な見え方になります。
ここで大事なのは、ハンドルリークそのものがクラッシュ行になるとは限らない ことです。 多いのはこういう壊れ方です。
- 新しい event / file / thread を作る API が失敗する
- SDK が内部で必要な資源を作れず、一般的な失敗コードだけ返す
- 失敗後のエラーハンドリングが薄く、
null/ invalid handle を踏んで落ちる - timeout が増えて、結果として watchdog や上位制御に kill される
つまり、クラッシュ地点は「最後の被害者」であって、「最初の犯人」とは限りません。
flowchart TB
accTitle: クラッシュ地点は最後の被害者
accDescr: どこかでハンドルを漏らし続けると、やがて新しい資源を作るAPIが失敗し、エラーハンドリングの薄い別の場所でクラッシュや停止として表面化するため、落ちた行は最初の犯人ではなく最後の被害者であることを示す。
leak2["どこかでハンドルを漏らし続ける"] --> fail["新しい資源を作るAPIが失敗"]
fail --> vict["別の場所でクラッシュ・停止"]
vict -.-> note["落ちた行は最後の被害者にすぎない"]
図4: リークそのものがクラッシュ行になるとは限らない。壊れ方はたいてい二次障害。
ここで素朴な疑問が出ます。たかだか数千個のハンドルで、なぜ落ちるのか です。
数字だけ見ると、上限はかなり遠いです。カーネルオブジェクトのハンドルは、プロセスあたり 2^24(約 1677 万)が理論上の上限です。ただし、ハンドルはページプールに置かれるので、実際に作れる数は使えるメモリ次第で決まりますし、32bit Windows では理論値よりずっと少なくなります。
要するに、理論上限に達して落ちるケースはむしろ少数派 です。実際に先に効くのは、たいてい次のどれかです。
| 先に頭を打つもの | 目安 | 効く場面 |
|---|---|---|
| GDI オブジェクト | セッションあたり理論上 65,536。加えてプロセスあたりの既定上限があり、レジストリの GDIProcessHandleQuota で 256〜65,536 の範囲で変更できる |
GUI が同居しているアプリ。数千個の桁で普通に頭を打ちます |
| SDK 内部の管理表 | ベンダー次第 | カメラ SDK が内部で持つハンドルテーブルや固定長配列が先に埋まる |
| ページプールなどのカーネル資源 | マシン全体で共有 | ハンドル以外の資源も一緒に食っている場合 |
| 32bit プロセスの仮想アドレス空間 | 2GB / 3GB | ハンドルそのものより、ハンドルに付随して確保されるバッファが効く |
つまり、「上限までまだ余裕があるから大丈夫」という読み方は成り立ちません。上限に達するかどうかではなく、戻るべきものが戻っていないかどうか で見るべきです。傾きが立った時点で、すでに異常だと考えたほうが安全です。
flowchart TB
accTitle: 理論上限より先に頭を打つもの
accDescr: カーネルハンドルの理論上限に達して落ちるケースは少数派で、実際にはGDIオブジェクトの上限、SDK内部の管理表、32bitプロセスのアドレス空間などが先に頭を打つため、戻るべきものが戻っているかで見るべきことを示す。
limit["カーネルの理論上限は約1677万"] -.-> rare["そこまで達するのは少数派"]
rare -.-> first["実際に先に頭を打つもの"]
first --> g1["GDIの上限"]
first --> g2["SDK内部の管理表"]
first --> g3["32bitのアドレス空間"]
g1 --> see["戻るか戻らないかで判断する"]
g2 --> see
g3 --> see
図5: 「上限まで余裕がある」は安心材料にならない。傾きが立った時点で異常と見る。
2.3. メモリリークとの違い
長時間運転後の不具合では、まずメモリリークを疑いたくなります。 もちろんそれ自体は自然ですが、ハンドルリークは別の軸で見たほうが早いことがあります。
| 観点 | メモリリーク | ハンドルリーク |
|---|---|---|
| まず見る指標 | Private Bytes, Commit, Working Set |
Handle Count |
| 典型症状 | メモリ逼迫、paging、遅くなる、OOM | Create* / Open* / SDK 内部初期化失敗、二次障害 |
| 潜みやすい場所 | キャッシュ、参照保持、解放忘れ | create/open と close/dispose の非対称 |
| 見え方 | メモリがじわじわ増える | handle count がじわじわ増えて戻らない |
なので、長時間運転の切り分けでは 「メモリだけを見る」だと片目で運転している状態 になりやすいです。
少なくとも Handle Count と Thread Count は一緒に見た方がかなり整理しやすくなります。
flowchart TB
accTitle: 長時間運転の切り分けで一緒に見る指標
accDescr: メモリリークとハンドルリークは見る指標が違うため、Private Bytesなどのメモリ系だけでなくHandle CountとThread Countも一緒に見ることで、片目で運転する状態を避けられることを示す。
watch["長時間運転の切り分け"] --> m1["メモリ系(Private Bytesなど)"]
watch --> m2["Handle Count"]
watch --> m3["Thread Count"]
m2 -.-> hint["増えて戻らないならハンドルリーク"]
図6: メモリだけを見るのは片目運転。ハンドルとスレッドの数も同じ画面で追う。
3. 事例: 産業用カメラ制御アプリが 1 か月後に突然落ちる
3.1. 起きていた症状
事象はシンプルでした。
- 産業用カメラを制御する Windows アプリが 24/7 で動いている
- 通常時は普通に動く
- 約 1 か月ほどたつと、ある日いきなりアプリが落ちる
- 再起動すると、またしばらくは動く
最初に困るのは、「落ちるまでが長い」 ことです。 1 回ごとの再現に 1 か月待つのは、調査としてかなり厳しいです。
さらに厄介だったのは、落ちる場所が毎回ぴったり同じではなかったことです。 あるときは再接続開始直後、あるときは撮像開始時、あるときは SDK 呼び出しの失敗後でした。
この見え方だと、最初は次のどれも疑えます。
- カメラ SDK 側の不安定さ
- 通信やデバイス切断起因の一時障害
- メモリリーク
- スレッドまわりの race
- ログに出ていない初期化失敗
つまり、「なんとなく怪しいもの」が多すぎる 状態でした。
flowchart TB
accTitle: この事例の調査を難しくした2点
accDescr: 約1か月の連続稼働後にいきなり落ちるため1回の再現に1か月かかることと、落ちる場所が毎回ぴったり同じではないことが重なり、SDKや通信やメモリなど怪しい候補が多すぎる状態だったことを示す。
sym["約1か月後にいきなり落ちる"] --> hard1["1回の再現に1か月かかる"]
sym --> hard2["落ちる場所が毎回少し違う"]
hard2 -.-> many["怪しい候補が多すぎる状態に"]
図7: 「落ちるまでが長い」「落ちる場所が揺れる」の2つが重なると、当てずっぽうでは進めない。
3.2. 最初に見た指標
そこで最初にやったのは、process 全体の資源の増え方を見ることでした。 今回の事例では、観測結果はおおよそこんな傾向でした。
| 指標 | 観測された傾向 | 読み |
|---|---|---|
Handle Count |
reconnect や timeout 後に少しずつ増え、戻らない | ハンドルリークを疑う |
Private Bytes |
増減はあるが、単調増加の傾きは弱い | 主犯が heap とは限らない |
Thread Count |
ほぼ横ばい | thread leak の可能性は低い |
| 落ちる場所 | 毎回少し違う | 二次障害の可能性が高い |
この時点で、視線はかなり絞れました。 「1 か月後に落ちる」のではなく、「途中で何かを少しずつ漏らしていて、その結果 1 か月後に落ちる」 と見たほうが自然だったからです。
flowchart TB
accTitle: 最初の指標観測から絞れた見立て
accDescr: Handle Countだけが増えて戻らず、Private Bytesの傾きは弱く、Thread Countは横ばいで、落ちる場所が毎回違うという観測から、少しずつ漏らした結果1か月後に落ちるという見立てに絞れたことを示す。
o1["Handle Countが増えて戻らない"] --> narrow["視線が絞れる"]
o2["Private Bytesの傾きは弱い"] --> narrow
o3["Thread Countはほぼ横ばい"] --> narrow
narrow --> view["少しずつ漏らして1か月後に落ちる"]
図8: 4つの指標の形を並べると、「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: 二次障害としてクラッシュ
図9: 真因は再接続の失敗経路でのevent handleのclose漏れ。積み重なった果てに別の場所で落ちる。
コードのイメージとしては、こういう漏れです。
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 ではなくコードの責務で表す
flowchart TB
accTitle: 修正方針の骨子
accDescr: createとcloseの責務を近づけ、途中失敗でも必ず解放されるようfinallyやデストラクタやsession object側へ寄せ、誰が閉じるかをコメントではなくコードの責務として表すという修正方針を示す。
pol["修正方針"] --> p1["createとcloseの責務を近づける"]
pol --> p2["解放をfinallyやデストラクタへ寄せる"]
pol --> p3["所有権をコードの責務で表す"]
p3 -.-> nc["コメント頼みの規約にしない"]
図10: 派手な修正ではない。資源の寿命をコードの構造そのものに埋め込む。
文章だけだと分かりにくいので、同じ処理を書き直した形も置いておきます。
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 に肩代わりさせる、ということです。
flowchart TB
accTitle: 所有権の移譲で決まる解放の担い手
accDescr: 処理の途中でどこから抜けても、所有権をsession側へ渡せたなら以後の解放はsessionが担い、渡せていないなら型やfinallyが必ず捨て、捨てる前にはSDKへの登録を外すという構造を示す。
exit["処理の途中でどこから抜けても"] --> q2{"所有権は渡せたか"}
q2 -->|"渡せた"| keep["session側が以後の解放を担う"]
q2 -->|"渡せていない"| drop["型やfinallyが必ず捨てる"]
drop -.-> unreg["捨てる前に登録を外す"]
図11: 「失敗したら閉じる」を毎回書かず、所有権の行き先で解放の担い手を決める。
ここは、特別なテクニックというより、資源寿命をコードに埋め込む整理です。
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[終了時の差分を確認]
図12: 月単位の再現を待たず、open・切断・再接続の境界だけを短いループで何千回も回す。
ポイントは、通常の「撮れている」時間ではなく、境界の寿命操作に時間を使う ことです。
具体的に効くのは、こんなシナリオです。
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 との差が数個以内に収まっているなら、まず健全です
- 逆に、サイクル数に比例してきれいに右肩上がりになるなら、その傾きの分だけ毎回漏れています
見るのは「多いか少ないか」ではなく、戻るか戻らないか です。ここを取り違えると、正常なアプリを疑って時間を溶かします。
flowchart TB
accTitle: Handle Countの傾きの読み方
accDescr: ウォームアップ後にbaselineを決め、1サイクルごとの差分を見て、サイクル後に元へ戻るならまず健全、サイクル数に比例して右肩上がりならその傾きの分だけ毎回漏れていると判断する読み方を示す。
base["ウォームアップ後にbaselineを決める"] --> cyc["1サイクルごとの差分を見る"]
cyc --> q3{"サイクル後に元へ戻るか"}
q3 -->|"戻る"| ok["まず健全"]
q3 -->|"比例して右肩上がり"| ng["傾きの分だけ毎回漏れている"]
図13: 絶対値の多い少ないではなく、「戻るか戻らないか」の形で判断する。
ここでのコツは、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 が無い、という片肺の流れが見つけやすくなります。
flowchart TB
accTitle: 資源ライフサイクルを対で追うログ
accDescr: CreateとCloseを対で記録し、sessionIdとresourceIdで同じ資源を結ぶことで、Createはあるのに Closeが無い片肺の流れを見つけられる。osHandleは再利用されるため単独では追えないことも示す。
lg["CreateとCloseを対で記録する"] --> ids["sessionIdとresourceIdで結ぶ"]
ids --> find["CloseされていないCreateを発見"]
lg -.-> reuse["osHandleは再利用されるので単独では不可"]
図14: プロセス全体の数から漏れ箇所へ降りるには、資源のライフサイクルを対にしたログが要る。
4.4. ハンドルリークは「落ちた場所」ではなく「漏らした場所」を探す
ここはかなり重要です。
ハンドルリークは、よくこういう形で見えます。
- 落ちた行:
CreateEvent失敗 - 本当の漏れ: 数日前から failure path で
CloseHandleが抜けていた
つまり、最後に落ちた API は 被害の出口 であって、原因の入口 とは限りません。
なので、調査の順番としては、
- どの資源が増え続けているかを見る
- どの操作境界で戻っていないかを見る
create/openとclose/disposeの対が崩れている箇所を探す- 最後にクラッシュ地点を読む
この順の方が、だいぶ迷子になりにくいです。
flowchart TB
accTitle: 漏らした場所へたどる調査の順番
accDescr: どの資源が増え続けているかを見て、どの操作境界で戻っていないかを見て、createとcloseの対が崩れている箇所を探し、最後にクラッシュ地点を読むという順番のほうが迷子になりにくいことを示す。
s1["増え続けている資源を見る"] --> s2["戻らない操作境界を見る"]
s2 --> s3["createとcloseの対の崩れを探す"]
s3 --> s4["最後にクラッシュ地点を読む"]
図15: クラッシュ地点は出口にすぎない。入口である「漏らした場所」からたどる。
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 だけで終わらせず、
重要なのは、成功時と失敗時でログの型を変えない ことです。 異常時だけ別形式になると、あとで集計しづらくなります。
flowchart TB
accTitle: 強化した4系統のログ
accDescr: 定期heartbeat、カメラsession単位の境界ログ、資源ライフサイクルログ、エラーの正規化という4系統を組み合わせ、成功時と失敗時で型を変えないことで、あとで原因にたどり着けるログになることを示す。
hb["定期heartbeat(資源の値)"] --> trace["原因にたどり着けるログ"]
bd["sessionの境界ログ"] --> trace
rl["資源ライフサイクルログ"] --> trace
er["エラーの正規化"] --> trace
trace -.-> same["成功時と失敗時で型を変えない"]
図16: ログ量を増やすのではなく、あとで突き合わせられる4系統を揃える。
5.3. どの粒度で取るか
ここでやりがちなのが、「とりあえず全部 INFO で吐く」です。 ただ、それをやると、あとで読むときにログの壁ができます。これはだいぶしんどいです。
粒度としては、だいたい次の分け方が現実的です。
- 定期監視
Handle Count,Private Bytes,Thread Count,ReconnectCount
- 操作境界
- session の start / done / fail
- 資源境界
create/open/registerとclose/dispose/unregister
- 異常時詳細
- error code、stack、dump 採取トリガ
毎フレームの詳細ログは、通常は不要です。 むしろ、「どの責務が開いて、どの責務が閉じたか」 が読めるログのほうが、長時間不具合には効きます。
flowchart TB
accTitle: ログの粒度の分け方
accDescr: 定期監視は資源のカウント、操作境界はsessionの開始と終了、資源境界はcreateとcloseの対、異常時だけ詳細を深く取るという分け方にし、全部をINFOで吐いてログの壁を作らないことを示す。
lv["ログの粒度"] --> l1["定期監視: カウント"]
lv --> l2["操作境界と資源境界"]
lv --> l3["異常時だけ詳細を深く"]
all["全部INFOで吐く"] -.-> wall["読めないログの壁"]
図17: 毎フレームの詳細より、「誰が開いて誰が閉じたか」が読める粒度に揃える。
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 の確認ポイントとあわせて整理します。
Time Travel Debugging ── 長期稼働で再現しない不具合を「録画」して巻き戻す
月に一度しか出ない不具合は、クラッシュダンプでは結果しか写りません。WinDbgのTime Travel Debugging(TTD)で実行を録画して巻き戻す方法を、TTD.exeの録画設計、リングバッファ、TTD.Callsクエリ、ダンプとの使い分けまで解説します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
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で探し、最後にクラッシュ地点を読む順序が迷子になりにくいです。