2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

これは何?

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

構成図

構成図は以下の通りです。
image.png

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

  • Httpsで通信。
  • PublicサブネットにALBを配置し、ProtectedサブネットにEC2、PrivateIsolatedにRDS,EFSを配置。
    • アウトバウンドはNAT Gateway経由。
  • コンテンツはEFSに格納。
  • 可用性
    • AutoScaling ... 希望台数2, 最大台数2のAuto Healing構成。
    • RDS ... Multi-AZ構成。
  • 起動テンプレートからは起動しない。(本当は、起動テンプレートから起動した方がいいのですが。。この記事では、CDKで簡単にAWS環境構築できることをアピールしたいため、イメージも作ってしまうと記事がボリューミーになってしまうためやめときますm__m)
    • 起動テンプレート用イメージを作られる方はこちらを参考に。
    • パフォーマンス的にOPCacheは有効にした方が良いかと。。(こちらも参考に。。)
  • CloudFrontは設定しない。。(本当は設定した方がベスト。。)
    • ベストプラクティスに則ると、最低限こちらに記載されているビヘイビアを作成しなければならない。。また別記事で記載したい。。
    • CloudFront使って静的コンテンツのキャッシュをすれば、5倍程TTFBの改善が見込める。(詳しくはこちら)

事前準備

Route 53のホストゾーンに、有効なパブリックホストゾーンが登録されていることが前提条件となります。
(Route 53上でドメインを取得していれば、自動的にパブリックホストゾーンも作成されます。)
image.png

作業手順

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

1. CDK プロジェクト作成

最初に、cdk initを実行して、CDKプロジェクトを作成します。

mkdir test_cdk
cd test_cdk
cdk init --language typescript

2. tsファイルの準備

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

./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()
./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)),
        })

    }
}

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

./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リソースをプロビジョニングします。

cdk synth 
cdk deploy 

これで完成です。

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

動作確認

それでは、Protectedサブネット常に配置したEC2にセッションマネージャで接続しようと思います。
こちらの手順で接続できれば成功です。

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

# 実行結果
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へ接続することができました。

# シークレット情報を取得
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

終わりに

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

Reference

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?