LambdaからECS(Python FastAPI)に移行

PoCなどでコストメリットのあるLambdaベースで開発を進め、性能問題などが発生し、ECSに移行するケースがあると思います。その際に、LambdaとECSを並行で進めるため、コードを共通化したいというケースを想定し、Lambda用のアダプターを作成して、ECSで動かす事例を紹介します。

# Lambdaを使ったToDo APIな

まずは、API Gateway + Lambda + DynamoDBを使った簡単なAPIを作成します。

| API Path    | HTTP Method | CodeUri           | Handler            | 説明                                                      |
|-------------|-------------|-------------------|--------------------|-----------------------------------------------------------|
| /todo       | POST        | ./src/create_todo | app.lambda_handler | リクエストボディから新規ToDo項目を作成し、DynamoDBに保存する。  |
| /todo/{id}  | GET         | ./src/get_todo    | app.lambda_handler | 指定されたIDのToDo項目をDynamoDBから取得する。              |
| /todo       | GET         | ./src/list_todos  | app.lambda_handler | DynamoDB上の全ToDo項目の一覧を返す。                        |
| /todo/{id}  | PUT         | ./src/update_todo | app.lambda_handler | 指定されたIDのToDo項目の内容を更新する。                    |
| /todo/{id}  | DELETE      | ./src/delete_todo | app.lambda_handler | 指定されたIDのToDo項目をDynamoDBから削除する。              |

## ディレクトリ構造

ディレクトリ構造は以下のようにしています。
簡単なAPIなので一つのコードにまとめることもできますが、各APIを疎結合にするため、別々のディレクトリとしました。

```
todo-api-python
├── samconfig.toml
├── src
│   ├── create_todo
│   │   ├── app.py
│   │   └── requirements.txt
│   ├── delete_todo
│   │   ├── app.py
│   │   └── requirements.txt
│   ├── get_todo
│   │   ├── app.py
│   │   └── requirements.txt
│   ├── list_todos
│   │   ├── app.py
│   │   └── requirements.txt
│   └── update_todo
│       ├── app.py
│       └── requirements.txt
└── template.yaml
```

## POST /todo

```python:./src/create_todo/app.py
import boto3
import json
import uuid
from datetime import datetime

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('todos')

def lambda_handler(event, context):
    body = json.loads(event['body'])
    task = body['task']

    todo_id = str(uuid.uuid4())
    created_at = datetime.utcnow().isoformat()

    item = {
        'id': todo_id,
        'task': task,
        'completed': False,
        'created_at': created_at
    }

    table.put_item(Item=item)

    return {
        'statusCode': 201,
        'body': json.dumps(item)
    }
```

```text:./src/create_todo/requirements.txt
```

※今回は、Lambda標準のパッケージを使っているため、追加するパッケージはありません。

## GET /todo/{id}

```python:./src/get_todo/app.py
import boto3
import json

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('todos')

def lambda_handler(event, context):
    todo_id = event['pathParameters']['id']

    response = table.get_item(Key={'id': todo_id})

    item = response.get('Item')
    if not item:
        return {
            'statusCode': 404,
            'body': json.dumps({'message': 'ToDo not found'})
        }

    return {
        'statusCode': 200,
        'body': json.dumps(item)
    }
```

```text:./src/get_todo/requirements.txt
```

## GET /todo

```python:./src/list_todos/app.py
import boto3
import json

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('todos')

def lambda_handler(event, context):
    response = table.scan()

    items = response.get('Items', [])

    return {
        'statusCode': 200,
        'body': json.dumps(items)
    }
```

```text:./src/list_todos/requirements.txt
```


## PUT /todo/{id}

```python:./src/update_todo/app.py
import boto3
import json

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('todos')

def lambda_handler(event, context):
    todo_id = event['pathParameters']['id']
    body = json.loads(event['body'])

    update_expression = "SET task = :task, completed = :completed"
    expression_values = {
        ':task': body['task'],
        ':completed': body['completed']
    }

    response = table.update_item(
        Key={'id': todo_id},
        UpdateExpression=update_expression,
        ExpressionAttributeValues=expression_values,
        ReturnValues="ALL_NEW"
        )

    return {
        'statusCode': 200,
        'body': json.dumps(response['Attributes'])
    }
```

