【Lambda@Edge x ウェブサイト運用】AWS S3&CloudFrontで構築したウェブサイトでOGP対応をしてみた


2021/08/05
蛸壺の技術ブログ|AWS S3&CloudFrontで構築したウェブサイトでOGP対応をしてみた

S3から静的なindex.htmlをCloudFrontでCDN配信する場合、AWS Lambda@Edgeを使ってこれまで
アクセスURLに/index.htmlを補完させてみたり、301リダイレクトさせてみたり、404ページへの誘導をさせてみたり、色々と応用をしてきました。

これだけでプリレンダリングさせたindex.htmlをCDN配信させているだけのウェブサイトでも、本物のWebサーバーがバックエンドで稼働しているウェブサイトとほとんど同じ感覚で使えるようになってきました。

...が、やはりAWS S3+CloudFrontで構築したウェブサイトは、本来色々と裏で処理をやってくれているサーバーが実体としてあるわけではないので、様々な問題が発生するのは覚悟しないといけません。

そんな問題の一つに、
「SNSのbotがOGPグラフを正しく読み込めない」問題があります。OGP(Open Graph Protocol)は、TwitterなどのSNSの中でWebページのリンクがシェアされた場合、metaタグにあるタイトルや概要・画像等を表示させる仕組みのことで、問題が発生した場合にはこれが機能しません。

今回はS3+CloudFrontから配信される静的ウェブサイトからでも、botから正しくOGP情報が読み込めるような方法を模索します。


OGPがbotから読めない?問題

例えばS3+CloudFrontで構築・運用している弊社のブログ記事をSlackの自分のチャンネルに貼り付けてみましょう。

合同会社タコスキングダム|蛸壺の技術ブログ

通常のウェブサイトであれば、上の図のyahooニュースの記事から見て取れるように、slackのbotがURLのOGP情報を取得してプレビュー画面を作成してれますが、S3+CloudFrontページでは表示されません。

これはブラウザとは違い、各SNSサービスが開発しているbotやcrawlerによっても対応がまちまちですが、Webサーバーからレスポンスとして返されるときにOGPタグがどう処理か、という話に行き着きます。

もっとも重要なgoogleクローラーはどうやらS3+CloudFrontページを上手くインデックスしてくれているようですが、他の大多数のbotやcrawlerが生のindex.htmlを読み込んで正しく表示してくれるとは限りません。

Slackでは専用のOGPグラフのデバッグツールがないので、twitterかfacebookの提供しているデバッグツールを利用します。

まずは
facebookのシェアリングデバッガーを試してみますと、

合同会社タコスキングダム|蛸壺の技術ブログ

その結果、
Curlエラー:61(BAD_CONTENT_ENCODING)でコケているようです。

どうもこれはfacebookのbotはCurlベースのプログラムで動作しており、CloudFrontから速度向上のために生のgzip/brotliファイルを配信していることでこのbotが未対応のエンコードとして読み込めないことが原因と憶測します。(AWSの設定で他にも原因あるかも知れませんが...)

数多存在するbotやcrawlerに個別に問題になっている原因を潰していくことは現状不可能ですので、Lambda@Edgeを使ってクライアント側のエージェントがブラウザか、それ以外でレスポンスの挙動を柔軟に変えれるようにすることを目指します。


Lambda@Edgeで選別〜ブラウザかクローラーか

まずLambda@Edgeの構成図は以下のようになります。

合同会社タコスキングダム|蛸壺の技術ブログ

ブラウザからのアクセスであれば通常通りそのままindex.htmlのページを返します。一方で特定のSNSのbotがアクセスしてきた場合に専用のS3リソースに誘導するようにしています。

なお今回の内容ではOGP情報を設定したJSONをS3に保存していますが、DynamoDBでOGP情報を保存しても考え方は同じです。


S3でJSONから読み込む

メタファイルをLambdaから生成するためのJSONデータをS3に保存しておきます。

データベースとして嵩張るようならばDynamoDBで構成しなおすのもアリかもしれません。

以下が最低限設定したいogp用のメタタグになります。

            
            og:title
og:image
og:description
og:url
og:type
        
またFacebookアプリIDを持っていたらfb:app_idも設定事項になっています。

