AWS S3 から Cloudflare Pages へ移行しました
このブログを AWS S3 から Cloudflare Pages へ移行した。これまでは S3 で静的ホスティングを行い、CloudFront を通じて配信していたが、管理の簡素化とパフォーマンス向上のために Cloudflare Pages を採用することにした。
このブログを AWS S3 から Cloudflare Pages へ移行した。これまでは S3 で静的ホスティングを行い、CloudFront を通じて配信していたが、管理の簡素化とパフォーマンス向上のために Cloudflare Pages を採用することにした。
Amazon S3 Files は、S3 バケットを NFS ファイルシステムとして EC2 などのコンピュートリソースに直接マウントできるサービスである。データは S3 に保持されたまま、通常のファイル操作 (ls、cp、cat など) で読み書きできる。
AWS SAM で構築したファイルストレージ API のユーザー認証に Amazon Cognito を使っている。先日、パスキー (WebAuthn) によるログインを追加したので、設定内容をまとめる。
Claude を API 経由で使う場合、Anthropic API 直接のほかに AWS Bedrock、Google Vertex AI、Microsoft Azure (Azure AI Foundry) 経由でも利用できる。基本料金はどの経路もほぼ同額だが、バッチ処理やクラウドエコシステムとの統合面で差がある。
このブログにコメント機能を追加したくなり、AWS サーバーレスで API を自作しました。その設計と実装について紹介します。
リクエストは以下の流れで処理されます。
ブラウザ (www.hikari-dev.com)
↓ HTTPS
API Gateway
├── GET /comment?postId=... → コメント取得
├── POST /comment → コメント投稿
└── PATCH /comment/{id} → 管理(非表示切り替え)
↓
Lambda (Node.js 20 / arm64)
↓
DynamoDB(コメント保存)
+ SES v2(管理者へのメール通知)
コードは TypeScript で書き、SAM(Serverless Application Model)で IaC 管理しています。Lambda は arm64(Graviton2)にして少しコストを抑えています。
テーブル名は blog-comments、パーティションキーは postId、ソートキーは commentId です。
| キー | 型 | 説明 |
|---|---|---|
postId | String | 記事の識別子(例: /blog/2026/03/20/hime) |
commentId | String | ULID(時系列ソート可能な ID) |
ソートキーに ULID を使っているので、QueryCommand で取得したコメントは投稿順に自動的に並びます。UUID にしなかったのはこの理由です。
コメントを DynamoDB に書き込む前に、keywords.json に定義したキーワードと照合します。
キーワードにヒットした場合は isHidden: true で自動非表示にし、isFlagged: "1" を付与します。ヒットしなければ即時公開です。
isFlagged は Sparse GSI のキーとして使っています。ヒットしないコメントにはこの属性を持たせないため、GSI に余計なパーティションが増えず、コストと効率の両面で有利です。DynamoDB Document Client の removeUndefinedValues: true を設定するだけで実現できます。
コメントが投稿されるたびに SES v2 で自分宛にメールが届きます。本文には投稿者名、本文、評価、IP アドレス、フラグ状態が含まれます。
メール送信は非同期で行い、失敗しても握りつぶします。コメント投稿のレスポンス速度に影響しないようにするためです。
DynamoDB には IP アドレスや User-Agent も保存しますが、GET エンドポイントのレスポンスには含めません。型定義レベルで分離しています。
| 層 | 対策 |
|---|---|
| ネットワーク | AWS WAF で 100 req / 5 分 / IP のレート制限 |
| CORS | https://www.hikari-dev.com のみ許可 |
| 管理 API | API Gateway の API キー認証(X-Api-Key ヘッダー) |
| スパム | キーワードフィルタで自動非表示 |
管理エンドポイント(PATCH /comment/{id})は SAM テンプレートで ApiKeyRequired: true を設定するだけで API キー認証が有効になります。Lambda Authorizer を自前で実装する必要がなく、シンプルです。
サーバーレス構成なのでサーバー管理も不要で、DynamoDB のオンデマンド課金により低トラフィックな個人ブログでもコストを最小限に抑えられています。
コードは SAM + TypeScript + esbuild でまとめており、sam build && sam deploy だけでデプロイできます。
自分専用のファイル共有システムが欲しいと思い、AWS のサーバーレスサービスだけでファイルストレージサービスを作りました。
この記事では、設計で意識したポイントと、実際のアーキテクチャを紹介します。
制作した Web システムは、Web ブラウザからファイルのアップロード・ダウンロード・フォルダ管理ができるクラウドストレージサービスです。
以下、構成図です。