```text:./src/update_todo/requirements.txt
```

## DELETE /todo/{id}

```python:./src/delete_todo/app.py
import boto3
import json

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('todos')

def lambda_handler(event, context):
    todo_id = event['pathParameters']['id']

    table.delete_item(Key={'id': todo_id})

    return {
        'statusCode': 204,
        'body': ''
    }
```

```text:./src/delete_todo/requirements.txt
```

## SAMによるデプロイ

SAMを使って、API Gateway、Lambda、DynamoDBをデプロイします。

```yaml:./template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Description: |
  ToDo application backend with AWS Lambda, API Gateway, and DynamoDB

Globals:
  Function:
    Runtime: python3.9
    MemorySize: 128
    Timeout: 10

Resources:

  TodosTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: todos
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      KeySchema:
        - AttributeName: id
          KeyType: HASH
      BillingMode: PAY_PER_REQUEST

  CreateTodoFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: app.lambda_handler
      CodeUri: ./src/create_todo
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref TodosTable
      Events:
        CreateTodo:
          Type: Api
          Properties:
            Path: /todo
            Method: post

  GetTodoFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: app.lambda_handler
      CodeUri: ./src/get_todo
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref TodosTable
      Events:
        GetTodo:
          Type: Api
          Properties:
            Path: /todo/{id}
            Method: get

  ListTodosFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: app.lambda_handler
      CodeUri: ./src/list_todos
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref TodosTable
      Events:
        ListTodos:
          Type: Api
          Properties:
            Path: /todo
            Method: get

  UpdateTodoFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: app.lambda_handler
      CodeUri: ./src/update_todo
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref TodosTable
      Events:
        UpdateTodo:
          Type: Api
          Properties:
            Path: /todo/{id}
            Method: put

  DeleteTodoFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: app.lambda_handler
      CodeUri: ./src/delete_todo
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref TodosTable
      Events:
        DeleteTodo:
          Type: Api
          Properties:
            Path: /todo/{id}
            Method: delete

Outputs:
  ApiEndpoint:
    Description: "API Gateway endpoint URL"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod"
```

sam deploy --guidedとして、samconfig.tomlを作成してもよいですし、以下のような定義を事前に作成することも可能です。

```toml:./samconfig.toml
version = 0.1
[default.deploy.parameters]
stack_name = "todo-api-python"
resolve_s3 = true
s3_prefix = "todo-api-python"
region = "ap-northeast-1"
capabilities = "CAPABILITY_IAM"
image_repositories = []
```

以下のようにbuild & deployをします。

```bash
sam build
sam deploy
```

## 動作確認

```bash
export API_URL=<API Gateway endpoint URL>
```

```bash
curl -X POST ${API_URL}/todo \
-H "Content-Type: application/json" \
-d '{"task": "deploy todo api"}'
```

```bash
id=<登録されたToDoのID>
```

```bash
curl -X GET ${API_URL}/todo/${id}
```

```bash
curl -X PUT ${API_URL}/todo/${id} \
-H "Content-Type: application/json" \
-d '{"task": "update todo api", "completed": true}'
```

```bash
curl -X DELETE ${API_URL}/todo/${id}
```

```bash
curl -X GET ${API_URL}/todo
```

# FastAPI to Lambdaアダプターの作成

Lambdaのコードをそのまま再利用してFastAPIに統合する、FastAPI to Lambdaアダプターを作成します。
このアダプターは、FastAPIのリクエストをLambdaのイベント形式に変換し、既存のLambdaハンドラを呼び出して、そのレスポンスをFastAPIのResponseに変換します。

