Serverless+GraphQL+NestJS+TypeORM+RDSをAWSにデプロイする

## はじめに

この記事の続きです。

https://qiita.com/shinobu_shiva/items/5590602427e5f4fd838e

前の記事で、GraphQL+NestJS+TypeORM+MySQL+Serverless(offline)という構成でローカル実行することが出来ました。次は、これをデプロイしてAWS上で動くようにしたいと思います。

成果物はこちらにあります。

https://github.com/shinobushiva/nestjs-typeorm-ts-example/tree/step3

## とりあえずAWSにデプロイしてみる

とりあえずは何も考えずにデプロイコマンドを叩いてみます。

```console:デプロイ実行
$ sls deploy
```

```console:デプロイ結果
Deploying compnestjs-typeorm-ts-example to stage dev (ap-northeast-1)

✔ Service deployed to stack compnestjs-typeorm-ts-example-dev (191s)

dashboard: https://app.serverless.com/shinobushiva/apps/myapp/compnestjs-typeorm-ts-example/dev/ap-northeast-1
endpoints:
  ANY - https://[random_sequence].execute-api.ap-northeast-1.amazonaws.com/dev/
  ANY - https://[random_sequence].execute-api.ap-northeast-1.amazonaws.com/dev/{proxy+}
functions:
  handler: compnestjs-typeorm-ts-example-dev-handler (55 MB)
```

とりあえずデプロイされました。つなぎ先のデータベースとか何も設定してないので動くわけはないのですが、とりあえず`GraphQL Playground`のURLを叩いてみます。

```
https://[random_sequence].execute-api.ap-northeast-1.amazonaws.com/dev/graphql
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5557/b3640d14-6fd1-aed7-8bd5-6d8c33b26252.png)

うん、まぁ。そりゃそうですよね。
何が悪いのかログを見てみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5557/19e38f87-47ab-3560-72e2-a80318412ab4.png)

`AWS`の`CloudWatch`から`Log groups`を開くと2つのロググループが作られています。どうやら`API Gateway`のログと`Lambda`関数のログのようです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5557/4613f6fa-78a3-d0d7-4209-100413b559fa.png)

`API Gateway` のログはサーバーエラーが起きたということぐらいしか情報がありません。

`Labmda`のログを見てみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5557/caab44b1-8bb6-2e39-6f53-80f8b35e2a1e.png)

`TypeOrm`の接続エラーが出ています。データベースへの接続に失敗していることがわかりますが、`Nest.js`のハンドラは正常に実行されていることが確認できます。

## RDSとかVPCとかIAMとかもろもろ

RDSやVPC等必要なリソースを `serverless.yml`に追記します。ここでは構成の説明は省きます。`RDS Proxy`は使わずに直接接続します。

```yaml:serverless.yml
org: shinobushiva
app: myapp
service: nestjs-typeorm-ts-example
frameworkVersion: '3'

provider:
  name: aws
  runtime: nodejs14.x
  region: ap-northeast-1
  stage: dev
  iamRoleStatements:
    - Effect: Allow
      Action:
        - rds-data:*
        - ec2:CreateNetworkInterface
        - ec2:DescribeNetworkInterfaces
        - ec2:DeleteNetworkInterface
      Resource: "*"
  vpc:
    securityGroupIds:
      - Ref: LambdaSecurityGroup
    subnetIds:
      - Ref: PrivateSubnetB
      - Ref: PrivateSubnetC
  environment:
    DB_HOST: 
      Fn::GetAtt: [DBInstance, Endpoint.Address]
    DB_PORT: ${env:DB_PORT}
    DB_USERNAME: ${env:DB_USERNAME}
    DB_PASSWORD: ${env:DB_PASSWORD}
    DB_DATABASE: ${env:DB_DATABASE}

