定期実行のレポートを PDF にして Slack や メールに添付して送る運用をしていました。
通常時は 2〜3 MB に収まっていたのですが、月末などデータが増える日に 10 MB の上限を超えて配信失敗 する事故がありました。「失敗したら手動で送り直す」では運用が回らないので、3 段階で品質を下げて再試行する 仕組みに切り替えました。
何が起きたか
配信失敗の通知だけが残る
1[ERROR] failed to upload report.pdf: file_size_exceeded (12.4 MB > 10 MB)2[ERROR] retry attempted: 12.4 MB > 10 MB3[ERROR] giving up after 3 retries3 回リトライしても結局 12.4 MB のままで、同じファイルを 3 回送って 3 回失敗しているだけでした。サイズが変わらないのにリトライしても無意味です。
縮退の選択肢
PDF サイズを下げる方法を整理しました。
| 手段 | 削減効果 | 副作用 |
|---|---|---|
| dpi を下げる | 2〜4 倍小さくなる | 画像がボケる |
| 表示件数を絞る | 件数比例で削減 | 詳細が見えない |
| 画像を除去 | 画像比率による | グラフが消える |
| テキストのみ化 | 大幅削減 | レイアウトが崩れる |
重要な観点: 「閲覧目的」を最後まで残すこと。つまり「何が起きたかを伝える」情報が消えない順序で縮退する必要があります。
段階的縮退の設計
縮退順序の決め方
「読み手にとって最後まで残すべき情報は何か」から逆算しました。
- 最初に削るのは見た目(dpi) — 数字は読める
- 次に削るのは件数 — Top N だけにする
- 次に削るのは画像 — 表だけにする
- 最後の手段はテキストサマリー — 1 行で要約
実装
1from dataclasses import dataclass, replace2from enum import Enum3
4class RenderQuality(Enum):5 FULL = "full" # dpi=300, 全件, 画像あり6 REDUCED_DPI = "low_dpi" # dpi=1507 TOP_N_ONLY = "top_n" # 件数 100 まで8 NO_IMAGE = "no_image" # 画像除去9 TEXT_SUMMARY = "summary" # テキストのみ10
11@dataclass(frozen=True)12class RenderConfig:13 dpi: int14 max_rows: int | None15 include_images: bool19 collapsed lines
16 text_only: bool17
18PRESETS = {19 RenderQuality.FULL: RenderConfig(dpi=300, max_rows=None, include_images=True, text_only=False),20 RenderQuality.REDUCED_DPI: RenderConfig(dpi=150, max_rows=None, include_images=True, text_only=False),21 RenderQuality.TOP_N_ONLY: RenderConfig(dpi=150, max_rows=100, include_images=True, text_only=False),22 RenderQuality.NO_IMAGE: RenderConfig(dpi=150, max_rows=100, include_images=False, text_only=False),23 RenderQuality.TEXT_SUMMARY: RenderConfig(dpi=72, max_rows=20, include_images=False, text_only=True),24}25
26DEGRADE_ORDER = [27 RenderQuality.FULL,28 RenderQuality.REDUCED_DPI,29 RenderQuality.TOP_N_ONLY,30 RenderQuality.NO_IMAGE,31 RenderQuality.TEXT_SUMMARY,32]33
34MAX_BYTES = 10 * 1024 * 1024 # 10 MBリトライループ
1def render_with_degradation(data: ReportData) -> tuple[bytes, RenderQuality]:2 """サイズ上限に収まるまで段階的に品質を下げる"""3 for quality in DEGRADE_ORDER:4 config = PRESETS[quality]5 pdf_bytes = render_pdf(data, config)6
7 if len(pdf_bytes) <= MAX_BYTES:8 if quality != RenderQuality.FULL:9 logger.warning(10 f"degraded to {quality.value}: "11 f"size={len(pdf_bytes)/1024/1024:.1f}MB"12 )13 return pdf_bytes, quality14
15 logger.info(8 collapsed lines
16 f"quality={quality.value} too large: "17 f"{len(pdf_bytes)/1024/1024:.1f}MB > {MAX_BYTES/1024/1024:.0f}MB"18 )19
20 # 全段階で上限超過21 raise SizeExceededError(22 f"could not fit within {MAX_BYTES/1024/1024:.0f}MB even at TEXT_SUMMARY"23 )ポイント: ループ内でログを出して、どの段階で何 MB だったかを記録します。後で「なぜ縮退したか」を追えます。
縮退時の通知設計
配信メッセージに縮退情報を載せる
縮退した PDF をそのまま送ると、受け取った人が「いつもより情報が少ない」ことに気づきません。メッセージ本文で明示します。
1def send_report(pdf: bytes, quality: RenderQuality, original_size_mb: float):2 if quality == RenderQuality.FULL:3 message = "本日のレポートをお送りします。"4 else:5 message = (6 f"本日のレポートをお送りします。\n"7 f"⚠️ ファイルサイズの都合で品質を下げています ({quality.value})。\n"8 f"詳細版は管理画面 https://example.com/reports/2026-05-02 を参照してください。"9 )10 deliver(pdf, message=message)全段階失敗時のフォールバック
SizeExceededError を投げるだけでは無音で失敗します。少なくとも要約テキストだけは届けるようにします。
1try:2 pdf, quality = render_with_degradation(data)3 send_report(pdf, quality, ...)4except SizeExceededError:5 # 最終フォールバック: テキストサマリーだけ Slack に投げる6 summary = generate_text_summary(data) # 200 文字以内7 deliver_text_only(8 message=f"⚠️ 本日のレポートはサイズ超過で添付できませんでした。\n\n{summary}\n\n"9 f"詳細は管理画面で確認してください: https://example.com/reports/today"10 )11 logger.error("PDF delivery failed at all degradation levels, sent text fallback")「PDF が届かない」より「テキストで概要だけ届く」ほうが運用上ずっとマシです。
失敗事例から学んだ罠
罠 1: 同じ条件で 3 回リトライしている
冒頭の事故がこれでした。リトライしても入力データもレンダリング設定も同じなら、結果も同じです。
教訓: 再試行は条件を変えてから。サイズ超過のリトライでは、必ず縮退設定を渡し直します。
罠 2: dpi だけ下げても効かない
「dpi を 300 → 150 にすれば半分になる」と思っていましたが、実際には データ件数が多い ことが主因のケースが多く、dpi 縮小だけでは焼け石に水でした。
教訓: 縮退候補は 複数の独立な軸(dpi / 件数 / 画像 / テキスト化)を用意して、効くものを選べるようにします。
罠 3: テキストフォールバックを後回しにすると忘れる
「全段階で失敗したら例外」だけ書いて、テキストフォールバックを後回しにしていました。半年後に本当に全段階失敗が起きて、何の通知もなく無音で消える事故がありました。
教訓: 「全部失敗したらどうするか」を最初に書く。フォールバック設計は最後ではなく最初に決めます。
罠 4: ログで縮退履歴を残さないと分析できない
毎日のレポートが「最近よく縮退してる」かは、ログに段階情報が残っていないと分かりません。
教訓: 縮退時のログは quality.value を必ず含める。後で grep "degraded to" で頻度を集計できます。
効果
1Before:2 - サイズ超過 → 配信失敗 → 翌朝に手動再送3 - 月 2〜3 件発生4
5After:6 - サイズ超過 → 自動縮退 → 縮退通知付きで配信7 - 月数件の縮退発生、配信失敗ゼロ8 - 縮退頻度が高いレポートを別途分析 → 構造的な肥大化に気づくまとめ
| 観点 | 設計指針 |
|---|---|
| 縮退順序 | 「読み手にとって残すべき情報」から逆算する |
| リトライ条件 | 必ず条件を変える。同条件リトライは無意味 |
| 通知 | 縮退したら明示する。受け手が「情報が減った」ことに気づける |
| 最終フォールバック | テキスト要約だけは必ず届ける。無音失敗を許さない |
| ログ | 縮退段階を残し、頻度分析できるようにする |
「リトライすれば直る」という発想ではなく、「縮退すれば届く」という発想に切り替えると、配信系の障害は劇的に減りました。
外部システムの上限(添付サイズ・トークン数・rate limit)と付き合うときは、段階的縮退の階段を最初に設計しておくと、運用コストが大きく下がります。