このメタタグを考慮して、各ページに対応させたOGPタグのJSONファイルの構造は例えば以下のようなものが考えられます。

            
            [
    {
        "pageId": "top",
        "title": "ページタイトル",
        "author": "著者名",
        "description": "ページの説明",
        "url": "リンクURL",
        "imgsrc": "見出し画像のリンクURL先(http://以下)",
        "mimeType": "画像のMIMEタイプ(image/jpegなど)",
        "width": "画像の幅",
        "height": "画像の高さ",
        "fb": {
            "appId": "Facebookアプリ用のID"
        }
    },
    //...
]
        
ページごとに生成するOGP情報が異なるため、pageIdというプロパティからページを検索させるようにします。


Lambda@Edgeの設定

それではLambda@Edge関数を新規作成していきます。

注意点として、現在Lambda@Edgeに対応している
バージニア北部(us-east-1)リージョンでLambda関数を設定する必要があります。

合同会社タコスキングダム|蛸壺の技術ブログ

では
ogpTransformerという名前で関数を新規作成します。

合同会社タコスキングダム|蛸壺の技術ブログ

今回はLambda@EdgeからS3を読み込みでアクセスするので、Lambdaの最低限の実行ロール(
AWSLambdaBasicExecutionRole)に、AmazonS3ReadOnlyAccessポリシーを追加でアタッチします。

合同会社タコスキングダム|蛸壺の技術ブログ

またLambda@Edgeを実行する場合にロールの信頼関係にも
edgelambda.amazonaws.comが追加されているか確認しましょう。

            
            {
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": [
          "edgelambda.amazonaws.com",
          "lambda.amazonaws.com"
        ]
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
        
信頼されたエンティティにedgelambda.amazonaws.comが表示されていたらOKです。

合同会社タコスキングダム|蛸壺の技術ブログ

Lambdaハンドラの準備

Lambda@Edgeのハンドラは以下のようになります。

            
            const AWS = require('aws-sdk');
//👇botはwhitelistへ個別に登録
const bots = [
    'twitter',
    'facebook',
    'slack',
    'line'
];

//👇ターゲットのバケットリージョンに合せる必要がある
//例)バケットが大阪リージョン(ap-northeast-3)にある場合
const s3 = new AWS.S3({
    apiVersion: '2006-03-01',
    region: 'ap-northeast-3'
});