```python:./src/lambda_adapter.py
from fastapi import Request, Response

def lambda_adapter(lambda_handler):
    async def adapter(request: Request):
        body_bytes = await request.body()
        event = {
            "httpMethod": request.method,
            "headers": dict(request.headers),
            "queryStringParameters": dict(request.query_params),
            "pathParameters": request.path_params,
            "body": body_bytes.decode() if body_bytes else None,
        }

        context = {}

        lambda_response = lambda_handler(event, context)

        headers = lambda_response.get('headers', {})
        return Response(
            content=lambda_response.get('body', ''),
            status_code=lambda_response.get('statusCode', 200),
            headers=headers
        )
    return adapter
```

# コンテナを作成

Lambdaのコードを実行するためのコンテナを作成します。

## ディレクトリ構造

本当は、./dockerの配下にコンテナで利用するファイルをまとめ、./srcを取り込みたかったのですが、Dockerfileの上位のファイル/ディレクトリを参照することができなかったため、以下のようなディレクトリ構造としました。ビルド用のスクリプトを作成することで解決できますが、今回は、CDK内でコンテナをビルドする方針としたため、以下のような構造としています。

```
src
├── create_todo
│   ├── app.py
│   └── requirements.txt
├── delete_todo
│   ├── app.py
│   └── requirements.txt
├── Dockerfile
├── get_todo
│   ├── app.py
│   └── requirements.txt
├── lambda_adapter.py
├── list_todos
│   ├── app.py
│   └── requirements.txt
├── main.py
├── requirements.txt
└── update_todo
    ├── app.py
    └── requirements.txt
```

## Lambdaを呼び出す処理

先ほど作成したアダプターを使って、Lambdaを呼び出します。

```python:./src/main.py
from fastapi import FastAPI, Request
from lambda_adapter import lambda_adapter
from list_todos.app  import lambda_handler as list_todos_lambda_handler
from create_todo.app import lambda_handler as create_todo_lambda_handler
from get_todo.app    import lambda_handler as get_todo_lambda_handler
from update_todo.app import lambda_handler as update_todo_lambda_handler
from delete_todo.app import lambda_handler as delete_todo_lambda_handler

app = FastAPI()

# GET /health:
@app.get("/health")
async def health_check():
    return {"status": "ok"}

# GET /todo
@app.get("/todo")
async def list_todos(request: Request):
    return await lambda_adapter(list_todos_lambda_handler)(request)

# POST /todo
@app.post("/todo")
async def todo_get(request: Request):
    return await lambda_adapter(create_todo_lambda_handler)(request)

# GET /todo/{id}
@app.get("/todo/{id}")
async def todo_get(request: Request):
    return await lambda_adapter(get_todo_lambda_handler)(request)

# PUT /todo/{id}
@app.put("/todo/{id}")
async def todo_get(request: Request):
    return await lambda_adapter(update_todo_lambda_handler)(request)

# DELETE /todo/{id}
@app.delete("/todo/{id}")
async def todo_get(request: Request):
    return await lambda_adapter(delete_todo_lambda_handler)(request)
```

```./src/requirements.txt
boto3
fastapi
uvicorn
```

## Dockerfile

上記で作成したmain.pyとlambda_adapter.pyをコンテナ内にコピーします。
また、利用するLambdaのコードもあわせてコンテナ内にコピーしています。

```Dockerfile:Dockerfile
# ベースイメージとしてpython3.9-slimを使用
FROM python:3.9-slim

# 作業ディレクトリを設定
WORKDIR /app

# ルートのrequirements.txtをコピーして依存関係をインストール
COPY requirements.txt .
RUN pip install --upgrade pip && \
    pip install -r requirements.txt

# main.py と lambda_adapter.py をコピー
COPY main.py .
COPY lambda_adapter.py .

# 各モジュールディレクトリを、ディレクトリ名を維持してコピー
COPY create_todo/ create_todo/
COPY delete_todo/ delete_todo/
COPY get_todo/ get_todo/
COPY list_todos/ list_todos/
COPY update_todo/ update_todo/

# コンテナが使用するポートを開放（FastAPIのデフォルトポート）
EXPOSE 8000

# FastAPIアプリ(main.py内の app)をuvicornで起動
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
```

# CDKでコンテナをデプロイ

次に、コンテナをECS上にデプロイするため、AWS CDKを利用して以下のリソースを作成します。

以下は、それぞれのスタックについての説明です。

