ログ設計のベストプラクティス|記録する項目・レベル・JSON化・マスキングをPythonの実例で解説

ログ設計のベストプラクティスは、「何のために残すか」を先に決め、1件ごとに「いつ・どこで・誰が・何を・結果どうなったか」を機械で読める形(JSONなど)で残し、パスワードや個人情報は出さないことです。レベルの使い分け、リクエストをまたいで追える相関ID、保存期間と出力先までを最初に決めておくと、障害調査で困りません。

この記事では、アプリケーションのログを設計するときに決めることを順番に整理し、PythonのloggingでJSONログ・相関ID・マスキングを実装した例を実行結果つきで紹介します。根拠には、OWASPのLogging Cheat Sheet、Python公式ドキュメント、OpenTelemetryのログデータモデルなど、公開されている一次資料を使っています。Pythonのloggingモジュールそのものの使い方は「Python logging完全ガイド」にまとめているので、ここでは「どう設計するか」に絞ります。

ログ設計で最初に決める5つのこと

ログは「とりあえず全部出す」と、量が増えるわりに障害時に欲しい情報が見つからなくなります。OWASPのチートシートも、監視や警告の内容は要件・設計の段階で決め、リスクに見合ったものにすべきだとしたうえで、チェックリストを機械的に当てはめると本当の問題が埋もれる(alarm fog)と注意しています。設計の最初に、次の5つを決めておきます。

決めること 具体的に決める内容 決めないと起きること
目的 障害調査・セキュリティ監視・監査・利用状況の分析のどれに使うか 必要な項目が欠ける/不要なログで容量が膨らむ
記録するイベント 認証の成功と失敗、権限エラー、入力検証の失敗、外部API呼び出し、設定変更、起動と停止など 事故のあとで「その操作の記録がない」となる
1件に入れる項目 時刻・サービス名・レベル・イベント名・相関ID・利用者ID・結果など ログ同士をつなげられず、原因までたどれない
出さない情報 パスワード、トークン、カード番号、接続文字列、必要以上の個人情報 ログ自体が情報漏えいの原因になる
出力先と保存期間 標準出力かファイルか、どこに集めるか、何日残すか、誰が読めるか ディスク枯渇や、消してはいけないログの消失

1件のログに入れる項目:いつ・どこで・誰が・何を

OWASPのチートシートは、アプリケーションのログには各イベントについて「when, where, who and what」を記録しなければならないとしています。挙げられている項目を、Webアプリで使う形に置き換えると次のようになります。

区分 項目の例 ポイント
いつ 記録した時刻、イベントが起きた時刻 国際的な形式(ISO 8601など)で、タイムゾーンを付ける
どこで アプリ名とバージョン、ホスト名、URLとHTTPメソッド、モジュール名 複数サーバーで動くならホスト名は必須
誰が 利用者ID(ログイン済みなら)、送信元IPアドレス 氏名やメールアドレスより、内部のIDを使う
何を イベントの種類、重要度(レベル)、説明 イベント名は「order_completed」のように固定の文字列にする
結果 成功・失敗・保留、その理由、HTTPステータスコード 「失敗した」だけでなく理由まで残す

さらに、1つのリクエストで出たログをまとめて追うための相関ID(リクエストID)を全行に入れておくと、障害調査の手間が大きく減ります。OpenTelemetryのログデータモデルでも、1件のログにTimestamp・SeverityText・SeverityNumber・Body・Attributesに加えて、TraceIdとSpanIdを持たせる形が定義されています。分散トレーシングを入れていなくても、同じ考え方で自前のIDを振れば十分役に立ちます。

ログレベルの使い分け

レベルは「誰が、いつ、そのログを見るべきか」で決めます。PythonのLogging HOWTO(2026年10月17日確認)は、標準の5つのレベルの使いどころを次のように説明しています。デフォルトのレベルはWARNINGで、何も設定しなければINFOとDEBUGは出力されません。

レベル 公式の説明(要旨) 設計での使い方の例
DEBUG 問題を診断するときにだけ関心がある詳細な情報 開発中・調査中だけ有効にする。本番では通常オフ
INFO 想定どおりのことが起きたことの確認 注文受付、ログイン成功、バッチの開始と終了
WARNING 想定外のことが起きた、または問題が近く起こりそう 入力の不備で処理を断った、リトライで回復した、残り容量が少ない
ERROR 重大な問題で、ある機能を実行できない 決済APIの呼び出し失敗、DB書き込みの失敗
CRITICAL プログラム自体が実行を続けられない重大なエラー 起動時に設定が読めない、必須の接続先に全くつながらない

