🖼️

Nuxt3でOGP画像を生成するLambdaを作った

2024/05/27に公開

やったこと

microCMSとNuxt3で作ったブログ記事のOGP画像を動的に生成させたかったので、Nuxt3とsatoriを使って画像生成をしました。
最初はCloudflareのPages Functionで動作させようと思っていたのですが、どう頑張っても容量制限に引っかかってしまうので、AWS Lambda上で動作させています。
また、画像の配信には単純な興味と料金面の魅力からCloudflare R2を利用しました。ただ、後述しますが、料金面では今回のケースは失敗だったかなと思います。

技術スタックと構成

  • Nuxt3
  • AWS Lambda
  • Cloudflare R2
  • microCMS

Nuxt3をLambda上で動かして、生成した画像をR2にアップロードしています。microCMS側で記事を編集した際にAPIに設定したwebhookからLamdbaを動作させて、画像を生成します。

コード部分の解説

実際のコードはこちら。以下かいつまんで説明していきます。

https://github.com/renoinn/nuxt-ogp-image

必要なパッケージのインストール

まずは必要なパッケージをインストールします。

package 説明
satori JSXをSVGに変換してくれる
v-satori Vueコンポーネントをsatoriが扱える形式に変換する
sharp svgをpngに変換する
unplugin-font-to-buffer フォントをimport文で読み込める
@aws-sdk/client-s3 R2にアップロードするのに利用

Vueコンポーネントを用意

conponents/以下にOGP画像にしたいVueコンポーネントを用意します。
v-satoriはtailwindをサポートしているので、スタイリングはtailwindを使って行います。

ちょっとハマったポイントとして、画像を使うときにsrc属性にpublicなパスや~/assets/img/hogehoge.pngみたいなエイリアスを利用したパスを指定すると、どちらも画像のパスを解決できずにビルドエラーになりました。
自分の場合はfaviconに使ってる小さ目なアイコンを使いたいだけだったので、base64形式にして直接埋め込みましたが、個々の解決方法は結局分からずじまいです。

<script setup lang="ts">
withDefaults(defineProps<{
  title?: string
}>(), {
  title: 'title',
})
</script>

<template>
  <div class="w-full h-full flex flex-col items-center justify-center bg-[#fdfcfa]">
    <h1 class="font-light text-6xl text-center mx-8 mb-8">
      {{ title }}
    </h1>
    <p>
      <img class="p-0 m-0 mr-4" width="32" height="32"
        src='data:image/png;base64,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' />
      <span class="text-xl">oomori-blog.pages.dev</span>
    </p>
  </div>
</template>

serverのroutesを作成

server/routes/以下にファイルを置くとそこがエンドポイントになります。
自分の場合はserver/routes/ogp.tsを作成しました。

画像の作成

上で書いた画像にしたいコンポーネントと、使用したいフォントデータを読み込ませておきます。
フォントは事前にGoogleフォントなどダウンロードしておきましょう。
unplugin-font-to-bufferのおかげで、import文で読み込んで、そのままBufferデータとして扱えます。
v-satoriでsvgを生成して、sharpでpngに変換します。

import { satori } from 'v-satori'
import sharp from 'sharp'
import Article from '@/components/OgImage/Article.vue'
import NotoSansJP from '@/assets/server/NotoSansJP-Light.ttf'

const generateImageWithTitle = async (title: String): Promise<Buffer> => {
  const svg = await satori(
    Article,
    {
      props: {
        title: title,
      },
      width: 1200,
      height: 600,
      fonts: [
        {
          name: 'NotoSansJP-Light',
          data: NotoSansJP,
          style: 'normal'
        }
      ]
    },
  )

  return await sharp(Buffer.from(svg)).png().toBuffer()
}

R2にアップロード

R2はS3互換なので、aws-sdkを利用してアップロードできます。
R2のバケット作成時にaccessKeyIdとsecretAccessKeyを控えておいて、.envとかに設定してRuntimeConfigで読み込んだりしましょう。

import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'

~~~~

  const s3 = new S3Client({
    region: 'auto',
    endpoint: config.r2Endpoint,
    credentials: {
      accessKeyId: config.r2AccessKeyId,
      secretAccessKey: config.r2AccessKeySecret,
    }
  })

  const id = body.contents?.new?.id
  const fileName = `${id}/og-image.png`
  await sendImage(s3, fileName, png)

~~~~

