Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

CloudFront + S3 Object LambdaでHTMLをPDFに変換して配信する

この記事はGunosy Advent Calendar 2024の6日目の記事です。昨日の記事はk.oshiroさんの「LLMでの業務支援 まとめ動画作成効率化プロジェクトの話」でした。

はじめに

こんにちは。プロダクト開発部Adsチームのjohnmanjiroです。普段は広告配信のAPIや管理画面を開発しています。

先日、S3上に保存したHTMLファイルからPDFを生成して表示する機能を実装する機会がありました。この記事では、その際に利用したS3 Object Lambdaについてご紹介します。

この機能の実装にあたり、AWSの以下のブログを参考にしています。

aws.amazon.com

S3 Object Lambdaとは

S3 Object Lambdaは、S3に対するGET、HEAD、LISTリクエストのレスポンスをLambdaで加工することができる機能です。 S3からのレスポンスが返ってくる際に間にLambdaが挟まっているイメージです。

通常であればS3上のHTMLをPDFに変換して配信するには、変換したPDFをまたS3などに保存する必要があり、これは余分にコストがかかってしまいます。それに対してS3 Object Lambdaであれば、「HTMLをPDFに変換するLambda関数」を用意することで、S3上のHTMLファイルのレスポンス時に変換処理を挟み、PDFファイルを返すということが可能になります。

Lambda@Edgeとの違い

似たことを実現する選択肢として、Lambda@Edgeがあります。Lambda@EdgeはCloudFrontの機能で、エッジロケーションでLambdaを実行することができるものです。しかし、リージョンがバージニア北部に限定されるなど、利用にはいくつかの制限があります。

それに対してS3 Object LambdaはCloudFrontを前提としない機能であることから、Lambda@Edgeのような制限を受けずに利用することができます。たとえば(詳細は後述しますが)、S3 Object Lambdaでは専用のプライベートなアクセスポイントが用意されるため、VPC内のリソースからS3上のファイルを変換したものを取得したい場合にもVPCから外に出る必要がありません。

実装

今回の構成

今回の機能はS3上に保存されているHTMLをPDFに変換してCloudFront経由で配信するというもののため、以下のような構成になります。

  • S3バケット
    • HTMLファイルが保存されているバケット
  • S3アクセスポイント
    • S3 Object LambdaがS3上のファイルを取得する際に利用するアクセスポイント
  • Lambda
    • HTMLを取得してPDFに変換するLambda関数
    • 署名付きURLを用いてS3上のファイルを取得する
      • S3に対する権限は不要
  • S3 Object Lambdaアクセスポイント
    • S3 Object Lambdaを経由してファイルを取得するためのアクセスポイント
  • CloudFront
    • PDFをユーザーに配信するためのCloudFront

必要な設定

前述の構成を実現するためのTerraformのコードは以下のようになります。

S3バケット

HTMLを保管するS3バケットと、それに対するS3アクセスポイントのアクセスを許可するバケットポリシーを用意します。

resource "aws_s3_bucket" "html_assets" {
  bucket = "html-assets"
}

resource "aws_s3_bucket_policy" "html_assets" {
  bucket = aws_s3_bucket.html_assets.id
  policy = data.aws_iam_policy_document.html_assets_bucket.json
}

data "aws_iam_policy_document" "html_assets_bucket" {
  statement {
    effect = "Allow"
    principals {
      type        = "AWS"
      identifiers = ["*"]
    }
    actions = ["*"]
    resources = [
      aws_s3_bucket.html_assets.arn,
      "${aws_s3_bucket.html_assets.arn}/*",
    ]
    condition {
      test     = "StringEquals"
      variable = "s3:DataAccessPointAccount"
      values = [
        local.account_id,
      ]
    }
  }
}

S3アクセスポイント

次に、作成したバケットに対するS3アクセスポイントを作成します。このアクセスポイントはCloudFrontを経由してS3 Object Lambdaから利用されるため、それらを許可するポリシーを用意します。

resource "aws_s3_access_point" "html_assets" {
  bucket = aws_s3_bucket.html_assets.id
  name   = "html-assets"
}

resource "aws_s3control_access_point_policy" "html_assets" {
  access_point_arn = aws_s3_access_point.html_assets.arn
  policy           = data.aws_iam_policy_document.html_assets_access_point.json
}

data "aws_iam_policy_document" "html_assets_access_point" {
  statement {
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"] # CloudFrontを許可
    }
    actions = [
      "s3:*",
    ]
    resources = [
      aws_s3_access_point.html_assets.arn,
      "${aws_s3_access_point.html_assets.arn}/object/*",
    ]
    condition {
      test     = "ForAnyValue:StringEquals"
      variable = "aws:CalledVia"
      values   = ["s3-object-lambda.amazonaws.com"] # S3 Object Lambdaを許可
    }
  }
}

Lambda

ここでは、Lambda本体と、それに付随するポリシーを用意します。Lambdaの権限に関しては、S3 Object Lambdaに関する部分以外は省略しています。

