RubyでPDF生成API|Rails・Sidekiq・本番運用パターン完全ガイド
RubyのエコシステムはPDF API統合と相性が抜群です。FaradayによるHTTPリクエスト管理、Sidekiqによる非同期ジョブ処理、Railsのサービスオブジェクトパターン――これらを組み合わせることで、堅牢なPDF生成パイプラインを構築できます。
このガイドでは、再利用可能なサービスオブジェクトの設計、リトライミドルウェアの設定、Sidekiqを使った非同期PDF生成、そしてWebhookによる配信まで、本番グレードのパターンに絞って解説します。
RubyとGoの基本的なコード例についてはRuby・Goクイックスタートガイドを参照してください。Node.js・Python・PHPの例は言語別クイックスタートにまとめています。GoのgoroutineベースのアプローチはGoにはGo PDF API 本番運用ガイドがあります。
前提条件
# APIキーを環境変数に設定
export FUNBREW_PDF_API_KEY="sk-your-api-key"
# Gemfile
gem 'faraday'
gem 'faraday-retry'
gem 'sidekiq' # 非同期生成用
無料アカウントを作成してAPIキーを取得してください。全オプションの詳細はAPIドキュメントを参照してください。
サービスオブジェクトパターン
PDF API呼び出しを一つのサービスクラスに集約します。実装の差し替え、ログ追加、テストが容易になります。
# app/services/pdf_generator.rb
require 'faraday'
require 'faraday/retry'
require 'json'
class PdfGenerator
BASE_URL = 'https://api.pdf.funbrew.cloud'.freeze
# レート制限や一時的なサーバーエラー時にリトライ
RETRY_OPTIONS = {
max: 3,
interval: 1.0,
backoff_factor: 2,
retry_statuses: [429, 500, 502, 503],
retry_if: ->(_env, _err) { true }
}.freeze
def initialize(api_key: ENV.fetch('FUNBREW_PDF_API_KEY'))
@conn = Faraday.new(url: BASE_URL) do |f|
f.request :retry, RETRY_OPTIONS
f.options.timeout = 60
f.options.open_timeout = 10
f.headers['Authorization'] = "Bearer #{api_key}"
f.headers['Content-Type'] = 'application/json'
end
end
# HTMLから生成。成功時はPDFバイナリを返し、失敗時は例外を発生させる。
def from_html(html, format: 'A4', engine: 'quality', **opts)
payload = { html: html, engine: engine, format: format }.merge(opts)
response = @conn.post('/v1/pdf/from-html', payload.to_json)
raise PdfGenerationError.new(response.status, response.body) unless response.success?
response.body
end
# 登録済みテンプレートから生成
def from_template(template_id, data, format: 'A4')
payload = { template_id: template_id, data: data, format: format }
response = @conn.post('/v1/pdf/from-template', payload.to_json)
raise PdfGenerationError.new(response.status, response.body) unless response.success?
response.body
end
# URLからPDF変換
def from_url(url, format: 'A4', wait_for: 'networkidle')
payload = { url: url, format: format, wait_for: wait_for }
response = @conn.post('/v1/pdf/from-url', payload.to_json)
raise PdfGenerationError.new(response.status, response.body) unless response.success?
response.body
end
class PdfGenerationError < StandardError
attr_reader :status, :body
def initialize(status, body)
@status = status
@body = body
super("PDF API error #{status}: #{body}")
end
def retryable?
[429, 500, 502, 503].include?(@status)
end
end
end
Railsアプリ内での使い方:
generator = PdfGenerator.new
# HTMLから直接生成
pdf = generator.from_html('<h1>請求書</h1><p>合計: ¥100,000</p>')
File.binwrite('invoice.pdf', pdf)
# テンプレートから生成
pdf = generator.from_template('invoice-v1', {
company_name: '株式会社テスト',
invoice_number: 'INV-2026-0042',
items: [
{ name: 'Webコンサルティング', quantity: 1, price: 150_000 },
{ name: 'デザイン制作', quantity: 3, price: 50_000 },
],
total: 300_000
})
Railsコントローラーへの統合
Railsコントローラーから直接PDFをインラインプレビューまたはダウンロードとして返します。
# app/controllers/invoices_controller.rb
class InvoicesController < ApplicationController
before_action :set_invoice, only: [:pdf]
# GET /invoices/:id.pdf
def pdf
generator = PdfGenerator.new
pdf_bytes = generator.from_template('invoice-v1', invoice_payload)
respond_to do |format|
format.pdf do
send_data pdf_bytes,
filename: "invoice-#{@invoice.number}.pdf",
type: 'application/pdf',
disposition: params[:download] ? 'attachment' : 'inline'
end
end
rescue PdfGenerator::PdfGenerationError => e
Rails.logger.error("PDF生成エラー: #{e.message}")
render json: { error: 'PDF生成に失敗しました' }, status: :service_unavailable
end
private
def set_invoice
@invoice = Invoice.find(params[:id])
authorize @invoice # Pundit / CanCanCan による認可
end
def invoice_payload
{
company_name: @invoice.company_name,
invoice_number: @invoice.number,
issued_at: @invoice.issued_at.strftime('%Y年%m月%d日'),
items: @invoice.line_items.map do |item|
{ name: item.name, quantity: item.quantity, price: item.unit_price_cents / 100.0 }
end,
total: @invoice.total_cents / 100.0
}
end
end
config/initializers/mime_types.rb にMIMEタイプを登録します:
Mime::Type.register 'application/pdf', :pdf
Sidekiqによる非同期PDF生成
大きなPDFや優先度の低いPDFは、リクエストスレッドの外で処理します。Sidekiqワーカーを使えば、APIのタイムアウトを気にせず非同期に生成できます。
# app/jobs/generate_invoice_pdf_job.rb
class GenerateInvoicePdfJob
include Sidekiq::Job
sidekiq_options queue: 'pdf', retry: 3
def perform(invoice_id, notify_user: true)
invoice = Invoice.find(invoice_id)
generator = PdfGenerator.new
pdf_bytes = generator.from_template('invoice-v1', invoice_payload(invoice))
# Active Storage(またはS3 / GCS)に保存
invoice.pdf_attachment.attach(
io: StringIO.new(pdf_bytes),
filename: "invoice-#{invoice.number}.pdf",
content_type: 'application/pdf'
)
invoice.update!(pdf_generated_at: Time.current)
# 顧客にPDFをメール送信
InvoiceMailer.pdf_ready(invoice).deliver_later if notify_user
rescue PdfGenerator::PdfGenerationError => e
Rails.logger.error("[GenerateInvoicePdfJob] Invoice #{invoice_id} 失敗: #{e.message}")
raise # Sidekiqのリトライ機構に委ねる
end
private
def invoice_payload(invoice)
{
company_name: invoice.company_name,
invoice_number: invoice.number,
issued_at: invoice.issued_at.strftime('%Y年%m月%d日'),
items: invoice.line_items.map { |i|
{ name: i.name, quantity: i.quantity, price: i.unit_price_cents / 100.0 }
},
total: invoice.total_cents / 100.0
}
end
end
コントローラーやモデルのコールバックからエンキュー:
# 請求書作成後にPDF生成をエンキュー
class Invoice < ApplicationRecord
after_commit :enqueue_pdf_generation, on: :create
private
def enqueue_pdf_generation
GenerateInvoicePdfJob.perform_async(id)
end
end
Sidekiqの設定
# config/sidekiq.yml
:queues:
- [critical, 3]
- [default, 2]
- [pdf, 1] # PDF生成 — クリティカルパスより低優先度
:concurrency: 10
PDF生成には数秒かかる場合があるため、タイムアウトを長めに設定します:
# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
config.server_middleware do |chain|
# 必要に応じてインストルメンテーションミドルウェアを追加
end
end
バッチPDF生成
月末の請求書一括生成や修了証の一括発行では、Rubyのスレッドを使って並列処理します。
# lib/pdf_batch_processor.rb
require 'parallel'
class PdfBatchProcessor
DEFAULT_CONCURRENCY = 5
def initialize(concurrency: DEFAULT_CONCURRENCY)
@concurrency = concurrency
# スレッドごとに個別のFaraday接続を使用(Faradayはスレッドセーフでない)
@thread_local_generator = Hash.new { |h, k| h[k] = PdfGenerator.new }
end
# 全アイテムを並列でPDF生成する。
# 戻り値: { id:, status: :ok/:error, path:, error: } のハッシュ配列
def run(items, &html_builder)
Parallel.map(items, in_threads: @concurrency) do |item|
generator = @thread_local_generator[Thread.current.object_id]
begin
html = html_builder.call(item)
pdf_bytes = generator.from_html(html, engine: 'fast')
path = write_pdf(item[:id], pdf_bytes)
{ id: item[:id], status: :ok, path: path, bytes: pdf_bytes.bytesize }
rescue PdfGenerator::PdfGenerationError => e
{ id: item[:id], status: :error, error: e.message }
end
end
end
private
def write_pdf(id, bytes)
path = Rails.root.join('tmp', 'pdfs', "#{id}.pdf")
FileUtils.mkdir_p(path.dirname)
File.binwrite(path, bytes)
path.to_s
end
end
月末の請求書一括生成での使用例:
invoices = Invoice.where(period: Date.today.beginning_of_month..)
processor = PdfBatchProcessor.new(concurrency: 5)
results = processor.run(invoices.map { |i| { id: i.id, invoice: i } }) do |item|
ApplicationController.render(
template: 'invoices/pdf',
assigns: { invoice: item[:invoice] },
layout: 'pdf'
)
end
success_count = results.count { |r| r[:status] == :ok }
Rails.logger.info("バッチ完了: #{results.size}件中#{success_count}件生成済み")
バッチ処理の詳細なパターンとキューベースの処理についてはPDF APIバッチ処理ガイドを参照してください。
エラーハンドリング
サービスオブジェクトは失敗時に PdfGenerator::PdfGenerationError を発生させます。Faradayのリトライミドルウェアの上位でより高度なリトライが必要な場合:
# lib/pdf_generator_with_circuit_breaker.rb
class PdfGeneratorWithCircuitBreaker
MAX_FAILURES = 5
OPEN_DURATION = 60 # 秒
def initialize
@generator = PdfGenerator.new
@failures = 0
@circuit_open = false
@opened_at = nil
@mutex = Mutex.new
end
def from_html(html, **opts)
@mutex.synchronize do
if @circuit_open
if Time.current - @opened_at > OPEN_DURATION
# ハーフオープン: 1回試みる
@circuit_open = false
@failures = 0
else
raise PdfGenerator::PdfGenerationError.new(503, 'サーキットブレーカーが開いています')
end
end
end
result = @generator.from_html(html, **opts)
@mutex.synchronize { @failures = 0 }
result
rescue PdfGenerator::PdfGenerationError => e
@mutex.synchronize do
@failures += 1
if @failures >= MAX_FAILURES
@circuit_open = true
@opened_at = Time.current
Rails.logger.warn("PDFサーキットブレーカーが#{@failures}回の失敗後に開きました")
end
end
raise
end
end
リトライ戦略を含む完全なエラーハンドリングパターンはPDF APIエラーハンドリング完全ガイドを参照してください。
APIキーのセキュアな管理
APIキーを絶対にコードに書き込まないでください。RailsではCredentialsまたは環境変数を使用します:
# .env(Gitにコミットしない)
FUNBREW_PDF_API_KEY=sk-live-abc123
# config/credentials.yml.enc(暗号化済み、コミット可)
# アクセス: Rails.application.credentials.funbrew_pdf_api_key
funbrew_pdf_api_key: sk-live-abc123
# イニシャライザー — いずれか一方を選択
api_key = ENV['FUNBREW_PDF_API_KEY'] ||
Rails.application.credentials.funbrew_pdf_api_key
generator = PdfGenerator.new(api_key: api_key)
IPアドレス制限やSSRF対策など本番環境のセキュリティ対策についてはPDF APIセキュリティガイドを参照してください。
テスト
テスト時にAPIを実際に呼び出さないよう、WebMockやVCRを使用します。
# spec/services/pdf_generator_spec.rb
require 'webmock/rspec'
RSpec.describe PdfGenerator do
let(:generator) { described_class.new(api_key: 'test-key') }
let(:fake_pdf) { '%PDF-1.4 fake'.b }
before do
stub_request(:post, 'https://api.pdf.funbrew.cloud/v1/pdf/from-html')
.with(
headers: { 'Authorization' => 'Bearer test-key' },
body: hash_including('html' => '<h1>テスト</h1>')
)
.to_return(status: 200, body: fake_pdf, headers: { 'Content-Type' => 'application/pdf' })
end
it '成功時はPDFバイナリを返す' do
result = generator.from_html('<h1>テスト</h1>')
expect(result).to eq(fake_pdf)
end
context 'APIが429を返す場合' do
before do
stub_request(:post, 'https://api.pdf.funbrew.cloud/v1/pdf/from-html')
.to_return(status: 429, body: '{"error":"rate_limited"}')
end
it 'PdfGenerationErrorを発生させる' do
expect { generator.from_html('<h1>テスト</h1>') }
.to raise_error(PdfGenerator::PdfGenerationError) do |err|
expect(err.status).to eq(429)
expect(err.retryable?).to be true
end
end
end
end
本番環境チェックリスト
本番デプロイ前に以下を確認してください:
| 項目 | 詳細 |
|---|---|
| APIキーを環境変数で管理 | ソースコードやGit管理のCredentialsに書き込まない |
| Faradayリトライ設定済み | retry_statuses: [429, 500, 502, 503] とバックオフ |
| Sidekiqキューを分離 | pdf キューを独立させてPDFバックログが重要ジョブを止めないように |
| タイムアウト設定 | 複雑なHTMLには60秒以上; Sidekiqジョブタイムアウトも合わせて設定 |
| エラーロギング | PdfGenerationError をSentry/Bugsnagで請求書IDとともに捕捉 |
| Active StorageまたはS3 | 生成PDFをRailsのtmpではなく外部ストレージに保存 |
| IPアドレス制限 | FUNBREW PDFダッシュボードで本番サーバーのIPを設定 |
監視・スケーリング・コスト管理を含む完全な本番チェックリストはPDF API本番運用ガイドを参照してください。
まとめ
Rubyの強み——Faradayのミドルウェアチェーン、Sidekiqの信頼性の高い非同期処理、Railsの規約——を組み合わせることで、堅牢なPDF生成パイプラインを構築できます。
- サービスオブジェクト: 統合ごとに1クラス、注入可能でテストしやすい
- Faradayリトライ: 429/5xxに対する自動バックオフ(手動リトライ不要)
- Sidekiqワーカー: リクエストスレッドから生成処理を切り離し、確実なリトライ
- Parallelによるバッチ: スレッドセーフな並列処理で大量ジョブに対応
- サーキットブレーカー: APIが不調な時のカスケード障害を防止
関連リンク
- Ruby・Go クイックスタート — net/httpとFaradayを使ったRuby・Goの基本コード例
- Go PDF API 本番運用ガイド — goroutineワーカープールとGoの本番パターン
- PDF APIバッチ処理ガイド — 大規模生成のためのキューベースパターン
- PDF APIエラーハンドリング完全ガイド — エラータイプ、リトライ戦略、サーキットブレーカー
- PDF APIセキュリティガイド — APIキー管理・IPアドレス制限・SSRF対策
- PDF API本番運用ガイド — 監視・スケーリング・コスト管理
- APIドキュメント — FUNBREW PDFの全オプションリファレンス
- Playground — コードを書く前にブラウザでHTMLからPDFへの変換をテスト