exports.handler = async (event, context, callback) => {
    const request = event.Records[0].cf.request;
    const userAgent = request.headers['user-agent'][0].value;
    let isBot = false;

    //👇登録されたBotかどうかを検索
    for (const bot of bots) {
        const rgx = new RegExp(bot,'i');
        if (rgx.test(userAgent)) {
            isBot = true;
            break;
        }
    }

    //👇アクセスURIから相対ルートを抽出(空の場合にはtop画面を表示)
    const uri = request.uri.replace(/^\//m, '').replace(/\/$/m, '') || 'top';

    //👇ボットからの画像リソースへのアクセスはスキップ
    if (/(\.jpe?g|\.png|\.gif)$/mi.test(uri)) {
        isBot = false;
    }

    if (isBot) {
        const ogpList = await getJson('[S3バケット名]', '[ogp.jsonを保存した場所(バケットキー)]');
        const ogpObj = ogpList.find(elem => elem.page_id == uri);
        const response = {
            status: '200',
            statusDescription: 'OK',
            headers: {
                'content-type': [{
                    key: 'Content-Type',
                    value: 'text/html'
                }]
            },
            body: getContent(ogpObj)
        };
        callback(null, response);
        return;
    }
    callback(null, request);
};

async function getJson(bucket, key) {
    try {
        const params = { Bucket: bucket, Key: key };
        const rawdata = await s3.getObject(params).promise();
        return JSON.parse(rawdata.Body.toString());
    } catch (err) {
        console.log(err);
        const message = `【エラー】S3バケット: ${bucket} 内のリソース: ${key} は存在しません。`;
        console.log(message);
        throw new Error(message);
    }
}

function getContent(ogpObj) {
    if (ogpObj) {
    return `<!doctype html><html lang="ja" prefix="og: http://ogp.me/ns#"><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>${ogpObj.title}</title>
    <meta content="${ogpObj.author}" name="author" />
    <meta content="${ogpObj.description}" name="description">
    <meta property="og:site_name" content="${ogpObj.title}" />
    <meta content="article" property="og:type" />
    <meta content="ja_JP" property="og:locale" />
    <meta content="${ogpObj.url}" property="og:url" />
    <meta content="${ogpObj.title}" property="og:title" />
    <meta content="${ogpObj.description}" property="og:description" />
    <meta content="https://${ogpObj.imgsrc}" property="og:image" />
    <meta content="https://${ogpObj.imgsrc}" property="og:image:secure_url" />
    <meta content="${ogpObj.mimeType}" property="og:image:type" />
    <meta content="${ogpObj.width}" property="og:image:width" />
    <meta content="${ogpObj.height}" property="og:image:height" />
    <meta content="画像のタイトル" property="og:image:alt" />
    <meta content="${ogpObj.fb.appId}" property="fb:app_id" />
    <meta content="summary_large_image" property="twitter:card" />
    <meta content="${ogpObj.title}" property="twitter:title" />
    <meta content="${ogpObj.description}" property="twitter:description" />
    <meta content="${ogpObj.imgsrc}" property="twitter:image" />
    </head><body></body></html>`;
    } else {
    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>
    </head><body></body></html>`;
    }
}
        
ソースコードは[コード]タブの左側のツリービューからindex.jsをクリックして、そのまま先ほどの貼り付けます。

Botにも正しく画像を配給する

既に上のindex.jsの中で、

            
            //...

//👇ボットからの画像へのアクセスはスキップ
if (/(\.jpe?g|\.png|\.gif)$/mi.test(uri)) {
    isBot = false;
}

//...
        
としている箇所がありますが、ウェブページ(index.html)を置いているS3バケットに画像リソースも保存して利用している場合に、この設定が非常に重要になります。

この画像かどうかの判別方法は色々と考えられますが、ここではアクセスURLパスが最後に画像の拡張子で終わっていることで判断しています。

合同会社タコスキングダム|蛸壺の技術ブログ

この仕組みがなければ、Botには空のHTMLファイルが返されるので、永遠に画像リソースへアクセスできずにOGP画像が表示されることがありません。

テスト

選択するテンプレートは今回テストするコードに合わせてRecords.cf.request.uriRecords.cf.request.request.headers['user-agent'].valueが必要になります。

            
            {
  "Records": [
    {
      "cf": {
        "config": {
          "distributionId": "EXAMPLE"
        },
        "request": {
          "uri": "/",
          "method": "GET",
          "clientIp": "1111:2222::3333:4444",
          "headers": {
            "host": [
              {
                "key": "Host",
                "value": "d123.cf.net"
              }
            ],
            "user-agent": [
              {
                "key": "User-Agent",
                "value": "slackbot"
              }
            ]
          }
        }
      }
    }
  ]
}
        
テストでバリエーションを試したい際には、uriプロパティやuser-argent.valueの値を変えて反応をみてください。

合同会社タコスキングダム|蛸壺の技術ブログ

Lambda@Edgeのデプロイ

まずはバージニア北部(us-east-1)であることを確認してから、Lambda関数を新規作成していきます。

合同会社タコスキングダム|蛸壺の技術ブログ

CloudFrontがトリガーされるイベントタイプは
ビュアーリクエストになります。

合同会社タコスキングダム|蛸壺の技術ブログ

CloudFrontのダッシュボードに移るとターゲットのCloudFrontインスタンスが
Deploying...の状態になっているのを確認し、しばらく待ちます。

合同会社タコスキングダム|蛸壺の技術ブログ

しばらく待つとデプロイが完了しています。

合同会社タコスキングダム|蛸壺の技術ブログ

Lambda@Edgeを導入した結果

ここまで設定を終えると、当初読めなかったS3+CloudFrontのブログページからでもOGP情報がなんとか読み出せるようになっているようです。

合同会社タコスキングダム|蛸壺の技術ブログ


まとめ

以上、今回はLambda@Edgeを活用したSNSのためのOGP対応を実例を示しながら説明していきました。

ウェブサイトにアクセスしてくるのはgoogleクローラだけでなく、多種多様な種類のボットが存在していますが、埋め込みの画像が読み込めなかったり、ファイルのエンコードが未対応だったりと、ボットの開発元のレベルがまちまちですので、本記事で紹介したテクニックでそこらへんのOGPメタタグを柔軟に変化させることができます。

運用する場合には対象のSNSでどう表示されるかをじっくり確かめながら、コードを改良してみましょう。


参考サイト

AmplifyでOGP対応はできない。でもLambda@edgeを使えば大丈夫!