RDS前提でできるだけサーバレスなAPIを作る

# 背景

最近はLambda + DynamoDBの構成ばかりで大変楽をしていたのですが、RDSと接続する必要が出てきました。

えー、、EC2いじるの？もうsshログインとかしたくない（忘れた

[Amazon RDS Proxy](https://aws.amazon.com/jp/rds/proxy/)が発表されたところなのでこれを使えれば何も問題ないのですが、既存RDSがまだサポート対象外だったので、Lambdaを使わずに楽をするにはどうすればいいか、、、？と考えて構成を作ってみました。

# 構成案

- API Gateway + VPCLink + VPC(NLB + ECS + Fargate + RDS) + PrivateLink + ECR
- VPCはIsolated Network内に作成
- 全部CDKで書く！

# やってみよう

リソースに依存関係があるので、後ろから順番に作成していきます。まずはECRから。

## ECR
```typescript
import {Repository, TagStatus} from '@aws-cdk/aws-ecr';

const repository = new Repository(this, `${this.stackName}Repository}`, {
    repositoryName: `repo-name`,
    lifecycleRules: [
        {
            tagStatus: TagStatus.ANY,
            maxImageCount: 3
        }
    ]
});
```

特に何も考えず作成できます。
無駄な課金を避けるのに、ライフサイクルルールで古いイメージは自動削除するように設定。

中身のDocker Imageには8080ポートで動作する、RDSにアクセスするAPIサーバを入れています。

## VPC

