目次全5項目
前回の記事の続きとして、ブログをWordPressからAstroに移し、S3とCloudFrontで配信するときの作り方をまとめます。最低限やらなければいけないことは2つあります。URLを維持することと、コメントを受け付けられるようにすることです。
URLは、設定さえすれば記事が同じURLで開けると思っていました。ところが、やらなければいけないことはほかにもありました。旧サイトのURLは、サイトマップの300件に対して全部で2,271件あり、種類ごとに、同じURLで開くか、リダイレクトするか、廃止するかを決める必要がありました。記事のファイルを同じURLになるように置いても、S3に置いただけでは開けませんでした。
01 / 05サイトマップにないURLを洗い出す#
URLを維持するには、まず旧サイトにどんなURLがあるかを調べます。WordPressのURLは、サイトマップに載っているものだけではありません。このブログでは、全部で2,271件ありました。種類ごとの件数と新しいサイトでの扱いは、前回の記事の表のとおりです。ここでは、サイトマップにないURLの種類と、その見つけ方をまとめます。
| 種類 | 例 | 見つけ方 |
|---|---|---|
| 記事IDのURL | /?p=100 |
記事の一覧(REST API)から組み立てる |
| 記事やタグのフィード | /blog/tag/10gbase-t/feed/ |
記事とタグのURLにfeed/を足す |
| 日付のアーカイブ | /blog/2005/12/31/ |
記事の公開日から組み立てる |
| 一覧の2ページ目以降 | /blog/category/custom-built-house/page/2/ |
各ページのrel="next"をたどる |
| 末尾に「/」がないURL | /about-this-site |
記事の一覧から組み立てる。アクセスログにも残る |
| 添付ファイルのページ | /blog/2010/01/12/rx-8-obd2-preparation/sony-dsc/ |
アクセスログ |
| 以前の形のURL | /2005/12/31/スラッグ、/category/programming/can/ |
アクセスログ |
| 自分で設定したリダイレクト | /2006/12/000432.html(Movable Typeの時代のURL) |
Redirectionプラグインの設定 |
| 途中で切れたURL(WordPressが近い記事を推測してリダイレクトしていたもの) | /blog/2023/02/10/selection-of-water-he |
アクセスログ |
アクセスログには、スキャナ(公開サーバの弱点を自動で探して回るプログラム)のアクセスも多く残っています。そこで、WordPressのURLの形に近いものだけを一覧に入れました。Search Consoleに出ていたURLも足しました。
一覧ができたら、旧サイトがそれぞれのURLに何を返していたかも記録しました。ページを返していたのか、どこかへリダイレクトしていたのかが分かれば、新しいサイトでの扱いを決められるためです。記録するときは、リダイレクトをたどらないようにしました。たどると、行き着いたページの200(OK)しか残らず、301(リダイレクト)だったことも行き先も分かりません。行き先のURLも一覧に足して記録したので、何段のリダイレクトでも最後の行き先まで分かります。
02 / 05非公開のS3には、Webサーバの機能がない#
記事を/blog/…/index.htmlとして置いても、/blog/…/にアクセスすると開けません。ApacheやnginxのようなWebサーバならindex.htmlを返しますが、CloudFrontからだけ読ませる非公開のS3は返さないためです。Webサーバが当たり前にしていて、WordPressのときは意識しなかったことが、ほかにもS3にはありません。違いと、このサイトの作り方は次のとおりです。
| Webサーバがしていること | 非公開のS3 | このサイトの作り方 |
|---|---|---|
/…/でindex.htmlを返す |
返さない | CloudFront Functionsで/…/index.htmlに読み替える |
/…を/…/へリダイレクトする |
しない | CloudFront Functionsで「/」を足してリダイレクトする |
| 設定したリダイレクトを返す | 返せない | CloudFront FunctionsとKeyValueStoreの対応表 |
| ないページに404のページを返す | XMLのエラーを返す | CloudFrontのエラーページの設定で、ビルドした404.htmlを返す |
| 応答を圧縮する | しない | CloudFrontの圧縮の設定 |
| 拡張子からContent-Typeを付ける | 置いたときの値を返す | aws s3 syncが拡張子から付ける。フィードは拡張子からだとapplication/xmlになるので、application/rss+xmlを指定する |
| 設定からCache-Controlを付ける | 置いたときの値を返す | aws s3 syncでファイルの種類ごとに指定する |
CloudFrontにS3の一覧を読む権限(s3:ListBucket)を与えないと、S3はないファイルに403を返し、CloudFrontのエラーページの設定が効きません。権限を与えると404を返すので、ビルドした404.htmlに差し替えられます。
S3にも静的ウェブサイトの機能があり、index.htmlの補完やリダイレクトはできます。ただ、公開のバケット(S3のファイルの置き場所)でしか使えず、HTTPSにも対応していません(ウェブサイトエンドポイント)。このサイトはS3を外から直接読めないようにしたかったので、この機能は使いませんでした。
03 / 05CloudFront FunctionsとKeyValueStoreでリダイレクトする#
リダイレクトは、CloudFrontの上でリクエストごとに動くCloudFront Functionsで返すようにしました。行き先は、対応表(リダイレクトするURLと行き先の組)で決めます。対応表の元は、最初の章で記録した旧サイトの応答です。旧サイトがリダイレクトしていたURLは、記録した最後の行き先へリダイレクトするようにしました。何段かリダイレクトしていたものも、1回で済みます。
対応表は、1,652件、177KBあります。関数のコードの上限は10KBなので、対応表をコードに書き込むことはできません。代わりに、対応表をCloudFront KeyValueStore(CloudFront Functionsから読める、キーと値の置き場所)に置き、関数から参照するようにしました。
関数で対応表を引く#
関数は、対応表にあるURLへのアクセスをリダイレクトします。対応表にないURLは、拡張子も「/」もなければ「/」を足してリダイレクトし、それ以外は/…/を/…/index.htmlに(フィードの/…/feed/はindex.xmlに)読み替えてからS3に渡します。コードは次のとおりです(hiroaki.jpからのリダイレクトと、/?page_id=の扱いは省略)。
import cf from 'cloudfront';
const kvs = cf.kvs();
function redirect(location) {
return { statusCode: 301, statusDescription: 'Moved Permanently', headers: { location: { value: location } } };
}
function decode(s) {
try {
return decodeURIComponent(s);
} catch (e) {
return null;
}
}
function query(qs, skip) {
const parts = [];
Object.keys(qs).forEach((k) => {
if (k === skip) return;
const values = qs[k].multiValue ? qs[k].multiValue.map((v) => v.value) : [qs[k].value];
values.forEach((v) => parts.push(k + '=' + v));
});
return parts.length ? '?' + parts.join('&') : '';
}
async function handler(event) {
const request = event.request;
const host = request.headers.host.value;
const uri = request.uri;
const qs = request.querystring;
const path = decode(uri);
if (path === null) {
request.uri = uri.replace(/%/g, '%25');
return request;
}
let key = path;
let used;
if (uri === '/' && qs.p) {
key += '?p=' + qs.p.value;
used = 'p';
}
let to;
try {
to = await kvs.get(key);
} catch (e) {
to = null;
}
if (to) return redirect('https://' + host + to + query(qs, used));
if (!path.endsWith('/') && !path.slice(path.lastIndexOf('/')).includes('.')) return redirect('https://' + host + encodeURI(path) + '/' + query(qs));
if (uri.endsWith('/feed/')) request.uri = uri + 'index.xml';
else if (uri.endsWith('/')) request.uri = uri + 'index.html';
return request;
}関数を作るときに気をつけたのは、次の4つです。
- 「/」を足すリダイレクトは、対応表に入れず、拡張子も「/」もないURLという規則にしました。ページごとに対応表へ入れると、記事を足すたびに対応表が変わり、公開のときの処理が増えるためです。ないページは、リダイレクトのあとで404になります
- 対応表のキーは、%エンコードを戻したパスにしました。%エンコードは、%E3と%e3のように、同じURLでも書き方が1つに決まらないためです。戻した形をキーにすると、どの書き方で届いても同じキーで引けます。記事IDのURL(/?p=123)だけは、クエリで記事が決まるので、クエリもキーに含めます
kvs.getは、キーがないと例外を投げます(キーバリューストアのヘルパーメソッド)。表にないふつうのページでは毎回例外になるので、catchで受けて、表にないものとして扱います- %エンコードを戻せないURL(/%E3%81など)は、そのまま渡すと、S3が400のエラーをXMLで返し、読者にはそのXMLがそのまま見えます。そこで、
%を%25に置き換えて、%を文字として渡すようにしました。S3はそのファイルを持っていないので404を返し、ビルドした404のページが出ます
リダイレクトでは、届いたクエリ(utm_sourceなど)を、旧サイトと同じく行き先にもそのまま付けるようにしました。
対応表があると、関数の試験もできます。手元で関数のコードをNode.jsで動かし、KeyValueStoreの代わりに対応表を、S3の代わりにビルドしたファイルを渡して、旧URLの一覧のURLごとに、記録した応答と合っているかを確かめるようにしました。プルリクエストと公開のたびに動くので、関数を直してURLを壊すと、公開の前に止まります。Node.jsでは動いてもCloudFront Functionsでは動かない書き方があるので、関数を更新したあとは、CloudFrontの試験の機能でも確かめています。
公開のたびに、対応表の変わったところだけを書き込む#
対応表が変わるのは、URLの扱いを変えたときです。そこで、公開の処理の中でKeyValueStoreに書き込むようにしました。全件を書き直すのではなく、ビルドから作った対応表とKeyValueStoreの中身を比べ、違うキーだけを書き換えます。
ただ、中身を比べるには全件を読む必要があり、KeyValueStoreを関数の外からAPIで読み書きすると費用がかかります(1,000回の呼び出しで1ドル。関数から読むときとは別の料金です)。そこで、表全体のハッシュを、表の中の1つのキーに入れておくようにしました。公開のときは、まずこのキーだけを読み、ビルドから作った表のハッシュと比べます。同じなら表は変わっていないので、全件は読みません。記事を足しただけの公開も含めて、呼び出しは1回で終わります。全件を読むときは、1回に返す件数を、標準の10件から上限の50件にしました。
04 / 05コメントは、Lambdaで受け付けてプルリクエストにする#
WordPressではPHPとデータベースがコメントを受け取っていましたが、静的サイトにはどちらもありません。そこで、コメントは、CloudFrontの後ろに置いたLambdaの関数URL(Lambdaの関数に直接HTTPSのアドレスを付ける機能)で受け付けるようにしました。Lambdaは、コメントを1件1ファイル(YAML)にして、記事のリポジトリにプルリクエストを開きます。私がそれをマージすると、記事を公開するときと同じ手順でサイトがビルドされ、コメントが記事に載ります。載せないときは閉じます。
関数URLをCloudFrontの後ろに置く#
受け付けの窓口には、Lambdaの関数URLを使い、CloudFrontの後ろに置きました。CloudFrontの後ろに置くと、サイトと同じドメインになるので、CORSの設定が要りません。API Gatewayでなく関数URLにしたのは、組み合わせるサービスが少なく済むためです。関数URLのアドレスに直接送られても受け付けないように、関数URLの認証は「AWS_IAM」にして、CloudFrontがOAC(オリジンアクセスコントロール)で署名したリクエストだけを受け付けるようにしました。
CloudFrontでPOSTを通すには、GETやDELETEなどを含む7つのメソッドをまとめて許すしかありません(許可するHTTPメソッドの設定)。関係のないリクエストでLambdaを動かさないように、関数URLに渡す前に、CloudFront Functionsで、形の合ったPOSTだけを通すようにしました。
function deny(code, text) {
return { statusCode: code, statusDescription: text, headers: { 'cache-control': { value: 'no-store' } } };
}
function handler(event) {
var r = event.request;
var h = r.headers;
if (r.uri !== '/api/comments') return deny(404, 'Not Found');
if (r.method !== 'POST') return deny(405, 'Method Not Allowed');
if (!h['x-amz-content-sha256'] || !/^[0-9a-f]{64}$/.test(h['x-amz-content-sha256'].value)) return deny(400, 'Bad Request');
if (!h['content-type'] || h['content-type'].value.indexOf('application/json') !== 0) return deny(415, 'Unsupported Media Type');
if (h.origin && h.origin.value !== 'https://blog.hiroaki.jp') return deny(403, 'Forbidden');
return r;
}CloudFrontから関数URLにPOSTを渡すときの注意は2つです。
- CloudFrontがOACで署名できるように、送る側が本文のSHA-256を計算し、
x-amz-content-sha256ヘッダーに入れる必要があります。ハッシュのない本文は受け付けられません(CloudFrontの手引き)。ブラウザではWeb Crypto APIで計算できます - Lambdaの関数に、CloudFrontからの呼び出しを許す権限は、2025年10月から
lambda:InvokeFunctionUrlとlambda:InvokeFunctionの2つが要ります
GitHub Appで、1時間だけ使えるトークンをもらう#
GitHubへの書き込みには、個人用のトークンではなくGitHub Appを使うことにしました。人に紐付かずに動く仕組みには、GitHubがGitHub Appを勧めています。
Lambdaは、Appの秘密鍵で署名した短い証明(JWT)を送って、1時間だけ使えるトークンをもらいます。そのときに、対象をこのリポジトリだけに、権限をリポジトリの中身とプルリクエストの書き込みだけに絞ります。トークンが漏れても、使える範囲と時間が限られます。Appの秘密鍵はParameter Storeに置き、Lambdaが起動したときに1回だけ読むようにしました。
ただ、GitHub Freeの非公開リポジトリではブランチの保護が使えないので、このAppはmainにも直接書き込めます。トークンの範囲と時間を絞るのは、その備えでもあります。
スパムと大量の投稿は、Turnstileの検証と件数の上限で止める#
前回の記事の3つ(Turnstile、ダミーの入力欄、同時実行数を1にする設定)に加えて、次の2つをしました。
- Turnstileの判定では、ドメイン名とアクション名も確かめるようにしました。ほかのサイトやほかのフォームで取った判定を、使い回させないためです(Cloudflareの手引きが勧めています)
- 開いているコメントのプルリクエストが5件、または24時間で10件に達したら、Lambdaが受け付けを止める(429を返す)ようにしました。コメントは私が1件ずつ確かめて載せるので、確かめきれない数のプルリクエストを積もらせないためです
Turnstileのスクリプトは、読者がフォームに触れたときに初めて読み込むようにしました。ページに読み込む外のスクリプトは、アクセス解析と広告だけにすると決めているためです。
05 / 05まとめ#
URLを維持するのに必要だったのは、サイトマップにないURLまで洗い出して旧サイトの応答を記録することと、index.htmlの補完やリダイレクトなど、非公開のS3にないWebサーバの機能を用意することでした。
振り返ると、WordPressを動かしたまま、Claude Codeで振る舞いの違いを確かめながら進めたのがよかったと思います。旧サイトがそれぞれのURLに何を返すかが分かっているので、リダイレクトも、旧サイトと新しいサイトの応答を比べながら作れました。既存のブログという「お手本」があったおかげで、効率よく進められました。
ただ、任せきりでは上手くいかないところもありました。Claude Codeは、旧サイトと同じ動きを再現するのは得意ですが、その動きでよいのかは疑いません。添付ファイルのページは、旧サイトと同じく画像ファイルへリダイレクトする作りでしたが、私が気になって聞いたことで、記事へのリダイレクトに変えました。リダイレクトの関数も、手元の試験は通っていましたが、念のため別のモデルにレビューを頼むと、壊れたURLで読者にXMLのエラーが見える穴が見つかりました。記事を足すたびに対応表を全部読み直す作りも、私が聞くまでそのままでした。
それでも、2,271件のURLを抱えたブログの移行を、思い立ってから4日で終えられたのは、生成AIがあったからです。得意なことと苦手なことを見極めながら、これからも使いこなしていきます。





コメント