認証の大部分は Cognito で行い、 ファイル転送は S3 の Presigned URL を Lambda で発行してクライアントと S3 が直接やり取りする仕組みです。
| レイヤー | 技術 |
|---|---|
| バックエンド | C# (.NET 8) / AWS Lambda |
| 認証 | Amazon Cognito + Managed Login v2 |
| API | API Gateway (REST) + Cognito Authorizer |
| ストレージ | Amazon S3 |
Cognito の OAuth 2.0 エンドポイントと Managed Login を活用し、認証機能を実現しました。
最終的に認証系の Lambda は TokenFunction 1 つだけになりました。
機能的にもセキュリティ的にも減らせるコードは減らすのが吉です。
AWS のサービスがやってくれることを自前で書く必要はありません。
ファイルのアップロード・ダウンロードで Lambda を経由すると、いくつかの問題が生じます:
Presigned URL なら、Lambda は URL を発行するだけで、実際のファイル転送はブラウザと S3 が直接行います。
Lambda の実行時間は数十ミリ秒で済み、ファイルサイズの制約も S3 の上限までとなります。
アップロードの流れ:
1. ブラウザ → Lambda: 「file.pdf をアップロードしたい!アップロード先の URL を送れ~」
2. Lambda → ブラウザ: 「アップロード先の Presigned URL だよ。ここに PUT してね~」
3. ブラウザ → S3: 「S3 に PUT するよ~」
4. ブラウザ → Lambda: 「アップロード完了したよ~」
S3 にはフォルダごとダウンロードする機能がありません。
複数ファイルの一括ダウンロードは、Lambda 上で ZIP を生成して一時的に S3 に置き、その Presigned URL を返す方式にしました。
一時 ZIP ファイルは S3 のライフサイクルルールで 1 日後に自動削除されるので、ゴミが溜まることはありません。
| 対策 | 実装 |
|---|---|
| ブルートフォース防止 | Cognito 標準のロック機能 (5 回失敗: 15 分ロック) |
| API 保護 | Cognito Authorizer による JWT 検証 |
| CORS | AllowedOrigin を特定のドメインに限定 |
| 一時ファイル管理 | S3 ライフサイクルで不要なファイルを 1 日で自動削除 |
サーバーレス構成なので、利用がなければコストはほぼゼロです。
個人利用なら月額数十円〜数百円程度に収まります。
インフラ全体を 1 つの template.yaml (AWS SAM) で定義しています。
Cognito User Pool、API Gateway、Lambda 3 関数、S3 バケット、CloudWatch アラーム、SNS — すべてのリソースを 600 行程度の YAML で定義しています。
PS C:\> aws ec2-instance-connect ssh --instance-id i-0aa38de21acf2aa1c --region ap-south-1
Bad permissions. Try removing permissions for user: \\OWNER RIGHTS (S-1-3-4) on file C:/Users/hikari/AppData/Local/Temp/tmpm9m1bf7j/private-key.
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: UNPROTECTED PRIVATE KEY FILE! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
Permissions for 'C:\\Users\\hikari\\AppData\\Local\\Temp\\tmpm9m1bf7j\\private-key' are too open.
It is required that your private key files are NOT accessible by others.
This private key will be ignored.
Load key "C:\\Users\\hikari\\AppData\\Local\\Temp\\tmpm9m1bf7j\\private-key": bad permissions
ec2-user@192.168.0.4: Permission denied (publickey,gssapi-keyex,gssapi-with-mic).
2025/06/11 時点での検証。
PS C:\> wsl -- aws ec2-instance-connect ssh --instance-id i-0aa38de21acf2aa1c --region ap-south-1
, #_
~\_ ####_ Amazon Linux 2023
~~ \_#####\
~~ \###|
~~ \#/ ___ https://aws.amazon.com/linux/amazon-linux-2023
~~ V~' '->
~~~ /
~~._. _/
_/ _/
_/m/'
Last login: Tue Jun 10 22:50:33 2025 from 192.168.0.183
[ec2-user@ip-192-168-0-4 ~]$
なぜ?
ダウングレードすれば接続できた。
治してほしいな。
参考: https://github.com/aws/aws-cli/issues/9114
msiexec.exe /i https://awscli.amazonaws.com/AWSCLIV2-2.17.35.msi
EC2 Instance Connect は、AWS EC2 インスタンスへの SSH 接続を簡素化するためのサービス。
従来の SSH 接続方法では、インスタンスに公開鍵を事前に配置する必要があったが、 EC2 Instance Connect を使用すると、一時的な SSH 公開鍵をインスタンスに送信し、接続を確立する。(ただし、一部の AMI を除き Instance Connect のパッケージをインストールする必要がある)
インスタンスへの接続方法は何パターンか存在する。

