NRIネットコム Blog

NRIネットコム社員が様々な視点で、日々の気づきやナレッジを発信するメディアです

CDKでスケーラブルなWebアプリケーション基盤を作成してみた

はじめに

こんにちは。大林です。 今回のブログでは、CDKで作成したスケーラブルなWebアプリケーション基盤の簡単な説明と作成してみての感想をまとめていきたいと思います。
CDKとは、プログラミング言語を使用してAWSリソースを定義できるツールのことです。CDKは、TypeScript、JavaScript、Java、Python、C#、Goに対応しているため、CloudFormationのYAMLやJSON形式のテンプレートの作成に慣れていない多くの開発者にとっては、使い慣れたプログラミング言語でAWSリソースを構築することができます。また、CloudFormationのテンプレートと比較するとコード量を抑えることができるといったメリットもあります。 CDKには、L1コンストラクト、L2コンストラクト、L3コンストラクトという3つのレイヤーがあり、L3になるにつれて抽象度が高くなります。
L1コンストラクトは、AWS CloudFormationのようにリソースを直接的に定義する最も抽象度の低いレイヤーです。L1コンストラクトを使用すると、コード内で定義したAWSリソースがそのままデプロイされるため、開発者がAWSリソースの細かい設定を管理する必要があります。
L2コンストラクトは、L1よりも高い抽象度でリソースを定義することができます。L2でAWSリソースを定義すると、コード量を削減しつつ、AWS側が用意しているデフォルトの設定でAWSリソースを作成できます。例えば、以下のようにVpcクラスを使用すると、VPC、パブリックサブネット、プライベートサブネット、インターネットゲートウェイ、ルートテーブル、NATゲートウェイがデフォルトで作成されます。

import { Duration, Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';

export class TestCdkAppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
    const vpc = new ec2.Vpc(this, 'Vpc', {});
  }
}

L3コンストラクトは、最も高い抽象度のレイヤーであり、よく利用されるAWSサービスのパターンを提供してくれます。例えば、LambdaRestApiクラスを使用すればLambda関数とAPIGatewayの組み合わせをAWS側が用意しているデフォルトの設定でデプロイすることができます。

docs.aws.amazon.com

Webアプリケーション基盤の構成とCDKスタック


今回構築したWebアプリケーション基盤は以下の通りです。今回はL2コンストラクトを学習するために、スタックのほとんどをL2コンストラクトで書いています。
このアプリケーション基盤にアクセスするには、Amazon CloudFrontを通じてアクセスします。CloudFrontからのリクエストはApplication Load Balancer(ALB)へとルーティングされ、画像や動画などのコンテンツを配信する際はAmazon S3から配信するようにしています。ALBは、Auto Scalingグループに属するEC2インスタンスへリクエストを分散させます。EC2インスタンスはプライベートサブネット内に配置されていて、インターネットにアクセスする際はNATゲートウェイを介してアクセスします。
今回は、SSMセッションマネージャーを使用したEC2インスタンスへの接続を想定していますが、プライベートサブネットに配置されたEC2インスタンスへの接続方法は、踏み台サーバーやEC2 Instance Connect Endpoint サービスを使用した方法もあります。以下のブログで踏み台サーバー、SSMセッションマネージャー、EC2 Instance Connect Endpoint サービスを使用したEC2インスタンスへの接続手順と接続方法の比較についてまとめているので、興味があれば読んでみてください。

tech.nri-net.com

データ層においては、マルチAZ構成のAmazon RDSをデータベースとして、またAmazon EFSをファイルストレージとして使用しています。 以下がTypeScriptで書いたスタックです。

import { Stack, StackProps, RemovalPolicy, Duration } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as autoscaling from 'aws-cdk-lib/aws-autoscaling';
import * as rds from 'aws-cdk-lib/aws-rds';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as aws_efs from 'aws-cdk-lib/aws-efs';