resources:
  Resources:
    ## VPC Resource
    VPC:
      Type: AWS::EC2::VPC
      Properties:
        CidrBlock: 10.0.0.0/24
        Tags:
          - { Key: Name, Value: nestjs-typeorm-ts-example VPC }
    PrivateSubnetB:
      Type: AWS::EC2::Subnet
      Properties:
        VpcId: !Ref VPC
        CidrBlock: 10.0.0.0/25
        AvailabilityZone: ap-northeast-1b
        Tags:
          - { Key: Name, Value: nestjs-typeorm-ts-example Private B }
    PrivateSubnetC:
      Type: AWS::EC2::Subnet
      Properties:
        VpcId: !Ref VPC
        CidrBlock: 10.0.0.128/25
        AvailabilityZone: ap-northeast-1c
        Tags:
          - { Key: Name, Value: nestjs-typeorm-ts-example Private C }
    LambdaSecurityGroup:
      Type: AWS::EC2::SecurityGroup
      Properties:
        GroupDescription: SecurityGroup for Lambda Functions
        VpcId: !Ref VPC
        Tags:
          - Key: "Name"
            Value: "LambdaSecurityGroup"
    RDSSecurityGroup:
      Type: AWS::EC2::SecurityGroup
      Properties:
        GroupDescription: SecurityGroup for RDS
        VpcId: !Ref VPC
        SecurityGroupIngress:
          - IpProtocol: tcp
            FromPort: ${env:DB_PORT}
            ToPort: ${env:DB_PORT}
            CidrIp: 10.0.0.0/24
        Tags:
          - Key: "Name"
            Value: "RDSSecurityGroup"
      DependsOn: VPC
    ## RDS Resource
    DBSubnetGroup:
      Type: AWS::RDS::DBSubnetGroup
      Properties:
        DBSubnetGroupDescription: "nestjs-typeorm-ts-example DB subnet group"
        DBSubnetGroupName: nestjs-typeorm-ts-example-db-subnet-group
        SubnetIds:
          - !Ref PrivateSubnetB
          - !Ref PrivateSubnetC
    DBInstance:
      Type: AWS::RDS::DBInstance
      DeletionPolicy: Snapshot
      Properties:
        AllocatedStorage: '5'
        DBInstanceClass: db.t3.micro
        DBParameterGroupName: !Ref DBParameterGroup
        DBSubnetGroupName: !Ref DBSubnetGroup
        Engine: MySQL
        EngineVersion: 8.0.23
        MasterUsername: ${env:DB_USERNAME}
        MasterUserPassword: ${env:DB_PASSWORD}
        StorageType: gp2
        VPCSecurityGroups:
          - !Ref RDSSecurityGroup
    DBParameterGroup:
      Type: AWS::RDS::DBParameterGroup
      Properties:
        Description: nestjs-typeorm-ts-example custom paramter group
        Family: MySQL8.0
        Parameters:
          character_set_database: utf8mb4
          character_set_client: utf8mb4
          character_set_connection: utf8mb4
          character_set_results: utf8mb4
          character_set_server: utf8mb4
functions:
  handler:
    handler: dist/handler.handler
    events:
      - http:
          cors: true
          path: "/"
          method: any
      - http:
          cors: true
          path: "{proxy+}"
          method: any
  createDatabase:
    handler: dist/handler.createDatabase
    events:
      - http:
          path: create-database
          method: get

plugins:
  - serverless-offline

```

## データベース作成用関数の追加

データベースを作成するためのlambda関数を追加しました。上記の`serverless.yml`には対象の部分を記載済みです。

`ts:src/handler.ts` に記述を追加します。

```ts:src/handler.tsの一部
//...省略
import { createConnection } from 'mysql2';
//...省略

export const createDatabase: Handler = async (event: any) => {
  console.log('process env', process.env);
  const options = {
    host: process.env.DB_HOST,
    user: process.env.DB_USERNAME,
    password: process.env.DB_PASSWORD,
    port: parseInt(process.env.DB_PORT),
  };
  console.log(options);
  const con = createConnection(options);
  console.log('connection created');
  const res = await con
    .promise()
    .query(`CREATE DATABASE IF NOT EXISTS \`${process.env.DB_DATABASE}\``);
  console.log('res', res);

  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        message: 'Cerate database success!',
        result: res,
        input: event,
      },
      null,
      2,
    ),
  };
};
```

ここまで修正したら `sls deploy` します。

```
https://[random_sequence].execute-api.ap-northeast-1.amazonaws.com/dev/create-database
```
エンドポイントにアクセスするとデータベースが作成されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5557/c3fa2ae0-546f-0b70-46e2-da780bbadad1.png)



## GraphQL周りの設定修正
ここで `GraphQL Playground` を開こうとすると `Internal server error`が発生し、次のようなエラーログが出されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5557/2582304b-6ec9-45ba-0115-cdf9e2c3a60c.png)

これは自動生成されるスキーマファイルをファイルシステムに書き込もうとして権限がないために発生しているエラーのようです。

いくつか解決の方法はあると思いますが、実行時にメモリ上に生成する方法があるようなのでその設定を使います。

https://docs.nestjs.com/graphql/quick-start#code-first

`src/app.module.ts`を次のように修正します

```ts:src/app.module.ts
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TasksModule } from './tasks/tasks.module';
import { TypeOrmConfigService } from './config/database';