インターネットから直接接続する方法は、インターネットゲートウェイを経由するか NAT ゲートウェイを経由する必要がある。また、パブリック IP アドレスが必要で閉域網に限定する場合は使えない。
ssh コマンドが使用可能なため、一番簡単である。
ssh <ユーザー名>@<パブリック IP アドレス>
AWS CLI を使用して、EC2 Instance Connect エンドポイントを使用して接続すれば、 パブリック IP アドレスは不要である。
また、その分の料金 (月数百円) を節約することができる。
AWS CLI を用いて、以下のようなコマンドで接続できるが、あらかじめキーペアをインポートして置き、インスタンスにキーペアを設定する必要がある。
具体的な接続方法は、例えば以下のコマンドから可能である。
aws ec2-instance-connect ssh --private-key-file .ssh/id_ed25519 --os-user <ユーザー名> --instance-id <インスタンス ID> --connection-type eice
※ただし、あらかじめアクセスキーを取得しておき、aws configure で設定しておく。
インターネットに接続したくないかつ公式ではない AMI を使用したいという場合にこの接続方法を使用するのが最も良い。
Amazon Linux や Ubuntu は Instance Connect エンドポイントを作成しておけば、 マネジメントコンソール上からインスタンスに接続が可能である。
ただし、一部の AMI を除いて、Instance Connect のパッケージをインストールする必要がある。
詳しくは、https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/ec2-instance-connect-set-up.html
セッションマネージャー用にエンドポイントを 2 つ設置、加えてインスタンスにセッションマネージャーからの接続を許可する IAM ロールをアタッチする必要がある。
また、一部の AMI を除いて、Session Manager のパッケージのインストールが必要である。
シリアルコンソールを用いると直接インスタンスへ接続が可能である。 パスワードが設定されていない場合はログインすらできないので注意である。
デフォルトですべての通信が許可されているので、 デフォルトで使用する場合は特に設定は必要ない。
最低限必要な設定を以下に示す。
インバウンドルールは、SSH (22) を許可する必要がある。
インスタンスの SSH サーバーのポート番号が 22 であるため、これを許可する。
アウトバウンドルールは、カスタム TCP (1024-65535) を許可する必要がある。
1024-65535 は SSH 接続時にクライアント側が使用するポート範囲である。
インバウンドルールは、SSH (22) を許可する必要がある。
この設定は必ず必要である。
セキュリティグループは通信を記憶 (ステートフル) しているため、通常はアウトバウンドルールの設定は不要である。
ステートフルのため不要である。
SSH (22) を許可する必要がある。
インスタンスの 22 番ポートに対し通信するため、これを許可する。
公式ページから AMI を入手します。
https://rockylinux.org/ja-JP/download
インスタンスに設定するアーキテクチャー (ARM (aarch64)) を選び、 Cloud Images の AWS AMI を選択。

バージョン番号でフィルターを掛け、条件にあったものを探す。

AMI ID はコピーできないので、デプロイボタンをクリックし、AWS コンソールからコピーする。
AMI ID で検索を掛けると、でてくる

所有者でフィルタリングしたほうが良いかも。
所有者 = 792107900819