const sendImage = async (client: S3Client, fileName: string, buffer: Buffer) => {
  const command = new PutObjectCommand({
    Bucket: '{{R2のbucketName}}',
    Key: fileName,
    Body: buffer,
    ContentType: 'image/png',
  })
  const res = await client.send(command)
  console.log(res)
}

microCMSのwebhookを受け取る

microCMSのwebhookはシークレット値を設定していると、X-MICROCMS-Signatureヘッダーに検証用の値を付与してくれます。
https://document.microcms.io/manual/webhook-setting#hb2d39bd6cc

設定したシークレット値を.envとかに置いておいて、送られてきたヘッダーを検証しましょう。
リクエストボディに記事データが入っているので、これを取りだすことでAPIを叩かずに動的にOGPを生成できます。

type WebhookBody = {
  service: string,
  api: string,
  id: string,
  type: string,
  contents: Contents | null,
}

type Contents = {
  old: ContentInfo | null,
  new: ContentInfo | null,
}

type ContentInfo = {
  id: string,
  status: Array<String>,
  draftKey: string | null,
  publishValue: {
    id: string,
    title: string
  },
  draftValue: null,
}

export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig(event)
  const signature = getRequestHeader(event, 'X-MICROCMS-Signature')
  const body = await readBody<WebhookBody>(event)
  const expectedSignature = crypto.createHmac('sha256', config.webhookSignature).update(JSON.stringify(body)).digest('hex')

  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
    console.log(`invalid signature ${signature}`)
    setResponseStatus(event, 401)
    return
  }

  const title = body.contents?.new?.publishValue.title ?? ''

~~~~

}

Lamdba側は関数URLを設定しておくことで、https://${Lamdbaのドメイン}/ogpみたいな感じでアクセスできる。
cdkでの設定は下記のような感じ。

const lambda = new Function(this, 'OgpImage', {
  functionName: 'OgpImage',
  handler: 'index.handler',
  runtime: Runtime.NODEJS_20_X,
  code: new AssetCode(`../.output/server/`),
  memorySize: 512,
  timeout: Duration.seconds(30)
});
const url = lambda.addFunctionUrl({
  authType: FunctionUrlAuthType.NONE,
});

R2の設定(バケット作成とドメイン設定)

cloudflareのコンソールからR2のバケットを作成します。
作成したら上で書いたaccessKeyIdとsecretAccessKeyを控えておきましょう。

R2のバケットはただ作成しただけだと署名付きURLでしかアクセスできません。今回はOGP画像用でパブリックにアクセスさせたかったのですが、R2のパブリックアクセスには2つの方法があります。

  • カスタムドメインを設定して公開
  • Cloudflare が管理するサブドメイン(*.r2.dev)で公開

r2.devドメインでの公開はキャッシュやWAF等の機能が使えず、レート制限もあるため本番環境での利用は非推奨で、あくまでも開発時の確認用らしい。なので実質カスタムドメインが必須になります。

自分の場合はCloudflare registrarで新規にドメインを契約して、さらにそのサブドメインにR2バケットを割り当てました。

料金面の比較

S3とR2の料金面の比較ではよく無料枠部分の比較がされます。S3の無料枠は利用時から12か月限定だったり、容量や操作回数などもR2の方が枠が大きいので、個人利用であればR2の方に軍配があります。

ただ、パブリックアクセスでの公開ではカスタムドメインが必須になるので、毎年¥1500~のお金がかかることになります。すでにドメインを持ってる人であれば、エグレスが無料だったりとR2のコスト面のメリットをフルに享受できますが、自分の場合はそうではなかったので、コスト面だけでみると少し失敗だったかなと思います。
S3+CloudFrontの構成であれば、カスタムドメインは必須ではないので、イニシャルコストとしては安価で済みます。一方で、こちらは転送量がかかってしまうので、アクセスが増えるほどコストがかかります。

この辺りはどのくらいの操作量や転送量になるか次第ですが、今回はR2を使いたいというのが目的になっていたのと(手段が目的化してますがw)、ドメインはどこかで取りたいとは思っていたので、ドメインを取ってそのサブドメインを割り当てました。

参考記事

https://www.memory-lovers.blog/entry/2023/12/08/173753#google_vignette
https://takagi.blog/using-cloudflare-r2-as-a-blog-image-hosting/
https://zenn.dev/ryota_09/articles/347c207987741b

Discussion