これは何?
最近AWS CDKで遊んでいるのですが、少ないコード量でさくさくと環境を作成できるため、今回は3層構造で高可用なAWS構成を構築したいと思います。
また、CDKで使用する言語はすべてTypeScriptで記述します。
構成図
商用でWordPressを利用する想定のものになります。
- Httpsで通信。
- PublicサブネットにALBを配置し、ProtectedサブネットにEC2、PrivateIsolatedにRDS,EFSを配置。
- アウトバウンドはNAT Gateway経由。
- コンテンツはEFSに格納。
- 可用性
- AutoScaling ... 希望台数2, 最大台数2のAuto Healing構成。
- RDS ... Multi-AZ構成。
- 起動テンプレートからは起動しない。(本当は、起動テンプレートから起動した方がいいのですが。。この記事では、CDKで簡単にAWS環境構築できることをアピールしたいため、イメージも作ってしまうと記事がボリューミーになってしまうためやめときますm__m)
- CloudFrontは設定しない。。(本当は設定した方がベスト。。)
事前準備
Route 53のホストゾーンに、有効なパブリックホストゾーンが登録されていることが前提条件となります。
(Route 53上でドメインを取得していれば、自動的にパブリックホストゾーンも作成されます。)
作業手順
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ファイルを以下のような内容にします。
#!/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()
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やドメインを各自設定いただければと思います。。
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が起動したので大丈夫そうです。
終わりに
CDKを使えば200行くらいで高可用性な3層構造のWebアプリケーションを構築することができました。
運用部分のリソースなども記事にまとめたいと思います。
Reference
- GitHub
- 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