2026/05/21

RubyでPDF生成API|Rails・Sidekiq・本番運用パターン完全ガイド

RubyRailsPDF APISidekiq本番運用

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が不調な時のカスケード障害を防止

関連リンク

Powered by FUNBREW PDF