Pythonのloggingパッケージの仕様調査
-AzureFunctionsでの注意点を添えて-

2023年12月19日掲載

キービジュアル

本記事はソフトバンク Advent Calendar 2023の19日目の記事です。

共通プラットフォーム開発本部の日吉です。クラウドテクノロジーブログへの投稿は初ということで簡単に自己紹介をさせてください。私は2023年4月にICTオペレーション本部(運用を主業務とする本部です)から現在の部署への異動という形で参画しました。前の部署ではネットワークの運用が主な業務で、毛色がまるで異なる部署に飛び込んできた新参ですので気持ちとしては新卒社員のやり直しをしている状態です。主業務がガラッと変わり、学びが多く刺激的な日々を過ごさせていただいています。

さて、今回はそんな私がPythonのloggingパッケージについて調査した内容を記事にしました。

目次

この記事は
  • PythonのloggingパッケージにおけるLogger、Formatter、Handlerの仕様に関する基礎をまとめた記事です。
  • loggingパッケージを利用して一貫性のあるログ出力を得る方法と、AzureFunctionsにデプロイした時の挙動について記述しています。
  • 簡単なサンプルコードが含まれます。

背景

事の発端は私の部署が開発・維持管理をしている社内向けツールで発生したエラーです。関数に渡している引数が不正な形だったので、内部の演算がエラーになっている様子でした。コード中のどこで値が不正になったのかが分からなかったのでロギングを仕込んで詳細を調査することにしました。

調査の詳細は記事の主題からそれてしまうため割愛しますが、結果的には自力でログを読み解き、修正することができました(これが異動後初めての成果でした)。

Pythonにおけるロギングといえばloggingパッケージを利用するケースが多いかと思いますが、初心者が知識ゼロで挑むには難解な仕様だと感じたので、復習の意味を込めてまとめてみようと思います。

Pythonのloggingパッケージの仕様について

loggingパッケージを使う利点は大きく以下の2点が挙げられます。

  • ログフォーマット(タイムスタンプ、エラー発生箇所、などユーザに提供したい情報)を統一し、一貫性のあるログ出力が可能になる
  • ログ出力レベルの制御が容易

これらの利点を得る方法を説明する前に、loggingパッケージの構成要素のなかでも重要な3つのオブジェクト(Logger、Handler、Formatter)について解説します。

なお、loggingパッケージに含まれるオブジェクトはこの3つだけではありませんので、より詳細に学びたい方は公式ドキュメントを参考にしてください。

全体像

Logger、Handler、Formatterはそれぞれログを受け渡しながら、ユーザが指示した形式でログを出力します。最初に全体像を捉えて頂いた方が後の説明も理解しやすいと思いますので、まずは以下のフロー図を見て、全体的なイメージを掴んでもらいたいと思います。この後一つ一つ説明しますので、今の段階では「ログが発生するとLogger→Handler→Formatterの順序で処理がされるんだな」「LoggerとHandlerには閾値があるんだな」くらいの理解で大丈夫です。

なお、公式ドキュメントにはより詳細なフロー図が描かれていますが、ここでは理解しやすさを重視して必要最小限のコンポーネントに絞って記述しています。

Logger

Loggerはログレコードを生成したり、ログレコードを上位のLoggerに伝達したりする役割を担います。Loggerは複数生成することができ、それらがツリー状の構造を取っています。ツリーは任意の形を取らせることが可能ですが、私が調査した限りは、モジュール名( __name__ )を利用してLoggerを生成し、フォルダ構造と対応するツリーにすることで、ログイベントの伝達経路を分かりやすくしているケースが多いようでした。

ツリー構造の最上位に位置するLoggerはroot Loggerと呼ばれ、loggingパッケージをインポートした時点で自動で生成されます。

以下に2つのloggingパッケージの利用例を記述してみました。

patternA-logging.pyは、root Loggerに直接ログイベントを送り込むような動作になります。この場合Loggerのツリー構造を無視したログイベントの伝達になるため、ログイベントがどんなパスを通って伝達されたか分からず、デバッグの役に立たない可能性があります。
 

