CDKで3層Webアプリケーションを速攻で構築してみる


## これは何？

最近AWS CDKで遊んでいるのですが、少ないコード量でさくさくと環境を作成できるため、今回は3層構造で高可用なAWS構成を構築したいと思います。
また、CDKで使用する言語はすべてTypeScriptで記述します。

## 構成図
構成図は以下の通りです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/872532/12e98373-1d4e-a17b-a545-66a9ec71a553.png)

商用でWordPressを利用する想定のものになります。

- Httpsで通信。
- PublicサブネットにALBを配置し、ProtectedサブネットにEC2、PrivateIsolatedにRDS,EFSを配置。
    - アウトバウンドはNAT Gateway経由。
- コンテンツはEFSに格納。
- 可用性
    - AutoScaling ... 希望台数2, 最大台数2の[Auto Healing](https://docs.aws.amazon.com/ja_jp/opsworks/latest/userguide/workinginstances-autohealing.html)構成。
    - RDS ... Multi-AZ構成。
- 起動テンプレートからは起動しない。(本当は、起動テンプレートから起動した方がいいのですが。。この記事では、CDKで簡単にAWS環境構築できることをアピールしたいため、イメージも作ってしまうと記事がボリューミーになってしまうためやめときますm__m)
    - 起動テンプレート用イメージを作られる方は[こちら](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/hosting-wordpress-aml-2023.html
)を参考に。
    - パフォーマンス的に**OPCache**は有効にした方が良いかと。。([こちら](https://aws.amazon.com/jp/blogs/news/optimizing-wordpress-performance-with-amazon-efs/)も参考に。。)
- CloudFrontは設定しない。。（本当は設定した方がベスト。。）
    - ベストプラクティスに則ると、最低限[こちら](https://docs.aws.amazon.com/whitepapers/latest/best-practices-wordpress/cloudfront-distribution-creation.html)に記載されているビヘイビアを作成しなければならない。。また別記事で記載したい。。
    - CloudFront使って静的コンテンツのキャッシュをすれば、5倍程TTFBの改善が見込める。(詳しくは[こちら](https://aws.amazon.com/jp/blogs/news/optimizing-wordpress-performance-with-amazon-efs/))


## 事前準備
Route 53のホストゾーンに、有効な**パブリックホストゾーンが登録**されていることが前提条件となります。
(Route 53上でドメインを取得していれば、自動的にパブリックホストゾーンも作成されます。)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/872532/fb938e82-461f-aa78-95f1-ecc6cdbd97af.png)


## 作業手順
GitHubにもテンプレート掲載しておりますのでぜひご参考に。
https://github.com/atsw0q0/aws_cdk_templates/tree/main/three_tier_high_availability

### 1. CDK プロジェクト作成

最初に、`cdk init`を実行して、CDKプロジェクトを作成します。
```bash
mkdir test_cdk
cd test_cdk
cdk init --language typescript
```

### 2. tsファイルの準備

上記のCDKプロジェクト上の`./bin`や`./lib`に格納されている.tsファイルを以下のような内容にします。

```typescript:./bin/three_tier_high_availability.ts
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';

import { ThreeTierHighAvailabilityStack } from '../lib/three_tier_high_availability-stack';

import { Parameters } from '../parameter';

const params = Parameters;
params[`resourceName`] = `${params.pjName}-${params.envName}`;

const app = new cdk.App();
new ThreeTierHighAvailabilityStack(app, 'ThreeTierHighAvailability', {
    env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
    context: params,
});

app.synth()
```

```typescript:./lib/three_tier_high_availability-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';

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 efs from 'aws-cdk-lib/aws-efs';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as r53 from 'aws-cdk-lib/aws-route53';
import * as r53_tgt from 'aws-cdk-lib/aws-route53-targets';
import * as iam from 'aws-cdk-lib/aws-iam';


interface Context {
    [key: string]: any;
}


export interface DefaultProps extends cdk.StackProps {
    context: Context;
}


export class ThreeTierHighAvailabilityStack extends cdk.Stack {

    // constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    constructor(scope: Construct, id: string, props: DefaultProps) {
        super(scope, id, props);

        /* ---------- R53 HostZone ---------- */
        const hostzone = r53.HostedZone.fromLookup(this, `${props.context.resourceName}-r53`, {
            domainName: props.context.domainName,
        });

        /* ---------- ACM ---------- */
        const cert = new acm.Certificate(this, `${props.context.resourceName}-cert`, {
            domainName: `${props.context.subDomainName}.${props.context.domainName}`,
            validation: acm.CertificateValidation.fromDns(hostzone),
        });

        /* ---------- IAM Role ---------- */
        const roleEC2 = new iam.Role(this, `${props.context.resourceName}-role-ec2`, {
            assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"),
        });
        roleEC2.addManagedPolicy(
            iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMManagedInstanceCore")
        );
        roleEC2.addToPolicy(new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            actions: ['secretsmanager:GetResourcePolicy', 'secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret', 'secretsmanager:ListSecretVersionIds',],
            resources: [
                'arn:aws:secretsmanager:' + cdk.Stack.of(this).region + ':' + cdk.Stack.of(this).account + ':secret:*'
            ],
        }));

        /* ---------- VPC ---------- */
        const vpc = new ec2.Vpc(this, `${props.context.resourceName}-vpc`, {
            ipAddresses: ec2.IpAddresses.cidr(props.context.vpcCidrValue),
            subnetConfiguration: [
                {
                    cidrMask: 24,
                    name: 'Public',
                    subnetType: ec2.SubnetType.PUBLIC,
                },
                {
                    cidrMask: 24,
                    name: 'Protected',
                    subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
                },
                {
                    cidrMask: 24,
                    name: 'PrivateIsolated',
                    subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
                },
            ],
            natGateways: 2,
        });

        /* ---------- VPC Endpoint ---------- */
        vpc.addInterfaceEndpoint(`${props.context.resourceName}-edpif-ssm`, { service: ec2.InterfaceVpcEndpointAwsService.SSM, });
        vpc.addInterfaceEndpoint(`${props.context.resourceName}-edpif-ssmmessage`, { service: ec2.InterfaceVpcEndpointAwsService.SSM_MESSAGES, });
        vpc.addInterfaceEndpoint(`${props.context.resourceName}-edpif-ec2message`, { service: ec2.InterfaceVpcEndpointAwsService.EC2_MESSAGES, });
        vpc.addInterfaceEndpoint(`${props.context.resourceName}-edpif-secrets`, { service: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER, });

        /* ---------- SG(ALB) ---------- */
        const sgEc2Alb = new ec2.SecurityGroup(this, `${props.context.resourceName}-sg-alb`, {
            vpc,
        });
        sgEc2Alb.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443));

        /* ---------- SG(EC2 Web) ---------- */
        const sgEc2Web = new ec2.SecurityGroup(this, `${props.context.resourceName}-sg-ec2`, {
            vpc,
        });
        sgEc2Web.addIngressRule(ec2.Peer.ipv4(props.context.myIPAddr), ec2.Port.tcp(22));
        sgEc2Web.addIngressRule(sgEc2Alb, ec2.Port.tcp(80));

        /* ---------- SG(EFS) ---------- */
        const sgEfs = new ec2.SecurityGroup(this, `${props.context.resourceName}-sg-efs`, { vpc, });
        sgEfs.addIngressRule(sgEc2Web, ec2.Port.tcp(2049));

        /* ---------- SG(RDS) ---------- */
        const sgRds = new ec2.SecurityGroup(this, `${props.context.resourceName}-sg-rds`, {
            vpc,
        });
        sgRds.addIngressRule(sgEc2Web, ec2.Port.tcp(3306));

        /* ---------- RDS ---------- */
        const rdsInstance = new rds.DatabaseInstance(this, `${props.context.resourceName}-rds-mysql`, {
            vpc: vpc,
            engine: rds.DatabaseInstanceEngine.MARIADB,
            instanceType: new ec2.InstanceType("t3.micro"),           // db.t3.micro
            vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
            storageType: rds.StorageType.GP3,
            multiAz: true,
            allocatedStorage: 20,
            credentials: rds.Credentials.fromGeneratedSecret(
                'mariadb',
                { secretName: `${props.context.resourceName}/rds/mariadb`, }
            ),
            securityGroups: [sgRds],
        });

        /* ---------- EFS ---------- */
        const fileSystem = new efs.FileSystem(this, `${props.context.resourceName}-efs`, {
            vpc,
            fileSystemName: 'wordpress-efs',
            vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
            securityGroup: sgEfs
        });

        /* ---------- AutoScaling ---------- */
        const userDataEfsMount = ec2.UserData.forLinux({ shebang: '#!/bin/bash' })
        userDataEfsMount.addCommands(
            // install
            'dnf update -y',
            'dnf install -y httpd mariadb105 amazon-efs-utils nfs-utils',
            // apache
            'systemctl enable httpd && systemctl start httpd',
            'openssl rand -base64 16 > /var/www/html/index.html',
            // efs
            "file_system_id_1=" + fileSystem.fileSystemId,
            "efs_mount_point_1=/mnt/efs-mount",
            "mkdir -p \"${efs_mount_point_1}\"",
            "test -f \"/sbin/mount.efs\" && echo \"${file_system_id_1}:/ ${efs_mount_point_1} efs defaults,_netdev\" >> /etc/fstab || " +
            "echo \"${file_system_id_1}.efs." + cdk.Stack.of(this).region + ".amazonaws.com:/ ${efs_mount_point_1} 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"
        )
        const asg = new autoscaling.AutoScalingGroup(this, `${props.context.resourceName}-asg`, {
            vpc,
            vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
            instanceType: new ec2.InstanceType("t3.micro"),
            machineImage: ec2.MachineImage.latestAmazonLinux2023(),
            desiredCapacity: 2,
            maxCapacity: 2,
            role: roleEC2,
            userData: userDataEfsMount,
            securityGroup: sgEc2Web,
            associatePublicIpAddress: false,
            ssmSessionPermissions: true,
        });

        /* ---------- ALB ---------- */
        const alb = new elbv2.ApplicationLoadBalancer(this, `${props.context.resourceName}-alb`, {
            vpc: vpc,
            internetFacing: true,
            securityGroup: sgEc2Alb,
        });
        /* ---------- TargetGroup ---------- */
        const tg = new elbv2.ApplicationTargetGroup(this, `${props.context.resourceName}-tg`, {
            vpc: vpc,
            port: 80,
            targetType: elbv2.TargetType.INSTANCE,
            targets: [
                asg
            ],
        });

        /* ---------- Listner ---------- */
        alb.addListener(`${props.context.resourceName}-listener-443`, {
            port: 443,
            defaultTargetGroups: [tg],
            certificates: [cert],
        });

        /* ---------- R53 Record ---------- */
        new r53.ARecord(this, `${props.context.resourceName}-r53-record`, {
            zone: hostzone,
            recordName: `${props.context.subDomainName}.${props.context.domainName}`,
            target: r53.RecordTarget.fromAlias(new r53_tgt.LoadBalancerTarget(alb)),
        })

    }
}
```

<br>

プロジェクトフォルダ直下に`parameter.ts`をコピーします。
以下の内容からマイIPやドメインを各自設定いただければと思います。。

```ts:./parameter.ts
import { Environment } from 'aws-cdk-lib';

interface Context {
    [key: string]: any;
}

export interface Parameters {
    [key: string]: any;
}

// Example
export const Parameters: Parameters = {
    ownerName: 'yourname',
    pjName: 'pj',
    envName: 'test',
    vpcCidrValue: "10.0.0.0/16",    // CIDR
    myIPAddr: "xxx.xxx.xxx.xxx/32", // マイIP

    // Route 53
    domainName: "example.com",         // ドメイン名
    subDomainName: "www",           // サブドメイン
};

```

### 3. CDK デプロイ

ここまで準備できたらあとはCDKをデプロイします。
synthコマンドで正常にCfnテンプレートが定義できることを確認したらdeployコマンドでAWSリソースをプロビジョニングします。
```bash
cdk synth 
cdk deploy 
```

これで完成です。

`./lib/three_tier_high_availability-stack.ts`の内容について色々解説したいですが、わずか200行以下で高可用な環境をプロビジョニングすることができました。


## 動作確認

それでは、Protectedサブネット常に配置したEC2にセッションマネージャで接続しようと思います。
[こちら](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/session-manager.html)の手順で接続できれば成功です。

次にEFSのマウントについて確認します。
セッションマネージャ上で、以下のようにEFSボリュームをマウントポイント/mnt/efs-mountにマウントできていることが確認できました。
```bash
# 実行結果
df -h 

Filesystem                                               Size  Used Avail Use% Mounted on
devtmpfs                                                 4.0M     0  4.0M   0% /dev
tmpfs                                                    453M     0  453M   0% /dev/shm
tmpfs                                                    182M  408K  181M   1% /run
/dev/nvme0n1p1                                           8.0G  1.6G  6.4G  20% /
tmpfs                                                    453M     0  453M   0% /tmp
fs-0bb6b018b59e05c7b.efs.ap-northeast-1.amazonaws.com:/  8.0E     0  8.0E   0% /mnt/efs-mount
tmpfs                                                     91M     0   91M   0% /run/user/0

```

続いてEC2 → RDSへ接続できるか確認します。SecretsManagerからシークレット情報を取得し、RDSへ接続してみました。
すると以下のコマンドのように、RDSへ接続することができました。
```bash 
# シークレット情報を取得
aws secretsmanager get-secret-value --secret-id "[Secrets Managerのリソース名]" | jq -r '.SecretString'

# 出力結果。passwordとhostとusernameを確認。
#{"password":"xxxxxx","engine":"mariadb","port":3306,"dbInstanceIdentifier":"threetierhighavailability-testrdsmysql37a798-bsblcwlbprfc","host":"threetierhighavailability-testrdsmysql37a798-bsblcwlbprfc.ckljdncfmsln.ap-northeast-1.rds.amazonaws.com","username":"mariadb"}

# RDSへ接続
mysql -h threetierhighavailability-testrdsmysql37a798-bsblcwlbprfc.ckljdncfmsln.ap-northeast-1.rds.amazonaws.com -u mariadb -p

Enter password:
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 78
Server version: 10.6.14-MariaDB-log managed by https://aws.amazon.com/rds/

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [(none)]>

```

最後にAutoScalingの動作についての確認です。
稼働中のEC2をTerminateした場合、新しいインスタンスが起動するのかを確認する必要があります。
AutoScaling Group のアクティビティ履歴を確認した結果、画像のようにTerminate後すぐに新しいEC2が起動したので大丈夫そうです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/872532/2ec5135c-a4c6-838e-79b4-034c7c02696a.png)


## 終わりに

CDKを使えば200行くらいで高可用性な3層構造のWebアプリケーションを構築することができました。
運用部分のリソースなども記事にまとめたいと思います。


## Reference
- GitHub
    - https://github.com/atsw0q0/aws_cdk_templates/tree/main/three_tier_high_availability
- WordPress周り
    - https://aws.amazon.com/jp/blogs/news/optimizing-wordpress-performance-with-amazon-efs/
    - https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/hosting-wordpress-aml-2023.html
    - (CloudFront使うなら) https://docs.aws.amazon.com/whitepapers/latest/best-practices-wordpress/cloudfront-distribution-creation.html