🛳

NestJS を Cloud Run へデプロイする方法いろいろ / 継続的デプロイの構築

2021/11/02に公開約13,300字1件のコメント

はじめに

NestJS はサーバーサイド TypeScript のフレームワークとして注目しています。手元で試すべく触っているうちに、せっかくならデプロイしたいという気持ちになりました。デプロイ先の選択肢はいろいろとありますが、最近は Google Cloud を触っていることもあり Cloud Run へデプロイしてみることに。この記事にデプロイした記録を残します。

結論的な意見

NestJS(というかNode.jsアプリ)を Cloud Run へデプロイするためには以下の選択肢があります。

  1. ソースコードのみ用意して gcloud run deploy で全部おまかせ
  2. ソースコードとDockerfileを用意してあとは gcloud run deploy でおまかせ
  3. ソースコードとDockerfile、Cloud Build トリガーを定義して Artifact Registry から任意のタイミングでリビジョンを作成する

個人開発レベルなら1か2で十分です。1は魔法みたいになんでもやってくれる一方、イメージの中身がブラックボックスすぎてエラーが起きた時の再現が大変でした。ですので2のDockerfileまで用意するやり方がおすすめです。3は、サービスとして稼働するアプリケーションなどの、コンテナを作成するタイミング、Cloud Run のリビジョンを作成するタイミング、そしてトラフィックを新しいリビジョンへ切り替えるタイミングを細かくコントロールしたい場合に有用です。

そもそも Cloud Run のデプロイはなにをやっているのか

Cloud Run のデプロイに関するページを見てみると、

  • コンテナイメージのデプロイ
  • git からの継続的なデプロイ
  • ソースコードからのデプロイ

という分類になっていますが、やや省略気味な分け方だと感じます。省略せずに書くと、手段はどうあれ、以下のような流れを踏むことになります。

デプロイ中におこることについてさらに詳しく知りたい方はこちらのブログが参考になります。

https://qiita.com/sakas1231/items/423cd7efcce77f5e426a

つまり、Cloud Run の視点でみると、コンテナイメージさえ用意してくれればデプロイできるよということです。とはいえコンテナイメージを用意するのって大変じゃない…?というユーザーのために、gcloud run deploy コマンドがいろいろなデプロイ方法を用意してくれています。

デプロイの方法を改めて分類

ここまでを踏まえて改めてデプロイ方法を整理してみましょう。

  1. ソースコードのみ用意して gcloud run deploy で全部おまかせ
  2. ソースコードとDockerfileを用意してあとは gcloud run deploy でおまかせ
  3. ソースコードとDockerfile、Cloud Build トリガーを定義して Artifact Registry から任意のタイミングでリビジョンを作成する

繰り返しますが、デプロイのプロセスとしては コンテナイメージ作成=>それを使ってCloud Run のリビジョン作成=>新しいリビジョンにトラフィックを流す という点で変わりません。あとは、これをだれが、どのタイミングでやるか、という違いだけです。

デプロイのための共通準備

まずは NestJS アプリケーションをデプロイできるように調整しましょう。これはどの手段を取るにしても、Cloud Run へデプロイするにあたり共通です。yarn nest new などを使い初期設定を終えたら、main.tsを次のように書き換えます。

main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import {
  FastifyAdapter,
  NestFastifyApplication,
} from '@nestjs/platform-fastify';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter({ logger: true }),
  );
+  const port = Number(process.env.PORT) || 3000; // Cloud Run の要件。環境変数PORTで起動するように。
+  await app.listen(port, '0.0.0.0'); // '0.0.0.0' を追加して外部からのアクセスを受け入れる。
}

bootstrap();

あとは、デプロイされたあと疎通を確認するために、なにかエンドポイントを一つ用意してください。ルートでなくても構いません。

yarn start
curl "http://localhost:3000/present"
[{"name":"happy_birthday_pack","prize":1},{"name":"good","prize":2}]

アプリケーションの修正はこれでOKです。

これ以降、gcloudコマンドを打っていきます。gcloudコマンドはデプロイ先の Google Cloud プロジェクトへauthが済んでいる前提です。authは以下のようにして実行してください。

gcloud config configurations activate my-project
gcloud auth login

ソースコードのみ用意して全部おまかせ(起動できませんでした)

Container terminated due to sandbox error. というリビジョン作成時のエラーが解消できず、起動できていません。何かご存知のかたいましたら終えてください。いまのところ、NestJSの全部おまかせコースデプロイはあきらめています。 🙇‍♂️

