🖼️

【AWS】AWSで画像配信サイトを作る技術

2024/04/30に公開

概要

この度、おりきゃらーずというオリジナルキャラクターの画像配信サイトをリリースしました。
以下おりきゃらーずの魅力を伝えるための宣伝 note です。

https://note.com/alichan69/n/nae6d765e1372

上記宣伝 note ではおりきゃらーずの魅力に焦点を当てて記載しました。
このサイトを開発していく中で AWS のみで画像配信サイトを作る際にどういうアーキテクチャを組めば、安く、速く、魅力的な画像をユーザーに届けることができるかの知見が溜まりましたのでそれについてこちらでは公開したいと思います ☺️
AWS 上で魅力的な画像配信サイトを手軽に作ってみたい方は、是非見てみてください〜🙏

基本的な画像配信サイトに必要な要件

大規模な配信サイトや複雑な要件が絡まってくる配信サイトだと必要な物が変わってくるかもしれませんが、基本的に今回の画像配信サイトで必要な要件は以下でした。

  • 画像を高速に配信して、ユーザーにより速く画像を届ける
  • 画像配信をより安く行う
  • 配信された画像に対して動的 OGP を実装してユーザーに魅力的な画像を届ける
動的 OGP とは?

動的 OGP とは、X や Facebook、LINE 等でよく見られる、画像の URL をシェアした時に表示される画像のことです。同じドメインでも(例えばおりきゃらーずの場合https://oricharaz.com)URLのパスの違い等によって表示される画像が切り替わるので動的OGPと呼ばれます。
例えば、以下の様にユーザー様が自分の配信したキャラクターの表示ページを X でシェアすると、X 上で配信したキャラクターの画像が表示されます。

上記要件を達成することを考え、今回アーキテクチャを組みました。

画像配信サイトのアーキテクチャ

以下が今回作成した画像配信サイトのアーキテクチャ図です。

ざっくり説明すると、お名前.com で取得した独自ドメイン(https://oricharaz.com という自分で指定した URL のこと)を Route53 で CloudFront のサブドメイン(CloudFront でランダムに発行される URL のこと)にルーティングし、CloudFront からのリクエストで画像が格納された S3 にリクエストが飛んで画像が配信されるようになっています。
このアーキテクチャを組むことで上記要件が達成可能になっています。
画像の高速配信については、CloudFront を使用することで画像のキャッシュが取得されるため達成可能です。
以下記事で CloudFront で高速画像配信する仕組みが記載されていますので、興味ある方はぜひ見てみてください 🙏

https://zenn.dev/alichan/articles/d9f0a0cfd7682f

画像配信をより安く行うですが、上記要件を達成しつつ画像を安く配信するために今回はコンテナを使用せずリクエストに対する従量課金制のサービスを中心にアーキテクチャを組むことで達成しました。
個人開発だとそもそもユーザーが集まるかどうかも微妙なので、集まらなければ維持費がほとんどかからないようにしました。

動的 OGP に関してですが、S3 の手前に挟んだ CloudFront に Lambda@Edge を付与して、そこでレスポンスの書き換えを行うことで達成しました。
ここは実装が複雑なので次の章で詳しく説明します。

画像配信サイトを作る方法

では、アーキテクチャの説明が終わりましたので画像配信サイトを作ってみます ☺️

  1. S3 バケットに画像を格納する

まず S3 バケットに以下の様に配信する画像を格納します。

バケット内のディレクトリ構造は以下の様になっています。

image(バケット)
└── images
   └── character
        ├── f849b8c7-8b02-4145-bebd-12fa43c2ae78(キャラクターのUUID)
        ... ├── eyecatch-image
            |   └── character-eyecatch-image.png
            ├── full-body-image
            |   └── character-full-body-image.png
            └── twitter-OGP-image
                └── twitter-OGP-image.png

バケット内に images ディレクトリを切り、この中に画像を格納していきます。
更に character ディレクトリを切り、その中にキャラクターの UUID ごとでディレクトリを切っていきます。
各 UUID のディレクトリには eyecatch-image、full-body-image、twitter-OGP-image と画像の用途ごとにディレクトリが切られています。
eye-catch-image にはサイト内でキャラクターのアイキャッチとして使用される画像が格納されています。
以下の様にサイト内のキャラクターの一覧等で使用されます。

full-body-image にはキャラクターの全身画像が格納されています。
以下の様にサイト内のキャラクターの詳細表示ページで表示されます。

twitter-OGP-image には OGP として使用される画像が格納されています。
X にキャラクターの詳細表示ページの URL が配信されると以下の様にこの画像が表示されます。

サイト内でどのような用途でどのような画像を使用するかによって、このディレクトリ構造は変えてみてください ☺️
最終的にサイト、X 共に上記の様に格納した画像が表示される状態を目指していきます。

  1. CloudFront で高速画像配信の設定を行う

まず、CloudFront で画像を高速配信する設定を行います。
以下の様にオリジンに image バケットを設定します。

CloudFront に images パスでリクエストが飛んできた時に上記 image バケットにリクエストが飛ぶようにビヘイビアを以下の様に設定します。

これで image バケットの画像を表示する際に CloudFront を挟んで高速に画像を配信することができるようになりました。
例えば、https://ドメイン名/images/character/f849b8c7-8b02-4145-bebd-12fa43c2ae78/eyecatch-image/character-eyecatch-image.png にリクエストが飛んできた時にこのビヘイビアに引っかかるので S3 バケットの/images/character/f849b8c7-8b02-4145-bebd-12fa43c2ae78/eyecatch-image/character-eyecatch-image.png に格納された画像が表示されます。
後で独自ドメインで CloudFront にアクセスできる様に設定しますがそうすればサイト内で img タグの src 属性に https://独自ドメイン名/images/character/f849b8c7-8b02-4145-bebd-12fa43c2ae78/eyecatch-image/character-eyecatch-image.png を設定するだけで画像が高速に表示されるというわけです。

これで、サイト内で表示されるアイキャッチ画像と全身画像を高速に表示する設定は終わりました。

  1. CloudFront で動的 OGP の設定を行う

次に、CloudFront で動的 OGP の設定を行います。
最終的に各キャラクターの詳細ページの URL を X に貼ったら、そのキャラクターの画像が表示できる事を目指します。

つまり、以下キャラクターの詳細表示ページの URL を X に貼ったら

このように X でこのキャラクターの画像が表示されます。

このように動的 OGP を実装することで、このキャラクターに興味を持ってくれた X ユーザーがキャラクター詳細ページに遷移することでサイトに訪れてくれるというわけです。

では、実装に移ります。

以下の様にオリジンに front バケットを設定します。
この中には画面を表示するための html ファイルが格納されています。

CloudFront に /orichara/read パスでリクエストが飛んできた時に上記 front バケットにリクエストが飛ぶようにビヘイビアを以下の様に設定します。
このパスはキャラクターの詳細表示ページ用のパスです。
これでキャラクターの詳細表示ページを表示する際に CloudFront が挟まれる様になりました。

ここからが少し難しいです。
X で動的 OGP を表示するには、html の head の meta タグに以下の様に X での OGP 画像の URL を設定する必要性があります。

<html>
  <head>
    ...
    <meta name="twitter:image" content="OGP画像のURL" />
    ...
  </head>
</html>

このメタタグ内の画像の URL ですが、各キャラクターごとに使用する画像が変わるのでキャラクターにあった画像の URL に書き換えなければいけません。
ここの URL の書き換えを行うために CloudFront に Lambda@Edge というレスポンスの書き換えを行う Lambda を付与する必要があります。
なぜこれが必要なのかの理屈は長くなるので興味がある方だけ読んでみてください ☺️

なぜ Lambda@Edge での書き換え処理が必要なのか

画像の URL を書き換えるには js で動的に URL を書き換える必要があります。
しかし、js はブラウザでしか動くことができません。
動的 OGP を表示させる仕組みとしては、X 上にキャラクターの詳細表示ページの URL を貼った時に X のクローラーが貼られた URL の html を読み込むことで head タグに設定された meta タグの OGP 画像の URL を読み込み、その画像を表示させています。
しかし、クローラーは js を実行することができないため、画像の URL が js で描画されず、空文字になってしまいます。
そのため、X のクローラーからのリクエストが飛んできた時はそれを判断し、クライアント側での描画を期待せず間に挟んだ CloudFront の Lambda@Edge で URL を描画してもらうようにしています。
本来、Next.js を使用している場合 SSR という選択肢がありますのでそちらを利用すればエッジ関数を挟まずともサーバー側で URL を描画してもらうことで URL が描画された html を返すことは可能なのですが今回コスト削減のため S3 での静的ホスティングを行うことにしたのでこのような実装になっています。
また、Lambda@Edge の他にエッジ関数の候補として CloudFront Functions という選択肢がありますが、そちらは簡単な書き換えを想定しており、DynamoDB 等他 AWS 内のサービスを使用できませんので今回は柔軟性の高い Lambda@Edge を選択しました。

では実装を開始します。

Lambda@Edge はバージニア北部の関数しか使用できないのでバージニア北部にレスポンスの書き換えを行う関数を作成します。

この関数に以下の様にコードを実装してください。
それぞれのコードの説明はコード内部に記載してあります。

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";

// OGP画像を表示させたいSNSのクローラーが読み込みを行う時にリクエストのヘッダーのユーザーエージェントに設定される文字列を宣言
const BOTS = [
  "Twitterbot",
  "facebook",
  "line",
  "Discord",
  "SkypeUriPreview",
  "Slack",
  "PlurkBot",
];
//  OGP画像を表示させるhtmlを生成する関数を宣言。クローラーが読み込みにきた時はこの関数でhtmlを生成して返す。
const generateContent = ({ title, description, imageURL, mimeType, url }) => {
  return ` <!doctype html>
        <html lang="ja">
        <head>
        <meta charset="utf-8" />
        <meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
        <meta content="width=device-width, initial-scale=1.0" name="viewport" />
        <title>${title}</title>
        <meta content="${description}" name="description">
        <meta content="${url}" property="og:url" />
        <meta content="article" property="og:type" />
        <meta content="ja_JP" property="og:locale" />
        <meta content="${title}" property="og:title" />
        <meta content="${description}" property="og:description" />
        <meta content="${imageURL}" property="og:image" />
        <meta content="${imageURL}" property="og:image:secure_url" />
        <meta content="${mimeType}" property="og:image:type" />
        <meta content="summary_large_image" property="twitter:card" />
        <meta content="@alichan0609" property="twitter:site" />
        <meta content="${title}" property="twitter:title" />
        <meta content="${description}" property="twitter:description" />
        <meta content="${imageURL}" property="twitter:image" />
        </head>
        <body></body>
        </html> `;
};

export const handler = async (event, context, callback) => {
  const request = event.Records[0].cf.request;
  const uri = request.uri;
  const id = request.querystring.split("=").pop();
  const userAgent = request.headers["user-agent"][0].value;
  // リクエストのユーザーエージェントが宣言したクローラーの配列に含まれるか確認
  const isBot = BOTS.some((v) => {
    return userAgent.includes(v);
  });
  const client = new DynamoDBClient({ region: "ap-northeast-1" });
  const docClient = DynamoDBDocumentClient.from(client);

  // リクエストがBOTからだった時行う処理
  if (isBot) {
    // クエリパラメーターのキャラクターIDをもとにDynamoDBからキャラクターの情報を取得
    const { Items } = await docClient.send(
      new QueryCommand({
        TableName: "Oricharaz",
        KeyConditionExpression: "DataName = :dataName AND ID = :id",
        ExpressionAttributeValues: {
          ":dataName": "character",
          ":id": `${id}`,
        },
      })
    );

    const item = Items[0];

    // 取得したキャラクター情報を下に、OGP画像のURL他必要な情報を入れてhtmlを生成
    const body = generateContent({
      title: `${item.Name} | おりきゃらーず`,
      description: `おりきゃらーずに配信されたオリキャラ、${item.Name}です!おりきゃらーずでオリキャラを見てみてね`,
      imageURL: item.TwitterOGPImage,
      mimeType: `image/${item.TwitterOGPImage.split(".").pop()}`,
      url: `https://oricharaz.com/orichara/read/?id=${id}`,
    });

    // responseを設定して返す
    const response = {
      status: "200",
      statusDescription: "OK",
      headers: {
        "content-type": [
          {
            key: "Content-Type",
            value: "text/html",
          },
        ],
      },
      body,
    };

    callback(null, response);
  } else {
    // リクエストがBOTからでない時行う処理
    let index = uri.lastIndexOf("/");
    request.uri = uri.slice(0, index + 1) + "index.html" + uri.slice(index + 1);

    callback(null, request);
  }
};

ちなみにこの Lambda に付与するロールとして、AmazonDynamoDBFullAccess を忘れずに付与するようにしてください。
これでレスポンスの書き換えを行う関数が完成しました。
最後に先ほど設定したビヘイビアのビューワーリクエスト関数に関数タイプ Lambda@Edge、関数 ARN にこの Lambda の ARN を設定すればレスポンスの書き換えが行われる様になります 👍

  1. お名前.com→Route53→CloudFront の順にルーティングを行い、 独自ドメインを設定する

最後に、今設定した CloudFront ディストリビューションで発行されたサブドメインに対してお名前.com→Route53→CloudFront の順にルーティングを行い、 独自ドメインを設定するようにします。
まず、Route53 でお名前.com で取得した oricharaz.com という独自ドメインををドメイン名として設定したホストゾーンを作成します。
ホストゾーンを作成すると以下のようにネームサーバーが発行されます。
NS レコードのネームサーバー 4 つを書き留めてください。

その後、お名前.com でこちらのネームサーバーに対してルーティングを行うため、利用するネームサーバーに先ほどメモしたネームサーバーを設定します。

これで、お名前.com→Route53 のルーティングが行われました。

次に Route53→CloudFront に対してルーティング設定を行います。
まず、CloudFront に対して https 通信ができるように ACM ででドメイン名が oricharaz.com の SSL 証明書を発行します。

CloudFront で発行したディストリビューションに代替ドメイン名 oricharaz.com を設定し、カスタム SSL 証明書に先ほど発行した SSL 証明書の設定を行います。

ホストゾーンのレコードを作成ボタンでレコードを追加してエイリアスオン、トラフィックのルーティング先を CloudFront ディストリビューションへのエイリアスを選択して CloudFront で発行されたディストリビューションのサブドメインを設定します。
これで少し待つと独自ドメインでサイトにアクセスできるようになります。

終わりに

お疲れ様でした。
画像配信サイト、世の中にはたくさんありますがなかなか自分で実装してみると面白かったです。
ここまで読んでいただき本当にありがとうございます 🙇‍♀️

参照

https://dev.classmethod.jp/articles/lambda-edge-ogp/

https://dev.classmethod.jp/articles/route53-domain-onamae/

https://zenn.dev/taiyou/articles/6dc3b1b6d7ebc9

Discussion