NestJSのDockerコンテナをCloud Run + Cloud SQLへデプロイする方法

## はじめに

NestJSの開発環境をローカルのDockerコンテナ上に構築し、簡単なAPIの実装を行なってから、本番環境としてCloud Run + Cloud SQLへデプロイする方法を記載します。

## DockerコンテナでNestJSを起動する

NestJSアプリとデータベース、両方のコンテナを同時に起動するためにDocker Composeを使いたいので、docker-compose.ymlを作成します。

```bash
$ mkdir my_project && cd my_project
$ touch docker-compose.yml
```

ローカルの開発環境なのでデータベースのパスワードなどはべた書きで構いません。
ここでのポイントはNestJSアプリのルートディレクトリを`nest-app`という一つ下の階層に
しておくこことと、`node_modules`を**匿名ボリューム**に設定することです。
そうしないと、バインドマウントされて`node_modules`の中身が消えてしまいます。

```yaml
version: '3'
services:
  db:
    image: mysql:8.0.27
    volumes:
      - ./data/mysql:/var/lib/mysql
      - ./docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf
      - ./logs:/var/log/mysql
    environment:
      MYSQL_ROOT_PASSWORD: 'my_database_root_password'
      MYSQL_DATABASE: 'my_database_name'
      MYSQL_USER: 'my_database_user'
      MYSQL_PASSWORD: 'my_database_password'
      TZ: 'Asia/Tokyo'
    ports:
      - "3306:3306"
  node:
    container_name: nest-app
    build:
      context: ./nest-app
      dockerfile: Dockerfile
    volumes:
      - ./nest-app:/usr/src/app
      - /usr/src/app/node_modules
    ports:
      - "3000:3000"
    stdin_open: true
    depends_on:
      - db
```

Dockerfileも作成します。

```bash
$ mkdir nest-app && cd nest-app
$ touch Dockerfile
```

Dockerfileは最小限の構成にします。

```docker
FROM node:16-alpine3.14
WORKDIR /usr/src/app
RUN npm install -g @nestjs/cli
```

Dockerイメージのビルドとコンテナの起動を行います。

```bash
$ docker-compose up -d --build
```

コンテナが起動しているか、確認します。

```bash
$ docker compose ps
NAME                COMMAND                  SERVICE             STATUS              PORTS
my_project-db-1     "docker-entrypoint.s…"   db                  running             0.0.0.0:3306->3306/tcp
nest-app            "docker-entrypoint.s…"   node                running             0.0.0.0:3000->3000/tcp
```

NestJSのプロジェクトを作成します。

```bash
$ docker compose exec node nest new .
```

どのパッケージマネージャーを使うか訊かれるので、yarnを選択します。

```bash
? Which package manager would you ❤️  to use? 
  npm 
❯ yarn 
  pnpm
```

プロジェクトの作成が完了したら、さっそくNestJSアプリを起動します。

```bash
$ docker compose exec node yarn start:dev
```

以下にアクセスして、`Hello World!`と表示されいれば、成功です！

```bash
http://localhost:3000/
```

##　DockerfileにNestJS起動までの処理を追加する

ここまでの手順がDockerイメージがビルドされる時に自動で実行されるようにDokerfileに
処理を追加します。

```docker
FROM node:16-alpine3.14
WORKDIR /usr/src/app
RUN npm install -g @nestjs/cli

+ COPY package.json ./
+ COPY yarn.lock ./
+ RUN yarn install
+ COPY . .
+ ENTRYPOINT ["yarn", "start:dev"]
```

##　TypeORMをインストールして、NestJS内でTypeORMを有効化する

今回はNestJSでデータベースを操作するためにTypeORMというO/Rマッパーを使います。
以下のコマンドで、TypeORMをインストールします。

```bash
$ docker compose exec node yarn add @nestjs/typeorm typeorm mysql2
```

次に、app.module.tsに以下を追加します。

```tsx
import { Module } from '@nestjs/common';
+ import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
+    TypeOrmModule.forRoot(),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
```

さらに、データベース接続情報としてルートディレクトにormconfig.jsonファイルを以下の内容で追加します。
`“synchronize”: true`を設定しておくと自動的にモデル定義をデータベースに反映してくれます。開発中は`true`にしておくとめちゃくちゃ便利なのでおすすめです。

```json
{
    "type": "mysql",
    "host": "db",
    "port": 3306,
    "username": "my_database_user",
    "password": "my_database_password",
    "database": "my_database_name",
    "entities": ["dist/**/*.entity{.ts,.js}"],
    "logging": true,
    "synchronize": true
}
```

##　ユーザをデータベースで操作できるように実装する

今回はuserモデルを作成し、データベースで扱えるように実装していきます。
まずはコマンドでCRUDのひな型を作成します。

```bash
$ docker compose exec node nest g resource users 
```

APIの方式はRESTを選択します。

```bash
? What transport layer do you use? 
❯ REST API 
  GraphQL (code first) 
  GraphQL (schema first) 
  Microservice (non-HTTP) 
  WebSockets
```

