メインコンテンツまでスキップ

「AWS」タグの記事が5件件あります

全てのタグを見る

AWS サーバーレスでブログのコメント API を自作した

· 約3分
ひかり
Main bloger

このブログにコメント機能を追加したくなり、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)にして少しコストを抑えています。

DynamoDB のテーブル設計

テーブル名は blog-comments、パーティションキーは postId、ソートキーは commentId です。

キー説明
postIdString記事の識別子(例: /blog/2026/03/20/hime
commentIdStringULID(時系列ソート可能な 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 のレート制限
CORShttps://www.hikari-dev.com のみ許可
管理 APIAPI Gateway の API キー認証(X-Api-Key ヘッダー)
スパムキーワードフィルタで自動非表示

管理エンドポイント(PATCH /comment/{id})は SAM テンプレートで ApiKeyRequired: true を設定するだけで API キー認証が有効になります。Lambda Authorizer を自前で実装する必要がなく、シンプルです。

まとめ

サーバーレス構成なのでサーバー管理も不要で、DynamoDB のオンデマンド課金により低トラフィックな個人ブログでもコストを最小限に抑えられています。

コードは SAM + TypeScript + esbuild でまとめており、sam build && sam deploy だけでデプロイできます。

AWS サーバーレスでクラウドストレージを作った

· 約4分
ひかり
Main bloger

はじめに

自分専用のファイル共有システムが欲しいと思い、AWS のサーバーレスサービスだけでファイルストレージサービスを作りました。

この記事では、設計で意識したポイントと、実際のアーキテクチャを紹介します。

何を作ったか

制作した Web システムは、Web ブラウザからファイルのアップロード・ダウンロード・フォルダ管理ができるクラウドストレージサービスです。

主な機能

  • ファイルのアップロード / ダウンロード
  • フォルダの作成・階層管理
  • 複数ファイル / フォルダの一括 ZIP ダウンロード
  • ユーザー認証(サインアップ・ログイン・パスワードリセット)
  • ユーザープロフィール管理

アーキテクチャ

以下、構成図です。

認証の大部分は Cognito で行い、 ファイル転送は S3 の Presigned URL を Lambda で発行してクライアントと S3 が直接やり取りする仕組みです。

技術スタック

レイヤー技術
バックエンドC# (.NET 8) / AWS Lambda
認証Amazon Cognito + Managed Login v2
APIAPI Gateway (REST) + Cognito Authorizer
ストレージAmazon S3

設計判断とその理由

認証を Cognito で行う

Cognito の OAuth 2.0 エンドポイントと Managed Login を活用し、認証機能を実現しました。

最終的に認証系の Lambda は TokenFunction 1 つだけになりました。

機能的にもセキュリティ的にも減らせるコードは減らすのが吉です。

AWS のサービスがやってくれることを自前で書く必要はありません。

Presigned URL によるファイル転送

ファイルのアップロード・ダウンロードで Lambda を経由すると、いくつかの問題が生じます:

  • Lambda のペイロード上限に引っかかる
  • 大きなファイルを Lambda のメモリに載せるとコストがかかる
  • 転送時間が Lambda の実行時間としてカウントされる

Presigned URL なら、Lambda は URL を発行するだけで、実際のファイル転送はブラウザと S3 が直接行います。

Lambda の実行時間は数十ミリ秒で済み、ファイルサイズの制約も S3 の上限までとなります。

アップロードの流れ:
1. ブラウザ → Lambda: 「file.pdf をアップロードしたい!アップロード先の URL を送れ~」
2. Lambda → ブラウザ: 「アップロード先の Presigned URL だよ。ここに PUT してね~」
3. ブラウザ → S3: 「S3 に PUT するよ~」
4. ブラウザ → Lambda: 「アップロード完了したよ~」

4. フォルダの ZIP ダウンロード

S3 にはフォルダごとダウンロードする機能がありません。

複数ファイルの一括ダウンロードは、Lambda 上で ZIP を生成して一時的に S3 に置き、その Presigned URL を返す方式にしました。

一時 ZIP ファイルは S3 のライフサイクルルールで 1 日後に自動削除されるので、ゴミが溜まることはありません。

セキュリティ

対策実装
ブルートフォース防止Cognito 標準のロック機能 (5 回失敗: 15 分ロック)
API 保護Cognito Authorizer による JWT 検証
CORSAllowedOrigin を特定のドメインに限定
一時ファイル管理S3 ライフサイクルで不要なファイルを 1 日で自動削除

コスト

サーバーレス構成なので、利用がなければコストはほぼゼロです。

  • Cognito: ESSENTIALS Tier は MAU 10,000 まで無料
  • Lambda: 月 100 万リクエストまで無料
  • S3: 保存量に応じた従量課金(GB あたり約 $0.025/月)
  • API Gateway: 100 万リクエストあたり $3.50

個人利用なら月額数十円〜数百円程度に収まります。

インフラのコード化

インフラ全体を 1 つの template.yaml (AWS SAM) で定義しています。

Cognito User Pool、API Gateway、Lambda 3 関数、S3 バケット、CloudWatch アラーム、SNS — すべてのリソースを 600 行程度の YAML で定義しています。

EC2 Instance Connect が鍵なしで Windows から繋がらない

· 約1分
ひかり
Main bloger

Windows で Instance Connect へ接続できない

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
[email protected]: Permission denied (publickey,gssapi-keyex,gssapi-with-mic).

2025/06/11 時点での検証。

WSL からだとログイン可能

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 まとめ

· 約4分
ひかり
Main bloger

EC2 Instance Connect とは

EC2 Instance Connect は、AWS EC2 インスタンスへの SSH 接続を簡素化するためのサービス。

従来の SSH 接続方法では、インスタンスに公開鍵を事前に配置する必要があったが、 EC2 Instance Connect を使用すると、一時的な SSH 公開鍵をインスタンスに送信し、接続を確立する。(ただし、一部の AMI を除き Instance Connect のパッケージをインストールする必要がある)

インスタンスへの接続方法

インスタンスへの接続方法は何パターンか存在する。

① インターネットから直接 (インスタンス接続は関係ない)

インターネットから直接接続する方法は、インターネットゲートウェイを経由するか NAT ゲートウェイを経由する必要がある。また、パブリック IP アドレスが必要で閉域網に限定する場合は使えない。

ssh コマンドが使用可能なため、一番簡単である。

ssh <ユーザー名>@<パブリック IP アドレス>

② EC2 Instance Connect エンドポイントを経由した接続

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 を使用したいという場合にこの接続方法を使用するのが最も良い。

③ AWS マネジメントコンソールから Instance Connect 接続

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 のパッケージのインストールが必要である。

EC2 シリアルコンソールからの接続

シリアルコンソールを用いると直接インスタンスへ接続が可能である。 パスワードが設定されていない場合はログインすらできないので注意である。

セキュリティ設定

ネットワーク ACL (インスタンスのあるサブネット)

デフォルトですべての通信が許可されているので、 デフォルトで使用する場合は特に設定は必要ない。

最低限必要な設定を以下に示す。

インバウンドルール

インバウンドルールは、SSH (22) を許可する必要がある。

インスタンスの SSH サーバーのポート番号が 22 であるため、これを許可する。

アウトバウンドルール

アウトバウンドルールは、カスタム TCP (1024-65535) を許可する必要がある。

1024-65535 は SSH 接続時にクライアント側が使用するポート範囲である。

セキュリティグループ (インスタンス)

インバウンドルール

インバウンドルールは、SSH (22) を許可する必要がある。

この設定は必ず必要である。

アウトバウンドルール

セキュリティグループは通信を記憶 (ステートフル) しているため、通常はアウトバウンドルールの設定は不要である。

セキュリティグループ (EC2 Instance Connect エンドポイント)

インバウンドルール

ステートフルのため不要である。

アウトバウンドルール

SSH (22) を許可する必要がある。

インスタンスの 22 番ポートに対し通信するため、これを許可する。

AWS で公式 Rocky Linux イメージを使う

· 約5分
ひかり
Main bloger

AMI の選び方

公式ページから AMI を入手します。

https://rockylinux.org/ja-JP/download

インスタンスに設定するアーキテクチャー (ARM (aarch64)) を選び、 Cloud Images の AWS AMI を選択。

alt text

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

alt text

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

AMI ID で検索を掛けると、でてくる

alt text

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

所有者 = 792107900819

alt text

事前準備

  • キーペアの登録
    • あらかじめ ssh-keygen -t ed25519 コマンドを実行て公開鍵を作成し、.ssh/id_ed25519.pub をキーペアにインポートしておく
  • AWS CLI の導入
    • CLI のインストール
    • アクセスキーの設定 (aws configure)

ネットワークを建てる

NAT ゲートウェイよりも、パブリック IP アドレスのほうが安いので、Elastic IP を作成。

図にするとこんな感じで建てる。

EC2 Instance Connect エンドポイントを作成

alt text

EC2 Instance Connect エンドポイントを作成することで AWS CLI からログインできる。

インスタンスを建てる

  • ping を要求を受け入れるため ICMP (エコー要求) を許可 (セキュリティグループ)
  • SSH 接続ができるようにこれを許可する (セキュリティグループ)
  • ムンバイリージョンと、arm64 が安い
  • vCPU あたり 1.5 GiB の RAM が必要 (最低でも t4g.medium)

というわけで、以下の条件で建てた。

  • ムンバイリージョン
  • アーキテクチャ: arm64
  • AMI: RHEL 8.10 (LVM, aarch64); ami-0415efd8380284dc4
  • インスタンスタイプ: t4g.medium
  • キーペア: PC で作った公開鍵 (.ssh/id_ed25519.pub)
  • ネットワーク: パブリックサブネット (インターネットゲートウェイへのルートが定義されているルートテーブルが関連付けされている)
  • セキュリティグループ: セキュリティグループを作成 (名前はデフォルト)
    • ssh, 0.0.0.0/0
    • カスタム ICMP - IPv4 (エコー要求), 0.0.0.0/0
  • ストレージ: 1x 10GiB, gp3

接続

PC のターミナルを開き、以下を実行。

aws ec2-instance-connect ssh --private-key-file .ssh/id_ed25519 --os-user rocky --instance-id i-*****************

Instance Connect パッケージのインストール

Rocky Linux の AMI イメージには、Instance Connect パッケージがなく、マネジメントコンソールからの接続ができない。 そのため、パッケージをインストールする。

https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/ec2-instance-connect-set-up.html を参考にパッケージをダウンロード。

  • ※RHEL のパッケージを選択
  • ※OS のメジャーバージョンやアーキテクチャーが違うと正常に動作しないので注意

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

インストールが完了すると、マネコン上からアクセスできるようになる

alt text

CDK (typescript)

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;
}
}