カテゴリー
【Lambda@Edge x ウェブサイト運用】AWS S3&CloudFrontで構築したウェブサイトでOGP対応をしてみた
※ 当ページには【広告/PR】を含む場合があります。
2021/08/05

S3から静的なindex.htmlをCloudFrontでCDN配信する場合、AWS Lambda@Edgeを使ってこれまで
これだけでプリレンダリングさせたindex.htmlをCDN配信させているだけのウェブサイトでも、本物のWebサーバーがバックエンドで稼働しているウェブサイトとほとんど同じ感覚で使えるようになってきました。
...が、やはりAWS S3+CloudFrontで構築したウェブサイトは、本来色々と裏で処理をやってくれているサーバーが実体としてあるわけではないので、様々な問題が発生するのは覚悟しないといけません。
そんな問題の一つに、
今回は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の提供しているデバッグツールを利用します。
まずは

その結果、
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)

では
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

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>`;
}
}
ソースコードは
[コード]
Botにも正しく画像を配給する
既に上のindex.jsの中で、
//...
//👇ボットからの画像へのアクセスはスキップ
if (/(\.jpe?g|\.png|\.gif)$/mi.test(uri)) {
isBot = false;
}
//...
としている箇所がありますが、ウェブページ(index.html)を置いているS3バケットに画像リソースも保存して利用している場合に、この設定が非常に重要になります。
この画像かどうかの判別方法は色々と考えられますが、ここではアクセスURLパスが最後に画像の拡張子で終わっていることで判断しています。

この仕組みがなければ、Botには空のHTMLファイルが返されるので、永遠に画像リソースへアクセスできずにOGP画像が表示されることがありません。
テスト
選択するテンプレートは今回テストするコードに合わせて
Records.cf.request.uri
Records.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でどう表示されるかをじっくり確かめながら、コードを改良してみましょう。
参考サイト
記事を書いた人
ナンデモ系エンジニア
主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。
カテゴリー