patternA-logging.py
import logging
val1 = "hello"
logging.warning("varName: {}".format(val1))


この程度のプログラムであれば大差ないのですが、プログラムが複雑化してくるとログの伝達経路がデバッグの役に立つため、Loggerのツリー構造を意識したpatternB-logger.pyの方がより好まれる利用方法だと思います。
 

patternB-logger.py
import logging
logger = logging.getLogger(__name__) 
val1 = "hello"
logger.warining("varName: {}".format(val1))


残念ながらここに記述した内容だけではHandlerやFormatterを適用していないので、PatternBのほうが情報量の少ないログになってしまいます。後段の実装例でこれがどのように変わるのかを見てもらいたいと思います。
 

それぞれのコードの実行結果
> python patternA-logging.py                                                                                                                       
WARNING:root:varName: hello

> python patternB-logger.py                                                                                                                        
varName: hello

Handler

HandlerはLoggerから渡されたログレコードを出力するのが主な役割です。loggingパッケージには様々な種類のHandlerが実装されていますが、標準出力にログを出力したい場合はStreamHandlerを利用します。

HandlerはLoggerに紐付けて利用されるため、それ単体でログを出力することはできません。Loggerは自身にHandlerが紐付けられているときログレコードをHandlerに渡して、Handlerがログの出力を行います。

Loggerには複数のHandlerを紐付けることも可能で、標準出力とファイルへの書込みを両方行いたいときには、StreamHandlerとFileHandlerの両方をLoggerに紐づければ実現できます。

同一種別(例えばStreamHandler)のHandlerが複数紐づけられた場合、同じ内容のログが重複して出力されることがあります(私はこの記事を書きながらそれを経験しました)。同じような状況に陥ったときには複数のHandlerがLoggerに紐付けられていないか確認してみてください。

Formatter

Formatterはログレコードの整形を主な役割とし、Handlerに紐づけて利用されます。Formatterを適切に定義することで、ログが出力される際のフォーマットが統一され、ユーザに一貫性のある情報を提供することができます。

どのようなパラメータが設定できるのかはPython Enhancement Proposals 282が参考になりますので、詳しく知りたい方は確認してみてください。

ログレベルの制御

もう一つの大事なトピックとしてログレベルの制御についても触れておきます。

トラブルシュートに備えて事細かにログを出力することは大切ですが、一方で通常稼働時に大量のログが出力されている状態は健全とは言えません。

状況に応じて過不足無く出力されるログこそが役に立つログだと言えるでしょう。

そのためにもログレベルの設定について学んでいきましょう。個々のログイベントがどのレベルで出力されるかは開発者の裁量で決まりますが、loggingライブラリの定義を翻訳すると概ね以下のような区分で書き分けるのが良さそうです。

ログレベルと利用シーンの対照表

LevelValue利用シーン
CRITICAL50プログラム全体の動作が停止するような重大な問題が生じている状態
ERROR40WARNINGよりも重大な事象が発生し、一部の機能が実行不可能な状態
WARNING30想定外の事象が発生したときや、不具合の予兆が発生している状態
INFO20期待した動作をしている状態
DEBUG10開発者が何らかの問題の調査をする時のみ利用する詳細情報

LoggerやHandlerは各インスタンス毎にログレベルの閾値を設定することができ、閾値以上のログを受け取ったときのみ後続の処理が実行されます。

この仕様を利用して、ユーザ入力で指定したレベルに応じて閾値を変更し、発生したログイベントの中から必要なログだけを出力させるといった実装が可能になります。

ここまで、loggingパッケージを利用するうえで抑えておきたいオブジェクト群を紹介しました。考慮すべき要素が多く、最初はとっつきづらいかもしれませんが、上手く使いこなすとユーザにとって有用なログ情報を的確に提供することができるツールでもあります。

ここまでは仕様の説明でしたが、ここからは実際にサンプルコードを動かしながら、ログ出力が制御される様子を確認してみましょう。

loggingパッケージを利用したロギングの実装例

前置きが長くなりましたが、簡単なログ出力の実装をしていきましょう。以下の点を押さえたログ出力が得られることを目指したいと思います。

  • ログがいつ出力されたのか把握したい
  • ログがどのモジュールで発生したのか把握したい
  • ログの重要度を把握したい
  • 必要に応じてログレベルの出し分けをできるようにしたい

