AWS CDKを使ってECS(Fargate起動タイプ)を構築する

この記事はTypeScript Advent Calender 2019の5日目の記事です。 投稿が遅くなってしまい、申し訳ありません。

さて、AWS CDKがGAになり、およそ5ヶ月くらい経ちました。 re:InventでAWS EKS on AWS Fargateが発表され、かなりの衝撃を受けましたが、 今回の記事ではFargate起動タイプのECSをAWS CDKを利用して、構築したいと思います。

もちろんTypeScriptのアドベントカレンダーなので、TypeScript版AWS CDKを利用します。 現在ではPythonやJava,.NETなど様々な言語でAWS CDKが使えるようです。

AWS CDK

AWS CDKはインフラ構築のためのツールで、インフラをアプリケーションコードを書くように定義できます。 AWS CDKのコードを実行すると、Cloud Formationテンプレートが生成され、CloudFormationを通してAWSリソースが作成されます。 AWS CDKはCloud Formationテンプレートを直接記述するより少ないコード量で記述できます。少ないコード量で記述できる理由としては以下の点が挙げられます。

例えばLamabdaとAPI Gatewayの組み合わせは以下のようなコードで実現できます。

import * as cdk from "@aws-cdk/core";
import * as lambda from "@aws-cdk/aws-lambda";
import * as apigw from "@aws-cdk/aws-apigateway";

export class CdkWorkshopStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // defines an AWS Lambda resource
    const hello = new lambda.Function(this, 'HelloHandler', {
      runtime: lambda.Runtime.NODEJS_8_10,      // execution environment
      code: lambda.Code.asset('lambda'),  // code loaded from the "lambda" directory
      handler: 'hello.handler'                // file is "hello", function is "handler"
    });

    // defines an API Gateway REST API resource backed by our "hello" function.
    new apigw.LambdaRestApi(this, 'Endpoint', {
      handler: hello
    });

  }
}

数行のコードによってAPI GatewayとLambdaの組み合わせが実現できます。 Lambdaを作成して、それをAPI Gatewayに紐づけるということが直感的にわかりやすく書かれています。 また、このコード上ではIAMに関することは全く表現されていませんが、裏側ではIAM ロールやポリシーが作成されています。

CDKの基礎について知りたいかたは一度AWS公式のワークショップを実施することをお勧めします。 https://cdkworkshop.com/

ECS(Fargate起動タイプ)構築

今回はExpressで書かれたHelloWorldアプリケーションをECSで実行することがゴールです。 今回利用するコードは全てhttps://github.com/hikaru7719/aws-cdk-for-fargateに上がっています。

Fargate上で実行するindex.tsは以下です。

import express from "express";
const app = express();

app.get("/", (req: express.Request, res: express.Response) => {
  res.send("Hello World");
});

app.listen(3000);

ECSで実行するには、Docker Imageが必要です。 今回の例では以下のDockerfileからDocker Imageを作成します。

FROM node:12.13.1
WORKDIR /usr/src/app
COPY package*.json ./
RUN yarn install
COPY . .
RUN yarn tsc --project .
EXPOSE 3000
CMD ["node", "dist/index.js"]

package.jsonやtsconfig.jsonの設定は省略します。詳細は上記のリポジトリを参照してください。

Expressで書かれたアプリケーションを実行するための、ECSを構築するためのコードが以下です。

import * as cdk from "@aws-cdk/core";
import * as ecs from "@aws-cdk/aws-ecs";
import * as elb from "@aws-cdk/aws-elasticloadbalancingv2";

export class MyStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // ECS クラスタの定義
    const cluster = new ecs.Cluster(this, "SampleCluster", {
      clusterName: "SmapleCluster"
    });

    // ECSタスクの定義
    const taskDefinition = new ecs.FargateTaskDefinition(this, "TaskDef");

    // ECSタスクの詳細設定
    const container = taskDefinition.addContainer("DefaultContainer", {
      image: ecs.ContainerImage.fromAsset("../app"),
      memoryLimitMiB: 512,
      cpu: 256
    });

    // ポートのマッピング
    container.addPortMappings({
      containerPort: 3000
    });

    // ECSのサービスの定義
    const ecsService = new ecs.FargateService(this, "Service", {
      cluster,
      taskDefinition,
      desiredCount: 2
    });

    // ALBの定義
    const lb = new elb.ApplicationLoadBalancer(this, "LB", {
      vpc: cluster.vpc,
      internetFacing: true
    });

    // ALBのリスナーを定義
    const listener = lb.addListener("Listener", { port: 80 });

    // ALBのターゲットを定義
    const targetGroup = listener.addTargets("ECS", {
      protocol: elb.ApplicationProtocol.HTTP,
      port: 3000,
      targets: [ecsService]
    });

    // 作成されたロードバランサのDNS名を表示
    new cdk.CfnOutput(this, "LoadBalancerDNS", {
      value: lb.loadBalancerDnsName
    });
  }
}

