🤖

PDFにウォーターマークを入れる実装でハマった話:ライブラリ選定から非同期化、そしてファイルベース処理での高速化

に公開

はじめに

Go 言語で既存の PDF に「転売を禁止する」などの文言を全ページへウォーターマーク(透かし)として埋め込みたい! そんなユースケースって意外とありますよね。
実際に小学校受験の学習管理ツール向け教材(約 40 ページの PDF)へ、ユーザ ID や生年月日とあわせて「複製・譲渡・転売禁止」の署名を入れ込む処理を実装しようとして、思ったより時間がかかった話を共有します。

  • ライブラリとして pdfcpu を採用した理由
  • Stripe のコールバックで走らせたらタイムアウトしてしまい、GCP の Pub/Sub 非同期バッチに切り替えた話
  • メモリ上で完結させる実装をファイルベースに変えたらパフォーマンス(特にメモリ消費)が大幅に改善した話

これらをまとめてみました。ざっくり 40 ページ、5MB〜30MB 程度の PDF に同じ文言を全ページへウォーターマークとして挿入するとどうなるのか、というリアルな知見です。


ライブラリ選定:pdfcpu vs gopdf ほか

まずはライブラリの選定。Go 製の PDF 操作ライブラリは意外と多くなく、代表格として以下が挙がりました:

  • pdfcpu: 既存 PDF のウォーターマークや分割・結合など、多機能な編集が可能
  • gopdf: PDF を新規生成する用途に強いが、既存 PDF の編集は苦手
  • UniDoc (unipdf) など他にも候補はあるが、有償ライセンスなど検討事項が多い

今回の要件は「既存の PDF に日本語文字を含むウォーターマークを一括挿入」すること。
gopdf などは既存 PDF を開いて編集するユースケースには対応が薄いので、最終的に pdfcpu に決めました。pdfcpu は以下のメリットがあります:

  • ウォーターマーク API が充実: api.TextWatermark() で全ページに手軽に文字を重ねられる
  • メンテナンスが続いている: 公式リポジトリがそこそこ活発
  • 依存が Go のみ: Ghostscript や Poppler といった外部バイナリ不要で、Docker/Cloud Run 上でも動きやすい

ただしデメリットとして、日本語フォントのインストールやキャッシュ周りがちょっと分かりにくい部分も。Cloud Run などでフォントキャッシュ先をしっかり指定しないと動作が止まることがあるため注意が必要です。


Stripe のコールバックで実行 → タイムアウト発生

最初は「教材を購入したら、その場で Stripe のコールバックから PDF にウォーターマークを入れたファイルを作り、レスポンスする」仕組みにしようとしていました。
しかし 5〜30MB 前後の PDF を pdfcpu で処理するのに数十秒かかる場合があり、Stripe の webhook タイムアウト(数秒〜 30 秒あたり)を超えてしまう…という問題に直面。

そこで方針転換して、GCP の Pub/Sub に署名リクエストを投げて、非同期で PDF に透かし入れ → 終了後、DB などに保存・通知というワークフローに切り替えました。
これなら Stripe 側のコールバックでは受け取り → Pub/Sub にメッセージ投入 → すぐ返すだけなので、タイムアウトを回避できます。ユーザーにも「署名処理が完了しました!」と後から別画面や通知で案内する設計です。

非同期化はベストプラクティスか?

大容量ファイルの加工をリアルタイムで処理すると、どうしても 30〜90 秒とかかってしまいがちです。ユーザーがずっと待つには長すぎる場合、非同期バッチに分けるのが定番の解決策ですね。
Pub/Sub や Cloud Tasks → Cloud Run Worker (いわゆる “task-worker” パターン)にすることで、ユーザー体験的にもサーバー負荷的にもだいぶ安定しました。


メモリでの処理 → ファイルベース処理への変更

さて、pdfcpu は以下の 2 種類の API を提供しています。

  1. api.AddWatermarks():メモリ上で []byte に対して処理する
  2. api.AddWatermarksFile():入力ファイルパス・出力ファイルパスを渡す形で処理する

最初は全部メモリ上で完結させていたのですが、5MB〜30MB の PDF データを読み込み → ウォーターマーク付きバイナリを生成 → 再アップロード、というフローでメモリ使用量がガッと膨らむことに気づきました。
さらに、38 ページ前後ある PDF を何度もバイナリに読み書きしていると、Go GC(ガーベジコレクション) のタイミング次第でスパイクすることもしばしば。

// メモリベースの署名例(問題があったころ)
pdfBytes, _ := getPdfFromGCS(ctx, client, bucketName, pdfPath)
signedPdfBytes, _ := addPDFSignature(pdfBytes, signiture)
uploadPDFToGCS(ctx, client, bucketName, fileName, signedPdfBytes)

