Loading
BLOG 開発者ブログ

2024年10月21日

[閉域網]AWS CDKを使用して、ALB+Lambda+S3でSPAなWebサイトを配信してみる

[閉域網]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://bucket-name.s3-website-Region.amazonaws.com

のような’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の組み合わせで静的アプリケーションを配信することができました。
閉域網における、アプリケーション配信の一つの選択肢となれば幸いです。

以上です。


imai.kのブログ