[閉域網]AWS CDKを使用して、ALB+Lambda+S3でSPAなWebサイトを配信してみる
AWS Direct Connectなどで構築された閉域網において、マネージドサービスを組み合わせて静的・SPAのWebアプリケーションを配信するにはいくつかの課題があると思います。
今回はALB/Lambda/S3を組み合わせることで、乗り越えてみたいと思います。
目次
はじめに
こんにちは。
クラウドソリューション第二グループのimai.kです。
みなさんは、閉域網にシステムを構築しなくてはいけない、というケースに遭遇されたことがあるのではないかと思います。
そして、閉域網かつマネージドサービスのみを使って静的なWebアプリケーションを配信したいと考えるでしょう。
AWSの場合、閉域網で静的Webサイトを配信するためにいくつか選択肢があると思います。
しかし、 HTTPS/カスタムドメイン/認証といった仕組みを組み込みつつ実現することは、意外と難易度が高いのではないかと思います。
今回は、そういった課題を頑張って乗り越えてみたいと思います。
ではやっていきましょう。
ところで
まずはAWSにおける閉域網でのWebサイト・Webアプリケーションの配信方式を挙げておこうと思います。
- 1. EC2による独自配信
- 2. VPCエンドポイント(Gateway)を用いた、S3単体での配信
- 3. VPCエンドポイント(Interface)を用いた、ALB + S3の配信
くらいでしょうか。
1は独自にWebサーバーを構築するため、HTTPS/カスタムドメイン/認証といった仕組みが実現可能です。
一方で、2の場合はS3の静的ホスティングの機能を用いることになるかと思いますので、
のような’HTTP’形式のURLを使用することになると思います。
そのため、S3単体ではHTTPSやカスタムドメインを使用することが難しいと思われます。
(S3の前段にALBを配置するとしても、ALBの機能ではHTTPSをHTTPにリダイレクトさせることはできないかと思います。 参考)
また、3の場合は静的なWebサイトのホスティングは可能ですが、S3の仕様上、Vue.jsやReactなどのSPAの配信には向かないと思います。
今回は上記1,2,3いずれでもなく以下のような構成を取り、LambdaにWebサーバー的な役割を持たせることでSPAの配信が可能とします。
準備
今回、以下前提が必要です。
- 1. 閉域網で利用可能なドメインを保有し、AWS Certificate Managerにて有効な証明書が発行済みであること
- 2. AWS CDKが利用可能な環境が構築済みであること
まずはCDKのプロジェクトを作成しましょう。
CDKで使用する言語はTypeScriptを選択しています。
$ mkdir cdk-alb-lambda-s3 && cd cdk-alb-lambda-s3
$ cdk init app --language typescript
プロジェクトを作成すると、lib配下に「cdk-alb-lambda-s3-stack.ts」ができていると思います。
「cdk-alb-lambda-s3-stack.ts」を編集し、ALB+Lambda+S3の構成を組んでいきます。
構築
早速ですが、実際に修正したソースコードは以下です。
バケット名やSSL証明書のARNなど、任意の値に変更してください。
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as aws_s3_deployment from 'aws-cdk-lib/aws-s3-deployment';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as elbv2Targets from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as route53Targets from 'aws-cdk-lib/aws-route53-targets';
import * as iam from 'aws-cdk-lib/aws-iam';
export class CdkAlbLambdaS3Stack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const bucket = new s3.Bucket(this, 'S3Bucket', {
bucketName: '{任意のバケット名}',
versioned: true,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
new aws_s3_deployment.BucketDeployment(this, 'DeployWebsite', {
sources: [aws_s3_deployment.Source.asset('./static-app/vue-project/dist')],
destinationBucket: bucket,
});
const vpc = new ec2.Vpc(this, 'Vpc', {
ipAddresses: ec2.IpAddresses.cidr('10.0.0.0/16'),
vpcName: 'vpc',
natGateways: 0,
subnetConfiguration: [
{
name: 'Private',
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
cidrMask: 24,
}
]
});
const securityGroup = new ec2.SecurityGroup(this, 'SecurityGroup', {
vpc: vpc,
allowAllOutbound: false,
});
securityGroup.addEgressRule(
securityGroup,
ec2.Port.HTTPS,
)
securityGroup.addIngressRule(
securityGroup,
ec2.Port.HTTPS,
)
const lambdaIamRole = new iam.Role(this, 'LambdaIamRole', {
roleName: 'LambdaIamRole',
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
});
lambdaIamRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'));
lambdaIamRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonS3ReadOnlyAccess'));
const staticSiteFunction = new lambda.Function(this, 'Lambda', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'static-site.get',
code: lambda.Code.fromAsset('./functions'),
role: lambdaIamRole,
});
const alb = new elbv2.ApplicationLoadBalancer(this, 'Alb', {
vpc: vpc,
securityGroup: securityGroup,
});
const tagetGroup = new elbv2.ApplicationTargetGroup(this, 'TagetGroup', {
targetType: elbv2.TargetType.LAMBDA,
targets: [new elbv2Targets.LambdaTarget(staticSiteFunction)],
vpc: vpc,
healthCheck: {
enabled: true,
},
});
alb.addListener('AlbListener', {
protocol: elbv2.ApplicationProtocol.HTTPS,
port: 443,
open: false,
defaultTargetGroups: [tagetGroup],
certificates: [elbv2.ListenerCertificate.fromArn('{事前に検証済みの証明書ARN}')],
});
const zone = new route53.PrivateHostedZone(this, 'PrivateHostedZone', {
zoneName: '{閉域網内で名前解決させたいドメイン名}',
vpc,
});
new route53.ARecord(this, 'AliasRecord', {
zone,
target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(alb)),
});
}
}
次に、「cdk-alb-lambda-s3」の直下に「functions」というディレクトリを作成し、TypeScriptのプロジェクトを作成しましょう。
$ mkdir functions && cd functions
$ npm init
$ npm install -D @types/aws-lambda esbuild @aws-sdk/client-s3
「functions」配下に「static-site.ts」というファイルを作成します。
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
export const get = async function (event: any, context: any) {
const regexPatterns = [/^\/$/, /^\/about$/]
const key = regexPatterns.some((pattern) => pattern.test(event.path))
? 'index.html'
: event.path.replace(/^\//, '')
const client = new S3Client({
region: 'ap-northeast-1'
})
const cmd = new GetObjectCommand({
Bucket: '{任意のバケット名}',
Key: key
})
const data = await client.send(cmd)
let isBase64Encoded = false
let body = null
if (/.(png|ico|svg)$/.test(event.path)) {
body = await data.Body.transformToString('base64')
isBase64Encoded = true
} else {
body = await data.Body.transformToString('UTF-8')
}
const response = {
statusCode: 200,
statusDescription: 'OK',
headers: {
'Content-Type': data.ContentType,
'Cache-Control': 'no-cache'
},
body: body,
isBase64Encoded: isBase64Encoded
}
return response;
};
また、package.jsonは以下内容としています。
{
"name": "functions",
"version": "1.0.0",
"main": "static-site.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"prebuild": "rm -rf dist",
"build": "esbuild static-site.ts --bundle --minify --sourcemap --platform=node --target=es2020 --outfile=dist/static-site.js",
"postbuild": "cd dist && zip -r static-site.zip static-site.js*"
},
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@aws-sdk/client-s3": "^3.637.0",
"@types/aws-lambda": "^8.10.145",
"esbuild": "^0.23.1"
}
}
LambdaでTypeScriptの関数を動作させるにあたり、JavaScriptへトランスパイルが必要です。
以下のようにfunctionsディレクトリ内でビルドしてください。
$ npm run build
サンプルWebアプリ作成
次に、S3に配置するSPAのWebアプリケーションを作りましょう。
今回はVue.jsのサンプルアプリケーションを、cdk-alb-lambda-s3ディレクト配下に用意してみます。
$ mkdir static-app && cd static-app
$ npm create vue@latest
✔ Project name: …
✔ Add TypeScript? … Yes
✔ Add JSX Support? … No
✔ Add Vue Router for Single Page Application development? … Yes
✔ Add Pinia for state management? … No
✔ Add Vitest for Unit testing? … No
✔ Add an End-to-End Testing Solution? … No
✔ Add ESLint for code quality? … No
✔ Add Prettier for code formatting? … No
✔ Add Vue DevTools 7 extension for debugging? (experimental) … No
Scaffolding project in ./...
Done.
$ npm install
$ npm run build
上記手順で、distにVue.jsの静的資材が作成されると思います。
デプロイ
ここまでの手順で準備が整いました。では、AWS環境にデプロイしてみましょう。
$ cdk synth
$ cdk deploy
リソースがデプロイできたら、最後に動作確認してみましょう。
手順は省きますが、CDKで作成したVPC上にWindows ServerのEC2インスタンスを作成し、Fleet Managerやセッションマネージャ経由でRDP接続してみましょう。
少々情報が古いかもしれませんが、 EC2 Windows インスタンスに Web GUI(Fleet Manager) でお手軽接続してみる などを参考にしていただければと思います。
EC2にRDP接続できる環境が出来上がりましたら、CDK内で指定したドメインにEC2上のブラウザからアクセスしてみましょう。
問題なければ、以下のような画面が表示されると思います。
さいごに
多少強引な手段ではありますが、ALB+Lambda+S3の組み合わせで静的アプリケーションを配信することができました。
閉域網における、アプリケーション配信の一つの選択肢となれば幸いです。
以上です。