早速、以下のサンプルコードを実行してみましょう。

import logging 
import argparse
from modules import submodule

# コマンドライン引数でログレベルを指定する
psr  = argparse.ArgumentParser()
psr.add_argument('-m', '--mode', help='実行時のログ出力モード', default='INFO')
args = psr.parse_args()

# Logger, Handler, Formatter をそれぞれインスタンス化
logger = logging.getLogger(__name__)
handler = logging.StreamHandler()
formatter = logging.Formatter("%(asctime)s %(name)s:%(lineno)s %(funcName)s [%(levelname)s]: %(message)s")

# HandlerにFormatterを紐付ける
handler.setFormatter(formatter)

# Loggerにログレベルの閾値を設定
logger.setLevel(args.mode)
handler.setLevel(args.mode)

# LoggerにHandlerを紐付ける
logger.addHandler(handler)

# ログ出力
logger.debug("main: デバッグログです")
logger.info("main: 想定通りです")
logger.warning("main: 想定外です")
logger.error("main: 重大です")
logger.critical("main: 極めて重大です")

# Submoduleを実行
submodule.log(args.mode)

今回は、Loggerのツリー構造を意識するか否かで出力がどの様に変わるのかを見たいので、以下のようにsubmodule.pyを実装し、上記のコードから呼び出すようにしています。

import logging

def log(mode):
    ## Logger, Handler, Formatterをそれぞれインスタンス化
    logger = logging.getLogger(__name__)
    handler = logging.StreamHandler()
    formatter = logging.Formatter("%(asctime)s %(name)s:%(lineno)s %(funcName)s [%(levelname)s]: %(message)s")

    # handlerにformatterを紐付ける
    handler.setFormatter(formatter)

    # loggerにログレベルの閾値を設定
    logger.setLevel(mode)

    # loggerにhandlerを紐付ける
    logger.addHandler(handler)

    # ログ出力
    logger.debug("submodule: デバッグログです")
    logger.info("submodule: 想定通りです")
    logger.warning("submodule: 想定外です")
    logger.error("submodule: 重大です")
    logger.critical("submodule: 極めて重大です")

このコードに対して引数にDEBUGを渡して実行すると以下のようになります。いつどのモジュールでどのレベルのログが出力されたかが統一されたフォーマットで出力されているのが分かると思います。

python main.py -m DEBUG

2023-08-07 17:54:15,261 __main__:30 <module> [DEBUG]: main: デバッグログです
2023-08-07 17:54:15,261 __main__:31 <module> [INFO]: main: 想定通りです
2023-08-07 17:54:15,261 __main__:32 <module> [WARNING]: main: 想定外です
2023-08-07 17:54:15,261 __main__:33 <module> [ERROR]: main: 重大です
2023-08-07 17:54:15,261 __main__:34 <module> [CRITICAL]: main: 極めて重大です
2023-08-07 17:54:15,261 modules.submodule:24 log [DEBUG]: submodule: デバッグログです
2023-08-07 17:54:15,261 modules.submodule:25 log [INFO]: submodule: 想定通りです
2023-08-07 17:54:15,261 modules.submodule:26 log [WARNING]: submodule: 想定外です
2023-08-07 17:54:15,261 modules.submodule:27 log [ERROR]: submodule: 重大です
2023-08-07 17:54:15,261 modules.submodule:28 log [CRITICAL]: submodule: 極めて重大です

引数にCRITICALを渡すとどうなるでしょうか?

python main.py -m CRITICAL

2023-08-07 17:53:30,361 __main__:34 <module> [CRITICAL]: main: 極めて重大です
2023-08-07 17:53:30,361 modules.submodule:28 log [CRITICAL]: submodule: 極めて重大です

コマンド実行時に指定したレベルによって閾値が変化していることが分かりますね。無事に目標を達成するようなロギングが実装できました。

Azure Functionsにおけるログレベルの制御

