CDKで構築するECS+ALB環境：Spring BootでHelloWorldアプリをデプロイ

# はじめに
この記事ではSpring BootでHello Worldアプリを作成し、CDKで構築したECS+ALB環境で動作させたいと思います。

 **前提**
 - VPC・Subnetは既に作成されている
 - 動作環境はM1 MacBook

# 目次
1. [リソース構成](#リソース構成)
1. [HelloWorldアプリを作成](#helloworldアプリを作成)
1. [CDKでインフラを定義する](#cdkでインフラを定義する)
1. [CDKを使ったdeploy](#cdkを使ったdeploy)
1. [まとめ](#まとめ)
1. [参考文献](#参考文献)

# リソース構成
```mermaid
flowchart LR

%%外部要素のUser
OU1[api call]
OU2[image push]

%%グループとサービス
subgraph GC[AWS]
   subgraph GV[vpc]
        subgraph GS1[public subnet]
            NW1{{"ALB"}}
        end
        subgraph GS2[private subnet]
            CP1(ECS)
        end
   end
   
   CP2(ECR)
end

%%サービス同士の関係
OU1 --> NW1
NW1 --> CP1
CP1 --> CP2
OU2 --> CP2

%%グループのスタイル
classDef SGC fill:none,color:#345,stroke:#345
class GC SGC

classDef SGV fill:none,color:#0a0,stroke:#0a0
class GV SGV

classDef SGPrS fill:#def,color:#07b,stroke:none
class GS2 SGPrS

classDef SGPuS fill:#efe,color:#092,stroke:none
class GS1 SGPuS

%%サービスのスタイル
classDef SOU fill:#aaa,color:#fff,stroke:#fff
class OU1,OU2 SOU

%%Network関連のスタイル
classDef SNW fill:#84d,color:#fff,stroke:none
class NW1,NW2,NW3 SNW

%%Compute関連のスタイル
classDef SCP fill:#e83,color:#fff,stroke:none
class CP1,CP2,CP3 SCP

%%Storage関連のスタイル
classDef SST fill:#493,color:#fff,stroke:#fff
class ST1,ST2,ST3 SST
```

# HelloWorldアプリを作成
**spring initializrで雛形を作成します**
[spring initializr](https://start.spring.io/)
記事作成時は以下を設定しgenerateしました
```shell
Project：Gradle-Groovy
Artifact：HelloWorld
Spring Boot：3.3
Packaging：Jar
Java：21
Dependencies：Spring Web
```
**HelloWorldを返すControllerを作成します**
main/java/com/example/HelloWorld以下にIndexController.javaを作成します
```java:IndexController.java
package com.example.HelloWorld;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/")
public class IndexController {
    @RequestMapping
    public String index() {
        return "HelloWorld";
    }
}
```

**Dockerfileを作成します**
```Dockerfile
FROM openjdk:21
EXPOSE 8080
RUN microdnf install findutils
WORKDIR /var/www/html/server
COPY .. /var/www/html/server
RUN ./gradlew build
ENTRYPOINT ["java", "-jar" , "build/libs/HelloWorld-0.0.1-SNAPSHOT.jar"]
```
**動作確認**
以下のコマンドを実行します
`curl`実行後に`HelloWorld`が出力されればOKです
```shell
% docker build -f app/Dockerfile -t hello-world .
% docker run --rm --name hello-world-container -p 8080:8080 -it hello-world
% curl http://localhost:8080
```

# CDKでインフラを定義する
**CDKのテンプレートを作成します**
以下のコマンドを実行すると、typescriptファイルが作成されます
```shell
% npx cdk init --language=typescript
```
**stackを定義します**
```ts:cdk-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import  * as ecr from 'aws-cdk-lib/aws-ecr';
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as ecs from "aws-cdk-lib/aws-ecs";

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

    const projectName = "hello-world";
    const vpcId = "{vpcのid}";
    const availabilityZones = ["{vpcで定義されているavailabilityZone}"];
    const publicSubnetIds = ["{vpcで定義されているpublicSubnetId}"];
    const publicSubnetRouteTableIds = ["{public subnetに紐づくRouteTableId}"];

    // Create ECR Repository
    const ecrRepository = new ecr.Repository(this, "EcrRepo", {
      repositoryName: `${projectName}-ecr-repo`,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      emptyOnDelete: true,
    });

    // find VPC
    const vpc = ec2.Vpc.fromVpcAttributes(this, "Vpc", {
        vpcId: vpcId,
        availabilityZones: availabilityZones,
        publicSubnetIds: publicSubnetIds,
        publicSubnetRouteTableIds: publicSubnetRouteTableIds
    });

    // Create ECS Cluster
    const cluster = new ecs.Cluster(this, "EcsCluster", {
      clusterName: `${projectName}-cluster`,
      vpc: vpc,
    });

    // Create ALB and ECS Fargate Service
    const service = new cdk.aws_ecs_patterns.ApplicationLoadBalancedFargateService(
        this,
        "FargateService",
        {
          loadBalancerName: `${projectName}-lb`,
          publicLoadBalancer: true,
          cluster: cluster,
          serviceName: `${projectName}-service`,
          cpu: 256,
          memoryLimitMiB: 512,
          assignPublicIp: true,
          taskSubnets: {subnetType: ec2.SubnetType.PUBLIC},
          taskImageOptions: {
            family: `${projectName}-taskdef`,
            containerName: `${projectName}-container`,
            image: ecs.ContainerImage.fromEcrRepository(ecrRepository, "latest"),
            logDriver: new ecs.AwsLogDriver({
              streamPrefix: `container`,
            }),
            containerPort: 8080,
          },
          runtimePlatform: {
            cpuArchitecture: ecs.CpuArchitecture.ARM64
          },
        }
    );
  }
}
```
基本的には
[ECSとECRのコンテナ構成をCDKで実装してみた](https://dev.classmethod.jp/articles/cdk-ecs-ecr/)の記事通りに設定しているのですが、何点かハマったところがあるので解説していきます。

**VPCの取得が上手くいかなかった**
```ts
// find VPC
const vpc = ec2.Vpc.fromVpcAttributes(this, "Vpc", {
    vpcId: vpcId,
    availabilityZones: availabilityZones,
    publicSubnetIds: publicSubnetIds,
    publicSubnetRouteTableIds: publicSubnetRouteTableIds
});
```
VPCを取得するコードです
`fromVpcAttributes`には`publicSubnetIds`や`publicSubnetRouteTableIds`が必須ではないですが、`publicSubnetIds`がない状態だと、diff実行時にVPCが適切に取得できず以下のエラーを出力する場合があります

```sh
Error: There are no 'Public' subnet groups in this VPC. Available types:
```
また、`publicSubnetRouteTableIds`を指定しない場合はrouteTableを認識できていないため以下のwarningが出力されます
```sh
[Warning at /CdkStack/Vpc/PublicSubnet1] No routeTableId was provided to the subnet ''. Attempting to read its .routeTable.routeTableId will return null/undefined. 
```

**実行環境の指定**
```ts
runtimePlatform: {
    cpuArchitecture: ecs.CpuArchitecture.ARM64
}
```
実行環境をARM64に設定しています
ECS FargateのデフォルトがX86_64のようなので、M1 MacBookでイメージをビルドしている関係上、そちらに合わせる必要があります
ECS実行時に以下のエラーが出力されていると、上記が原因の可能性があるので、確認してみてください

```sh
exec /usr/java/openjdk-21/bin/java: exec format error
```

**portの指定**
```ts
containerPort: 8080
```
今回アプリケーションを8080ポートで実行しているので、ALBからECSへの8080ポートを通過可能にする必要があります
設定していなければ、HealthCheckがずっと通らずにECSのdeployが終わらないので、deployがなかなか終わらない時に確認してみてください

# CDKを使ったdeploy
deployしていくのですが、初回実行時に注意点があるので、初回実行時と2回目以降で分けて解説していきます

### 初回実行時
初回実行時にそのままdeployをしてしまうと、ECRにイメージがpushされていないので、ECSがイメージを取得できずエラーになってしまいます
そのため、まずはECRのみを作成→イメージをpush→ECS+ALBをdeployするという手順を踏む必要があります
[cdk-ecr-deployment](https://github.com/cdklabs/cdk-ecr-deployment)を使うと、そこも上手くやってくれるようなのですが、今回は上手く動作させることができなかったので別々の手順を踏みます

1. ECR以外をコメントアウトしdeployを行います
1. ECRにビルドしたイメージをpushします
    コンソールでECRへ遷移し「プッシュコマンドの表示」で表示されたコマンドを実行していきます
1. コメントアウトを外し、deployを行います
1. 実行完了後、ALBのDNS名が表示されるので、そちらをcurlコマンドで実行し、HelloWorldと表示されれば動作確認完了です
```shell
% cdk deploy
Outputs:
CdkStack.FargateServiceLoadBalancerDNSXXXXXXXX = <ALBのDNS名>
CdkStack.FargateServiceServiceURLXXXXXXXX = <ECSサービスURL>
% curl <ALBのDNS名>
HelloWorld ← 表示されれば動作確認完了
```

### 2回目以降
2回目以降はECRにイメージが既に存在するので、先に実行する必要はありませんが、ECSはlatestタグのものを参照するようになっているので、先ほどと同じようにlatestタグでpushしdeployしてしまうと変更がされません
そのためタグをlatestではないimageをpush→ECSの参照先を変える→deployという手順を踏む必要があります

1. ECRにタグ名を変えたイメージをpushします
「プッシュコマンドの表示」で表示されたコマンドの、latestを任意の名前に変更し実行します
2. ECSの参照先を変更します
    ```ts:cdk-stack.ts
    image: ecs.ContainerImage.fromEcrRepository(ecrRepository, "latest")
    ```
    この部分で参照しているので、latestの文字列を先ほど指定したタグ名に変更します
3. deployを実行、変更が反映されていれば完了です

# まとめ
今回はCDKでECS+ALBの環境を作成してみました
ApplicationLoadBalancedFargateServiceがよしなに必要なものを作成してくれるので、セキュリティグループを1つずつ作成する手間もなく、思ったよりも簡単に作成することができました

cdk-ecr-deploymentを上手く動作させれなかったのでdeployの部分が煩雑になりましたが、上手く動いていればdeployするだけで良いので、次の機会では動作させたいと思います

# 参考文献
- [ECSとECRのコンテナ構成をCDKで実装してみた](https://dev.classmethod.jp/articles/cdk-ecs-ecr/)