C# Tips | ファイル・ディレクトリ操作:ログローテーション

C sharp C#
スポンサーリンク

はじめに なぜ「ログローテーション」が必要になるのか

業務システムを真面目に運用し始めると、必ずと言っていいほど「ログファイルがデカくなりすぎる問題」にぶつかります。
1つのログファイルに延々と書き続けると、数GBになってエディタで開けない、バックアップに時間がかかる、ディスクを圧迫する、といったトラブルが起きます。

そこで登場するのが「ログローテーション」です。
一定の条件(サイズ・日付・起動回数など)でログファイルを切り替え、古いものはリネームしたり削除したりして、ログを“回転”させる仕組みです。
ここでは、C# で自前の簡易ログローテーションユーティリティを書く、という視点でかみ砕いて説明していきます。


ログローテーションの基本設計をイメージする

どんなルールで「ローテーションするか」を決めるか

まず最初に決めるべきは、「どのタイミングでログファイルを切り替えるか」です。
典型的には次のようなパターンがあります。

サイズベース:ログファイルが一定サイズ(例:10MB)を超えたら、新しいファイルに切り替える。
日付ベース:日付が変わったら、新しいファイルに切り替える(1日1ファイル)。

初心者向けに一番分かりやすいのは「サイズベース」です。
「今のログファイルのサイズを見て、閾値を超えたらローテーションする」というシンプルな条件で実装できます。

ローテーションした後のファイル名をどうするか

次に、「古いログファイルをどう扱うか」を決めます。
よくあるのは、次のような命名ルールです。

app.log(現役)
app.log.1(一つ前)
app.log.2(二つ前)

新しいログを作るときに、既存のファイルを順番に繰り上げていくイメージです。
この「番号付きローテーション」は実装が分かりやすく、初心者にもおすすめです。


サイズベースのログローテーションユーティリティを作る

クラスの全体像を先に見る

まずは、「最大サイズと世代数を指定して、ログを書き込むたびにローテーションを判断する」クラスを作ってみます。

using System;
using System.IO;
using System.Text;

public sealed class RotatingLogger : IDisposable
{
    private readonly string _basePath;
    private readonly long _maxBytes;
    private readonly int _maxRoll;
    private readonly Encoding _encoding;

    private StreamWriter _writer;

    public RotatingLogger(string basePath, long maxBytes, int maxRoll, Encoding? encoding = null)
    {
        _basePath = basePath;
        _maxBytes = maxBytes;
        _maxRoll = maxRoll;
        _encoding = encoding ?? Encoding.UTF8;

        Directory.CreateDirectory(Path.GetDirectoryName(_basePath)!);

        _writer = CreateWriter(_basePath);
    }

    public void Log(string message)
    {
        RotateIfNeeded();

        string line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}";
        _writer.WriteLine(line);
        _writer.Flush();
    }

    private void RotateIfNeeded()
    {
        _writer.Flush();

        var fileInfo = new FileInfo(_basePath);
        if (fileInfo.Exists && fileInfo.Length >= _maxBytes)
        {
            _writer.Dispose();
            RollFiles();
            _writer = CreateWriter(_basePath);
        }
    }

    private void RollFiles()
    {
        string oldest = $"{_basePath}.{_maxRoll}";
        if (File.Exists(oldest))
        {
            File.Delete(oldest);
        }

        for (int i = _maxRoll - 1; i >= 1; i--)
        {
            string src = $"{_basePath}.{i}";
            string dst = $"{_basePath}.{i + 1}";

            if (File.Exists(src))
            {
                File.Move(src, dst, overwrite: true);
            }
        }

        if (File.Exists(_basePath))
        {
            File.Move(_basePath, $"{_basePath}.1", overwrite: true);
        }
    }

    private StreamWriter CreateWriter(string path)
    {
        var stream = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read);
        return new StreamWriter(stream, _encoding);
    }

    public void Dispose()
    {
        _writer.Dispose();
    }
}
C#

一気に出しましたが、ここから重要なところを分解して説明していきます。


重要ポイント① ローテーション条件のチェック

「書く前にチェックする」か「書いた後にチェックする」か

Log メソッドの中で、最初に RotateIfNeeded() を呼んでいます。

public void Log(string message)
{
    RotateIfNeeded();

    string line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}";
    _writer.WriteLine(line);
    _writer.Flush();
}
C#

ここでの設計ポイントは、「書く前にローテーションを判断している」ことです。
つまり、「この行を書いたらサイズオーバーしそうか?」ではなく、「今の時点で既にオーバーしていないか?」を見ています。

厳密に「1バイトも超えたくない」場合は、
「今のサイズ+これから書く行のサイズ」を見て判断する、という実装もありえます。
ただ、初心者向けには「ざっくり最大サイズを超えたらローテーション」で十分です。

ファイルサイズの取得と Flush の意味

RotateIfNeeded の中身を見てみます。

private void RotateIfNeeded()
{
    _writer.Flush();

    var fileInfo = new FileInfo(_basePath);
    if (fileInfo.Exists && fileInfo.Length >= _maxBytes)
    {
        _writer.Dispose();
        RollFiles();
        _writer = CreateWriter(_basePath);
    }
}
C#

ここで Flush() を先に呼んでいるのが重要です。
StreamWriter は内部バッファを持っているので、書き込んだ内容がまだ OS のファイルに反映されていないことがあります。
Flush してから FileInfo.Length を見ることで、「実際のファイルサイズ」を正しく取得できます。


重要ポイント② ローテーションのロジック(ファイルの繰り上げ)

一番古いファイルを消し、残りを繰り上げる

RollFiles の中身を見てみます。