resource "aws_lambda_function" "convert_pdf" {
  s3_bucket     = "deploy_resources"
  s3_key        = "lambda/convert-pdf/package.zip"
  function_name = "convert-pdf"
  handler       = "index.handler"
  runtime       = "nodejs20.x"
  role          = aws_iam_role.convert_pdf_lambda.arn

  memory_size = 2048
  timeout     = 10
}

resource "aws_iam_role" "convert_pdf_lambda" {
  name = "lambda.convert-pdf"
}

# S3 Object Lambdaとして利用する際に必要
resource "aws_iam_role_policy_attachment" "convert_pdf_s3_object_lambda_execution" {
  role       = aws_iam_role.convert_pdf_lambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonS3ObjectLambdaExecutionRolePolicy"
}

# CloudFrontからS3 Object Lambdaアクセスポイントを経由して利用されるため必要
resource "aws_lambda_permission" "allow_cloudfront_call_convert_pdf" {
  statement_id  = "lambda-allow-cloudfront"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.convert_pdf.function_name
  principal     = "cloudfront.amazonaws.com"
}

S3 Object Lambdaアクセスポイント

S3 Object Lambdaアクセスポイントと、CloudFrontからのアクセスを許可するポリシーを用意します。

resource "aws_s3control_object_lambda_access_point" "pdf_converter" {
  name = "pdf-converter"

  configuration {
    supporting_access_point = aws_s3_access_point.pdf_converter.arn

    transformation_configuration {
      actions = ["GetObject"]
      content_transformation {
        aws_lambda {
          function_arn = aws_lambda_function.convert_pdf.arn
        }
      }
    }
  }
}

resource "aws_s3control_object_lambda_access_point_policy" "pdf_converter" {
  name   = aws_s3control_object_lambda_access_point.pdf_converter.name
  policy = data.aws_iam_policy_document.pdf_converter_object_lambda_access_point.json
}

data "aws_iam_policy_document" "pdf_converter_object_lambda_access_point" {
  statement {
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }
    actions = [
      "s3-object-lambda:Get*",
    ]
    resources = [
      aws_s3control_object_lambda_access_point.pdf_converter.arn,
    ]
    condition {
      test     = "StringEquals"
      variable = "aws:SourceArn"
      values   = [aws_cloudfront_distribution.pdf_converter.arn]
    }
  }
}

CloudFront

最後にCloudFrontを作成します*1。オリジンにはS3 Object Lambdaアクセスポイントを指定します。

また、S3 Object Lambdaアクセスポイントはパブリックにすることはできません。そのため、CloudFrontのオリジンアクセスコントロールを利用してオリジンに対するリクエストを認証しています。

resource "aws_cloudfront_distribution" "pdf_converter" {
  origin {
    domain_name              = "${aws_s3control_object_lambda_access_point.pdf_converter.alias}.s3.${local.region}.amazonaws.com"
    origin_id                = "${aws_s3control_object_lambda_access_point.pdf_converter.alias}.s3.${local.region}.amazonaws.com"
    origin_access_control_id = aws_cloudfront_origin_access_control.pdf_converter.id
  }
  # 省略
}

resource "aws_cloudfront_origin_access_identity" "pdf_converter" {
  name                              = "pdf-converter"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

これで、今回の機能を実現するための設定が完了しました。

Lambdaの実装

最後にLambdaの実装について説明します。

前項でのLambdaの設定で、S3に対する権限が渡されていないことに気づかれた方もいるかと思います。これは、S3 Object LambdaがS3上のファイルを取得する際に、S3から署名付きURLが発行され、それを利用してファイルを取得するためです。そのため、LambdaにS3に対する権限を付与する必要はなく、実装は以下のようになります。

import axios from "axios";
import { S3Client, WriteGetObjectResponseCommand } from "@aws-sdk/client-s3";

// https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/olap-event-context.html
interface ObjectContext {
  inputS3Url: string;
  outputRoute: string;
  outputToken: string;
}

export const handler = async (event) => {
  const context: ObjectContext = event.getObjectContext;
  const { inputS3Url, outputRoute, outputToken } = context;
  // 署名付きURLを利用してS3からHTMLを取得
  const presignedResponse = await axios.get(inputS3Url);
  const html = presignedResponse.data;

  // HTMLをPDFに変換
  const pdf = await convertToPDF(html);
  const s3 = new S3Client();
  // PDFとしてレスポンスを返す
  const command = new WriteGetObjectResponseCommand({
    RequestRoute: outputRoute,
    RequestToken: outputToken,
    ContentType: "application/pdf",
    Body: pdf,
  });
  await s3.send(command);
  return;
};

おわりに

この記事ではS3 Object Lambdaを利用してHTMLをPDFに変換して配信する方法についてご紹介しました。作成したCloudFront経由でアクセスすることでPDFとして取得することができます。また、作成したCloudFrontを経由しなければ通常のHTMLとして取得することも可能です。

S3のレスポンスを柔軟に加工することができるため、他にも様々な活用方法が考えられます。ぜひお試しください!

明日はtakujiさんの「LLMを使った広告問い合わせ対応の話」です!お楽しみに!

*1:余談ですが、S3上での暗号化にKMSを利用している場合にはKMS側でCloudFrontを許可する設定も必要です。