NaN/NaN/NaN

サーバーレスアーキテクチャでPDFを生成したいとき、Lambda関数にChromiumをバンドルするのは現実的ではありません。デプロイパッケージの容量制限、コールドスタートの遅延、メモリ消費の問題が発生します。

PDF生成をAPIに委任すれば、Lambda関数は軽量なHTTPリクエストを送るだけで済みます。この記事では、FUNBREW PDFのAPIをAWS Lambdaから呼び出してサーバーレスにPDFを生成する方法を、Node.jsとPythonのコード例付きで解説します。

API自体の基本的な使い方はクイックスタートガイドを参照してください。

サーバーレスでPDF APIを使うメリット

Lambda + 自前PDF生成の問題点

課題 詳細
デプロイサイズ Chromiumバンドルで約130MB。Lambdaの250MB制限に迫る
コールドスタート 重いパッケージほど初回起動が遅い(5〜10秒以上)
メモリ消費 Chromiumの起動だけで500MB以上必要
並行実行 同時実行数の制限にすぐ到達する

PDF API委任のメリット

メリット 詳細
軽量デプロイ HTTPクライアントだけで数KB
高速起動 コールドスタートが100ms以下に
低メモリ 128MBでも十分動作
無制限スケール API側が並列処理を担当
コスト削減 メモリ割り当てを最小限にできる

準備

APIキーの管理

Lambda関数でAPIキーを安全に管理するには、AWS Secrets Managerまたは環境変数の暗号化を使います。

# AWS CLIでSecrets Managerにキーを保存
aws secretsmanager create-secret \
  --name funbrew-pdf-api-key \
  --secret-string "sk-your-api-key"

APIキーのセキュリティについてはセキュリティガイドで詳しく解説しています。

IAMロールの設定

Lambda関数には以下の権限が必要です。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue"
      ],
      "Resource": "arn:aws:secretsmanager:ap-northeast-1:*:secret:funbrew-pdf-api-key-*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject"
      ],
      "Resource": "arn:aws:s3:::your-pdf-bucket/*"
    }
  ]
}

Node.jsでのLambda関数実装

基本的なPDF生成

const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');

const secretsManager = new SecretsManagerClient({ region: 'ap-northeast-1' });
const s3 = new S3Client({ region: 'ap-northeast-1' });

let cachedApiKey = null;

async function getApiKey() {
  if (cachedApiKey) return cachedApiKey;

  const command = new GetSecretValueCommand({ SecretId: 'funbrew-pdf-api-key' });
  const response = await secretsManager.send(command);
  cachedApiKey = response.SecretString;
  return cachedApiKey;
}

exports.handler = async (event) => {
  const apiKey = await getApiKey();

  // PDF生成APIを呼び出し
  const response = await fetch('https://api.pdf.funbrew.cloud/v1/pdf/from-html', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      html: event.html || '<h1>Hello from Lambda</h1>',
      engine: 'quality',
      format: 'A4',
    }),
  });

  if (!response.ok) {
    const errorBody = await response.text();
    throw new Error(`PDF API error: ${response.status} - ${errorBody}`);
  }

  const pdfBuffer = Buffer.from(await response.arrayBuffer());

  // S3にアップロード
  const key = `pdfs/${Date.now()}.pdf`;
  await s3.send(new PutObjectCommand({
    Bucket: process.env.PDF_BUCKET,
    Key: key,
    Body: pdfBuffer,
    ContentType: 'application/pdf',
  }));

  return {
    statusCode: 200,
    body: JSON.stringify({
      message: 'PDF generated and uploaded',
      s3Key: key,
      size: pdfBuffer.length,
    }),
  };
};

テンプレートを使ったPDF生成

HTMLテンプレートとデータを分離すると、Lambda関数の汎用性が上がります。テンプレートの設計についてはテンプレートエンジンガイドを参照してください。

exports.handler = async (event) => {
  const { templateId, data } = event;
  const apiKey = await getApiKey();

  // テンプレートに動的データを埋め込む
  const html = buildHtml(templateId, data);

  const response = await fetch('https://api.pdf.funbrew.cloud/v1/pdf/from-html', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ html, engine: 'quality', format: 'A4' }),
  });

  if (!response.ok) {
    throw new Error(`PDF API error: ${response.status}`);
  }

  // 以降、S3アップロードなどの処理
  // ...
};

function buildHtml(templateId, data) {
  const templates = {
    invoice: (d) => `
      <html>
      <head><style>
        body { font-family: 'Noto Sans JP', sans-serif; padding: 40px; }
        .header { display: flex; justify-content: space-between; }
        table { width: 100%; border-collapse: collapse; margin-top: 20px; }
        th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
      </style></head>
      <body>
        <div class="header">
          <h1>請求書</h1>
          <p>No. ${d.invoiceNumber}</p>
        </div>
        <p>発行日: ${d.date}</p>
        <p>宛先: ${d.customerName}</p>
        <table>
          <tr><th>品目</th><th>数量</th><th>単価</th><th>金額</th></tr>
          ${d.items.map(i => `<tr><td>${i.name}</td><td>${i.qty}</td><td>${i.price}</td><td>${i.qty * i.price}</td></tr>`).join('')}
        </table>
      </body>
      </html>
    `,
  };

  return templates[templateId](data);
}

