AWS CDKを利用してECS+FargateでLaravel環境構築

## 概要
AWS CDKを利用してECS+FargateでLaravel11の初期画面を表示するまでのテンプレートを作成しました。
何番煎じか分かりませんが、そのものズバリな記事はなかったので残しておこうと思います。
最終的なコードは[リポジトリ](https://github.com/naniwadari/ecs_parctice)にあります。

## 前提
- AWS CLIのインストール([インストールガイド](https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/getting-started-install.html))
- AWS CDKのインストール([インストールガイド](https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/getting_started.html))
- AWS CLIで`aws configure`を実行し、アカウントに接続できていること
- `cdk bootstrap`がアカウントに対して実行済みであること。

## 利用言語
- Typescript

## 解説
※コンテナの中身の解説はしません。Dockerfileは[リポジトリ](https://github.com/naniwadari/ecs_parctice/tree/master/docker)をご参照ください。

### 設定
環境ごとで設定を変えたいし、リポジトリには上げたくない情報もあるので設定ファイルを参照する形にしています。
読み込む設定ファイルを`cdk deploy`時に自動的に切り替えるようにしたかったですが、今回は直で読み込みに行ってます。
```stack/config/example.ts
import { RemovalPolicy } from "aws-cdk-lib"

export const config = {
  account: "xxxxxxxxxxxxxx",
  region: "ap-northeast-1",
  // スタック名
  stackName: "xxxxxx-stack",
  resourceName: "xxxxxxxxxxxxx",
  ecrRepository: {
    // お試しなのでスタック削除時はまとめて削除、本番では非推奨
    removalPolicy: RemovalPolicy.DESTROY,
    autoDeleteImage: true,
  },
  logGroup: {
    // お試しなのでスタック削除時はまとめて削除、本番では非推奨
    removalPolicy: RemovalPolicy.DESTROY,
  }
}
```

### ECRの定義
LaravelとNginxのコンテナイメージを保存するECRの定義と、ビルド＆デプロイ設定を行います。
```stack/ecs_stack.ts
    // Nginx用リポジトリ
    const ecrRepositoryNginx = new Ecr.Repository(this, "EcrRepoNginx", {
      repositoryName: `${resourceName}-ecr-repo-nginx`,
      removalPolicy: config.ecrRepository.removalPolicy,
      emptyOnDelete: config.ecrRepository.autoDeleteImage,
    })

    const dockerImageAssetNginx = new DockerImageAsset(this, "DockerImageAssetNginx", {
      // Dockerfileは親ディレクトリ参照できないのでdirecotryとfileを別で定義
      directory: '.',
      file: "docker/nginx/Dockerfile",
      platform: Platform.LINUX_AMD64,
    })

    new EcrDeploy.ECRDeployment(this, "DeployDockerImageNginx", {
      src: new EcrDeploy.DockerImageName(dockerImageAssetNginx.imageUri),
      dest: new EcrDeploy.DockerImageName(
        `${accountId}.dkr.ecr.${region}.amazonaws.com/${ecrRepositoryNginx.repositoryName}:latest`
      )
    })

    //PHPのリポジトリは名称以外同様のため省略
```

#### 注意点
Dockerfileはビルド時に親ディレクトリを参照できません。参照する場合、実行ディレクトリはルートにし、ファイルを個別で指定する必要があります。

↓こういうケース
```./docker/php/Dockerfile
COPY ./app/ /var/www/app
```

### VPCとクラスターの定義
ECSにおけるクラスターはサービスとタスクを実行する基盤です。
クラスター内には複数のタスクが配置され、それぞれが独自のコンテナを実行します。

```stack/ecs_stack.ts
    const vpc = new Ec2.Vpc(this, "Vpc", {
      vpcName: `${resourceName}-vpc`,
      maxAzs: 2,
      ipAddresses: Ec2.IpAddresses.cidr("10.0.0.0/20"),
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: `${resourceName}-public`,
          subnetType: Ec2.SubnetType.PUBLIC,
        },
      ],
    })

    const cluster = new Ecs.Cluster(this, "EcsCluster", {
      clusterName: `${resourceName}-cluster`,
      vpc: vpc,
    })
```

### タスク定義
タスク定義を行います。
ECSにおけるタスクとは、コンテナ群の定義です。個人的にはdocker-composeの役割と考えるとしっくり来ます。
今回は、コンテナのポートとログ設定を行っています。

また、NginxコンテナはPHPコンテナに依存しているため、依存関係も定義します。
```stack/ecs_stack.ts
    /**
     * タスクとコンテナのポートとログを定義
     */
    const taskDefinition = new Ecs.FargateTaskDefinition(this, "TaskDefinition", {
      family: `${resourceName}-taskdef`
    })

    const logGroup = new Logs.LogGroup(this, "LogGroup", {
      logGroupName: `/aws/ecs/${resourceName}`,
      removalPolicy: config.logGroup.removalPolicy,
    })

    const nginxContainer = taskDefinition.addContainer('nginx', {
      image: Ecs.ContainerImage.fromEcrRepository(ecrRepositoryNginx, "latest"),
      portMappings: [
        {
          hostPort: 80,
          containerPort: 80,
        }
      ],
      logging: new Ecs.AwsLogDriver({
        streamPrefix: `nginx`,
        logGroup: logGroup,
      }),
    })

    const appContainer = taskDefinition.addContainer('app', {
      image: Ecs.ContainerImage.fromEcrRepository(ecrRepositoryPhp, "latest"),
      portMappings: [
        {
          hostPort: 9000,
          containerPort: 9000,
        }
      ],
      logging: new Ecs.AwsLogDriver({
        streamPrefix: `app`,
        logGroup: logGroup,
      }),
    })

    // コンテナの依存関係を定義
    nginxContainer.addContainerDependencies({
      container: appContainer,
      condition: Ecs.ContainerDependencyCondition.START
    })
```

#### 注意点
ネットワークはデフォルトのawsvpcを利用しています。
この場合、コンテナ間はlocalhostで通信を行うため、Nginxの通信設定はそれに則る必要があります。
```./docker/nginx/default.conf
    # ECSのネットワーキングがawsvpcの場合、localhostで接続できる
    fastcgi_pass localhost:9000;
```
私は当初コンテナ名で記載しており、通信が通らず苦悩しました。。。😭

### ecs-patternsを利用してALBとFargateの定義
`ecs-patterns`はECSの利用パターンに沿ったテンプレートを用意してくれているライブラリです。
今回は`ApplicationLoadBalancedFargateService`を利用します([公式Doc](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ecs_patterns.ApplicationLoadBalancedFargateService.html))
```
    const service = new EcsPatterns.ApplicationLoadBalancedFargateService(
      this,
      "FargateService",
      {
        loadBalancerName: `${resourceName}-lb`,
        publicLoadBalancer: true,
        cluster: cluster,
        serviceName: `${resourceName}-service`,
        cpu: 256,
        desiredCount: 2,
        memoryLimitMiB: 512,
        assignPublicIp: true,
        taskSubnets: { subnetType: Ec2.SubnetType.PUBLIC },
        targetProtocol: ApplicationProtocol.HTTP,
        taskDefinition: taskDefinition,
      }
    )
```

#### 注意点
特に定義していない場合、LBのターゲットポートが**最初にタスク定義したコンテナ**のポートになります。
今回の場合、PHPのコンテナを最初に定義すると9000番にアクセスしてしまいます。
当然、ヘルスチェックも失敗するので、タスクが停止します。
LBがどのポートに繋げようとしているかはチェックしておきましょう。

## デプロイ
`cdk deploy`を実行すればスタックを作成することができます。
複数プロファイルがある場合は、事前に`export AWS_PROFILE=xxxxxxx`でプロファイルを指定してください。

デプロイに成功すると下記の出力がされるので、DNS名にブラウザでアクセスしてLaravelの初期画面が表示されたら完了です。
```
 ✅ xxxxxxxx-stack

✨  Deployment time: 00.00s

Outputs:
xxxxxxxx-stack.FargateServiceLoadBalancerDNSxxxxxxx = {DNS名}
xxxxxxxx-stack.FargateServiceServiceURLxxxxx = {ECSサービスURL}
```

## まとめ
普段EC2インスタンスでの構築をしており、ECSに慣れていなかったため良い勉強になりました。
Laravelをコンテナで運用したい方の参考になれば幸いです。