ここまではローカルでPythonを実行していましたが、今度はAzure Functionsへデプロイして動作を確認してみましょう。今回は動作確認が目的なので特に凝ったデプロイ方法は取らず、Microsoft社のクイックスタートマニュアル通りに進めます。

Azure Functionsへのデプロイと動作確認

ローカルで実装したサンプルコードをAzure Functionsにデプロイするように少し修正しました。

import azure.functions as func
import logging
from modules import submodule

app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)

@app.route(route="logging_python")
def logging_python(req: func.HttpRequest) -> func.HttpResponse:
    # Loggerを生成する
    logger = logging.getLogger(__name__)

    # StreamHandlerを定義する
    handler = logging.StreamHandler()

    # Formatterを定義する(第一引数はメッセージのフォーマット文字列、第二引数は日付時刻のフォーマット文字列)
    formatter = logging.Formatter("%(asctime)s %(name)s:%(lineno)s %(funcName)s [%(levelname)s]: %(message)s")

    # HTTPリクエストボディからログ出力モードを指定
    mode = req.params.get('mode')
    if not mode:
        try:
            req_body = req.get_json()
        except ValueError:
            pass
        else:
            mode = req_body.get('mode')

    # HandlerにFormatterを紐付ける
    handler.setFormatter(formatter)
    # Log Levelを変更
    logger.setLevel(mode)
    # LoggerにHandlerを紐付ける
    if not logger.handlers:
        logger.addHandler(handler)
    # ログ出力
    logger.debug("main: デバッグログです")
    logger.info("main: 想定通りです")
    logger.warning("main: 想定外です")
    logger.error("main: 重大です")
    logger.critical("main: 極めて重大です")

    submodule.log(mode)

    return func.HttpResponse(f"This HTTP triggered function on {mode} level log emission.")

マニュアルにも画像付きで分かりやすい手順が掲載されているので、デプロイの過程は省きます。デプロイ後の状態がこちらです。

動作確認も非常に簡単で、わざわざEndpointとKeyを指定してHTTPリクエストを外部から投げるまでもなく、Azure PortalのGUI上からテスト実行ができます。CRITICALを閾値に設定してテストを実行してみましょう。

以下のログが実行結果です。

2023-12-02T07:32:25Z   [Critical]   main: 極めて重大です
2023-12-02T07:32:25Z   [Critical]   submodule: 極めて重大です

ん...? 変です。ローカルで実行したときと違ってFormatterが効いていません。ローカルと同じ出力が得られてめでたしと締めたかったのですが、もう少し調査が必要そうです。

Microsoftのドキュメントを参考に自分なりに原因を考察してみました。

結論としては、関数のCode・Runに表示されるログはFunctions ホストのログ(Application Insightへ送信されるログ)であり、コードが出力したログそのもの(ドキュメント内で言うところのworkerのログ)は見えないのではないかと考えました。

workerのログが見えないかと探してみたところ、関数アプリのLog Streamで見つけることができました。


関数アプリのLogStream中には1つのログイベントに対して自前のHandlerが出力したログと、AzureFunctions独自と思われるログが並んでいました。

以下のログの中で、タイムスタンプが2つ打たれているものが自前のHandlerが出力したログです。(1〜3行目、7行目、9行目、11〜12行目、14行目、19〜20行目 が該当)

