フォーム読み込み中
本記事はソフトバンク Advent Calendar 2023の19日目の記事です。
共通プラットフォーム開発本部の日吉です。クラウドテクノロジーブログへの投稿は初ということで簡単に自己紹介をさせてください。私は2023年4月にICTオペレーション本部(運用を主業務とする本部です)から現在の部署への異動という形で参画しました。前の部署ではネットワークの運用が主な業務で、毛色がまるで異なる部署に飛び込んできた新参ですので気持ちとしては新卒社員のやり直しをしている状態です。主業務がガラッと変わり、学びが多く刺激的な日々を過ごさせていただいています。
さて、今回はそんな私がPythonのloggingパッケージについて調査した内容を記事にしました。
事の発端は私の部署が開発・維持管理をしている社内向けツールで発生したエラーです。関数に渡している引数が不正な形だったので、内部の演算がエラーになっている様子でした。コード中のどこで値が不正になったのかが分からなかったのでロギングを仕込んで詳細を調査することにしました。
調査の詳細は記事の主題からそれてしまうため割愛しますが、結果的には自力でログを読み解き、修正することができました(これが異動後初めての成果でした)。
Pythonにおけるロギングといえばloggingパッケージを利用するケースが多いかと思いますが、初心者が知識ゼロで挑むには難解な仕様だと感じたので、復習の意味を込めてまとめてみようと思います。
loggingパッケージを使う利点は大きく以下の2点が挙げられます。
これらの利点を得る方法を説明する前に、loggingパッケージの構成要素のなかでも重要な3つのオブジェクト(Logger、Handler、Formatter)について解説します。
なお、loggingパッケージに含まれるオブジェクトはこの3つだけではありませんので、より詳細に学びたい方は公式ドキュメントを参考にしてください。
Logger、Handler、Formatterはそれぞれログを受け渡しながら、ユーザが指示した形式でログを出力します。最初に全体像を捉えて頂いた方が後の説明も理解しやすいと思いますので、まずは以下のフロー図を見て、全体的なイメージを掴んでもらいたいと思います。この後一つ一つ説明しますので、今の段階では「ログが発生するとLogger→Handler→Formatterの順序で処理がされるんだな」「LoggerとHandlerには閾値があるんだな」くらいの理解で大丈夫です。
なお、公式ドキュメントにはより詳細なフロー図が描かれていますが、ここでは理解しやすさを重視して必要最小限のコンポーネントに絞って記述しています。
Loggerはログレコードを生成したり、ログレコードを上位のLoggerに伝達したりする役割を担います。Loggerは複数生成することができ、それらがツリー状の構造を取っています。ツリーは任意の形を取らせることが可能ですが、私が調査した限りは、モジュール名( __name__ )を利用してLoggerを生成し、フォルダ構造と対応するツリーにすることで、ログイベントの伝達経路を分かりやすくしているケースが多いようでした。
ツリー構造の最上位に位置するLoggerはroot Loggerと呼ばれ、loggingパッケージをインポートした時点で自動で生成されます。
以下に2つのloggingパッケージの利用例を記述してみました。
patternA-logging.pyは、root Loggerに直接ログイベントを送り込むような動作になります。この場合Loggerのツリー構造を無視したログイベントの伝達になるため、ログイベントがどんなパスを通って伝達されたか分からず、デバッグの役に立たない可能性があります。
import logging
val1 = "hello"
logging.warning("varName: {}".format(val1))
この程度のプログラムであれば大差ないのですが、プログラムが複雑化してくるとログの伝達経路がデバッグの役に立つため、Loggerのツリー構造を意識した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はLoggerから渡されたログレコードを出力するのが主な役割です。loggingパッケージには様々な種類のHandlerが実装されていますが、標準出力にログを出力したい場合はStreamHandlerを利用します。
HandlerはLoggerに紐付けて利用されるため、それ単体でログを出力することはできません。Loggerは自身にHandlerが紐付けられているときログレコードをHandlerに渡して、Handlerがログの出力を行います。
Loggerには複数のHandlerを紐付けることも可能で、標準出力とファイルへの書込みを両方行いたいときには、StreamHandlerとFileHandlerの両方をLoggerに紐づければ実現できます。
同一種別(例えばStreamHandler)のHandlerが複数紐づけられた場合、同じ内容のログが重複して出力されることがあります(私はこの記事を書きながらそれを経験しました)。同じような状況に陥ったときには複数のHandlerがLoggerに紐付けられていないか確認してみてください。
Formatterはログレコードの整形を主な役割とし、Handlerに紐づけて利用されます。Formatterを適切に定義することで、ログが出力される際のフォーマットが統一され、ユーザに一貫性のある情報を提供することができます。
どのようなパラメータが設定できるのかはPython Enhancement Proposals 282が参考になりますので、詳しく知りたい方は確認してみてください。
もう一つの大事なトピックとしてログレベルの制御についても触れておきます。
トラブルシュートに備えて事細かにログを出力することは大切ですが、一方で通常稼働時に大量のログが出力されている状態は健全とは言えません。
状況に応じて過不足無く出力されるログこそが役に立つログだと言えるでしょう。
そのためにもログレベルの設定について学んでいきましょう。個々のログイベントがどのレベルで出力されるかは開発者の裁量で決まりますが、loggingライブラリの定義を翻訳すると概ね以下のような区分で書き分けるのが良さそうです。
ログレベルと利用シーンの対照表
Level | Value | 利用シーン |
---|---|---|
CRITICAL | 50 | プログラム全体の動作が停止するような重大な問題が生じている状態 |
ERROR | 40 | WARNINGよりも重大な事象が発生し、一部の機能が実行不可能な状態 |
WARNING | 30 | 想定外の事象が発生したときや、不具合の予兆が発生している状態 |
INFO | 20 | 期待した動作をしている状態 |
DEBUG | 10 | 開発者が何らかの問題の調査をする時のみ利用する詳細情報 |
LoggerやHandlerは各インスタンス毎にログレベルの閾値を設定することができ、閾値以上のログを受け取ったときのみ後続の処理が実行されます。
この仕様を利用して、ユーザ入力で指定したレベルに応じて閾値を変更し、発生したログイベントの中から必要なログだけを出力させるといった実装が可能になります。
ここまで、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: 極めて重大です
コマンド実行時に指定したレベルによって閾値が変化していることが分かりますね。無事に目標を達成するようなロギングが実装できました。
ここまではローカルでPythonを実行していましたが、今度はAzure Functionsへデプロイして動作を確認してみましょう。今回は動作確認が目的なので特に凝ったデプロイ方法は取らず、Microsoft社のクイックスタートマニュアル通りに進めます。
ローカルで実装したサンプルコードを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 に変更されます。
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日目の投稿もお楽しみに。
条件に該当するページがございません