コードだけ見ていくとすごく単純なことをやっているように見えますが、裏ではECSが実行できるように、様々なリソース定義が行われています。 上からコードを見ていきます。

const cluster = new ecs.Cluster(this, "SampleCluster", {
    clusterName: "SmapleCluster"
});

ecs.ClusterコンストラクタはECSクラスタの宣言です。この関数の裏側ではECSを実行するための、VPCの作成、サブネットの作成(プライベートサブネットとパブリックサブネット両方作成される。)が行われています。 既存のVPCを利用したい場合には第三引数のオプションから渡すことが可能です。

次にECSタスクの定義です。 ECSタスクは実行環境についての設定項目です。 起動タイプはEC2なのかFargateなのか、CPUやMemoryはどれくらい利用するのか、実行するDocker Imageは何を使うか、どのネットワークモードを選択するかといったことを設定します。

以下の部分がECSタスクの定義です。

new ecs.FargateTaskDefinition(this, "TaskDef");

今回はここでタスクを生成してから、taskDefinition.addContainer関数でコンテナの設定を行なっています。 今回はCPU256, Memory512を設定します。 image: ecs.ContainerImage.fromAsset(“../app”)の部分は実行するDocekイメージを指定しています。 今回のfromAsset関数はローカル上のDokcerfileが含まれるディレクトリを指定することでDocker Imageを作成し、そのイメージをタスクの実行イメージとして設定します。

container.addPortMappings({
    containerPort: 3000
});

addMappings関数でコンテナの3000番ポートをホストと接続し、疎通できるようにします。

次はECS Serviceの設定です。 ECS ServiceはECS Taskをどのネットワークで動かすか、何個のタスクを実行するかを指定します。

const ecsService = new ecs.FargateService(this, "Service", {
    cluster,
    taskDefinition,
    desiredCount: 2
});

今回はdesiredCount:2を指定したので、タスク(コンテナ)は2個実行されます。 また、この関数は引数で渡した、clusterに紐づいているネットワークのプライベートサブネット上でタスク(コンテナ)を実行します。

次にロードバランサーを作成していきます。

const lb = new elb.ApplicationLoadBalancer(this, "LB", {
    vpc: cluster.vpc,
    internetFacing: true
});

これでALBを作成します。 vpcはECSクラスタに紐づいているVPCを指定します。 今回はインターネットに露出したロードバランサーを作成したいので、internetFacingをtrueにします。

次にロードバランサーのリスナーを作成します。

const listener = lb.addListener("Listener", { port: 80 });

80番ポートにHTTPリクエストを受信した時にリスナーを起動するように設定します。

次にリスナーのターゲットを設定していきます。

const targetGroup = listener.addTargets("ECS", {
    protocol: elb.ApplicationProtocol.HTTP,
    port: 3000,
    targets: [ecsService]
});

ターゲットをECS Serviceにすることで起動している2つのECS タスクにロードバランスします。 今回はコンテナの3000番ポートでアプリケーションが動いているのでportを3000に設定します。

最後の関数はcdk deloyの実行時にALBのDNS名を表示してくれます。

new cdk.CfnOutput(this, "LoadBalancerDNS", {
    value: lb.loadBalancerDnsName
});

これでアプリケーションをECS Fargate上で実行する準備ができました。

デプロイ

AWS CDKの実行は以下の手順で可能です。

CDKの初期化
$ yarn cdk bootstrap

CDKの実行
$ yarn cdk deploy
cdk deploy後にロードバランサーのDNS名が表示されるので、そのURLにアクセスしてみます。

Hello Worldが表示されました!!! デプロイ成功です!!!!

まとめ

上記のコードを見ると非常に簡単にインフラ構築ができることがわかると思います。 (ちなみに今回のコードをcdk synthコマンドでCloudFormationテンプレートに変換したところ706行ありました。) とても便利なツールなのでみなさんもぜひ AWS CDKを使ってみてください!!