🐈

CDKでECS FargateとALBを構築し、Webサーバーの負荷分散を実現する

に公開

現状の確認

今現在、ECSのサービスにてNginxを1つ起動するCDKコードを書いてきたが、1つ起動しただけではあまり意味がないのでここでは2つ(あるいはそれ以上)起動してみることにしよう。

現状のNginx

new ecs.FargateService(this, 'WebService', {
    cluster: this.cluster,
    taskDefinition: taskDef,
    desiredCount: 1, // <------------------------------------ これ
    vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
    assignPublicIp: true,
    securityGroups: [sg],
    enableExecuteCommand: true,
    serviceName: webServiceName, // dev-web / prod-web
});

このdesiredCountの設定によりタスクが1つ起動してくる。


1つだけ起動したタスク

要するにこのカウントを2とか3とかにすればWebサーバーがその分だけ起動してくるということだ。当然料金は vCPUとメモリ割り当て×タスク数×稼働時間 ...とタスク数に応じて増えていくわけだ。

タスクを2つに変更する

lib/ecs-stack.ts
@@ -115,7 +115,7 @@ export class EcsStack extends cdk.Stack {
         new ecs.FargateService(this, 'WebService', {
             cluster: this.cluster,
             taskDefinition: taskDef,
-            desiredCount: 1,
+            desiredCount: 2,
             vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
             assignPublicIp: true,
             securityGroups: [sg],

これは単純にdesiredCount2に引き上げてデプロイすればよい。


サービスで2タスクが起動

今、それぞれにパブリックIPが付与されており、それぞれのIPアドレスでアクセスすればNginxのデフォルトページが見える状態になっている。

ただしそれぞれのIPアドレスで見えても何の意味もない

みたいにそれぞれ独立したIPアドレスが当たっていたとして、そこにアクセスして何か意味があるんかというと、無い。では何のためにwebサーバーのタスク数を増やすのかという話だが、これは中央で受けてそれぞれのタスクがもっているwebサーバー(ここではNginx)に分散する装置があって初めて役に立つものである。この装置のことを「ロードバランサー」と呼ぶ。

AWSにおけるロードバランサーの種類

実はAWSにはロードバランサーの種類がいくつかあり、それぞれ役割が異なるがwebで使う場合は「ALB一択」 である。NLBだの何だのはまあ一応用語として知っとくくらいでok

いずれにせよここではALBを使うので、ALBでタスク3つに分散する図を以下に記す。まずこれを、概念として抑えておこう。

つまり、ここではブラウザーがALBのアドレスにアクセスすれば後は勝手にタスクに振り分けられる事という事になる。

ALBの料金

  • 基本料金: 時間単位で課金され、約 $0.0225〜$0.0252/時間。月単位だと約 $16〜$18/月 に相当
  • LCU: 「大量トラフィック」「複雑ルール」「長時間接続」のどれか1つでも突出すると 跳ねるやつ

どれか1つでも突出するとというのは対象の項目があって、その中でも最大のやつが課金対象になる。ここではコストに関して、特にLCUに関して細かく書かないが、実際に運用に出すときはここを注意して見ておかないといけない。

指標 1 LCU の基準値 実際に増える状況 言葉でのイメージ
新規接続数 25 新規接続/秒 API やチャットなど短命コネクションが多発 大量トラフィック(接続回数)
アクティブ接続数 1,000 同時接続 WebSocket, SSE など長時間張りっぱなし 長時間接続
処理バイト数 1 GB/時 大きな画像・動画を配信、静的ファイルを直で流す 大量トラフィック(データ量)
ルール評価数 1,000 ルール評価/秒 パス/ホスト/ヘッダーベースの条件を多数設定 複雑ルール

まあ要するに、何でもかんでもALBを経由すると痛い目を見る事もあるぞってことで、ただ、ここでは構築ガイドなので単に置いておくだけなら全然1LCUで賄えるのではあるが、ただしアイドルが多いALBでも18USD、月に2500円前後飛んでいくので、テストが終わったらちゃんとクリーンアップしといた方がよい。

ALBを単純に配置するコードを書いていく

ここから実際にコードを動かしていく。一度に構築していってもよいのであるが、まあハンズオンらしくちょいちょい付け足しつつ解説していこう。

まず現在のコード

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';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as ssm from 'aws-cdk-lib/aws-ssm';

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);

        const isDev = props.envName === 'dev';
        const clusterName = `ecs-cluster-${props.envName}`;
        const webServiceName = `${props.envName}-web`;

        const webLogGroupName = `/aws/ecs/${clusterName}/${webServiceName}`;
        const execLogGroupName = `/aws/ecs/${clusterName}/exec`;


        // Upsert
        new logs.LogRetention(this, 'WebLogRetention', {
            logGroupName: webLogGroupName,
            retention: isDev ? logs.RetentionDays.TWO_WEEKS : logs.RetentionDays.SIX_MONTHS,
        });
        // Exec ログ用 LogGroup を用意(上書き先)
        new logs.LogRetention(this, 'ExecLogRetention', {
            logGroupName: execLogGroupName,
            retention: isDev ? logs.RetentionDays.ONE_WEEK : logs.RetentionDays.ONE_MONTH,
        });

        // 以降は参照のみ(=CFNが再作成しに行かない)
        const webLogGroup = logs.LogGroup.fromLogGroupName(this, 'WebLogGroupImported', webLogGroupName);
        const execLogGroup = logs.LogGroup.fromLogGroupName(this, 'ExecLogGroupImported', execLogGroupName);

        this.cluster = new ecs.Cluster(this, 'EcsCluster', {
            vpc: props.vpc,
            clusterName,
            containerInsights: !isDev,
            executeCommandConfiguration: {
                logging: ecs.ExecuteCommandLogging.OVERRIDE,
                logConfiguration: {
                    cloudWatchLogGroup: execLogGroup,
                    cloudWatchEncryptionEnabled: false,
                },
            },
        });

        const taskDef = new ecs.FargateTaskDefinition(this, 'WebTaskDef', {
            cpu: 256, // 最小クラス
            memoryLimitMiB: 512, // 最小メモリ
        });

        const messageParam = ssm.StringParameter.fromStringParameterName(
            this,
            'MessageParam',
            `/demo/${props.envName}/message`,
        );
        const dbPasswordParam = ssm.StringParameter.fromSecureStringParameterAttributes(
            this,
            'DbPasswordParam',
            {
                parameterName: `/demo/${props.envName}/db_password`,
                // version: 1, // 必要なら固定
            },
        );
        // 起動時に ECS が取りに行くので executionRole にのみ付与(最小権限)
        const execRole = taskDef.obtainExecutionRole();
        messageParam.grantRead(execRole);
        dbPasswordParam.grantRead(execRole);

        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({
                logGroup: webLogGroup,
                streamPrefix: 'nginx', // 役割だけをprefixに
            }),
            secrets: {
                APP_MESSAGE: ecs.Secret.fromSsmParameter(messageParam),
                DB_PASSWORD: ecs.Secret.fromSsmParameter(dbPasswordParam),
            },
        });

        // (必須) SSM Messages チャネル用の権限
        taskDef.addToTaskRolePolicy(
            new iam.PolicyStatement({
                actions: [
                    'ssmmessages:CreateControlChannel',
                    'ssmmessages:CreateDataChannel',
                    'ssmmessages:OpenControlChannel',
                    'ssmmessages:OpenDataChannel',
                ],
                resources: ['*'],
            }),
        );

        const sg = new ec2.SecurityGroup(this, 'WebSvcSg', {
            vpc: props.vpc,
            allowAllOutbound: true,
            description: `Allow HTTP from anywhere for web (${isDev ? 'dev' : 'prod'})`,
        });
        sg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'HTTP');

        // サービス(単一タスク、ALBなし、Public直当て)
        new ecs.FargateService(this, 'WebService', {
            cluster: this.cluster,
            taskDefinition: taskDef,
            desiredCount: 2,
            vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
            assignPublicIp: true,
            securityGroups: [sg],
            enableExecuteCommand: true,
            serviceName: webServiceName, // dev-web / prod-web
        });
    }
}

