はじめに
最近、AWS CDKでコンテナ関連のリソース構築をしておりますが、コンテナリソースをどこまでCDKで実装するべきかが大きな悩みの種でした。
今回、コンテナ周辺のリソースをCDK、コンテナ本体のリソースをecspressoで構築した為、構築内容の紹介となっています。
ecspressoとは
ECSサービス、タスクに関わる最小限のリソースをコード管理する事ができるツールです。
CDKを使う中で「CDKでサービス定義、タスク定義をするのは運用上厳しい」と感じた為、
- CDKでサービス定義、タスク定義以外のリソースを構築
- ecspressoでサービス定義、タスク定義、デプロイにも利用
- ecspressoの外部参照機能(後述)を利用し、CDKで定義したリソースを取り込み
といった構築の仕方をしています。
動作環境
cdk 2.59.0
ecspresso v2.1.0
構築内容
今回はサンプルアプリケーションとして
- パブリックサブネットにALB
- プライベートサブネットにnginxコンテナ
を構築していきます。
構築手順
大まかな構築手順は以下です。
- CDKで関連リソースを定義し、デプロイ
- ecspressoでサービス定義、タスク定義をし、デプロイ
スタックの作成
まずはCDKのスタックを記載します。
各種ファイルの記載は以下の通りです。
import { CdkEcspressoStack } from '../lib/cdk-ecspresso-stack';
const app = new cdk.App();
new CdkEcspressoStack(app,'CdkEcspressoStack');
スタックの宣言内容は以下です。
import { Stack, StackProps } from "aws-cdk-lib";
import { Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
import { Vpc } from 'aws-cdk-lib/aws-ec2'
import { Construct } from "constructs";
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ssm from 'aws-cdk-lib/aws-ssm';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import { Cluster } from "aws-cdk-lib/aws-ecs";
import { Repository } from "aws-cdk-lib/aws-ecr";
// CDK + ecspressoで構築するコンテナ関連リソース
export class CdkEcspressoStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id);
// VPC
const vpc = new Vpc(this, "Vpc", { maxAzs: 2 });
const subnetIdList = vpc.privateSubnets.map(obj => obj.subnetId);
// SG
const albSg = new ec2.SecurityGroup(this, 'AlbSg', { vpc, allowAllOutbound: false });
const containerSg = new ec2.SecurityGroup(this, 'ContainerSg', { vpc });
albSg.addIngressRule(ec2.Peer.ipv4('0.0.0.0/0') , ec2.Port.tcp(8080)); // インバウンドを許可
albSg.connections.allowTo(containerSg, ec2.Port.tcp(80)); // ALB ⇔ コンテナ間の通信を許可
// ALB
const alb = new elbv2.ApplicationLoadBalancer(this, 'Alb', { vpc, internetFacing: true, securityGroup: albSg });
// TG
const containerTg = new elbv2.ApplicationTargetGroup(this, 'ContainerTg', { targetType: elbv2.TargetType.IP, port: 80, vpc });
// ALBリスナー
alb.addListener('Listener', { defaultTargetGroups: [containerTg], open: true, port: 8080 }); // 作成したTGをALBに紐づけ
// ECSクラスタ
const cluster = new Cluster(this, 'EcsCluster', { vpc, clusterName: 'cdk-ecspresso' });
// タスクロール
const taskRole = new Role(this, 'TaskRole', { assumedBy: new ServicePrincipal('ecs-tasks.amazonaws.com'), });
// タスク実行ロール
const taskExecRole = new Role(this, 'TaskExecRole', { assumedBy: new ServicePrincipal('ecs-tasks.amazonaws.com'), });
// ロググループ
const logGroup = new LogGroup(this, 'logGroup', {});
// ECR
const repository = new Repository(this, 'Repository', {});
// タスク実行ロールに権限付与
repository.grantPull(taskExecRole); // ECRのPULL権限
logGroup.grantWrite(taskExecRole); // ログ吐き出し権限
// SSMパラメータの設定
new ssm.StringParameter(this, 'TaskRoleParam', { parameterName: '/ecs/cdk-ecspresso/task-role', stringValue: taskRole.roleArn });
new ssm.StringParameter(this, 'TaskExecRoleParam', { parameterName: '/ecs/cdk-ecspresso/task-exec-role', stringValue: taskExecRole.roleArn });
new ssm.StringParameter(this, 'ContainerSubnet1Param', { parameterName: '/ecs/cdk-ecspresso/subnet-id-a', stringValue: subnetIdList[0] });
new ssm.StringParameter(this, 'ContainerSubnet2Param', { parameterName: '/ecs/cdk-ecspresso/subnet-id-c', stringValue: subnetIdList[1] });
new ssm.StringParameter(this, 'ContainerSgParam', { parameterName: '/ecs/cdk-ecspresso/sg-id', stringValue: containerSg.securityGroupId });
new ssm.StringParameter(this, 'ContainerTgParam', { parameterName: '/ecs/cdk-ecspresso/tg-arn', stringValue: containerTg.targetGroupArn });
new ssm.StringParameter(this, 'LogGroupParam', { parameterName: '/ecs/cdk-ecspresso/log-group-name', stringValue: logGroup.logGroupName });
}
}
CDKではコンテナ周辺リソースを定義という事で、VPC・サブネット・ALB・セキュリティグループやターゲットグループだけでなく、ECSクラスターや各種ロールも定義します。
ECRへのpush・pullやログ吐き出し等の権限付与を考えると、各種ロールやロググループはCDK側で明示的に生成すると便利だと考えています。
CDKで宣言したリソースをecspressoで利用する為の工夫
AWS CDKのベストプラクティスの一つとして、自動で生成されるリソース名を使用し、物理的な名前を使用しないというものがあります。
リソース名を明示する方が便利な場面もありますが、明示する事でリソースのUPDATE時に失敗してしまう事もあります。なので、ベストプラクティスにもある通り、可能ならば自動生成名で良いのかなと思います。
ただ外部処理からの参照が必要な際に、決め打ちでリソース名・IDを指定できなくなる点には注意が必要です。それこそスタックやコンストラクトといった、大きな単位での変更を加えた際に自動生成名が変わり、外部処理からの参照箇所も修正となると面倒なので、今回はecspresso v2からの新機能である「SSMパラメータストア参照」機能を利用しています。
その為、ecspressoでデプロイ時に使うパラメータをSSMパラメータとして保存しています。パラメータストアへの保存処理により、CDKによる自動生成の名称が変更になった時の差異を吸収する事が可能です。
// SSMパラメータの設定
new ssm.StringParameter(this, 'TaskRoleParam', { parameterName: '/ecs/cdk-ecspresso/task-role', stringValue: taskRole.roleArn });
new ssm.StringParameter(this, 'TaskExecRoleParam', { parameterName: '/ecs/cdk-ecspresso/task-exec-role', stringValue: taskExecRole.roleArn });
new ssm.StringParameter(this, 'ContainerSubnet1Param', { parameterName: '/ecs/cdk-ecspresso/subnet-id-a', stringValue: subnetIdList[0] });
new ssm.StringParameter(this, 'ContainerSubnet2Param', { parameterName: '/ecs/cdk-ecspresso/subnet-id-c', stringValue: subnetIdList[1] });
new ssm.StringParameter(this, 'ContainerSgParam', { parameterName: '/ecs/cdk-ecspresso/sg-id', stringValue: containerSg.securityGroupId });
new ssm.StringParameter(this, 'ContainerTgParam', { parameterName: '/ecs/cdk-ecspresso/tg-arn', stringValue: containerTg.targetGroupArn });
new ssm.StringParameter(this, 'LogGroupParam', { parameterName: '/ecs/cdk-ecspresso/log-group-name', stringValue: logGroup.logGroupName });
スタックの構築
まずはcdk deploy
でスタックを構築します。
cdk deploy CdkEcspressoStack
ecspresso用のファイルを準備
デプロイの為に、以下3つのファイルを用意します。
region: ap-northeast-1
cluster: cdk-ecspresso
service: nginx
service_definition: ecs-service-def.json
task_definition: ecs-task-def.json
timeout: "10m0s"
plugins:
- name: ssm
以下はサービス定義設定ファイルです。
ターゲットグループARN、セキュリティグループID、サブネットIDはパラメータストアから取得しています。
{
"deploymentConfiguration": {
"deploymentCircuitBreaker": {
"enable": false,
"rollback": false
},
"maximumPercent": 200,
"minimumHealthyPercent": 100
},
"desiredCount": 1,
"enableECSManagedTags": false,
"healthCheckGracePeriodSeconds": 0,
"launchType": "FARGATE",
"loadBalancers": [
{
"containerName": "nginx",
"containerPort": 80,
"targetGroupArn": "{{ ssm `/ecs/cdk-ecspresso/tg-arn` }}"
}
],
"networkConfiguration": {
"awsvpcConfiguration": {
"securityGroups": [
"{{ ssm `/ecs/cdk-ecspresso/sg-id` }}"
],
"subnets": [
"{{ ssm `/ecs/cdk-ecspresso/subnet-id-a` }}",
"{{ ssm `/ecs/cdk-ecspresso/subnet-id-c` }}"
]
}
},
"placementConstraints": [],
"placementStrategy": [],
"platformVersion": "LATEST",
"schedulingStrategy": "REPLICA",
"serviceRegistries": []
}
以下はタスク定義設定ファイルです。
ロググループ名、タスク実行ロールARN、タスクロールARNはパラメータストアから取得しています。
{
"containerDefinitions": [
{
"name": "nginx",
"image": "nginx:latest",
"environment": [
{
"name": "AWS_REGION",
"value": "ap-northeast-1"
},
{
"name": "TZ",
"value": "Asia/Tokyo"
}
],
"essential": true,
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "{{ ssm `/ecs/cdk-ecspresso/log-group-name` }}",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "ecs"
}
},
"portMappings": [
{
"containerPort": 80,
"hostPort": 80,
"protocol": "tcp"
}
]
}
],
"cpu": "256",
"executionRoleArn": "{{ ssm `/ecs/cdk-ecspresso/task-exec-role` }}",
"family": "cdk-ecspresso",
"memory": "512",
"networkMode": "awsvpc",
"requiresCompatibilities": [
"FARGATE"
],
"taskRoleArn": "{{ ssm `/ecs/cdk-ecspresso/task-role` }}"
}
"{{ ssm `/ecs/cdk-ecspresso/sg-id` }}"
といった形でSSMパラメータストア参照を利用しています。
ecspressoでのSSMパラメータストア参照については、以下記事にIAMの設定内容など記載しましたので、宜しければご覧ください。
ecspressoでサービス定義、タスク定義をデプロイ
最後にecspresso deploy
をして、ターミナル上で実行結果を見守ります。
Service is stable now. Completed!
となり、デプロイ成功となります。
さいごに
- 「CDKやCloudFormationでECSのサービス定義、タスク定義まで構築しているが、どうも運用しにくい...」と感じている方や、既存リソースがある程度定義されていて「コンテナの最低限のリソースをコード化&デプロイに使いたい」となった際に、ecspressoは非常に便利なツールです。
- SSMのパラメータストア参照の他、TerraformやCloudFormationのパラメータを参照する事も可能な為、既存のIaCに組み込みやすい点も、ecspressoの良い点だと感じています。
20230503追記
上記デプロイをハンズオン形式で簡単にお試し頂けるように、GitHubリポジトリを公開しました。
https://github.com/yoyoyo-pg/cdk-ecspresso
参考文献
https://github.com/kayac/ecspresso
https://zenn.dev/fujiwara/books/ecspresso-handbook-v2