ssh-keygen -t ed25519 コマンドを実行て公開鍵を作成し、.ssh/id_ed25519.pub をキーペアにインポートしておくNAT ゲートウェイよりも、パブリック IP アドレスのほうが安いので、Elastic IP を作成。
図にするとこんな感じで建てる。


EC2 Instance Connect エンドポイントを作成することで AWS CLI からログインできる。
というわけで、以下の条件で建てた。
PC のターミナルを開き、以下を実行。
aws ec2-instance-connect ssh --private-key-file .ssh/id_ed25519 --os-user rocky --instance-id i-*****************
Rocky Linux の AMI イメージには、Instance Connect パッケージがなく、マネジメントコンソールからの接続ができない。 そのため、パッケージをインストールする。
https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/ec2-instance-connect-set-up.html を参考にパッケージをダウンロード。
curl https://amazon-ec2-instance-connect-us-west-2.s3.us-west-2.amazonaws.com/latest/linux_arm64/ec2-instance-connect.rhel8.rpm -o /tmp/ec2-instance-connect.rpm
curl https://amazon-ec2-instance-connect-us-west-2.s3.us-west-2.amazonaws.com/latest/linux_amd64/ec2-instance-connect-selinux.noarch.rpm -o /tmp/ec2-instance-connect-selinux.rpm
sudo dnf install -y /tmp/ec2-instance-connect.rpm /tmp/ec2-instance-connect-selinux.rpm
インストールが完了すると、マネコン上からアクセスできるようになる