一番簡単なのは[NetworkLoadBalancedFargateService](https://github.com/aws/aws-cdk/tree/master/packages/%40aws-cdk/aws-ecs-patterns)を使うことで、これならほとんど何も考えずVPCからFargateまで全部作れるのですが、いくつか難点もありました。

試験環境で最小構成で、、と試してみたら1日4ドルくらい請求されてて、「あれ？Fargateってこんな高い？おかしくない？」と調べたらNAT Gatewayを自動生成してしまっており、もったいない感。

加えてCDKの1.19.0時点では[適切なセキュリティグループを自動設定してくれない](https://github.com/aws/aws-cdk/issues/1490)という問題もあり、諦めて全部手書きすることに。

```typescript
import {
    Peer,
    Port,
    SecurityGroup,
    SubnetType,
    Vpc
} from '@aws-cdk/aws-ec2';

const vpc = new Vpc(this, `${this.stackName}VPC`, {
    cidr: '10.0.0.0/16',
    natGateways: 0,
    subnetConfiguration: [
        {
            cidrMask: 19,
            name: 'Isolated',
            subnetType: SubnetType.ISOLATED,
        },
    ],
    enableDnsHostnames: true,
    enableDnsSupport: true
});

const securityGroup = new SecurityGroup(this, `${this.stackName}SecurityGroup`, {
    vpc,
    securityGroupName: 'API-NLB'
});
securityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(8080)); // ECSとNLBの通信用
```

はい、これでIsolatedなVPCができました。CIDRは適当。NAT作りたくないという強い意思の下、明示的に「natGateways: 0」って書いた。
なお、このセキュリティグループは既存のRDSにアクセスできるように別途設定しています（既存のRDSはCDKじゃなかったのでこれは手動で）。


ただ、最後まで作成した後にCannotPullContainerErrorが出て「えー、ECRとECSが通信できてないの、、、なんで？」となりました。
調べたところECRはインターネットを介してアクセスすることになってるとか。

...同じAWSリソースやんか、、、まじか、、、

さらに調べると、[NATの代わりにPrivateLinkも使えるよ、という情報](https://aws.amazon.com/jp/premiumsupport/knowledge-center/ecs-pull-container-api-error-ecr/)があり、料金としてはNATよりもマシだったのでこちらを採用することに。

```typescript
import {GatewayVpcEndpointAwsService, InterfaceVpcEndpointAwsService} from '@aws-cdk/aws-ec2';

securityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(443)); // ECRからコンテナを取得する用

vpc.addInterfaceEndpoint(`${this.stackName}EcrDockerEndpoint`, {
    service: InterfaceVpcEndpointAwsService.ECR_DOCKER,
    privateDnsEnabled: true,
    subnets: {subnetType: SubnetType.ISOLATED},
    securityGroups: [securityGroup]
});
vpc.addInterfaceEndpoint(`${this.stackName}CloudWatchLogsEndpoint`, {
    service: InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS,
    privateDnsEnabled: true,
    subnets: {subnetType: SubnetType.ISOLATED},
    securityGroups: [securityGroup]
});
vpc.addGatewayEndpoint(`${this.stackName}S3Endpoint`, {
    service: GatewayVpcEndpointAwsService.S3,
    subnets: [{subnetType: SubnetType.ISOLATED}]
});
```

ECRと接続するのに「InterfaceVpcEndpointAwsService.ECR_DOCKER」を作る。これはわかる。
ECSのタスクがログ出力するために「InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS」を作る。仕方ない、これもわかる。
「GatewayVpcEndpointAwsService.S3」、、、S3にアクセスしとらんのに何でやねん？ですが、[PrivateネットワークだとS3へのアクセス権もいる](https://stackoverflow.com/questions/58688475/cannotpullcontainererror-context-canceled-error-when-starting-ecs-task)んだって。

くぅ、PrivateLink3つ、、それでもまぁ、NATをサブネットごとに作るよりはお安いか。
セキュアな構成ってコストかかるね！と思いつつ次に進みます。
（AWS内で完結した構成のつもりなのになー。腑に落ちないぜ！）

## NLB
```typescript
import {Duration} from '@aws-cdk/core';
import {NetworkLoadBalancer, NetworkTargetGroup, TargetType} from '@aws-cdk/aws-elasticloadbalancingv2';

const nlb = new NetworkLoadBalancer(this, `${this.stackName}NLB`, {
    vpc,
    loadBalancerName: 'ApiNLB',
    vpcSubnets: {subnetType: SubnetType.ISOLATED},
    crossZoneEnabled: true,
    internetFacing: false
});

const targetGroup = new NetworkTargetGroup(this, `${this.stackName}TargetGroup`, {
    targetGroupName: 'ecs-tagtet-group',
    vpc,
    port: 8080,
    targetType: TargetType.IP,
    deregistrationDelay: Duration.seconds(0) // 開発環境デプロイ高速化のため。本番環境は数値上げておく
});

nlb.addListener(`${this.stackName}Listener`, {
    port: 80,
    defaultTargetGroups: [targetGroup]
});
```

NLBを作成完了しました。この時点ではtargetGroupには何も追加せず、後ほどECSの作成で利用します。

## ECS + Fargate
```typescript
import {Cluster, ContainerImage, FargateService, FargateTaskDefinition, LogDriver} from '@aws-cdk/aws-ecs';
import {CompositePrincipal, Role, ServicePrincipal} from '@aws-cdk/aws-iam';
import {RetentionDays} from '@aws-cdk/aws-logs';

const taskDef = new FargateTaskDefinition(this, `${this.stackName}TaskDefinition`, {
    cpu: 256,
    memoryLimitMiB: 512,
    executionRole: new Role(this, `${this.stackName}ExecutionRole`, {
        assumedBy: new CompositePrincipal(
            new ServicePrincipal('ecs.amazonaws.com'),
            new ServicePrincipal('ecs-tasks.amazonaws.com')
        )
    }),
    taskRole: new Role(this, `${this.stackName}TaskRole`, {
        assumedBy: new ServicePrincipal('ecs-tasks.amazonaws.com'),
    })
});

const container = taskDef.addContainer(`${this.stackName}Container`, {
    image: ContainerImage.fromEcrRepository(repository, 'latest'),
    logging: LogDriver.awsLogs({
        streamPrefix: 'Fargate',
        logRetention: RetentionDays.ONE_WEEK
    })
});
container.addPortMappings({
    containerPort: 8080,
});

const cluster = new Cluster(this, `${this.stackName}Cluster`, {vpc});

const service = new FargateService(this, `${this.stackName}Service`, {
    cluster,
    securityGroup,
    serviceName: 'ApiService',
    taskDefinition: taskDef,
    vpcSubnets: {subnetType: SubnetType.ISOLATED},
    assignPublicIp: false,
    desiredCount: 1
});
service.loadBalancerTarget({
    containerName: container.containerName,
    containerPort: 8080,
});

service.attachToNetworkTargetGroup(targetGroup);
service.autoScaleTaskCount({minCapacity: 1, maxCapacity: 2})
    .scaleOnCpuUtilization(`${this.stackName}AutoScale`, {
            targetUtilizationPercent: 80
        }
    );
```

はい、できましたー。
とさっくり言うてますが、設定可能なパラメータは大量にあり、ざっと調べて意味が分かったものだけ書いてます。
コンテナ周り奥深すぎて、全然最適な設定にたどり着ける気がしない、、、

この辺はもう少し運用経験を積まないとダメそうです。

おかしいな、EC2より楽になるのを期待してるんだけど、まだあんまり楽な感じがしないw

## API Gateway + VPCLink
```typescript
import {
    RestApi,
    ConnectionType,
    Integration,
    IntegrationType,
    VpcLink
} from '@aws-cdk/aws-apigateway';

const vpcLink = new VpcLink(this, `${this.stackName}VpcLink`, {
    vpcLinkName: 'api-vpc-link',
    description: 'APIのNLBに接続します。',
    targets: [nlb]
});

const api = new RestApi(this, `${this.stackName}RestApi`, {
    restApiName: 'API',
    deployOptions: {stageName: 'development'}
});

const v1 = api.root.addResource('v1');
v1.addMethod('GET', new Integration({
    type: IntegrationType.HTTP_PROXY,
    integrationHttpMethod: 'GET',
    uri: `http://${nlb.loadBalancerDnsName}`,
    options: {
        connectionType: ConnectionType.VPC_LINK,
        vpcLink: vpcLink
    }}), {});
```

ここまでやって、API Gatewayのテストからリクエストが通れば完成です！

# 感想

VPCとか遠い昔の記憶だし、ECSとか[「ぜんぜんわからない　俺たちは雰囲気でECSをやっている」](https://potato4d.github.io/huniki_generator/?text=ECS)という感じなので、動きはしたものの「これ運用に耐えるんか？」という気持ちでいっぱいです。

これからもう少しブラッシュアップして行きたいと思いつつ、どなたかのお役に立てれば幸いです。

# 追記
CDK最高！