2023-12-02T09:01:50Z   [Information]   2023-12-02 09:01:49,562 function_app:36 logging_python [DEBUG]: main: デバッグログです
2023-12-02T09:01:50Z   [Information]   2023-12-02 09:01:49,568 function_app:37 logging_python [INFO]: main: 想定通りです
2023-12-02T09:01:50Z   [Warning]   2023-12-02 09:01:49,568 function_app:38 logging_python [WARNING]: main: 想定外です
2023-12-02T09:01:50Z   [Verbose]   main: デバッグログです
2023-12-02T09:01:50Z   [Information]   main: 想定通りです
2023-12-02T09:01:50Z   [Warning]   main: 想定外です
2023-12-02T09:01:50Z   [Error]   2023-12-02 09:01:49,569 function_app:39 logging_python [ERROR]: main: 重大です
2023-12-02T09:01:50Z   [Error]   main: 重大です
2023-12-02T09:01:50Z   [Information]   2023-12-02 09:01:49,573 function_app:40 logging_python [CRITICAL]: main: 極めて重大です
2023-12-02T09:01:50Z   [Critical]   main: 極めて重大です
2023-12-02T09:01:50Z   [Information]   2023-12-02 09:01:49,574 modules.submodule:23 log [DEBUG]: submodule: デバッグログです
2023-12-02T09:01:50Z   [Information]   2023-12-02 09:01:49,575 modules.submodule:24 log [INFO]: submodule: 想定通りです
2023-12-02T09:01:50Z   [Verbose]   submodule: デバッグログです
2023-12-02T09:01:50Z   [Warning]   2023-12-02 09:01:49,576 modules.submodule:25 log [WARNING]: submodule: 想定外です
2023-12-02T09:01:50Z   [Information]   submodule: 想定通りです
2023-12-02T09:01:50Z   [Warning]   submodule: 想定外です
2023-12-02T09:01:50Z   [Error]   submodule: 重大です
2023-12-02T09:01:50Z   [Critical]   submodule: 極めて重大です
2023-12-02T09:01:50Z   [Error]   2023-12-02 09:01:49,579 modules.submodule:26 log [ERROR]: submodule: 重大です
2023-12-02T09:01:50Z   [Information]   2023-12-02 09:01:49,579 modules.submodule:27 log [CRITICAL]: submodule: 極めて重大です

Microsoftのドキュメント中には以下のように記述されていることから、自前のログをApplication Insightへ送信する方法があるはずなのですが、調査しきれなかったので、これは将来の課題にしたいと思います。

既定では、書き込まれたカスタム アプリケーション ログは Functions ホストに送信され、"Worker" カテゴリを介して Application Insights に送信されます。 いくつかの言語スタックでは、ログを Application Insights に直接送信することができ、書き込んだログの出力方法を完全に制御できます。 ログ パイプラインが worker -> Functions host -> Application Insights から worker -> Application Insights に変更されます。

host.jsonによるログレベルの閾値制御

Application Insightの話が出たのでついでに触れておきたいことがあります。

検索バーに「Azure Functions ログレベル」と入力して最も先に到達する情報は「Azure Functions のログ監視におけるログレベルの閾値はhost.jsonで制御できる」というMicrosoftのドキュメントではないでしょうか。

ここで述べられているログレベルの閾値はApplication Insightへ転送するログのレベルという観点の記述であり、loggingパッケージの閾値を直接制御できるわけではないことに注意が必要です。

host.jsonにおいて以下のように記述されていた場合、loggingパッケージでログレベルの閾値をDEBUGにセットしていたとしてもApplication Insightに送信されるのはWarning以上のログのみです。

{
  "version": "2.0",
  "logging": {
    "logLevel": {
      "default": "Warning"
    },
  〜中略〜
  }
}

普段からApplication Insightを利用してログを観測しているようなプロジェクトではあえてloggingパッケージでログレベルの閾値を設定せず、host.jsonの設定で閾値を制御するのも一つの手段かもしれません。

まとめ

業務中に得た学びを文字に起こしてみただけの記事ですが、思ってよりも言語化に苦戦しました。人に説明できて初めて本当の理解だとするならば、今回改めて記事に書き起こすことで理解を深めるいい機会になりました。

そして、私の記事が私と同じような初学者をはじめ、ロギングを理解したいと願う全ての読者の一助になったなら、初学者なりに筆を走らせた甲斐があります。

ソフトバンク Advent Calendar 2023 もいよいよ残り1週間を切りましたが、まだまだ興味深い記事の公開が予定されています。20日目の投稿もお楽しみに。

関連サービス

Microsoft Azure

Microsoft Azureは、Microsoftが提供するパブリッククラウドプラットフォームです。コンピューティングからデータ保存、アプリケーションなどのリソースを、必要な時に必要な量だけ従量課金で利用することができます。

MSPサービス

MSP(Managed Service Provider)サービスは、お客さまのパブリッククラウドの導入から運用までをトータルでご提供するマネージドサービスです。

おすすめの記事

条件に該当するページがございません