AWS CDKで作る ECS - 初歩の初歩
の続きではあるが初歩的な内容なのでこの記事から読んでもいいようにしてある
ECSとは
まず、今日日ECSとはECS Fargate
のことであることが大半だ。ECS on EC2
というかEC2モード
みたいなものもあるんだが、これはほとんど使われないので本稿でも切り捨てるのを前提とする。
ECS Fargateとは
これはDockerイメージを放りこんだら勝手に動かしてくれるやつであり、Dockerと連携するのが基本となる。これのよいところはDockerを起動するマシン自体が不要なことだ。これは、いわゆるサーバーレス的アプローチである。自分でdockerを起動する場合はEC2とか用意してそこでdocker run
だのdocker-compose up
だのを仕掛ける必要があるが、そのこと自体が不要 = 実行環境のOSの面倒(セキュリティーアップデートなどなど)を見なくていいというメリットがありますね。要するにイメージの中身だけに注力/注視すればよいということになる。
ECS Fargateのざっくりとした概念
これは
クラスター
サービス
タスク
という三段構えで構成される。ただしサービス
は必ずしも必要な概念ではない。絶対に必要なのはクラスター
とタスク
ではあるがサーバー環境を構築する場合はサービス
を使わないという事は、出来るけど無駄なので素直にサービスを使う事になる。以下に関係を図で示す
ざっくりこのようになる。サービスを使わず(RunTaskで)起動されたタスクは役割を終えたら死滅するワンショットなタスクで起動される事がほとんどであるが、サービスの中で起動しているタスクは基本的にサーバータスク(たとえばwebサーバーなど)であるので起動しっぱなしである事がほとんどだ。そしてサービス
によりタスクのカウントが維持される。サービスで2つタスクを起動しつづける指令を出したらサービスが1つ死んでも勝手に2つに揃えるという機能がある。 まず、大前提としてこの概念を大雑把に把握しておくこと。
ECS(Fargate)の概念を理解するべきターゲット
以上のことからECS Fargateの、とりわけサービスを使ったタスクの維持に関しては、その性質からほとんど99%くらいでウェブサービスの用途で使われる。
例えば
こんな感じで分散させるとサーバー1、サーバー2、サーバー3の負荷が、そこそこ均等に分散され高トラフィックに強くなるという構成が考えられる。うまく組んでやればこの手の分散サーバー環境が一瞬で手に入るというわけだ。
ECSは高トラフィック専用なのか?俺には必要ないのか?と思う人へ
単一サーバー(シングルタスク)でも十分使えるので概念だけでも掴んでおきましょう。というか将来的に分散環境にする場合も含めて、これらの概念を事前に把握しながらプログラミングしておくと結構喜ばれると思う。つまり上記の例ではサーバー1、サーバー2、サーバー3に分散アクセスされる場合、プログラムのソースコードレベルでどのような準備をするのか?という事になりますね。
ただし、単一で使う場合にせよ、分散させるにせよ、webサーバーとして公開する場合はプロダクションでは ALB
が必須と考えるべきだ。それぞれのタスクを丸裸にしてhttpを公開することもできなくはないのだが、httpsをタスクの中で構成するとかはしんどいのでタスクのレベルではhttpに留め、ALBでhttpsを受ける構成が王道である。逆にいうとこれ以外あんま手が無いと思っておいていいレベル。なのでまともな運用をする場合ALBの月額利用くらいの金が追加コストとなる。とはいえ、さくっと組んでみる分には費用はほとんど目を瞑っていいレベルなので何となくでやってみてもよいだろう、ただしその場合も学習が終了したら必ずdestroyしておかないと当然利用料がダラダラ発生するのでそこは注意が必要。
ざっくりECSを使い始めるにはどういう手順になるのか
というのを下にまとめてみよう
-
ECSクラスター
を作成する -
タスク定義
を作成する。これはタスクがどういう条件で起動するかを詳細に指示するもので、ここで指定されたスペックで起動してくる(=コストの大半がここで決定される)
- (本稿ではクラスターから直接タスクを起動し、一度ここで確認する)
-
サービス
を作成し、タスクがいくつ起動してくるとかその手の設定を指示する
なお、起動されるべきDockerイメージをどう作るのか?という話もあるだろうがそれは実は別のレイヤーと考えてもよいので少なくともこのページの解説では深く取り上げない。nginx
デフォルトのイメージを起動し、それをwebサーバーとしてアクセスさせ、ECSのざっくりとした概念を掴むハンズオンとする。これだと長くならずに済むだろうから。
以下、CDKで作成していく
CDKで作成するにあたっての事前計画
スタック設計としては EcsStack
という専用スタックでクラスター/サービス/タスク定義の3点セットを生成し、stackのdestroy時には全て消滅させる設計とする。dev
とprod
でほとんど変えないが、手厚いオプションに関してはprod
だけ与える方針にする(高精度のログ取得など)。
なお、基本的なCDK
の知識は必要なので、もしここまで何を言ってんのかわからん場合は以下を読んだ方がいいかも
現状のVPC設計
またvpc設計は以下のようになっている
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
interface VpcStackProps extends cdk.StackProps {
envName: 'dev' | 'prod';
}
export class VpcStack extends cdk.Stack {
public readonly vpc: ec2.Vpc;
constructor(scope: Construct, id: string, props: VpcStackProps) {
super(scope, id, props);
// NATなし、パブリックサブネットのみのVPC
this.vpc = new ec2.Vpc(this, 'MyVpc', {
availabilityZones: ['ap-northeast-1c', 'ap-northeast-1d'],
natGateways: 0, // NAT Gatewayは作らない
subnetConfiguration: [
{
subnetType: ec2.SubnetType.PUBLIC,
name: 'PublicSubnet',
cidrMask: 24,
},
],
});
new cdk.CfnOutput(this, 'CurrentEnv', {
value: props.envName,
description: 'Current environment name (dev or prod)',
});
}
}
これは「全てパブリックにap-northeast-1cおよび1dに」つめこむようなサブネットが作成されているだけのVPCではあるが、重要なのは1cと1dの2つAZがある事だ。単一AZでECS Fargateを組むのは学習段階でも行わない事とする。これをするとALBを使う時に問題になる(ALBは必ず2つ以上のAZ必要)理由から
なお、このVpcStack
はEc2Stack
の記事のためにAZを固定しているが実際には固定する必要はECS
においてはないのでmaxAzs: 2
とするだけでもokである。
ECSクラスターを作成してみる
それでは実際にCDK
のコードを書いていく。まずはシンプルにECSクラスター
を1つだけ作る。ECSクラスターはここまでちょっとだけ触れてきたが、とにかくECSを使うための下敷のようなもので、それ自体は特に機能を持たないのであるが(多少の設定要素はあり)、ECSを使うにあたっては必ず必要になる。ここでは環境に合わせてprod
とかdev
とかをサフィックスとして付けたクラスターを作成してみよう。
import * as cdk 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';
interface EcsStackProps extends cdk.StackProps {
envName: 'dev' | 'prod';
vpc: ec2.IVpc;
}
export class EcsStack extends cdk.Stack {
public readonly cluster: ecs.Cluster;
constructor(scope: Construct, id: string, props: EcsStackProps) {
super(scope, id, props);
this.cluster = new ecs.Cluster(this, 'EcsCluster', {
vpc: props.vpc,
clusterName: `ecs-cluster-${props.envName}`,
containerInsights: props.envName === 'prod',
});
}
}
ここではクラスター「のみ」作成しているが
containerInsights: props.envName === 'prod'
のコードにおいて渡されたenvName
がprod
の時はcontainerInsights
がtrueとなるようにしている。
エントリーポイント bin/app.ts の更新
bin/app.tsのエントリーポイントを以下のようにして、Ec2のスタックは今回の学習では外す事にした
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { VpcStack } from '../lib/vpc-stack';
// import { Ec2Stack } from '../lib/ec2-stack';
import { EcsStack } from '../lib/ecs-stack';
const app = new cdk.App();
const envName = app.node.tryGetContext('env') || 'dev';
// 共通タグ
cdk.Tags.of(app).add('Env', envName);
// スタック名にenvを含めてdev/prod共存可能に
const vpcStack = new VpcStack(app, `VpcStack-${envName}`, {
envName,
// env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
});
// new Ec2Stack(app, `Ec2Stack-${envName}`, {
// vpc: vpcStack.vpc,
// envName,
// // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
// });
new EcsStack(app, `EcsStack-${envName}`, {
vpc: vpcStack.vpc,
envName,
// env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
});
今回はEcsStackだけ純粋に違いが見たいのでVpcStackは最初から投入しておくとよいかも。
prod
とdev
のVpcStack
だけ先行適用するには以下のようにする。
cdk deploy -c env=prod VpcStack-prod
cdk deploy -c env=dev VpcStack-dev
これにより、それぞれの環境に応じた独立のネットワークが作成される。ここまでの記事で見てきたように、これらは相互不干渉なのでそれぞれの環境に影響を及ぼさないネットワークが2つ作成される事になる。
EcsStackを適用する
これもそれぞれ適用してみよう
cdk deploy -c env=prod EcsStack-prod
cdk deploy -c env=dev EcsStack-dev
これによりprod
とdev
の2つのクラスターが作成される。
作成されたクラスターを確認する
完了すると https://ap-northeast-1.console.aws.amazon.com/ecs/v2/clusters?region=ap-northeast-1 あたりから2つのクラスターが確認できるはず
prod
にはContainer Insights
モニタリングが付与されている
以下のawscliコマンドで内容を確認できる。ここではprodを確認
aws ecs describe-clusters --clusters ecs-cluster-prod --include SETTINGS --region ap-northeast-1
{
"clusters": [
{
"clusterArn": "arn:aws:ecs:ap-northeast-1:****:cluster/ecs-cluster-prod",
"clusterName": "ecs-cluster-prod",
"status": "ACTIVE",
"registeredContainerInstancesCount": 0,
"runningTasksCount": 0,
"pendingTasksCount": 0,
"activeServicesCount": 0,
"statistics": [],
"tags": [],
"settings": [
{
"name": "containerInsights",
"value": "enabled"
}
],
"capacityProviders": [],
"defaultCapacityProviderStrategy": []
}
],
"failures": []
}
なお、この段階では課金は発生していない。実際に課金が発生しはじめるのはタスクが起動してからである。
タスク定義を作成する
続いてタスクを起動させるための雛形であるタスク定義を作成していく。これも必ず必要。
ここでは非常に単純なタスク定義としてnginx
をdefaultで起動させる。さらにSGも1つ追加している
@@ -19,5 +19,23 @@ export class EcsStack extends cdk.Stack {
clusterName: `ecs-cluster-${props.envName}`,
containerInsights: props.envName === 'prod',
});
+
+ const taskDef = new ecs.FargateTaskDefinition(this, 'NginxTaskDef', {
+ cpu: 256, // 最小クラス
+ memoryLimitMiB: 512, // 最小メモリ
+ });
+
+ taskDef.addContainer('NginxContainer', {
+ // image: ecs.ContainerImage.fromRegistry('nginx:latest'), // Docker Hub
+ // Docker Hub ではなく Public ECR ミラーを使う
+ image: ecs.ContainerImage.fromRegistry('public.ecr.aws/nginx/nginx:stable'),
+ portMappings: [{ containerPort: 80 }],
+ logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'nginx' }),
+ });
+ const sg = new ec2.SecurityGroup(this, 'NginxSvcSg', {
+ vpc: props.vpc,
+ allowAllOutbound: true,
+ description: 'Allow HTTP from anywhere for nginx (training only)',
+ });
+ sg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'HTTP');
}
}
ここでは試しにprod
をdeployしてみよう
cdk deploy -c env=prod EcsStack-prod
この時点ではまだタスク「定義」だけなので実際のタスクは起動していない。
コードの詳細について
ではコードをある程度細かい単位で見ていくことにする。
const taskDef = new ecs.FargateTaskDefinition(this, 'NginxTaskDef', {
cpu: 256, // 最小クラス
memoryLimitMiB: 512, // 最小メモリ
});
cpuとメモリーのスペックを設定する。ここがベースの課金ラインになる。
cpu: 256
ECSのCPU単位は「1 vCPU
= 1024」。よって256は0.25 vCPU
。
これがECSタスクの最小設定となる
memoryLimitMiB: 512
512メガバイトというこれも最小メモリー。場合によってはこのメモリーだと普通に落ちるが、今回のテストでは問題ないだろう。
デフォルトではx86アーキテクチャが想定されるが、ARM64を動かしたい場合はここで設定する
const taskDef = new ecs.FargateTaskDefinition(this, 'NginxTaskDef', {
cpu: 512,
memoryLimitMiB: 1024,
runtimePlatform: {
cpuArchitecture: ecs.CpuArchitecture.ARM64,
operatingSystemFamily: ecs.OperatingSystemFamily.LINUX,
},
});
など
taskDef.addContainer('NginxContainer', {
// image: ecs.ContainerImage.fromRegistry('nginx:latest'), // Docker Hub
// Docker Hub ではなく Public ECR ミラーを使う
image: ecs.ContainerImage.fromRegistry('public.ecr.aws/nginx/nginx:stable'),
portMappings: [{ containerPort: 80 }],
logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'nginx' }),
});
ここについてはecs.ContainerImage.fromRegistry('nginx:latest')
がコメントアウトされているが、これについてはurlを付けないと基本的にDockerHub
というサービスのイメージを取得しにいく。しかしここのアクセスはAWSから行うとしばしば過多になりイメージのダウンロードに失敗することがあるためpublic.ecr.aws
というミラーからダウンロードするように指示している。すなわちコメントアウトされている行は実質的には同じことをやっているに過ぎない。
さらにportMappings
でコンテナーのポート80をタスクのポート80にマッピングしている。
作成されたタスク定義を確認してみよう
https://ap-northeast-1.console.aws.amazon.com/ecs/v2/task-definitions?region=ap-northeast-1# から確認できる
新たなタスク定義が確認できる。ここではenvがdev
である
またawscli
からも確認が可能だ。
aws ecs describe-task-definition \
--task-definition EcsStackprodNginxTaskDef0E87DBF1 \
--region ap-northeast-1
これで最新のタスク定義が得られる
{
"taskDefinition": {
"taskDefinitionArn": "arn:aws:ecs:ap-northeast-1:****:task-definition/EcsStackprodNginxTaskDef0E87DBF1:1",
"containerDefinitions": [
{
"name": "NginxContainer",
"image": "public.ecr.aws/nginx/nginx:stable",
"cpu": 0,
"links": [],
"portMappings": [
{
"containerPort": 80,
"hostPort": 80,
"protocol": "tcp"
}
],
"essential": true,
"entryPoint": [],
"command": [],
"environment": [],
"environmentFiles": [],
"mountPoints": [],
"volumesFrom": [],
"secrets": [],
"dnsServers": [],
"dnsSearchDomains": [],
"extraHosts": [],
"dockerSecurityOptions": [],
"dockerLabels": {},
"ulimits": [],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "EcsStack-prod-NginxTaskDefNginxContainerLogGroup9863ED0C-JAG0mUxyuAvD",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "nginx"
},
"secretOptions": []
},
"systemControls": [],
"credentialSpecs": []
}
],
"family": "EcsStackprodNginxTaskDef0E87DBF1",
"taskRoleArn": "arn:aws:iam::****:role/EcsStack-prod-NginxTaskDefTaskRoleD32035E2-n5ckKSVVIZr1",
"executionRoleArn": "arn:aws:iam::****:role/EcsStack-prod-NginxTaskDefExecutionRole32D6C22A-p0wd2eH8uy6z",
"networkMode": "awsvpc",
"revision": 1,
"volumes": [],
"status": "ACTIVE",
"requiresAttributes": [
{
"name": "com.amazonaws.ecs.capability.logging-driver.awslogs"
},
{
"name": "ecs.capability.execution-role-awslogs"
},
{
"name": "com.amazonaws.ecs.capability.docker-remote-api.1.19"
},
{
"name": "com.amazonaws.ecs.capability.docker-remote-api.1.17"
},
{
"name": "com.amazonaws.ecs.capability.task-iam-role"
},
{
"name": "com.amazonaws.ecs.capability.docker-remote-api.1.18"
},
{
"name": "ecs.capability.task-eni"
}
],
"placementConstraints": [],
"compatibilities": [
"EC2",
"FARGATE"
],
"requiresCompatibilities": [
"FARGATE"
],
"cpu": "256",
"memory": "512",
"registeredAt": "2025-08-15T08:34:47.427000+00:00",
"registeredBy": "arn:aws:sts::****:assumed-role/cdk-hnb659fds-cfn-exec-role-****-ap-northeast-1/AWSCloudFormation"
},
"tags": []
}
ここで、タスク定義は 「どのような」タスクを起動するかを定義したが、「どうやって」タスクを起動するかは一切書かれていない点に着目する。たとえばどういうイメージで、どういうスペックで起動するかは書いたけど、どのようなネットワークで、どのポートを解放して、とかは書いていないよね、って話になるわけだ。
いきなりnginxタスクをクラスターから起動する(RunTask)
ここで、オーソドックスなサービスからサーバーを起動する方法ではなく、クラスター上で「いきなりタスクを起動する」という事をやってみよう。この段階で配置したタスクが正しく起動するかどうかチェックする。サービス→タスクの起動は自動的に起動されるため、この段階で手動確認しておいた方が失敗は少ない。
クラスターからタスクを起動する動線
すると、「どのタスク定義を」起動するかの指定が出てくる
CDKにより作成されたタスク定義が出てくる
続いて「どのリビジョン」を起動するかを指定する。これはタスク定義を更新すると番号が増えていく仕様だ。今回は1度も修正していないのでrev:1
であるが、何度かやりなおしたりすると1以外になっているかもしれない、いずれにせよ「最新」をチョイスする
起動するリビジョンとして「最新」を指定する
続いてコンピューティングタイプにおいては「起動タイプ
」を指定する
起動タイプを指定している
さらにデフォルトでは閉じられているが ネットワーキング
を必ず開く
開かれたネットワーキング
ここでは1つ他で使っているVPCが出てきたのでマスクしたが、デフォルトVPCとCDKで作成したVPCの2つが出ているはずだ
ここでは作成したVPCを選択しよう。するとVpcStack
で指定してあったap-northeast-1c
とap-northeast-1d
の2つの表示に切り替わる。
作成されたprodのVPCと「デフォルトVPC」
さらにセキュリティーグループも作成したものに変更する
セキュリティーグループの変更
パブリックIPがonになっていることも確認
もろもろ確認するべきところ
。この状態で「作成」を押す
作成する
すると、タスクが「プロビジョニング中」になる
プロビジョニング中のタスク
しばらく経つと「実行中」になる
実行中のタスク
起動したnginxタスクの確認
実行中のタスクよりタスクIDをクリックする
タスクIDをクリック
ネットワーキング
タブよりパブリックIPをコピー
パブリックIPが見える
するとnginxのデフォルトページが見える
nginxのデフォルトページが表示された
ここまでで行ったこと
これはnginx
を起動するためのタスク定義を「クラスターから直接起動した」例であり、これはECSの用語ではRunTask
という。
この構成は単なる動作確認に過ぎず、サーバーの例はクラスターから直接RunTask
する事はほとんどない。ただし、クラスターからRunTask
できないタスクはサービスからも起動できないので、ここで一度確認しておいたというわけだ。確認が済んだらタスクを削除する
タスクを停止する
サービスを作成しサービスからタスクを実行する
クラスター→タスク直起動ではなく「サービス」からタスクを起動してみよう。実際にはこちらの手続きの方が遥かに一般的であるし、重要である。
サービスを追加するcdkコード
@@ -40,6 +40,15 @@ export class EcsStack extends cdk.Stack {
description: 'Training only: allow HTTP from anywhere',
});
sg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'HTTP');
+
+ // サービス(単一タスク、ALBなし、Public直当て)
+ new ecs.FargateService(this, 'NginxService', {
+ cluster: this.cluster,
+ taskDefinition: taskDef,
+ desiredCount: 1,
+ vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
+ assignPublicIp: true,
+ securityGroups: [sg],
});
}
}
ここで**desiredCount
はタスクを1つだけ起動するということでサービスからのタスク起動において非常に重要な値**となる。このdesiredCount
を条件によって増減したりするという事により高負荷に対応するという運用になるわけだ。とはいえ、ここでは1つに固定して最低限のサービスの挙動を理解する事をまず行う。
タスクを直接起動したように「パブリックネットワーク(vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC }
)」に「パブリックIPを付けて(assignPublicIp: true
)」起動指示する。またsecurityGroupは先に作成したHTTPを解放するものをそのまま流用する。
cdk diffとdeploy
ではこのdiffを適用したら
cdk diff -c env=prod
で確認してみよう。
Stack EcsStack-prod
Resources
[+] AWS::ECS::Service NginxService/Service NginxServiceFFC975BB
✨ Number of stacks with differences: 2
納得したら
cdk deploy -c env=prod EcsStack-prod
でdeploy
作成されたサービスの確認
サービスが作成されている
サービスが開始されると 「自動的に」タスクがdesiredCount
に基いた数を起動する。
さて、サービスのデプロイが成功したら以下のようタスクが自動的に起動する
自動的にタスクが1つ起動している
この記事を書いてる際にタスク定義をごちゃごちゃ弄り倒したらrev:7まで上がった。まあそんな感じでどんどんリビジョンが上がっていく運用になっていくと思う
タスクの確認
当該タスクに入りネットワーキングタブを押すとパブリックIPが確認できる
ネットワーキングからパブリックIPを確認
これにアクセスして確認する
nginxの起動を確認
手動でタスクを起動しないようにする
サービスから手動でタスクを「停止」すると確かにそのサービスは停止されるのだが、desiredCount
に基いてタスクは即座に復帰してくる。ここではdesiredCount: 1
がセットされているのでタスクを完全に止めるにはdesiredCount: 0
にする(あるいはサービス自体、あるいはstackをdestroyするという手もあるが)
0
に落とす
desiredCount
0で自動的に停止したタスク
まとめ
これはECSを利用する上での基本中の基本のみをピックアップし、まずはECSの概念を掴むという内容であるから実運用には一切適していない。次回以降深い設定を見たり、あるいは運用にあたってのALBやSSLの話、スケールイン/スケールアウトなどの話を書ければいいけどねえ。
今回の変更
ソースレベルで変更が追えるのがIoCの強みなのでどんどん利用する
Discussion