言語や製品によってレベルの名前と数は違います。syslogの規格RFC 5424は0(Emergency)から7(Debug)までの8段階、OpenTelemetryはTRACE・DEBUG・INFO・WARN・ERROR・FATALの6つの範囲に1〜24の数値を割り当てています。複数の言語やミドルウェアのログを1か所に集めるなら、どのレベルをどれに対応させるかを表にしておくと、集めたあとに「ERRORだけ見る」といった絞り込みがずれません。

実務でよくある失敗は、ユーザーの入力ミスのような「想定内の失敗」をERRORで出してしまうことです。ERRORを監視の通知条件にしていると通知が鳴りっぱなしになり、本当の障害が埋もれます。「運用担当者が今すぐ対応する必要があるか」をERRORの基準にすると判断しやすくなります。

構造化ログ(JSON)にする理由

「2026-10-17 注文を受け付けました user=tanaka」のような自由な文章のログは、人が読むには楽ですが、ツールで集計・検索するには毎回パターンを書いて切り出す必要があります。1行を1つのJSONにしておけば、ログ管理ツールが項目ごとに自動で取り込み、「event=order_rejected の件数を時間ごとに数える」といった検索がそのままできます。

構造化するときは、次の点をチーム内で決めておきます。

  • 項目名(time / level / service / event / request_id など)をサービス間で統一する
  • 時刻はUTCかJSTかを決め、タイムゾーン付きで出す
  • イベント名は文章ではなく固定のキーにし、可変の値は別の項目に入れる
  • 例外は1行目のメッセージと、必要ならスタックトレースを別項目に入れる

PythonでJSONログ・相関ID・マスキングを実装する

ここまでの設計を、標準ライブラリだけで実装した例です。Formatterを自作して1行1JSONで出し、contextvarsでリクエストごとの相関IDを持ち回り、パスワードは伏せ字、メールアドレスは先頭1文字以外を隠しています。Python 3.12.15で実行しました。

import contextvars
import json
import logging
import re
import sys
import uuid
from datetime import datetime, timezone

# 1リクエストの間だけ使う「相関ID」
request_id = contextvars.ContextVar("request_id", default="-")

SECRET_KEYS = {"password", "token", "card_number"}
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")

def mask(value):
    """メールアドレスは先頭1文字だけ残して隠す"""
    if not isinstance(value, str):
        return value
    return EMAIL.sub(lambda m: m.group()[0] + "...@...", value)

class JsonFormatter(logging.Formatter):
    def format(self, record):
        entry = {
            "time": datetime.fromtimestamp(record.created, timezone.utc).isoformat(timespec="milliseconds"),
            "level": record.levelname,
            "service": "order-api",
            "logger": record.name,
            "request_id": request_id.get(),
            "event": record.getMessage(),
        }
        for key, value in getattr(record, "fields", {}).items():
            entry[key] = "[REDACTED]" if key in SECRET_KEYS else mask(value)
        if record.exc_info:
            entry["error"] = self.formatException(record.exc_info).splitlines()[-1]
        return json.dumps(entry, ensure_ascii=False)

handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
log = logging.getLogger("order")
log.addHandler(handler)
log.setLevel(logging.INFO)

def place_order(user_email, item_id, qty, password):
    request_id.set(uuid.uuid4().hex[:8])
    log.info("order_received", extra={"fields": {
        "user": user_email, "item_id": item_id, "qty": qty, "password": password}})
    log.debug("在庫テーブルを検索")          # INFOより下なので出ない
    try:
        if qty <= 0:
            raise ValueError("数量は1以上")
        log.info("order_completed", extra={"fields": {"item_id": item_id, "qty": qty}})
    except ValueError:
        log.warning("order_rejected", exc_info=True, extra={"fields": {"item_id": item_id, "qty": qty}})

place_order("tanaka@example.com", "A-100", 2, "hunter2")
place_order("suzuki@example.com", "B-200", 0, "pass1234")

実行結果:

{"time": "2026-10-17T07:53:09.154+00:00", "level": "INFO", "service": "order-api", "logger": "order", "request_id": "bcfe8c2a", "event": "order_received", "user": "t...@...", "item_id": "A-100", "qty": 2, "password": "[REDACTED]"}
{"time": "2026-10-17T07:53:09.155+00:00", "level": "INFO", "service": "order-api", "logger": "order", "request_id": "bcfe8c2a", "event": "order_completed", "item_id": "A-100", "qty": 2}
{"time": "2026-10-17T07:53:09.155+00:00", "level": "INFO", "service": "order-api", "logger": "order", "request_id": "0b444164", "event": "order_received", "user": "s...@...", "item_id": "B-200", "qty": 0, "password": "[REDACTED]"}
{"time": "2026-10-17T07:53:09.155+00:00", "level": "WARNING", "service": "order-api", "logger": "order", "request_id": "0b444164", "event": "order_rejected", "item_id": "B-200", "qty": 0, "error": "ValueError: 数量は1以上"}