ソースコードからデプロイするためには、あらかじめビルドを実行しておき、distを作っておく必要があります。調べてみると、責務の分解点は次のようになっていそうです。

  1. ソースコードをビルドする(開発者)
  2. .gcloudignoreを用意する(開発者)
  3. gcloud run deploy を実行する(開発者)
  4. ソースコードをGCSへアップロードする(gcloud)
  5. Cloud Build トリガーが起動
    1. ランタイム検出
    2. yarn install --production
    3. イメージビルド
    4. ビルドしたイメージを Artifact Registry へプッシュ
  6. Artifact Registry のイメージを使って Cloud Run のリビジョンを作成(gcloud)
  7. 新しいリビジョンへ100%トラフィックを流す(gcloud)

3番までは私たちで行います。えぇ、Dockerfile もないのにイメージのビルドってどうやってるの...?と気になりますが、どうやら BuildpacksというGoogleのツールを使って自動でランタイムを検出・イメージビルドを行っているそうです。またすごい技術ですね。

1. ソースコードをビルドする

おそらくビルドに必要な素材を集める部分はあらかじめ開発者の環境でやってね、ということなのだと思います。というわけで手元で実行します。

yarn build

distディレクトリが作成されればOKです。

2. .gcloudignoreを用意する

最初、これをやっていなくてハマりました…。.gcloudignoreが存在しない場合、gcloud run deployコマンドはなんとかスリムに保とうと、.gitignoreを参照してしまいます。.gitignoreはたいていdistは除外対象となっていて、成果物が Cloud Storage バケットにアップロードされません。今回は distをアップロード対象へ含めたいので、.gcloudignoreを用意します。

.gcloudignore
# compiled output
/node_modules

# Logs
logs
*.log

# OS
.DS_Store
...

ここにdistが含まれていなければ大丈夫です。逆に、node_modulesは除外するようにしてください。GCSへのアップロードへ時間がかかりますし、イメージビルドの過程でyarn installを実行してくれるのでアップロードは不要です。

3. gcloud run deploy を実行する

gcloud run deploy \ 
  backend-user-from-source # 1
  --region asia-northeast2 
  --command="yarn start:prod" # 2
  --source . # 3
  1. Cloud Run のサービス名です。App Engine のサービスと意味合いはほぼ同じです。誤解を恐れずにいうとマイクロサービスの名前でしょうか。
  2. 裏側で docker build を実行してくれるのでそのときの CMD を指定します。何も指定しないと yarn start になりますが、NestJSは本番動作用のコマンドとしてyarn start:prodを用意してくれているのでこちらを実行するよう指定します。
  3. カレントディレクトリをアップロード対象にして実行します

実行してあとは待つだけです!


