導入:本番で一番こわいのは「沈黙する例外」である
本番の Flask アプリで最初に壊れるのは、機能ではなく**「障害の見え方」**です。ユーザーから「エラーになる」と報告が来る。しかしレスポンスは無味乾燥な 500 Internal Server Error の HTML、ログには何も相関情報がなく、どのリクエストが・どのユーザーで・どの段で落ちたのかが繋がらない——この「沈黙」が、5 分で終わる調査を 2 時間の地獄に変えます。
Flask は、本番モードでは例外発生時に「ごく簡素なページを表示し、例外をロガーに記録する」だけの賢い既定を持っています(これは公式の明言です)。学習には十分ですが、REST API の本番運用には、この既定をそのまま使ってはいけません。API のクライアント(フロントエンド・モバイル・他サービス)が必要とするのは HTML ページではなく、機械可読な構造化 JSON エラーであり、調査する側が必要とするのは、リクエスト ID で相関した構造化ログです。
本記事は、Flask 本番運用ガイド(ピラー)の §6「エラー処理とロギング」を、本番品質まで深掘りするスポークです。扱うのは次の 3 層です。
- エラー処理:
errorhandler/abort/ カスタムHTTPException、解決順序、API のための JSON エラー設計。 - ロギング:
dictConfig、app.logger、構造化ログ、リクエスト ID 相関。 - 可観測性:Sentry によるエラー集約、PII スクラブ、ヘルスチェック、そしてトレーシングへの橋渡し。
筆者は、経済産業大臣賞を受賞した B2B SaaS のバックエンドを Python / Flask / SQLAlchemy / PostgreSQL で設計・実装し、ALB → ECS(Fargate) 上で構造化ログ・エラートラッキングとともに本番運用してきました。ここで示すのは、その実戦で「障害を 5 分で追える」ようにするために必要だった設計だけです。
💡 この記事で扱うバージョン:Flask 3.1 系を前提とします。エラー処理は
werkzeug.exceptions、ロギングは標準loggingがそのまま土台です。コードは公式ドキュメント(flask.palletsprojects.com の stable)のパターンに基づきます。
1. 既定のエラー挙動:なぜ API は「自分でエラーを設計」しなければならないか
まず、Flask が何もしないとどうなるかを正確に押さえます。公式の言葉そのままです——「アプリケーションが本番モードで動作していて例外が送出されると、Flask はごく簡素なページを表示し、例外をロガーに記録する」。
これは Web ページとしては妥当な既定です。しかし API では 2 つの問題があります。
- レスポンスが HTML:
Content-Type: text/htmlの 500 ページが返るので、JSON を期待するクライアントはパースに失敗します。エラーの種類(バリデーション失敗か、リソース不在か、サーバー内部障害か)をクライアントが機械的に判別できません。 - デバッグモードの罠:開発時に
debug=Trueだと、500 のハンドラは使われずインタラクティブデバッガが表示されます。これを本番で晒すと任意コード実行につながる重大な脆弱性です(本番ではDEBUGは必ず無効)。
⚠️ 本番で
DEBUG=Trueは厳禁。公式は「デバッグモードでは『500 Internal Server Error』のハンドラは使われず、代わりにインタラクティブデバッガが表示される」と明言しています。デバッガは Werkzeug のコンソールであり、サーバー上で任意の Python を実行できます。本番で晒せば即座に侵害されます。DEBUGの扱いは本番運用ガイドの設定章を参照してください。
結論はシンプルです。API では、例外がそのまま 500 HTML になることを許さず、「どの例外を・どの HTTP ステータスで・どんな JSON で返すか」を自分で設計する。それが本章以降のテーマです。
2. エラーハンドラの基礎:errorhandler / register_error_handler / abort
2.1 ハンドラを登録する 2 つの方法
例外やステータスコードに対する処理は、ハンドラ関数として登録します。書き方は 2 通りで、機能は同じです。
from werkzeug.exceptions import BadRequest
# 方法 A:デコレータ(局所的で読みやすい)
@app.errorhandler(BadRequest)
def handle_bad_request(e):
return "bad request!", 400
# 方法 B:register_error_handler(ファクトリ内でまとめて登録しやすい)
app.register_error_handler(400, handle_bad_request)
ここで重要な公式の事実が 2 つあります。
HTTPExceptionのサブクラスと HTTP コードは登録時に互換です。BadRequest.code == 400なので、@app.errorhandler(BadRequest)と@app.errorhandler(400)は同じ意味になります。- ハンドラの戻り値のステータスコードは自動設定されない。公式は「レスポンスのステータスコードはハンドラのコードに設定されない。ハンドラからレスポンスを返すときは、適切な HTTP ステータスコードを必ず自分で指定すること」と注意しています。つまり
return "bad request!", 400のように第 2 戻り値でステータスを明示しなければ、200 で返ってしまいます。
⚠️ 「ステータスコードの付け忘れ」は頻出バグです。
@app.errorhandler(404)の中でreturn jsonify(error="not found")とだけ書くと、HTTP ステータスは 200 になります。エラーハンドラの戻り値には必ず, 404のようにコードを添えてください。アプリケーションファクトリ構成での登録(register_error_handlerをcreate_app内で呼ぶ)は本番運用ガイドのファクトリ章と整合します。
2.2 abort(code, description):ビューから明示的に中断する
ビュー関数の途中で「ここで 404 を返したい」というとき、abort() を使います。abort() は対応する HTTPException を送出し、登録済みのハンドラに処理を委ねます。公式のパターンです。
from flask import abort, render_template, request
@app.route("/profile")
def user_profile():
username = request.args.get("username") # クエリ文字列から取得
if username is None:
abort(400) # 引数不正 → 400
user = get_user(username=username)
if user is None:
abort(404) # 該当ユーザーなし → 404
return render_template("profile.html", user=user)
@app.errorhandler(404)
def page_not_found(e):
return render_template("404.html"), 404
abort(404, description="Resource not found") のように第 2 引数で説明文を上書きできます。これは後述の JSON エラーで、クライアントに具体的な理由を伝えるのに役立ちます。
💡 ビューの中で「
usernameが無ければ 400」「ユーザーが居なければ 404」と早期にabortするのは、ガード節(early return)の一形態です。深いネストを避け、正常系のロジックを下にまっすぐ並べられます。request.argsの取得やgを使ったリクエストスコープの値の引き回しは、アプリケーション/リクエストコンテキスト解説で詳しく扱っています。
2.3 カスタム HTTPException サブクラス:ドメイン固有のエラーを定義する
標準にないステータス(例:507 Insufficient Storage)や、ドメイン固有のエラーを定義したいときは、HTTPException を継承します。公式のパターンです。
import werkzeug.exceptions
class InsufficientStorage(werkzeug.exceptions.HTTPException):
code = 507
description = "Not enough storage space."
app.register_error_handler(InsufficientStorage, handle_507)
raise InsufficientStorage()
code と description をクラス属性に持たせるだけで、raise するだけの自前例外が作れます。業務的に意味のある名前(InsufficientStorage)でコードを書けるため、ビュー関数の可読性が上がります。
3. 解決順序:どのハンドラが選ばれるか(コード → クラス階層 → 最も具体的)
複数のハンドラを登録したとき、「どれが選ばれるか」は本番の挙動を左右する核心です。公式の規則をそのまま引きます。
Flask がリクエスト処理中に例外を捕捉すると、まずコードで照合される。そのコードのハンドラが無ければ、Flask はクラス階層でエラーを照合し、最も具体的なハンドラが選ばれる。ハンドラが一切登録されていなければ、
HTTPExceptionのサブクラスはそのコードに関する汎用メッセージを表示し、それ以外の例外は汎用の「500 Internal Server Error」に変換される。
これを表で整理します。
| 段階 | 照合対象 | 例 |
|---|---|---|
| ① コード一致 | @app.errorhandler(404) のような数値コード | abort(404) → コード 404 のハンドラ |
| ② クラス階層 | コード一致が無ければ例外クラスを辿る | BadRequest(=HTTPException のサブクラス)→ HTTPException のハンドラ |
| ③ 最も具体的 | 階層上、より近い(具体的な)ハンドラを優先 | Exception と HTTPException の両方があれば、HTTPException の例外には後者 |
| ④ 未登録時 | HTTPException は汎用メッセージ、それ以外は 500 へ | 想定外の例外 → 500 Internal Server Error |
この規則の実用的な含意は 2 つあります。
- 広いハンドラ(
HTTPException/Exception)と狭いハンドラ(404)を併存させてよい。狭い方が優先されるので、「個別に作り込みたいエラー(404)は専用ハンドラ、それ以外は包括ハンドラで JSON 化」という二段構えが組めます。 ExceptionハンドラはHTTPExceptionを奪わない。公式は明言しています——「HTTPExceptionとExceptionの両方にハンドラを登録した場合、HTTPExceptionのハンドラの方が具体的なので、ExceptionハンドラはHTTPExceptionサブクラスを処理しない」。この性質が、次章の「想定外の例外だけを 500 として握る」設計を可能にします。
3.1 Blueprint と解決順序:404 だけは例外
Blueprint ごとにハンドラを登録すると、その Blueprint 配下のエラーに対してはBlueprint のハンドラがアプリ全体のハンドラより優先されます。ただし重大な例外が 1 つあります。公式の言葉です。
Blueprint に登録したハンドラはグローバルに登録したものより優先される。ただし Blueprint は 404 ルーティングエラーを処理できない。404 はルーティングの段階で発生し、どの Blueprint かが決定される前だからである。
⚠️ 「Blueprint で 404 を握ろうとして握れない」は典型的なハマりどころです。存在しない URL は、どの Blueprint にも到達する前にルーティング層で 404 になります。したがって404 ハンドラはアプリレベル(
@app.errorhandler(404))に登録してください。Blueprint レベルのハンドラは、その Blueprint の中でabort(403)するような「到達後のエラー」にのみ効きます。Blueprint 構成そのものは本番運用ガイドで扱っています。
4. JSON エラー設計:API のための「一貫したエラーエンベロープ」
ここからが API 設計の本丸です。目標は、すべてのエラーが同じ形の JSON で返ること。クライアントは 1 種類のパース処理だけでエラーを扱えます。
4.1 すべての HTTPException を JSON に変える
abort(404) や abort(400) が返すのは既定で HTML です。これを一括で JSON に変えるのが、公式が示す HTTPException ハンドラです。
from flask import json
from werkzeug.exceptions import HTTPException
@app.errorhandler(HTTPException)
def handle_exception(e):
"""すべての HTTP エラーを JSON で返す。"""
# HTTPException が持つレスポンスをベースに、本文だけ JSON に差し替える
response = e.get_response()
response.data = json.dumps({
"code": e.code,
"name": e.name,
"description": e.description,
})
response.content_type = "application/json"
return response
e.get_response() が返すレスポンスはステータスコードがすでに正しく設定済みなので、ここでは , e.code を添える必要がありません(§2.1 の注意は、レスポンスオブジェクトではなくタプルを返す場合の話です)。code / name / description を本文に入れることで、abort(404, description="...") の説明文がそのままクライアントに届きます。
4.2 想定外の例外も 500 JSON に変える(isinstance パススルー)
HTTPException ハンドラは「Flask が知っている HTTP エラー」を捌きます。しかし、KeyError や ZeroDivisionError のような想定外の例外は HTTPException ではありません。これらも JSON で返したいなら、Exception ハンドラを足します。公式のパターンです。
from werkzeug.exceptions import HTTPException
@app.errorhandler(Exception)
def handle_exception(e):
# HTTPException はここで握らず、専用ハンドラ(§4.1)に委ねる
if isinstance(e, HTTPException):
return e
# 想定外の例外だけを 500 として扱う
return render_template("500_generic.html", e=e), 500
ここで if isinstance(e, HTTPException): return e のパススルーが要点です。§3 で見たとおり HTTPException ハンドラの方が具体的なので本来 Exception ハンドラには HTTPException は来ませんが、コードの意図を明示し、将来の登録順変更にも頑健にするため、このガードを入れておくのが定石です。API なら render_template の代わりに JSON を返します。
from flask import jsonify
from werkzeug.exceptions import HTTPException
@app.errorhandler(Exception)
def handle_unexpected_exception(e):
if isinstance(e, HTTPException):
return e
# 想定外の例外は詳細を漏らさず、一貫したエンベロープで 500 を返す
app.logger.exception("unhandled exception") # スタックトレースはログへ
return jsonify(code=500, name="Internal Server Error",
description="予期しないエラーが発生しました。"), 500
⚠️ エラーレスポンスに内部情報を載せない。スタックトレース・SQL 文・ファイルパスをクライアントに返すのは情報漏洩です。詳細は
app.logger.exception()でログ側に記録し、クライアントには汎用メッセージだけを返します。秘密や PII を露出させない原則はセキュリティ実装ガイドと一貫します。
4.3 想定外例外の正体:InternalServerError と original_exception
未捕捉の例外が 500 ハンドラに渡るとき、渡される e は元の例外そのものではありません。公式の重要な事実です。
Flask 1.1.0 以降、このエラーハンドラには常に
InternalServerErrorのインスタンスが渡され、元の未捕捉例外そのものではない。元のエラーはe.original_exceptionで参照できる。
from werkzeug.exceptions import InternalServerError
@app.errorhandler(InternalServerError)
def handle_500(e):
original = e.original_exception # 元の例外(KeyError 等)を取り出す
app.logger.error("internal error: %s", original)
return jsonify(code=500, name="Internal Server Error"), 500
加えて§1 でも触れたとおり、デバッグモードでは 500 ハンドラは使われずデバッガが出るため、500 ハンドラの動作確認は DEBUG=False で行う必要があります。
4.4 アプリ独自のエラー型:InvalidAPIUsage パターン
「業務的なエラー(在庫不足・利用上限超過など)を、メッセージと任意のペイロード付きで返したい」というとき、HTTPException を継承する代わりに、素の Exception を継承した API エラー型を作る公式パターンが便利です。
from flask import jsonify
class InvalidAPIUsage(Exception):
status_code = 400
def __init__(self, message, status_code=None, payload=None):
super().__init__()
self.message = message
if status_code is not None:
self.status_code = status_code
self.payload = payload
def to_dict(self):
rv = dict(self.payload or ())
rv["message"] = self.message
return rv
@app.errorhandler(InvalidAPIUsage)
def invalid_api_usage(e):
return jsonify(e.to_dict()), e.status_code
ビューからは、業務ルール違反を 1 行で表現できます。
@app.route("/api/orders", methods=["POST"])
def create_order():
if not has_stock():
raise InvalidAPIUsage("在庫が不足しています。", status_code=409,
payload={"reason": "out_of_stock"})
...
payload に任意のキーを足せるので、クライアントが分岐に使える機械可読な情報(reason など)を添えられます。status_code を可変にしておくことで、400 系も 409 も同じ型で表現できます。
4.5 marshmallow の ValidationError を同じエンベロープに揃える
入力バリデーションに marshmallow を使っているなら、その ValidationError も同じエラーエンベロープに統合すべきです。対になる marshmallow × Flask × SQLAlchemy で本番 REST API を設計する では、@app.errorhandler(ValidationError) で err.messages を 422(Unprocessable Entity) に変換するハンドラを示しました。これと本記事のハンドラ群を、1 つの登録関数にまとめるのが本番の型です。
# errors.py — エラーハンドラを 1 箇所に集約する
from flask import Flask, jsonify
from marshmallow import ValidationError
from werkzeug.exceptions import HTTPException
def register_error_handlers(app: Flask) -> None:
@app.errorhandler(ValidationError)
def handle_validation_error(e: ValidationError):
# バリデーション失敗 → 422。フィールド別メッセージを返す
return jsonify(code=422, name="Unprocessable Entity",
errors=e.messages), 422
@app.errorhandler(HTTPException)
def handle_http_exception(e: HTTPException):
# 既知の HTTP エラー → JSON 化(abort(404, description=...) 等)
return jsonify(code=e.code, name=e.name, description=e.description), e.code or 500
@app.errorhandler(InvalidAPIUsage)
def handle_invalid_api_usage(e: InvalidAPIUsage):
# 業務ルール違反 → 任意のステータス+ペイロード
return jsonify(code=e.status_code, **e.to_dict()), e.status_code
@app.errorhandler(Exception)
def handle_unexpected(e: Exception):
if isinstance(e, HTTPException):
return e # 既知の HTTP エラーは専用ハンドラへ委ねる
app.logger.exception("unhandled exception")
return jsonify(code=500, name="Internal Server Error",
description="予期しないエラーが発生しました。"), 500
これで API のエラーは、出自(HTTP・バリデーション・業務ルール・想定外)に関わらず、code / name を共通キーとして持つ一貫した JSON になります。クライアントは code で機械的に分岐でき、errors(422)や reason(業務)といった出自固有の情報を必要に応じて読み取れます。
| エラー出自 | 例外 | ステータス | 主なキー |
|---|---|---|---|
| 入力バリデーション | ValidationError(marshmallow) | 422 | errors(フィールド別) |
| 既知の HTTP エラー | HTTPException / abort() | 4xx/5xx | description |
| 業務ルール違反 | InvalidAPIUsage | 任意(409 等) | message / reason |
| 想定外 | その他すべての Exception | 500 | description(汎用) |
💡 この
register_error_handlers(app)をcreate_app()から呼ぶことで、ハンドラ登録がファクトリの一部として一元管理されます。各ルートにtry / exceptを散らかさない——これが DRY とテスト容易性の両立です。
5. ロギングの基礎:dictConfig を「アプリ生成より前に」
エラーを JSON で返せるようになったら、次は調査するためのログです。Flask のロギングは標準 logging そのものです。新しい API を覚える必要はありませんが、設定するタイミングだけは Flask 固有の鉄則があります。
5.1 最重要の鉄則:設定はアプリ生成より前に
公式の最重要注意です——「app.logger がロギング設定前にアクセスされると、既定ハンドラが追加される。可能なら、アプリケーションオブジェクトを生成する前にロギングを設定すること」。
正準形は logging.config.dictConfig です。公式のテンプレートをそのまま示します。
from logging.config import dictConfig
dictConfig({
"version": 1,
"formatters": {"default": {
"format": "[%(asctime)s] %(levelname)s in %(module)s: %(message)s",
}},
"handlers": {"wsgi": {
"class": "logging.StreamHandler",
"stream": "ext://flask.logging.wsgi_errors_stream",
"formatter": "default",
}},
"root": {"level": "INFO", "handlers": ["wsgi"]},
})
app = Flask(__name__) # ← dictConfig の「後」で生成する
stream: ext://flask.logging.wsgi_errors_stream は Flask 特有の指定で、リクエスト処理中は WSGI サーバーのエラーストリーム(environ["wsgi.errors"])へ、それ以外は sys.stderr へ書き出します。
⚠️
dictConfigをcreate_appの中(Flask(__name__)の後)に書くと手遅れになることがあります。ファクトリ構成では、create_appを呼ぶ前にdictConfigを実行するか、ロギング設定をモジュールの import 時(create_app定義より前のトップレベル)に置きます。ファクトリの起動順序は本番運用ガイドのファクトリ章と合わせて設計してください。
5.2 app.logger の使い方とログレベル
ログ出力は app.logger を使います。%s 形式の遅延フォーマットを使うのが標準作法です。
app.logger.debug("詳細な診断情報")
app.logger.info("%s logged in successfully", user.username)
app.logger.warning("認証に %d 回失敗", attempts)
app.logger.error("DB への接続に失敗: %s", err)
⚠️ ログレベルの落とし穴。Python の
loggingの既定レベルは通常WARNINGです。つまり設定したレベルより下のログは一切出ません。app.logger.info(...)を仕込んだのに何も出ない、という事故の大半はこれです。dictConfigのroot.level(または該当ロガーのレベル)をINFO以上に明示してください。
5.3 既定ハンドラの挙動と、その除去
ロギングを一切設定しないと、Flask は app.logger に StreamHandler を 1 つ追加し、リクエスト中は environ["wsgi.errors"](通常 sys.stderr)へ、それ以外は sys.stderr へ書き出します。自前のハンドラを付けたいのに既定ハンドラが残ると二重出力になるため、明示的に外します。
from flask.logging import default_handler
app.logger.removeHandler(default_handler)
なお、Werkzeug(開発サーバー)はリクエスト/レスポンスを werkzeug ロガーに記録します。本番ではアクセスログは Gunicorn / リバースプロキシ側で扱うことが多く、その設計はデプロイガイドで扱います。
6. 構造化ログとリクエスト ID:ログを「相関できる」状態にする
[2026-06-20 10:00:00] INFO in views: order created のような行ログは、人間が 1 行ずつ読む分には十分ですが、集約基盤(CloudWatch Logs / Datadog / Loki 等)で機械的に検索・相関するには不向きです。本番では 2 つの強化を入れます——(1) リクエスト文脈の注入と (2) JSON 構造化です。
6.1 リクエスト文脈をログに注入する(RequestFormatter)
公式は、ログレコードにリクエスト情報(URL・接続元)を注入するカスタム Formatter を示しています。要点は has_request_context() でリクエストコンテキストの有無を判定することです(起動時やバックグラウンドのログはリクエスト外で出るため)。
import logging
from flask import has_request_context, request
from flask.logging import default_handler
class RequestFormatter(logging.Formatter):
def format(self, record):
if has_request_context():
record.url = request.url
record.remote_addr = request.remote_addr
else:
record.url = None
record.remote_addr = None
return super().format(record)
formatter = RequestFormatter(
"[%(asctime)s] %(remote_addr)s requested %(url)s\n"
"%(levelname)s in %(module)s: %(message)s"
)
default_handler.setFormatter(formatter)
has_request_context() を介すことで、リクエスト中でもバックグラウンドでも同じフォーマッタが安全に使えるようになります。has_request_context() とコンテキストの寿命の詳細はコンテキスト解説を参照してください。
6.2 リクエスト ID(相関 ID)を発番して全ログに通す
障害調査で決定的なのがリクエスト ID(相関 ID)です。1 リクエストに一意の ID を振り、そのリクエスト中のすべてのログ行に同じ ID を含めれば、「このリクエストで起きたこと」を ID 一発で串刺し検索できます。before_request で発番し、g に載せ、ログレコードへ注入します。
import uuid
from flask import g, has_request_context, request
@app.before_request
def assign_request_id():
# 上流(ALB 等)が X-Request-ID を付けていれば踏襲、無ければ発番する
g.request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
@app.after_request
def echo_request_id(response):
# クライアント/上流が突き合わせられるよう、レスポンスにも載せる
if "request_id" in g:
response.headers["X-Request-ID"] = g.request_id
return response
これを RequestFormatter に組み込みます。
class RequestFormatter(logging.Formatter):
def format(self, record):
if has_request_context():
record.request_id = g.get("request_id", "-")
record.url = request.url
record.remote_addr = request.remote_addr
else:
record.request_id = "-"
record.url = None
record.remote_addr = None
return super().format(record)
💡
gはリクエストごとに独立です。コンテキストローカルとして実装されているため、Gunicorn の複数ワーカー/スレッドが並行処理しても、リクエスト ID が混ざることはありません。gの正体(contextvarsベースで「コンテキスト内グローバル」)はコンテキスト解説で詳説しています。上流の ALB やリバースプロキシが付ける ID を踏襲する設計はデプロイガイドとも噛み合います。
6.3 JSON 構造化ログ:集約基盤で検索可能にする
CloudWatch Logs Insights や Datadog でフィールド単位に検索・集計するには、ログ自体を JSON 行にします。リクエスト ID・レベル・モジュールが構造化フィールドになるため、「request_id = ... の全ログ」「level = ERROR を時系列で」といったクエリが即座に書けます。
import json
import logging
from flask import g, has_request_context, request
class JsonFormatter(logging.Formatter):
def format(self, record):
log = {
"timestamp": self.formatTime(record),
"level": record.levelname,
"module": record.module,
"message": record.getMessage(),
}
if has_request_context():
log["request_id"] = g.get("request_id", "-")
log["method"] = request.method
log["path"] = request.path
log["remote_addr"] = request.remote_addr
# 例外があればスタックトレースも構造化して載せる
if record.exc_info:
log["exc_info"] = self.formatException(record.exc_info)
return json.dumps(log, ensure_ascii=False)
💡 本番ではログをファイルに書かず、stdout/stderr に出す。コンテナ(ECS/Kubernetes)では 12-factor の原則どおり、アプリはログをストリームとして標準出力へ吐き、収集はプラットフォーム(CloudWatch Logs / Fluent Bit 等)に任せるのが定石です。
logging.StreamHandler(stdout)+JsonFormatterで十分です。なお、標準ライブラリには
logging.handlers.RotatingFileHandler(サイズでローテーションするファイルハンドラ)もありますが、これは Flask のロギングドキュメントの例ではなく標準ライブラリの機能です。VM 上で直接ファイルに残す構成では選択肢になりますが、コンテナでは前述のとおり stdout を推奨します。
実運用への組み込みは、dictConfig の handlers に JsonFormatter を指すフォーマッタを定義する形になります。これでログは、§4 の JSON エラーレスポンスとは別経路で、集約基盤に構造化されたまま流れ込みます。
7. エラー集約(Sentry)と PII スクラブ
ログを集約しても、「いつ・どのエラーが・何件・どのリリースで増えたか」を集計・通知するには、エラートラッキング基盤が要ります。代表が Sentry です。Flask とは公式インテグレーションで繋ぎます。
pip install "sentry-sdk[flask]"
import sentry_sdk
from sentry_sdk.integrations.flask import FlaskIntegration
sentry_sdk.init(
dsn="https://<key>@<org>.ingest.sentry.io/<project>", # 環境変数から注入する
integrations=[FlaskIntegration()],
traces_sample_rate=0.1, # トレースは一部サンプリング(コスト最適化)
send_default_pii=False, # 既定で PII を送らない(後述)
environment="production",
)
FlaskIntegration を入れるだけで、未捕捉例外が自動的に Sentry へ送られ、スタックトレース・リクエスト情報・リリースで集約・通知されます。app.logger.error(...) 相当のログも取り込めます。
7.1 最重要:PII と秘密をスクラブする
ここが本番で最も注意すべき点です。エラートラッキング基盤に個人情報(PII)や秘密が流れ込んだ瞬間、その基盤自体が情報漏洩経路になります。
send_default_pii=Falseを明示し、Sentry が自動でユーザー情報・Cookie・ボディを拾わないようにする。- リクエストボディに含まれる連絡先・パスワード・トークンを、送信前フックでマスクする。
- 相関に必要なのは「誰か」ではなく「同じ人か」なので、ユーザー識別子はハッシュ化して使う。
def scrub(event, hint):
# 機密ヘッダを落とす
headers = event.get("request", {}).get("headers", {})
for key in ("Authorization", "Cookie", "X-Api-Key"):
headers.pop(key, None)
return event
sentry_sdk.init(
dsn="...",
integrations=[FlaskIntegration()],
send_default_pii=False,
before_send=scrub, # 送信直前に PII/秘密をスクラブする
)
⚠️ このサイト自身のセキュリティ方針とも一貫します——お問い合わせフォーム等の PII はログにもエラートラッカーにも残さない。ログ・エラーイベント・スパン属性のいずれにも、個人名や連絡先を入れないでください。秘密情報の取り扱いはセキュリティ実装ガイド、テレメトリへの PII 混入の回避はOpenTelemetry 可観測性ガイドでそれぞれ詳説しています。
7.2 SMTP でエラーをメール通知する(小規模向け)
Sentry を導入しない小規模構成では、標準ライブラリの SMTPHandler で「ERROR 以上をメール送信」する手もあります。デバッグ時には送らない(if not app.debug)のが作法です。
import logging
from logging.handlers import SMTPHandler
if not app.debug:
mail_handler = SMTPHandler(
mailhost="127.0.0.1",
fromaddr="server-error@example.com",
toaddrs=["ops@example.com"],
subject="Application Error",
)
mail_handler.setLevel(logging.ERROR) # ERROR 以上だけ通知
app.logger.addHandler(mail_handler)
メールはノイズが多く規模が大きいと破綻しやすいので、本格運用では Sentry のような集約基盤を推奨します。
8. ヘルスチェックと、トレーシングへの橋渡し
可観測性の最後のピースは、「アプリが生きているか・受け入れ可能か」をロードバランサに伝えるヘルスチェックです。ALB → ECS(Fargate) 構成では、これが異常なタスクの自動切り離しと再起動を駆動します。
8.1 liveness と readiness を分ける
2 種類のチェックを別エンドポイントに分けるのが本番の型です。混ぜると、DB の一時的な不調でアプリ全体が殺されるような過剰反応を招きます。
| 種類 | エンドポイント | 意味 | 失敗時の挙動 |
|---|---|---|---|
| liveness | /health | プロセスが生きているか(依存は見ない) | タスクを再起動する |
| readiness | /ready | トラフィックを受けられるか(DB 等の依存を確認) | LB から一時的に外す |
from flask import jsonify
from sqlalchemy import text
from .extensions import db
@app.get("/health")
def health():
# liveness: 依存を一切叩かず、プロセスの生存だけを返す(軽量・高速)
return jsonify(status="ok"), 200
@app.get("/ready")
def ready():
# readiness: DB へ最小クエリを投げ、受け入れ可能かを判定する
try:
db.session.execute(text("SELECT 1"))
except Exception:
app.logger.exception("readiness check failed: db unreachable")
return jsonify(status="unavailable", checks={"db": "fail"}), 503
return jsonify(status="ok", checks={"db": "ok"}), 200
💡 liveness は依存を叩かないのが鉄則です。
/healthで DB を確認してしまうと、DB が一瞬詰まっただけで ALB がアプリのタスクを「死んでいる」と判断して再起動し、復旧を妨げることがあります。「再起動で直る障害(プロセス異常)」だけを liveness で、「待てば直る障害(依存の一時不調)」は readiness で受ける——この切り分けが、無用な再起動ループを防ぎます。ALB のヘルスチェックパス設定や ECS のタスク定義との対応はデプロイガイドで扱います。
8.2 トレーシング・メトリクスへの橋渡し
ログ・エラー・ヘルスチェックを整えると、次に欲しくなるのが**「1 リクエストが複数サービスをどう通ったか」を線で追うトレース**です。本記事の構造化ログとリクエスト ID は、その入口になります。
- 本記事の request_id は、分散トレーシングの trace_id に発展します。OpenTelemetry を入れれば、リクエスト ID を自分で発番する代わりに、標準のコンテキスト伝播で複数サービスをまたいで自動相関できます。
- ログに
trace_id/span_idを載せれば、「メトリクスで気づき → トレースで場所を特定 → ログでなぜかを読む」 の 3 シグナル相関が成立します。
この「3 シグナル(トレース・メトリクス・ログ)を相関させる」可観測性の全体設計、計装、サンプリング、PII スクラブの詳細は OpenTelemetry 本番可観測性ガイド に集約しています。そして、実際に障害が起きたときに「誰が・どう動き・どう記録するか」の運用面は インシデント対応 / ポストモーテム / オンコール ガイド で扱っています。本記事のエラー JSON とリクエスト ID は、その調査の起点になる土台です。
まとめ:本番の Flask は「沈黙しない」
Flask の既定は賢いですが、本番 API ではそのまま使えません。エラーを設計し、ログを相関可能にし、障害を集約して初めて、「動いているのに分からない」が消えます。本記事の要点を再掲します。
- 既定の 500 HTML を許さない。API ではエラーを JSON で返すよう自分で設計する。本番で
DEBUG=Trueはデバッガを晒す重大脆弱性。 errorhandler/abort/ カスタムHTTPExceptionでエラーを宣言的に扱い、解決順序(コード → クラス階層 → 最も具体的)を理解する。Blueprint は 404 を握れないことに注意。- JSON エラーを一貫したエンベロープに統一する。
HTTPException→JSON、Exception(isinstanceパススルー)、InvalidAPIUsage、そして marshmallow のValidationError→422 を 1 つのregister_error_handlersに集約する。 InternalServerError.original_exceptionで想定外例外の正体を掴み、内部情報はクライアントに漏らさずログへ記録する。dictConfigをアプリ生成より前に実行する。app.loggerを触る前に設定しないと既定ハンドラが付く。ログレベル既定WARNINGの罠に注意。RequestFormatter+ リクエスト ID(g+before_request)+ JSON 構造化ログで、集約基盤から ID 一発で串刺し検索できる状態にする。コンテナではファイルでなく stdout。- Sentry(
sentry-sdk[flask])でエラーを集約し、send_default_pii=False+before_sendで PII / 秘密をスクラブする。 /health(liveness)と/ready(readiness, DB 疎通)を分け、ALB/ECS の自動回復を正しく駆動する。request_id はトレーシングへの橋渡しになる。
本番ロギング / エラー設計チェックリスト
- API のエラーはすべて JSON で返るか(HTML 500 が漏れていないか)
-
DEBUGは本番で確実に無効か(デバッガが晒されていないか) - エラーハンドラの戻り値に HTTP ステータスを明示しているか(200 で返っていないか)
- 404 ハンドラはアプリレベルで登録しているか(Blueprint では握れない)
-
ValidationError→422 を含め、エラーエンベロープが全出自で一貫しているか - 想定外例外の詳細(スタックトレース・SQL・パス)をクライアントに返していないか
-
dictConfigをアプリ生成より前に実行しているか - ログレベルは
INFO以上に明示し、出るべきログが出ているか - リクエスト ID を発番し、全ログ行とレスポンスヘッダに相関させているか
- コンテナでログをstdout/stderr(JSON 構造化)に出しているか(ファイルに書いていないか)
- Sentry 等に PII / 秘密が流れていないか(
send_default_pii=False+ スクラブ) -
/health(liveness, 依存を叩かない)と/ready(readiness, DB 確認)を分けているか
エラー処理・ロギング・可観測性は、機能ではなく**「障害が起きたときの自分の速度」を決める投資**です。Flask の薄さは、この層を自分で設計する自由——そして責任——を意味します。全体像はFlask 本番運用ガイドへ、入力境界の設計は marshmallow × Flask × SQLAlchemy ガイド へ、3 シグナルの可観測性は OpenTelemetry ガイド へとつないでください。