**VpcStack**  
- プライベート（isolated）サブネットのみの VPC を作成します。  
- S3、DynamoDB、ECR、CloudWatch Logs にアクセスするための必要な VPC エンドポイント（Gateway または Interface 型）を構築し、内部通信を効率化・セキュアにしています。

**EcrStack**  
- ローカルの Docker コンテキストからコンテナイメージをビルドし、プライベートな ECR リポジトリにプッシュする役割を担います。  
- このスタックで作成したイメージは、後続の ECS サービスで利用されます。

**NlbStack**  
- プライベートサブネットに NLB（Network Load Balancer）を作成します。  
- NLB 自体はセキュリティグループを持たないため、NLB のトラフィックを受ける ECS ターゲットに対して、VPC 内からの HTTP (TCP 80) トラフィックを許可するためのセキュリティグループを別途作成・設定します。

**EcsStack**  
- ECS クラスターを作成し、Fargate タスク定義とサービスを構築します。  
- タスク定義では、EcrStackでビルドした FastAPI コンテナイメージを利用してコンテナを起動し、FastAPI アプリケーションを実行します。  
- また、対象となる ECS ターゲットのセキュリティグループに、NLB からのトラフィック（例えば、TCP 8000 へのアクセス）を許可するルールを追加し、NLB 経由でのアクセスが確実に届くように設定しています。

## ディレクトリ構造

```
cdk
├── bin
│   └── app.ts
├── cdk.json
├── lib
│   ├── ecr-stack.ts
│   ├── ecs-stack.ts
│   ├── nlb-stack.ts
│   └── vpc-stack.ts
├── package.json
└── tsconfig.json
```

## エントリーポイント

```typescript:./cdk/bin/app.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { VpcStack } from '../lib/vpc-stack';
import { EcrStack } from '../lib/ecr-stack';
import { NlbStack } from '../lib/nlb-stack';
import { EcsStack } from '../lib/ecs-stack';

const app = new cdk.App();

// VPCスタックの作成
const vpcStack = new VpcStack(app, 'VpcStack');

// Dockerイメージビルド＆ECRデプロイ用スタックの作成
const ecrStack = new EcrStack(app, 'EcrStack');

// NLBスタック（VPCはVpcStackから）
const nlbStack = new NlbStack(app, 'NlbStack', {
  vpc: vpcStack.vpc,
});

// ECSスタック（VPC、ECRのイメージ、NLBのリスナー・セキュリティグループを参照）
new EcsStack(app, 'EcsStack', {
  vpc: vpcStack.vpc,
  dockerImageAsset: ecrStack.imageAsset,
  nlbListener: nlbStack.listener,
  nlbSecurityGroup: nlbStack.nlbSecurityGroup,
});
```

## VpcStack

```typescript:./cdk/lib/vpc-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';

export class VpcStack extends cdk.Stack {
  public readonly vpc: ec2.Vpc;
  
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // マルチAZ、プライベート（isolated）サブネットのみのVPCを作成
    this.vpc = new ec2.Vpc(this, 'MyVpc', {
      maxAzs: 2,
      natGateways: 0,
      subnetConfiguration: [
        {
          name: 'Private',
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
      ],
    });

    // S3用ゲートウェイエンドポイントを追加
    this.vpc.addGatewayEndpoint('S3Endpoint', {
      service: ec2.GatewayVpcEndpointAwsService.S3,
      subnets: [{
        subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
      }],
    });

    // DynamoDB用ゲートウェイエンドポイントを追加
    this.vpc.addGatewayEndpoint('DynamoDbEndpoint', {
      service: ec2.GatewayVpcEndpointAwsService.DYNAMODB,
      subnets: [{
        subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
      }],
    });

    // ECR API用インターフェースエンドポイントを追加
    this.vpc.addInterfaceEndpoint('EcrApiEndpoint', {
      service: ec2.InterfaceVpcEndpointAwsService.ECR,
      subnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
    });

    // ECR Docker用インターフェースエンドポイントを追加
    this.vpc.addInterfaceEndpoint('EcrDkrEndpoint', {
      service: ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER,
      subnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
    });

    // CloudWatch Logs用インターフェースエンドポイントを追加
    this.vpc.addInterfaceEndpoint('CloudWatchLogsEndpoint', {
      service: ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS,
      subnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
    });
  }
}
```

