続・Serverless+GraphQL+NestJS+TypeORM+RDSをAWSにデプロイする＆ローカルDocker開発環境整える

## はじめに

この記事の続きです。

https://qiita.com/shinobu_shiva/items/62a221d086443d188b3a

前回の記事で、Serverless+GraphQL+NestJS+TypeORM+RDSという構成でAWSにデプロイすることが出来ました。

今回はローカルでの開発環境を整えます。ローカルでの通常開発に加えて、Dockerでも開発できるようにしていきます。あわせて、データベース接続周りなどの細かい修正とリファクタリングも行います。

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

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

## データベース接続エラーへの対応

前回作ったマイグレーション周りの実装ですが、ざっと作ったので以前の記事で発生していたデータベース接続エラーが再発していました。

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

解決するようにプログラムを修正します。

はじめに `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;
    }
  }
// ...省略
```

```ts:src/lib/migration.ts 変更後
import {
  Connection,
  ConnectionOptions,
  ConnectionManager,
  getConnectionManager,
} from 'typeorm';
import ormconfig from 'src/config/ormconfig';

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 {
      const connectionManager: ConnectionManager = getConnectionManager();

      try {
        if (connectionManager.has(ormconfig.name)) {
          this.connection = connectionManager.get(ormconfig.name);
        }
      } catch (err) {}
      if (!this.connection) {
        this.connection = connectionManager.create(this.config);
      }
      if (!this.connection.isInitialized) {
        await this.connection.initialize();
      }
    } catch (error) {
      throw error;
    }
  }
// ...省略
```

`ConnectionManager`経由で接続を行うことで、他の部分と接続周りを一本化しました。

もう一つ、`src/config/database.ts`も修正します。

```ts:src/config/database.ts 変更前
import { Injectable } from '@nestjs/common';
import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm';
import { ConnectionManager, getConnectionManager } from 'typeorm';
import ormconfig from './ormconfig';

@Injectable()
export class TypeOrmConfigService implements TypeOrmOptionsFactory {
  async createTypeOrmOptions(): Promise<TypeOrmModuleOptions> {
    const ormOptions: TypeOrmModuleOptions = {
      ...ormconfig,
      keepConnectionAlive: true,
      autoLoadEntities: true,
    };

    const connectionManager: ConnectionManager = getConnectionManager();
    let options: any;

    if (connectionManager.has('default')) {
      options = connectionManager.get('default').options;
    } else {
      options = ormOptions as TypeOrmModuleOptions;
    }
    return options;
  }
}

```

```ts:src/config/database.ts 変更後
import { Injectable } from '@nestjs/common';
import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm';
import { ConnectionManager, getConnectionManager } from 'typeorm';
import ormconfig from './ormconfig';

@Injectable()
export class TypeOrmConfigService implements TypeOrmOptionsFactory {
  async createTypeOrmOptions(): Promise<TypeOrmModuleOptions> {
    const ormOptions: TypeOrmModuleOptions = {
      ...ormconfig,
      keepConnectionAlive: true,
      autoLoadEntities: false, //ここをfalseに修正
    };

    const connectionManager: ConnectionManager = getConnectionManager();
    let options: any;

    const connectionName = ormconfig.name;
    if (connectionManager.has(connectionName)) {
      options = connectionManager.get(connectionName).options;
    } else {
      options = ormOptions as TypeOrmModuleOptions;
    }
    return options;
  }
}
```

細かいですが、`autoLoadEntities`を`false`に修正しています。また接続の名前を`default`とベタ書きしていたところを設定オブジェクトから取得するようにリファクタリングしました。

次に`src/tasks/tasks.service.ts`を修正します。

```ts:src/tasks/tasks.service.ts 変更前
import { Injectable } from '@nestjs/common';
import {
  ConnectionManager,
  getConnectionManager,
  getRepository,
  Repository,
} from 'typeorm';
import { CreateTaskInput } from './dto/create-task.input';
import { UpdateTaskInput } from './dto/update-task.input';
import { Task } from './entities/task.entity';

@Injectable()
export class TasksService {
  get taskRepostiory(): Repository<Task> {
    const connectionManager: ConnectionManager = getConnectionManager();
    if (connectionManager.has('default')) {
      const entityMetadata = connectionManager
        .get('default')
        .entityMetadatas.find((metadata) => {
          return metadata.tableName === 'task';
        });
      if (entityMetadata) {
        return getRepository(entityMetadata.name);
      } else {
        return null;
      }
    }
  }
// ...以下省略
```

```ts:src/tasks/tasks.service.ts 変更後
import { Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { CreateTaskInput } from './dto/create-task.input';
import { UpdateTaskInput } from './dto/update-task.input';
import { Task } from './entities/task.entity';
import { getEntityRepository } from 'src/config/ormUtils';

@Injectable()
export class TasksService {
  get taskRepostiory(): Repository<Task> {
    return getEntityRepository('task');
  }
// ...以下省略
```

```ts:src/config/ormUtils.ts
import {
  ConnectionManager,
  getConnectionManager,
  getRepository,
  Repository,
} from 'typeorm';
import ormconfig from './ormconfig';

export const getEntityRepository = <T>(name: string) => {
  const connectionManager: ConnectionManager = getConnectionManager();
  if (connectionManager.has(ormconfig.name)) {
    const entityMetadata = connectionManager
      .get(ormconfig.name)
      .entityMetadatas.find((metadata) => {
        return metadata.tableName === name;
      });
    if (entityMetadata) {
      return getRepository(entityMetadata.name) as Repository<T>;
    } else {
      return null;
    }
  }
};
```