上記パターンだと使いやすいものの、大きめ PDF だと 300MB 超えのメモリを一瞬食らう場合があり、Cloud Run のコンテナが落ちることも…。
そこでファイルベースの処理に変更しました。具体的には GCS から一時ファイル tempInPath にダウンロード → api.AddWatermarksFile(in, out, ...) → できあがった tempOutPath をアップロード、という流れです。

func CreateAndUploadSignaturePDF(ctx context.Context, client *storage.Client, pdfURL string, userID string, birthDateStr string) (string, error) {
	// 1. GCS上のPDFをダウンロード
	tempInPath, _ := createTempFile("input", ".pdf")
	downloadPdfToFile(ctx, client, bucketName, pdfPath, tempInPath)

	// 2. 透かし入りのPDFを一時ファイルへ書き出し
	tempOutPath, _ := createTempFile("output", ".pdf")
	signature := "転売はいけません!"
	if err := addPDFSignature(tempInPath, tempOutPath, signature); err != nil {
		return "", err
	}

	// 3. 完成したファイルを GCS へアップロード
	uploadPdfURL, err := uploadPDFFileToGCS(ctx, client, bucketName, fileName, tempOutPath)
	// ...
	return uploadPdfURL, nil
}

func addPDFSignature(inPath, outPath, signature string) error {
	wm, _ := api.TextWatermark(signature, "font:NotoSansJP-Thin, points:12, ...", true, false, types.POINTS)
	return api.AddWatermarksFile(inPath, outPath, nil, wm, nil)
}

このファイルベース版に切り替えたところ、メモリ使用量が大幅に改善。処理時間自体はそこまで激変しなかったものの、Cloud Run のコンテナ落ちリスクを減らせたのが大きかったです。

パフォーマンスについて

処理時間は、約 30MB の PDF(38 ページ程度)で 30〜45 秒 ほどかかるケースがあります。さらにダウンロードとアップロードの合計に 20〜30 秒ほど加算される場合もあり、トータルで 1 分超になることが。

改善策

  1. 非同期化: 上述のように Pub/Sub でワーカーへまわし、ユーザーを待たせない
  2. Cloud Run のスペック上げ: CPU やメモリを増やすと多少高速化。ローカル PC だと 10 秒で終わる作業がサーバーだと 30 秒、なんてことはよくあります
  3. PDF のサイズ圧縮: 最初から超高解像度の画像が入った PDF だと、処理に時間がかかりやすい。画像やスキャン画質を落とすなどで劇的に速度&容量が下がる場合もある

とはいえ、「1 分前後で完了するなら、非同期ジョブとしては許容範囲」という判断をすることも多いです。ユーザーがリアルタイムで待たなければ、多少時間がかかっても運用に問題ないケースは意外とあります。


まとめ

  • pdfcpu を使えば、既存の PDF に日本語のテキストを簡単にウォーターマーク挿入できる
  • しかしファイルが大きいと処理に 10〜30 秒かかりうるし、Stripe コールバックなど同期の場面で呼ぶとタイムアウトしがち
  • *Pub/Sub(または Cloud Tasks)**で非同期化すればタイムアウト問題を回避可能
  • メモリで全処理するよりも、ファイルベース APIで一時ファイルを介すほうがメモリ使用量が抑えられて安定しやすい
  • 最後は Cloud Run のスペックやリージョン設定、PDF 自体の圧縮などでも多少改善の余地あり

もし同様の「既存 PDF に何か書き足したい」というユースケースに挑戦される方がいれば、ぜひこの辺のハマりポイントを参考にしてみてください。ライブラリ選定では「pdfcpu が最適か UniDoc が良いか、あるいは外部バイナリ使うか」といった検討が必須ですが、個人的にはpdfcpu のウォーターマーク機能が便利なので、多少フォント周りでハマるとしてもいまのところお気に入りです。

補足

「pdfcpu はフォントのキャッシュが面倒」と言われることもありますが、Docker / Cloud Run などの環境変数や sync.Once を活用して初期化を 1 回にし、キャッシュ先ディレクトリをしっかり指定すれば運用できています。

以上、教材 PDF のウォーターマーク挿入で気をつけたポイントと、同期処理 → 非同期化への切り替え、ファイルベース処理でのメモリ削減などを紹介しました。:sparkles:

大容量の PDF でも問題なくさばけるよう工夫すれば、署名・透かし機能を手軽に実装できるはずなので、ぜひお試しください!

GitHubで編集を提案

Discussion