## EcrStack

```typescript:./cdk/lib/ecr-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets';

export class EcrStack extends cdk.Stack {
  // DockerImageAssetが作成したイメージを後続スタックで利用
  public readonly imageAsset: ecr_assets.DockerImageAsset;
  
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Dockerfileが配置されているディレクトリ（ここではプロジェクトルート）からDockerイメージをビルド  
    // repositoryNameを指定すると、新規のプライベートECRリポジトリが作成されます
    this.imageAsset = new ecr_assets.DockerImageAsset(this, 'TodoApiImage', {
      directory: '../src', // Dockerfileのあるディレクトリ
    });
  }
}
```

## NlbStack

```typescript:./cdk/lib/nlb-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';

interface NlbStackProps extends cdk.StackProps {
  vpc: ec2.Vpc;
}

export class NlbStack extends cdk.Stack {
  public readonly loadBalancer: elbv2.NetworkLoadBalancer;
  public readonly nlbSecurityGroup: ec2.SecurityGroup;
  public readonly listener: elbv2.NetworkListener;
  
  constructor(scope: Construct, id: string, props: NlbStackProps) {
    super(scope, id, props);

    // セキュリティグループ
    this.nlbSecurityGroup = new ec2.SecurityGroup(this, 'NlbSecurityGroup', {
      vpc: props.vpc,
      description: 'Security group for ECS targets behind the NLB',
      allowAllOutbound: true,
    });

    // VPC内からのHTTPを許可
    this.nlbSecurityGroup.addIngressRule(
      ec2.Peer.ipv4(props.vpc.vpcCidrBlock),
      ec2.Port.tcp(80),
      'Allow inbound HTTP from within the VPC'
    );

    // プライベートサブネット（isolated）にNLBを作成、マルチAZ対応
    this.loadBalancer = new elbv2.NetworkLoadBalancer(this, 'MyNLB', {
      vpc: props.vpc,
      internetFacing: false,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
      },
      crossZoneEnabled: true,
      securityGroups: [this.nlbSecurityGroup]
    });

    // リスナーの作成（例としてTCPポート80）
    this.listener = this.loadBalancer.addListener('Listener', {
      port: 80,
      protocol: elbv2.Protocol.TCP,
    });
  }
}
```

## EcsStack