エントリポイントも作成します。

```bash
? Would you like to generate CRUD entry points? (Y/n)
```

ここまでのディレクトリ構造は以下のようになります。（distとtestとspecファイルは除外）

```bash
＄ tree -I "dist|test|*.spec.ts"
.
├── Dockerfile
├── README.md
├── nest-cli.json
├── node_modules
├── ormconfig.json
├── package-lock.json
├── package.json
├── src
│   ├── app.controller.ts
│   ├── app.module.ts
│   ├── app.service.ts
│   ├── main.ts
│   └── users
│       ├── dto
│       │   ├── create-user.dto.ts
│       │   └── update-user.dto.ts
│       ├── entities
│       │   └── user.entity.ts
│       ├── users.controller.ts
│       ├── users.module.ts
│       └── users.service.ts
├── tsconfig.build.json
├── tsconfig.json
└── yarn.lock
```

user.entity.tsでUserモデルの定義を行います。

```typescript
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number

  @Column({ unique: true })
  email: string;

  @Column()
  name: string;
}
```

users.service.tsでデータベース操作の処理を実装します。

```typescript
import { Injectable } from '@nestjs/common';
+ import { InjectRepository } from '@nestjs/typeorm';
+ import { Repository } from 'typeorm';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
+ import { User } from './entities/user.entity';

@Injectable()
export class UsersService {
+  constructor(
+    @InjectRepository(User)
+    private readonly userRepository: Repository<User>,
+  ) {}
+
  create(createUserDto: CreateUserDto) {
-    return 'This action adds a new user';
+    return this.userRepository.create(createUserDto);
  }

  findAll() {
-    return `This action returns all users`;
+    return this.userRepository.find();
  }

  findOne(id: number) {
-    return `This action returns a #${id} user`;
+    return this.userRepository.findOne(id);
  }

  update(id: number, updateUserDto: UpdateUserDto) {
-    return `This action updates a #${id} user`;
+    return this.userRepository.update(id, updateUserDto);
  }

  remove(id: number) {
-    return `This action removes a #${id} user`;
+    return this.userRepository.delete(id);
  }
}

```

実装できたので、user登録と一覧のAPIを実行して動作確認します。

```bash
$ curl -X POST http://localhost:3000/users -d '{"email": "hoge@example.com", "name": "hogehoge"}' -H "Content-Type: application/json"
{"email":"hoge@example.com","name":"hogehoge","id":1}

