AWS CDKで手軽に始めるIaCによるアプリホスティング
はじめに
これまで、アプリのインフラ構築は手動で行ってきましたが、今回はインフラをコードで管理する Infrastructure as Code (IaC) に挑戦しました。
AWS CDKを使うことで、複雑なインフラ設定もコード化してシンプルに管理でき、効率的なデプロイが可能になりました。この記事では、AWS CDKを活用してIaCでアプリをホスティングする方法を紹介します。
アプリ構成の概要
今回作成したアプリは以下の構成です。フロントエンドとバックエンドの両方にDockerイメージを使い、AWSでそれらを効率よく動作させるように設計しました。
フロントエンド: React + TypeScript
バックエンド: NestJS + TypeScript
以下に、アプリの構成図を示します。
インフラ設計
まず VPC(仮想プライベートクラウド) を使ってセキュアなネットワーク構成を作る必要がありました。そのため、VPC内でパブリックサブネットとプライベートサブネットを設定します。
パブリックサブネットには ALB(Application Load Balancer) を配置して、プライベートサブネットには ECS(Elastic Container Service) で管理されるFargateのバックエンドとフロントエンドサーバーを配置しました。これにより、サーバーは外部に直接公開されず、セキュリティが強化されます。
ユーザーは、インターネットゲートウェイを介してアプリケーションにアクセスすることができます。
アクセスはパブリックサブネットに配置されたALBを通じて受け付けられます。ALBは、リクエストを適切なサービスにルーティングし、負荷分散を行うように設計しました。
データベースに関してはDynamoDBを使用してデータを保存するようにしました。
プライベートサブネット内のECSタスクは、VPCエンドポイント経由でDynamoDBにアクセスするように構成しています。これにより、インターネットを経由せずにDynamoDBと通信できるため、通信のセキュリティとパフォーマンスが向上します。
サーバーで利用するDockerイメージは ECR(Elastic Container Registry) に保存します。
ECSがこのイメージを使用してアプリケーションを実行します。また、VPCエンドポイントを介してS3やECRからイメージを取得するため、プライベートなネットワーク内でのリソース利用が可能です。
実装
次に、上記のインフラ設計を実際にコードに移していきます。
セットアップ
まずはAWS CLIを使って認証情報を設定します。
aws configure
その後、CDKのセットアップをしていきます。
npm install -g aws-cdk
mkdir cdk && cd cdk // 好きなディレクトリを作ってそのディレクトリに移動する
cdk init app --language typescript
cdk bootstrap aws://${AWS_ACCOUNT_ID}/${AWS_REGION_NAME}
これでCDKの設定に必要なファイルが自動生成されます。
フロントエンド・バックエンドともにDocker Imageを用意して、ECRに格納しておきます。
CDKの内容を記述
cdk-stack.ts
を変更します。
CdkStackクラスが出来上がっているので、そのコンストラクタの中に上記の設計を記述していきます。
テスト環境・商用環境など様々な環境を作りたいので、コンストラクタの引数にenvironmentを追加し、今後使えるようにします。
export class CdkStack extends cdk.Stack {
constructor(
scope: Construct,
id: string,
environment: string, // 追加
props?: cdk.StackProps
) {
このCdkStackクラスは、cdk.ts
で呼び出しているので、environmentを使えるようにcdk.ts
内のコードも修正します。
#!/usr/bin/env node
import * as cdk from "aws-cdk-lib";
import "source-map-support/register";
import { CdkStack } from "../lib/cdk-stack";
const app = new cdk.App();
const environment = app.node.tryGetContext("env"); // デプロイ時にコマンドラインから取得できるように
new CdkStack(app, `${environment}CdkStack`, environment);
app.synth();
以降cdk-stack.ts
のCdkStackクラスのコンストラクタの中を記載していきます。
まずはVPCを作成します。今回はプライベートサブネットとパブリックサブネットが必要なので、以下のように記述します。
const vpc = new ec2.Vpc(this, `${environment}AppVpc`, {
vpcName: `${environment}AppVpc`,
maxAzs: 2,
natGateways: 1,
subnetConfiguration: [
{
name: `${environment}AppPublicSubnet`,
subnetType: ec2.SubnetType.PUBLIC,
cidrMask: 24,
},
{
name: `${environment}AppPrivateSubnet`,
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
cidrMask: 24,
},
],
});
インターネットゲートウェイに関しては、パブリックサブネットを作っているので、コード上では明記してませんが自動で作られます。
次に、プライベートサブネットが使うVPCエンドポイントを作っていきます。
// DynamoDBへのVPCエンドポイント
vpc.addGatewayEndpoint(`${environment}DynamoDbEndpoint`, {
service: ec2.InterfaceVpcEndpointAwsService.DYNAMODB,
});
// S3へのVPCエンドポイント(Docker Imageの取得に必要)
vpc.addGatewayEndpoint(`${environment}S3Endpoint`, {
service: ec2.GatewayVpcEndpointAwsService.S3,
});
// ECRへのVPCエンドポイント(リクエストを送るためのエンドポイント)
vpc.addInterfaceEndpoint(`${environment}EcrApiEndpoint`, {
service: ec2.InterfaceVpcEndpointAwsService.ECR,
subnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
});
// ECRへのVPCエンドポイント(Docker Imageを取得するためのエンドポイント)
vpc.addInterfaceEndpoint(`${environment}EcrDockerEndpoint`, {
service: ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER,
subnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
});
// CloudWatchへのVPCエンドポイント(ログの記録のため)
vpc.addInterfaceEndpoint(`${environment}CloudWatchLogsEndpoint`, {
service: ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS,
subnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
});
全てプライベートサブネットとの接続ですが、DynamoDBとS3に関しては addGatewayEndpoint(ゲートウェイ型のエンドポイント) を利用できるので、サブネットの指定は不要です。
次に、コンテナ管理に必要なECSを作っていきます。
const cluster = new ecs.Cluster(this, `${environment}AppCluster`, {
clusterName: `${environment}AppCluster`,
vpc: vpc,
});
Fargateが使うIAMロールを作ります。
const taskExecutionRole = new iam.Role(
this,
`${environment}TaskExecutionRole`,
{
// ECSタスクだけがこのロールを被ることができるように設定
assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
}
);
// 必要なアクセス権を付与
taskExecutionRole.addManagedPolicy(
iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AmazonECSTaskExecutionRolePolicy"
)
);
フロントエンドのFargateタスク定義を作成します。
const frontendTaskDef = new ecs.FargateTaskDefinition(
this,
`${environment}AppFrontendTaskDef`,
{
family: `${environment}AppFrontendTaskDef`,
memoryLimitMiB: 512,
cpu: 256,
executionRole: taskExecutionRole,
}
);
今回はECRを使うので、フロントエンドのイメージのリポジトリを指定します。(Docker Hubを使う場合は不要)
const frontendRepository = ecr.Repository.fromRepositoryName(
this,
`${environment}frontendRepository`,
"app-react-nginx-image"
);
上記で作成したタスク定義にコンテナの情報を追加します。今回はイメージリポジトリの指定を行なっています。
const frontendContainer = frontendTaskDef.addContainer(
`${environment}AppFrontendContainer`,
{
containerName: `${environment}AppFrontendContainer`,
image: ecs.ContainerImage.fromEcrRepository(
frontendRepository,
"latest"
),
logging: new ecs.AwsLogDriver({
streamPrefix: "Frontend", // ログを記録するときのPrefixを指定
}),
}
);
frontendContainer.addPortMappings({
containerPort: 80,
protocol: ecs.Protocol.TCP,
});
containerPortは、ALBからFargateまでのアクセスのポートを指すので、アプリ自体がhttpsホスティングされているかどうかに関わらず、80のままで大丈夫です。
次にフロントエンドサーバーのためにFargateサービスを立ち上げます。
const frontendService = new ecs.FargateService(
this,
`${environment}AppFrontendService`,
{
serviceName: `${environment}AppFrontendService`,
cluster: cluster,
taskDefinition: frontendTaskDef,
assignPublicIp: false,
vpcSubnets: {
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
},
}
);
// 自動スケーリングの設定
const scalingFrontend = frontendService.autoScaleTaskCount({
minCapacity: 1,
maxCapacity: 5,
});
// CPU使用率が50%を超えたら自動スケーリング
scalingFrontend.scaleOnCpuUtilization("CpuScalingFrontend", {
targetUtilizationPercent: 50,
});
// メモリ使用率が70%を超えたら自動スケーリング
scalingFrontend.scaleOnMemoryUtilization("MemoryScalingFrontend", {
targetUtilizationPercent: 70,
});
ここまででフロントエンドサーバーの設定が完了しました。次にバックエンドサーバーの設定に移ります。フロントエンドサーバーとほとんど同じなので解説は割愛します。
const backendTaskDef = new ecs.FargateTaskDefinition(
this,
`${environment}AppBackendTaskDef`,
{
family: `${environment}AppBackendTaskDef`,
memoryLimitMiB: 512,
cpu: 256,
executionRole: taskExecutionRole,
}
);
const backendRepository = ecr.Repository.fromRepositoryName(
this,
`${environment}backendRepository`,
"app-nestjs-image"
);
const backendContainer = backendTaskDef.addContainer(
`${environment}AppBackendContainer`,
{
containerName: `${environment}AppBackendContainer`,
image: ecs.ContainerImage.fromEcrRepository(
backendRepository,
"latest"
),
logging: new ecs.AwsLogDriver({
streamPrefix: "Backend",
}),
}
);
backendContainer.addPortMappings({
containerPort: 3000,
protocol: ecs.Protocol.TCP,
});
const backendService = new ecs.FargateService(
this,
`${environment}AppBackendService`,
{
serviceName: `${environment}AppBackendService`,
cluster: cluster,
taskDefinition: backendTaskDef,
assignPublicIp: false,
vpcSubnets: {
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
},
}
);
const scalingBackend = backendService.autoScaleTaskCount({
minCapacity: 1,
maxCapacity: 5,
});
scalingBackend.scaleOnCpuUtilization("CpuScalingBackend", {
targetUtilizationPercent: 60,
});
scalingBackend.scaleOnMemoryUtilization("MemoryScalingBackend", {
targetUtilizationPercent: 75,
});
次に、ALBを作ります。
const loadBalancer = new elbv2.ApplicationLoadBalancer(
this,
`${environment}AppApplicationLoadBalancer`,
{
vpc,
loadBalancerName: `${environment}AppApplicationLoadBalancer`,
internetFacing: true,
}
);
ALBをプライベートサブネットのリソースと繋げます。
const FrontendTargetGroup = new elbv2.ApplicationTargetGroup(
this,
`${environment}AppFrontendTG`,
{
targetGroupName: `${environment}AppFrontendTG`,
vpc,
port: 80,
protocol: elbv2.ApplicationProtocol.HTTP,
targets: [frontendService],
healthCheck: {
path: "/",
},
}
);
const backendTargetGroup = new elbv2.ApplicationTargetGroup(
this,
`${environment}AppBackendTG`,
{
targetGroupName: `${environment}AppBackendTG`,
vpc,
port: 3000,
protocol: elbv2.ApplicationProtocol.HTTP,
targets: [backendService],
healthCheck: {
path: "/v1/health", // ヘルスチェック用のAPIを指定
},
}
);
ALBのリクエスト受付について記述します。
const listener = loadBalancer.addListener(`${environment}HttpListener`, {
port: 80,
open: true,
defaultAction: elbv2.ListenerAction.forward([FrontendTargetGroup]),
});
listener.addTargetGroups(`${environment}BackendTargetGroups`, {
targetGroups: [backendTargetGroup],
priority: 1,
conditions: [elbv2.ListenerCondition.pathPatterns(["/v1/*"])],
});
今回は一つのALBで、フロントエンド・バックエンドに振り分けを行なっています。デフォルトでフロントエンドに振り分けますが、/v1でアクセスがあった場合はバックエンドのFargateに振り分けるような設定にしています。もしALBを二つにする場合はaddTargetGroupsの記載は不要です。
最後に必要なテーブルを作ります。DynamoDBではPartitionKeyの記載だけが必須ですが、必要に応じてSortKeyやIndex、TTLの記載も行なってください。
const usersTable = new dynamodb.Table(this, `${environment}UsersTable`, {
tableName: `${environment}-users`,
partitionKey: {
name: "account_id",
type: dynamodb.AttributeType.STRING,
},
});
usersTable.grantReadWriteData(backendTaskDef.taskRole);
これでIaCに必要なコードが書けたので、あとは実行して確認します。
実行
まずは上記のCDKのコードに構文エラーがないかどうかを確認します。
cdk synth --context env=dev
エラーがある場合は以下のようなエラーが出るので、必要に応じて修正します。
Error: Validation failed with the following errors:
[undefinedCdkStack/undefinedAppFrontendTargetGroup] Target group name: "undefinedAppFrontendTargetGroup" can have a maximum of 32 characters.
[undefinedCdkStack/undefinedAppBackendTargetGroup] Target group name: "undefinedAppBackendTargetGroup" can have a maximum of 32 characters.
エラーなしに実行ができれば、デプロイします。Docker Imageがうまく動かないものだったりすると、Fargateを立ち上げてエラーが出て落ちて再度立ち上げ直してを繰り返すことになるので、もし過剰に時間がかかっている場合はAWSのマネジメントコンソールを見て確認してください。
cdk deploy --context env=dev
これでIaCによるアプリホスティングは完了です。
あとがき
今回初めてIaCに挑戦してみましたが、インフラ管理をコードベースで行うことで、商用・開発環境など複数の環境で人為的ミスなくインフラ構築をすることができて、非常に良いと感じました。
この記事が少しでも参考になればいいねを押してもらえれば励みになります!
最後まで読んでいただきありがとうございました。
Discussion