2026/05/20

GoでPDF生成API|goroutine並列処理・リトライ・本番運用パターン

GoPDF API並列処理本番運用

Goの並行性モデルはPDFの大量生成に最適です。goroutineは軽量で、channelを使えばサードパーティ不要の有界ワーカープールを簡単に構築できます。このガイドでは、goroutineベースの並列処理・リトライロジック・コンテキスト伝播・Gin/Echoとの統合など、本番環境で必要なGoパターンに集中して解説します。

RubyとGoの基本的なコード例についてはRuby・Goクイックスタートガイドを参照してください。Node.js・Python・PHPの例は言語別クイックスタートにまとめています。

前提条件

# APIキーを環境変数に設定
export FUNBREW_PDF_API_KEY="sk-your-api-key"

無料アカウントを作成してAPIキーを取得してください。全オプションの詳細はAPIドキュメントを参照してください。

基本的なPDF生成

まず、APIコールをきれいにラップする本番対応の構造体を定義します。

package pdfapi

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)

// Client はHTTPクライアントをラップする。リクエスト間で共有して再利用する。
type Client struct {
	httpClient *http.Client
	apiKey     string
	baseURL    string
}

// NewClient は本番向けのデフォルト設定でクライアントを生成する。
func NewClient() *Client {
	return &Client{
		httpClient: &http.Client{
			Timeout: 60 * time.Second,
			// コネクションプールを共有してTLSハンドシェイクを削減する。
			Transport: &http.Transport{
				MaxIdleConns:        100,
				MaxIdleConnsPerHost: 20,
				IdleConnTimeout:     90 * time.Second,
			},
		},
		apiKey:  os.Getenv("FUNBREW_PDF_API_KEY"),
		baseURL: "https://pdf.funbrew.cloud/api/v1",
	}
}

type GenerateRequest struct {
	HTML    string         `json:"html"`
	Options map[string]any `json:"options,omitempty"`
}

// Generate はHTMLを送信してPDFバイト列を返す。
func (c *Client) Generate(ctx context.Context, req GenerateRequest) ([]byte, error) {
	body, err := json.Marshal(req)
	if err != nil {
		return nil, fmt.Errorf("marshal: %w", err)
	}

	httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
		c.baseURL+"/pdf/generate", bytes.NewReader(body))
	if err != nil {
		return nil, fmt.Errorf("build request: %w", err)
	}
	httpReq.Header.Set("X-API-Key", c.apiKey)
	httpReq.Header.Set("Content-Type", "application/json")

	resp, err := c.httpClient.Do(httpReq)
	if err != nil {
		return nil, fmt.Errorf("http: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		errBody, _ := io.ReadAll(resp.Body)
		return nil, &APIError{Status: resp.StatusCode, Body: string(errBody)}
	}

	return io.ReadAll(resp.Body)
}

// APIError はHTTPステータスとレスポンスボディを保持する。
type APIError struct {
	Status int
	Body   string
}

func (e *APIError) Error() string {
	return fmt.Sprintf("PDF API %d: %s", e.Status, e.Body)
}

// IsRetryable はリトライ対象のエラーかを返す。
func (e *APIError) IsRetryable() bool {
	return e.Status == 429 || e.Status >= 500
}
func main() {
	client := pdfapi.NewClient()

	ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
	defer cancel()

	pdf, err := client.Generate(ctx, pdfapi.GenerateRequest{
		HTML: `<!DOCTYPE html>
<html lang="ja">
<head><style>
  body { font-family: 'Noto Sans JP', sans-serif; padding: 40px; color: #1a202c; }
  h1 { font-size: 24pt; border-bottom: 2px solid #3b82f6; padding-bottom: 8px; }
  .meta { color: #718096; font-size: 10pt; margin-top: 4px; }
</style></head>
<body>
  <h1>月次レポート</h1>
  <p class="meta">FUNBREW PDF APIで生成</p>
  <p>レポート本文がここに入ります。</p>
</body>
</html>`,
		Options: map[string]any{
			"format": "A4",
			"engine": "quality",
			"margin": map[string]string{
				"top": "20mm", "bottom": "20mm",
				"left": "15mm", "right": "15mm",
			},
		},
	})
	if err != nil {
		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
		os.Exit(1)
	}

	os.WriteFile("report.pdf", pdf, 0644)
	fmt.Printf("generated report.pdf (%d bytes)\n", len(pdf))
}