これに対してALBを1つ置く

では以下のように追加してみよう。

lib/ecs-stack.ts
lib/ecs-stack.ts
+++ b/lib/ecs-stack.ts
@@ -5,6 +5,7 @@ import * as ecs from 'aws-cdk-lib/aws-ecs';
 import * as iam from 'aws-cdk-lib/aws-iam';
 import * as logs from 'aws-cdk-lib/aws-logs';
 import * as ssm from 'aws-cdk-lib/aws-ssm';
+import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
 
 interface EcsStackProps extends cdk.StackProps {
     envName: 'dev' | 'prod';
@@ -99,6 +100,21 @@ export class EcsStack extends cdk.Stack {
             }),
         );
 
+        const albSg = new ec2.SecurityGroup(this, 'AlbSg', {
+            vpc: props.vpc,
+            allowAllOutbound: true,
+            description: 'ALB security group',
+        });
+        // インターネットから80番だけ開放(HTTPS不要とのことなので443は作らない)
+        albSg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'HTTP from Internet');
+
+        const alb = new elbv2.ApplicationLoadBalancer(this, 'Alb', {
+            vpc: props.vpc,
+            internetFacing: true,
+            securityGroup: albSg,
+            vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
+        });
+
         const sg = new ec2.SecurityGroup(this, 'WebSvcSg', {
             vpc: props.vpc,
             allowAllOutbound: true,

これはサービスの定義より前にコードを書くこと。

なお、この状態はALBが孤立して配置されてるだけになる。以下の図を参照


作成されたALB

これはEC2リソースツリーから辿って確認する。

ターゲットグループの作成と分散

現状、ALBが孤立しているだけなので以下の図のようにタスク1、タスク2にALBを経由した場合分散してアクセスされるようにする。

これにはまずターゲットグループを作る必要がある

ターゲットグループを作る

lib/ecs-stack.ts
@@ -115,6 +115,20 @@ export class EcsStack extends cdk.Stack {
             vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
         });
 
+        // Target Group(Fargate=IPターゲット)
+        const tg = new elbv2.ApplicationTargetGroup(this, 'WebTg', {
+            vpc: props.vpc,
+            port: 80,
+            protocol: elbv2.ApplicationProtocol.HTTP,
+            targetType: elbv2.TargetType.IP,
+            healthCheck: {
+                path: '/', // 必要に応じて /health へ
+                interval: cdk.Duration.seconds(30),
+                // 200 だけに縛ると事故りやすい。実運用は 200-399 を推奨
+                healthyHttpCodes: '200-399',
+            },
+        });
+
         const sg = new ec2.SecurityGroup(this, 'WebSvcSg', {
             vpc: props.vpc,
             allowAllOutbound: true,

これをデプロイする事によりターゲットグループが作成される、が


ターゲットグループから見てもターゲットなし

このようにターゲットがなかったりロードバランサーの関連付けがなかったりする。

ターゲットグループとターゲットそしてALBを結合する

今は入れ物を作っただけなので、これらを結んでいく。

lib/ecs-stack.ts
@@ -129,6 +129,10 @@ export class EcsStack extends cdk.Stack {
             },
         });
 
+        // HTTP/80 リスナーを作って TG へフォワード
+        const httpListener = alb.addListener('HttpListener', { port: 80, open: true });
+        httpListener.addTargetGroups('DefaultTg', { targetGroups: [tg] });
+
         const sg = new ec2.SecurityGroup(this, 'WebSvcSg', {
             vpc: props.vpc,
             allowAllOutbound: true,
@@ -137,7 +141,7 @@ export class EcsStack extends cdk.Stack {
         sg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'HTTP');
 
         // サービス(単一タスク、ALBなし、Public直当て)
-        new ecs.FargateService(this, 'WebService', {
+        const service = new ecs.FargateService(this, 'WebService', {
             cluster: this.cluster,
             taskDefinition: taskDef,
             desiredCount: 2,
@@ -147,5 +151,8 @@ export class EcsStack extends cdk.Stack {
             enableExecuteCommand: true,
             serviceName: webServiceName, // dev-web / prod-web
         });
+
+        // サービスをターゲットグループに登録(タスクENIのIPが自動でTGに入る)
+        service.attachToApplicationTargetGroup(tg);
     }
 }

以上のデプロイをAWS マネジメントコンソールから確認し、実際に接続してみる

現状でターゲットを登録し「リスナー」も登録した。ここではAWS マネジメントコンソールより確認してみよう。

ロードバランサーよりリスナーを確認

EC2ロードバランサー → 当該ロードバランサー

でHTTPリスナーを確認する。


HTTP:80リスナーが登録されている

このようにリスナーとは文字通りhttpポート80やらhttpsポート443やらをどう扱うかという設定だ。ALBというのはこれ自体1つのhttpやhttpsサーバーが単一で起動しているようなものなのでhttpやhttpsの待受(あるいは任意ポートの待受)の処理を記述でき、基本的には「ターゲットグループ転送」の処理を行う。

ターゲットグループを確認

これは先程作成した


ヘルシーなターゲットが2つ登録されている

このように登録されたタスクが2つ登録されている。またここでパブリックIPではなくプライベートIPで登録されている点にも注意する。

ロードバランサーにアクセスしてみる

再度ロードバランサーに移動し、「DNS名」を見る

このDNSにアクセスするのだが、今日日のブラウザーだとhttpsを見にいきがちなのでそこは注意。curlでアクセスするのが間違いがないかも。


curl http://EcsSta-Alb16-mw36ft3EJKMV-1845548002.ap-northeast-1.elb.amazonaws.comした画面

あるいは


-i オプションでhttpヘッダを確認

ブラウザーでは以下のようになる


ブラウザーでアクセスしてみた形

nginxの内容を変更したりして実際の振り分けを確認する

さて、通常dockerイメージをカスタムする場合はデフォルトイメージをカスタムしてECRに配置し、そこからひっぱってくるのが常道であるが、今回の確認のような僅かな変更の場合はECSタスクを起動する際にちゃっと内容を書き換えてしまう方法があるのでそれを利用する。

ecsタスク定義のcommandを使う

lib/ecs-stack.ts
@@ -85,6 +85,23 @@ export class EcsStack extends cdk.Stack {
                 APP_MESSAGE: ecs.Secret.fromSsmParameter(messageParam),
                 DB_PASSWORD: ecs.Secret.fromSsmParameter(dbPasswordParam),
             },
+            command: [
+                'sh',
+                '-c',
+                [
+                    // タスクごとに異なる識別子(ホスト名=コンテナID相当 & ランダムUUID)
+                    'HOST=$(cat /etc/hostname)',
+                    'UUID=$(cat /proc/sys/kernel/random/uuid)',
+                    // 適当なHTMLを書き込み
+                    'mkdir -p /usr/share/nginx/html',
+                    'echo "<html><body style=\'font-family:sans-serif\'>" > /usr/share/nginx/html/index.html',
+                    'echo "<h1>Hello from $HOST</h1>" >> /usr/share/nginx/html/index.html',
+                    'echo "<p>uuid: $UUID</p>" >> /usr/share/nginx/html/index.html',
+                    'echo "</body></html>" >> /usr/share/nginx/html/index.html',
+                    // nginx をフォアグラウンドで
+                    "nginx -g 'daemon off;'",
+                ].join(' && '),
+            ],
         });
 
         // (必須) SSM Messages チャネル用の権限

とまあこんな感じでやってデプロイする

タスクのそれぞれのパブリックIPにアクセスすると?


タスク1


タスク2

のように2タスクそれぞれ異なる画面が出てくる。

ロードバランサーからアクセスする


リロードするとタスクが切り替わっているのがわかる

このようにロードバランサーを経由するとうまいこと振り分けられているのがわかる。まあ実際には異なる画面がどんどん出てくるとかいうのは全く使いものにならないので、同じ内容を揃えてあげて分散させるというのが本来のやりかたであるが今回はテストとしてこのようにしておいた。

現状のセキュリティーと問題点

この図の通り、各タスクにはロードバランサーからアクセス可能であるのに加えて、各タスクにそれぞれ割り当てられたパブリックIPからもアクセス可能な状態となっている。このタスクごとの入口は基本的に不要なので以下のように入口をALBに一本化する

sgをsrvSgへ

まずalbSg(ALB用のSG)とsg(ECSサービス用のSG)が混っているのでsgsrvSgとした

lib/ecs-stack.ts
@@ -155,12 +155,12 @@ export class EcsStack extends cdk.Stack {
         const httpListener = alb.addListener('HttpListener', { port: 80, open: true });
         httpListener.addTargetGroups('DefaultTg', { targetGroups: [tg] });
 
-        const sg = new ec2.SecurityGroup(this, 'WebSvcSg', {
+        const srvSg = new ec2.SecurityGroup(this, 'WebSvcSg', {
             vpc: props.vpc,
             allowAllOutbound: true,
             description: `Allow HTTP from anywhere for web (${isDev ? 'dev' : 'prod'})`,
         });
-        sg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'HTTP');
+        srvSg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'HTTP');
 
         // サービス(単一タスク、ALBなし、Public直当て)
         const service = new ecs.FargateService(this, 'WebService', {
@@ -169,7 +169,7 @@ export class EcsStack extends cdk.Stack {
             desiredCount: 2,
             vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
             assignPublicIp: true,
-            securityGroups: [sg],
+            securityGroups: [srvSg],
             enableExecuteCommand: true,
             serviceName: webServiceName, // dev-web / prod-web
         });

ECSタスクの接続をALBからに限定する

lib/ecs-stack.ts
@@ -158,11 +158,10 @@ export class EcsStack extends cdk.Stack {
         const srvSg = new ec2.SecurityGroup(this, 'WebSvcSg', {
             vpc: props.vpc,
             allowAllOutbound: true,
-            description: `Allow HTTP from anywhere for web (${isDev ? 'dev' : 'prod'})`,
+            description: `Allow HTTP only from ALB for web (${isDev ? 'dev' : 'prod'})`,
         });
-        srvSg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'HTTP');
+        srvSg.addIngressRule(albSg, ec2.Port.tcp(80), 'HTTP from ALB');

これにより、タスクのIP直アクセスではnginxに到達できなくなり、ALBからに限定された。

まとめ

ここでは簡単なALBと、ターゲットグループ、そしてリスナーについてハンズオン形式で解説した。次回webサーバーが2つ以上になっているとよいこととか気をつけることとか、諸々あるので書いていく、かも(もう基本的な事は大体網羅したのでそろそろハンズオンをやめて深い話に入っていくかもしれない)

Discussion