45395 - シコウサクゴ -

PDF生成の上限超過に段階的に縮退する:dpi縮小→件数削減→画像除去の3段階リトライ

定期実行のレポートを 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 MB
3
[ERROR] giving up after 3 retries

3 回リトライしても結局 12.4 MB のままで、同じファイルを 3 回送って 3 回失敗しているだけでした。サイズが変わらないのにリトライしても無意味です。

縮退の選択肢

PDF サイズを下げる方法を整理しました。

手段削減効果副作用
dpi を下げる2〜4 倍小さくなる画像がボケる
表示件数を絞る件数比例で削減詳細が見えない
画像を除去画像比率によるグラフが消える
テキストのみ化大幅削減レイアウトが崩れる

重要な観点: 「閲覧目的」を最後まで残すこと。つまり「何が起きたかを伝える」情報が消えない順序で縮退する必要があります。

段階的縮退の設計

縮退順序の決め方

「読み手にとって最後まで残すべき情報は何か」から逆算しました。

  1. 最初に削るのは見た目(dpi) — 数字は読める
  2. 次に削るのは件数 — Top N だけにする
  3. 次に削るのは画像 — 表だけにする
  4. 最後の手段はテキストサマリー — 1 行で要約

実装

1
from dataclasses import dataclass, replace
2
from enum import Enum
3
4
class RenderQuality(Enum):
5
FULL = "full" # dpi=300, 全件, 画像あり
6
REDUCED_DPI = "low_dpi" # dpi=150
7
TOP_N_ONLY = "top_n" # 件数 100 まで
8
NO_IMAGE = "no_image" # 画像除去
9
TEXT_SUMMARY = "summary" # テキストのみ
10
11
@dataclass(frozen=True)
12
class RenderConfig:
13
dpi: int
14
max_rows: int | None
15
include_images: bool
19 collapsed lines
16
text_only: bool
17
18
PRESETS = {
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
26
DEGRADE_ORDER = [
27
RenderQuality.FULL,
28
RenderQuality.REDUCED_DPI,
29
RenderQuality.TOP_N_ONLY,
30
RenderQuality.NO_IMAGE,
31
RenderQuality.TEXT_SUMMARY,
32
]
33
34
MAX_BYTES = 10 * 1024 * 1024 # 10 MB

リトライループ

1
def 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, quality
14
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 をそのまま送ると、受け取った人が「いつもより情報が少ない」ことに気づきません。メッセージ本文で明示します。

1
def 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 を投げるだけでは無音で失敗します。少なくとも要約テキストだけは届けるようにします。

1
try:
2
pdf, quality = render_with_degradation(data)
3
send_report(pdf, quality, ...)
4
except 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" で頻度を集計できます。

効果

1
Before:
2
- サイズ超過 → 配信失敗 → 翌朝に手動再送
3
- 月 2〜3 件発生
4
5
After:
6
- サイズ超過 → 自動縮退 → 縮退通知付きで配信
7
- 月数件の縮退発生、配信失敗ゼロ
8
- 縮退頻度が高いレポートを別途分析 → 構造的な肥大化に気づく

まとめ

観点設計指針
縮退順序「読み手にとって残すべき情報」から逆算する
リトライ条件必ず条件を変える。同条件リトライは無意味
通知縮退したら明示する。受け手が「情報が減った」ことに気づける
最終フォールバックテキスト要約だけは必ず届ける。無音失敗を許さない
ログ縮退段階を残し、頻度分析できるようにする

「リトライすれば直る」という発想ではなく、「縮退すれば届く」という発想に切り替えると、配信系の障害は劇的に減りました。

外部システムの上限(添付サイズ・トークン数・rate limit)と付き合うときは、段階的縮退の階段を最初に設計しておくと、運用コストが大きく下がります。

Article title:PDF生成の上限超過に段階的に縮退する:dpi縮小→件数削減→画像除去の3段階リトライ
Article author:45395
Release time:2026-05-02

記事へのご質問・ご感想をお聞かせください

フィードバックを送る