$ curl http://localhost:3000/users                                                                                                   
[{"id":1,"email":"hoge@example.com","name":"hogehoge"}]
```

これでローカルでの実装は完了したので、いよいよGCPにデプロイしていきます。

##　Cloud Runで動作するように設定

NestJSアプリをCloud Runでも動作するように設定します。
main.tsで以下の変更を加えます。

- 環境変数PORTで起動するように修正
- '0.0.0.0' を追加して外部からのアクセスを許可する

```typescript
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
+  const port = Number(process.env.PORT) || 3000; // 環境変数PORTで起動するように修正
+  await app.listen(port, '0.0.0.0'); // '0.0.0.0' を追加して外部からのアクセスを許可する
}
bootstrap();
```

ここまででアプリ側の準備はできました。

##　GCPのプロジェクトを作成する

次のコマンドでGCPにログインし、プロジェクトを作成します。
[プロジェクトID]、[プロジェクト名]はご自身のものに置き換えてください。

```bash
$ gcloud auth login
$ gcloud projects create [プロジェクトID] --name "[プロジェクト名]"
$ gcloud config set project [プロジェクトID]
```

##　Cloud SQLインスタンスを起動する

[Cloud SQL](https://console.cloud.google.com/sql)コンソールから新しいインスタンスを作成します。
「請求先アカウント設定」のポップアップが表示されたらアカウントを設定しましょう。
「インスタンスを作成」ボタンをクリックして「データベースエンジンの選択」はMySQLを選択します。
APIが有効化されてなければ「APIを有効にする」ボタンをクリックします。
インスタンスの情報を入力して、インスタンスを作成します。

![Untitled.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80699/3caa1fb4-95cd-b641-de9f-2ebd10037366.png)

左のメニューからユーザとデータベースも作成しておきます。
ユーザはこちらから
![Untitled_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80699/34c36396-262c-9049-f34a-85400aa135d9.png)

データベースはこちらから
![Untitled_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80699/27987336-c0e0-a117-ecb3-25257c203c56.png)

##　Secret Managerでシークレットを設定する

[Secret Manager](https://console.cloud.google.com/security/secret-manager)を使うと本番環境の環境変数がセキュアに利用できます。
[Secret Manager](https://console.cloud.google.com/security/secret-manager)コンソールでAPIが有効化されてなければ「有効にする」ボタンをクリックします。
APIを有効化したらコマンドでデータベースに接続するための情報をシークレットに設定していきます。まずは、シークレットを作成します。

```bash
$ gcloud secrets create typeorm_connection                                                                                                                                                                
$ gcloud secrets create typeorm_driver_extra                                                                                                                                                
$ gcloud secrets create typeorm_port                                                   
$ gcloud secrets create typeorm_username                                                                                                                                                                   
$ gcloud secrets create typeorm_password                    
$ gcloud secrets create typeorm_database                                                                                                                                                                   
$ gcloud secrets create typeorm_entities                                                            
$ gcloud secrets create typeorm_logging
$ gcloud secrets create typeorm_synchronize
```

シークレットに値を設定します。
ここでの注意点は、`socketPath`を指定するときは`TYPEORM_DRIVER_EXTRA`環境変数に
**json形式**で記述しないといけないというところです。

```bash
$ printf "mysql" | gcloud secrets versions add typeorm_connection --data-file=-
$ printf '{"socketPath": "/cloudsql/[プロジェクトID]:asia-northeast1:my-db"}' | gcloud secrets versions add typeorm_driver_extra --data-file=-
$ printf "3306" | gcloud secrets versions add typeorm_port --data-file=-
$ printf "my-username" | gcloud secrets versions add typeorm_username --data-file=-
$ printf "my-passowrd" | gcloud secrets versions add typeorm_password --data-file=-
$ printf "my-database" | gcloud secrets versions add typeorm_database --data-file=-
$ printf "dist/**/*.entity.js" | gcloud secrets versions add typeorm_entities --data-file=-
$ printf "true" | gcloud secrets versions add typeorm_logging --data-file=-
$ printf "true" | gcloud secrets versions add typeorm_synchronize --data-file=-
```

##　シークレットにアクセスするために権限を付与する

権限を付与するサービスアカウントを調べるためにコマンドを実行します。
この場合、`220306768224-compute@developer.gserviceaccount.com`がサービスアカウントになります。

```bash
$ gcloud iam service-accounts list
DISPLAY NAME                            EMAIL                                               DISABLED
Compute Engine default service account  220306768224-compute@developer.gserviceaccount.com  False
```

サービスアカウントにシークレットにアクセスするための権限を付与します。

```bash
$ gcloud projects add-iam-policy-binding [プロジェクトID] \
--member=serviceAccount:220306768224-compute@developer.gserviceaccount.com \
--role=roles/secretmanager.secretAccessor
```

##　Cloud Runにデプロイする

[Cloud Run API](https://console.cloud.google.com/apis/library/run.googleapis.com)コンソールにアクセして「有効にする」をクリックします。
「管理」をクリックして画面右上の「認証情報を作成」をクリックします。
「Cloud Run Admin API」を選択して、「アプリケーションデータ」を選択し、
「いいえ、使用していません」を選択します。
![Untitled_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80699/48bcfcd0-c5ae-448e-c44d-308a8450d423.png)

シークレットを環境変数として公開するには`—update-secrets`オプションで設定します。

```bash
$ gcloud run deploy nestjs-app --region asia-northeast1 --source . --add-cloudsql-instances=[プロジェクトID]:asia-northeast1:my-db \
--update-secrets=TYPEORM_CONNECTION=typeorm_connection:1 \
--update-secrets=TYPEORM_DRIVER_EXTRA=typeorm_driver_extra:1 \
--update-secrets=TYPEORM_PORT=typeorm_port:1 \
--update-secrets=TYPEORM_USERNAME=typeorm_username:1 \
--update-secrets=TYPEORM_PASSWORD=typeorm_password:1 \
--update-secrets=TYPEORM_DATABASE=typeorm_database:1 \
--update-secrets=TYPEORM_ENTITIES=typeorm_entities:1 \
--update-secrets=TYPEORM_LOGGING=typeorm_logging:1 \
--update-secrets=TYPEORM_SYNCHRONIZE=typeorm_synchronize:1
```

最後に動作確認してみます。

```bash
$ curl -X POST https://nestjs-app-tfrtn4c2ca-an.a.run.app/users -d '{"email": "hoge@example.com", "name": "hogehoge"}' -H "Content-Type: application/json"
{"email":"hoge@example.com","name":"hogehoge","id":1}

$ curl https://nestjs-app-tfrtn4c2ca-an.a.run.app/users                                                                                                   
[{"id":1,"email":"hoge@example.com","name":"hogehoge"}]
```
正常に動作することが確認できました
以上で、開発環境の構築から実装、そして本番環境のデプロイまで完了しました！

## まとめ
- `node_modules`は匿名ボリュームで
- TypeORMでの開発中は`"synchronize": true`が便利
- Cloud Runの本番環境で環境変数を利用したいときはSecret Managerを使う
- socketPathを指定するときはTYPEORM_DRIVER_EXTRA環境変数に
json形式で記述する

## 参考文献
- [Cloud Run+Cloud SQLでPrisma in NestJSを動かすお話](https://qiita.com/faronan/items/d99132950b61753050d3)
- [NestJS を Cloud Run へデプロイする方法いろいろ / 継続的デプロイの構築](https://zenn.dev/waddy/articles/nestjs-cloudrun-deploy)