```typescript:./cdk/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 ecr_assets from 'aws-cdk-lib/aws-ecr-assets';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';

interface EcsStackProps extends cdk.StackProps {
  vpc: ec2.Vpc;
  dockerImageAsset: ecr_assets.DockerImageAsset;
  nlbListener: elbv2.NetworkListener;
  nlbSecurityGroup: ec2.SecurityGroup;
}

export class EcsStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: EcsStackProps) {
    super(scope, id, props);

    // ECSクラスター作成（マルチAZ）
    const cluster = new ecs.Cluster(this, 'EcsCluster', {
      vpc: props.vpc,
    });

    // タスク実行用IAMロール（DynamoDB利用可能な権限を付与）
    const taskRole = new iam.Role(this, 'EcsTaskRole', {
      assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
    });
    taskRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonDynamoDBFullAccess'));

    // Fargateタスク定義の作成
    const taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDef', {
      taskRole: taskRole,
    });

    // ECRに格納したDockerイメージを利用するコンテナの追加  
    // ※main.py内でFastAPIアプリ（ポート8000）を実行する前提
    const container = taskDefinition.addContainer('AppContainer', {
      image: ecs.ContainerImage.fromDockerImageAsset(props.dockerImageAsset),
      logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'ecs' }),
    });
    container.addPortMappings({
      containerPort: 8000,
    });

    // ECS用のセキュリティグループを作成し、同一VPC内からのTCP:8000通信を許可
    const ecsSecurityGroup = new ec2.SecurityGroup(this, 'EcsSecurityGroup', {
      vpc: props.vpc,
      description: 'Allow inbound TCP 8000 from within the VPC',
      allowAllOutbound: true,
    });
    // VPC CIDR を指定して、同一VPC内からのアクセスを許可
    ecsSecurityGroup.addIngressRule(
      ec2.Peer.ipv4(props.vpc.vpcCidrBlock),
      ec2.Port.tcp(8000),
      'Allow inbound TCP 8000 from within the VPC'
    );
    // NLBのセキュリティグループを指定して、NLBからのアクセスを許可
    ecsSecurityGroup.addIngressRule(
      ec2.Peer.securityGroupId(props.nlbSecurityGroup.securityGroupId),
      ec2.Port.tcp(8000),
      'Allow inbound TCP 8000 from NLB security group'
    );

    // Fargateサービスの作成（プライベートサブネット配置、マルチAZ対応）
    const service = new ecs.FargateService(this, 'FargateService', {
      cluster,
      taskDefinition,
      assignPublicIp: false,
      securityGroups: [ecsSecurityGroup],
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
      },
      desiredCount: 2,
    });

    // ECSサービス作成後、NLBリスナーに直接サービスを登録し、ヘルスチェック設定を追加
    props.nlbListener.addTargets('EcsTargets', {
      port: 8000,
      targets: [service],
      healthCheck: {
        path: '/health',
        healthyHttpCodes: "200", // 追加：正常時に返されるHTTPコード
        protocol: elbv2.Protocol.HTTP,
        interval: cdk.Duration.seconds(30),
        timeout: cdk.Duration.seconds(5),
        healthyThresholdCount: 2,
        unhealthyThresholdCount: 2,
      },
    });
  }
}
```

:::note info

ECSのタスク定義は必要に応じてリソースやカーネルパラメータのチューニングが必要です。

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ecs.ContainerDefinitionOptions.html

```typescript:チューニングの例
    const container = taskDefinition.addContainer('MyContainer', {
      memoryLimitMiB: 512,
      logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'ecs' }),
      systemControls: [
        {
          namespace: 'net.ipv4.ip_forward',
          value: '1',
        },
        {
          namespace: 'net.core.somaxconn',
          value: '1024',
        },
        {
          namespace: 'net.ipv4.tcp_keepalive_time',
          value: '300',
        },
      ],
    });
```
:::

## CDKデプロイ

CDKのデプロイに必要なcdk.json、package.json、tsconfig.jsonを作成します。（一例です）

```json:./cdk/cdk.json
{
  "app": "npx ts-node --prefer-ts-exts bin/app.ts",
  "context": {}
}
```

```json:./cdk/package.json
{
  "name": "cdk-app",
  "version": "0.1.0",
  "bin": {
    "cdk-app": "bin/app.js"
  },
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "cdk": "cdk"
  },
  "dependencies": {
    "aws-cdk-lib": "^2.186.0",
    "constructs": "^10.4.2"
  },
  "devDependencies": {
    "typescript": "^4.9.5",
    "ts-node": "^10.9.2"
  }
}
```

```json:./cdk/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2021",
    "module": "commonjs",
    "lib": ["ES2021"],
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true
  },
  "include": [
    "bin/**/*.ts",
    "lib/**/*.ts"
  ]
}
```

以下のようにデプロイします。

```
npm install
npx cdk bootstrap
npx cdk deploy --all
```

# API Gatewayに登録

CDKで作成したECSをAPI Gatewayに登録します。
Lambda用に作成したAPI Gatewayとは別のAPI Gatewayを作成しています。

## SAMによるデプロイ