同じリクエストのログには同じrequest_idが付くので、2件目の注文が「受付→拒否」と進んだことを1つのIDで追えます。DEBUGのログはロガーのレベルをINFOにしているので出ていません。パスワードは[REDACTED]に置き換わり、平文では一度も出力されていません。

実際のWebアプリでは、相関IDはリクエストを受けた入り口(ミドルウェア)で発行し、上流から届いたIDがあればそれを引き継ぎます。外部APIを呼ぶときにヘッダーで渡せば、別のサービスのログとも突き合わせられます。

ログに出してはいけない情報

OWASPのチートシートは、ログに直接記録せず、削除・マスク・ハッシュ化・暗号化すべきものとして次のような情報を挙げています。

  • アプリケーションのソースコード
  • セッションIDの値(追跡が必要ならハッシュ値に置き換える)
  • アクセストークン
  • 健康情報や公的な識別番号など、配慮が必要な個人データ
  • 認証用のパスワード
  • データベースの接続文字列
  • 暗号鍵などの秘密情報
  • 銀行口座やカード会員のデータ
  • 収集に同意していない、または収集が違法になる情報

氏名・電話番号・メールアドレスのような個人データも、扱いに注意が必要なものとして挙げられています。調査に本人の特定が要らないなら、削除や仮名化(内部IDへの置き換え)を検討します。上の実装例のように、出力の直前でキー名を見て伏せ字にする仕組みを共通部品にしておくと、開発者ごとの書き忘れを防げます。

ログそのものを攻撃から守る

ログは調査の証拠になるため、攻撃の対象にもなります。OWASPは、ログの機密性(誰が読めるか)・完全性(誰が書き換えられるか)・可用性(ディスクを埋め尽くされないか)・説明責任(記録を消されないか)の4つの観点で守るよう求めており、関連する弱点としてCWE-117(ログ出力の不適切な無害化)を挙げています。

わかりやすい例が、ユーザーの入力に改行を混ぜて偽のログ行を作る「ログインジェクション」です。テキスト形式のログに入力をそのまま埋め込むと、次のようになります。

import json
import logging
import sys

user_input = "guest\nERROR:auth:login_success user=admin"

# 1行テキスト形式(ユーザー入力をそのまま埋め込む)
plain = logging.getLogger("plain")
h = logging.StreamHandler(sys.stdout)
h.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
plain.addHandler(h)
plain.warning("login_failed user=%s", user_input)

print("-----")

# JSON形式(改行は \n としてエスケープされる)
print(json.dumps({"level": "WARNING", "event": "login_failed", "user": user_input}, ensure_ascii=False))

実行結果:

WARNING:plain:login_failed user=guest
ERROR:auth:login_success user=admin
-----
{"level": "WARNING", "event": "login_failed", "user": "guest\nERROR:auth:login_success user=admin"}

テキスト形式では、入力に含まれた改行のせいで「ERROR:auth:login_success user=admin」という、本物と見分けのつかない行ができてしまいました。JSONにすると改行は\nとしてエスケープされ、1件のログの中に収まります。構造化ログには、検索しやすさだけでなく、こうした改ざんに強いという利点もあります。

出力先・ローテーション・保存期間

コンテナやクラウドで動かすアプリケーションでは、ログをファイルに書かずに標準出力へ流し、集めて保存するのは実行環境に任せる構成がよく使われます。The Twelve-Factor App(日本語訳)も、アプリケーションはログファイルへの書き込みや管理をしようとせず、イベントストリームを標準出力へ書き出すべきだとしています。

サーバーに直接ファイルで残す場合は、容量によるローテーション(RotatingFileHandler)か、時刻によるローテーション(TimedRotatingFileHandler)を設定し、ディスクが埋まらないようにします。ファイル出力の具体的な書き方は「Pythonでログをファイルに出力する方法」で解説しています。

保存期間について、OWASPは、必要な保存期間より前に消してはならず、その期間を超えて保持してもいけないとしています。期間は法令・規制・契約で決まることがあるため、業界の規則や取引先との契約を確認して決めます。

ログの書き込みでリクエストの応答が遅くなるのを避けたいときは、Python標準のQueueHandlerとQueueListenerを使うと、書き込みを別スレッドに任せられます。公式ドキュメントも、クライアントに素早く応答する必要があるWebアプリケーションなどで重要だと説明しています。