請求書PDFの自動化については請求書PDF自動化ガイドも参考になります。

PythonでのLambda関数実装

import json
import os
import time
import boto3
import urllib.request

secrets_client = boto3.client('secretsmanager', region_name='ap-northeast-1')
s3_client = boto3.client('s3', region_name='ap-northeast-1')

cached_api_key = None

def get_api_key():
    global cached_api_key
    if cached_api_key:
        return cached_api_key

    response = secrets_client.get_secret_value(SecretId='funbrew-pdf-api-key')
    cached_api_key = response['SecretString']
    return cached_api_key

def handler(event, context):
    api_key = get_api_key()
    html = event.get('html', '<h1>Hello from Lambda</h1>')

    # PDF生成APIを呼び出し
    payload = json.dumps({
        'html': html,
        'engine': 'quality',
        'format': 'A4',
    }).encode('utf-8')

    req = urllib.request.Request(
        'https://api.pdf.funbrew.cloud/v1/pdf/from-html',
        data=payload,
        headers={
            'Authorization': f'Bearer {api_key}',
            'Content-Type': 'application/json',
        },
        method='POST',
    )

    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            pdf_bytes = resp.read()
    except urllib.error.HTTPError as e:
        error_body = e.read().decode('utf-8')
        raise Exception(f'PDF API error: {e.code} - {error_body}')

    # S3にアップロード
    bucket = os.environ['PDF_BUCKET']
    key = f'pdfs/{int(time.time() * 1000)}.pdf'

    s3_client.put_object(
        Bucket=bucket,
        Key=key,
        Body=pdf_bytes,
        ContentType='application/pdf',
    )

    return {
        'statusCode': 200,
        'body': json.dumps({
            'message': 'PDF generated and uploaded',
            's3Key': key,
            'size': len(pdf_bytes),
        }),
    }

Pythonの場合、標準ライブラリのurllibだけで実装できるため、外部パッケージのインストールが不要です。Lambda Layerを用意する必要もありません。

API Gateway + Lambda構成

REST APIとしてPDF生成を公開するには、API GatewayとLambdaを組み合わせます。

アーキテクチャ

クライアント → API Gateway → Lambda → FUNBREW PDF API
                                 ↓
                                S3(PDF保存)
                                 ↓
                              CloudFront(配信)

SAMテンプレート

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Globals:
  Function:
    Timeout: 60
    MemorySize: 256
    Runtime: nodejs20.x
    Environment:
      Variables:
        PDF_BUCKET: !Ref PdfBucket

Resources:
  PdfGeneratorFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      CodeUri: src/
      Events:
        GeneratePdf:
          Type: Api
          Properties:
            Path: /generate-pdf
            Method: post
      Policies:
        - S3CrudPolicy:
            BucketName: !Ref PdfBucket
        - AWSSecretsManagerGetSecretValuePolicy:
            SecretArn: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:funbrew-pdf-api-key-*

  PdfBucket:
    Type: AWS::S3::Bucket
    Properties:
      LifecycleConfiguration:
        Rules:
          - Id: DeleteOldPdfs
            Status: Enabled
            ExpirationInDays: 30

Outputs:
  ApiEndpoint:
    Value: !Sub https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/generate-pdf

コールドスタート対策

Lambdaのコールドスタートを最小限に抑えるためのテクニックを紹介します。

1. Provisioned Concurrency

頻繁に呼ばれる関数には、Provisioned Concurrencyを設定してコールドスタートを完全に排除します。

# SAMテンプレートに追加
PdfGeneratorFunction:
  Properties:
    ProvisionedConcurrencyConfig:
      ProvisionedConcurrentExecutions: 5

2. 初期化処理をハンドラ外に

AWS SDKクライアントやAPIキーのキャッシュは、ハンドラ関数の外側で初期化します。上のコード例ではcachedApiKey変数がこのパターンを実装しています。Lambda実行環境が再利用されるとき、これらの変数は保持されます。

3. 軽量なデプロイパッケージ

PDF APIを使う最大のメリットがここにあります。Chromiumをバンドルする必要がないため、パッケージサイズを数KB〜数MBに抑えられます。

# Node.jsの場合、AWS SDK v3は個別パッケージをインストール
npm install @aws-sdk/client-s3 @aws-sdk/client-secrets-manager

# Pythonの場合、追加パッケージ不要(boto3はLambdaランタイムに含まれる)

4. SnapStart(Java)

Java Lambdaを使う場合、SnapStartを有効化するとコールドスタートを大幅に短縮できます。

タイムアウト対策

Lambdaのタイムアウト上限は15分ですが、API Gatewayを通す場合は29秒の制限があります。

同期処理の場合

単一のPDF生成は通常1〜5秒で完了するため、API Gateway経由でも問題ありません。

// Lambda関数のタイムアウトを60秒に設定(余裕を持たせる)
// API Gatewayのタイムアウトは29秒

非同期処理が必要なケース

