CDKでFargate&ECS Exec
はじめに
内容
CDKでECS Execが有効なコンテナーを作成しようとしたら、躓いてしまったのでその紹介しながら、CDKでECS Execできるまでの最小構成のテンプレートを記述しています。
環境
日付 | 2021年8月22日 |
---|---|
aws cli | 2.2.27 |
@aws-cdk/core | 1.117.0 |
ECS Execとは
AWSのDockerコンテナの実行サービスであるECSで実行中のコンテナに対してコマンドを実行できるという機能です。簡単にいうとAWS上のコンテナに対してローカルのコンテナと同じようにdokcer exec
やdocker-compose exec
が実行できます。もちろんbashを起動することで対話的なコマンドラインで作業することもできます。
CDKとは
TypeScriptなどのプログラミング言語 を用いてAWS上のリソースを管理することができます。分類としてはTerraformやCloudFormationなどと同じIaC(Infrastructure as Code)に該当します。特徴としてはTerraformやCloudFormationが宣言的なリソースの宣言なのに対してCDKは手続き的にインフラを記述することができます。
実装していく!
Ecs Execを設定せずに作成
まずはEcs Execを設定しない状態でFaragteを起動します。
上から順に、VPCの作成、ECS Clusterの作成、実行ロールの作成、タスクロールの作成、タスク定義の作成、ECS Serviceの作成という順で記述しています。
ロールなどの権限は以下を公式のサンプルを参考にしています。
参考 デバッグに Amazon ECS Exec を使用する
後の躓きポイントにもなるので予め紹介しておくと、@aws-cdk/aws-ecs-patterns
というECSを利用する際のテンプレートをまとめてくれているパッケージがあります。その中のApplicationLoadBalancedFargateService
FargateをWebアプリケーションとして利用するのに必要なターゲットグループやロードバランサーをまとめてくれる便利なスタックになっています。
import * as cdk from "@aws-cdk/core";
import * as ec2 from "@aws-cdk/aws-ec2";
import * as ecs from "@aws-cdk/aws-ecs";
import * as iam from "@aws-cdk/aws-iam";
import * as ecsp from "@aws-cdk/aws-ecs-patterns";
export class SampleStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const vpc = createVpc(this);
const cluster = createCluster(this, vpc);
const executionRole = createTaskExecutionRole(this);
const taskRole = createTaskRole(this);
const taskDefinition = createTaskDefinition(this, executionRole, taskRole);
const service = createService(this, cluster, taskDefinition);
}
}
function createVpc(stack: cdk.Stack) {
return new ec2.Vpc(stack, `SampleVpc`, {
natGateways: 0,
cidr: "10.0.0.0/16",
maxAzs: 2,
subnetConfiguration: [
{
cidrMask: 24,
name: "application",
subnetType: ec2.SubnetType.PUBLIC,
},
],
});
}
function createCluster(stack: cdk.Stack, vpc: ec2.Vpc) {
const cluster = new ecs.Cluster(stack, "SampleCluster", {
vpc,
clusterName: `${stack.stackName}-cluster`,
});
return cluster;
}
function createTaskExecutionRole(stack: cdk.Stack): iam.Role {
const role = new iam.Role(stack, "EcsTaskExecutionRole", {
roleName: "EcsTaskExecutionRole",
assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AmazonECSTaskExecutionRolePolicy"
),
iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMReadOnlyAccess"),
],
});
return role;
}
function createTaskRole(stack: cdk.Stack): iam.Role {
const role = new iam.Role(stack, "TaskRole", {
assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
});
role.addToPrincipalPolicy(
new iam.PolicyStatement({
actions: [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel",
],
resources: ["*"],
})
);
return role;
}
function createTaskDefinition(
stack: cdk.Stack,
executionRole: iam.Role,
taskRole: iam.Role
) {
const taskDefinition = new ecs.FargateTaskDefinition(
stack,
"SampleTaskDefinition",
{
cpu: 512,
memoryLimitMiB: 2048,
executionRole,
taskRole,
}
);
new ecs.ContainerDefinition(stack, "SampleContainer", {
containerName: "SampleContainer",
taskDefinition,
image: ecs.ContainerImage.fromRegistry("httpd:2.4"),
cpu: 10,
entryPoint: ["sh", "-c"],
command: [
"/bin/sh -c \"echo '<html> <head> <title>Amazon ECS Sample App</title> <style>body {margin-top: 40px; background-color: #333;} </style> </head><body> <div style=color:white;text-align:center> <h1>Amazon ECS Sample App</h1> <h2>Congratulations!</h2> <p>Your application is now running on a container in Amazon ECS.</p> </div></body></html>' > /usr/local/apache2/htdocs/index.html && httpd-foreground\"",
],
portMappings: [
{
hostPort: 80,
containerPort: 80,
protocol: ecs.Protocol.TCP,
},
],
});
return taskDefinition;
}
function createService(
stack: cdk.Stack,
cluster: ecs.Cluster,
taskDefinition: ecs.TaskDefinition
) {
const serviceName = `${stack.stackName}-service`;
const service = new ecsp.ApplicationLoadBalancedFargateService(
stack,
"SampleFargateService",
{
cluster,
serviceName,
cpu: 256,
desiredCount: 1,
taskDefinition,
memoryLimitMiB: 512,
publicLoadBalancer: true,
taskSubnets: cluster.vpc.selectSubnets({
subnetType: ec2.SubnetType.PUBLIC,
}),
assignPublicIp: true,
openListener: true,
}
);
return service;
}
この状態でcdk deploy
コマンドを実行します。
% cdk deploy (git)-[master]
This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:
IAM Statement Changes
┌───┬─────────────────────────────┬────────┬─────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────────┬───────────┐
│ │ Resource │ Effect │ Action │ Principal │ Condition │
├───┼─────────────────────────────┼────────┼─────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────┼───────────┤
│ + │ ${EcsTaskExecutionRole.Arn} │ Allow │ sts:AssumeRole │ Service:ecs-tasks.amazonaws.com │ │
├───┼─────────────────────────────┼────────┼─────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────┼───────────┤
│ + │ ${TaskRole.Arn} │ Allow │ sts:AssumeRole │ Service:ecs-tasks.amazonaws.com │ │
├───┼─────────────────────────────┼────────┼─────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────┼───────────┤
│ + │ * │ Allow │ ssmmessages:CreateControlChannel │ AWS:${TaskRole} │ │
│ │ │ │ ssmmessages:CreateDataChannel │ │ │
│ │ │ │ ssmmessages:OpenControlChannel │ │ │
│ │ │ │ ssmmessages:OpenDataChannel │ │ │
└───┴─────────────────────────────┴────────┴─────────────────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────────────────────────┴───────────┘
IAM Policy Changes
┌───┬─────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────┐
│ │ Resource │ Managed Policy ARN │
├───┼─────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${EcsTaskExecutionRole} │ arn:${AWS::Partition}:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy │
│ + │ ${EcsTaskExecutionRole} │ arn:${AWS::Partition}:iam::aws:policy/AmazonSSMReadOnlyAccess │
└───┴─────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────┘
Security Group Changes
┌───┬───────────────────────────────────────────────────────┬─────┬────────────┬───────────────────────────────────────────────────────┐
│ │ Group │ Dir │ Protocol │ Peer │
├───┼───────────────────────────────────────────────────────┼─────┼────────────┼───────────────────────────────────────────────────────┤
│ + │ ${SampleFargateService/LB/SecurityGroup.GroupId} │ In │ TCP 80 │ Everyone (IPv4) │
│ + │ ${SampleFargateService/LB/SecurityGroup.GroupId} │ Out │ TCP 80 │ ${SampleFargateService/Service/SecurityGroup.GroupId} │
├───┼───────────────────────────────────────────────────────┼─────┼────────────┼───────────────────────────────────────────────────────┤
│ + │ ${SampleFargateService/Service/SecurityGroup.GroupId} │ In │ TCP 80 │ ${SampleFargateService/LB/SecurityGroup.GroupId} │
│ + │ ${SampleFargateService/Service/SecurityGroup.GroupId} │ Out │ Everything │ Everyone (IPv4) │
└───┴───────────────────────────────────────────────────────┴─────┴────────────┴───────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)
Do you wish to deploy these changes (y/n)? y
SampleStack: deploying...
SampleStack: creating CloudFormation changeset...
✅ SampleStack
Outputs:
SampleStack.SampleFargateServiceLoadBalancerDNS31896940 = Sampl-Sampl-1VDQOTYYPHVU4-1458992770.ap-northeast-1.elb.amazonaws.com
SampleStack.SampleFargateServiceServiceURLFB9D8017 = http://Sampl-Sampl-1VDQOTYYPHVU4-1458992770.ap-northeast-1.elb.amazonaws.com
Stack ARN:
arn:aws:cloudformation:ap-northeast-1:303391106411:stack/SampleStack/3c3395a0-03aa-11ec-8105-064a048f128d
SampleStack.SampleFargateServiceServiceURLFB9D8017がロードバランサーのULRです。
アクセスしてうまく行っているかを確認します。
ECS Execを有効にする
ECS Exec未対応で躓いた話
まずどうやってECS Execを有効にするかですが、以下の公式ユーザーガイドではサービス起動時のみでしか有効にできないようです。
参考 デバッグに Amazon ECS Exec を使用する
CDKであれば、ECSサービスのコンストラクタにそのフラグがあるはず...
FargateService
のpropsにはenableExecuteCommand
というには引数がありますが、今回利用している@aws-cdk_aws/ecs-patterns
のApplicationLoadBalancedFargateService
にはそのインターフェースがありません。
ついこないだまで(2021年6月時点)はFargateService
にもこのインターフェースがありませんでした。
ではApplicationLoadBalancedFargateService
でもECS Execする方法をご紹介します。
CDKはCloudFormationのラッパーにであることを利用する
解決策は2つあります。1つはFargateService
を利用してロードバランサーなどのリソースも自分で定義するという方法。前述にもにもあるとおり、私がCDKを触り始めたときはFargateService
もまだenableExecuteCommand
属性がなかったので利用できませんでした。今実装するならこの方法を利用すると思います。
もう一つは、CDKからCloudFormationをゴリゴリと書き換える方法です。今回はこちらを紹介します。というのもCDKはCloudFormationに依存している以上、CloudFormationリリース→CDKで対応という流れになるはずで、enableExecuteCommand
以外でもCloudFormationでは対応しているがCDKでは未対応という場合に役に立つと思います。
それでは実装の仕方を紹介します。
CDKのリソースのコンストラクタは中にはnode.chilren
に一緒に作る子リソースが格納されています。
それらのリソースは最終的にCfnResource
を継承したクラスになり、そのクラスでCloudFormationを記述しています。
今回はApplicationLoadBalancedFargateService
クラスの中にあるFargateService
クラスの中にあるCfnService
クラスがCfnResource
を継承したクラスでです。なのでCfnService
の属性を強引に書き換えることで今回のようにCloudFormationでは対応しているがCDKでは未対応という場合に上書きすることができます。
では実装を見ていきます。
function enableExecuteCommand(
service: ecsp.ApplicationLoadBalancedFargateService
) {
service.node.children.filter(isFargateService).forEach((fargateService) => {
fargateService.node.children.filter(isCfnService).forEach((cfnService) => {
cfnService.addOverride("Properties.EnableExecuteCommand", true);
});
});
}
function isFargateService(cdkChild: any): cdkChild is ecs.FargateService {
return cdkChild instanceof ecs.FargateService;
}
function isCfnService(cdkChild: any): cdkChild is ecs.CfnService {
return cdkChild instanceof ecs.CfnService;
}
解説するとisFargateService
とisCfnService
はTypeScriptの恩恵をギリギリまで受けるためのType Guardです。
ApplicationLoadBalancedFargateService
からCfnService
までたどって、addOverride
で属性を直で書き換えに行っています。
これでECS Execが有効なコンテナを作成することができます。
最終アウトプット
import * as cdk from "@aws-cdk/core";
import * as ec2 from "@aws-cdk/aws-ec2";
import * as ecs from "@aws-cdk/aws-ecs";
import * as iam from "@aws-cdk/aws-iam";
import * as ecsp from "@aws-cdk/aws-ecs-patterns";
export class SampleStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const vpc = createVpc(this);
const cluster = createCluster(this, vpc);
const executionRole = createTaskExecutionRole(this);
const taskRole = createTaskRole(this);
const taskDefinition = createTaskDefinition(this, executionRole, taskRole);
const service = createService(this, cluster, taskDefinition);
}
}
function createVpc(stack: cdk.Stack) {
return new ec2.Vpc(stack, `SampleVpc`, {
natGateways: 0,
cidr: "10.0.0.0/16",
maxAzs: 2,
subnetConfiguration: [
{
cidrMask: 24,
name: "application",
subnetType: ec2.SubnetType.PUBLIC,
},
],
});
}
function createCluster(stack: cdk.Stack, vpc: ec2.Vpc) {
const cluster = new ecs.Cluster(stack, "SampleCluster", {
vpc,
clusterName: `${stack.stackName}-cluster`,
});
return cluster;
}
function createTaskExecutionRole(stack: cdk.Stack): iam.Role {
const role = new iam.Role(stack, "EcsTaskExecutionRole", {
roleName: "EcsTaskExecutionRole",
assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AmazonECSTaskExecutionRolePolicy"
),
iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMReadOnlyAccess"),
],
});
return role;
}
function createTaskRole(stack: cdk.Stack): iam.Role {
const role = new iam.Role(stack, "TaskRole", {
assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
});
role.addToPrincipalPolicy(
new iam.PolicyStatement({
actions: [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel",
],
resources: ["*"],
})
);
return role;
}
function createTaskDefinition(
stack: cdk.Stack,
executionRole: iam.Role,
taskRole: iam.Role
) {
const taskDefinition = new ecs.FargateTaskDefinition(
stack,
"SampleTaskDefinition",
{
cpu: 512,
memoryLimitMiB: 2048,
executionRole,
taskRole,
}
);
new ecs.ContainerDefinition(stack, "SampleContainer", {
containerName: "SampleContainer",
taskDefinition,
image: ecs.ContainerImage.fromRegistry("httpd:2.4"),
cpu: 10,
entryPoint: ["sh", "-c"],
command: [
"/bin/sh -c \"echo '<html> <head> <title>Amazon ECS Sample App</title> <style>body {margin-top: 40px; background-color: #333;} </style> </head><body> <div style=color:white;text-align:center> <h1>Amazon ECS Sample App</h1> <h2>Congratulations!</h2> <p>Your application is now running on a container in Amazon ECS.</p> </div></body></html>' > /usr/local/apache2/htdocs/index.html && httpd-foreground\"",
],
portMappings: [
{
hostPort: 80,
containerPort: 80,
protocol: ecs.Protocol.TCP,
},
],
});
return taskDefinition;
}
function createService(
stack: cdk.Stack,
cluster: ecs.Cluster,
taskDefinition: ecs.TaskDefinition
) {
const serviceName = `${stack.stackName}-service`;
const service = new ecsp.ApplicationLoadBalancedFargateService(
stack,
"SampleFargateService",
{
cluster,
serviceName,
cpu: 256,
desiredCount: 1,
taskDefinition,
memoryLimitMiB: 512,
publicLoadBalancer: true,
taskSubnets: cluster.vpc.selectSubnets({
subnetType: ec2.SubnetType.PUBLIC,
}),
assignPublicIp: true,
openListener: true,
}
);
enableExecuteCommand(service);
return service;
}
function enableExecuteCommand(
service: ecsp.ApplicationLoadBalancedFargateService
) {
/**
* 現状ApplicationLoadBalancedFargateServiceにEnableExecuteCommandをTrueにするプロパティは存在しないため
* 以下の方法で強引に書き換える必要があります。
*/
service.node.children.filter(isFargateService).forEach((fargateService) => {
fargateService.node.children.filter(isCfnService).forEach((cfnService) => {
cfnService.addOverride("Properties.EnableExecuteCommand", true);
});
});
}
function isFargateService(cdkChild: any): cdkChild is ecs.FargateService {
return cdkChild instanceof ecs.FargateService;
}
function isCfnService(cdkChild: any): cdkChild is ecs.CfnService {
return cdkChild instanceof ecs.CfnService;
}
ECS Execのコマンドを実行してみます。
$ aws ecs execute-command \
--cluster "SampleStack-cluster" \
--task "f747f10162534a9b818d26717e604bba" \
--container SampleContainer \
--interactive \
--command "/bin/sh"
The Session Manager plugin was installed successfully. Use the AWS CLI to start a session.
Starting session with SessionId: ecs-execute-command-0395f23f7329ef80a
うまく接続できました。