こちらは AWS CDK Advent Calendar 2021 の 17 日目の記事です。
何について書こうかなとギリギリまで悩んでいたのですが、そういえば以前CDKを触っていてちょっとハマったなということを思い出して、Security Group を用いた接続許可設定についてを整理してみようかと思います。
CDK で EC2 等の VPC に属するリソースについて接続許可の設定をする場合、基本は Construct の機能を使うことになると思いますが、Security Group を直接作成する方法もありますし、 Connections というのを使う方法もあります。
私の場合、初めてAWSを使った頃は、CLI や SDK を使って手続き的にリソースを作ることが多かったので、その頃の経験を引きずって CDK を使い始めてからも同じように手続き的に Security Group を直接作ってリソースにアタッチしていることが多かった気がしますし、最近も Security Group の作成をしていました。Connections は、そもそも使うことほとんど無かったので、この機会に使い方や使う場面をちゃんと理解しておきたいと思います。
この記事では、題材として API Reference の aws_ec2 モジュールの解説にある Allowing Connections の部分を確認しつつ、実際の動作も確認していこうと思います。
なぜこれを書くのか
AWS CDK に興味を持っていて、この記事をご覧頂いているような方々には釈迦に説法だとは思いますが、Security Group は、EC2、ECS、ELB等々の VPC内で使う各種リソース間での接続許可を設定しようとすると、必ず使うことになる大事なものですよね。
AWS CloudFormation であれば、マネジメントコンソールで設定するのと同じように Security Group を作り、EC2 (ENI) にアタッチする Security Group を選ぶといったことを定義していくわけなのですが、 CDK の場合には上手く抽象化されているため Security Group の存在を意識しなくても設定出来たりします。もちろん CloudFormation やマネジメントコンソール同様に、セキュリティグループを作ってアタッチして、ということもできます。
そんな大事な Security Group なのに、いつまでも何となくのままでままでやっていてはハマることもあるし、その時々で設定の仕方が違ったりしてポリシーのない行き当たりばったりな書き方をしていると一緒に動くメンバー(将来の自分含む)がメンテナンスもしづらくなってしまうので、ちょっと整理しておこおうかなと思った次第です。
API Reference には何が書いてあるか
Allowing Connections から引用
In AWS, all network traffic in and out of Elastic Network Interfaces (ENIs) is controlled by Security Groups. You can think of Security Groups as a firewall with a set of rules. By default, Security Groups allow no incoming (ingress) traffic and all outgoing (egress) traffic. You can add ingress rules to them to allow incoming traffic streams. To exert fine-grained control over egress traffic, set allowAllOutbound: false on the SecurityGroup, after which you can add egress traffic rules.
ざっくり要約してみる
- ENI を出入りする全てのネットワークトラフィックは Security Group で制御される
- デフォルトでは、入力トラフィックは許可されず、全ての出力トラフィックは許可される
- 入力ルールを追加することで、入力トラフィックを許可できる
- 出力トラフィックを細かく制御するには、
allowAllOutbound: false
を設定して出力ルールを追加する
直接 Security Group を作って、入力ルールを書く場合の例:
const mySecurityGroup = new ec2.SecurityGroup(this, 'SecurityGroup', {
vpc,
description: 'Allow ssh access to ec2 instances',
allowAllOutbound: true // Can be set to false
});
mySecurityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(22), 'allow ssh access from the world');
IPv4 の from Any で SSH (22/tcp) での接続(入力)を許可しつつ、外部への通信は全部許可する場合は、このようになります。作成したセキュリティグループは、例えば EC2 を作成する定義の中で、この Security Group を使ってねという定義をすることで使用されます。
ちなみに、 allowAllOutbound
は デフォルト が true
ですが、API Referenceのサンプルでは変更するものを明示するために、あえて記述しているようですね。 デフォルトが true
であることは、こちら で確認できます。
allowAllOutbound を false に設定する場合は、他リソースへの影響をよく確認してください。私が過去にはまった際は、ECS と Fargate の構成で作っていた環境において Security Group を直接書いて指定していたのですが、Fargateプラットフォームが1.3.0の時は allowAllOutbound: false でも問題無かったのです。この設定を入れていたのは、セキュリティの観点から余計な通信を許可したくなかったという気持ちがありました。ただFargateプラットフォーム 1.3.0 では Fargate の ENI を使って行われていたAPIリクエストが、同 1.4.0 から Task の ENI を使って行われるようになったことで、Task の起動が出来なくなると言う現象があったことを思い出します。その時は、allowAllOutbound を true にすることで対策としました。ちなみに Fargateプラットフォーム 1.4.0の話は、次の記事が詳しいです。 https://aws.amazon.com/jp/blogs/news/aws-fargate-launches-platform-version-1-4/
CDK のコード(この記事のコードは、全てTypeScriptです)を初めて読む方は、サンプルコードの1行目のnew ec2.SecurityGroup
に注目してください。
ここで宣言されている SecurityGroup の Construct の API Reference を見ると読み解くことが出来ます。右メニューから Construct Propsを選ぶと指定可能なプロパティの一覧が分かります。 vpc
のように最後に ?
がないものは指定が必須、allowAllOutbound?
のように最後に ?
があるものは必要なときだけ指定すれば良いプロパティです。他の Construct についても、同じように見ていくと読み進めやすいかなと思います。
さらに次のようにも記載があります。
All constructs that create ENIs on your behalf (typically constructs that create EC2 instances or other VPC-connected resources) will all have security groups automatically assigned. Those constructs have an attribute called connections, which is an object that makes it convenient to update the security groups. If you want to allow connections between two constructs that have security groups, you have to add an Egress rule to one Security Group, and an Ingress rule to the other. The connections object will automatically take care of this for you:
こちらもざっと要約してみます
- EC2 または VPCに接続するその他のリソースは、ユーザーに代わってENIを作成し、それらには Security Group が自動的に割り当てられる
- これらの構成には、 Connections という属性があり、これは Security Group を更新するために使用できる
- Security Group を持つ二つの構成要素(リソース)の接続を許可する場合、一方には出力ルールを、もう一方には入力ルールを追加する必要があるが、 Connections は これを自動的に処理する
そう、自動で Security Group を作ってくれてるんですよね。なので、先の例の様に、わざわざ明示的に Security Group を作る必要が無かったりします。この辺がCDKは便利だなーと思うところでもあります。使う立場としては Security Group を作りたいのでは無く、 リソース間の接続許可のための設定をしたいだけなので、やりたいことに注力できるのは良いことだと思います。
Connectionsを使用して、何を許可するかのみを意識して書く場合の例:
declare const loadBalancer: elbv2.ApplicationLoadBalancer;
declare const appFleet: autoscaling.AutoScalingGroup;
declare const dbFleet: autoscaling.AutoScalingGroup;
// Allow connections from anywhere
loadBalancer.connections.allowFromAnyIpv4(ec2.Port.tcp(443), 'Allow inbound HTTPS');
// The same, but an explicit IP address
loadBalancer.connections.allowFrom(ec2.Peer.ipv4('1.2.3.4/32'), ec2.Port.tcp(443), 'Allow inbound HTTPS');
// Allow connection between AutoScalingGroups
appFleet.connections.allowTo(dbFleet, ec2.Port.tcp(443), 'App can call database');
API Reference には、こんなサンプルがあります。
loadBalancer
というのは、ALBですね。ALBには、from Any で HTTPS (443/tcp) を許可する場合の例と、特定のIPv4アドレス (1.2.3.4/32) からのみ HTTPS (443/tcp) を許可する例が書かれています。
appFleet
というのは、アプリケーションサーバーのまとまりを意図しているかと思いますが、 appFleet
から dbFleet
への HTTPS (443/tcp) での接続許可も設定されています。(この例は DB の利用も HTTPS での APIなのかな?)
こんな感じで、Security Group の存在を意識しない使い方も出来ます。 CDK のコンセプトを考えると、こちらを積極的に採用していくのが良いのかなーと思ったりもしますが、上手い使い分けを見定めたいところです。
API Referenceには、この後にも 接続ピアの設定、ポート範囲の設定方法、デフォルトのポートを持つConstructについて、Security Group のルールについて、既存の Security Group をインポートする方法 についての記載がありますが、これらは既に紹介したサンプルコードを更に深掘りした内容なので、興味を持った方は実際のドキュメントの方をご覧ください。この記事では一旦ドキュメントは離れて実際のコードで結果を確認してみたいと思います。
試してみる
確認に使用した構成
次のような環境を作って試していきます。
ALB, ECS, Fargate で動く、Webシステムを想定した構成です。例ではデータベースは用意しませんでした。
Construct を活用した場合
最初は、Security Group の直接的な作成や、Connections も使用せずに、Construct の機能だけを使う様に注意して書いてみました。おそらく、これが CDK らしいコードと言えると思います。
いつもだったら、ALB や Fargate サービスの設定の中で、手癖で Security Group を書いてしまっていましたが、そんなことをしなくても CDK が上手いことやってくれているので、すごくシンプルにかけている気がします。今回は検証目的なのでコード量が少ないですが、実環境で規模が大きくなってくると影響も大きくなるのではないでしょうか。
このコードは、AWS CDK v2.1.0 で構築を確認しています。これをベースにして、Security Group を直接作ってみたり、Connections を使ってみたりして違いを見てみたいと思います。
手元で構築を試してみたい場合は、公式ドキュメントやCDK Workshop を参考にして環境構築をしてみてください。
記載しているコード例は、 cdk init app --language typescript
後に lib/cdkv2-stack.ts
のみを編集して、 cdk deploy
を行っています。(ファイル名は cdk init
を実行した際のディレクトリ名によって異なります。私は cdkv2
というディレクトリの中で作業しています。)
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
export class Cdkv2Stack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// ECS cluster の作成 (VPCも一緒に作成してくれてます。別途作成したVPCを指定することもできる。)
// https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ecs-readme.html
const ecsCluster = new ecs.Cluster(this, 'Cluster', {
enableFargateCapacityProviders: true,
});
// タスク定義
// https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html
const ecsTask = new ecs.FargateTaskDefinition(this, 'EcsTask');
const ecsContainer = ecsTask.addContainer('DefaultContainer', {
image: ecs.ContainerImage.fromRegistry("amazon/amazon-ecs-sample"),
});
// タスクが使用するポートを定義
ecsContainer.addPortMappings({
containerPort: 80,
});
// サービスの定義、クラスターやタスクは事前定義したものを指定している
// https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ecs.FargateService.html
const ecsService = new ecs.FargateService(this, 'Service', {
cluster: ecsCluster,
taskDefinition: ecsTask,
desiredCount: 2,
platformVersion: ecs.FargatePlatformVersion.LATEST,
});
// ALBの作成
// https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_elasticloadbalancingv2-readme.html
const albForEcsService = new elbv2.ApplicationLoadBalancer(this, 'Alb', {
vpc: ecsCluster.vpc,
internetFacing: true,
});
// 80版ポートで外部への公開をする。 `open: true` があることで、 from Any での許可となる。
const albListener = albForEcsService.addListener('Listener', {
port: 80,
open: true, // true がデフォルト
});
albListener.addTargets('ecsService', {
protocol: elbv2.ApplicationProtocol.HTTP,
targets: [ecsService],
});
}
}
CDKのコードとしての補足
- VPC作成は、ECSのクラスタ作成に任せている (ecs.Clusterのデフォルト動作)
- Security Group の設定には、Security Group の明示的な作成や、Connections の利用も行っていない
- Security Group を直接作成してアタッチする場合、次の箇所で指定を行うことができる
今回はサンプルとして、一つのスタックにまとめてしまっていますが、実際の利用では他リソースの必要性も踏まえた上で、スタックの分割も考慮した方が良いと思います
できあがった Security Group
ALB用
Fargate用
この後の例では、手段は違うものの、これらと同じ結果になるように書いています。
Security Group の直接的な作成を行った場合
ではここで、敢えて Security Group を直接作成してみるということをやってみます。CDK に慣れていない内は、ある意味手続き的なこういった書き方をしてしまうことがあると思います。
コード全体はこちら
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
export class Cdkv2Stack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// ECS cluster の作成 (VPCも一緒に作成してくれてます。別途作成したVPCを指定することもできる。)
// https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ecs-readme.html
const ecsCluster = new ecs.Cluster(this, 'Cluster', {
enableFargateCapacityProviders: true,
});
// Security Group 定義
// ALB用
const securityGroupForAlb = new ec2.SecurityGroup(this, 'SgAlb', {
vpc: ecsCluster.vpc,
allowAllOutbound: false
});
securityGroupForAlb.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80));
// Fargate用
const securityGroupForFargate = new ec2.SecurityGroup(this, 'SgFargate', {
vpc: ecsCluster.vpc,
allowAllOutbound: true
});
securityGroupForFargate.addIngressRule(securityGroupForAlb, ec2.Port.tcp(80));
// タスク定義
// https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html
const ecsTask = new ecs.FargateTaskDefinition(this, 'EcsTask');
const ecsContainer = ecsTask.addContainer('DefaultContainer', {
image: ecs.ContainerImage.fromRegistry("amazon/amazon-ecs-sample"),
});
// タスクが使用するポートを定義
ecsContainer.addPortMappings({
containerPort: 80,
});
// サービスの定義、クラスターやタスクは事前定義したものを指定している
// https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ecs.FargateService.html
const ecsService = new ecs.FargateService(this, 'Service', {
cluster: ecsCluster,
taskDefinition: ecsTask,
desiredCount: 2,
platformVersion: ecs.FargatePlatformVersion.LATEST,
securityGroups: [ securityGroupForFargate ],
});
// ALBの作成
// https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_elasticloadbalancingv2-readme.html
const albForEcsService = new elbv2.ApplicationLoadBalancer(this, 'Alb', {
vpc: ecsCluster.vpc,
internetFacing: true,
securityGroup: securityGroupForAlb,
});
// 80版ポートで外部への公開をする。 `open: true` があることで、 from Any での許可となる。
const albListener = albForEcsService.addListener('Listener', {
port: 80,
open: true, // true がデフォルト
});
albListener.addTargets('ecsService', {
protocol: elbv2.ApplicationProtocol.HTTP,
targets: [ecsService],
});
}
}
全体を見ても、何が変わったかよく分からないかと思いますので、最初の例との差分を以下に示します。
以下のように Security Group の定義をまとめてやっています。実は記述する場所もポイントがあり、VPCが作成されたあと且つ Security Group を利用するリソースの作成前である必要があります。
ECS クラスタを作成する中でVPCの作成をしていたので、ECSのクラスタ作成の後にまとめて書いています。また Fargate 用の Security Group は 接続元として ALB に限定したかったので、Security Group を作成する順序も気をつける必要がありました。
「ECSクラスタ」「ECSサービス」「ALB」位の粒度までなら、コードとして依存関係を定義していくのもツラくないのですが、「ALBが使用する Security Group と Fargate が使用する Security グループがあって、ALB から Fargate への通信を制御したいから記述していく順序は....」となってくると、書いていてちょっと大変だなと感じます。
$ diff -u lib/cdkv2-stack.ts.orig lib/cdkv2-stack.ts
--- lib/cdkv2-stack.ts.orig 2021-12-14 22:08:58.000000000 +0900
+++ lib/cdkv2-stack.ts 2021-12-15 00:03:27.000000000 +0900
@@ -15,6 +15,23 @@
enableFargateCapacityProviders: true,
});
+
+ // Security Group 定義
+ // ALB用
+ const securityGroupForAlb = new ec2.SecurityGroup(this, 'SgAlb', {
+ vpc: ecsCluster.vpc,
+ allowAllOutbound: false
+ });
+ securityGroupForAlb.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80));
+
+ // Fargate用
+ const securityGroupForFargate = new ec2.SecurityGroup(this, 'SgFargate', {
+ vpc: ecsCluster.vpc,
+ allowAllOutbound: true
+ });
+ securityGroupForFargate.addIngressRule(securityGroupForAlb, ec2.Port.tcp(80));
+
+
// タスク定義
// https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html
const ecsTask = new ecs.FargateTaskDefinition(this, 'EcsTask');
@@ -33,6 +50,7 @@
taskDefinition: ecsTask,
desiredCount: 2,
platformVersion: ecs.FargatePlatformVersion.LATEST,
+ securityGroups: [ securityGroupForFargate ],
});
@@ -41,6 +59,7 @@
const albForEcsService = new elbv2.ApplicationLoadBalancer(this, 'Alb', {
vpc: ecsCluster.vpc,
internetFacing: true,
+ securityGroup: securityGroupForAlb,
});
// 80版ポートで外部への公開をする。 `open: true` があることで、 from Any での許可となる。
const albListener = albForEcsService.addListener('Listener', {
この例のまとめ
- この例では、Security Group の前に ECS クラスタの定義も必要だった
- VPCを作成した後じゃ無いと、 Security Group を定義出来ない
- VPCを別スタックで作っているなら、ECS クラスタは気にしなくて良いが、とはいえ順序を気にする必要がある
- Security Group を最初にまとめて定義する必要がある
- peer として Security Group を指定するために、記述する順序を意識する必要がある
- マネジメントコンソールで行う操作を思い浮かべながら、コードとして記述する順序を考えなければならず書いていて快適じゃなかった
- 単純にツライ
- 差分を見ると減っているところが無く、追加だけというのも残念なところ (大変になって、コード量も増える)
大きな粒度でリソース間の依存関係を定義する辺りは自然に書いていけるのですが、Security Groupのような細かい単位でそれをやろうとするとだんだんツラく感じてきました。手続き的に記述の順序を気にしなければならない範囲が増えてくるのが、しんどいのかなーと思います。
Connectionsを使った場合
こちらのパターンは、Connections の使い処が正直無かったのですが、比較のために一応やってみました。
コード全体はこちら
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
export class Cdkv2Stack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// ECS cluster の作成 (VPCも一緒に作成してくれてます。別途作成したVPCを指定することもできる。)
// https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ecs-readme.html
const ecsCluster = new ecs.Cluster(this, 'Cluster', {
enableFargateCapacityProviders: true,
});
// タスク定義
// https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html
const ecsTask = new ecs.FargateTaskDefinition(this, 'EcsTask');
const ecsContainer = ecsTask.addContainer('DefaultContainer', {
image: ecs.ContainerImage.fromRegistry("amazon/amazon-ecs-sample"),
});
// タスクが使用するポートを定義
ecsContainer.addPortMappings({
containerPort: 80,
});
// サービスの定義、クラスターやタスクは事前定義したものを指定している
// https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ecs.FargateService.html
const ecsService = new ecs.FargateService(this, 'Service', {
cluster: ecsCluster,
taskDefinition: ecsTask,
desiredCount: 2,
platformVersion: ecs.FargatePlatformVersion.LATEST,
});
// ALBの作成
// https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_elasticloadbalancingv2-readme.html
const albForEcsService = new elbv2.ApplicationLoadBalancer(this, 'Alb', {
vpc: ecsCluster.vpc,
internetFacing: true,
});
// 80版ポートで外部への公開をする。 `open: true` があることで、 from Any での許可となる。
const albListener = albForEcsService.addListener('Listener', {
port: 80,
open: false,
});
albListener.addTargets('ecsService', {
protocol: elbv2.ApplicationProtocol.HTTP,
targets: [ecsService],
});
albForEcsService.connections.allowFrom(ec2.Peer.ipv4('0.0.0.0/0'), ec2.Port.tcp(80), "Allow from anyone on port 80");
}
}
先ほどの例と同じく全体を見てもよくわかないと思いますので差分をご覧ください。
ApplicationLoadBalancer の Construct でリスナーを作成すると、デフォルトでは from Any での接続を許可しますので、これをまず無効化します。
その後、敢えて Connections を使って、from Any での許可設定を行っています。通常は行う必要が無いですが、接続元のIPアドレスに制限をかけたいときなんかはこの方法が使えますね。Security Groupを直接定義するとまた面倒なので、こういったちょっとした調整をするのに Connections を使うのは便利そうです。
$ diff -u lib/cdkv2-stack.ts.orig lib/cdkv2-stack.ts
--- lib/cdkv2-stack.ts.orig 2021-12-14 22:08:58.000000000 +0900
+++ lib/cdkv2-stack.ts 2021-12-14 22:36:52.000000000 +0900
@@ -45,12 +45,13 @@
// 80版ポートで外部への公開をする。 `open: true` があることで、 from Any での許可となる。
const albListener = albForEcsService.addListener('Listener', {
port: 80,
- open: true, // true がデフォルト
+ open: false,
});
albListener.addTargets('ecsService', {
protocol: elbv2.ApplicationProtocol.HTTP,
targets: [ecsService],
});
+ albForEcsService.connections.allowFrom(ec2.Peer.ipv4('0.0.0.0/0'), ec2.Port.tcp(80), "Allow from anyone on port 80");
}
}
この例のまとめ
- Construct をちゃんと使えば、敢えて Connections を使う場面はほとんど無い
- Construct のデフォルトだと都合が悪い場合のみ、設定を調整すれば良い
- 設定の調整をするのに、Connections は便利且つわかりやすい
このパターンは何の不満も無いですね。とても快適に書くことが出来ます。
全体のまとめ
- Constructの機能を活かして書くと、Security Groupを直接書かないといけない場面はほぼ無いのではないか
- Security Group を直接書いていくと、CDKに任せられず、自分自身で考えねばならない範囲(依存関係や順序)が増えてきて書くのが大変になってくる
- Construct で実現出来ない範囲は、 Connections を使うのが良い
こんな感じで、当たり前なところに落ち着いた気がしますが、個人的には、何となくでやっていたことを整理して理解できたので良い機会になりました。今回改めて確認してみて、Security Group を直接書くのはやめようと思いました。少なくとも初手でいきなり Security Group を書くという選択肢は無いですね。
最後まで読んで頂きありがとうございます。少しでもお役に立つことがあれば幸いです。 Happy Coding ! メリークリスマス ! 良いお年を !