private void RollFiles()
{
    string oldest = $"{_basePath}.{_maxRoll}";
    if (File.Exists(oldest))
    {
        File.Delete(oldest);
    }

    for (int i = _maxRoll - 1; i >= 1; i--)
    {
        string src = $"{_basePath}.{i}";
        string dst = $"{_basePath}.{i + 1}";

        if (File.Exists(src))
        {
            File.Move(src, dst, overwrite: true);
        }
    }

    if (File.Exists(_basePath))
    {
        File.Move(_basePath, $"{_basePath}.1", overwrite: true);
    }
}
C#

やっていることを順番に言葉で追うと、こうなります。

一番古い世代(app.log.5 など)を削除する。
maxRoll - 1 から 1 まで逆順に回して、app.log.4app.log.5app.log.3app.log.4 …と繰り上げる。
最後に、現役の app.logapp.log.1 にリネームする。

ここで「逆順に回している」のが重要です。
1 から順に繰り上げてしまうと、app.log.1app.log.2 にする前に、元の app.log.2 が上書きされてしまいます。
なので、「大きい番号から順に動かす」というのが安全なパターンです。


重要ポイント③ ファイルオープンと共有モード

FileStream の FileShare をどうするか

CreateWriter を見てみます。

private StreamWriter CreateWriter(string path)
{
    var stream = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read);
    return new StreamWriter(stream, _encoding);
}
C#

ここで FileShare.Read を指定しているのは、「ログを書き込みながら、別プロセスや別ツールが読み取れるようにする」ためです。
例えば、運用担当が tail 的なツールでログを監視したい場合、読み取り共有がないと「ファイルがロックされて開けない」状態になります。

逆に、「絶対に他から触られたくない」場合は FileShare.None にする選択肢もあります。
ログの用途に応じて、ここは意識して選ぶポイントです。


実際の使い方の例

シンプルなコンソールアプリで試してみる

この RotatingLogger を使って、適当にログを大量に書いてみる例です。

using System;
using System.Text;

class Program
{
    static void Main()
    {
        string logPath = @"C:\logs\app.log";

        long maxBytes = 1024 * 1024;   // 1MB
        int maxRoll = 5;

        using var logger = new RotatingLogger(logPath, maxBytes, maxRoll, Encoding.UTF8);

        for (int i = 0; i < 100_000; i++)
        {
            logger.Log($"Test message {i}");
        }

        Console.WriteLine("Done.");
    }
}
C#

実行すると、app.log のサイズが 1MB を超えるたびにローテーションされ、
app.log, app.log.1, app.log.2, … が順に作られていきます。
app.log.5 より古いものは削除されるので、ログの世代数が一定に保たれます。


日付ベースのログローテーションに発展させる

「日付が変わったらファイル名を変える」パターン

サイズベースに慣れてきたら、次は「日付ベース」も考えられます。
例えば、app_2025-01-28.log のように日付入りのファイル名にして、日付が変わったら新しいファイルを開く、というパターンです。

考え方はサイズベースと同じで、「ログを書く前に、今使っているファイルが今日の日付に対応しているか」をチェックします。
違っていたら、古いファイルを閉じて、新しい日付のファイルを開きます。

日付ベースの場合は、「世代数で削除する」代わりに、「一定日数より古いファイルを削除する」といった運用もよくあります。
ここまで来ると少し設計が広がるので、まずはサイズベースをしっかり理解してから手を出すとよいです。


実務ユーティリティとしての注意点

例外が出たときにどうするか

ログローテーション中に File.MoveFile.Delete が失敗することがあります。
例えば、ウイルス対策ソフトが一時的にファイルをロックしている、別プロセスが開いている、などです。

実務では、こうした例外をどう扱うかも設計ポイントになります。

ログローテーションに失敗してもアプリは止めたくないなら、例外をキャッチしてログに書くだけにする。
どうしてもログが重要で、ローテーション失敗は致命的なら、例外をそのまま上に投げる。

初心者向けの段階では、「まずは素直に例外を投げる」で構いませんが、
実際の現場では「ログの重要度」に応じて振る舞いを決めることになります。

マルチスレッド・複数プロセスからの同時書き込み

今回の RotatingLogger は「1プロセス内で1インスタンスを使う」前提で書いています。
複数スレッドから同じインスタンスを使う場合は、Log メソッドに lock を入れるなどの工夫が必要です。

また、複数プロセスが同じログファイルに書き込むような構成になると、
ローテーションのタイミングで競合が起きやすくなります。
その場合は、プロセスごとに別ファイルに書く、OSやライブラリのロガーを使う、などの設計が必要になります。


まとめ 「ログローテーション」を自前で書けると運用のイメージが変わる

ログローテーションは、一見地味ですが、運用をちゃんと考えるときに避けて通れないテーマです。
自前で簡易版を書いてみると、「ログファイルってこうやって増えて、こうやって整理されるんだ」という感覚がつかめます。

今回のポイントを整理すると、次のようになります。

ログローテーションは「いつ切り替えるか」と「どう名前を付けるか」をまず決める。
サイズベースなら、「書く前にサイズを見て、閾値を超えていたらローテーション」が分かりやすい。
世代管理は「一番古いものを消し、番号を逆順に繰り上げ、現役を .1 にする」のが定番パターン。
StreamWriter の Flush をしてからファイルサイズを見ることで、正しいローテーション判定ができる。
FileShare の指定や例外処理、マルチスレッド対応などを意識すると、実務レベルに近づいていく。

まずは今回のサイズベース版をそのまま動かしてみて、
「ログが回転していく様子」を体感してみると、運用設計のイメージが一気にクリアになります。

タイトルとURLをコピーしました