こんにちは、エンジニアのはまぐちです。
普段はサーバーサイドのエンジニアとして、Ruby on Railsをメインに開発しており、時々フロントエンド開発やモバイルアプリの開発にも携わっています。 今回は、担当プロダクトのインフラ改善の一環として実装したAWS WAF、CloudFront、Lambda@Edgeの導入について紹介します。
どんなことをしたのか?
担当しているプロダクトでは、toCクライアントアプリとtoB事業者向けWeb管理画面を提供しており、これらのバックエンドとしてAWSを使用したAPIサーバーとWebアプリケーションを運用しています。今回はセキュリティ強化とパフォーマンス向上を目的として、以下の機能を実装しました。
- セキュリティ強化のためのWAF導入:APIサーバーとWebアプリケーションを悪意のあるトラフィックから保護
- パフォーマンス向上のためのCloudFront導入:コンテンツ配信ネットワークによる高速化と負荷軽減
- エッジロケーションでの画像最適化処理の実装:Lambda@Edgeを活用したリアルタイム画像処理
グリーエックスでは、aumoやQUANTをはじめとする複数のプロダクトでAWSを採用しており、aumoでは既に一部でWAFやCloudFrontを導入しています。それぞれのサービスについてはこちらからご覧ください。
システム構成と実装
担当プロダクトでは、Terraformを使用してAWSのインフラリソースを管理しています。今回の実装では、AWSコンソールでリソースを作成・調整して動作確認後、Terraformで定義しました。以下、各サービスの構成と実装について説明します。
AWS WAFの導入
AWS WAF(Web Application Firewall)は、WebアプリケーションやAPIを悪意のあるトラフィックから保護するクラウドセキュリティサービスです。SQLインジェクション、クロスサイトスクリプティング(XSS)などの一般的な攻撃パターンや、DDoS攻撃からアプリケーションを守ることができます。
この実装では、AWSが提供するマネージドルールを活用してセキュリティ対策を実装しました。マネージドルールには、OWASP Top 10などの一般的な脆弱性対策、既知の悪意のある入力の検知、疑わしいIPアドレスからの攻撃対策が含まれており、複雑な設定をすることなく効果的なセキュリティ対策を導入できます。さらに、DDoS対策としてレートベース制限も設定し、特定のIPアドレスからの過剰なリクエストを自動的にブロックするようにしています。レートベース制限では、特定のIPアドレスが5分間に設定した回数を超えるリクエストを送信した場合に、そのIPアドレスからの後続リクエストを自動的にブロックします。これにより、ボットやスクリプトによる自動攻撃を効果的に防ぐことができます。
// AWS マネージドルール - コアルールセット (一般的な脆弱性対策)
rule {
name = "AWS-AWSManagedRulesCommonRuleSet"
priority = 0
override_action {
none {}
}
statement {
managed_rule_group_statement {
name = "AWSManagedRulesCommonRuleSet"
vendor_name = "AWS"
}
}
}
// AWS マネージドルール - 既知の悪意のある入力
rule {
name = "AWS-AWSManagedRulesKnownBadInputsRuleSet"
priority = 1
statement {
managed_rule_group_statement {
name = "AWSManagedRulesKnownBadInputsRuleSet"
vendor_name = "AWS"
}
}
}
// AWS マネージドルール - Amazon IP評判リスト
rule {
name = "AWS-AWSManagedRulesAmazonIpReputationList"
priority = 2
statement {
managed_rule_group_statement {
name = "AWSManagedRulesAmazonIpReputationList"
vendor_name = "AWS"
}
}
}
// レートベース制限 - DDoS対策
rule {
name = "RateBasedRule"
priority = 3
action {
block {}
}
statement {
rate_based_statement {
limit = 1000
aggregate_key_type = "IP"
}
}
}
CloudFrontの導入
Amazon CloudFrontは、世界各地のエッジロケーションを通じて静的・動的コンテンツを高速配信するコンテンツ配信ネットワーク(CDN)サービスです。ユーザーに最も近いエッジロケーションからコンテンツを配信することで、レイテンシ(遅延時間)を大幅に削減し、エンドユーザーの体験を向上させることができます。
この構成では、キャッシュによるレスポンス高速化とオリジンリソースへの負荷軽減を実現しています。具体的には、S3バケットに保存されている静的アセット(画像、CSS、JavaScriptファイルなど)用のCloudFrontディストリビューションと、ECSで動作するアプリケーションサーバー用のCloudFrontディストリビューションの2つの構成を構築しました。これにより、静的コンテンツと動的コンテンツの両方で高速化を実現しています。
価格クラスについては、担当プロダクトが国内向けサービスであるため、PriceClass_200を選択しています。これにより、北米、ヨーロッパ、アジア、中東、アフリカのエッジロケーションが利用でき、日本のユーザーにとって十分なパフォーマンスを確保しつつ、コストを最適化できます。
resource "aws_cloudfront_distribution" "example_asset_distribution" {
origin {
domain_name = aws_s3_bucket.assets.bucket_regional_domain_name
origin_id = local.example_asset_origin_id
origin_access_control_id = aws_cloudfront_origin_access_control.example_asset_oac.id
}
enabled = true
is_ipv6_enabled = true
comment = "Example Asset Distribution"
price_class = "PriceClass_200"
aliases = ["assets.example.com"]
web_acl_id = aws_wafv2_web_acl.example_cloudfront_acl.arn
}
Lambda@Edgeによる画像最適化とURL署名検証
Lambda@Edgeは、AWS Lambdaの拡張サービスで、CloudFrontのエッジロケーションでカスタムロジックを実行できるエッジコンピューティングサービスです。オリジンサーバーではなく、ユーザーに近いエッジロケーションで処理を実行することで、レイテンシを大幅に削減し、パフォーマンスを向上させることができます。
この構成では、エッジロケーションでの画像リサイズ処理とURL署名検証の両方を実装しています。
エッジロケーションでの画像リサイズ処理
CloudFrontのエッジロケーションで画像のリサイズを行うことで、クライアントアプリの負荷軽減を実現し、各デバイスに最適化された画像サイズを配信できます。また、クライアントのブラウザがWebP形式をサポートしている場合は、自動的にWebP形式で配信することで、さらなるファイルサイズの削減と表示速度の向上を実現しています。
一般的には画像アップロード時に複数サイズの画像を事前生成することが多いですが、今回は動的にリサイズを行う方式を採用しました。この方式により、クライアントからの多様なリサイズ要求に柔軟に対応でき、また使用されない可能性のある画像サイズを事前生成する必要がないため、ストレージ容量を効率的に活用できます。
画像のリサイズについては、AWSのブログ記事を参考に実装しました。
Resizing Images with Amazon CloudFront & Lambda@Edge
Lambda@Edgeの実装では、viewer-requestハンドラーとorigin-responseハンドラーの2つの関数を使用しています。
URL署名検証の実装
ユーザー固有の画像を扱うため、URL署名による認証も実装しています。KMSで鍵を発行し、サーバー上で署名を行い、Lambda@Edgeで検証を行う仕組みです。これにより、ユーザー固有の画像へのセキュアなアクセス制御と時限付きアクセスを実現しています。
CloudFrontには署名付きURL機能が標準で提供されていますが、今回は自前でLambda@Edge上に実装しました。これは、画像リサイズのパラメータをクライアント側で自由に設定できるようにするためです。CloudFrontの署名付きURL検証では、パラメータが追加されると検証に失敗してしまうため、動的な画像リサイズ要求に対応するには自前実装が必要でした。
URL署名は必須要件ではありませんが、本実装ではセキュリティを考慮して実装しました。署名検証はviewer-requestイベントで行い、無効な署名の場合は適切にエラーレスポンスを返すようにしています。
KMSを使用した署名検証の流れ
- サーバー側でKMSを使用してURL署名を生成
- Lambda@Edgeのviewer-requestで署名を検証
- 有効期限と署名の両方をチェック
- 無効な場合は403エラーを返却
実装詳細
viewer-requestハンドラーの実装
viewer-requestハンドラーでは、リクエストの前処理として以下の処理を行っています。
- URL署名検証:KMSを使用したセキュアな署名検証
- IPアドレスによるアクセス制御:社内IPなど特定のIPからのリクエスト時は署名検証をスキップ
- サイズパラメータの処理とパス変換:リクエストされたサイズに応じたパス生成
- WebP対応の判定:クライアントのWebPサポート状況を確認
// URL署名検証処理
async function validateSignedUrl(request, headers, params, clientIP) {
// 許可IP以外は署名検証が必須
const isAllowedIP = clientIP && isIPAllowed(clientIP);
if (!isAllowedIP) {
// 署名パラメータが不足している場合は403エラー
if (!params.signature || !params.expires) {
return createForbiddenResponse('Access Denied: Signed URL required');
}
try {
// 完全なURLを構築(署名対象のベースURL)
const host = headers.host[0].value;
const baseUrl = `https://${host}${request.uri}`;
// KMS署名検証
const isValidSignature = await verifySignedUrl(
baseUrl,
params.signature,
params.expires
);
if (!isValidSignature) {
// 署名が無効な場合は403エラーを返す
return createForbiddenResponse('Access Denied: Invalid signature');
}
} catch (error) {
console.error('Signed URL verification failed:', error);
return createServerErrorResponse();
}
}
// 署名パラメータをクエリから削除(後続処理のため)
if (params.signature || params.expires) {
delete params.signature;
delete params.expires;
request.querystring = querystring.stringify(params);
}
return null;
}
// サイズパラメータ処理とパス変換
function processImageResize(request, headers, params) {
// 元の画像URIを取得
let fwdUri = request.uri;
// サイズパターンが指定されていない場合はオリジナル画像を要求
if (!params.size) {
delete request.querystring;
return { shouldReturn: true, request };
}
// 指定されたサイズパターンが有効かチェック
const sizePattern = params.size.toLowerCase();
const width = variables.sizePatterns[sizePattern];
// 有効なサイズパターンでない場合はオリジナル画像を要求
if (!width) {
delete request.querystring;
return { shouldReturn: true, request };
}
// Acceptヘッダーを読み取り、WebPサポートを確認
let accept = headers['accept'] ? headers['accept'][0].value : '';
let format = accept.includes(variables.webpExtension) ? 'webp' : 'original';
// リサイズ画像のパスを構築
let resizedUri = '/resized';
resizedUri += `/${width}x0`; // 幅のみ指定、高さは0でアスペクト比維持
resizedUri += `/${format}`;
// `/uploads`プレフィックスを処理
if (fwdUri.startsWith('/uploads')) {
resizedUri += fwdUri.substring('/uploads'.length);
} else {
resizedUri += fwdUri;
}
// 最終的な転送先URIを設定
request.uri = resizedUri;
delete request.querystring;
return { shouldReturn: false, request };
}
origin-responseハンドラーの実装
origin-responseハンドラーでは、レスポンスの後処理として以下の処理を行っています。
- 403/404エラー時の画像リサイズ処理:リサイズ画像が存在しない場合の動的生成
- Sharp.jsを使用した画像リサイズ:高品質な画像変換処理
- リサイズ画像のS3アップロード:同じ画像の重複リサイズを避けるため
- 署名付きURLでのリダイレクト:セキュアなアクセスを継続
// 403/404のレスポンスの場合のみリサイズ処理を実行
if (response.status === '403' || response.status === '404') {
// パスを解析してサイズと元画像パスを取得
const regex = /^\/resized\/(\d+)x(\d+)(?:\/([^\/]+))?(.+)$/;
const match = uri.match(regex);
if (!match) {
callback(null, response);
return;
}
const width = parseInt(match[1], 10);
const height = parseInt(match[2], 10);
const formatPath = match[3]; // webpなどのフォーマット
const originalPath = match[4]; // オリジナル画像の完全なパス
try {
// オリジナル画像をS3から取得
const getCommand = new GetObjectCommand({
Bucket: ORIGINAL_BUCKET,
Key: originalKey
});
const originalImageResponse = await s3Client.send(getCommand);
// 画像をリサイズ
let resizedImage = Sharp(originalImage).resize({ width: width });
const buffer = await resizedImage.toBuffer();
// リサイズした画像をS3にアップロード
const putCommand = new PutObjectCommand({
Body: buffer,
Bucket: RESIZED_BUCKET,
Key: resizedKey,
ContentType: contentType,
CacheControl: 'max-age=31536000'
});
await s3Client.send(putCommand);
// 成功したら301リダイレクトを返す
const baseUrl = `https://${CLOUDFRONT_DOMAIN}${uri}`;
const signedUrl = await signUrl(baseUrl, KMS_KEY_ALIAS);
response.status = '301';
response.statusDescription = 'Moved Permanently';
response.headers['location'] = [{ key: 'Location', value: signedUrl }];
response.headers['cache-control'] = [{ key: 'Cache-Control', value: 'max-age=31536000' }];
callback(null, response);
} catch (error) {
console.error('Error processing image:', error);
// エラー処理...
}
}
Lambda@Edgeを使用する際は、以下の制約があります。
- リージョン制限:Lambda@Edge関数は必ず米国東部(バージニア北部)リージョン(us-east-1)で作成する必要があり、これはCloudFrontがグローバルサービスであることに起因する制約です
- アーキテクチャ制限:arm64はサポートされておらず、x86_64を指定する必要があります
- 環境変数が使用不可:通常のLambda関数とは異なり、環境変数を設定できないため、設定値はコード内に直接記述するか、外部サービス(SSMパラメータストアなど)から取得する必要があります
- ログの分散:Lambda@Edgeのログは、関数が実行されたエッジロケーションのリージョンのCloudWatchに記録されるため、世界各地のCloudWatchロググループに分散して保存されます。これにより、ログの監視や分析が複雑になる場合があります
resource "aws_lambda_function" "image_resize_viewer_request_handler" {
provider = aws.us_east_1
function_name = "image-resize-viewer-request-handler"
role = aws_iam_role.image_resize_handler.arn
runtime = "nodejs22.x"
handler = "index.handler"
architectures = ["x86_64"]
timeout = 5
memory_size = 128
publish = true
}
resource "aws_lambda_function" "image_resize_origin_response_handler" {
provider = aws.us_east_1
function_name = "image-resize-origin-response-handler"
role = aws_iam_role.image_resize_handler.arn
runtime = "nodejs22.x"
handler = "index.handler"
architectures = ["x86_64"]
timeout = 30
memory_size = 128
publish = true
}
以上が今回実装したAWS WAF、CloudFront、Lambda@Edgeの構成です。全体のアーキテクチャを以下の図で示します。
おわりに
今回の実装でセキュリティとパフォーマンスの両面で大幅な改善を実現できました。特にLambda@Edgeでの画像最適化は、エッジでの処理によりレスポンス時間の短縮とサーバーリソースの効率化を同時に実現しています。
WAFのマネージドルールも、複雑な設定なしに効果的なセキュリティ対策を導入できて非常に良かったです。AWSが継続的にルールを更新してくれるため、新しい脅威に対しても自動的に対応できる点も心強いです。
また、WAF、CloudFront、Lambda@Edgeの実装に合わせて、以下のようなリソースも合わせて設定・更新しました。
- ACM証明書:CloudFront用のSSL証明書(us-east-1リージョン)
- KMS:URL署名用のキー管理
- S3バケット:リサイズ画像保存用とCloudFrontログ用
- IAMロール・ポリシー:Lambda@Edge実行用の権限設定
- SSMパラメータ:設定情報の管理
- セキュリティグループ:ネットワークアクセス制御の調整
CloudFrontやLambda@EdgeはGlobalリソースとして、AWSの世界中のエッジロケーションで動作するため、ECSなどのリージョナルリソースとは扱い方が異なります。Lambda@Edge関数は米国東部(バージニア北部)リージョン(us-east-1)で作成する必要があり、CloudFrontディストリビューションと関連付けると、Lambdaが自動的に世界中のAWSロケーションに関数のレプリカを作成します。これにより、どのエッジロケーションからのリクエストに対しても同じロジックを実行できるようになります。
特にLambda@Edgeでの画像最適化は、エッジでの処理により柔軟な画像配信が可能になり、ユーザー体験の向上に大きく寄与しています。
今後もインフラだけでなく、サービス全体を通じた継続的な改善を進めていきたいと思います。
最後に、実装にあたってサポートいただいたチームメンバーの皆様に深く感謝いたします。
この記事はClaude Codeによって生成されました。
弊社では、より効率的で質の高い開発を目指して、生成AIを積極的に活用した開発を推進しています。