import logging
import logging.handlers
import queue
import sys

q = queue.Queue(-1)
console = logging.StreamHandler(sys.stdout)
console.setFormatter(logging.Formatter("%(levelname)s %(message)s"))

# 書き込みは別スレッドのリスナーに任せる
listener = logging.handlers.QueueListener(q, console)
listener.start()

log = logging.getLogger("app")
log.setLevel(logging.INFO)
log.addHandler(logging.handlers.QueueHandler(q))

log.info("request handled")
log.debug("この行はINFO未満なので捨てられる")
listener.stop()

実行結果:

INFO request handled

ログ管理システムを開発・導入するときの全体像

サーバーやサービスが増えると、各サーバーにログインしてログを見るやり方では追いつかなくなります。ログ管理の仕組みは、自分たちで組む場合も、クラウドのサービスを使う場合も、次の4つの段階で考えると整理できます。

段階 やること 設計で決めること
収集 各サーバー・コンテナの出力を集める エージェントを置くか、標準出力を実行環境から集めるか
加工 形式をそろえる、不要な項目や個人情報を落とす 項目名の統一、マスキングをどこで行うか
保存・検索 保存して、項目で検索・集計できるようにする 保存期間、閲覧できる人、容量と費用の上限
通知・可視化 条件に合うログで通知し、グラフで傾向を見る 通知の条件(ERRORの件数、認証失敗の急増など)と通知先

最初から大きな仕組みを作る必要はありません。アプリ側で「1行1JSON・項目名の統一・相関ID・マスキング」さえ守っておけば、あとからどのツールに乗せ替えても、そのまま検索と集計に使えます。逆にアプリ側のログが自由な文章のままだと、どんなツールを入れても効果は限られます。

なお「運用ログ」という言葉は、システムが自動で出すログとは別に、運用担当者が行った作業(リリース、設定変更、障害対応)を時系列で書き残す記録を指すこともあります。こちらも「いつ・誰が・何を・結果どうなったか」を残す点は同じで、作業記録の時刻とシステムのログの時刻を突き合わせられるようにしておくと、変更が原因の障害を早く見つけられます。

設計を見直すときのチェックリスト

  • 各ログの目的(障害調査・監視・監査)を説明できるか
  • 認証の成功と失敗、権限エラー、設定変更、起動と停止が記録されているか
  • 全行に時刻(タイムゾーン付き)・サービス名・レベル・イベント名・相関IDがあるか
  • ERRORが「今すぐ対応が必要なもの」に限られているか
  • パスワード・トークン・接続文字列が出ていないか(テストで確かめたか)
  • ユーザー入力の改行でログ行が増えない形式になっているか
  • 保存期間・閲覧権限・ディスク容量の上限が決まっているか
  • 過去の障害調査で「足りなかった情報」が反映されているか

よくある質問

Q. ログ設計のベストプラクティスを一言で言うと何ですか?

目的を先に決め、1件ごとに「いつ・どこで・誰が・何を・結果」を機械で読める形で残し、秘密情報は出さないことです。全行に相関IDを入れ、レベルの基準と保存期間をチームで決めておくと、障害調査で使えるログになります。

Q. ログはJSONにしたほうがよいですか?

複数のサーバーやサービスのログを集めて検索・集計するならJSONなどの構造化ログをおすすめします。項目ごとに検索でき、ユーザー入力の改行で偽のログ行が作られる問題も防げます。人が読むだけの小さなスクリプトならテキスト形式でもかまいません。

Q. 本番環境ではどのログレベルまで出すべきですか?

一般にはINFO以上を出し、DEBUGは調査のときだけ一時的に有効にします。PythonのloggingはデフォルトのレベルがWARNINGなので、INFOを出したい場合は明示的に設定する必要があります。

Q. ログに個人情報を出してもよいですか?

パスワードやトークン、カード情報は出してはいけません。氏名やメールアドレスも、調査に本人の特定が要らないなら内部のIDに置き換えるかマスクします。OWASPのLogging Cheat Sheetは、これらを削除・マスク・ハッシュ化・暗号化するよう求めています。

Q. ログの保存期間はどう決めればよいですか?

法令・業界の規制・取引先との契約で決まっている期間を確認し、それに合わせます。OWASPは、必要な期間より前に消してはならず、期間を超えて保持してもいけないとしています。決まりがない場合も、調査に必要な期間と保存費用から期間を決めて文書にしておきます。

関連記事

プログラミング学習に興味がある方は、まずは無料体験会へ

無料体験会・お申し込みはこちら