LoginSignup
8
10

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-27

背景

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

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

Amazon RDS Proxyが発表されたところなのでこれを使えれば何も問題ないのですが、既存RDSがまだサポート対象外だったので、Lambdaを使わずに楽をするにはどうすればいいか、、、?と考えて構成を作ってみました。

構成案

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

やってみよう

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

ECR

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を使うことで、これならほとんど何も考えずVPCからFargateまで全部作れるのですが、いくつか難点もありました。

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

加えてCDKの1.19.0時点では適切なセキュリティグループを自動設定してくれないという問題もあり、諦めて全部手書きすることに。

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も使えるよ、という情報があり、料金としてはNATよりもマシだったのでこちらを採用することに。

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へのアクセス権もいるんだって。

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

NLB

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

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

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をやっている」という感じなので、動きはしたものの「これ運用に耐えるんか?」という気持ちでいっぱいです。

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

追記

CDK最高!

8
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
10