1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【.NET/クラウド】OOM Killer !Try-Catchをすり抜ける「完全なる無言のクラッシュ」の正体と非同期ロガーの罠

1
Posted at

【.NET/クラウド】Try-Catchをすり抜ける「完全なる無言のクラッシュ」の正体と非同期ロガーの罠

ローカルPCでは爆速で動作するバッチ処理が、クラウド上のSTG環境や本番環境にデプロイした途端、処理の途中で不定期に消滅する。しかも「エラーログは一切出力されず、Try-Catchにも引っかからない」「CPUやメモリの監視アラートも鳴らない」。

システム運用において、このような「完全なる無言のクラッシュ(Silent Crash)」は原因究明が最も困難な障害の一つです。

本記事では、バックエンドエンジニアやインフラ担当者向けに、マネージドランタイム(.NET等)、非同期ロガー、そしてクラウドインフラが組み合わさった環境で発生する「不可視の障害」のメカニズムと、その解決策について厳密に解説します。

⏱️ 10秒で分かるまとめ

  • 何が起きているの?
    ループ処理などによる一瞬の異常な負荷(マイクロバースト)でメモリが枯渇し、OSがプロセスを強制終了させています。
  • なぜCatchできないの?
    OSからの強制終了(OOM Killer)はアプリケーションの外側からの攻撃(SIGKILL)なので、プログラム内の try-catch は実行されません。
  • なぜログが残らないの?
    Serilogなどの「非同期ロガー」はログを一旦メモリ(キュー)に溜めます。強制終了時、メモリごと吹き飛ぶため未送信のログが消滅します。
  • なぜアラートが鳴らないの?
    クラウドの監視メトリクスは「数分間の平均値」を取るため、数秒〜数十秒の異常なスパイクは平均化されて閾値に届きません。
  • どう対策するの?
    ループ内のログ出力をやめる(I/O負荷軽減)、メモリの増強、そしてCPU監視だけでなくプロセス自体の「死活監視(Uptime Check)」を導入しましょう。

image.png

image.png

📝 前提となる環境とアーキテクチャ