@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      imports: [TasksModule],
      useClass: TypeOrmConfigService,
    }),
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: true, //ここを修正
      debug: true,
      playground: true,
    }),
    TasksModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
```

上記をデプロイして`/graphql`にアクセスすることで`GraphQL Playground`を開くことが出来ます。

```
https://[random_sequence].execute-api.ap-northeast-1.amazonaws.com/dev/graphql
```

# マイグレーションできるようにする

https://github.com/nathanagez/serverless-typeorm-migrations

上記のようなプラグインがあるようですが、ここではプラグインから抜粋して組み込みました。

`src/lib/migration.ts` を作ります。

```ts:src/lib/migration.ts
import { createConnection, Connection, ConnectionOptions } from 'typeorm';

interface MigrationIndexSignature {
  [key: string]: any;
}

export default class Migration implements MigrationIndexSignature {
  private config: ConnectionOptions;
  private connection: Connection | null;
  [handlerName: string]: any;

  constructor(config: ConnectionOptions) {
    this.config = config;
    this.connection = null;
  }

  private async init() {
    try {
      this.connection = await createConnection(this.config);
    } catch (error) {
      throw error;
    }
  }

  async runMigration() {
    try {
      await this.init();
      const result = await this.connection?.runMigrations({
        transaction: 'none',
      });
      await this.connection?.close();
      return result;
    } catch (error) {
      throw error;
    }
  }

  async undoLastMigration() {
    try {
      await this.init();
      await this.connection?.undoLastMigration({
        transaction: 'none',
      });
      await this.connection?.close();
    } catch (error) {
      throw error;
    }
  }
}
```

`src/database.ts` を作ります。

```ts:src/database.ts
import Migration from './lib/migration';
import { Context, Callback } from 'aws-lambda';
import ormconfig from './config/ormconfig';

const success = (response: any) => ({
  statusCode: 200,
  body: JSON.stringify(response),
});

const handler =
  (handlerName: string) =>
  async (event: any, context: Context, callback: Callback) => {
    context.callbackWaitsForEmptyEventLoop = false;

    const migration = new Migration(ormconfig);

    try {
      const response = await migration[handlerName]();
      callback(null, success(response));
    } catch (error) {
      callback(error);
    }
  };

const migrationUp = handler('runMigration');
const migrationDown = handler('undoLastMigration');

export { migrationUp, migrationDown };

```
`serverless.yml`に追記します。

```yml:serverless.yml
functions:
  # ...省略
  migrationUp:
    handler: dist/database.migrationUp
    events:
      - http:
          path: migration-up
          method: get
  migrationDown:
    handler: dist/database.migrationDown
    events:
      - http:
          path: migration-down
          method: get
```

これをデプロイすると `/migration-up`でマイグレーション実行、`/migration-down`でロールバックできるようになります。

## まとめ
AWSのApiGateway, Lambda上で動く Nest.js+TypeORMでGraphQLできる環境をデプロイすることが出来ました。
RDSやVPCなどの設定はCloud Formationでゴリゴリ書いた感じになっていますが、私のインフラ力ではこれが限界でした。
ただ、このままの構成でアプリケーションコードを書いていくとLambdaの250MB制限に引っかかってしまいそうな気がするのでDockerでデプロイできるようにしようかなと思っています。


## 参考

https://dev.classmethod.jp/articles/migration-amazon-rds-mysql-to-aurora-using-aws-cloudformation/

https://dev.classmethod.jp/articles/rds-proxy-deploy-with-serverless-framework/