このファイルはエンティティのサービスについて記述したものですが、今後作るエンティティでも同じ記述を行う部分がありますので、再利用可能部分を別ファイルに切り出すリファクタリングを行いました。

## ローカル開発環境の整備

ローカル開発環境として、通常に`sls offline`で開発する場合とDocker上で`sls offline`を実行して開発する場合に対応します。

```:関連するファイル
Dockerfile
Dockerfile.local
docker-compose.yml
docker.env.default
docker/mysql/my.cnf
serverless.yml
serverless/environment.dev.yml
serverless/environment.local-docker.yml
serverless/environment.local.yml
serverless/startup.sh
```

上記は関連するファイルの一覧です。以下にざっくりと説明します。

### serverless.yml

`serverless.yml`に対しては環境ごとの設定を外部に切り出しました。ローカルでの実行やDockerでの実行を`stage`で分けることで実現しています。

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

custom:
  environment:
    local: ./serverless/environment.local.yml
    local-docker: ./serverless/environment.local-docker.yml
    dev: ./serverless/environment.dev.yml

provider:
  name: aws
  runtime: nodejs14.x
  region: ap-northeast-1
  stage: ${opt:stage, 'local'} #デフォルトではlocalを使う
  iamRoleStatements:
    - Effect: Allow
      Action:
        - rds-data:*
        - ec2:CreateNetworkInterface
        - ec2:DescribeNetworkInterfaces
        - ec2:DeleteNetworkInterface
      Resource: "*"
  vpc:
    securityGroupIds:
      - Ref: LambdaSecurityGroup
    subnetIds:
      - Ref: PrivateSubnetB
      - Ref: PrivateSubnetC
  # stageごとに異なる環境変数を読み込む
  environment: ${file(${self:custom.environment.${self:provider.stage}})}
# ...以下省略
```

以下はDockerでの実行時に読み込む設定ファイルです。

```yml:serverless/environment.local-docker.yml
DB_HOST: db
DB_PORT: ${env:DB_PORT}
DB_USERNAME: ${env:DB_USERNAME}
DB_PASSWORD: ${env:DB_PASSWORD}
DB_DATABASE: ${env:DB_DATABASE}
```

### Docker

`docker-compose.yml`ではserverlessの実行環境とMySQLデータベースを作っています。

こちらの記事を参考にしました。

https://qiita.com/ucan-lab/items/b094dbfc12ac1cbee8cb

```yml:docker-compose.yml
version: '3'
services:
  serverless:
    build:
      context: ./
      dockerfile: Dockerfile.local
      args:
        - AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
        - AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
    volumes:
      - ./:/app
    working_dir: /app
    ports:
      - 3000:3000
  db:
    platform: linux/x86_64 # for M1 Mac
    image: mysql:8.0
    volumes:
      - db-store:/var/lib/mysql
      - ./logs:/var/log/mysql
      - ./docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf
    environment:
      - MYSQL_DATABASE=${DB_DATABASE}
      - MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
      - TZ='Asia/Tokyo'
    ports:
      - 3306:3306
volumes:
  db-store:
```

`docker/mysql/my.cnf`にはMySQLの設定を記述しています

```config:docker/mysql/my.cnf
# MySQLサーバーへの設定
[mysqld]
# 文字コード/照合順序の設定
character-set-server = utf8mb4
collation-server = utf8mb4_bin

# タイムゾーンの設定
default-time-zone = SYSTEM
log_timestamps = SYSTEM

# デフォルト認証プラグインの設定
default-authentication-plugin = mysql_native_password

# エラーログの設定
log-error = /var/log/mysql/mysql-error.log

# スロークエリログの設定
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 5.0
log_queries_not_using_indexes = 0

# 実行ログの設定
general_log = 1
general_log_file = /var/log/mysql/mysql-query.log

# mysqlオプションの設定
[mysql]
# 文字コードの設定
default-character-set = utf8mb4

# mysqlクライアントツールの設定
[client]
# 文字コードの設定
default-character-set = utf8mb4
```

`Dockerfile.local`は次のようになっています。

これは、こちらを参考にしました。

https://shiro-secret-base.com/?p=987#DockerfiledockerserverlessDockerfile

```dockerfile:Dockerfile.local
FROM node:latest
 
ARG AWS_ACCESS_KEY_ID
ARG AWS_SECRET_ACCESS_KEY
 
RUN apt-get update
RUN apt-get install -y \
    python3-pip \
    jq
 
RUN pip3 install awscli --upgrade --user
RUN pip3 install yq
 
RUN apt-get install -y awscli
 
RUN npm install -g serverless serverless-offline
 
RUN sls config credentials --provider aws --key $AWS_ACCESS_KEY_ID --secret $AWS_SECRET_ACCESS_KEY
EXPOSE 3000

CMD ["serverless/startup.sh"]
```

`serverless/startup.sh`でserverlessのオフライン環境を実行しています。

```shell:serverless/startup.sh
#!/bin/bash

cd /app
export $(cat .env | grep -v ^\# | xargs)
sls offline --host 0.0.0.0 --stage local-docker
```

## 諸々の実行方法

#### ローカルでの実行
MySQLデータベースはローカルに起動しておいてください。
```
$ yarn build
$ sls offline
```

#### Dockerでの実行

```
$ yarn build
$ docker-compose build
$ docker-compose up
```

#### AWSへのデプロイ
デプロイ時には `stage`に`dev`を指定してください

```
$ yarn build
$ sls deploy --stage dev
```

## まとめ

Docker実行を含めてローカルでの開発環境を整えられました。次はAWSにECR経由でデプロイできるようにしようと思っています。

なお、タイトルのインフレが止まらないのが悩みの種です。