ジョブの一覧には「成功」と出ている。なのに、そのジョブは実は失敗していた。
2026年10月、Macで毎朝7時に動かしているニュース集めのジョブ(news_bot)で、そんなことが起きました。原因はPythonのloggingの設定です。
Python loggingは、getLogger(__name__) でロガーを取り、レベル・出力先・書式の3つを決めれば使えます。
私はlaunchdで動かしている27本のPythonジョブすべてで、自作の共通ロガー setup_logger() を使っています。基本の書き方から、その中身と実際に踏んだ4つの落とし穴まで、実物のコードでまとめました。
この記事でわかること
- Python loggingとprintの違い、最小コード
- ログレベル5段階の使い分けと、INFOが出ない理由
- Logger・Handler・Formatter・Filterの役割
- ファイル出力とローテーション(サイズ方式と日次方式の選び方)
- 27本の定期ジョブで使っている共通ロガーの実物と、そこで踏んだ落とし穴
動作確認はPython 3.11.14(macOS)で行いました。コードはすべて標準ライブラリだけで動きます。
PARTS LIST /目次
Python loggingとは?printとの違いと最小コード
Python loggingは、print()の代わりに使う「記録係」です。時刻や重要度を自動で付け、画面とファイルの両方へ同時に書き出せます。最初の3行さえ覚えれば、残りは必要になったときに足していけば十分です。
loggingはPython標準のログ記録の仕組み
Python loggingとは、プログラムの動作記録(ログ)を、重要度・出力先・書式に分けて残すための標準ライブラリです。pip install は要りません。
printでも記録は残せます。違いが出るのは、本番で動かし始めてからです。
自動実行のスクリプトで効くのは、下の2行です。誰も見ていない時間に失敗したジョブを後から調べるとき、手がかりは時刻と重要度の付いたログファイルしかありません。
basicConfigとgetLogger(name)の3行で始める
一番短い書き方は、basicConfig で全体の設定を1回だけ行い、モジュールごとに getLogger(__name__) でロガーを取る形です。
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
logger.info("処理を開始します")
logger.warning("設定ファイルが見つからないので既定値を使います")
2026-11-11 20:55:50,648 - INFO - 処理を開始します
2026-11-11 20:55:50,648 - WARNING - 設定ファイルが見つからないので既定値を使います
__name__ にはモジュール名が入ります。同じ名前で getLogger を呼ぶと同じロガーが返るので、ファイルごとに1回取れば十分です。
logging.info() のように直接呼んでも動きます。ただ、それだとどのモジュールのログか区別できなくなるため、名前付きのロガーを使うほうが後で楽です。
Python loggingのログレベル5段階と使い分け
ログレベルは、ログの重要度を表す5段階の目盛りです。設定したレベル以上のログだけが出力され、それより下は捨てられます。初心者がつまずく「ログが出ない」の大半は、この目盛りの初期値が原因です。
DEBUG〜CRITICALの意味と数値
レベルには数値が割り当てられていて、数値が大きいほど深刻なログです。
迷ったら「人が後で対応する必要があるか」で決めます。対応が要るならERROR以上、要らないならINFOかWARNINGです。
私の共通ロガーは、レベルをINFOに固定しています。定期ジョブでは、開始・件数・終了がINFOで残っていれば、あとから何が起きたかを追えます。
INFOが出ないのは初期値がWARNINGだから
設定を何もしないと、ロガーの大元(ルートロガー)のレベルはWARNING(30)です。INFOやDEBUGは黙って捨てられます。
basicConfigは、ルートロガーにまだ出力先がないときだけ設定します。先にどこかで呼ばれていたり、ログを1回でも出していたりすると、あとから level=logging.DEBUG を渡しても何も変わりません。設定し直したいときは force=True(Python 3.8以降)を付けます。
手元で試すと、初期状態のルートロガーのレベルは 30 WARNING と表示されました。basicConfig(level=logging.INFO) のあとに level=logging.DEBUG で呼び直しても、DEBUGのログは出ませんでした。
logger.exceptionで例外の詳細を残す
例外を捕まえたら、logger.error ではなく logger.exception を使います。レベルはERRORのまま、トレースバック(どの行で何が起きたか)まで書き残してくれます。
try:
1 / 0
except ZeroDivisionError:
logger.exception("割り算に失敗")
ERROR:__main__:割り算に失敗
Traceback (most recent call last):
File "t.py", line 2, in <module>
1 / 0
~~^~~
ZeroDivisionError: division by zero
except の外で呼ぶと、付けるトレースバックがありません。except ブロックの中で使うものと覚えておけば迷いません。
loggingの4つの部品:Logger・Handler・Formatter・Filter
loggingの設定が分かりにくいのは、部品が4つに分かれているからです。順番に並べると、「誰が書くか → 何を通すか → どこへ書くか → どんな形で書くか」の流れです。
1本のログが出力されるまでの流れを図にしました。
flowchart LR
A["Logger<br>logger.info()を受け取る"] --> B["Filter<br>通す・書き換える"]
B --> C["Handler<br>出力先を決める"]
C --> D["Formatter<br>1行の形に整える"]
D --> E["画面やファイル"]
Handlerは1つのLoggerに何個でも付けられます。「画面にも出して、ファイルにも残す」は、Handlerを2つ付けるだけです。
getLoggerの名前とロガーの親子関係
ロガー名はドットで階層になります。"app.db" は "app" の子で、すべてのロガーはルートロガーの子孫です。
子で出たログは、自分のHandlerで出力されたあと、親のHandlerにも渡ります(propagate)。自分のロガーとルートの両方にHandlerを付けると、同じログが2回出ます。
logging.basicConfig() # ルートにHandlerが付く
lg = logging.getLogger("dup")
lg.addHandler(logging.StreamHandler())
lg.warning("2回出る")
2回出る
WARNING:dup:2回出る
対策は、どちらか一方にだけHandlerを付けるか、子のロガーで lg.propagate = False にすることです。
Formatterでログのformatを決める
Formatterは、1行のログの形を決める部品です。%(名前)s の形で、入れたい情報を並べます。
日時の形を変えたいときは、logging.Formatter(fmt, datefmt="%Y-%m-%d %H:%M:%S") のように datefmt を渡します。
Filterで出力前に中身を書き換える
Filterは、ログを出すかどうかを決める部品です。filter(record) が False を返したログは捨てられ、True なら出力されます。
捨てるだけでなく、record の中身を書き換えてから True を返すこともできます。私は書き換えの仕組みを、パスワードやAPIキーを伏せ字にする用途で使っています(実物は後半の共通ロガーの節で紹介します)。
Python loggingでファイル出力する方法
ログをファイルに残すときは、Handlerをファイル用のものに替えます。気をつけるのは、文字コードとファイルの膨らみ方の2つです。
FileHandlerにはencoding="utf-8"を付ける
一番素直なファイル出力は FileHandler です。既定は追記モードなので、実行のたびに前回の続きへ書き足されます。
handler = logging.FileHandler("app.log", encoding="utf-8")
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
logger.addHandler(handler)
encoding を省くと、OSの既定の文字コードで書かれます。Macなら困りませんが、Windowsの日本語環境では日本語のログが文字化けする原因になります。basicConfig(filename="app.log", encoding="utf-8") のように、basicConfigでも指定できます(Python 3.9以降)。
RotatingFileHandlerでサイズに上限をつける
FileHandlerのままだと、ログファイルは消さない限り膨らみ続けます。RotatingFileHandler を使うと、決めたサイズを超えたところで新しいファイルに切り替わります。
from logging.handlers import RotatingFileHandler
handler = RotatingFileHandler(
"app.log",
maxBytes=5 * 1024 * 1024, # 5MBを超えたら切り替える
backupCount=2, # app.log.1 と app.log.2 まで残す
encoding="utf-8",
)
超えると app.log が app.log.1 に、app.log.1 が app.log.2 に送られ、それより古いものは消えます。この設定なら、ディスクを使うのは最大で5MB×3ファイル=15MBです。maxBytes を0のままにすると、切り替えは起きません。
日次ローテーションをやめてサイズ方式に戻した理由
日付で切り替えたいなら TimedRotatingFileHandler(when="midnight") もあります。私も一度はこちらにしました。
2026年3月15日に共通ロガーを作ったときはサイズ方式でした。同じ日のうちに、毎日深夜に切り替わる日次方式へ変えています。
3月29日、サイズ方式に戻しました。どのジョブも1日1回、数十行しか書かないので、日次で切るとファイルが細切れになるだけだったからです。
2026年11月11日時点で、ジョブのログを大きい順に見ると、一番大きいものでも1.2MBでした。週1回動くSSL証明書の監視ジョブは、2026年5月9日の初回から数えて62行です。5MBの上限には、まだどれも届いていません。
27本の定期ジョブで使う共通ロガーsetup_logger
ここからは、私のリポジトリで実際に使っているロガーの話です。launchdで動かすPythonのジョブ27本(2026年11月11日時点)は、すべて python/common/ にある setup_logger() でロガーを作っています。リポジトリ全体では57本のスクリプトがこの関数を呼んでいます。
setup_loggerのコード(要点版)
実物はコメント込みで300行近くあるので、骨組みだけを抜き出しました。ファイルと画面の両方に同じ書式で出し、サイズでローテーションします。
import logging
from logging.handlers import RotatingFileHandler
FORMAT = "%(asctime)s - %(levelname)s - %(message)s"
def setup_logger(name, log_file, max_bytes=5 * 1024 * 1024, backup_count=2):
"""ファイルと画面の両方に出すロガーを返す"""
logger = logging.getLogger(name)
if logger.handlers: # 2回呼ばれても、Handlerを二重に付けない
return logger
logger.setLevel(logging.INFO)
formatter = logging.Formatter(FORMAT)
file_handler = RotatingFileHandler(
log_file, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
)
file_handler.setFormatter(formatter)
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
logger.addHandler(file_handler)
logger.addHandler(stream_handler)
logger.addFilter(SecretMaskingFilter()) # 次の節で紹介
return logger
使う側は、スクリプトの先頭で1行呼ぶだけです。SSL証明書の監視ジョブでは、こう書いています。
logger = setup_logger("ssl_cert_monitor", str(_DIR / "ssl_cert_monitor.log"))
if logger.handlers の2行は、前の節で見た「同じログが2回出る」を防ぐためのものです。同じ名前で何度呼んでも、Handlerは1組だけになります。
APIキーやWebhook URLをログに残さない
ログには、うっかり秘密の値が混ざります。エラーの中身をそのまま書くと、接続先のURLやトークンまで出てしまうことがあるからです。共通ロガーには、出力の直前に伏せ字へ置き換えるFilterを付けています。
import re
_SECRET_KEY = re.compile(
r"(?i)\b(api[_-]?key|secret|password|token|webhook[_-]?url)\b(\s*[:=]\s*)(\S+)"
)
_WEBHOOK_URL = re.compile(r"https://(?:discord(?:app)?\.com/api/webhooks|hooks\.slack\.com/services)/\S+")
class SecretMaskingFilter(logging.Filter):
"""ログ本文の秘密情報らしき文字列を伏せ字にする"""
def filter(self, record):
message = record.getMessage()
masked = _WEBHOOK_URL.sub("[MASKED_WEBHOOK_URL]", message)
masked = _SECRET_KEY.sub(lambda m: f"{m.group(1)}{m.group(2)}[MASKED]", masked)
if masked != message:
record.msg = masked
record.args = () # 置き換え済みの本文に、引数を当て直さないようにする
return True
実際に流すと、ファイルにはこう残ります。
2026-11-11 20:55:50,648 - INFO - api_key=[MASKED] で接続
2026-11-11 20:55:50,648 - INFO - 送信先 [MASKED_WEBHOOK_URL]
Loggerに付けたFilterは子のロガーに効かない
Loggerに付けたFilterは、そのLoggerで直接出したログにしか効きません。子のロガーから上がってきたログは素通りします。全部に効かせたいときは、Handlerの側にもFilterを付けます。
ログの書式はダッシュボードの解析にそろえる
書式を %(asctime)s - %(levelname)s - %(message)s に固定しているのには理由があります。全ジョブの成否を集計する自作のダッシュボードが、ログの各行をこの正規表現で読んでいるからです。
r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+ - (INFO|WARNING|ERROR|CRITICAL|DEBUG) - (.*)"
ダッシュボードは、この形の行にERRORがあるかどうかで失敗を判定します。1本だけ書式を変えると、そのジョブはエラーが出ても失敗として数えられません。
共通ロガーにまとめると、書式を変えるときは1か所直せば27本すべてが同じ形になります。ログを機械に読ませるなら、書式は全スクリプトで1つにそろえるのが一番の近道でした。
共通ロガーで実際に起きた4つの落とし穴
共通ロガーにまとめても、ログが「期待した場所に、期待した形で」残るとは限りません。2026年10月に見つかった4つの問題は、10月24日にまとめて直しました。どれも、エラーは出ていないのにログが静かに欠けたり、散らばったりする種類のものです。
__main__のエラーがファイルに残らなかった
冒頭のnews_botの件は、この落とし穴でした。失敗したのに、ダッシュボードには「成功」と表示されていました。
例外を捕まえてログに書くデコレーター(@handle_errors)は、スクリプトを直接実行すると「__main__」という名前のロガーに書きます。setup_loggerで作った「news_bot」ロガーの子ではないので、ファイル用のHandlerに届きません。
行き場のないエラーは標準エラーにだけ出て、アプリのログファイルには残りませんでした。ダッシュボードはファイルにERRORがないので、成功と判定していました。
対処として、ルートロガーに「WARNING以上を共通ロガーのHandlerへ転送するHandler」を1つ付けました。共通モジュールが getLogger(__name__) で書いたエラーも、転送用のHandlerを通ってファイルに届きます。INFOまで転送すると外部ライブラリのログで埋まるので、WARNING以上に絞っています。
相対パスのログが実行した場所に散らばった
RotatingFileHandler("static_publish.log") のように相対パスを渡すと、実行したときのカレントディレクトリを基準に開かれます。launchdから動かすぶんには毎回同じ場所でも、手で別のディレクトリから実行すると、そこに新しいログファイルができます。
2026年10月、static_publish.log がリポジトリの直下にできていて気づきました。いまは、相対パスを「setup_loggerを呼んだスクリプトのあるディレクトリ」基準の絶対パスに直してから開いています。呼び出し元のファイルは inspect.currentframe().f_back から取っています。
テストのERRORが本番ログに混ざった
多くのスクリプトは、import した瞬間に setup_logger() を呼び、本番のログファイルを開きます。pytestがそのスクリプトをimportすると、テストでわざと起こしたERRORまで本番のログに書かれます。
2026年10月、記事のエクスポートジョブ(wp_export)のログにテストのERRORが残り、ダッシュボードが失敗と判定しました。いまは、テスト実行中だけ環境変数(LOGGER_UTILS_REDIRECT_DIR)を立て、リポジトリ内のログの書き込み先を一時ディレクトリへ差し替えています。環境変数はルートの conftest.py で設定します。
launchdのstderrログが8MBを超えた
StreamHandlerは標準エラーに書きます。launchdから動かすと、標準エラーはplistの StandardErrorPath に指定したファイルへ追記され続けます。RotatingFileHandlerの管理外なので、上限がありません。
wp_exportでは、このファイルが8MBを超えていました。いまは、setup_loggerを最初に呼んだときに、標準エラーのファイルが5MBを超えていれば末尾1MBだけを残して切り詰めています。ログの置き場所の決め方は、launchdの記事にまとめています。
Python loggingのよくある質問
Python loggingで、つまずきやすい点をQ&Aにまとめました。どれも手元のPython 3.11.14で動きを確かめています。
Q1. basicConfigを書いたのにログの設定が変わらないのはなぜですか?
A1. basicConfigは、ルートロガーにまだHandlerがないときだけ設定するからです。先にどこかで呼ばれていると、2回目以降は無視されます。logging.basicConfig(level=logging.DEBUG, force=True) のように force=True を付けると設定し直せます(Python 3.8以降)。
Q2. 同じログが2回出力されるのはなぜですか?
A2. 自分のロガーと親(多くはルートロガー)の両方にHandlerが付いているからです。子で出たログは親にも渡るため、2回書かれます。どちらか一方にだけHandlerを付けるか、子のロガーで propagate = False にしてください。
Q3. ログの本文はf-stringと%書式のどちらで書けばいいですか?
A3. logger.info("%s件を処理しました", count) のように、引数で渡す%書式がおすすめです。ログが捨てられるレベルのときは、文字列を組み立てる処理そのものを省けます。f-stringは捨てられるときも必ず組み立てられ、Ruffでも警告の対象(G004)です。
Q4. loggingの設定をファイルにまとめることはできますか?
A4. できます。辞書で渡す logging.config.dictConfig か、ini形式のファイルを読む logging.config.fileConfig を使います。どちらも既定で、それまでに作られたロガーを無効にする(disable_existing_loggers=True)点に気をつけてください。
Q5. printからloggingへはどう置き換えればいいですか?
A5. スクリプトの先頭で basicConfig と getLogger(__name__) を書き、print(...) を logger.info(...) に置き換えます。エラーを表示しているprintは logger.error、except の中なら logger.exception にすると、後で探しやすくなります。
Q6. ログファイルの日本語が文字化けするときはどうすればいいですか?
A6. ファイル用のHandlerに encoding="utf-8" を指定してください。指定しないとOSの既定の文字コードで書かれ、Windowsの日本語環境では化ける原因になります。basicConfigでも encoding="utf-8" を渡せます(Python 3.9以降)。
Python loggingを使い始める3つのポイントまとめ
Python loggingは、Logger・Handler・Formatter・Filterの4つの部品を組み合わせて、ログを残す仕組みです。最初は3行で始め、困ったら部品を足せば足ります。
2026年3月に共通ロガーを作ってから約8か月、27本の定期ジョブで使ってきて、押さえておけばよかったのは次の3つでした。
- 書式・レベル・ローテーションは共通の関数に1か所でまとめ、Handlerの二重登録を防ぐ
- 出力の少ない定期ジョブは、日次よりサイズ方式(5MB×3世代など)のほうが扱いやすい
- エラーが本当にファイルまで届いているかを、わざと失敗させて確かめる
落とし穴はどれも、ログが黙って欠ける種類のものでした。ログは、書いたつもりの場所に残っていて初めて役に立ちます。
ERRORが出たときにスマホで気づけるようにしたいなら、Discordへの通知を組み合わせる方法があります。
残っていなければ、Handlerがどのロガーに付いているかを見直す合図です。確かめたら、その1行は消しておきましょう。
続きを追う:X(@Tom_Java_Tech) / RSS
途中経過は、また書きます。