Building using Buildpacks and deploying container to Cloud Run service [backend-user] in project [my-project] region [asia-northeast2]
X Building and deploying... Cloud Run error: Internal error occurred while performing container health check.
  ✓ Uploading sources...
  ✓ Building Container... Logs are available at [https://console.cloud.google.com/cloud-build/builds/xxx].
  X Creating Revision... Cloud Run error: Internal error occurred while performing container health check.
  . Routing traffic...
Deployment failed
ERROR: (gcloud.run.deploy) Cloud Run error: Internal error occurred while performing container health check.

失敗です。 どうやらリビジョンの作成に失敗している模様…ログをみますが…

Container terminated due to sandbox error. と出ておりこれ以上追えませんでした。なお Artifact Registry にアップされたイメージを手元にPullしてyarn start:prodしたところ、正常に起動できました。コンテナイメージに悪い部分がみあたらず、こちらにできることはあまりなさそうです。何かご存知の方いますか?🤔

Dockerfileを用意してあとはでおまかせ

個人開発レベルではこのやり方が一番コスパよさそうでした。責務の分解点は次のようなイメージです。

  1. .gcloudignoreを用意する(開発者)
  2. Dockerfileを用意する(開発者)
  3. gcloud run deploy を実行する(開発者)
  4. ソースコードをGCSへアップロードする(gcloud)
  5. Dockerfileを使ってイメージをビルドする(CloudBuild)
  6. ビルドしたイメージを Artifact Registry へプッシュ(CloudBuild)
  7. Artifact Registry のイメージを使って Cloud Run のリビジョンを作成(gcloud)
  8. 新しいリビジョンへ100%トラフィックを流す(gcloud)

このやり方はDockerfileを用意しているので、自動でトリガーされたCloudBuildが Buildpacks は使わずにDockerfileを使ってビルドします。

1. .gcloudignoreを用意する

さきほどと同じファイルが利用できます。省略します。

2. Dockerfileを用意する

次のようなファイルを用意しました。マルチステージビルドで、ビルド用と実行用を分けています。

# syntax=docker/dockerfile:1
FROM node:16 AS builder
# ビルドには devDependencies もインストールする必要があるため
ENV NODE_ENV=development
WORKDIR /app
COPY package.json ./
COPY yarn.lock ./
RUN yarn install
COPY . .
RUN yarn build


FROM node:16-stretch-slim AS runner
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
WORKDIR /app
COPY package.json ./
COPY yarn.lock ./
# NODE_ENV=productionにしてyarn install(npm install)するとdevDependenciesがインストールされません
RUN yarn install
COPY --from=builder /app/dist ./dist
CMD ["yarn", "start:prod"]

参考:

https://matsuand.github.io/docs.docker.jp.onthefly/develop/develop-images/multistage-build/
https://times.hrbrain.co.jp/entry/2020/12/17/nestjs-docker-optimize
https://dev.to/erezhod/setting-up-a-nestjs-project-with-docker-for-back-end-development-30lg

3. gcloud run deploy を実行する

CMD で起動コマンドを指定しているので、gcloudのオプションで渡す必要がなくなります。

gcloud run deploy \ 
  backend-user-from-source
  --region asia-northeast2 
  --source .

✓ Building and deploying... Done.
  ✓ Uploading sources...
  ✓ Building Container...
  ✓ Creating Revision...
  ✓ Routing traffic...

デプロイできました。待ち時間は5分ほどでした。疎通も確認してみます。

yarn start

curl "https://backend-user-xxxxxxx-dt.a.run.app/present"
[{"name":"happy_birthday_pack","prize":1},{"name":"good","prize":2}]

大丈夫そうです。Dockerfileを作るのが少し大変ですが、手間と便利さのバランスが一番いいと感じました。

Cloud Build を活用した継続的デプロイ

アプリケーションのデプロイサイクルを考えてみます。最新のコードを常にデプロイしておけばいい、のであれば先に紹介した方法でもよさそうです。ただ、例えば稼働中のアプリケーションを更新する場合、リリースにかかる時間が小さいほど嬉しいですね。つまり、リリース前までで Cloud Run のリビジョンを作成しておき、リリース時はトラフィックの切り替えのみ行いたい、という要件を実現したいとします。

  1. Dockerfileを用意する(開発者)
  2. Artifact Registry のリポジトリを作成しておく(開発者)
  3. cloudbuild.yaml を用意する(開発者)
  4. mainブランチへのpushで起動する CloudBuild GitHubトリガーを作成する(開発者)
  5. CloudBuild起動(自動)
    1. Dockerfileを使ってイメージをビルドする
    2. ビルドしたイメージを Artifact Registry へプッシュ
    3. Artifact Registry のイメージを使って Cloud Run のリビジョンを作成
  6. 新しいリビジョンへ100%トラフィックを流す(開発者)

一度トリガーを作成したあとは、4〜5で継続的デプロイを回せるようになります。

具体的にみていきましょう。

0. Dockerfileを用意する

先ほどと同じものが利用できます。再掲します。

# syntax=docker/dockerfile:1
FROM node:16 AS builder
# ビルドには devDependencies もインストールする必要があるため
ENV NODE_ENV=development
WORKDIR /app
COPY package.json ./
COPY yarn.lock ./
RUN yarn install
COPY . .
RUN yarn build


FROM node:16-stretch-slim AS runner
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
WORKDIR /app
COPY package.json ./
COPY yarn.lock ./
# NODE_ENV=productionにしてyarn install(npm install)するとdevDependenciesがインストールされません
RUN yarn install
COPY --from=builder /app/dist ./dist
CMD ["yarn", "start:prod"]

1. Artifact Registry のリポジトリを作成しておく

おまかせコースでは Artifact Registry のリポジトリを自動で作成してくれた一方で、ここでは開発者が作成しなければなりません。作成自体はgcloud でも コンソールでもよいですが、組織やチームで定められたインフラ管理方法を適用することをおすすめします。今回は Terrafrom を使って作成しました。

https://cloud.google.com/artifact-registry/docs/integrate-terraform?hl=ja

ほぼこちらに記載どおりの内容で Artifact Registry のリポジトリが作成できます。次のようなmoduleをつくりました。

modules/artifact-registry/main.ts
variable "artifact_registry_location" {
  type = string
  # https://cloud.google.com/storage/docs/locations
  description = "Artifact Registry のロケーションをどこにするか"
}


resource "google_artifact_registry_repository" "gql-nest-prisma-training" {
  provider = google-beta

  location      = var.artifact_registry_location
  repository_id = "gql-nest-prisma-training"
  description = "ユーザー向けバックエンドAPI"
  format = "DOCKER"
}

このファイルを用意して

terrafrom apply

としました。これでリポジトリが作成されます。

repository_id = "gql-nest-prisma-training" について。これがリポジトリ名になります。Artifact Registryのリポジトリ名は自由ですが、GitHubリポジトリと一致させておくと無難そうです。

2. cloudbuild.yamlを用意する

https://cloud.google.com/build/docs/deploying-builds/deploy-cloud-run?hl=ja#required_iam_permissions

このあたりを参考にしました。まず、CloudBuildのサービスアカウントに権限を付与してください。Cloud Run管理者です。私は画面からやっちゃいました。

次に cloudbuild.yaml を作りました。

こちらがベースですが Artifact Registry へプッシュしている点が異なります(サンプルはContainer Registry)。

deploy/cloudbuild.yaml
steps:
  # 4-1. Build the container image
  - name: 'gcr.io/cloud-builders/docker'
    args: ['build', '-t', '${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_SERVICE_NAME}:$SHORT_SHA', '.' ]
  # 4-2. Push the container image to Container Registry
  - name: 'gcr.io/cloud-builders/docker'
    args: ['push', '${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_SERVICE_NAME}:$SHORT_SHA']
  # 4-3. Create Cloud Run Revision using image
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
    entrypoint: gcloud
    args:
      - 'run'
      - 'deploy'
      - ${_SERVICE_NAME}
      - '--no-traffic'
      - '--image'
      - '${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_SERVICE_NAME}:$SHORT_SHA'
      - '--region'
      - '$_REGION'
      - '--revision-suffix'
      - $SHORT_SHA
  # 4-4. Display traffic update command
  - name: 'bash'
    entrypoint: 'echo'
    args:
      - 'gcloud run services update-traffic ${_SERVICE_NAME} --region=${_REGION} --to-revisions=${_SERVICE_NAME}-$SHORT_SHA=100'

substitutions:
  _REGION: asia-northeast2
  _REPOSITORY: $REPO_NAME
  _SERVICE_NAME: backend-user

  • 4-1: 後でイメージをプッシュするので、タグ付きでイメージをビルドしています
  • 4-2: Artifact Registry へプッシュしています
  • 4-3: Cloud Run のリビジョンを トラフィック0% で作成します
  • 4-4: このトリガーはコミットハッシュなどのコンテキストを持っているので、トラフィック移行コマンドを表示してあげます(コピペ用)

ここまでリリース直前に実行できます。実際にトリガーを起動してみましょう。

3,4,5 トリガー起動〜CloudRunトラフィック移行

Cloud Build トリガーを起動すると、ソースコードからcloudbuild.ymlが探し当てられ、ビルドが進行します。

Artifact Registry へイメージがプッシュされていることがわかります👇

このイメージを使って、Cloud Run へ新しいリビジョンが作成されました👇

Cloud Build トリガーの仕事はここまでです。最後に、リリース時、開発者は、CloudShellなどで移行コマンドをコピペして実行できます。

新しいリビジョンにトラフィックが移行できました。トラフィック切り替えは5秒程度で完了します。問題があったら、前のリビジョンへトラフィックを戻せばOKです。

おわりに

NestJS を Cloud Run へデプロイするパターンを試しました。最初に継続的デプロイの仕組みを整えておけば、少しずつデプロイフローも改善できるためあとあとラクになるはずです。冒頭でも述べたとおり、

  • 個人開発レベル:Dockerfileだけ作成してあとはおまかせ
  • 本番プロダクト:CloudBuildトリガーを作成してリリース時はトラフィック移行のみにする

という考え方がお得だと感じました。

NestJSはPrismaと連携して使おうと思っているので、次は Cloud Run と Cloud SQL を組み合わせて使うようデプロイを試してみます。

Discussion

全おまかせコースで失敗する件について。

「Cloud Run 実行環境第二世代だとどうか?」というアドバイスをいただいたので実行しました。

gcloud beta run deploy backend-user-from-source --execution-environment gen2  --region asia-northeast2 --command="node dist/main" --source .

エラー内容変わりました!

が、やはり起動できず…あと一歩感はあります。

ログインするとコメントできます