export class TestCdkAppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // VPC
    const vpc = new ec2.Vpc(this, 'TestVpc', {
    natGateways: 1, 
    subnetConfiguration: [
      {
        subnetType: ec2.SubnetType.PUBLIC,
        name: 'Public',
        cidrMask: 24,
      },
      {
        subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        name: 'Private',
        cidrMask: 24,
      },
      {
        subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        name: 'db',
        cidrMask: 24,
      },
    ],
    });
    
    // EFS
    // VPCが削除される際にEFSも削除されるようにRemovalPolicyを設定している
    const filesystem = new aws_efs.FileSystem(this, 'FileSystem', {
      vpc:vpc,
      removalPolicy: RemovalPolicy.DESTROY,
    })
    
    // セキュリティグループ(ALB用)
    const albSecurityGroup = new ec2.SecurityGroup(this, 'ALBSecurityGroup', {
    vpc,
    allowAllOutbound: true,
    });
    
    // ALB
    const alb = new elbv2.ApplicationLoadBalancer(this, 'ALB', {
    vpc,
    internetFacing: true,
    securityGroup: albSecurityGroup,
    });
    
    // ALBリスナー
    const listener = alb.addListener('Listener', {
    port: 80,
    open: true,
    });
    
    // セキュリティグループ(EC2インスタンス用)
    const ec2SecurityGroup = new ec2.SecurityGroup(this, 'EC2SecurityGroup', {
    vpc,
    allowAllOutbound: true,    
    });
    
    // AutoScalingグループ
    // ユーザーデータスクリプトを用いてEC2インスタンスがEFSにアクセスできるようにしている
    const ec2_user_data = new ec2.MultipartUserData()
    const ec2_command = ec2.UserData.forLinux()
    ec2_user_data.addUserDataPart(ec2_command, ec2.MultipartBody.SHELL_SCRIPT, true)
    ec2_command.addCommands(
      "#!/bin/bash",
      // EFS
      "yum install -y amazon-efs-utils",
      "yum install -y nfs-utils",
      "file_system_id_1=" + filesystem.fileSystemId,
      "efs_mount_point_1=/var/www/html",
      "mkdir -p \"${efs_mount_point_1}\"",
      "test -f \"/sbin/mount.efs\" && echo \"${file_system_id}:/ ${efs_mount_point} efs defaults,_netdev\" >> /etc/fstab || " +
      "echo \"${file_system_id_1}.efs." + Stack.of(this).region + ".amazonaws.com:/ ${efs_mount_point} nfs4 nfsvers=4.1,rsize=1048576,wsize=1048576,hard,timeo=600,retrans=2,noresvport,_netdev 0 0\" >> /etc/fstab",
      "mount -a -t efs,nfs4 defaults",
    )
    // CPU使用率が50%を超えたらスケールアウトする
    // クールダウン期間は1分
    // プライベートサブネットに配置されたEC2インスタンスにSSMセッションマネージャーでアクセスすることを想定しているので、ssmSessionPermissionsをtrueにする
    const asg = new autoscaling.AutoScalingGroup(this, 'asg', {
      vpc: vpc,
      vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
      securityGroup: ec2SecurityGroup,
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.T2, ec2.InstanceSize.SMALL),
      machineImage: new ec2.AmazonLinuxImage({ generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2 }),
      ssmSessionPermissions: true,
      userData: ec2_user_data,
      minCapacity: 1,
      maxCapacity: 2,
      desiredCapacity: 1
    })
    asg.scaleOnCpuUtilization("CpuutilizationScaling", {
        targetUtilizationPercent: 50,
        cooldown: Duration.minutes(1),
        estimatedInstanceWarmup: Duration.minutes(1),
    });
    // EFSとEC2を関連付ける
    filesystem.connections.allowDefaultPortFrom(asg)
    // ターゲットを追加
    listener.addTargets("ApplicationFleet", {
    port: 80,
    targets: [asg]
    });
    
    // RDSサブネットグループ
    const subnetGroup = new rds.SubnetGroup(this,'SubnetGroup', {
    vpc,
    description: 'SubnetGroup',
    subnetGroupName: 'SubnetGroup',
    vpcSubnets: {
      subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
    }
    });
    
    // RDS
    // RDSデータベースインスタンスがマルチAZ構成で作成される
    // MySQL 8.0.31をエンジンとして使用している
    const dbserver = new rds.DatabaseInstance(this, 'TestDB', {
    vpc,
    engine: rds.DatabaseInstanceEngine.mysql({ version: rds.MysqlEngineVersion.VER_8_0_31 }),
    instanceType: ec2.InstanceType.of(ec2.InstanceClass.T2, ec2.InstanceSize.SMALL),
    databaseName: "testDB",
    multiAz: true,
    subnetGroup: subnetGroup,
    });
    
    // EC2とRDS間の通信ができるようにする
    dbserver.connections.allowDefaultPortFrom(asg);

    // S3バケット
 // ブロックパブリックアクセスを有効化する
    const s3bucket = new s3.Bucket(this, 'S3Bucket', {
        blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL
    });

    // CloudFront ディストリビューション
    // ALBとS3をオリジンに設定している
    // /contents/のリクエストはS3バケットにルーティングされる
    const distribution = new cloudfront.Distribution(this, 'Distribution', {
        defaultBehavior: {
          origin: new origins.HttpOrigin(alb.loadBalancerDnsName),
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS
        },
        additionalBehaviors: {
          '/contents/*': {
            origin: new origins.S3Origin(s3bucket),
          },
        },
      });
      
    // Origin Access Control (OAC) 
    const cfnOriginAccessControl = new cloudfront.CfnOriginAccessControl(this, 'OriginAccessControl', {
        originAccessControlConfig: {
            name: 'OriginAccessControlForContentsBucket',
            originAccessControlOriginType: 's3',
            signingBehavior: 'always',
            signingProtocol: 'sigv4',
            description: 'Access Control',
        },
    });
    
    // OACをL1コンストラクトで設定する
    const cfnDistribution = distribution.node.defaultChild as cloudfront.CfnDistribution;
    cfnDistribution.addPropertyOverride('DistributionConfig.Origins.1.OriginAccessControlId', cfnOriginAccessControl.ref);
    cfnDistribution.addPropertyOverride('DistributionConfig.Origins.1.DomainName', s3bucket.bucketRegionalDomainName);
    cfnDistribution.addOverride('Properties.DistributionConfig.Origins.1.S3OriginConfig.OriginAccessIdentity', '');
    cfnDistribution.addPropertyDeletionOverride('DistributionConfig.Origins.1.CustomOriginConfig');
    
    // バケットポリシーの追加
    const bucketPolicyStatement = new iam.PolicyStatement({
        actions: ['s3:GetObject'],
        effect: iam.Effect.ALLOW,
        principals: [new iam.ServicePrincipal('cloudfront.amazonaws.com')],
        resources: [`${s3bucket.bucketArn}/*`],
    });
    bucketPolicyStatement.addCondition('StringEquals', {
        'AWS:SourceArn': `arn:aws:cloudfront::${Stack.of(this).account}:distribution/${distribution.distributionId}`,
    });
    s3bucket.addToResourcePolicy(bucketPolicyStatement);
  }
}

感想

以前、CDKをL1コンストラクトで作成したことはあったのですが、抽象度の高いL2コンストラクトで作成したのは今回が初めてでした。 L2コンストラクトでAWSリソースを定義するとコード量を抑えることができる一方で、意図した通りの構成になっていないことがありました。
特に、OACの実装です。CDKのL2コンストラクトのドキュメントを読んでみると、OAIの設定はあるのですがOACの設定は見当たりません。(※2024/3/18時点) そのため、OACの設定はL1コンストラクトを使用して実装しました。L2コンストラクトは便利でコード量を削減できますが、細かい設定に関してはL2コンストラクトが用意されていないこともあります。その際は、L1コンストラクトで抽象度を下げた実装が必要になってきます。
今後のCDKでの実装では、CDKは必ずしもL2コンストラクトで完結するわけではないことを念頭に置いて、アウトプットを継続していきたいと思います!

執筆者大林 優斗

クラウドエンジニア
AWSを活用したシステムの設計と開発をやらせていただいています。


執筆記事一覧:https://tech.nri-net.com/archive/author/y-obayashi