CDK 作ったので載せておく。参考までに。
keyName (キーペア) の名前は変えておく。
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
export interface RockyLinuxStackProps extends cdk.StackProps {
}
export class RockyLinuxStack extends cdk.Stack {
public constructor(scope: cdk.App, id: string, props: RockyLinuxStackProps = {}) {
super(scope, id, props);
// Resources
const ec2dhcpOptions = new ec2.CfnDHCPOptions(this, 'EC2DHCPOptions', {
domainName: 'ap-south-1.compute.internal',
domainNameServers: [
'AmazonProvidedDNS',
],
],
});
ec2dhcpOptions.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;
const ec2InternetGateway = new ec2.CfnInternetGateway(this, 'EC2InternetGateway', {
{
value: 'igw',
key: 'Name',
},
],
});
ec2InternetGateway.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;
const ec2vpc = new ec2.CfnVPC(this, 'EC2VPC', {
cidrBlock: '10.0.0.0/16',
enableDnsSupport: true,
instanceTenancy: 'default',
enableDnsHostnames: true,
{
value: 'vpc',
key: 'Name',
},
],
});
ec2vpc.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;
const ec2VPCGatewayAttachment = new ec2.CfnVPCGatewayAttachment(this, 'EC2VPCGatewayAttachment', {
vpcId: ec2vpc.ref,
internetGatewayId: ec2InternetGateway.ref,
});
ec2VPCGatewayAttachment.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;
const ec2NetworkAcl = new ec2.CfnNetworkAcl(this, 'EC2NetworkAcl', {
vpcId: ec2vpc.ref,
],
});
ec2NetworkAcl.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;
const ec2RouteTable = new ec2.CfnRouteTable(this, 'EC2RouteTable', {
vpcId: ec2vpc.ref,
});
ec2RouteTable.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;
const ec2SecurityGroup = new ec2.CfnSecurityGroup(this, 'EC2SecurityGroup', {
groupDescription: 'launch-wizard-1 created 2025-04-27T00:11:58.641Z',
groupName: 'launch-wizard-1',
vpcId: ec2vpc.ref,
securityGroupIngress: [
{
cidrIp: '0.0.0.0/0',
ipProtocol: 'tcp',
fromPort: 22,
toPort: 22,
},
{
cidrIp: '0.0.0.0/0',
ipProtocol: 'icmp',
fromPort: 8,
toPort: -1,
},
],
securityGroupEgress: [
{
cidrIp: '0.0.0.0/0',
ipProtocol: '-1',
fromPort: -1,
toPort: -1,
},
],
});
ec2SecurityGroup.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;
const ec2Subnet = new ec2.CfnSubnet(this, 'EC2Subnet', {
vpcId: ec2vpc.ref,
mapPublicIpOnLaunch: false,
enableDns64: false,
availabilityZoneId: 'aps1-az1',
privateDnsNameOptionsOnLaunch: {
EnableResourceNameDnsARecord: false,
HostnameType: 'ip-name',
EnableResourceNameDnsAAAARecord: false,
},
cidrBlock: '10.0.0.0/20',
ipv6Native: false,
{
value: 'subnet-public1-ap-south-1a',
key: 'Name',
},
],
});
ec2Subnet.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;
const ec2InstanceConnectEndpoint = new ec2.CfnInstanceConnectEndpoint(this, 'EC2InstanceConnectEndpoint', {
preserveClientIp: false,
securityGroupIds: [
ec2SecurityGroup.attrGroupId,
],
subnetId: ec2Subnet.attrSubnetId,
});
ec2InstanceConnectEndpoint.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;
const ec2vpcdhcpOptionsAssociation = new ec2.CfnVPCDHCPOptionsAssociation(this, 'EC2VPCDHCPOptionsAssociation', {
vpcId: ec2vpc.ref,
dhcpOptionsId: ec2dhcpOptions.ref,
});
ec2vpcdhcpOptionsAssociation.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;
const ec2RouteHg = new ec2.CfnRoute(this, 'EC2RouteHG', {
routeTableId: ec2RouteTable.ref,
destinationCidrBlock: '0.0.0.0/0',
gatewayId: ec2InternetGateway.ref,
});
ec2RouteHg.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;
const ec2SubnetNetworkAclAssociation = new ec2.CfnSubnetNetworkAclAssociation(this, 'EC2SubnetNetworkAclAssociation', {
networkAclId: ec2NetworkAcl.ref,
subnetId: ec2Subnet.ref,
});
ec2SubnetNetworkAclAssociation.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;
const ec2SubnetRouteTableAssociation = new ec2.CfnSubnetRouteTableAssociation(this, 'EC2SubnetRouteTableAssociation', {
routeTableId: ec2RouteTable.ref,
subnetId: ec2Subnet.ref,
});
ec2SubnetRouteTableAssociation.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;
const ec2Instance = new ec2.CfnInstance(this, 'EC2Instance', {
tenancy: 'default',
instanceInitiatedShutdownBehavior: 'stop',
cpuOptions: {
threadsPerCore: 1,
coreCount: 2,
},
blockDeviceMappings: [
{
ebs: {
volumeType: 'gp3',
iops: 3000,
volumeSize: 10,
encrypted: false,
deleteOnTermination: true,
},
deviceName: '/dev/sda1',
},
],
availabilityZone: 'ap-south-1a',
privateDnsNameOptions: {
enableResourceNameDnsARecord: false,
hostnameType: 'ip-name',
enableResourceNameDnsAaaaRecord: false,
},
ebsOptimized: true,
disableApiTermination: false,
keyName: 'hikari',
sourceDestCheck: true,
placementGroupName: '',
networkInterfaces: [
{
privateIpAddresses: [
{
privateIpAddress: '10.0.3.59',
primary: true,
},
],
secondaryPrivateIpAddressCount: 0,
deviceIndex: '0',
groupSet: [
ec2SecurityGroup.ref,
],
ipv6Addresses: [
],
subnetId: ec2Subnet.ref,
associatePublicIpAddress: true,
deleteOnTermination: true,
},
],
imageId: 'ami-0415efd8380284dc4',
instanceType: 't4g.medium',
monitoring: false,
],
creditSpecification: {
cpuCredits: 'unlimited',
},
});
ec2Instance.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;
const ec2ElasticIp = new ec2.CfnEIP(this, 'EC2ElasticIp', {
domain: 'vpc',
{
key: 'Name',
value: 'elastic-ip',
},
],
});
ec2ElasticIp.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;
const ec2EipAssociation = new ec2.CfnEIPAssociation(this, 'EC2EipAssociation', {
eip: ec2ElasticIp.ref,
instanceId: ec2Instance.ref,
});
ec2EipAssociation.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.DELETE;
}
}