本記事の事象は、以下の技術スタックを組み合わせたモダンな環境で発生しやすいトラップです。

  • ランタイム: .NET (C#) や Java などの、ガベージコレクション(GC)を持つマネージドランタイム
  • ロガー: Serilog などの非同期バッチ処理型ロギングフレームワーク
  • インフラ: クラウド上のIaaS(Compute Engine / EC2 など)で、リソース(特にメモリ)が制限された環境

罠1:「エラーがないからアプリは正常」という誤認

大量のデータを並列処理する際、私たちは当然のように try-catch ブロックを書き、例外発生時には _logger.LogError(ex) のようにログを出力するコードを実装します。それにも関わらずログがない場合、「アプリがエラーを吐いていないのだから、ネットワークや別の要因に違いない」と推測してしまいがちですが、これが第一の罠です。

💡 原因:OOM Killer と非同期ロガーのインメモリキュー消滅

クラウドのVMは、開発用PC(RAM 16GB〜32GB等)と比較してメモリがシビアです。アプリケーションがファイルデータなどを巨大な byte[] としてメモリ空間(.NETであればLOH: Large Object Heap)に一括展開すると、急速にOSの物理メモリが枯渇します。

OSはシステム全体のパニックを防ぐため、OOM Killer (Out Of Memory Killer) を発動させます。これはユーザー空間の例外ハンドラ(try-catch)を一切経由せず、カーネルレベルで対象プロセスに SIGKILL(Windowsの場合は即時のプロセス終了)を送信し、強制終了させます。

ここでモダンな非同期ロガーの仕様が牙を剥きます。
アプリケーションの性能を落とさないため、ロガーは Log.Information() を呼ばれた際、直接DBやファイルに書き込まず、ヒープメモリ上に構築された内部キューに一旦保存(バッファリング)します。
プロセスが即死した瞬間、この「送信待ちだったキュー」もプロセス空間ごとOSに破棄されます。つまり、クラッシュ直前に吐き出していたはずのエラーやトレースログは、永遠に失われるのです。

🔰 初心者向け:ざっくり言うと?
プログラムは「処理しながら毎回データベースに書き込む」と遅くなるため、郵便ポスト(メモリ上のキュー)に一旦手紙(ログ)を溜めておき、郵便屋さん(別スレッド)がまとめて運ぶ仕組みを採用しています。
しかし、メモリ不足でOSに強制終了させられると、郵便ポストごと爆破されてしまいます。そのため「エラーもなく突然消えた」ように見えるのです。

罠2:「CPUは平均30%だから余裕がある」という誤認

プロセスが落ちるほどの負荷がかかったなら、監視システム(Cloud MonitoringやCloudWatch)の「CPU使用率アラート」が鳴るはずだ、というのもよくある罠です。

💡 原因:評価ウィンドウとマイクロバースト(GCスラッシング)

メトリクスのアラートには必ず「評価ウィンドウ(例:1分間や5分間の平均)」が存在します。
メモリが枯渇する直前、ランタイムのガベージコレクタ(GC)は「なんとか空き容量を作らなきゃ!」とパニックになり、フル回転を始めます。この時、GCの掃除処理だけで数十秒間CPUが100%に張り付く現象(GCスラッシング)が起きます。

しかし、数十秒間100%に張り付いた後にプロセスが死んでCPUが0%になると、1分間の平均値としては「30%」など非常にマイルドな数字に丸め込まれてしまいます。このようなマイクロバースト(瞬発的なスパイク)は、標準的な監視網を簡単にすり抜けます。

🔰 初心者向け:ざっくり言うと?
「過去1分間の平均点数が80点以下ならアラートを鳴らす」というルールを設定しているとします。ある生徒が最初の10秒だけ100点分の異常な暴れ方をして、その後すぐに倒れて0点になったとします。1分で平均すると「平均たったの16点」に見えるため、監視システムは「異常なし」と判断してしまいます。

罠3:ローカル変数の Semaphore による並列制御の限界

クラッシュを防ぐため、以下のようにコードを修正して並列数を制限(例:10並列)することがよくあります。

public async Task UploadParallelAsync(IEnumerable<(string FileName, byte[] Data)> uploadDataList)
{
    // 並列数を10に制限
    using var semaphore = new SemaphoreSlim(10);

    var tasks = uploadDataList.Select(async item =>
    {
        await semaphore.WaitAsync();
        try
        {
            // byte[] Data を用いた重いアップロード処理
        }
        finally { semaphore.Release(); }
    });
    await Task.WhenAll(tasks);
}

これでローカルテストは通るかもしれませんが、本番環境では依然として危険です。

💡 原因:セマフォのスコープと byte[] のメモリ一括展開

このコードには2つの問題があります。

  1. リクエスト単位の制限: SemaphoreSlim がメソッド内で宣言されているため、この制限は「1つのリクエスト(または1回のバッチ起動)内」でしか効きません。もしユーザーのアクセスが重なり、このメソッドが同時に5回呼ばれたら、システム全体では 10 × 5 = 50 並列になり、再びサーバが死にます。
  2. データの事前展開: 引数として byte[] Data のリストを受け取っている時点で、このメソッドが呼ばれるに全ファイルデータがメモリ上に展開されています。並列数を絞っても、外側でOOMを引き起こす火種が残っています。

🔰 初心者向け:ざっくり言うと?
「10人ずつお店に入ってね」というルール(セマフォ)を作っても、入り口のドアが複数あって、それぞれのドアで「10人ずつ」と言っていたらお店の中はパンクします。また、お店に入る前に全員に重いリュック(byte配列)を背負わせていたら、床(メモリ)が抜けてしまいます。

本番環境へ向けた解決策とアプローチ(オプション)

この問題を解決するためには、システムの要件に合わせて「コード」「アーキテクチャ」「監視」の3レイヤーで対策を選択する必要があります。

オプションA:コードレイヤーでの根本解決(ストリーミングとグローバル制御)

メモリ枯渇の根本原因を排除するアプローチです。

  • ストリーミング化: ファイルデータを byte[] ではなく Stream で受け渡し、アップロード先(GCSやS3など)のAPIへそのまま流し込みます。これにより、ファイルサイズが数GBであってもメモリ消費はバッファサイズ(数MB)に抑えられます。
  • グローバルな並列制御: アプリケーション全体での同時処理数を制限するため、セマフォを static にするか、ASP.NET Coreの RateLimiter 等のミドルウェア層で制御します。

オプションB:アーキテクチャレイヤーでの解決(非同期ワーカー分離)

Webアプリケーション内で重い処理を待機させないアプローチです。

  • Pub/Subパターンの導入: ユーザーからリクエストを受け取った際、重いデータ処理の「指示」だけをキュー(Cloud Pub/Sub、Amazon SQSなど)に書き込み、フロントエンドには即座にレスポンスを返します。
  • 裏で稼働する独立したワーカー(バッチサーバ等)が、自身の安全なペースでキューからメッセージを取り出して処理します。これにより、ユーザー数が10倍になってもクラッシュすることはなくなります。

オプションC:監視レイヤーでの検知強化(死活監視とOSログ)

アプリが「無言で死ぬ」ことを前提に監視の網を張るアプローチです。

  • ヘルスチェックの徹底: ロードバランサや外形監視(Uptime Check等)から、定期的にアプリケーションの /health エンドポイントを叩きます。CPU張り付きで応答不可になった場合やプロセスが落ちた場合に、即座に異常を検知できます。
  • OSイベントログの監視: プロセスが殺された場合、Linuxであれば syslog(OOM Killerの痕跡)、Windowsであればシステムイベントログ(Event ID: 7034「サービスが予期せず終了しました」)にOS側の記録が必ず残ります。ログ転送エージェントを用いてこれらの特定のイベントを検知し、アラートを発報します。

まとめ

「Try-Catchをすり抜ける」「アラートが鳴らない」という不可視の障害に直面した際は、アプリケーションのコードベースだけを見つめていても解決しません。
OSのメモリ管理、非同期ロガーの仕様、そしてクラウドの監視メトリクスの計算方法といった「インフラとランタイムの境界」の知識を組み合わせることで、初めて真の原因に辿り着くことができます。

💡 単語一覧

  • OOM Killer (Out Of Memory Killer): メモリ不足時にシステム全体がフリーズするのを防ぐため、OSが最もメモリを消費しているプロセスを強制終了させる仕組み。
  • GC (Garbage Collection): プログラムが使い終わったメモリを自動的に回収・整理する機能。
  • LOH (Large Object Heap): .NET環境において、85,000バイト以上の大きなデータを格納する専用のメモリ領域。断片化しやすく、メモリ枯渇の原因になりやすい。
  • SIGKILL: Linux等のOSがプロセスに対して送る「強制終了」のシグナル。プロセスは拒否や後処理をすることができず、即座に消滅する。
  • マイクロバースト: ネットワークやCPUにおいて、ミリ秒〜数十秒単位の極めて短時間に発生する強烈な負荷のスパイク。分単位の平均値監視では発見が難しい。
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?