こんにちは。AWS CDKでAWSをいろいろ操作する練習中です。
前回はVPC上にEC2インスタンスを立てましたが、今回はECS編です。
ECSとは「Amazon Elastic Container Service」のことで、いわゆるAWS上にDockerコンテナを実行する環境を提供してくれます。
さらに実行するサーバ環境は自分でEC2インスタンスを用意するタイプと、その環境すらもAWSに用意してもらうFargateタイプがあります。Fargateタイプは実行するDocker イメージだけを用意すればよいので、ホント手軽に環境を構築できます。
今回はそのFargateタイプを使います。
前提
- AWS CDK で Infrastructure as Code する: VPC編 でVPCが構築できていること
やってみる
作る環境はこんな感じ。
- ECSサービスでnginx上にサンプルのアプリ(Hello的なヤツ) を立ち上げます。
- Public Subnetに Application Load Balancerを設置(ALB)
- ALBのセキュリティグループは、WEB用のポート8080に外部のネットワークからアクセスOKとする
- なかで待ち受けるnginxはポート80番で動くのですが、区別するためにあえて外(ALBのリスナのポート)はポート8080にしてみました
- Private Subnet にECS環境を構築
- ECSのセキュリティグループは、ALBのセキュリティグループからと、VPC内のネットからのアクセスのみOKとする
- Dockerのイメージは、シンプルにDocker Hub上のnginxを用います。その定義を書いたECSのタスク定義とECSのサービスを作成
- ECSが必要とするロールを作成
CDKのソースコード
コードです。作成済みのGitのリンクです。適宜ダウンロードするかGitのコマンドで取得してください。
さて、コードの主要部分だけ、のせておきます。
main部分
メイン部分で様々なスタックを作っていきます。前回の記事までに作成したVPCのうえに
- ECSSecurityGroupStack: ELBとECSが使用するセキュリティグループ
- ECSRoleStack: ECSが使用するロール
- ELBStack: Public Subnet に配置するALB
- ECSServiceELBStack: そのELB上にListenerと、そのリスナの先のTargetGroup
- AppTaskdefinitionStack: ECSサービスのコンテナを指定するタスク定義
- AppServiceStack: ECSサービス自体
を作っていきます。
#!/usr/bin/env node
import 'source-map-support/register'
import * as cdk from 'aws-cdk-lib'
import { VPCStack } from '../lib/VPCStack'
import { ELBStack } from '../lib/ELBStack'
import { ClusterStack } from '../lib/ClusterStack'
import { ECSRoleStack } from '../lib/ECSRoleStack'
import { ECSServiceStack } from '../lib/ECSServiceStack'
import { BastionStack } from '../lib/BastionStack'
import { ECSSecurityGroupStack } from '../lib/ECSSecurityGroupStack'
import { ECSServiceELBStack } from '../lib/ECSServiceELBStack'
import { AppTaskdefinitionStack } from '../lib/AppTaskdefinitionStack'
import { ContainerInfo, ServiceInfo } from '../lib/Utils'
const main = () => {
const app = new cdk.App()
const vpcStack = new VPCStack(app, 'VPCStack')
// new BastionStack(app, 'BastionStack', vpcStack.vpc, vpcStack.publicSubnets[0])
const sgStack = new ECSSecurityGroupStack(app, 'ECSSecurityGroupStack', { vpc: vpcStack.vpc })
const clusterStack = new ClusterStack(app, 'ClusterStack')
const ecsRoleStack = new ECSRoleStack(app, 'ECSRoleStack')
const elbStack = new ELBStack(app, 'ELBStack', {
subnets: vpcStack.publicSubnets,
elbSecuriyGroup: sgStack.ELBSecurityGroup,
})
const serviceInfo: ServiceInfo = {
serviceName: 'app-service',
listenerPort: 8080,
testListenerPort: 9080,
}
const containerInfo: ContainerInfo = {
name: 'app',
port: 80,
healthCheckPath: '/',
}
const serviceStackELB = new ECSServiceELBStack(app, 'ECSServiceELBStack', {
loadbalancer: elbStack.loadbalancer,
vpc: vpcStack.vpc,
containerInfo,
serviceInfo,
})
const appTaskdefinition = new AppTaskdefinitionStack(app, 'AppTaskdefinitionStack', {
ecsTaskRole: ecsRoleStack.ecsTaskRole,
ecsTaskExecutionRole: ecsRoleStack.ecsTaskExecutionRole,
containerInfo,
})
const serviceStack = new ECSServiceStack(app, 'AppServiceStack', {
cluster: clusterStack.cluster,
subnets: vpcStack.privateSubnets, // ECSを配置するネットはPrivate Subnet
taskDef: appTaskdefinition.taskDef,
ecsSecurityGroup: sgStack.ECSSecurityGroup,
targetGroup: serviceStackELB.targetGroup,
containerInfo,
serviceInfo,
})
}
main()
SecurityGroup
セキュリティグループ(SG)です。ELB向けのSGは外からの8080-8088への通信を受け付けるようにしました。ECSはサービスを増やしていくとリスナを増やしていかないといけないので、ポートを幅であけておくことにします。
ECS向けのSGは、ELBのSGからとVPCのネットワークからの通信を受け付けるようにしました。9080系とかもいつか使うんですが、今回は説明は割愛します。
import { App, Stack, StackProps } from 'aws-cdk-lib'
import { CfnSecurityGroup, CfnSecurityGroupIngress, CfnSubnet, CfnVPC } from 'aws-cdk-lib/aws-ec2'
import { Profile, getProfile } from './Utils'
type ECSSecurityGroupStackProps = StackProps & {
vpc: CfnVPC
}
export class ECSSecurityGroupStack extends Stack {
public readonly ECSSecurityGroup: CfnSecurityGroup
public readonly ELBSecurityGroup: CfnSecurityGroup
constructor(scope: App, id: string, /*vpc: CfnVPC,*/ props: ECSSecurityGroupStackProps) {
super(scope, id, props)
const p = getProfile(this)
// 8080-8088,9080-9088 がOKなセキュリティグループをELB用に作成
this.ELBSecurityGroup = createELBSecurityGroup(this, `ELBSecurityGroup${p.name}`, props.vpc, p)
// ELBのSGと、VPCのネットからのアクセスを許可
this.ECSSecurityGroup = createECSSecurityGroup(
this,
`ECSSecurityGroup${p.name}`,
props.vpc,
this.ELBSecurityGroup,
p,
)
}
}
const createELBSecurityGroup = (stack: Stack, id: string, vpc: CfnVPC, p: Profile): CfnSecurityGroup => {
const group = new CfnSecurityGroup(stack, id, {
groupName: `elb-sg${p.name}`,
groupDescription: 'elb-sg',
vpcId: vpc.attrVpcId,
tags: [{ key: 'Name', value: `ELB-SecurityGroup${p.name}` }],
})
new CfnSecurityGroupIngress(stack, 'ELBSecurityGroupIngress000', {
ipProtocol: '-1',
groupId: group.ref,
sourceSecurityGroupId: group.ref,
})
new CfnSecurityGroupIngress(stack, 'ELBSecurityGroupIngress001', {
ipProtocol: 'tcp',
fromPort: 8080,
toPort: 8088,
groupId: group.ref,
cidrIp: '0.0.0.0/0',
})
new CfnSecurityGroupIngress(stack, 'ELBSecurityGroupIngress002', {
ipProtocol: 'tcp',
fromPort: 9080,
toPort: 9088,
groupId: group.ref,
cidrIp: '0.0.0.0/0',
})
return group
}
const createECSSecurityGroup = (
stack: Stack,
id: string,
vpc: CfnVPC,
elbsg: CfnSecurityGroup,
p: Profile,
): CfnSecurityGroup => {
const group = new CfnSecurityGroup(stack, id, {
groupName: `ecs-sg${p.name}`,
groupDescription: 'ecs-sg',
vpcId: vpc.attrVpcId,
tags: [{ key: 'Name', value: `ECS-SecurityGroup${p.name}` }],
})
new CfnSecurityGroupIngress(stack, 'ECSSecurityGroupIngress000', {
ipProtocol: '-1',
groupId: group.ref,
sourceSecurityGroupId: group.ref,
})
new CfnSecurityGroupIngress(stack, 'ECSSecurityGroupIngress001', {
ipProtocol: '-1',
groupId: group.ref,
sourceSecurityGroupId: elbsg.ref,
})
new CfnSecurityGroupIngress(stack, 'ECSSecurityGroupIngress002', {
ipProtocol: '-1',
groupId: group.ref,
cidrIp: vpc.cidrBlock,
})
return group
}
ELB
ELBです。ELBにはALB(Application Load Balancer)とNLB(Network Load Balancer) があるのですが今回はALBで。なのでTypeはapplication で、インターネット向け(internet-facing) なELBを作成しています。サブネットはPublicなサブネットを指定しています。
インターネットに公開しない内部向けのELBを構築する際は、schemeをinternet-facingではなくinternalに、サブネットはPrivateなサブネットを指定します。
セキュリティグループは先に作成したELB用のSGを指定しています。
import { App, Stack, StackProps } from 'aws-cdk-lib'
import { CfnSecurityGroup, CfnSecurityGroupIngress, CfnSubnet, CfnVPC } from 'aws-cdk-lib/aws-ec2'
import { Profile, getProfile, toRefs } from './Utils'
import { CfnLoadBalancer } from 'aws-cdk-lib/aws-elasticloadbalancingv2'
// import * as sqs from 'aws-cdk-lib/aws-sqs';
type ELBStackProps = StackProps & {
subnets: CfnSubnet[]
elbSecuriyGroup: CfnSecurityGroup
albFlag?: boolean
internetFlag?: boolean
}
export class ELBStack extends Stack {
public readonly loadbalancer: CfnLoadBalancer
constructor(scope: App, id: string, props: ELBStackProps) {
props.albFlag = props.albFlag ?? true // 指定しないときはtrue(ALB)
props.internetFlag = props.internetFlag ?? true // 指定しないときはtrue(internet-facing)
super(scope, id, props)
const p = getProfile(this)
const type = props.albFlag ? 'application' : 'network'
const scheme = props.internetFlag ? 'internet-facing' : 'internal'
this.loadbalancer = createELB(this, 'app-ELB', type, scheme, props.subnets, [props.elbSecuriyGroup], p)
}
}
const createELB = (
stack: Stack,
name: string,
type: string,
scheme: string,
subnets: CfnSubnet[],
elbsgs: CfnSecurityGroup[],
p: Profile,
): CfnLoadBalancer => {
return new CfnLoadBalancer(stack, `${name}${p.name}`, {
type,
name: `${name}${p.name}`,
subnets: toRefs(subnets),
securityGroups: toRefs(elbsgs),
scheme,
})
}
ECSタスク定義
ECSのタスク定義です。まずは全体。
import { App, ScopedAws, Stack, StackProps } from 'aws-cdk-lib'
import { ContainerInfo, getProfile } from './Utils'
import { CfnTaskDefinition } from 'aws-cdk-lib/aws-ecs'
import { CfnRole } from 'aws-cdk-lib/aws-iam'
type AppTaskdefinitionStackProps = StackProps & {
ecsTaskRole: CfnRole
ecsTaskExecutionRole: CfnRole
containerInfo: ContainerInfo
}
export class AppTaskdefinitionStack extends Stack {
public readonly taskDef: CfnTaskDefinition
constructor(scope: App, id: string, props: AppTaskdefinitionStackProps) {
super(scope, id, props)
const p = getProfile(this)
const { accountId, region } = new ScopedAws(this)
this.taskDef = new CfnTaskDefinition(this, 'ECSTaskDefinition', {
family: `${props.containerInfo.name}-taskdefinition${p.name}`,
containerDefinitions: [
{
essential: true,
image: 'nginx',
name: props.containerInfo.name,
logConfiguration: {
logDriver: 'awslogs',
options: {
'awslogs-create-group': 'true',
'awslogs-group': `/ecs/app-taskdefinition${p.name}`,
'awslogs-region': `${region}`,
'awslogs-stream-prefix': 'ecs',
},
},
memoryReservation: 100,
portMappings: [
{
containerPort: props.containerInfo.port,
hostPort: props.containerInfo.port,
protocol: 'tcp',
},
],
},
],
taskRoleArn: props.ecsTaskRole.attrArn,
executionRoleArn: props.ecsTaskExecutionRole.attrArn,
networkMode: 'awsvpc',
requiresCompatibilities: ['FARGATE'],
cpu: '256',
memory: '512',
})
}
}
大事なとこは containerDefinitionsの配列でDockerのコンテナイメージを指定しています。メイン部からパラメタでもらっている値を埋めてみると、コンテナ定義の部分はこんな感じ。
containerDefinitions: [
{
image: 'nginx',
name: 'app',
logConfiguration: {
logDriver: 'awslogs',
options: {
'awslogs-create-group': 'true',
'awslogs-group': `/ecs/app-taskdefinition`,
'awslogs-region': `${region}`,
'awslogs-stream-prefix': 'ecs',
},
},
portMappings: [
{
containerPort: 80,
hostPort: 80,
protocol: 'tcp',
},
],
},...
イメージはnginxのイメージ(実際はコンテナイメージのURLを入れたりします)、コンテナはポートは80番を使用、ログはCloudWatchへ転送してね1、みたいな指定をしています。
ECSサービスが使うリスナやターゲットグループ
ELB関連のリスナやターゲットグループです。まずは全体。
import { App, ScopedAws, Stack, StackProps } from 'aws-cdk-lib'
import { CfnVPC } from 'aws-cdk-lib/aws-ec2'
import { ContainerInfo, ServiceInfo, getProfile } from './Utils'
import { CfnListener, CfnLoadBalancer, CfnTargetGroup } from 'aws-cdk-lib/aws-elasticloadbalancingv2'
const createTargetGroup = (
stack: Stack,
id: string,
groupName: string,
containerInfo: ContainerInfo,
loadbalancer: CfnLoadBalancer,
vpcId: string,
): CfnTargetGroup => {
const targetProtocol = loadbalancer.type === 'network' ? 'TCP' : 'HTTP'
const param = {
healthCheckPath: containerInfo.healthCheckPath,
name: groupName,
port: containerInfo.port,
protocol: targetProtocol,
targetType: 'ip',
healthCheckProtocol: 'HTTP',
healthCheckTimeoutSeconds: 20,
unhealthyThresholdCount: 5,
vpcId: vpcId,
}
if (loadbalancer.type === 'network') {
return new CfnTargetGroup(stack, id, param)
} else {
return new CfnTargetGroup(stack, id, {
...param,
// ALBがStickySessionしたい場合。
targetGroupAttributes: [
{
key: 'stickiness.enabled',
value: 'true',
},
{
key: 'stickiness.type',
value: 'lb_cookie',
},
{
key: 'stickiness.lb_cookie.duration_seconds',
value: '86400',
},
],
})
}
}
const createListener = (
stack: Stack,
id: string,
targetGroup: CfnTargetGroup,
listenerPort: number,
loadbalancer: CfnLoadBalancer,
): CfnListener => {
return new CfnListener(stack, id, {
defaultActions: [
{
type: 'forward',
targetGroupArn: targetGroup.ref,
},
],
loadBalancerArn: loadbalancer.ref,
port: listenerPort,
protocol: loadbalancer.type === 'network' ? 'TCP' : 'HTTP',
})
}
type ECSServiceELBStackProps = StackProps & {
loadbalancer: CfnLoadBalancer
vpc: CfnVPC
containerInfo: ContainerInfo
serviceInfo: ServiceInfo
}
export class ECSServiceELBStack extends Stack {
public readonly targetGroup: CfnTargetGroup
public readonly targetGroupSub: CfnTargetGroup
public readonly listener: CfnListener
public readonly testListener: CfnListener
constructor(scope: App, id: string, props: ECSServiceELBStackProps) {
super(scope, id, props)
const p = getProfile(this)
const { accountId, region } = new ScopedAws(this)
const serviceName = props.serviceInfo.serviceName
const targetGroup = createTargetGroup(
this,
'TargetGroup',
`${serviceName}-group`,
props.containerInfo,
props.loadbalancer,
props.vpc.ref,
)
const targetGroupSub = createTargetGroup(
this,
'TargetGroupSub',
`${serviceName}-groupsub`,
props.containerInfo,
props.loadbalancer,
props.vpc.ref,
)
this.targetGroup = targetGroup
this.targetGroupSub = targetGroupSub
const listener = createListener(this, 'Listener', targetGroup, props.serviceInfo.listenerPort, props.loadbalancer)
const testListener = createListener(
this,
'ListenerTest',
targetGroup,
props.serviceInfo.testListenerPort!,
props.loadbalancer,
)
this.listener = listener
this.testListener = testListener
}
}
ELBに8080ポートでリクエストを受け付けるリスナーを作成し、デフォルトの挙動として作成するターゲットグループに転送するよう設定しています。でターゲットグループは、コンテナのポート80を設定し、 targetType: 'ip',
なグループとしています。またターゲットグループは通常「そのグループに属するリソース2」も指定するんですが、ECSサービス側で動的に制御するので、ココでは指定しなくてOKです。
その他もろもろオプション指定もあるんですが、説明は割愛します。
ECSサービス
最後にECSのサービスです。
import { App, ScopedAws, Stack, StackProps } from 'aws-cdk-lib'
import { CfnSecurityGroup, CfnSubnet } from 'aws-cdk-lib/aws-ec2'
import { ContainerInfo, ServiceInfo, getProfile, toRefs } from './Utils'
import { CfnCluster, CfnService, CfnTaskDefinition } from 'aws-cdk-lib/aws-ecs'
import { CfnTargetGroup } from 'aws-cdk-lib/aws-elasticloadbalancingv2'
type ECSServiceStackProps = StackProps & {
subnets: CfnSubnet[]
cluster: CfnCluster
taskDef: CfnTaskDefinition
ecsSecurityGroup: CfnSecurityGroup
containerInfo: ContainerInfo
serviceInfo: ServiceInfo
targetGroup: CfnTargetGroup
}
export class ECSServiceStack extends Stack {
public readonly ecsService: CfnService
constructor(scope: App, id: string, props: ECSServiceStackProps) {
super(scope, id, props)
const p = getProfile(this)
const { accountId, region } = new ScopedAws(this)
const DesiredCount = props.subnets.length
const ecsService = new CfnService(this, 'ECSService', {
cluster: props.cluster.ref,
// deploymentController: ECS
capacityProviderStrategy: [
{
capacityProvider: 'FARGATE',
base: 0,
weight: 1,
},
],
deploymentController: {
type: 'ECS',
},
deploymentConfiguration: {
maximumPercent: 200,
minimumHealthyPercent: 100,
deploymentCircuitBreaker: {
enable: true,
rollback: true,
},
},
// deploymentController: ECS
// // deploymentController: CODE_DEPLOY
// launchType: 'FARGATE',
// deploymentController: {
// type: 'CODE_DEPLOY',
// },
// // deploymentController: CODE_DEPLOY
taskDefinition: props.taskDef.ref,
serviceName: props.serviceInfo.serviceName,
schedulingStrategy: 'REPLICA',
desiredCount: DesiredCount,
loadBalancers: [
{
containerName: props.containerInfo.name,
containerPort: props.containerInfo.port,
// LoadBalancerName:
// Ref: 'AWS::NoValue'
targetGroupArn: props.targetGroup.ref,
},
],
networkConfiguration: {
awsvpcConfiguration: {
assignPublicIp: 'DISABLED',
securityGroups: toRefs([props.ecsSecurityGroup]),
subnets: toRefs(props.subnets),
},
},
platformVersion: 'LATEST',
serviceConnectConfiguration: { enabled: false },
tags: [{ key: 'Name', value: `ECS${p.name}` }],
enableEcsManagedTags: true,
})
this.ecsService = ecsService
}
}
タスク定義は先ほど作成したもの(taskDefinition: props.taskDef.ref
)をつかうとか、セキュリティグループは先ほど作成したSGを指定するとか、下記のようなロードバランサの設定をするとか、
loadBalancers: [
{
containerName: 'app',
containerPort: 80,
targetGroupArn: props.targetGroup.ref, // このターゲットグループを使ってね、という指定
},
],
ECSを配置するサブネットはPrivateサブネットにする、などなどの設定をしています。
これで ELB:8080 → リスナの定義で ポート80のターゲットグループに転送 → ターゲットグループに設定されたコンテナたちに転送
という構成ができましたね。
cdk.json
{
"app": "npx ts-node --prefer-ts-exts bin/cdk-samples.ts",
...
"context": {
...
"dev": {
"name": "dev-20230903"
}
}
}
実行してみる
長かったですね。。CDKを実行して、ECSサービスでWEBサーバを立ててみましょう。
$ yarn cdk deploy --all
... しばらくかかります
$
終わったようです。
ちゃんとできているか、WEBサーバにアクセスしてみます。
% aws elbv2 describe-load-balancers \
--query "LoadBalancers[*].[LoadBalancerName,DNSName]" \
--output table
---------------------------------------------------------------------------------------------------------------------
| DescribeLoadBalancers |
+---------------------------------+---------------------------------------------------------------------------------+
| ....... | xx.elb.ap-northeast-1.amazonaws.com |
| app-ELB-dev-20230903 | app-ELB-dev-20230903-1280708179.ap-northeast-1.elb.amazonaws.com |
+---------------------------------+---------------------------------------------------------------------------------+
%
$ curl http://app-ELB-dev-20230903-1280708179.ap-northeast-1.elb.amazonaws.com:8080 -i
HTTP/1.1 200 OK
Date: Sun, 27 Aug 2023 16:08:03 GMT
Content-Type: text/html
Content-Length: 615
Connection: keep-alive
Set-Cookie: AWSALB=oCDHz/dYXw1e0q8vCAQyHHxxxxQG71ZGv64ybMzI; Expires=Sun, 03 Sep 2023 16:08:03 GMT; Path=/
Set-Cookie: AWSALBCORS=oCDHz/dYXw1e0xxxxxx71ZGv64ybMzI; Expires=Sun, 03 Sep 2023 16:08:03 GMT; Path=/; SameSite=None
Server: nginx/1.25.2
Last-Modified: Tue, 15 Aug 2023 17:03:04 GMT
ETag: "64dbafc8-267"
Accept-Ranges: bytes
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>
<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>
<p><em>Thank you for using nginx.</em></p>
</body>
</html>
$
うまくいったようですね!AWSのコンソール画面も見てみましょう。
Elastic Container Service > クラスター > app-server-dev-20230903-cluster > サービス > app-service > ネットワーキング
できていそうです。
以下はELBの画面。ターゲットグループやリスナーもできてるっぽいです。9080に関しては今回は説明を割愛します。
ターゲットグループの中身。Private Subnet上にたっているポート80のコンテナ達が登録されているのがわかりますね!
などなど、ECSサービスでnginxを起動してみました!
作った環境を消す
最後に、作った環境を消す際には
$ yarn cdk destroy --all
で。VPCからなにまで全て一度に消してくれます。。
TIPS
今回はココまでですが、今回作成したCDKのコードを書き換えて、今後も以下のことをやっていきたいと思います。
- アプリをnginxではなく、SpringBootなどの自前のアプリに差し替え
- LogをデフォルトのCloudWatchに流す設定にしているところを、Logを制御するサイドカー(別のコンテナ)を設定し、それに制御させる
- まずはAWSが提供する FluentBitのイメージを使って制御する
- 自前で用意したFluentBitのイメージを使って、設定ファイルなども制御する
- SpringBootが出力するJSON形式のログを整形してCloudWatchに出力するとか、エラーのみのロググループを作成するとか自前のイメージでいろいろやる
- ALBをPrivate Subnetに配置する
- PrivateなSubnetに配置しつつも、おなじVPC上のEC2インスタンスにPortForwardingを仕掛けて、外のネットからもアクセスできるようにする練習
- ALBじゃなくてNLBにしてみる
- API GatewayのVPC Linkなどを用いる場合に状況によってはNLBを使う必要もあるため、やってみる
- セッション共有のための API Gateway → NLB → ALBなどの多段ELB構成をやってみる
- ECSコンテナへのリリース方式(デプロイタイプ)をいろいろ試してみる
- 今回はデフォの方式(ローリングアップデート?)を用いた
- ELBとCodeDeployの機構を用いることで、blue green deployment 3してみる
お疲れさまでした!
関連リンク
- 今回のソースコード
- AWS CDKのTIPS集
- AWS CDK で Infrastructure as Code する: VPC編
- AWS CDK で Infrastructure as Code する: EC2編