🐕

AWS CDKで作る ECS - 初歩の初歩

に公開

https://zenn.dev/catatsumuri/articles/1be7f443ab010b

https://zenn.dev/catatsumuri/articles/24584e68d39d34

の続きではあるが初歩的な内容なのでこの記事から読んでもいいようにしてある

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を使い始めるにはどういう手順になるのか

というのを下にまとめてみよう

  1. ECSクラスターを作成する
  2. タスク定義を作成する。これはタスクがどういう条件で起動するかを詳細に指示するもので、ここで指定されたスペックで起動してくる(=コストの大半がここで決定される)
  • (本稿ではクラスターから直接タスクを起動し、一度ここで確認する)
  1. サービスを作成し、タスクがいくつ起動してくるとかその手の設定を指示する

なお、起動されるべきDockerイメージをどう作るのか?という話もあるだろうがそれは実は別のレイヤーと考えてもよいので少なくともこのページの解説では深く取り上げない。nginxデフォルトのイメージを起動し、それをwebサーバーとしてアクセスさせ、ECSのざっくりとした概念を掴むハンズオンとする。これだと長くならずに済むだろうから。

以下、CDKで作成していく

CDKで作成するにあたっての事前計画

スタック設計としては EcsStackという専用スタックでクラスター/サービス/タスク定義の3点セットを生成し、stackのdestroy時には全て消滅させる設計とする。devprodでほとんど変えないが、手厚いオプションに関してはprodだけ与える方針にする(高精度のログ取得など)。

なお、基本的なCDKの知識は必要なので、もしここまで何を言ってんのかわからん場合は以下を読んだ方がいいかも

https://zenn.dev/catatsumuri/articles/1be7f443ab010b

現状のVPC設計

またvpc設計は以下のようになっている

lib/vpc-stack.ts
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必要)理由から

なお、このVpcStackEc2Stackの記事のためにAZを固定しているが実際には固定する必要はECSにおいてはないのでmaxAzs: 2とするだけでもokである。

ECSクラスターを作成してみる

それでは実際にCDKのコードを書いていく。まずはシンプルにECSクラスターを1つだけ作る。ECSクラスターはここまでちょっとだけ触れてきたが、とにかくECSを使うための下敷のようなもので、それ自体は特に機能を持たないのであるが(多少の設定要素はあり)、ECSを使うにあたっては必ず必要になる。ここでは環境に合わせてprodとかdevとかをサフィックスとして付けたクラスターを作成してみよう。

lib/ecs-stack.ts
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'

のコードにおいて渡されたenvNameprodの時はcontainerInsightsがtrueとなるようにしている。

エントリーポイント bin/app.ts の更新

bin/app.tsのエントリーポイントを以下のようにして、Ec2のスタックは今回の学習では外す事にした

bin/app.ts
#!/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は最初から投入しておくとよいかも。

proddevVpcStackだけ先行適用するには以下のようにする。

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

これによりproddevの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つ追加している

lib/ecs-stack.ts
@@ -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-1cap-northeast-1dの2つの表示に切り替わる。


作成されたprodのVPCと「デフォルトVPC」

さらにセキュリティーグループも作成したものに変更する


セキュリティーグループの変更

パブリックIPがonになっていることも確認


もろもろ確認するべきところ

。この状態で「作成」を押す


作成する

すると、タスクが「プロビジョニング中」になる


プロビジョニング中のタスク

しばらく経つと「実行中」になる


実行中のタスク

起動したnginxタスクの確認

実行中のタスクよりタスクIDをクリックする


タスクIDをクリック

ネットワーキングタブよりパブリックIPをコピー


パブリックIPが見える

するとnginxのデフォルトページが見える


nginxのデフォルトページが表示された

ここまでで行ったこと

これはnginxを起動するためのタスク定義を「クラスターから直接起動した」例であり、これはECSの用語ではRunTaskという。

この構成は単なる動作確認に過ぎず、サーバーの例はクラスターから直接RunTaskする事はほとんどない。ただし、クラスターからRunTaskできないタスクはサービスからも起動できないので、ここで一度確認しておいたというわけだ。確認が済んだらタスクを削除する


タスクを停止する

サービスを作成しサービスからタスクを実行する

クラスター→タスク直起動ではなく「サービス」からタスクを起動してみよう。実際にはこちらの手続きの方が遥かに一般的であるし、重要である。

サービスを追加するcdkコード

lib/ecs-stack.ts
@@ -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の話、スケールイン/スケールアウトなどの話を書ければいいけどねえ。

今回の変更

https://github.com/catatsumuri/cdktest/commit/fcd03925fa13ac643245a375561e3c60f75937da

ソースレベルで変更が追えるのがIoCの強みなのでどんどん利用する

Discussion