大量のPDF生成や複雑なテンプレートの場合は、非同期パターンを使います。

クライアント → API Gateway → Lambda(ジョブ登録)
                                ↓
                            SQS / EventBridge
                                ↓
                            Lambda(PDF生成)→ S3
                                ↓
                            Webhook通知 → クライアント
const { SQSClient, SendMessageCommand } = require('@aws-sdk/client-sqs');
const sqs = new SQSClient({ region: 'ap-northeast-1' });

// ジョブ登録Lambda
exports.enqueueHandler = async (event) => {
  const body = JSON.parse(event.body);
  const jobId = `job-${Date.now()}`;

  await sqs.send(new SendMessageCommand({
    QueueUrl: process.env.PDF_QUEUE_URL,
    MessageBody: JSON.stringify({
      jobId,
      html: body.html,
      webhookUrl: body.webhookUrl,
    }),
  }));

  return {
    statusCode: 202,
    body: JSON.stringify({ jobId, status: 'queued' }),
  };
};

// PDF生成Lambda(SQSトリガー)
exports.processHandler = async (event) => {
  for (const record of event.Records) {
    const { jobId, html, webhookUrl } = JSON.parse(record.body);

    // PDF生成
    const pdfBuffer = await generatePdf(html);

    // S3にアップロード
    const s3Key = `pdfs/${jobId}.pdf`;
    await uploadToS3(s3Key, pdfBuffer);

    // Webhook通知
    if (webhookUrl) {
      await fetch(webhookUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          event: 'pdf.generated',
          jobId,
          s3Key,
          size: pdfBuffer.length,
        }),
      });
    }
  }
};

Webhookの詳しい実装方法はWebhook連携ガイドを参照してください。

S3連携:生成したPDFの管理

署名付きURLでの配信

生成したPDFをユーザーに配信する場合、S3の署名付きURLを使うと認証付きの一時URLを発行できます。

const { GetObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');

async function getDownloadUrl(s3Key) {
  const command = new GetObjectCommand({
    Bucket: process.env.PDF_BUCKET,
    Key: s3Key,
  });

  // 1時間有効な署名付きURL
  return await getSignedUrl(s3, command, { expiresIn: 3600 });
}

ライフサイクルルール

不要になったPDFを自動削除するライフサイクルルールを設定しておきましょう。SAMテンプレートの例では30日後に自動削除しています。

トラブルシューティング

よくあるエラーと対処法

エラー 原因 対処
Task timed out Lambda/API Gatewayのタイムアウト超過 タイムアウト値の見直し、非同期処理への切り替え
ECONNRESET API呼び出し中のネットワークエラー リトライロジックの実装
AccessDeniedException IAMロールの権限不足 S3・Secrets Managerのポリシーを確認
413 Payload Too Large HTMLが大きすぎる HTMLの最適化、外部CSS/画像の使用

エラーハンドリングの詳細はエラーハンドリングガイドを参照してください。

リトライ戦略

Lambda関数にリトライロジックを組み込みましょう。

async function callPdfApiWithRetry(html, maxRetries = 3) {
  const apiKey = await getApiKey();

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch('https://api.pdf.funbrew.cloud/v1/pdf/from-html', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${apiKey}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ html, engine: 'quality', format: 'A4' }),
        signal: AbortSignal.timeout(30000),
      });

      if (response.ok) {
        return Buffer.from(await response.arrayBuffer());
      }

      // 4xxエラーはリトライしない
      if (response.status >= 400 && response.status < 500) {
        throw new Error(`Client error: ${response.status}`);
      }
    } catch (error) {
      if (attempt === maxRetries) throw error;
      // 指数バックオフ
      await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
    }
  }
}

CloudWatch Logsでのデバッグ

構造化ログを出力すると、CloudWatch Logs Insightsでの検索が容易になります。

console.log(JSON.stringify({
  level: 'info',
  event: 'pdf_generation',
  status: 'success',
  duration_ms: endTime - startTime,
  pdf_size: pdfBuffer.length,
  s3_key: key,
}));

本番運用のチェックリスト

  • APIキーをSecrets Managerまたは暗号化環境変数で管理している
  • Lambda関数のタイムアウトを適切に設定している(60秒推奨)
  • リトライロジックを実装している
  • S3バケットにライフサイクルルールを設定している
  • CloudWatch Alarmsでエラー率を監視している
  • DLQ(Dead Letter Queue)を設定している

本番環境の詳しい設定は本番環境ガイドも参考にしてください。

まとめ

AWS LambdaとPDF APIの組み合わせは、サーバーレスPDF生成の最適解です。Chromiumをバンドルする必要がなく、デプロイパッケージは軽量、コールドスタートは高速、メモリ使用量も最小限で済みます。

  • 同期処理: API Gateway + Lambdaで単純なPDF生成
  • 非同期処理: SQS + Lambda + Webhookで大量のPDF生成
  • 配信: S3 + CloudFront + 署名付きURLで安全に配信

まずは無料アカウントを作成して、PlaygroundでAPI動作を確認してみてください。

関連リンク

Powered by FUNBREW PDF