PythonのRotatingFileHandlerでログローテーション|maxBytes・backupCountの設定と複数プロセス対策
ログローテーションは、肥大化するログファイルを一定の条件で新しいファイルへ切り替え、古い世代を順に捨てていく仕組みです。Pythonのlogging.handlers.RotatingFileHandlerはこれを標準ライブラリだけで実現し、ローテーションに関わる設定はmaxBytesとbackupCountの2つで決まります。ただし、この2つは片方でも0にするとローテーションが一切起きず、複数プロセスから同じファイルへ書くと世代の入れ替えそのものが失敗します。ここでは最小構成から、保存世代の見積もり、logrotateとの併用、複数プロセス環境での破綻の回避までを扱います(記載はPython 3.14系の標準ライブラリの挙動に基づきます)。
まとめ
RotatingFileHandler("app.log", maxBytes=10*1024*1024, backupCount=5)が実務の出発点。maxBytesとbackupCountはどちらかが0だとローテーションが起きないため、両方に非ゼロを入れる。- ディスク使用量の上限は
maxBytes×(backupCount+1)で決まる。上の例なら約60MBが上限。 - 日次・時刻で切り替えたいなら
TimedRotatingFileHandler(when="midnight"など)。サイズ上限を守りたいならRotatingFileHandler。両方の性質は標準ライブラリでは併用できない。 - Linuxの
logrotateでローテーションするなら、Python側はWatchedFileHandlerにする。RotatingFileHandlerのまま外部ローテーションを併用すると、リネームされた古いファイルへ書き続ける。 - Gunicornのワーカーやマルチプロセスから同じログファイルへ書く構成では、標準のRotatingFileHandlerは壊れる。
QueueHandler+QueueListenerで書き込みを1プロセスに集約するか、concurrent-log-handlerに置き換える。
以下、パラメータの挙動から順に見ていきます。
RotatingFileHandlerの最小構成とパラメータ
maxBytesとbackupCountの意味と「0」の罠
コンストラクタのシグネチャはRotatingFileHandler(filename, mode='a', maxBytes=0, backupCount=0, encoding=None, delay=False, errors=None)です。既定値がどちらも0である点が重要で、公式ドキュメントはmaxBytesとbackupCountのいずれかが0ならロールオーバーは起こらないと明記しています。RotatingFileHandler("app.log")とだけ書くと、ローテーションしないただのFileHandlerと同じ動作になり、ログが無限に肥大します。
import logging
from logging.handlers import RotatingFileHandler
logger = logging.getLogger("app")
logger.setLevel(logging.INFO)
handler = RotatingFileHandler(
"logs/app.log",
maxBytes=10 * 1024 * 1024, # 10MBでローテーション
backupCount=5, # app.log.1 〜 app.log.5 を保持
encoding="utf-8",
delay=True, # 最初の書き込みまでファイルを開かない
)
handler.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)s %(name)s %(process)d %(message)s"
))
logger.addHandler(handler)
logger.info("起動しました")
encoding="utf-8"は日本語ログでは必須です。指定しないとロケール依存のエンコーディングになり、Windowsではcp932で書かれて文字化けやUnicodeEncodeErrorを招きます。delay=Trueを付けると最初のemit()までファイルを開かないため、ログを1行も出さないプロセスが空ファイルを作らずに済みます。ファイルを開く時点を遅らせるだけで、複数プロセスが同じファイルを開くこと自体は防げません(後述の破綻はこれでは回避できません)。出力先ディレクトリは事前に作っておく必要があり、パスの組み立てにはPython pathlibの使い方|resolve・絶対パス取得・Path(__file__).resolve().parentとos.pathの違いで解説しているPathを使うと、実行ディレクトリに依存しない絶対パスにできます。
ロールオーバー時のファイル名と削除の順序
ローテーションの判定はレコードを書き出す直前に行われます。「今のファイルサイズ+これから書く1行」がmaxBytesを超えるなら、書く前に切り替えます。つまりmaxBytesはほぼ上限として守られ、あとから超過分を削るような動きはしません。
厳密には「ほぼ」です。判定はファイル位置(バイト)にメッセージの文字数を足して行うため、UTF-8で1文字3バイトになる日本語ログでは、実ファイルがmaxBytesをわずかに超えます(maxBytes=1000で日本語を出力すると1,057バイトになる、といった数%の超過です)。容量設計では上限ちょうどではなく数%の余裕を見てください。
切り替えでは、既存のバックアップが末尾から順にずれていきます。backupCount=5・ベース名app.logなら、app.log.5が削除され、app.log.4→app.log.5…と改名が進み、最後に現在のapp.logがapp.log.1になって新しいapp.logが開かれます。番号が小さいほど新しいログです。
命名規則と移動処理は差し替えられます。上で作ったhandlerに対して、namer(ローテーション後のファイル名を返す関数)とrotator(「移動」の実装)を設定すると、切り替えと同時にgzip圧縮できます。
import gzip, os, shutil
def gzip_rotator(source, dest):
with open(source, "rb") as fin, gzip.open(dest, "wb") as fout:
shutil.copyfileobj(fin, fout)
os.remove(source)
handler.namer = lambda name: name + ".gz" # app.log.1.gz として世代管理させる
handler.rotator = gzip_rotator
namerを既定のままにしてrotatorの中で.gzを付け足す書き方は避けてください。ハンドラーはapp.log.1が存在する前提で世代をずらすため、実体がapp.log.1.gzしかないと世代シフトが空振りし、backupCountを指定しても最新の1世代しか残りません(backupCount=3で実行するとapp.log.1.gzだけが毎回上書きされます)。圧縮後のファイル名はnamer側で決めるのが正しい形です。
時刻で切り替えるTimedRotatingFileHandler
「1日1ファイル」「毎週月曜に切り替え」のように時間で区切りたい場合はTimedRotatingFileHandlerを使います。whenに指定できる値はS(秒)、M(分)、H(時)、D(日)、W0〜W6(曜日指定・W0が月曜)、midnightの6系統です。intervalと組み合わせて「when="H", interval=6=6時間ごと」のように指定します。midnightとW0〜W6ではatTimeで切り替え時刻を指定でき、省略すると初回のロールオーバー時刻がプロセス起動時刻に引きずられます。深夜3時に切り替えたいならatTime=datetime.time(3, 0)を明示してください。
from logging.handlers import TimedRotatingFileHandler
handler = TimedRotatingFileHandler(
"logs/app.log",
when="midnight", # 日付が変わるタイミングで切り替え
interval=1,
backupCount=14, # 14日分を保持
encoding="utf-8",
utc=False, # ローカルタイム基準。コンテナのTZ設定に注意
)
ローテーション後のファイルには日時サフィックスが付き、app.log.2026-07-12のような名前になります(whenが時分秒の場合は%Y-%m-%d_%H-%M-%Sまで含みます)。backupCountを超えた古いファイルは、このサフィックス形式にマッチするものを探して削除する実装になっているため、namerで命名規則を大きく変えると古いファイルが削除されなくなる点に注意してください。
時刻ベースの弱点は、サイズの上限を保証できないことです。障害時にエラーログが噴き出すと、1日分のファイルが数GBに膨らみます。逆にRotatingFileHandlerはサイズの上限を守れますが、「昨日のログ」を日付で特定できません。標準ライブラリはサイズと時刻の両方を条件にするハンドラーを持たないため、両立が必要なら後述のconcurrent-log-handlerのようにサイズ・時刻双方に対応した実装を使うか、OS側のlogrotateに寄せる判断になります。
保存世代とディスク使用量の見積もり
「ログの保存期間をどう決めるか」はbackupCountの設計そのものです。RotatingFileHandlerが保証するのはサイズであり、期間ではありません。まず上限は次の式で決まります。
| 設定 | ファイル数 | ディスク上限 | 目安の保持期間 |
|---|---|---|---|
| maxBytes=10MB / backupCount=5 | 6 | 約60MB | 1日1MB出るなら約60日 |
| maxBytes=100MB / backupCount=3 | 4 | 約400MB | 1日100MB出るなら約4日 |
| maxBytes=10MB / backupCount=0 | 1 | 無制限 | ローテーションされない |
上限はmaxBytes×(backupCount+1)です。設計の順序は逆にします。まず「このサーバーでログに割ける容量」と「障害調査で遡りたい期間」を決め、1日あたりのログ量を実測してからmaxBytesとbackupCountを逆算します。1日あたりの量が読めないうちは、backupCountを多め(10〜20)にしてmaxBytesを小さく(10〜50MB)するほうが、調査可能な期間を確保しやすくなります。
監査要件などで「90日間の保存」が決まっている場合、サイズ基準のRotatingFileHandlerでは期間を保証できません。この場合はTimedRotatingFileHandlerで日次に切り、backupCount=90とするのが素直です。アプリケーション以外のログ(データベースのスロークエリログなど)も含めた保存方針は、スロークエリログとは|MySQLの設定・見方・解析ツールと改善手順のように出力元ごとの設定が別々になるため、サーバー全体では出力元を一覧化して合計容量を見積もってください。
dictConfigによる設定の外部化
ハンドラーをコード内で組み立てると、開発環境と本番環境でログ設定を切り替えるたびにコードを触ることになります。logging.config.dictConfig()を使えば、辞書(実務ではYAMLやJSONから読む)で宣言できます。
import logging.config
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
},
},
"handlers": {
"file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": "logs/app.log",
"maxBytes": 10485760,
"backupCount": 5,
"encoding": "utf-8",
"formatter": "standard",
"level": "INFO",
},
"console": {
"class": "logging.StreamHandler",
"formatter": "standard",
"level": "DEBUG",
},
},
"root": {"handlers": ["file", "console"], "level": "DEBUG"},
}
logging.config.dictConfig(LOGGING)
maxBytesは辞書の中では数値リテラルで書きます(10*1024*1024のような式はJSON/YAMLでは評価されないため、10485760のように展開しておきます)。ロガーとハンドラーのlevelは独立で、レコードはロガーのレベルで足切りされてからハンドラーのレベルで再度足切りされます。ロガー側がINFOのままハンドラーだけDEBUGにしてもDEBUGログは出ません。上の例のようにルートロガーをDEBUGにし、ハンドラーごとに出す粒度を変えるのが定石です。
レベルはDEBUG(10) < INFO(20) < WARNING(30) < ERROR(40) < CRITICAL(50)の5段階で、設定した値以上のレコードが通ります。既定のルートロガーはWARNINGのため、何も設定せずlogger.info()を呼んでも出力されないのは仕様です。
複数プロセス書き込みでのローテーション破綻
ここが実務で最も踏みやすい落とし穴です。GunicornやuWSGIのワーカー、multiprocessingで分岐した子プロセス、cronで並走するバッチ——これらが同じapp.logを同じRotatingFileHandlerの設定で開くと、ローテーションのタイミングで破綻します。Pythonの公式クックブックは、複数プロセスから単一のファイルへログを書くことは標準ではサポートされないと明言しています。ファイルへのアクセスをプロセス間で直列化する標準的な手段がないためです。
症状はOSによって違います。Windowsでは、あるプロセスがファイルを開いたままだと別プロセスがそれをリネームできないため、ロールオーバーがPermissionErrorで失敗します。このとき例外は呼び出し元へ伝播せず、標準エラー出力にトレースバックが出るだけなので(Gunicornのエラーログに埋もれて気づかれません)、そのまま以降のログが落ち続けます。Linuxではリネーム自体は成功しますが、他プロセスは開いたままの古いinodeへ書き続けるため、ローテーション後のログが「消えたファイル」に書かれて回収できなくなります。どちらも、ログが必要になった障害調査の当日に発覚します。
書き込みを1プロセスに集約する(QueueHandler + QueueListener)
標準ライブラリだけで解決するなら、各プロセスはQueueHandlerでキューにレコードを積むだけにして、実際にファイルへ書くのはQueueListenerを持つ1プロセスだけにします。ファイルを開くプロセスが1つになるため、RotatingFileHandlerの前提が満たされます。
import logging
import multiprocessing
from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler
from multiprocessing import Process, Queue
def worker(queue):
logging.getLogger().addHandler(QueueHandler(queue))
logging.getLogger().setLevel(logging.INFO)
logging.getLogger("worker").info("子プロセスからのログ")
if __name__ == "__main__":
# multiprocessingの内部ロガーはQueueへのput時にDEBUGログを出す。
# 同じキューに載ると再帰・デッドロックを招くためINFO以上に上げる
multiprocessing.get_logger().setLevel(logging.INFO)
queue = Queue() # multiprocessing.Queue(queue.Queueはスレッド間専用)
file_handler = RotatingFileHandler(
"logs/app.log", maxBytes=10485760, backupCount=5, encoding="utf-8"
)
listener = QueueListener(queue, file_handler, respect_handler_level=True)
listener.start()
procs = [Process(target=worker, args=(queue,)) for _ in range(4)]
for p in procs:
p.start()
for p in procs:
p.join()
listener.stop()
キューにはmultiprocessing.Queueを使います(queue.Queueはスレッド間でしか共有できません)。なお公式ドキュメントは、multiprocessing内部のロガーがmultiprocessing.Queueへのput時にDEBUGログを出すため、同じキューをQueueHandlerに渡すとデッドロックや無限再帰を招きうると警告しています。multiprocessingのロガーのレベルを上げるか、専用のキューを分けてください。spawnモード(WindowsとmacOSの既定)では子プロセスにハンドラーが引き継がれないため、上のように子側でQueueHandlerを登録し直す実装が必要です。
concurrent-log-handlerへ置き換える
プロセス構成に手を入れられない場合——たとえばGunicornのワーカーが直接ファイルに書く既存構成——は、ファイルロックで直列化するconcurrent-log-handlerが現実的です。最新版は0.9.29(2026年2月22日リリース、Apache-2.0)で、ConcurrentRotatingFileHandlerはRotatingFileHandlerの差し替えとして設計されており、WindowsとPOSIXの両方でロックを取ります。時刻ベースのConcurrentTimedRotatingFileHandlerもあり、use_gzip=Trueでローテーション時の圧縮も指定できます。
from concurrent_log_handler import ConcurrentRotatingFileHandler
handler = ConcurrentRotatingFileHandler(
"logs/app.log",
maxBytes=10485760,
backupCount=5,
encoding="utf-8",
use_gzip=True,
)
判断基準ははっきりしています。ログ量が多くスループットが要る、あるいはプロセス構成を自分で設計できるならQueueHandler方式——書き込みが1プロセスに集約されるのでロック競合が起きず、標準ライブラリだけで完結します。既存のワーカー構成を変えずに今のローテーション不具合を止めたいならconcurrent-log-handler——ハンドラーのクラス名を差し替えるだけで済みます。反対に、複数プロセス環境で標準のRotatingFileHandlerをそのまま使い続ける選択だけは避けてください。「今は動いているように見える」のは、まだローテーションの瞬間に競合していないだけです。
logrotateと併用するならWatchedFileHandler
Linuxサーバーでは、アプリケーションのログもOSのlogrotateでまとめて管理する運用がよくあります。このときPython側でRotatingFileHandlerを使ってはいけません。logrotateがファイルをリネームしても、Pythonプロセスは開いているファイルディスクリプタ(=リネーム後の古いファイル)へ書き続けるため、新しいapp.logには何も書かれなくなります。
正解はWatchedFileHandlerです。このハンドラーは書き込みのたびにファイルのデバイス番号とinodeを確認し、変化していればストリームを閉じてファイル名で開き直します。外部ツールによるローテーションを検知するための実装で、Unix/Linux向けです(Windowsは開いているファイルをリネームできないため、この方式自体が成立しません)。書き込みのたびにos.statを呼ぶため、毎秒数千行を吐くような高頻度のログでは相応のオーバーヘッドになります。その規模ではファイル出力自体を見直す段階です。
from logging.handlers import WatchedFileHandler
handler = WatchedFileHandler("/var/log/myapp/app.log", encoding="utf-8")
/var/log/myapp/*.log {
daily
rotate 14
missingok
notifempty
compress
delaycompress
# 既定の方式。リネーム後に新しいファイルを作る
create 0640 myapp myapp
}
WatchedFileHandlerを使う場合、logrotate側は既定のcreate(リネーム+新規作成)で構いません。copytruncateは「コピーしてから元ファイルを切り詰める」動作で、開き直しが不要になる代わりに、コピーと切り詰めの間に書かれたログが失われます。ハンドラーを差し替えられない事情がない限り、copytruncateは避けてください。
アプリケーションをsystemdとは何か?Linuxにおける役割と概要解説で常駐サービス化しているなら、そもそもファイルへ書かず標準出力に出してjournaldに集める選択もあります。コンテナ環境でも同様で、標準出力に出してDockerのログドライバーやログ収集基盤へ委ねるなら、アプリ側のローテーション設定自体が不要になります。ファイル出力とローテーションが本当に必要なのは、ログ収集基盤がない、あるいはファイルとして手元に残す要件がある場合です。
ローテーションされない・ログが重複するときの確認
- まったくローテーションしない:
maxBytesかbackupCountが0のままになっていないか。どちらか一方でも0なら切り替わりません。 - ローテーション後にログが書かれない:複数プロセスが同じファイルを開いていないか。前章の症状です。
lsofで同一ファイルを開いているプロセス数を確認します。 - 同じログが2回出る:ハンドラーを二重登録しているか、子ロガーとルートロガーの両方にハンドラーが付いています。
logger.propagateは既定でTrueのため、子ロガーのレコードは親にも伝播します。モジュール側ではlogging.getLogger(__name__)でロガーを取得するだけにし、ハンドラーの登録はエントリポイントで1回だけ行うのが原則です。 - ファイルが空のまま:ロガーのレベルが
WARNING(既定)でinfo()が落とされている、あるいはdelay=Trueでまだ1件も出力していない状態です。 - 日付が想定とずれる:
TimedRotatingFileHandlerのutcとコンテナのタイムゾーン設定を確認します。TZ未設定のコンテナはUTCで動きます。
複数台のサーバーに散ったログを一箇所で確認したい段階に入ったら、SSH経由でログを回収する方法をParamikoとは?PythonでSSH接続・コマンド実行・SFTP転送を行う使い方【5.0対応】にまとめています。恒常的な運用では、ログ収集基盤への転送を検討する時期です。
よくある質問
RotatingFileHandlerでログがローテーションされないのはなぜですか?
ほとんどの場合、maxBytesかbackupCountが0のままです。公式ドキュメントのとおり、どちらか一方でも0だとロールオーバーは発生せず、単一ファイルに追記され続けます。RotatingFileHandler("app.log")のように引数を省略すると既定値が両方0になるため、必ず両方に非ゼロを指定してください。それでも切り替わらない場合は、複数プロセスが同じファイルを開いていないかを疑います。
maxBytesとbackupCountはどのくらいの値にすべきですか?
ディスク使用量の上限がmaxBytes×(backupCount+1)になるため、ログに割ける容量から逆算します。1日あたりのログ量が読めない段階では、10MB×backupCount=10(上限約110MB)程度から始め、実測後に調整するのが安全です。1ファイルを大きくしすぎるとエディタで開けず調査しづらくなるため、maxBytesは10〜50MB程度に収めることをおすすめします。
RotatingFileHandlerとTimedRotatingFileHandlerはどちらを使うべきですか?
ディスク容量の上限を守ることが最優先ならRotatingFileHandler、障害調査で「何月何日のログ」を日付で特定したいならTimedRotatingFileHandlerです。標準ライブラリの両ハンドラーはサイズと時刻を同時に条件にできないため、両方が必要ならconcurrent-log-handlerのような外部実装か、OS側のlogrotate(sizeとdailyの併記が可能)に寄せます。
Gunicornのように複数ワーカーがある環境でも使えますか?
そのままでは使えません。複数プロセスが同じファイルを開いた状態でローテーションすると、WindowsではリネームがPermissionErrorで失敗し、Linuxでは古いinodeへ書き続けてログが失われます。QueueHandlerとQueueListenerで書き込みを1プロセスに集約するか、ファイルロックを持つConcurrentRotatingFileHandlerへ差し替えてください。
logrotateでローテーションする場合、Python側の設定はどうしますか?
WatchedFileHandlerを使います。書き込みのたびにinodeの変化を検出して開き直すため、logrotateがcreate方式でリネームしても新しいファイルへ追従できます。RotatingFileHandlerのまま併用すると、リネームされた古いファイルに書き続けることになります。