```./template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Description: |
  ToDo application backend with AWS Lambda, API Gateway, DynamoDB,
  and ECS integration via VPC Link with proxy+.

Globals:
  Function:
    Runtime: python3.9
    MemorySize: 128
    Timeout: 10

Parameters:
  # CDK でデプロイした ECS の NLB の DNS 名
  NlbDnsName:
    Type: String
    Description: "DNS name of the ECS service's NLB"

  # CDK でデプロイした ECS の NLB の ARN
  NlbArn:
    Type: String
    Description: "ARN of the ECS service's NLB"

Resources:

  TodosTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: todos
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      KeySchema:
        - AttributeName: id
          KeyType: HASH
      BillingMode: PAY_PER_REQUEST

  CreateTodoFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: app.lambda_handler
      CodeUri: ./src/create_todo
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref TodosTable
      Events:
        CreateTodo:
          Type: Api
          Properties:
            Path: /todo
            Method: post

  GetTodoFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: app.lambda_handler
      CodeUri: ./src/get_todo
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref TodosTable
      Events:
        GetTodo:
          Type: Api
          Properties:
            Path: /todo/{id}
            Method: get

  ListTodosFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: app.lambda_handler
      CodeUri: ./src/list_todos
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref TodosTable
      Events:
        ListTodos:
          Type: Api
          Properties:
            Path: /todo
            Method: get

  UpdateTodoFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: app.lambda_handler
      CodeUri: ./src/update_todo
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref TodosTable
      Events:
        UpdateTodo:
          Type: Api
          Properties:
            Path: /todo/{id}
            Method: put

  DeleteTodoFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: app.lambda_handler
      CodeUri: ./src/delete_todo
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref TodosTable
      Events:
        DeleteTodo:
          Type: Api
          Properties:
            Path: /todo/{id}
            Method: delete

  # VPCリンク作成: NLB の ARN をターゲットとする
  EcsVpcLink:
    Type: AWS::ApiGateway::VpcLink
    Properties:
      Name: EcsVpcLink
      TargetArns:
        - !Ref NlbArn

  # ECS 統合用 API を追加（proxy+ を利用）
  EcsApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: Prod
      DefinitionBody:
        openapi: "3.0.1"
        info:
          title: "ECS Proxy API"
          version: "1.0"
        paths:
          /{proxy+}:
            x-amazon-apigateway-any-method:
              parameters:
                - name: proxy
                  in: path
                  required: true
                  schema:
                    type: string
              x-amazon-apigateway-integration:
                uri: !Sub "http://${NlbDnsName}/{proxy}"
                httpMethod: ANY
                type: http_proxy
                connectionType: VPC_LINK
                connectionId: !Ref EcsVpcLink
                requestParameters:
                  integration.request.path.proxy: method.request.path.proxy
                passthroughBehavior: when_no_match
                timeoutInMillis: 29000

Outputs:
  ApiEndpoint:
    Description: "API Gateway endpoint URL for Lambda functions"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod"

  EcsApiEndpoint:
    Description: "API Gateway endpoint URL for ECS integration via VPC Link"
    Value: !Sub "https://${EcsApi}.execute-api.${AWS::Region}.amazonaws.com/Prod"
```

以下のようにビルド&デプロイをします。

```bash
sam build
sam deploy \
  --parameter-overrides \
  NlbDnsName=<NLBのFQDN> \   
  NlbArn=<NLBのARN>
```

## 動作確認

```bash
export API_URL=<API Gateway endpoint URL for ECS integration via VPC Link>
```

```bash
curl -X POST ${API_URL}/todo \
-H "Content-Type: application/json" \
-d '{"task": "deploy todo api"}'
```

```bash
id=<登録されたToDoのID>
```

```bash
curl -X GET ${API_URL}/todo/${id}
```

```bash
curl -X PUT ${API_URL}/todo/${id} \
-H "Content-Type: application/json" \
-d '{"task": "update todo api", "completed": true}'
```

```bash
curl -X DELETE ${API_URL}/todo/${id}
```

```bash
curl -X GET ${API_URL}/todo
```

:::note info
上記の例は、API Gatewayを利用していますが、ペイロードサイズの制限が問題となる場合は、API Gateway + NLBではなく、ALB(必要に応じて+CloudFront)で公開するなどの検討が必要です。

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/limits.html
:::

# リソース削除

template.yamlが存在するディレクトリに移動し、以下のコマンドを実行します。

```
sam delete
```

次に、CDKのディレクトリに移動し、以下のコマンドを実行します。

```
cd cdk
cdk destroy --all
```