指数バックオフによるリトライ

レートリミット(HTTP 429)や一時的なサーバーエラー(5xx)は本番環境で必ず起こります。指数バックオフのリトライループで Generate をラップします。

package pdfapi

import (
	"context"
	"errors"
	"math"
	"time"
)

// RetryConfig はリトライ動作を制御する。
type RetryConfig struct {
	MaxAttempts int
	BaseDelay   time.Duration
	MaxDelay    time.Duration
}

// DefaultRetryConfig は本番環境向けの標準設定。
var DefaultRetryConfig = RetryConfig{
	MaxAttempts: 4,
	BaseDelay:   500 * time.Millisecond,
	MaxDelay:    30 * time.Second,
}

// GenerateWithRetry は指数バックオフでGenerateをラップする。
func (c *Client) GenerateWithRetry(ctx context.Context, req GenerateRequest, cfg RetryConfig) ([]byte, error) {
	var lastErr error

	for attempt := 0; attempt < cfg.MaxAttempts; attempt++ {
		pdf, err := c.Generate(ctx, req)
		if err == nil {
			return pdf, nil
		}

		var apiErr *APIError
		if !errors.As(err, &apiErr) || !apiErr.IsRetryable() {
			// 400 Bad Requestなどリトライ不可のエラーは即時失敗。
			return nil, err
		}

		lastErr = err

		// delay = baseDelay * 2^attempt (MaxDelayで上限)
		delay := time.Duration(float64(cfg.BaseDelay) * math.Pow(2, float64(attempt)))
		if delay > cfg.MaxDelay {
			delay = cfg.MaxDelay
		}

		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		case <-time.After(delay):
		}
	}

	return nil, fmt.Errorf("%d回試行しましたが失敗しました: %w", cfg.MaxAttempts, lastErr)
}

使用例:

pdf, err := client.GenerateWithRetry(ctx, req, pdfapi.DefaultRetryConfig)

goroutineワーカープールによるバッチ生成

クイックスタートガイドでは sync.WaitGroup とセマフォチャネルを使ったシンプルな例を示しています。本番バッチジョブでは、専用のワーカープールがより保守しやすく可観測性も高くなります。

package main

import (
	"context"
	"fmt"
	"os"
	"sync"
	"time"
)

// Job は1件のPDF生成タスクを表す。
type Job struct {
	ID   string
	HTML string
}

// Result は1件の生成結果を保持する。
type Result struct {
	ID    string
	PDF   []byte
	Bytes int
	Err   error
}

// BatchGenerate は最大 concurrency 個のgoroutineを並列実行する。
// 入力スライスと同じ順序で結果を返す。
func BatchGenerate(ctx context.Context, client *pdfapi.Client, jobs []Job, concurrency int) []Result {
	results := make([]Result, len(jobs))

	type indexedJob struct {
		index int
		job   Job
	}

	jobCh := make(chan indexedJob, len(jobs))
	var wg sync.WaitGroup

	// ワーカーgoroutineを起動。
	for w := 0; w < concurrency; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for ij := range jobCh {
				pdf, err := client.GenerateWithRetry(ctx, pdfapi.GenerateRequest{
					HTML: ij.job.HTML,
					Options: map[string]any{
						"format": "A4",
						"engine": "quality",
					},
				}, pdfapi.DefaultRetryConfig)

				// 元のインデックスに書き込むのでmutexが不要。
				results[ij.index] = Result{
					ID:    ij.job.ID,
					PDF:   pdf,
					Bytes: len(pdf),
					Err:   err,
				}
			}
		}()
	}

	// 全ジョブをキューに投入。
	for i, j := range jobs {
		jobCh <- indexedJob{index: i, job: j}
	}
	close(jobCh)

	wg.Wait()
	return results
}

