はじめに なぜ「ログローテーション」が必要になるのか
業務システムを真面目に運用し始めると、必ずと言っていいほど「ログファイルがデカくなりすぎる問題」にぶつかります。
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.4 → app.log.5、app.log.3 → app.log.4 …と繰り上げる。
最後に、現役の app.log を app.log.1 にリネームする。
ここで「逆順に回している」のが重要です。
1 から順に繰り上げてしまうと、app.log.1 を app.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.Move や File.Delete が失敗することがあります。
例えば、ウイルス対策ソフトが一時的にファイルをロックしている、別プロセスが開いている、などです。
実務では、こうした例外をどう扱うかも設計ポイントになります。
ログローテーションに失敗してもアプリは止めたくないなら、例外をキャッチしてログに書くだけにする。
どうしてもログが重要で、ローテーション失敗は致命的なら、例外をそのまま上に投げる。
初心者向けの段階では、「まずは素直に例外を投げる」で構いませんが、
実際の現場では「ログの重要度」に応じて振る舞いを決めることになります。
マルチスレッド・複数プロセスからの同時書き込み
今回の RotatingLogger は「1プロセス内で1インスタンスを使う」前提で書いています。
複数スレッドから同じインスタンスを使う場合は、Log メソッドに lock を入れるなどの工夫が必要です。
また、複数プロセスが同じログファイルに書き込むような構成になると、
ローテーションのタイミングで競合が起きやすくなります。
その場合は、プロセスごとに別ファイルに書く、OSやライブラリのロガーを使う、などの設計が必要になります。
まとめ 「ログローテーション」を自前で書けると運用のイメージが変わる
ログローテーションは、一見地味ですが、運用をちゃんと考えるときに避けて通れないテーマです。
自前で簡易版を書いてみると、「ログファイルってこうやって増えて、こうやって整理されるんだ」という感覚がつかめます。
今回のポイントを整理すると、次のようになります。
ログローテーションは「いつ切り替えるか」と「どう名前を付けるか」をまず決める。
サイズベースなら、「書く前にサイズを見て、閾値を超えていたらローテーション」が分かりやすい。
世代管理は「一番古いものを消し、番号を逆順に繰り上げ、現役を .1 にする」のが定番パターン。StreamWriter の Flush をしてからファイルサイズを見ることで、正しいローテーション判定ができる。
FileShare の指定や例外処理、マルチスレッド対応などを意識すると、実務レベルに近づいていく。
まずは今回のサイズベース版をそのまま動かしてみて、
「ログが回転していく様子」を体感してみると、運用設計のイメージが一気にクリアになります。