func main() {
	client := pdfapi.NewClient()

	jobs := []Job{
		{ID: "INV-001", HTML: "<h1>請求書 001</h1><p>株式会社サンプル — ¥100,000</p>"},
		{ID: "INV-002", HTML: "<h1>請求書 002</h1><p>グローベックス — ¥250,000</p>"},
		{ID: "INV-003", HTML: "<h1>請求書 003</h1><p>イニテック — ¥180,000</p>"},
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()

	// 5並列 — APIプランのレートリミットに合わせて調整する。
	results := BatchGenerate(ctx, client, jobs, 5)

	for _, r := range results {
		if r.Err != nil {
			fmt.Fprintf(os.Stderr, "%s: 失敗 — %v\n", r.ID, r.Err)
			continue
		}
		path := r.ID + ".pdf"
		os.WriteFile(path, r.PDF, 0644)
		fmt.Printf("%s: OK — %d bytes → %s\n", r.ID, r.Bytes, path)
	}
}

クイックスタートガイドのシンプルなgoroutine例との主な違い:

  • 結果を事前割り当てスライスの元のインデックスに書き込むため、mutexなしで順序を保持できる
  • ワーカーはバッファ付きchannelをブロックして読む。goroutine数はちょうど concurrency 個に維持される
  • GenerateWithRetry をワーカー内部で呼ぶため、リトライはプール内に閉じている

Gin統合

package main

import (
	"context"
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
)

// PDFHandler はGinハンドラー。起動時にクライアントを注入してリクエスト間で再利用する。
type PDFHandler struct {
	client *pdfapi.Client
}

func (h *PDFHandler) Download(c *gin.Context) {
	invoiceID := c.Param("id")

	// 本番ではここでDBから請求書データを取得する。
	html := buildInvoiceHTML(invoiceID)

	ctx, cancel := context.WithTimeout(c.Request.Context(), 45*time.Second)
	defer cancel()

	pdf, err := h.client.GenerateWithRetry(ctx, pdfapi.GenerateRequest{
		HTML: html,
		Options: map[string]any{"format": "A4", "engine": "quality"},
	}, pdfapi.DefaultRetryConfig)

	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	c.Header("Content-Disposition", `attachment; filename="invoice-`+invoiceID+`.pdf"`)
	c.Data(http.StatusOK, "application/pdf", pdf)
}

func buildInvoiceHTML(id string) string {
	return `<!DOCTYPE html><html lang="ja"><head><style>
		body { font-family: 'Noto Sans JP', sans-serif; padding: 40px; }
	</style></head><body>
		<h1>請求書 ` + id + `</h1>
		<p>FUNBREW PDF APIで生成</p>
	</body></html>`
}

func main() {
	client := pdfapi.NewClient()
	handler := &PDFHandler{client: client}

	r := gin.Default()
	r.GET("/invoices/:id/pdf", handler.Download)
	r.Run(":8080")
}

Echo統合

package main

import (
	"context"
	"net/http"
	"time"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

type PDFService struct {
	client *pdfapi.Client
}

func (s *PDFService) DownloadReport(c echo.Context) error {
	reportID := c.Param("id")

	ctx, cancel := context.WithTimeout(c.Request().Context(), 45*time.Second)
	defer cancel()

	pdf, err := s.client.GenerateWithRetry(ctx, pdfapi.GenerateRequest{
		HTML: `<h1>レポート ` + reportID + `</h1><p>FUNBREW PDF</p>`,
		Options: map[string]any{"format": "A4", "engine": "quality"},
	}, pdfapi.DefaultRetryConfig)
	if err != nil {
		return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
	}

	c.Response().Header().Set("Content-Disposition",
		`attachment; filename="report-`+reportID+`.pdf"`)
	return c.Blob(http.StatusOK, "application/pdf", pdf)
}

func main() {
	svc := &PDFService{client: pdfapi.NewClient()}

	e := echo.New()
	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	e.GET("/reports/:id/pdf", svc.DownloadReport)
	e.Logger.Fatal(e.Start(":8080"))
}

本番運用パターン

環境変数による設定管理

package config

import (
	"fmt"
	"os"
	"strconv"
	"time"
)

type PDFConfig struct {
	APIKey      string
	BaseURL     string
	Timeout     time.Duration
	Concurrency int
	MaxRetries  int
}

func LoadPDFConfig() (*PDFConfig, error) {
	apiKey := os.Getenv("FUNBREW_PDF_API_KEY")
	if apiKey == "" {
		return nil, fmt.Errorf("FUNBREW_PDF_API_KEY が設定されていません")
	}

	concurrency, _ := strconv.Atoi(os.Getenv("PDF_CONCURRENCY"))
	if concurrency <= 0 {
		concurrency = 5
	}

	timeout, _ := strconv.Atoi(os.Getenv("PDF_TIMEOUT_SECONDS"))
	if timeout <= 0 {
		timeout = 60
	}

	maxRetries, _ := strconv.Atoi(os.Getenv("PDF_MAX_RETRIES"))
	if maxRetries <= 0 {
		maxRetries = 4
	}

	return &PDFConfig{
		APIKey:      apiKey,
		BaseURL:     "https://pdf.funbrew.cloud/api/v1",
		Timeout:     time.Duration(timeout) * time.Second,
		Concurrency: concurrency,
		MaxRetries:  maxRetries,
	}, nil
}

可観測性: ログとメトリクス

構造化ログとレイテンシ計測をクライアントに追加します。

package pdfapi

import (
	"context"
	"log/slog"
	"time"
)

// InstrumentedClient はロギングとメトリクスを追加するラッパー。
type InstrumentedClient struct {
	*Client
	logger *slog.Logger
}

func NewInstrumentedClient(logger *slog.Logger) *InstrumentedClient {
	return &InstrumentedClient{
		Client: NewClient(),
		logger: logger,
	}
}

func (ic *InstrumentedClient) Generate(ctx context.Context, req GenerateRequest) ([]byte, error) {
	start := time.Now()

	pdf, err := ic.Client.Generate(ctx, req)

	duration := time.Since(start)
	if err != nil {
		ic.logger.Error("PDF生成失敗",
			"duration_ms", duration.Milliseconds(),
			"error", err,
		)
		// ここでPrometheusカウンターなどをインクリメントする。
		return nil, err
	}

	ic.logger.Info("PDF生成完了",
		"duration_ms", duration.Milliseconds(),
		"size_bytes", len(pdf),
	)
	return pdf, nil
}

バッチワーカーのグレースフルシャットダウン

長期稼働バッチワーカーでは SIGTERM を受け取ったとき、処理中のジョブを完了させてから終了させます。

package main

import (
	"context"
	"fmt"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	client := pdfapi.NewClient()

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
	defer stop()

	jobs := loadJobsFromQueue() // キュー/DBからジョブを取得。

	// シグナル受信後、最大2分間は処理中のジョブを待つ。
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer cancel()

	results := BatchGenerate(shutdownCtx, client, jobs, 5)

	for _, r := range results {
		if r.Err != nil {
			fmt.Fprintf(os.Stderr, "%s: 失敗 — %v\n", r.ID, r.Err)
		} else {
			fmt.Printf("%s: OK — %d bytes\n", r.ID, r.Bytes)
		}
	}
}

コネクションプールのチューニング

デフォルトの http.Transport は中程度のスループットまで問題ありません。高並列バッチでは以下のようにチューニングします。

transport := &http.Transport{
	// MaxIdleConnsPerHost をワーカープールのサイズに合わせる。
	MaxIdleConnsPerHost: 20,
	MaxIdleConns:        100,
	IdleConnTimeout:     90 * time.Second,
	// ネットワーク障害時のフェイルファストを速くする。
	DialContext: (&net.Dialer{
		Timeout:   5 * time.Second,
		KeepAlive: 30 * time.Second,
	}).DialContext,
	TLSHandshakeTimeout: 10 * time.Second,
}

まとめ

パターン 使いどころ
単純な Generate 呼び出し HTTPハンドラーでオンデマンドPDF生成
GenerateWithRetry 本番のあらゆるパス(レートリミットは必ず発生する)
ワーカープール(BatchGenerate 請求書一括生成・夜間レポート・データエクスポート
InstrumentedClient レイテンシメトリクスが必要なサービス
signal.NotifyContext Kubernetes/systemdで動くバッチワーカー

Goの context パッケージはPDF APIと自然に統合されます。Gin/Echoのリクエストコンテキストを Generate に渡すだけでキャンセレーションが自動的に伝播します。共有の http.Transport によってコネクションが再利用されるため、連続したリクエストでTLSハンドシェイクのオーバーヘッドを払う必要がありません。

関連リンク

Powered by FUNBREW PDF